mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
1 Commits
v1.0.69
...
feat/ppe-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d3c709914 |
32
CHANGELOG.md
32
CHANGELOG.md
@@ -2,37 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.69] - 2026-07-13
|
||||
|
||||
### Features
|
||||
|
||||
- support docs fetch selection anchors (#1815)
|
||||
- **apps**: support modern_html app type with TOS publish path and app type querying
|
||||
- **im**: show bot sender display names when reading messages (#1829)
|
||||
- add drive list comments shortcut (#1845)
|
||||
- support wiki sources in drive export (#1802)
|
||||
- add application domain with slash command management shortcuts (#1806)
|
||||
- validate IM idempotency key length (#1797)
|
||||
- surface reply context and mentions in im.message.receive_v1 (#1798)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- route brand-sensitive endpoints through the resolver (#1836)
|
||||
|
||||
### Documentation
|
||||
|
||||
- document OKR block XML guidance (#1648)
|
||||
- refine doubao whiteboard workflow routing (#1841)
|
||||
- clarify Mindnote token handling (#1827)
|
||||
|
||||
### Tests
|
||||
|
||||
- isolate semantic waiver fixtures from wall clock
|
||||
|
||||
### Misc
|
||||
|
||||
- Merge lark sheets development branch (#1833)
|
||||
|
||||
## [v1.0.68] - 2026-07-09
|
||||
|
||||
### Features
|
||||
@@ -1469,7 +1438,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.69]: https://github.com/larksuite/cli/releases/tag/v1.0.69
|
||||
[v1.0.68]: https://github.com/larksuite/cli/releases/tag/v1.0.68
|
||||
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67
|
||||
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
|
||||
|
||||
@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
|
||||
// (not backed by from_meta service specs). Descriptions are now centralized in
|
||||
// service_descriptions.json.
|
||||
func getShortcutOnlyDomainNames() []string {
|
||||
return []string{"application", "base", "contact", "docs", "markdown", "apps", "note"}
|
||||
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
|
||||
}
|
||||
|
||||
@@ -96,40 +96,6 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.receive_v1", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
resolved := payload["resolved_output_schema"].(map[string]interface{})
|
||||
props := resolved["properties"].(map[string]interface{})
|
||||
for _, field := range []string{
|
||||
"root_id",
|
||||
"thread_id",
|
||||
"reply_to",
|
||||
"sender_type",
|
||||
"mentions",
|
||||
} {
|
||||
if _, ok := props[field]; !ok {
|
||||
t.Errorf("receive schema missing field %q", field)
|
||||
}
|
||||
}
|
||||
msgDesc := props["message_id"].(map[string]interface{})["description"].(string)
|
||||
if !strings.Contains(msgDesc, "Recommended idempotency key") {
|
||||
t.Errorf("message_id description should guide deduplication, got %q", msgDesc)
|
||||
}
|
||||
eventDesc := props["event_id"].(map[string]interface{})["description"].(string)
|
||||
if strings.Contains(eventDesc, "safe for deduplication") {
|
||||
t.Errorf("event_id description should not recommend deduplication, got %q", eventDesc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
|
||||
@@ -13,29 +13,17 @@ import (
|
||||
|
||||
// ImMessageReceiveOutput is the flattened shape for im.message.receive_v1; `desc` tags drive the reflected schema.
|
||||
type ImMessageReceiveOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always im.message.receive_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Event delivery ID. Do not use as the message deduplication key; use message_id instead."`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
|
||||
ID string `json:"id,omitempty" desc:"Message ID (legacy alias of message_id, kept for compatibility)" kind:"message_id"`
|
||||
MessageID string `json:"message_id,omitempty" desc:"Message ID; prefixed with om_. Recommended idempotency key for im.message.receive_v1 consumers." kind:"message_id"`
|
||||
CreateTime string `json:"create_time,omitempty" desc:"Message creation time (ms timestamp string)" kind:"timestamp_ms"`
|
||||
UpdateTime string `json:"update_time,omitempty" desc:"Message update time (ms timestamp string); emitted only when different from create_time" kind:"timestamp_ms"`
|
||||
ChatID string `json:"chat_id,omitempty" desc:"Chat/conversation ID; prefixed with oc_" kind:"chat_id"`
|
||||
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
|
||||
MessageType string `json:"message_type,omitempty" desc:"Message type"`
|
||||
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
|
||||
SenderType string `json:"sender_type,omitempty" desc:"Sender type" enum:"user,bot"`
|
||||
RootID string `json:"root_id,omitempty" desc:"Root message ID of the reply/thread context, when present" kind:"message_id"`
|
||||
ThreadID string `json:"thread_id,omitempty" desc:"Thread ID, when present"`
|
||||
ReplyTo string `json:"reply_to,omitempty" desc:"Parent message ID of the direct reply context, when present" kind:"message_id"`
|
||||
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
|
||||
Mentions []MentionOutput `json:"mentions,omitempty" desc:"Compact mentions aligned with im +messages-mget"`
|
||||
}
|
||||
|
||||
type MentionOutput struct {
|
||||
Key string `json:"key,omitempty" desc:"Mention placeholder key, for example @_user_1"`
|
||||
ID string `json:"id,omitempty" desc:"Mentioned user open_id; prefixed with ou_" kind:"open_id"`
|
||||
Name string `json:"name,omitempty" desc:"Mentioned display name"`
|
||||
Type string `json:"type" desc:"Event type; always im.message.receive_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
|
||||
ID string `json:"id,omitempty" desc:"Message ID (legacy alias of message_id, kept for compatibility)" kind:"message_id"`
|
||||
MessageID string `json:"message_id,omitempty" desc:"Message ID; prefixed with om_" kind:"message_id"`
|
||||
CreateTime string `json:"create_time,omitempty" desc:"Message creation time (ms timestamp string)" kind:"timestamp_ms"`
|
||||
ChatID string `json:"chat_id,omitempty" desc:"Chat/conversation ID; prefixed with oc_" kind:"chat_id"`
|
||||
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
|
||||
MessageType string `json:"message_type,omitempty" desc:"Message type"`
|
||||
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
|
||||
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
|
||||
}
|
||||
|
||||
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
@@ -48,20 +36,15 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
|
||||
Event struct {
|
||||
Message struct {
|
||||
MessageID string `json:"message_id"`
|
||||
RootID string `json:"root_id"`
|
||||
ParentID string `json:"parent_id"`
|
||||
ThreadID string `json:"thread_id"`
|
||||
ChatID string `json:"chat_id"`
|
||||
ChatType string `json:"chat_type"`
|
||||
MessageType string `json:"message_type"`
|
||||
Content string `json:"content"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Mentions []interface{} `json:"mentions"`
|
||||
} `json:"message"`
|
||||
Sender struct {
|
||||
SenderType string `json:"sender_type"`
|
||||
SenderID struct {
|
||||
SenderID struct {
|
||||
OpenID string `json:"open_id"`
|
||||
} `json:"sender_id"`
|
||||
} `json:"sender"`
|
||||
@@ -98,54 +81,7 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
|
||||
ChatType: msg.ChatType,
|
||||
MessageType: msg.MessageType,
|
||||
SenderID: envelope.Event.Sender.SenderID.OpenID,
|
||||
SenderType: envelope.Event.Sender.SenderType,
|
||||
RootID: msg.RootID,
|
||||
ThreadID: msg.ThreadID,
|
||||
ReplyTo: msg.ParentID,
|
||||
Content: content,
|
||||
Mentions: compactMentions(msg.Mentions),
|
||||
}
|
||||
if msg.UpdateTime != "" && msg.UpdateTime != msg.CreateTime {
|
||||
out.UpdateTime = msg.UpdateTime
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func compactMentions(mentions []interface{}) []MentionOutput {
|
||||
if len(mentions) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]MentionOutput, 0, len(mentions))
|
||||
for _, raw := range mentions {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
mention := MentionOutput{
|
||||
Key: stringField(item, "key"),
|
||||
ID: mentionOpenID(item["id"]),
|
||||
Name: stringField(item, "name"),
|
||||
}
|
||||
if mention.Key != "" || mention.ID != "" || mention.Name != "" {
|
||||
out = append(out, mention)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stringField(m map[string]interface{}, key string) string {
|
||||
v, _ := m[key].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func mentionOpenID(raw interface{}) string {
|
||||
switch v := raw.(type) {
|
||||
case map[string]interface{}:
|
||||
openID, _ := v["open_id"].(string)
|
||||
return openID
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,32 +84,19 @@ func TestProcessImMessageReceive_Text(t *testing.T) {
|
||||
},
|
||||
"event": {
|
||||
"sender": {
|
||||
"sender_type": "user",
|
||||
"sender_id": {"open_id": "ou_sender"}
|
||||
},
|
||||
"message": {
|
||||
"message_id": "om_text_001",
|
||||
"root_id": "om_root_001",
|
||||
"parent_id": "om_parent_001",
|
||||
"thread_id": "omt_thread_001",
|
||||
"chat_id": "oc_chat",
|
||||
"chat_type": "p2p",
|
||||
"message_type": "text",
|
||||
"create_time": "1776409468987",
|
||||
"update_time": "1776409469999",
|
||||
"content": "{\"text\":\"hello @_user_1\"}",
|
||||
"mentions": [
|
||||
{
|
||||
"key": "@_user_1",
|
||||
"id": {"open_id": "ou_mentioned"},
|
||||
"name": "Alice"
|
||||
}
|
||||
]
|
||||
"content": "{\"text\":\"hello there\"}"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runReceive(t, payload)
|
||||
outMap := runReceiveMap(t, payload)
|
||||
|
||||
if out.Type != "im.message.receive_v1" {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
@@ -123,69 +110,12 @@ func TestProcessImMessageReceive_Text(t *testing.T) {
|
||||
if out.SenderID != "ou_sender" {
|
||||
t.Errorf("SenderID = %q", out.SenderID)
|
||||
}
|
||||
if out.Content != "hello @Alice" {
|
||||
t.Errorf("Content = %q, want \"hello @Alice\"", out.Content)
|
||||
if out.Content != "hello there" {
|
||||
t.Errorf("Content = %q, want \"hello there\"", out.Content)
|
||||
}
|
||||
if out.Timestamp != "1776409469273" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
for field, want := range map[string]string{
|
||||
"sender_type": "user",
|
||||
"root_id": "om_root_001",
|
||||
"thread_id": "omt_thread_001",
|
||||
"reply_to": "om_parent_001",
|
||||
"update_time": "1776409469999",
|
||||
} {
|
||||
if got, _ := outMap[field].(string); got != want {
|
||||
t.Errorf("%s = %q, want %q", field, got, want)
|
||||
}
|
||||
}
|
||||
mentions, _ := outMap["mentions"].([]interface{})
|
||||
if len(mentions) != 1 {
|
||||
t.Fatalf("mentions length = %d, want 1: %#v", len(mentions), outMap["mentions"])
|
||||
}
|
||||
mention, _ := mentions[0].(map[string]interface{})
|
||||
for field, want := range map[string]string{
|
||||
"key": "@_user_1",
|
||||
"id": "ou_mentioned",
|
||||
"name": "Alice",
|
||||
} {
|
||||
if got, _ := mention[field].(string); got != want {
|
||||
t.Errorf("mentions[0].%s = %q, want %q", field, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessImMessageReceive_OmitsUnchangedUpdateTime(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_test_text",
|
||||
"event_type": "im.message.receive_v1",
|
||||
"create_time": "1776409469273",
|
||||
"app_id": "cli_test"
|
||||
},
|
||||
"event": {
|
||||
"sender": {
|
||||
"sender_type": "user",
|
||||
"sender_id": {"open_id": "ou_sender"}
|
||||
},
|
||||
"message": {
|
||||
"message_id": "om_text_001",
|
||||
"chat_id": "oc_chat",
|
||||
"chat_type": "p2p",
|
||||
"message_type": "text",
|
||||
"create_time": "1776409468987",
|
||||
"update_time": "1776409468987",
|
||||
"content": "{\"text\":\"hello there\"}"
|
||||
}
|
||||
}
|
||||
}`
|
||||
outMap := runReceiveMap(t, payload)
|
||||
|
||||
if _, ok := outMap["update_time"]; ok {
|
||||
t.Errorf("update_time should be omitted when it equals create_time: %#v", outMap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessImMessageReceive_Interactive(t *testing.T) {
|
||||
@@ -258,22 +188,3 @@ func runReceive(t *testing.T, payload string) ImMessageReceiveOutput {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runReceiveMap(t *testing.T, payload string) map[string]interface{} {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_test",
|
||||
EventType: "im.message.receive_v1",
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -248,18 +248,10 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
|
||||
|
||||
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
|
||||
allowSet := LoadOverrideAutoApproveAllow()
|
||||
// recommend.allow special-cases scopes absent from scope_priorities.json
|
||||
// (application v7 is not in the platform catalog yet) so interactive
|
||||
// login's "common scopes" tier still offers them. Only the read scope is
|
||||
// admitted: write stays out of the recommended tier by design.
|
||||
if !allowSet["application:app_slash_command:read"] {
|
||||
t.Error("expected application:app_slash_command:read in override allow set")
|
||||
}
|
||||
if allowSet["application:app_slash_command:write"] {
|
||||
t.Error("write scope must NOT be in the recommended tier")
|
||||
}
|
||||
if len(allowSet) != 1 {
|
||||
t.Errorf("expected exactly 1 override allow entry, got %d", len(allowSet))
|
||||
// recommend.allow in scope_overrides.json is intentionally empty:
|
||||
// no scopes are special-cased into the auto-approve set anymore.
|
||||
if len(allowSet) != 0 {
|
||||
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,7 @@
|
||||
"vc:meeting.meetingevent:read": 75
|
||||
},
|
||||
"recommend": {
|
||||
"allow": [
|
||||
"application:app_slash_command:read"
|
||||
],
|
||||
"allow": [],
|
||||
"deny": [
|
||||
"im:chat",
|
||||
"im:message.send_as_user"
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
"en": { "title": "Approval", "description": "Approval instance, and task management" },
|
||||
"zh": { "title": "审批", "description": "审批实例、审批任务管理" }
|
||||
},
|
||||
"application": {
|
||||
"en": { "title": "Application", "description": "Open Platform app self-management: slash commands for the currently bound app" },
|
||||
"zh": { "title": "应用管理", "description": "开放平台应用自管理:当前绑定应用的斜杠指令管理" }
|
||||
},
|
||||
"apps": {
|
||||
"en": { "title": "Apps", "description": "Develop, deploy HTML, web pages and applications" },
|
||||
"zh": { "title": "应用", "description": "开发、部署 HTML、Web 页面和应用" }
|
||||
|
||||
@@ -65,7 +65,7 @@ func safePath(raw, flagName string) (string, error) {
|
||||
}
|
||||
|
||||
if isAbsolutePath(raw) {
|
||||
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: use a relative path like ./filename; flags that support stdin can read an out-of-tree file via '-' instead)", flagName, raw)
|
||||
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
|
||||
}
|
||||
|
||||
path := filepath.Clean(raw)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.69",
|
||||
"version": "1.0.68",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package application provides shortcuts for Open Platform app
|
||||
// self-management (slash commands of the current bound app).
|
||||
package application
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// Shortcuts returns all shortcuts of the application domain.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
SlashCommandList,
|
||||
SlashCommandCreate,
|
||||
SlashCommandUpdate,
|
||||
SlashCommandDelete,
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
// slashCommandBasePath is the raw v7 endpoint (not in meta_data.json / SDK).
|
||||
const slashCommandBasePath = "/open-apis/application/v7/app_slash_commands"
|
||||
|
||||
// clientCacheHint is printed to stderr after every successful write.
|
||||
const clientCacheHint = "note: changes take ~5 minutes to appear in Feishu clients (client-side cache); the server state is already updated - list reflects it immediately."
|
||||
|
||||
// parseDescriptionI18n parses repeated --description-i18n values ("<lang>=<text>",
|
||||
// split on the FIRST '='). Returns nil for empty input. Duplicate langs rejected.
|
||||
func parseDescriptionI18n(values []string) (map[string]string, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
m := make(map[string]string, len(values))
|
||||
for _, v := range values {
|
||||
idx := strings.Index(v, "=")
|
||||
if idx <= 0 || idx == len(v)-1 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid --description-i18n value %q: expected <lang>=<text> (e.g. zh_cn=你好)", v).
|
||||
WithParam("--description-i18n")
|
||||
}
|
||||
lang := strings.TrimSpace(v[:idx])
|
||||
text := v[idx+1:]
|
||||
if lang == "" || strings.TrimSpace(text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid --description-i18n value %q: language and text must be non-empty", v).
|
||||
WithParam("--description-i18n")
|
||||
}
|
||||
if _, dup := m[lang]; dup {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"duplicate language %q in --description-i18n", lang).
|
||||
WithParam("--description-i18n")
|
||||
}
|
||||
m[lang] = text
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// validateCommandName rejects empty and slash-prefixed command names.
|
||||
func validateCommandName(name, flagName string) error {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s must not be empty", flagName).WithParam(flagName)
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "/") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s must not start with \"/\" - the slash is implied (use %q)",
|
||||
flagName, strings.TrimPrefix(trimmed, "/")).WithParam(flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// encodeCommandIDPathSegment applies the same normalization and escaping to
|
||||
// command IDs in dry-run output and real requests.
|
||||
func encodeCommandIDPathSegment(id string) string {
|
||||
return validate.EncodePathSegment(strings.TrimSpace(id))
|
||||
}
|
||||
|
||||
// buildSlashCommandBody assembles a create/update request body. Only provided
|
||||
// fields are included: PATCH is field-level partial (absent top-level fields
|
||||
// are preserved server-side; a provided i18n map REPLACES the whole map).
|
||||
// icon sits at the top level, sibling of description (verified live; the
|
||||
// official create sample nesting icon inside description is a doc bug).
|
||||
func buildSlashCommandBody(command, description string, i18n map[string]string, iconKey string) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if command != "" {
|
||||
body["command"] = command
|
||||
}
|
||||
if description != "" || len(i18n) > 0 {
|
||||
desc := map[string]interface{}{}
|
||||
if description != "" {
|
||||
desc["default_value"] = description
|
||||
}
|
||||
if len(i18n) > 0 {
|
||||
desc["i18n"] = i18n
|
||||
}
|
||||
body["description"] = desc
|
||||
}
|
||||
if iconKey != "" {
|
||||
body["icon"] = map[string]interface{}{"icon_key": iconKey}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// isCommandExists reports whether err is the server-side name-collision error
|
||||
// (code=40000000, message contains "command already exists"; verified live).
|
||||
func isCommandExists(err error) bool {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return p.Code == 40000000 && strings.Contains(p.Message, "command already exists")
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestParseDescriptionI18n_OK(t *testing.T) {
|
||||
m, err := parseDescriptionI18n([]string{"zh_cn=你好", "en_us=Hello=World"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if m["zh_cn"] != "你好" {
|
||||
t.Errorf("zh_cn = %q", m["zh_cn"])
|
||||
}
|
||||
// 只按首个 = 分割:值内可含 =
|
||||
if m["en_us"] != "Hello=World" {
|
||||
t.Errorf("en_us = %q", m["en_us"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDescriptionI18n_Empty(t *testing.T) {
|
||||
m, err := parseDescriptionI18n(nil)
|
||||
if err != nil || m != nil {
|
||||
t.Fatalf("nil input: m=%v err=%v", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDescriptionI18n_BadFormat(t *testing.T) {
|
||||
for _, bad := range []string{"zh_cn", "=text", "zh_cn=", " =x"} {
|
||||
_, err := parseDescriptionI18n([]string{bad})
|
||||
if err == nil {
|
||||
t.Errorf("%q: expected error", bad)
|
||||
continue
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("%q: expected validation problem, got %v", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDescriptionI18n_DuplicateLang(t *testing.T) {
|
||||
_, err := parseDescriptionI18n([]string{"zh_cn=a", "zh_cn=b"})
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate language error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected validation/invalid_argument, got %v", err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--description-i18n" {
|
||||
t.Fatalf("expected param --description-i18n, got %#v", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCommandName(t *testing.T) {
|
||||
if err := validateCommandName("greet", "--command"); err != nil {
|
||||
t.Fatalf("greet: %v", err)
|
||||
}
|
||||
for _, bad := range []string{"", " ", "/greet"} {
|
||||
if err := validateCommandName(bad, "--command"); err == nil {
|
||||
t.Errorf("%q: expected error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSlashCommandBody(t *testing.T) {
|
||||
body := buildSlashCommandBody("greet", "hi", map[string]string{"zh_cn": "你好"}, "skill_outlined")
|
||||
if body["command"] != "greet" {
|
||||
t.Errorf("command = %v", body["command"])
|
||||
}
|
||||
desc := body["description"].(map[string]interface{})
|
||||
if desc["default_value"] != "hi" {
|
||||
t.Errorf("default_value = %v", desc["default_value"])
|
||||
}
|
||||
if desc["i18n"].(map[string]string)["zh_cn"] != "你好" {
|
||||
t.Errorf("i18n = %v", desc["i18n"])
|
||||
}
|
||||
// icon 与 description 顶层平级(实测钉死,文档 create 示例是笔误)
|
||||
if body["icon"].(map[string]interface{})["icon_key"] != "skill_outlined" {
|
||||
t.Errorf("icon = %v", body["icon"])
|
||||
}
|
||||
// partial:不提供的字段不出现(PATCH 语义依赖)
|
||||
partial := buildSlashCommandBody("", "", nil, "skill_outlined")
|
||||
if _, has := partial["command"]; has {
|
||||
t.Error("empty command must be omitted")
|
||||
}
|
||||
if _, has := partial["description"]; has {
|
||||
t.Error("empty description must be omitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCommandExists(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "matching code and message",
|
||||
err: errs.NewAPIError(errs.SubtypeUnknown,
|
||||
"Invalid Param 'command'. command already exists.").WithCode(40000000),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "same message with different code",
|
||||
err: errs.NewAPIError(errs.SubtypeUnknown,
|
||||
"Invalid Param 'command'. command already exists.").WithCode(40000031),
|
||||
},
|
||||
{
|
||||
name: "same code with different message",
|
||||
err: errs.NewAPIError(errs.SubtypeUnknown,
|
||||
"Invalid Param 'icon_key'. icon_key is invalid.").WithCode(40000000),
|
||||
},
|
||||
{name: "nil error"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isCommandExists(tt.err); got != tt.want {
|
||||
t.Fatalf("isCommandExists() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlashCommandShortcuts_SharedScopesAcrossIdentities locks in the
|
||||
// reversal of the OAuth-isolation design: all four slash-command shortcuts
|
||||
// declare identical scopes for the bot and user identities (plain Scopes /
|
||||
// ConditionalScopes, no per-identity overrides), so a user-identity
|
||||
// pre-flight sees the same scope set a bot identity would.
|
||||
func TestSlashCommandShortcuts_SharedScopesAcrossIdentities(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantScope string
|
||||
wantConditional string
|
||||
hasConditional bool
|
||||
}{
|
||||
{
|
||||
name: "list",
|
||||
shortcut: SlashCommandList,
|
||||
wantScope: "application:app_slash_command:read",
|
||||
},
|
||||
{
|
||||
name: "create",
|
||||
shortcut: SlashCommandCreate,
|
||||
wantScope: "application:app_slash_command:write",
|
||||
wantConditional: "application:app_slash_command:read",
|
||||
hasConditional: true,
|
||||
},
|
||||
{
|
||||
name: "update",
|
||||
shortcut: SlashCommandUpdate,
|
||||
wantScope: "application:app_slash_command:write",
|
||||
wantConditional: "application:app_slash_command:read",
|
||||
hasConditional: true,
|
||||
},
|
||||
{
|
||||
name: "delete",
|
||||
shortcut: SlashCommandDelete,
|
||||
wantScope: "application:app_slash_command:write",
|
||||
wantConditional: "application:app_slash_command:read",
|
||||
hasConditional: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, identity := range []string{"user", "bot"} {
|
||||
declared := tc.shortcut.DeclaredScopesForIdentity(identity)
|
||||
if !containsStr(declared, tc.wantScope) {
|
||||
t.Errorf("%s: DeclaredScopesForIdentity(%q) = %v, want to contain %q", tc.name, identity, declared, tc.wantScope)
|
||||
}
|
||||
if tc.hasConditional && !containsStr(declared, tc.wantConditional) {
|
||||
t.Errorf("%s: DeclaredScopesForIdentity(%q) = %v, want to contain conditional %q", tc.name, identity, declared, tc.wantConditional)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func containsStr(list []string, want string) bool {
|
||||
for _, v := range list {
|
||||
if v == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlashCommandCreate registers a new slash command on the current bound app.
|
||||
var SlashCommandCreate = common.Shortcut{
|
||||
Service: "application",
|
||||
Command: "+slash-command-create",
|
||||
Description: "Register a slash command (/ command) on the current bound Open Platform app; --force converts a name collision into an update (idempotent re-run)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"application:app_slash_command:write"},
|
||||
ConditionalScopes: []string{
|
||||
"application:app_slash_command:read", // only the --force collision path lists to resolve the id
|
||||
},
|
||||
AuthTypes: []string{"bot", "user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "command", Desc: "command name WITHOUT the leading slash (server enforces uniqueness per app; max 100 commands)", Required: true},
|
||||
{Name: "description", Desc: "default description shown in the client command panel (description.default_value)", Required: true},
|
||||
{Name: "description-i18n", Type: "string_array", Desc: "localized description, repeatable, format <lang>=<text> (e.g. zh_cn=发送问候); language codes are passed through to the server"},
|
||||
{Name: "icon-key", Desc: "icon key (server default: skill_outlined; invalid keys are rejected server-side with code 40000031)"},
|
||||
{Name: "force", Type: "bool", Desc: "on name collision, resolve the existing command by name and update it in place"},
|
||||
},
|
||||
Tips: []string{
|
||||
`lark-cli application +slash-command-create --command greet --description "say hi" --description-i18n zh_cn=问候 --as bot`,
|
||||
"changes take ~5 minutes to appear in clients (client-side cache); the server updates immediately",
|
||||
"user identity needs explicit authorization first: lark-cli auth login --scope application:app_slash_command:write",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateCommandName(runtime.Str("command"), "--command"); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(strings.TrimSpace(runtime.Str("description"))) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--description must not be blank").WithParam("--description")
|
||||
}
|
||||
if _, err := parseDescriptionI18n(runtime.StrArray("description-i18n")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
|
||||
if err != nil {
|
||||
// The CLI validates first; keep this guard for direct DryRun callers.
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
body := buildSlashCommandBody(name, runtime.Str("description"), i18n, runtime.Str("icon-key"))
|
||||
d := common.NewDryRunAPI().
|
||||
Desc("Create a slash command on the current bound app").
|
||||
POST(slashCommandBasePath).
|
||||
Body(body)
|
||||
if runtime.Bool("force") {
|
||||
d.Desc("--force: on 'command already exists' (code 40000000), GET list to resolve command_id then PATCH the same body")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := buildSlashCommandBody(name, runtime.Str("description"), i18n, runtime.Str("icon-key"))
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", slashCommandBasePath, nil, body)
|
||||
action := "created"
|
||||
if err != nil {
|
||||
if !isCommandExists(err) {
|
||||
return err
|
||||
}
|
||||
if !runtime.Bool("force") {
|
||||
p, _ := errs.ProblemOf(err)
|
||||
rewrapped := errs.NewAPIError(errs.SubtypeAlreadyExists, "slash command %q already exists", name).
|
||||
WithHint("rerun with --force to update it, or use `lark-cli application +slash-command-update --command %q`", name).
|
||||
WithCause(err)
|
||||
if p.Code != 0 {
|
||||
rewrapped = rewrapped.WithCode(p.Code)
|
||||
}
|
||||
if p.LogID != "" {
|
||||
rewrapped = rewrapped.WithLogID(p.LogID)
|
||||
}
|
||||
return rewrapped
|
||||
}
|
||||
// --force: name collision -> resolve id -> PATCH (idempotent re-run).
|
||||
id, rerr := resolveCommandID(runtime, name)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
patchBody := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
|
||||
data, err = runtime.CallAPITyped("PATCH", slashCommandBasePath+"/"+encodeCommandIDPathSegment(id), nil, patchBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
action = "updated"
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
data["action"] = action
|
||||
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s /%v (command_id: %v)\n", action, data["command"], data["command_id"])
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func createOKStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/application/v7/app_slash_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": sampleItem("greet", "id-new"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createConflictStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/application/v7/app_slash_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 40000000, "msg": "Invalid Param 'command'. command already exists.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func patchOKStub(id string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/application/v7/app_slash_commands/" + id,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": sampleItem("greet", id),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_OK(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(createOKStub())
|
||||
|
||||
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", "greet", "--description", "hi",
|
||||
"--description-i18n", "zh_cn=你好", "--description-i18n", "en_us=Hello",
|
||||
"--icon-key", "skill_outlined", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
if data["action"] != "created" {
|
||||
t.Fatalf("action = %v", data["action"])
|
||||
}
|
||||
if data["command_id"] != "id-new" {
|
||||
t.Fatalf("command_id = %v", data["command_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_ValidateRejects(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
cases := [][]string{
|
||||
{"+slash-command-create", "--command", "/greet", "--description", "hi", "--as", "bot"},
|
||||
{"+slash-command-create", "--command", "greet", "--description", "hi", "--description-i18n", "bad", "--as", "bot"},
|
||||
{"+slash-command-create", "--command", "greet", "--description", "hi", "--description-i18n", "zh_cn=a", "--description-i18n", "zh_cn=b", "--as", "bot"},
|
||||
{"+slash-command-create", "--command", "greet", "--description", " ", "--as", "bot"},
|
||||
}
|
||||
for i, args := range cases {
|
||||
err := mountAndRun(t, SlashCommandCreate, args, f, stdout)
|
||||
if err == nil {
|
||||
t.Errorf("case %d: expected validation error", i)
|
||||
continue
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation {
|
||||
t.Errorf("case %d: expected validation problem, got %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_ConflictNoForce(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(createConflictStub())
|
||||
|
||||
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", "greet", "--description", "hi", "--as", "bot"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict error")
|
||||
}
|
||||
p, _ := errs.ProblemOf(err)
|
||||
if p == nil || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeAlreadyExists || p.Code != 40000000 {
|
||||
t.Fatalf("expected api/already_exists code 40000000, got %#v", p)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "--force") || !strings.Contains(p.Hint, "+slash-command-update") {
|
||||
t.Fatalf("hint must offer --force and update, got %q", p.Hint)
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("rewrapped error must be *errs.APIError, got %T", err)
|
||||
}
|
||||
if errors.Unwrap(apiErr) == nil {
|
||||
t.Fatal("rewrapped conflict error must preserve the original cause via WithCause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_ForceConvertsToUpdate(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(createConflictStub())
|
||||
reg.Register(listStub([]interface{}{sampleItem("greet", "id-exist")}))
|
||||
reg.Register(patchOKStub("id-exist"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", "greet", "--description", "hi2", "--force", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
if data["action"] != "updated" {
|
||||
t.Fatalf("action = %v (force must convert to update)", data["action"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_TrimsCommandBeforeCreateAndForceResolution(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
conflict := createConflictStub()
|
||||
reg.Register(conflict)
|
||||
reg.Register(listStub([]interface{}{sampleItem("greet", "id-exist")}))
|
||||
reg.Register(patchOKStub("id-exist"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", " greet ", "--description", "hi", "--force", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(conflict.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode captured create body: %v", err)
|
||||
}
|
||||
if body["command"] != "greet" {
|
||||
t.Fatalf("command = %q, want trimmed value %q", body["command"], "greet")
|
||||
}
|
||||
}
|
||||
|
||||
func createIconInvalidStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/application/v7/app_slash_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 40000031, "msg": "Invalid Param 'icon_key'. icon_key is invalid.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlashCommandCreate_ForceDoesNotConvertNonConflict guards against --force
|
||||
// blindly treating ANY POST failure as a name collision: only the
|
||||
// "command already exists" (40000000) shape may fall through to the
|
||||
// GET+PATCH idempotent-update path. No PATCH stub is registered here, so if
|
||||
// the code mistakenly attempted a PATCH, the httpmock registry would fail
|
||||
// the unexpected request and surface a different (registry) error instead
|
||||
// of the original icon_key failure asserted below.
|
||||
func TestSlashCommandCreate_ForceDoesNotConvertNonConflict(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(createIconInvalidStub())
|
||||
|
||||
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", "greet", "--description", "hi", "--icon-key", "bogus", "--force", "--as", "bot"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected the original icon_key error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryAPI || p.Subtype == errs.SubtypeAlreadyExists || p.Code != 40000031 {
|
||||
t.Fatalf("expected original API error code 40000031 without collision reclassification, got %#v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
if err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", "greet", "--description", "hi", "--icon-key", "skill_outlined", "--dry-run", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "POST") || !strings.Contains(out, slashCommandBasePath) {
|
||||
t.Fatalf("dry-run must show POST path: %s", out)
|
||||
}
|
||||
// icon 顶层:dry-run body 里 icon 不嵌套在 description 内
|
||||
if !strings.Contains(out, "icon_key") {
|
||||
t.Fatalf("dry-run must include body: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_ForceHelpHasNoMetavar(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "application"}
|
||||
SlashCommandCreate.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
forceFlag := cmd.Flags().Lookup("force")
|
||||
if forceFlag == nil {
|
||||
t.Fatal("missing --force flag")
|
||||
}
|
||||
placeholder, usage := pflag.UnquoteUsage(forceFlag)
|
||||
if placeholder != "" {
|
||||
t.Fatalf("boolean --force must not render a value placeholder, got %q", placeholder)
|
||||
}
|
||||
if !strings.Contains(usage, "update it in place") || strings.Contains(usage, "gh ") {
|
||||
t.Fatalf("unexpected --force help: %q", usage)
|
||||
}
|
||||
if help := cmd.Flags().FlagUsages(); !strings.Contains(help, "--force") || !strings.Contains(help, "update it in place") {
|
||||
t.Fatalf("rendered help missing --force description:\n%s", help)
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlashCommandDelete removes a slash command (irreversible; command_id is not
|
||||
// reused - recreating the same name yields a NEW id).
|
||||
var SlashCommandDelete = common.Shortcut{
|
||||
Service: "application",
|
||||
Command: "+slash-command-delete",
|
||||
Description: "Delete a slash command from the current bound app (high-risk: irreversible; recreating the same name yields a new command_id)",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"application:app_slash_command:write"},
|
||||
ConditionalScopes: []string{
|
||||
"application:app_slash_command:read", // only the --command by-name path
|
||||
},
|
||||
AuthTypes: []string{"bot", "user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "command-id", Desc: "target command_id; mutually exclusive with --command"},
|
||||
{Name: "command", Desc: "target command name WITHOUT leading slash (resolved via live list, needs read scope); mutually exclusive with --command-id"},
|
||||
},
|
||||
Tips: []string{
|
||||
"lark-cli application +slash-command-delete --command greet --yes --as bot",
|
||||
"deleted commands may linger in clients for ~5 minutes (client cache)",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
id := strings.TrimSpace(runtime.Str("command-id"))
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
if (id == "") == (name == "") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"provide exactly one of --command-id or --command").WithParam("--command-id")
|
||||
}
|
||||
if name != "" {
|
||||
return validateCommandName(name, "--command")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
d := common.NewDryRunAPI().Desc("HIGH-RISK: delete a slash command (irreversible; same-name recreate gets a NEW command_id)")
|
||||
target := strings.TrimSpace(runtime.Str("command-id"))
|
||||
if target == "" {
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
d.GET(slashCommandBasePath).
|
||||
Desc(fmt.Sprintf("resolve command_id by name %q via GET list first", name))
|
||||
target = "<resolved_command_id>"
|
||||
} else {
|
||||
target = encodeCommandIDPathSegment(target)
|
||||
}
|
||||
return d.DELETE(slashCommandBasePath + "/" + target)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
id := strings.TrimSpace(runtime.Str("command-id"))
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
if id == "" {
|
||||
resolved, err := resolveCommandID(runtime, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id = resolved
|
||||
}
|
||||
if _, err := runtime.CallAPITyped("DELETE", slashCommandBasePath+"/"+encodeCommandIDPathSegment(id), nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
out := map[string]interface{}{"action": "deleted", "command_id": id}
|
||||
if name != "" {
|
||||
out["command"] = name
|
||||
}
|
||||
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "note: recreating the same command name will yield a NEW command_id.")
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "deleted command_id %s\n", id)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func deleteOKStub(id string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: slashCommandBasePath + "/" + id,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_RequiresYes(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command-id", "id1", "--as", "bot"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation_required without --yes")
|
||||
}
|
||||
if errs.CategoryOf(err) != errs.CategoryConfirmation {
|
||||
t.Fatalf("expected confirmation category, got %v (%v)", errs.CategoryOf(err), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_ByIDWithYes(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(deleteOKStub("id1"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command-id", "id1", "--yes", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
// 上游 DELETE 返回空对象;CLI 必须补 action/command_id(写操作返回资源 ID)
|
||||
if data["action"] != "deleted" || data["command_id"] != "id1" {
|
||||
t.Fatalf("data = %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_ByNameWithYes(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub([]interface{}{sampleItem("greet", "id7")}))
|
||||
reg.Register(deleteOKStub("id7"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command", "greet", "--yes", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
if data["command"] != "greet" || data["command_id"] != "id7" {
|
||||
t.Fatalf("data = %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_ByNameDryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command", "greet", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Description string `json:"description"`
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
Method string `json:"method"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
if !strings.Contains(got.Description, "HIGH-RISK") || strings.Contains(got.Description, "resolve command_id") {
|
||||
t.Fatalf("top-level description must contain only the risk context: %q", got.Description)
|
||||
}
|
||||
if len(got.API) != 2 || got.API[0].Method != "GET" || !strings.Contains(got.API[0].Desc, "resolve command_id") {
|
||||
t.Fatalf("first call must describe name resolution: %#v", got.API)
|
||||
}
|
||||
if got.API[1].Method != "DELETE" || strings.Contains(got.API[1].Desc, "resolve command_id") {
|
||||
t.Fatalf("second call must be the delete without the resolve description: %#v", got.API)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_Validate(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
for _, args := range [][]string{
|
||||
{"+slash-command-delete", "--yes", "--as", "bot"},
|
||||
{"+slash-command-delete", "--command-id", "id1", "--command", "greet", "--yes", "--as", "bot"},
|
||||
} {
|
||||
err := mountAndRun(t, SlashCommandDelete, args, f, stdout)
|
||||
if err == nil {
|
||||
t.Errorf("%v: expected validation error", args)
|
||||
continue
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("%v: expected validation problem, got %v", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_ByIDEncodesTrimmedPathSegment(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(deleteOKStub("id%2Fwith%20space%3Fx"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command-id", " id/with space?x ", "--yes", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlashCommandList lists all slash commands of the current bound app.
|
||||
var SlashCommandList = common.Shortcut{
|
||||
Service: "application",
|
||||
Command: "+slash-command-list",
|
||||
Description: "List all slash commands (/ commands) registered on the currently bound Open Platform app; source of command_id for update/delete",
|
||||
Risk: "read",
|
||||
Scopes: []string{"application:app_slash_command:read"},
|
||||
AuthTypes: []string{"bot", "user"},
|
||||
Tips: []string{
|
||||
"lark-cli application +slash-command-list --as bot",
|
||||
"user identity needs explicit authorization first: lark-cli auth login --scope application:app_slash_command:read",
|
||||
"the upstream API returns all commands at once (max 100 per app, no pagination)",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("List all slash commands of the current bound app (read-only)").
|
||||
GET(slashCommandBasePath)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
data, err := runtime.CallAPITyped("GET", slashCommandBasePath, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
if items == nil {
|
||||
items = []interface{}{}
|
||||
}
|
||||
out := map[string]interface{}{"items": items, "count": len(items)}
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%d slash command(s)\n", len(items))
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
desc := ""
|
||||
if d, ok := m["description"].(map[string]interface{}); ok {
|
||||
desc, _ = d["default_value"].(string)
|
||||
}
|
||||
fmt.Fprintf(w, " /%v\t%v\t%s\n", m["command"], m["command_id"], desc)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func appTestConfig() *core.CliConfig {
|
||||
return &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
|
||||
}
|
||||
|
||||
// mountAndRun mounts the shortcut under a parent cobra command and runs it.
|
||||
// Mirrors shortcuts/contact tests.
|
||||
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "application"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func listStub(items []interface{}) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/application/v7/app_slash_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"items": items},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func sampleItem(name, id string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"command": name, "command_id": id,
|
||||
"create_time": "1783318553", "update_time": "1783318553",
|
||||
"description": map[string]interface{}{"default_value": "desc of " + name},
|
||||
"icon": map[string]interface{}{"icon_key": "skill_outlined"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandList_JSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub([]interface{}{sampleItem("greet", "id1"), sampleItem("weather", "id2")}))
|
||||
|
||||
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--format", "json", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
items := data["items"].([]interface{})
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("items = %d", len(items))
|
||||
}
|
||||
if data["count"] != float64(2) {
|
||||
t.Fatalf("count = %v", data["count"])
|
||||
}
|
||||
first := items[0].(map[string]interface{})
|
||||
for _, k := range []string{"command", "command_id", "description", "icon", "create_time", "update_time"} {
|
||||
if _, ok := first[k]; !ok {
|
||||
t.Errorf("missing item key %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandList_Empty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub(nil))
|
||||
|
||||
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--format", "json", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok || len(items) != 0 {
|
||||
t.Fatalf("empty list must be [] not %v", data["items"])
|
||||
}
|
||||
if data["count"] != float64(0) {
|
||||
t.Fatalf("count = %v", data["count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandList_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--dry-run", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/application/v7/app_slash_commands") || !strings.Contains(out, "GET") {
|
||||
t.Fatalf("dry-run must show GET path, got %s", out)
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// matchCommandID finds the command_id of the item whose "command" equals
|
||||
// name (exact match - the server enforces name uniqueness, so first hit is the
|
||||
// only hit).
|
||||
func matchCommandID(items []interface{}, name string) string {
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if m["command"] == name {
|
||||
id, _ := m["command_id"].(string)
|
||||
if id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// commandNotFoundError reports a resolution miss against the live list as an
|
||||
// API-category not-found error (the name is a valid argument shape; the
|
||||
// resource simply does not exist server-side - this is not a validation
|
||||
// failure of caller input).
|
||||
func commandNotFoundError(name string) error {
|
||||
return errs.NewAPIError(errs.SubtypeNotFound,
|
||||
"slash command %q not found in the current bound app", name).
|
||||
WithHint("run `lark-cli application +slash-command-list` to see registered commands")
|
||||
}
|
||||
|
||||
// resolveCommandID resolves a command name to its command_id via the live
|
||||
// list endpoint (in-memory only; never touches local files). Requires the
|
||||
// read scope on the current identity.
|
||||
func resolveCommandID(runtime *common.RuntimeContext, name string) (string, error) {
|
||||
data, err := runtime.CallAPITyped("GET", slashCommandBasePath, nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
id := matchCommandID(items, name)
|
||||
if id == "" {
|
||||
return "", commandNotFoundError(name)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestMatchCommandID(t *testing.T) {
|
||||
items := []interface{}{
|
||||
sampleItem("greet", "id1"),
|
||||
sampleItem("weather", "id2"),
|
||||
}
|
||||
id := matchCommandID(items, "weather")
|
||||
if id != "id2" {
|
||||
t.Fatalf("got id=%q", id)
|
||||
}
|
||||
id = matchCommandID(items, "nope")
|
||||
if id != "" {
|
||||
t.Fatalf("miss should return empty, got id=%q", id)
|
||||
}
|
||||
// 精确匹配:大小写与空白不做宽容
|
||||
id = matchCommandID(items, "Greet")
|
||||
if id != "" {
|
||||
t.Fatalf("match must be exact, got %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNotFoundErrorShape(t *testing.T) {
|
||||
err := commandNotFoundError("nope")
|
||||
if err == nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("expected api/not_found, got %#v", p)
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// validateUpdateTarget enforces: exactly one of --command-id/--command, and at
|
||||
// least one editable field; --description-i18n requires --description (PATCH
|
||||
// replaces the whole description object - sending i18n alone would drop
|
||||
// default_value, so both values must be provided together).
|
||||
func validateUpdateTarget(runtime *common.RuntimeContext) error {
|
||||
id := strings.TrimSpace(runtime.Str("command-id"))
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
if (id == "") == (name == "") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"provide exactly one of --command-id or --command").WithParam("--command-id")
|
||||
}
|
||||
if name != "" {
|
||||
if err := validateCommandName(name, "--command"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
hasDesc := strings.TrimSpace(runtime.Str("description")) != ""
|
||||
hasI18n := len(runtime.StrArray("description-i18n")) > 0
|
||||
hasIcon := strings.TrimSpace(runtime.Str("icon-key")) != ""
|
||||
if !hasDesc && !hasI18n && !hasIcon {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"provide at least one of --description / --description-i18n / --icon-key").WithParam("--description")
|
||||
}
|
||||
if hasI18n && !hasDesc {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--description-i18n requires --description: PATCH replaces the whole description object, so default_value must be provided together").WithParam("--description-i18n")
|
||||
}
|
||||
if _, err := parseDescriptionI18n(runtime.StrArray("description-i18n")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SlashCommandUpdate updates description/i18n/icon of an existing slash command.
|
||||
var SlashCommandUpdate = common.Shortcut{
|
||||
Service: "application",
|
||||
Command: "+slash-command-update",
|
||||
Description: "Update description / localized descriptions / icon of a slash command on the current bound app, addressed by --command-id or by name via --command",
|
||||
Risk: "write",
|
||||
Scopes: []string{"application:app_slash_command:write"},
|
||||
ConditionalScopes: []string{
|
||||
"application:app_slash_command:read", // only the --command by-name path lists to resolve the id
|
||||
},
|
||||
AuthTypes: []string{"bot", "user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "command-id", Desc: "target command_id (from +slash-command-list or create output); mutually exclusive with --command"},
|
||||
{Name: "command", Desc: "target command name WITHOUT leading slash; resolved via live list (needs read scope); mutually exclusive with --command-id"},
|
||||
{Name: "description", Desc: "new default description (description.default_value)"},
|
||||
{Name: "description-i18n", Type: "string_array", Desc: "localized description, repeatable <lang>=<text>; REPLACES the whole i18n map (missing languages are dropped); requires --description"},
|
||||
{Name: "icon-key", Desc: "new icon key (invalid keys rejected server-side with code 40000031)"},
|
||||
},
|
||||
Tips: []string{
|
||||
`lark-cli application +slash-command-update --command greet --description "new text" --as bot`,
|
||||
"PATCH is field-level partial: fields you do not pass are preserved server-side",
|
||||
"the command NAME itself cannot be changed (API limitation): rename = delete + create (new command_id)",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateUpdateTarget(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
|
||||
if err != nil {
|
||||
// The CLI validates first; keep this guard for direct DryRun callers.
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
body := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
|
||||
d := common.NewDryRunAPI()
|
||||
target := strings.TrimSpace(runtime.Str("command-id"))
|
||||
if target == "" {
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
d.GET(slashCommandBasePath).
|
||||
Desc(fmt.Sprintf("resolve command_id by name %q via GET list first", name))
|
||||
target = "<resolved_command_id>"
|
||||
} else {
|
||||
target = encodeCommandIDPathSegment(target)
|
||||
}
|
||||
return d.PATCH(slashCommandBasePath + "/" + target).
|
||||
Desc("Update a slash command by command_id").
|
||||
Body(body)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
id := strings.TrimSpace(runtime.Str("command-id"))
|
||||
if id == "" {
|
||||
resolved, err := resolveCommandID(runtime, strings.TrimSpace(runtime.Str("command")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id = resolved
|
||||
}
|
||||
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
|
||||
data, err := runtime.CallAPITyped("PATCH", slashCommandBasePath+"/"+encodeCommandIDPathSegment(id), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
data["action"] = "updated"
|
||||
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "updated /%v (command_id: %v)\n", data["command"], data["command_id"])
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
func TestSlashCommandUpdate_ByID(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(patchOKStub("id1"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command-id", "id1", "--description", "new", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
if data["action"] != "updated" {
|
||||
t.Fatalf("action = %v", data["action"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_ByName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub([]interface{}{sampleItem("greet", "id9")}))
|
||||
reg.Register(patchOKStub("id9"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command", "greet", "--icon-key", "skill_outlined", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_ByNameNotFound(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub(nil))
|
||||
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command", "nope", "--description", "x", "--as", "bot"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected not-found error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("expected api/not_found, got %#v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_Validate(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"both id and name", []string{"+slash-command-update", "--command-id", "id1", "--command", "greet", "--description", "x", "--as", "bot"}},
|
||||
{"neither id nor name", []string{"+slash-command-update", "--description", "x", "--as", "bot"}},
|
||||
{"no editable field", []string{"+slash-command-update", "--command-id", "id1", "--as", "bot"}},
|
||||
{"i18n without description", []string{"+slash-command-update", "--command-id", "id1", "--description-i18n", "zh_cn=x", "--as", "bot"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := mountAndRun(t, SlashCommandUpdate, c.args, f, stdout)
|
||||
if err == nil {
|
||||
t.Errorf("%s: expected validation error", c.name)
|
||||
continue
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("%s: expected validation problem, got %v", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_ByIDEncodesTrimmedPathSegment(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(patchOKStub("id%2Fwith%20space%3Fx"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command-id", " id/with space?x ", "--description", "new", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_ByNameDryRunDescriptions(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command", " greet ", "--description", "new", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Description string `json:"description"`
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
Method string `json:"method"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
if strings.Contains(got.Description, "resolve command_id") {
|
||||
t.Fatalf("resolve description must be attached to GET, not top-level: %q", got.Description)
|
||||
}
|
||||
if len(got.API) != 2 || got.API[0].Method != "GET" || !strings.Contains(got.API[0].Desc, "resolve command_id") {
|
||||
t.Fatalf("first call must describe name resolution: %#v", got.API)
|
||||
}
|
||||
if got.API[1].Method != "PATCH" || !strings.Contains(got.API[1].Desc, "Update a slash command") {
|
||||
t.Fatalf("second call must describe update: %#v", got.API)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_ByIDDryRunEncodesTrimmedPathSegment(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command-id", " id/with space?x ", "--description", "new", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
URL string `json:"url"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
wantURL := slashCommandBasePath + "/id%2Fwith%20space%3Fx"
|
||||
if len(got.API) != 1 || got.API[0].URL != wantURL || got.API[0].Desc == "" {
|
||||
t.Fatalf("dry-run call = %#v, want encoded URL %q with description", got.API, wantURL)
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
miaodaCLIPkg = "@lark-apaas/miaoda-cli@latest"
|
||||
miaodaCLIPkg = "@lark-apaas/miaoda-cli@0.1.20-alpha.dd573f8"
|
||||
npmRegistry = "https://registry.npmmirror.com"
|
||||
metaRelPath = ".spark/meta.json"
|
||||
steeringRelPath = ".agent/skills/steering"
|
||||
|
||||
@@ -431,6 +431,11 @@ func (ctx *RuntimeContext) buildRequest(method, url string, params map[string]in
|
||||
if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil {
|
||||
req.ExtraOpts = append(req.ExtraOpts, optFn)
|
||||
}
|
||||
// TODO: remove PPE headers once testing is complete and promoted to production.
|
||||
ppeHeaders := http.Header{}
|
||||
ppeHeaders.Set("x-use-ppe", "1")
|
||||
ppeHeaders.Set("x-tt-env", "ppe_miaoda_lark_cli")
|
||||
req.ExtraOpts = append(req.ExtraOpts, larkcore.WithHeaders(ppeHeaders))
|
||||
return req
|
||||
}
|
||||
|
||||
@@ -1070,7 +1075,7 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
if rctx.stdinConsumed {
|
||||
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
|
||||
WithParam("--"+fl.Name).
|
||||
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others inline or as @file with a relative path under the current directory (e.g. --%s @./payload.json)", fl.Name)
|
||||
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others as @file (e.g. --%s @/path/to/file)", fl.Name)
|
||||
}
|
||||
rctx.stdinConsumed = true
|
||||
data, err := io.ReadAll(rctx.IO().In)
|
||||
@@ -1104,16 +1109,9 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
|
||||
if err != nil {
|
||||
verr := ValidationErrorf("--%s: %v", fl.Name, err).
|
||||
return ValidationErrorf("--%s: %v", fl.Name, err).
|
||||
WithParam("--" + fl.Name).
|
||||
WithCause(err)
|
||||
if slices.Contains(fl.Input, Stdin) {
|
||||
// Rejected @file paths are usually absolute (temp files under
|
||||
// /tmp). Steer toward stdin rather than cd / copying the file
|
||||
// into the project tree.
|
||||
verr = verr.WithHint("this flag also reads stdin: pipe the file contents into this command and pass --%s -", fl.Name)
|
||||
}
|
||||
return verr
|
||||
}
|
||||
// strip a leading UTF-8 BOM so it
|
||||
// can't corrupt the first CSV cell or break JSON parsing downstream.
|
||||
|
||||
@@ -227,35 +227,6 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveInputFlags_FileErrorSuggestsStdin pins the recovery hint when
|
||||
// an @file path is rejected (typically an absolute /tmp path): flags that
|
||||
// also accept stdin must explain the portable `--flag -` form — never cd'ing
|
||||
// into the target directory or copying the file into the project tree.
|
||||
func TestResolveInputFlags_FileErrorSuggestsStdin(t *testing.T) {
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"csv": "@/tmp/does-not-exist.csv"}, "")
|
||||
flags := []Flag{{Name: "csv", Input: []string{File, Stdin}}}
|
||||
|
||||
err := resolveInputFlags(rctx, flags)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for rejected @file path")
|
||||
}
|
||||
vErr := assertValidationParam(t, err, "--csv")
|
||||
if !strings.Contains(vErr.Hint, "pipe the file contents") || !strings.Contains(vErr.Hint, "--csv -") {
|
||||
t.Errorf("hint %q should explain the portable stdin form", vErr.Hint)
|
||||
}
|
||||
|
||||
// A flag without stdin support must not get the stdin hint.
|
||||
rctx = newTestRuntimeWithStdin(map[string]string{"file": "@/tmp/does-not-exist.xlsx"}, "")
|
||||
err = resolveInputFlags(rctx, []Flag{{Name: "file", Input: []string{File}}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for rejected @file path")
|
||||
}
|
||||
vErr = assertValidationParam(t, err, "--file")
|
||||
if strings.Contains(vErr.Hint, "stdin") {
|
||||
t.Errorf("hint %q must not suggest stdin for a file-only flag", vErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripUTF8BOM(t *testing.T) {
|
||||
cases := []struct{ name, in, want string }{
|
||||
{"leading BOM removed", "\uFEFFhello", "hello"},
|
||||
|
||||
@@ -39,7 +39,7 @@ func wrapExportContextErr(err error) error {
|
||||
var DriveExport = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+export",
|
||||
Description: "Export a doc/docx/sheet/bitable/slides or wiki document to a local file with limited polling",
|
||||
Description: "Export a doc/docx/sheet/bitable/slides to a local file with limited polling",
|
||||
Risk: "read",
|
||||
Scopes: []string{
|
||||
"docs:document.content:read",
|
||||
@@ -47,12 +47,10 @@ var DriveExport = common.Shortcut{
|
||||
"docx:document:readonly",
|
||||
"drive:drive.metadata:readonly",
|
||||
},
|
||||
ConditionalScopes: []string{"wiki:node:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "source document URL; doc type and token are inferred, and wiki URLs are resolved to the underlying document"},
|
||||
{Name: "token", Desc: "source document token; bare tokens require --doc-type, and wiki tokens should use --doc-type wiki"},
|
||||
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides | wiki (required only when --token is a bare token)", Enum: []string{"doc", "docx", "sheet", "bitable", "slides", "wiki"}},
|
||||
{Name: "token", Desc: "source document token", Required: true},
|
||||
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable", "slides"}},
|
||||
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only) | pptx (slides only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}},
|
||||
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
|
||||
{Name: "only-schema", Type: "bool", Desc: "export only bitable schema when --doc-type bitable --file-extension base"},
|
||||
@@ -77,7 +75,6 @@ var DriveExport = common.Shortcut{
|
||||
// task and poll, but do not download" — callers that only need the ready file
|
||||
// token / status get it back without writing a local file.
|
||||
type ExportParams struct {
|
||||
URL string
|
||||
Token string
|
||||
DocType string
|
||||
FileExtension string
|
||||
@@ -90,7 +87,6 @@ type ExportParams struct {
|
||||
|
||||
func (p ExportParams) spec() driveExportSpec {
|
||||
return driveExportSpec{
|
||||
URL: p.URL,
|
||||
Token: p.Token,
|
||||
DocType: p.DocType,
|
||||
FileExtension: p.FileExtension,
|
||||
@@ -110,7 +106,6 @@ func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
|
||||
outputDir = "."
|
||||
}
|
||||
return ExportParams{
|
||||
URL: runtime.Str("url"),
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
@@ -132,90 +127,60 @@ func validateExport(p ExportParams) error {
|
||||
|
||||
// PlanExportDryRun builds the dry-run plan for an export without performing I/O.
|
||||
func PlanExportDryRun(runtime *common.RuntimeContext, p ExportParams) *common.DryRunAPI {
|
||||
spec, source, err := normalizeDriveExportSpecInput(p.spec())
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if err := validateDriveExportNormalizedSpecForSource(spec, source); err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
if source.Type == "wiki" {
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[0] Resolve wiki node to underlying document token").
|
||||
Params(map[string]interface{}{"token": source.Token})
|
||||
spec.Token = "obj_token_from_step_0"
|
||||
if spec.DocType == "" {
|
||||
spec.DocType = "obj_type_from_step_0"
|
||||
}
|
||||
dry.Set("wiki_token", source.Token)
|
||||
}
|
||||
|
||||
spec := p.spec()
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
desc := "2-step orchestration: fetch docx markdown -> write local file"
|
||||
if source.Type == "wiki" {
|
||||
desc = "3-step orchestration: resolve wiki -> fetch docx markdown -> write local file"
|
||||
}
|
||||
dry.Desc(desc).
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dry.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dry
|
||||
return dr
|
||||
}
|
||||
|
||||
desc := "3-step orchestration: create export task -> limited polling -> download file"
|
||||
if source.Type == "wiki" {
|
||||
desc = "4-step orchestration: resolve wiki -> create export task -> limited polling -> download file"
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
dry.Desc(desc).
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
if spec.OnlySchema {
|
||||
body["only_schema"] = true
|
||||
}
|
||||
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(buildDriveExportTaskBody(spec)).
|
||||
Body(body).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dry.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dry
|
||||
return dr
|
||||
}
|
||||
|
||||
// RunExport drives create export task -> bounded poll -> optional download. It
|
||||
// is the shared core behind both drive +export and sheets +workbook-export. An
|
||||
// empty p.OutputDir skips the download step and returns the ready file token.
|
||||
func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportParams) error {
|
||||
spec, source, err := normalizeDriveExportSpecInput(p.spec())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDriveExportNormalizedSpecForSource(spec, source); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
spec := p.spec()
|
||||
outputDir := p.OutputDir
|
||||
preferredFileName := strings.TrimSpace(p.FileName)
|
||||
overwrite := p.Overwrite
|
||||
|
||||
var wikiResolution driveExportWikiResolution
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
if source.Type == "wiki" {
|
||||
resolvedSpec, resolution, err := resolveDriveExportWikiSource(ctx, runtime, spec, source.Token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec = resolvedSpec
|
||||
wikiResolution = resolution
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
@@ -257,23 +222,21 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(annotateDriveExportWikiOutput(map[string]interface{}{
|
||||
runtime.Out(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(content),
|
||||
}, wikiResolution), nil)
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticket, resolvedSpec, resolution, err := createDriveExportTaskResolvingWiki(ctx, runtime, spec, source)
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec = resolvedSpec
|
||||
wikiResolution = resolution
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
@@ -311,7 +274,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
// no local download (e.g. sheets +workbook-export without an output
|
||||
// path). Skip the download and return the status envelope.
|
||||
if strings.TrimSpace(outputDir) == "" {
|
||||
runtime.Out(annotateDriveExportWikiOutput(map[string]interface{}{
|
||||
runtime.Out(map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
@@ -321,7 +284,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
"file_size": status.FileSize,
|
||||
"ready": true,
|
||||
"downloaded": false,
|
||||
}, wikiResolution), nil)
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -344,7 +307,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(annotateDriveExportWikiOutput(out, wikiResolution), nil)
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -394,19 +357,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(annotateDriveExportWikiOutput(result, wikiResolution), nil)
|
||||
runtime.Out(result, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
}
|
||||
|
||||
func annotateDriveExportWikiOutput(out map[string]interface{}, resolution driveExportWikiResolution) map[string]interface{} {
|
||||
if !resolution.Resolved {
|
||||
return out
|
||||
}
|
||||
out["wiki_token"] = resolution.WikiToken
|
||||
out["wiki_node"] = map[string]interface{}{
|
||||
"obj_token": resolution.ObjToken,
|
||||
"obj_type": resolution.ObjType,
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -27,16 +27,9 @@ var (
|
||||
driveExportPollInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
const (
|
||||
driveExportResolvedDocTypeValues = "doc, docx, sheet, bitable, slides"
|
||||
driveExportInputDocTypeValues = driveExportResolvedDocTypeValues + ", wiki"
|
||||
driveExportFileExtensionValues = "docx, pdf, xlsx, csv, markdown, base, pptx"
|
||||
)
|
||||
|
||||
// driveExportSpec contains the normalized export request understood by the
|
||||
// shortcut and the underlying export task APIs.
|
||||
type driveExportSpec struct {
|
||||
URL string
|
||||
Token string
|
||||
DocType string
|
||||
FileExtension string
|
||||
@@ -44,19 +37,6 @@ type driveExportSpec struct {
|
||||
OnlySchema bool
|
||||
}
|
||||
|
||||
type driveExportInputSource struct {
|
||||
Type string
|
||||
Token string
|
||||
Param string
|
||||
}
|
||||
|
||||
type driveExportWikiResolution struct {
|
||||
Resolved bool
|
||||
WikiToken string
|
||||
ObjToken string
|
||||
ObjType string
|
||||
}
|
||||
|
||||
// driveExportTaskResultCommand prints the resume command shown when bounded
|
||||
// export polling times out locally.
|
||||
func driveExportTaskResultCommand(ticket, docToken string) string {
|
||||
@@ -147,49 +127,45 @@ func (s driveExportStatus) StatusLabel() string {
|
||||
// validateDriveExportSpec enforces shortcut-level export constraints before any
|
||||
// backend request is sent.
|
||||
func validateDriveExportSpec(spec driveExportSpec) error {
|
||||
normalized, source, err := normalizeDriveExportSpecInput(spec)
|
||||
if err != nil {
|
||||
return err
|
||||
if err := validate.ResourceName(spec.Token, "--token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return validateDriveExportNormalizedSpecForSource(normalized, source)
|
||||
}
|
||||
|
||||
func validateDriveExportNormalizedSpec(spec driveExportSpec) error {
|
||||
switch spec.DocType {
|
||||
case "doc", "docx", "sheet", "bitable", "slides":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc-type %q: allowed values are %s", spec.DocType, driveExportInputDocTypeValues).
|
||||
WithParam("--doc-type").
|
||||
WithHint("use --url when you have a document URL; use --doc-type wiki only with a bare Wiki node token so the CLI can resolve the underlying document type")
|
||||
}
|
||||
|
||||
if err := validate.ResourceName(spec.Token, "--token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc-type %q: allowed values are doc, docx, sheet, bitable, slides", spec.DocType).WithParam("--doc-type")
|
||||
}
|
||||
|
||||
switch spec.FileExtension {
|
||||
case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are %s", spec.FileExtension, driveExportFileExtensionValues).
|
||||
WithParam("--file-extension").
|
||||
WithHint("choose an export format supported by the source type; common choices are docx/pdf for docs, xlsx/csv for sheets, xlsx/csv/base for bitable, and pptx/pdf for slides")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base, pptx", spec.FileExtension).WithParam("--file-extension")
|
||||
}
|
||||
|
||||
if err := validateDriveExportFormatCompatibility(spec); err != nil {
|
||||
return err
|
||||
if spec.FileExtension == "markdown" && spec.DocType != "docx" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension markdown only supports --doc-type docx")
|
||||
}
|
||||
|
||||
if spec.FileExtension == "base" && spec.DocType != "bitable" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension base only supports --doc-type bitable")
|
||||
}
|
||||
|
||||
if spec.OnlySchema && (spec.DocType != "bitable" || spec.FileExtension != "base") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").
|
||||
WithParam("--only-schema").
|
||||
WithHint("retry with --doc-type bitable --file-extension base, or remove --only-schema")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").WithParam("--only-schema")
|
||||
}
|
||||
|
||||
if spec.FileExtension == "pptx" && spec.DocType != "slides" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension pptx only supports --doc-type slides")
|
||||
}
|
||||
|
||||
if spec.DocType == "slides" && spec.FileExtension != "pptx" && spec.FileExtension != "pdf" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-type slides only supports --file-extension pptx or pdf")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
if spec.FileExtension != "csv" || (spec.DocType != "sheet" && spec.DocType != "bitable") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").
|
||||
WithParam("--sub-id").
|
||||
WithHint("remove --sub-id, or retry with --doc-type sheet|bitable --file-extension csv")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").WithParam("--sub-id")
|
||||
}
|
||||
if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--sub-id")
|
||||
@@ -197,212 +173,15 @@ func validateDriveExportNormalizedSpec(spec driveExportSpec) error {
|
||||
}
|
||||
|
||||
if spec.FileExtension == "csv" && (spec.DocType == "sheet" || spec.DocType == "bitable") && strings.TrimSpace(spec.SubID) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is required when exporting sheet/bitable as csv").
|
||||
WithParam("--sub-id").
|
||||
WithHint("retry with --sub-id <sheet_id_or_table_id>; if you need the whole workbook, use --file-extension xlsx instead")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is required when exporting sheet/bitable as csv").WithParam("--sub-id")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDriveExportFormatCompatibility(spec driveExportSpec) error {
|
||||
if driveExportFileExtensionAllowedForDocType(spec.DocType, spec.FileExtension) {
|
||||
return nil
|
||||
}
|
||||
allowed := strings.Join(driveExportAllowedFileExtensions(spec.DocType), ", ")
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported export format: --doc-type %s cannot be exported as %s",
|
||||
spec.DocType,
|
||||
spec.FileExtension,
|
||||
).
|
||||
WithParam("--file-extension").
|
||||
WithHint("retry with --file-extension %s. If the token came from a URL, prefer --url so the CLI infers the correct source type before validating the export format", allowed)
|
||||
}
|
||||
|
||||
func driveExportFileExtensionAllowedForDocType(docType, fileExtension string) bool {
|
||||
for _, allowed := range driveExportAllowedFileExtensions(docType) {
|
||||
if fileExtension == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func driveExportAllowedFileExtensions(docType string) []string {
|
||||
switch normalizeDriveExportDocType(docType) {
|
||||
case "doc":
|
||||
return []string{"docx", "pdf"}
|
||||
case "docx":
|
||||
return []string{"docx", "pdf", "markdown"}
|
||||
case "sheet":
|
||||
return []string{"xlsx", "csv"}
|
||||
case "bitable":
|
||||
return []string{"xlsx", "csv", "base"}
|
||||
case "slides":
|
||||
return []string{"pptx", "pdf"}
|
||||
default:
|
||||
return []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}
|
||||
}
|
||||
}
|
||||
|
||||
func validateDriveExportNormalizedSpecForSource(spec driveExportSpec, source driveExportInputSource) error {
|
||||
if source.Type == "wiki" && spec.DocType == "" {
|
||||
return validateDriveExportPendingWikiSpec(spec, source)
|
||||
}
|
||||
return validateDriveExportNormalizedSpec(spec)
|
||||
}
|
||||
|
||||
func validateDriveExportPendingWikiSpec(spec driveExportSpec, source driveExportInputSource) error {
|
||||
param := source.Param
|
||||
if param == "" {
|
||||
param = "--token"
|
||||
}
|
||||
if err := validate.ResourceName(spec.Token, param); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(param)
|
||||
}
|
||||
|
||||
switch spec.FileExtension {
|
||||
case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are %s", spec.FileExtension, driveExportFileExtensionValues).
|
||||
WithParam("--file-extension").
|
||||
WithHint("Wiki export format is validated after resolving the Wiki node; choose a format normally supported by the underlying document type")
|
||||
}
|
||||
if spec.OnlySchema && spec.FileExtension != "base" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").
|
||||
WithParam("--only-schema").
|
||||
WithHint("retry with --file-extension base, or remove --only-schema")
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" && spec.FileExtension != "csv" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").
|
||||
WithParam("--sub-id").
|
||||
WithHint("remove --sub-id, or retry with --file-extension csv if the Wiki node resolves to a sheet/bitable")
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--sub-id")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeDriveExportSpecInput(spec driveExportSpec) (driveExportSpec, driveExportInputSource, error) {
|
||||
spec.URL = strings.TrimSpace(spec.URL)
|
||||
spec.Token = strings.TrimSpace(spec.Token)
|
||||
spec.DocType = strings.ToLower(strings.TrimSpace(spec.DocType))
|
||||
spec.FileExtension = strings.ToLower(strings.TrimSpace(spec.FileExtension))
|
||||
|
||||
if spec.Token == "" && spec.URL == "" {
|
||||
return spec, driveExportInputSource{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "either --url or --token is required").WithParam("--url")
|
||||
}
|
||||
if spec.Token != "" && spec.URL != "" {
|
||||
return spec, driveExportInputSource{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url and --token are mutually exclusive").WithParam("--url")
|
||||
}
|
||||
|
||||
source := driveExportInputSource{
|
||||
Type: spec.DocType,
|
||||
Token: spec.Token,
|
||||
Param: "--token",
|
||||
}
|
||||
|
||||
rawInput := spec.Token
|
||||
inputParam := "--token"
|
||||
if spec.URL != "" {
|
||||
rawInput = spec.URL
|
||||
inputParam = "--url"
|
||||
}
|
||||
|
||||
if ref, ok := common.ParseResourceURL(rawInput); ok {
|
||||
refType := normalizeDriveExportDocType(ref.Type)
|
||||
source = driveExportInputSource{
|
||||
Type: refType,
|
||||
Token: ref.Token,
|
||||
Param: inputParam,
|
||||
}
|
||||
spec.Token = ref.Token
|
||||
if refType != "wiki" {
|
||||
if !isDriveExportDocType(refType) {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"%s URL type %q is not supported by drive +export; use a doc/docx/sheet/base/slides/wiki URL or token",
|
||||
inputParam,
|
||||
ref.Type,
|
||||
).WithParam(inputParam)
|
||||
}
|
||||
if spec.DocType == "wiki" {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--doc-type wiki conflicts with %s URL type %q",
|
||||
inputParam,
|
||||
refType,
|
||||
).
|
||||
WithParam("--doc-type").
|
||||
WithHint("remove --doc-type when passing --url; the CLI will infer %q from the URL", refType)
|
||||
}
|
||||
if spec.DocType != "" && spec.DocType != refType {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--doc-type %q conflicts with %s URL type %q",
|
||||
spec.DocType,
|
||||
inputParam,
|
||||
refType,
|
||||
).WithParam("--doc-type")
|
||||
}
|
||||
spec.DocType = refType
|
||||
} else if spec.DocType == "wiki" {
|
||||
spec.DocType = ""
|
||||
}
|
||||
return spec, source, nil
|
||||
}
|
||||
|
||||
if strings.Contains(rawInput, "://") {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported %s URL %q: use a recognized Lark document URL",
|
||||
inputParam,
|
||||
rawInput,
|
||||
).WithParam(inputParam)
|
||||
}
|
||||
if spec.URL != "" {
|
||||
return spec, source, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported --url %q: use a recognized Lark document URL",
|
||||
spec.URL,
|
||||
).WithParam("--url")
|
||||
}
|
||||
if spec.DocType == "" {
|
||||
return spec, source, errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-type is required when --token is a bare token (allowed: %s)", driveExportInputDocTypeValues).
|
||||
WithParam("--doc-type").
|
||||
WithHint("if you have the original document link, prefer --url <document_url>; if this is a Wiki node token, use --doc-type wiki")
|
||||
}
|
||||
if spec.DocType == "wiki" {
|
||||
source.Type = "wiki"
|
||||
source.Token = spec.Token
|
||||
spec.DocType = ""
|
||||
}
|
||||
return spec, source, nil
|
||||
}
|
||||
|
||||
func normalizeDriveExportDocType(docType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(docType)) {
|
||||
case "base":
|
||||
return "bitable"
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(docType))
|
||||
}
|
||||
}
|
||||
|
||||
func isDriveExportDocType(docType string) bool {
|
||||
switch normalizeDriveExportDocType(docType) {
|
||||
case "doc", "docx", "sheet", "bitable", "slides":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func buildDriveExportTaskBody(spec driveExportSpec) map[string]interface{} {
|
||||
// createDriveExportTask starts the asynchronous export job and returns its
|
||||
// ticket for subsequent polling.
|
||||
func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) (string, error) {
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
@@ -414,13 +193,8 @@ func buildDriveExportTaskBody(spec driveExportSpec) map[string]interface{} {
|
||||
if spec.OnlySchema {
|
||||
body["only_schema"] = true
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// createDriveExportTask starts the asynchronous export job and returns its
|
||||
// ticket for subsequent polling.
|
||||
func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) (string, error) {
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, buildDriveExportTaskBody(spec))
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -432,79 +206,6 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec)
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
func resolveDriveExportWikiSource(ctx context.Context, runtime *common.RuntimeContext, spec driveExportSpec, wikiToken string) (driveExportSpec, driveExportWikiResolution, error) {
|
||||
wikiToken = strings.TrimSpace(wikiToken)
|
||||
if err := validate.ResourceName(wikiToken, "--token"); err != nil {
|
||||
return spec, driveExportWikiResolution{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node for export: %s\n", common.MaskToken(wikiToken))
|
||||
data, err := driveInspectCallWithRetry(ctx, func() (map[string]interface{}, error) {
|
||||
return runtime.CallAPITyped(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": wikiToken},
|
||||
nil,
|
||||
)
|
||||
})
|
||||
if err != nil {
|
||||
return spec, driveExportWikiResolution{}, err
|
||||
}
|
||||
|
||||
node := common.GetMap(data, "node")
|
||||
objType := normalizeDriveExportDocType(common.GetString(node, "obj_type"))
|
||||
objToken := common.GetString(node, "obj_token")
|
||||
if objType == "" || objToken == "" {
|
||||
return spec, driveExportWikiResolution{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken)
|
||||
}
|
||||
if !isDriveExportDocType(objType) {
|
||||
return spec, driveExportWikiResolution{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"wiki resolved to %q, but drive +export only supports doc, docx, sheet, bitable, and slides",
|
||||
objType,
|
||||
).WithParam("--token")
|
||||
}
|
||||
if spec.DocType != "" && spec.DocType != objType {
|
||||
return spec, driveExportWikiResolution{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"wiki resolved to %q, but --doc-type is %q; use --doc-type %s",
|
||||
objType,
|
||||
spec.DocType,
|
||||
objType,
|
||||
).WithParam("--doc-type")
|
||||
}
|
||||
|
||||
spec.Token = objToken
|
||||
spec.DocType = objType
|
||||
if err := validateDriveExportNormalizedSpec(spec); err != nil {
|
||||
return spec, driveExportWikiResolution{}, err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
return spec, driveExportWikiResolution{
|
||||
Resolved: true,
|
||||
WikiToken: wikiToken,
|
||||
ObjToken: objToken,
|
||||
ObjType: objType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func createDriveExportTaskResolvingWiki(ctx context.Context, runtime *common.RuntimeContext, spec driveExportSpec, source driveExportInputSource) (string, driveExportSpec, driveExportWikiResolution, error) {
|
||||
if source.Type == "wiki" {
|
||||
resolvedSpec, resolution, err := resolveDriveExportWikiSource(ctx, runtime, spec, source.Token)
|
||||
if err != nil {
|
||||
return "", spec, resolution, err
|
||||
}
|
||||
ticket, err := createDriveExportTask(runtime, resolvedSpec)
|
||||
return ticket, resolvedSpec, resolution, err
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err != nil {
|
||||
return "", spec, driveExportWikiResolution{}, err
|
||||
}
|
||||
return ticket, spec, driveExportWikiResolution{}, nil
|
||||
}
|
||||
|
||||
// getDriveExportStatus fetches the current backend state for a previously
|
||||
// created export task.
|
||||
func getDriveExportStatus(runtime *common.RuntimeContext, token, ticket string) (driveExportStatus, error) {
|
||||
|
||||
@@ -33,36 +33,10 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
name: "markdown docx ok",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "markdown"},
|
||||
},
|
||||
{
|
||||
name: "docx url infers doc type",
|
||||
spec: driveExportSpec{URL: "https://example.feishu.cn/docx/docxURL123", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "wiki url can defer doc type until resolution",
|
||||
spec: driveExportSpec{URL: "https://example.feishu.cn/wiki/wikiURL123", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "wiki url with doc-type wiki can defer doc type until resolution",
|
||||
spec: driveExportSpec{URL: "https://example.feishu.cn/wiki/wikiURL123", DocType: "wiki", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "wiki token with doc-type wiki can defer doc type until resolution",
|
||||
spec: driveExportSpec{Token: "wiki123", DocType: "wiki", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "bare token requires doc type",
|
||||
spec: driveExportSpec{Token: "docx123", FileExtension: "pdf"},
|
||||
wantErr: "--doc-type is required",
|
||||
},
|
||||
{
|
||||
name: "markdown non docx rejected",
|
||||
spec: driveExportSpec{Token: "doc123", DocType: "doc", FileExtension: "markdown"},
|
||||
wantErr: "cannot be exported as markdown",
|
||||
},
|
||||
{
|
||||
name: "docx csv rejected",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "csv"},
|
||||
wantErr: "cannot be exported as csv",
|
||||
wantErr: "only supports --doc-type docx",
|
||||
},
|
||||
{
|
||||
name: "csv without sub id rejected",
|
||||
@@ -98,27 +72,17 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
{
|
||||
name: "base non bitable rejected",
|
||||
spec: driveExportSpec{Token: "sheet123", DocType: "sheet", FileExtension: "base"},
|
||||
wantErr: "cannot be exported as base",
|
||||
},
|
||||
{
|
||||
name: "sheet pdf rejected",
|
||||
spec: driveExportSpec{Token: "sheet123", DocType: "sheet", FileExtension: "pdf"},
|
||||
wantErr: "cannot be exported as pdf",
|
||||
},
|
||||
{
|
||||
name: "bitable pdf rejected",
|
||||
spec: driveExportSpec{Token: "base123", DocType: "bitable", FileExtension: "pdf"},
|
||||
wantErr: "cannot be exported as pdf",
|
||||
wantErr: "only supports --doc-type bitable",
|
||||
},
|
||||
{
|
||||
name: "pptx non slides rejected",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "pptx"},
|
||||
wantErr: "cannot be exported as pptx",
|
||||
wantErr: "only supports --doc-type slides",
|
||||
},
|
||||
{
|
||||
name: "slides csv rejected",
|
||||
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "csv"},
|
||||
wantErr: "cannot be exported as csv",
|
||||
wantErr: "slides only supports",
|
||||
},
|
||||
{
|
||||
name: "unknown doc type rejected",
|
||||
@@ -149,29 +113,6 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveExportUnsupportedFormatHasHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveExportSpec(driveExportSpec{
|
||||
Token: "docx123",
|
||||
DocType: "docx",
|
||||
FileExtension: "csv",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported format error, got nil")
|
||||
}
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if valErr.Param != "--file-extension" {
|
||||
t.Fatalf("param = %q, want --file-extension", valErr.Param)
|
||||
}
|
||||
if !strings.Contains(valErr.Hint, "docx, pdf, markdown") || !strings.Contains(valErr.Hint, "--url") {
|
||||
t.Fatalf("hint = %q, want allowed formats and URL retry guidance", valErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
fetchStub := &httpmock.Stub{
|
||||
@@ -499,76 +440,6 @@ func TestDriveExportMarkdownRejectsMissingDocumentContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportURLInfersDocType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_url"},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_url",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_url",
|
||||
"file_name": "url-report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_url/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="url-report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--url", "https://example.feishu.cn/docx/docxURL123",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("unmarshal export_tasks body: %v", err)
|
||||
}
|
||||
if createBody["token"] != "docxURL123" {
|
||||
t.Fatalf("export_tasks body token = %v, want token from URL", createBody["token"])
|
||||
}
|
||||
if createBody["type"] != "docx" {
|
||||
t.Fatalf("export_tasks body type = %v, want inferred docx", createBody["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -639,266 +510,6 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportWikiURLResolvesBeforeAsyncTask(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_wiki"},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_wiki",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_wiki",
|
||||
"file_name": "wiki-report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_wiki/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="wiki-report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--url", "https://example.feishu.cn/wiki/wikiNode123",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("unmarshal export_tasks body: %v", err)
|
||||
}
|
||||
if createBody["token"] != "docxResolved" {
|
||||
t.Fatalf("export_tasks body token = %v, want resolved docx token", createBody["token"])
|
||||
}
|
||||
if createBody["type"] != "docx" {
|
||||
t.Fatalf("export_tasks body type = %v, want docx", createBody["type"])
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"wiki_token": "wikiNode123"`) {
|
||||
t.Fatalf("stdout missing wiki token context: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportBareWikiTypeResolvesBeforeAsyncTask(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_wiki_token"},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_wiki_token",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_wiki_token",
|
||||
"file_name": "wiki-token-report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_wiki_token/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="wiki-token-report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "wikiNodeBare",
|
||||
"--doc-type", "wiki",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("unmarshal export_tasks body: %v", err)
|
||||
}
|
||||
if createBody["token"] != "docxResolved" {
|
||||
t.Fatalf("export_tasks body token = %v, want resolved docx token", createBody["token"])
|
||||
}
|
||||
if createBody["type"] != "docx" {
|
||||
t.Fatalf("export_tasks body type = %v, want resolved docx type", createBody["type"])
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"wiki_token": "wikiNodeBare"`) {
|
||||
t.Fatalf("stdout missing wiki token context: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportBareWikiTokenFileTokenInvalidDoesNotFallback(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
firstCreate := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Status: 404,
|
||||
Body: map[string]interface{}{
|
||||
"code": 1069914,
|
||||
"msg": "file token invalid",
|
||||
"log_id": "20260708000000TEST",
|
||||
},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
return strings.Contains(string(body), `"token":"wikiNodeBare"`)
|
||||
},
|
||||
}
|
||||
reg.Register(firstCreate)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "wikiNodeBare",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected file token invalid error, got nil")
|
||||
}
|
||||
|
||||
if len(firstCreate.CapturedBody) == 0 {
|
||||
t.Fatal("first export task request was not sent with the original token")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed API error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Code != 1069914 {
|
||||
t.Fatalf("error code = %d, want 1069914", problem.Code)
|
||||
}
|
||||
if strings.Contains(stderr.String(), "Resolving wiki node for export") {
|
||||
t.Fatalf("stderr unexpectedly contains wiki resolution log: %s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportWikiResolvedTypeMismatch(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": "shtResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "https://example.feishu.cn/wiki/wikiSheet123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected type mismatch error, got nil")
|
||||
}
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(valErr.Message, `wiki resolved to "sheet"`) {
|
||||
t.Fatalf("error message = %q, want resolved type", valErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveExportEmptyOutputDirDownloadsToCwd guards the export refactor: an
|
||||
// explicit empty --output-dir must still download to the current directory
|
||||
// (normalized to "."), not trigger the export-only no-download path that the
|
||||
|
||||
@@ -54,21 +54,15 @@ type ImportParams struct {
|
||||
FolderToken string
|
||||
Name string
|
||||
TargetToken string
|
||||
// FileExtension optionally overrides the extension inferred from File's
|
||||
// name. Leave empty to infer from File (the default). Callers that have
|
||||
// sniffed the file's real container use this to correct a mislabeled name
|
||||
// so the backend receives the true format.
|
||||
FileExtension string
|
||||
}
|
||||
|
||||
func (p ImportParams) spec() driveImportSpec {
|
||||
return driveImportSpec{
|
||||
FilePath: p.File,
|
||||
DocType: strings.ToLower(p.DocType),
|
||||
FolderToken: p.FolderToken,
|
||||
Name: p.Name,
|
||||
TargetToken: p.TargetToken,
|
||||
EffectiveExt: strings.TrimPrefix(strings.ToLower(p.FileExtension), "."),
|
||||
FilePath: p.File,
|
||||
DocType: strings.ToLower(p.DocType),
|
||||
FolderToken: p.FolderToken,
|
||||
Name: p.Name,
|
||||
TargetToken: p.TargetToken,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +127,7 @@ func RunImport(ctx context.Context, runtime *common.RuntimeContext, p ImportPara
|
||||
}
|
||||
|
||||
// Step 1: Upload file as media
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec)
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
|
||||
if uploadErr != nil {
|
||||
return uploadErr
|
||||
}
|
||||
@@ -209,14 +203,14 @@ func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64,
|
||||
if !info.Mode().IsRegular() {
|
||||
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", spec.FilePath).WithParam("--file")
|
||||
}
|
||||
if err = validateDriveImportFileSize(spec.FileExtension(), spec.DocType, info.Size()); err != nil {
|
||||
if err = validateDriveImportFileSize(spec.FilePath, spec.DocType, info.Size()); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
func appendDriveImportUploadDryRun(dry *common.DryRunAPI, spec driveImportSpec, fileSize int64) {
|
||||
extra, err := buildImportMediaExtra(spec.FileExtension(), spec.DocType)
|
||||
extra, err := buildImportMediaExtra(spec.FilePath, spec.DocType)
|
||||
if err != nil {
|
||||
extra = fmt.Sprintf(`{"obj_type":"%s","file_extension":"%s"}`, spec.DocType, spec.FileExtension())
|
||||
}
|
||||
|
||||
@@ -59,39 +59,14 @@ type driveImportSpec struct {
|
||||
FolderToken string
|
||||
Name string
|
||||
TargetToken string // existing bitable token to import data into (only for type=bitable)
|
||||
|
||||
// EffectiveExt is a caller-supplied override for the extension otherwise
|
||||
// derived from FilePath (see ImportParams.FileExtension). It lets a caller
|
||||
// that has detected the file's real container correct a mislabeled name
|
||||
// (e.g. an OOXML workbook saved as .xls). Empty means "trust the filename".
|
||||
EffectiveExt string
|
||||
}
|
||||
|
||||
// rawExtension is the lowercased extension taken verbatim from the file name.
|
||||
func (s driveImportSpec) rawExtension() string {
|
||||
func (s driveImportSpec) FileExtension() string {
|
||||
return strings.TrimPrefix(strings.ToLower(filepath.Ext(s.FilePath)), ".")
|
||||
}
|
||||
|
||||
// FileExtension is the extension the import pipeline treats as authoritative:
|
||||
// the content-sniffed override when set, otherwise the file name's extension.
|
||||
func (s driveImportSpec) FileExtension() string {
|
||||
if s.EffectiveExt != "" {
|
||||
return s.EffectiveExt
|
||||
}
|
||||
return s.rawExtension()
|
||||
}
|
||||
|
||||
// SourceFileName is the name used when staging the upload media. When content
|
||||
// sniffing corrected the extension, the staged name must carry the corrected
|
||||
// suffix too: the import backend cross-checks the media file name's extension
|
||||
// against the file_extension in the import task and rejects a mismatch with
|
||||
// "import file extension not match" (code 1069910).
|
||||
func (s driveImportSpec) SourceFileName() string {
|
||||
base := filepath.Base(s.FilePath)
|
||||
if s.EffectiveExt != "" && s.EffectiveExt != s.rawExtension() {
|
||||
base = strings.TrimSuffix(base, filepath.Ext(base)) + "." + s.EffectiveExt
|
||||
}
|
||||
return base
|
||||
return filepath.Base(s.FilePath)
|
||||
}
|
||||
|
||||
func (s driveImportSpec) TargetFileName() string {
|
||||
@@ -122,20 +97,18 @@ func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{}
|
||||
|
||||
// uploadMediaForImport uploads the source file to the temporary import media
|
||||
// endpoint and returns the file token consumed by import_tasks.
|
||||
func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, spec driveImportSpec) (string, error) {
|
||||
filePath := spec.FilePath
|
||||
fileName := spec.SourceFileName()
|
||||
func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, docType string) (string, error) {
|
||||
importInfo, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil {
|
||||
return "", driveInputStatError(err)
|
||||
}
|
||||
|
||||
fileSize := importInfo.Size()
|
||||
if err = validateDriveImportFileSize(spec.FileExtension(), spec.DocType, fileSize); err != nil {
|
||||
if err = validateDriveImportFileSize(filePath, docType, fileSize); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
extra, err := buildImportMediaExtra(spec.FileExtension(), spec.DocType)
|
||||
extra, err := buildImportMediaExtra(filePath, docType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -166,12 +139,12 @@ func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, s
|
||||
})
|
||||
}
|
||||
|
||||
func buildImportMediaExtra(ext, docType string) (string, error) {
|
||||
func buildImportMediaExtra(filePath, docType string) (string, error) {
|
||||
// The import media endpoint uses extra to decide both the target native type
|
||||
// and how to interpret the uploaded source file.
|
||||
extraBytes, err := json.Marshal(map[string]string{
|
||||
"obj_type": docType,
|
||||
"file_extension": ext,
|
||||
"file_extension": strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), "."),
|
||||
})
|
||||
if err != nil {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "build upload extra failed: %v", err).WithCause(err)
|
||||
@@ -179,10 +152,10 @@ func buildImportMediaExtra(ext, docType string) (string, error) {
|
||||
return string(extraBytes), nil
|
||||
}
|
||||
|
||||
func driveImportFileSizeLimit(ext, docType string) (int64, bool) {
|
||||
func driveImportFileSizeLimit(filePath, docType string) (int64, bool) {
|
||||
// Keep the limit mapping local to import flows so we do not widen behavior
|
||||
// changes beyond drive +import.
|
||||
switch ext {
|
||||
switch strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") {
|
||||
case "docx", "doc":
|
||||
return driveImport600MBFileSizeLimit, true
|
||||
case "pptx":
|
||||
@@ -201,12 +174,13 @@ func driveImportFileSizeLimit(ext, docType string) (int64, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func validateDriveImportFileSize(ext, docType string, fileSize int64) error {
|
||||
limit, ok := driveImportFileSizeLimit(ext, docType)
|
||||
func validateDriveImportFileSize(filePath, docType string, fileSize int64) error {
|
||||
limit, ok := driveImportFileSizeLimit(filePath, docType)
|
||||
if !ok || fileSize <= limit {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".")
|
||||
if ext == "csv" {
|
||||
// CSV is the only source format whose limit depends on the target type.
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
|
||||
@@ -94,61 +94,61 @@ func TestValidateDriveImportFileSize(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ext string
|
||||
filePath string
|
||||
docType string
|
||||
fileSize int64
|
||||
wantText string
|
||||
}{
|
||||
{
|
||||
name: "docx exceeds 600mb limit",
|
||||
ext: "docx",
|
||||
filePath: "./report.docx",
|
||||
docType: "docx",
|
||||
fileSize: driveImport600MBFileSizeLimit + 1,
|
||||
wantText: "exceeds 600.0 MB import limit for .docx",
|
||||
},
|
||||
{
|
||||
name: "csv sheet exceeds 20mb limit",
|
||||
ext: "csv",
|
||||
filePath: "./data.csv",
|
||||
docType: "sheet",
|
||||
fileSize: driveImport20MBFileSizeLimit + 1,
|
||||
wantText: "exceeds 20.0 MB import limit for .csv when importing as sheet",
|
||||
},
|
||||
{
|
||||
name: "csv bitable exceeds 100mb limit",
|
||||
ext: "csv",
|
||||
filePath: "./data.csv",
|
||||
docType: "bitable",
|
||||
fileSize: driveImport100MBFileSizeLimit + 1,
|
||||
wantText: "exceeds 100.0 MB import limit for .csv when importing as bitable",
|
||||
},
|
||||
{
|
||||
name: "xlsx within 800mb limit",
|
||||
ext: "xlsx",
|
||||
filePath: "./data.xlsx",
|
||||
docType: "sheet",
|
||||
fileSize: driveImport800MBFileSizeLimit,
|
||||
},
|
||||
{
|
||||
name: "pptx exceeds 500mb limit",
|
||||
ext: "pptx",
|
||||
filePath: "./deck.pptx",
|
||||
docType: "slides",
|
||||
fileSize: driveImport500MBFileSizeLimit + 1,
|
||||
wantText: "exceeds 500.0 MB import limit for .pptx",
|
||||
},
|
||||
{
|
||||
name: "pptx within 500mb limit",
|
||||
ext: "pptx",
|
||||
filePath: "./deck.pptx",
|
||||
docType: "slides",
|
||||
fileSize: driveImport500MBFileSizeLimit,
|
||||
},
|
||||
{
|
||||
name: "base exceeds 20mb limit",
|
||||
ext: "base",
|
||||
filePath: "./snapshot.base",
|
||||
docType: "bitable",
|
||||
fileSize: driveImport20MBFileSizeLimit + 1,
|
||||
wantText: "exceeds 20.0 MB import limit for .base",
|
||||
},
|
||||
{
|
||||
name: "base within 20mb limit",
|
||||
ext: "base",
|
||||
filePath: "./snapshot.base",
|
||||
docType: "bitable",
|
||||
fileSize: driveImport20MBFileSizeLimit,
|
||||
},
|
||||
@@ -158,7 +158,7 @@ func TestValidateDriveImportFileSize(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveImportFileSize(tt.ext, tt.docType, tt.fileSize)
|
||||
err := validateDriveImportFileSize(tt.filePath, tt.docType, tt.fileSize)
|
||||
if tt.wantText == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
driveListCommentsDefaultPageSize = 50
|
||||
driveListCommentsDefaultSolvedStatus = "false"
|
||||
driveListCommentsDefaultScope = "all"
|
||||
)
|
||||
|
||||
var driveListCommentsTypes = []string{"doc", "docx", "sheet", "file", "slides", "bitable", "base", "wiki"}
|
||||
|
||||
type driveListCommentsRef struct {
|
||||
Token string
|
||||
Type string
|
||||
SourceFlag string
|
||||
}
|
||||
|
||||
type driveListCommentsTarget struct {
|
||||
FileToken string
|
||||
FileType string
|
||||
}
|
||||
|
||||
type driveListCommentsSpec struct {
|
||||
Ref driveListCommentsRef
|
||||
PageSize int
|
||||
PageToken string
|
||||
SolvedStatus string
|
||||
CommentScope string
|
||||
NeedReaction bool
|
||||
NeedRelation bool
|
||||
}
|
||||
|
||||
// DriveListComments lists document comments through the Drive comments API,
|
||||
// while accepting Wiki URLs/tokens and resolving them to the underlying object.
|
||||
var DriveListComments = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+list-comments",
|
||||
Description: "List comments for doc/docx/sheet/file/slides/base(bitable), with URL parsing and Wiki token unwrapping",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:document.comment:read"},
|
||||
ConditionalScopes: []string{"wiki:node:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "recommended: Lark/Feishu document URL (doc/docx/sheet/file/slides/base/bitable/wiki); Wiki URLs are unwrapped automatically"},
|
||||
{Name: "token", Desc: "document token, Wiki token, or document URL; bare tokens require --type"},
|
||||
{Name: "type", Desc: "document type for bare --token; optional for URLs but must match the URL type when provided", Enum: driveListCommentsTypes},
|
||||
{Name: "solved-status", Default: driveListCommentsDefaultSolvedStatus, Desc: "comment solved filter: false=unresolved, true=solved, all=all comments", Enum: []string{"false", "true", "all"}},
|
||||
{Name: "comment-scope", Default: driveListCommentsDefaultScope, Desc: "comment scope filter: all=all comments, whole=full-document comments, partial=local/selection comments", Enum: []string{"all", "whole", "partial"}},
|
||||
{Name: "need-reaction", Type: "bool", Desc: "include reaction data on comment cards"},
|
||||
{Name: "need-relation", Type: "bool", Desc: "include docx comment relation data; ignored for non-docx targets"},
|
||||
{Name: "page-size", Type: "int", Default: "50", Desc: "page size, 1-100"},
|
||||
{Name: "page-token", Desc: "pagination token from previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveListCommentsSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateDriveListCommentsSpec(spec)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveListCommentsSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if err := validateDriveListCommentsSpec(spec); err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildDriveListCommentsDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveListCommentsSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDriveListCommentsSpec(spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target, err := resolveDriveListCommentsTarget(ctx, runtime, spec.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := buildDriveListCommentsParams(spec, target.FileType)
|
||||
path := fmt.Sprintf("/open-apis/drive/v1/files/%s/comments", validate.EncodePathSegment(target.FileToken))
|
||||
|
||||
data, err := runtime.CallAPITyped("GET", path, params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(buildDriveListCommentsOutput(target, data), nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func readDriveListCommentsSpec(runtime *common.RuntimeContext) (driveListCommentsSpec, error) {
|
||||
ref, err := resolveDriveListCommentsInput(runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveListCommentsSpec{}, err
|
||||
}
|
||||
return driveListCommentsSpec{
|
||||
Ref: ref,
|
||||
PageSize: runtime.Int("page-size"),
|
||||
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||
SolvedStatus: strings.TrimSpace(runtime.Str("solved-status")),
|
||||
CommentScope: strings.TrimSpace(runtime.Str("comment-scope")),
|
||||
NeedReaction: runtime.Bool("need-reaction"),
|
||||
NeedRelation: runtime.Bool("need-relation"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateDriveListCommentsSpec(spec driveListCommentsSpec) error {
|
||||
if spec.PageSize < 1 || spec.PageSize > 100 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be between 1 and 100").WithParam("--page-size")
|
||||
}
|
||||
if _, ok := driveListCommentsSolvedStatusParam(spec.SolvedStatus); !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --solved-status %q; allowed: false, true, all", spec.SolvedStatus).WithParam("--solved-status")
|
||||
}
|
||||
if _, ok := driveListCommentsScopeParam(spec.CommentScope); !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --comment-scope %q; allowed: all, whole, partial", spec.CommentScope).WithParam("--comment-scope")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveDriveListCommentsInput(urlInput, tokenInput, explicitType string) (driveListCommentsRef, error) {
|
||||
urlInput = strings.TrimSpace(urlInput)
|
||||
tokenInput = strings.TrimSpace(tokenInput)
|
||||
if urlInput != "" && tokenInput != "" {
|
||||
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url and --token are mutually exclusive; pass one input only").WithParam("--url")
|
||||
}
|
||||
if urlInput == "" && tokenInput == "" {
|
||||
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --token").WithParam("--url")
|
||||
}
|
||||
|
||||
raw := urlInput
|
||||
sourceFlag := "--url"
|
||||
if raw == "" {
|
||||
raw = tokenInput
|
||||
sourceFlag = "--token"
|
||||
}
|
||||
inputType := normalizeDriveListCommentsType(strings.ToLower(strings.TrimSpace(explicitType)))
|
||||
|
||||
if ref, ok := common.ParseResourceURL(raw); ok {
|
||||
refType := normalizeDriveListCommentsType(ref.Type)
|
||||
if inputType != "" && inputType != refType {
|
||||
return driveListCommentsRef{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
inputType,
|
||||
refType,
|
||||
).WithParam("--type")
|
||||
}
|
||||
if !driveListCommentsTypeSupported(refType) {
|
||||
return driveListCommentsRef{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported %s resource type %q; comments list supports doc, docx, sheet, file, slides, bitable/base, and wiki",
|
||||
sourceFlag,
|
||||
refType,
|
||||
).WithParam(sourceFlag)
|
||||
}
|
||||
return driveListCommentsRef{Token: ref.Token, Type: refType, SourceFlag: sourceFlag}, nil
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported %s URL %q: use a recognized Lark document URL or pass a bare token with --type", sourceFlag, raw).WithParam(sourceFlag)
|
||||
}
|
||||
if strings.ContainsAny(raw, "/?#") {
|
||||
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bare token %q: remove path/query fragments or pass a recognized Lark document URL", raw).WithParam(sourceFlag)
|
||||
}
|
||||
if inputType == "" {
|
||||
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when %s is a bare token (allowed: doc, docx, sheet, file, slides, bitable, base, wiki)", sourceFlag).WithParam("--type")
|
||||
}
|
||||
if !driveListCommentsTypeSupported(inputType) {
|
||||
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --type %q; allowed: doc, docx, sheet, file, slides, bitable, base, wiki", inputType).WithParam("--type")
|
||||
}
|
||||
return driveListCommentsRef{Token: raw, Type: inputType, SourceFlag: sourceFlag}, nil
|
||||
}
|
||||
|
||||
func normalizeDriveListCommentsType(docType string) string {
|
||||
switch strings.TrimSpace(docType) {
|
||||
case "base":
|
||||
return "bitable"
|
||||
default:
|
||||
return strings.TrimSpace(docType)
|
||||
}
|
||||
}
|
||||
|
||||
func driveListCommentsTypeSupported(docType string) bool {
|
||||
switch normalizeDriveListCommentsType(docType) {
|
||||
case "doc", "docx", "sheet", "file", "slides", "bitable", "wiki":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func resolveDriveListCommentsTarget(ctx context.Context, runtime *common.RuntimeContext, ref driveListCommentsRef) (driveListCommentsTarget, error) {
|
||||
if ref.Type != "wiki" {
|
||||
return driveListCommentsTarget{FileToken: ref.Token, FileType: ref.Type}, nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(ref.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": ref.Token},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return driveListCommentsTarget{}, err
|
||||
}
|
||||
|
||||
node := common.GetMap(data, "node")
|
||||
objType := normalizeDriveListCommentsType(common.GetString(node, "obj_type"))
|
||||
objToken := common.GetString(node, "obj_token")
|
||||
if objType == "" || objToken == "" {
|
||||
return driveListCommentsTarget{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
|
||||
}
|
||||
if !driveListCommentsTypeSupported(objType) || objType == "wiki" {
|
||||
return driveListCommentsTarget{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"wiki resolved to %q, but comments list only supports doc, docx, sheet, file, slides, and bitable",
|
||||
objType,
|
||||
).WithParam(ref.SourceFlag)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
return driveListCommentsTarget{FileToken: objToken, FileType: objType}, nil
|
||||
}
|
||||
|
||||
func buildDriveListCommentsDryRun(spec driveListCommentsSpec) *common.DryRunAPI {
|
||||
if spec.Ref.Type == "wiki" {
|
||||
params := buildDriveListCommentsParams(spec, "<obj_type from step 1>")
|
||||
if spec.NeedRelation {
|
||||
params["need_relation"] = "<sent only when obj_type is docx>"
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: resolve wiki -> list comments").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to underlying document").
|
||||
Params(map[string]interface{}{"token": spec.Ref.Token}).
|
||||
GET("/open-apis/drive/v1/files/<obj_token from step 1>/comments").
|
||||
Desc("[2] List comments on resolved document").
|
||||
Params(params)
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc("1-step request: list comments").
|
||||
GET("/open-apis/drive/v1/files/:file_token/comments").
|
||||
Params(buildDriveListCommentsParams(spec, spec.Ref.Type)).
|
||||
Set("file_token", spec.Ref.Token)
|
||||
}
|
||||
|
||||
func buildDriveListCommentsParams(spec driveListCommentsSpec, fileType string) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"file_type": fileType,
|
||||
"page_size": spec.PageSize,
|
||||
}
|
||||
if spec.PageToken != "" {
|
||||
params["page_token"] = spec.PageToken
|
||||
}
|
||||
if value, ok := driveListCommentsSolvedStatusParam(spec.SolvedStatus); ok && value != nil {
|
||||
params["is_solved"] = *value
|
||||
}
|
||||
if value, ok := driveListCommentsScopeParam(spec.CommentScope); ok && value != nil {
|
||||
params["is_whole"] = *value
|
||||
}
|
||||
if spec.NeedReaction {
|
||||
params["need_reaction"] = true
|
||||
}
|
||||
if spec.NeedRelation && fileType == "docx" {
|
||||
params["need_relation"] = true
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func driveListCommentsSolvedStatusParam(status string) (*bool, bool) {
|
||||
switch strings.TrimSpace(status) {
|
||||
case "false", "":
|
||||
value := false
|
||||
return &value, true
|
||||
case "true":
|
||||
value := true
|
||||
return &value, true
|
||||
case "all":
|
||||
return nil, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func driveListCommentsScopeParam(scope string) (*bool, bool) {
|
||||
switch strings.TrimSpace(scope) {
|
||||
case "all", "":
|
||||
return nil, true
|
||||
case "whole":
|
||||
value := true
|
||||
return &value, true
|
||||
case "partial":
|
||||
value := false
|
||||
return &value, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func buildDriveListCommentsOutput(target driveListCommentsTarget, data map[string]interface{}) map[string]interface{} {
|
||||
items := common.GetSlice(data, "items")
|
||||
return map[string]interface{}{
|
||||
"file_token": target.FileToken,
|
||||
"file_type": target.FileType,
|
||||
"items": items,
|
||||
"has_more": common.GetBool(data, "has_more"),
|
||||
"page_token": common.GetString(data, "page_token"),
|
||||
"count": len(items),
|
||||
}
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestResolveDriveListCommentsInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
urlInput string
|
||||
rawInput string
|
||||
docType string
|
||||
wantResource string
|
||||
wantType string
|
||||
wantErr string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "url docx",
|
||||
urlInput: "https://example.larksuite.com/docx/docxResource?from=wiki",
|
||||
wantResource: "docxResource",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "token flag also accepts url",
|
||||
rawInput: "https://example.larksuite.com/base/bitableResource",
|
||||
wantResource: "bitableResource",
|
||||
wantType: "bitable",
|
||||
},
|
||||
{
|
||||
name: "bare wiki token",
|
||||
rawInput: "wikiResource",
|
||||
docType: "wiki",
|
||||
wantResource: "wikiResource",
|
||||
wantType: "wiki",
|
||||
},
|
||||
{
|
||||
name: "url and token mutually exclusive",
|
||||
urlInput: "https://example.larksuite.com/docx/docxResource",
|
||||
rawInput: "docxResource",
|
||||
wantErr: "mutually exclusive",
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "bare token needs type",
|
||||
rawInput: "docxResource",
|
||||
wantErr: "--type is required",
|
||||
wantParam: "--type",
|
||||
},
|
||||
{
|
||||
name: "type conflicts with url",
|
||||
urlInput: "https://example.larksuite.com/wiki/wikiResource",
|
||||
docType: "docx",
|
||||
wantErr: "conflicts",
|
||||
wantParam: "--type",
|
||||
},
|
||||
{
|
||||
name: "unsupported url type",
|
||||
urlInput: "https://example.larksuite.com/drive/folder/folderResource",
|
||||
wantErr: "unsupported",
|
||||
wantParam: "--url",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := resolveDriveListCommentsInput(tt.urlInput, tt.rawInput, tt.docType)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
assertDriveListCommentsValidationError(t, err, tt.wantParam)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Token != tt.wantResource || got.Type != tt.wantType {
|
||||
t.Fatalf("got (%q, %q), want (%q, %q)", got.Token, got.Type, tt.wantResource, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveListCommentsSpec(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
valid := driveListCommentsSpec{
|
||||
PageSize: 50,
|
||||
SolvedStatus: "false",
|
||||
CommentScope: "all",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*driveListCommentsSpec)
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "invalid page size",
|
||||
mutate: func(spec *driveListCommentsSpec) {
|
||||
spec.PageSize = 0
|
||||
},
|
||||
wantParam: "--page-size",
|
||||
},
|
||||
{
|
||||
name: "invalid solved status",
|
||||
mutate: func(spec *driveListCommentsSpec) {
|
||||
spec.SolvedStatus = "open"
|
||||
},
|
||||
wantParam: "--solved-status",
|
||||
},
|
||||
{
|
||||
name: "invalid comment scope",
|
||||
mutate: func(spec *driveListCommentsSpec) {
|
||||
spec.CommentScope = "inline"
|
||||
},
|
||||
wantParam: "--comment-scope",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec := valid
|
||||
tt.mutate(&spec)
|
||||
err := validateDriveListCommentsSpec(spec)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
assertDriveListCommentsValidationError(t, err, tt.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertDriveListCommentsValidationError(t *testing.T, err error, wantParam string) {
|
||||
t.Helper()
|
||||
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category = %q, want %q", validationErr.Category, errs.CategoryValidation)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if validationErr.Param != wantParam {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, wantParam)
|
||||
}
|
||||
if cause := errors.Unwrap(err); cause != nil {
|
||||
t.Fatalf("unexpected cause on direct validation error: %v", cause)
|
||||
}
|
||||
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected errs.ProblemOf to recognize typed error: %v", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("problem category = %q, want %q", problem.Category, errs.CategoryValidation)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDriveListCommentsParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
defaultSpec := driveListCommentsSpec{
|
||||
PageSize: 50,
|
||||
SolvedStatus: "false",
|
||||
CommentScope: "all",
|
||||
}
|
||||
defaultParams := buildDriveListCommentsParams(defaultSpec, "docx")
|
||||
if got := defaultParams["is_solved"]; got != false {
|
||||
t.Fatalf("default is_solved = %#v, want false", got)
|
||||
}
|
||||
if _, ok := defaultParams["is_whole"]; ok {
|
||||
t.Fatalf("default params should omit is_whole: %#v", defaultParams)
|
||||
}
|
||||
if _, ok := defaultParams["user_id_type"]; ok {
|
||||
t.Fatalf("default params should omit user_id_type: %#v", defaultParams)
|
||||
}
|
||||
|
||||
allPartialSpec := driveListCommentsSpec{
|
||||
PageSize: 100,
|
||||
PageToken: "next",
|
||||
SolvedStatus: "all",
|
||||
CommentScope: "partial",
|
||||
NeedReaction: true,
|
||||
NeedRelation: true,
|
||||
}
|
||||
allPartialParams := buildDriveListCommentsParams(allPartialSpec, "docx")
|
||||
if _, ok := allPartialParams["is_solved"]; ok {
|
||||
t.Fatalf("solved-status all should omit is_solved: %#v", allPartialParams)
|
||||
}
|
||||
if got := allPartialParams["is_whole"]; got != false {
|
||||
t.Fatalf("comment-scope partial is_whole = %#v, want false", got)
|
||||
}
|
||||
if got := allPartialParams["need_reaction"]; got != true {
|
||||
t.Fatalf("need_reaction = %#v, want true", got)
|
||||
}
|
||||
if got := allPartialParams["need_relation"]; got != true {
|
||||
t.Fatalf("need_relation = %#v, want true for docx", got)
|
||||
}
|
||||
if got := allPartialParams["page_token"]; got != "next" {
|
||||
t.Fatalf("page_token = %#v, want next", got)
|
||||
}
|
||||
|
||||
sheetParams := buildDriveListCommentsParams(allPartialSpec, "sheet")
|
||||
if _, ok := sheetParams["need_relation"]; ok {
|
||||
t.Fatalf("need_relation should be ignored for non-docx: %#v", sheetParams)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListCommentsExecuteDocx(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments",
|
||||
OnMatch: func(req *http.Request) {
|
||||
query := req.URL.Query()
|
||||
if got := query.Get("file_type"); got != "docx" {
|
||||
t.Errorf("file_type = %q, want docx", got)
|
||||
}
|
||||
if got := query.Get("is_solved"); got != "false" {
|
||||
t.Errorf("is_solved = %q, want false", got)
|
||||
}
|
||||
if got := query.Get("is_whole"); got != "" {
|
||||
t.Errorf("is_whole = %q, want omitted", got)
|
||||
}
|
||||
if got := query.Get("user_id_type"); got != "" {
|
||||
t.Errorf("user_id_type = %q, want omitted", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []map[string]interface{}{
|
||||
{"comment_id": "comment_1", "is_solved": false},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "next",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveListComments, []string{
|
||||
"+list-comments",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxResource" {
|
||||
t.Fatalf("file_token = %q, want docxResource", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "docx" {
|
||||
t.Fatalf("file_type = %q, want docx", got)
|
||||
}
|
||||
if got := data["count"]; got != float64(1) {
|
||||
t.Fatalf("count = %#v, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListCommentsExecuteWikiResolvesToDocx(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("token"); got != "wikiResource" {
|
||||
t.Errorf("wiki token = %q, want wikiResource", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxFromWikiResource",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/docxFromWikiResource/comments",
|
||||
OnMatch: func(req *http.Request) {
|
||||
query := req.URL.Query()
|
||||
if got := query.Get("is_solved"); got != "" {
|
||||
t.Errorf("is_solved = %q, want omitted for solved-status all", got)
|
||||
}
|
||||
if got := query.Get("is_whole"); got != "true" {
|
||||
t.Errorf("is_whole = %q, want true", got)
|
||||
}
|
||||
if got := query.Get("need_relation"); got != "true" {
|
||||
t.Errorf("need_relation = %q, want true for resolved docx", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []map[string]interface{}{},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveListComments, []string{
|
||||
"+list-comments",
|
||||
"--token", "wikiResource",
|
||||
"--type", "wiki",
|
||||
"--solved-status", "all",
|
||||
"--comment-scope", "whole",
|
||||
"--need-relation",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxFromWikiResource" {
|
||||
t.Fatalf("file_token = %q, want docxFromWikiResource", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "docx" {
|
||||
t.Fatalf("file_type = %q, want docx", got)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ func Shortcuts() []common.Shortcut {
|
||||
DrivePreview,
|
||||
DriveCover,
|
||||
DriveAddComment,
|
||||
DriveListComments,
|
||||
DriveExport,
|
||||
DriveExportDownload,
|
||||
DriveImport,
|
||||
|
||||
@@ -20,15 +20,14 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+download",
|
||||
"+preview",
|
||||
"+cover",
|
||||
"+add-comment",
|
||||
"+list-comments",
|
||||
"+export",
|
||||
"+export-download",
|
||||
"+import",
|
||||
"+version-history",
|
||||
"+version-get",
|
||||
"+version-revert",
|
||||
"+version-delete",
|
||||
"+add-comment",
|
||||
"+export",
|
||||
"+export-download",
|
||||
"+import",
|
||||
"+move",
|
||||
"+delete",
|
||||
"+status",
|
||||
|
||||
@@ -34,53 +34,6 @@ func extractUserIDs(users []interface{}) []string {
|
||||
return ids
|
||||
}
|
||||
|
||||
// stringField safely extracts a string value from a map.
|
||||
func stringField(m map[string]interface{}, key string) string {
|
||||
v, _ := m[key].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// mentionOpenID extracts open_id from a mention id field (nested object or plain string).
|
||||
func mentionOpenID(raw interface{}) string {
|
||||
switch v := raw.(type) {
|
||||
case map[string]interface{}:
|
||||
openID, _ := v["open_id"].(string)
|
||||
return openID
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// compactMentions converts the raw mentions array into a compact form with key, id, name.
|
||||
func compactMentions(mentions []interface{}) []map[string]interface{} {
|
||||
if len(mentions) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]interface{}, 0, len(mentions))
|
||||
for _, raw := range mentions {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
m := map[string]interface{}{}
|
||||
if k := stringField(item, "key"); k != "" {
|
||||
m["key"] = k
|
||||
}
|
||||
if id := mentionOpenID(item["id"]); id != "" {
|
||||
m["id"] = id
|
||||
}
|
||||
if n := stringField(item, "name"); n != "" {
|
||||
m["name"] = n
|
||||
}
|
||||
if len(m) > 0 {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// compactBase builds the common compact output fields shared by all IM event processors.
|
||||
// Every compact output includes: type (event_type), event_id, and timestamp (header create_time).
|
||||
func compactBase(raw *RawEvent) map[string]interface{} {
|
||||
|
||||
@@ -16,13 +16,9 @@ import (
|
||||
// ImMessageProcessor handles im.message.receive_v1 events.
|
||||
//
|
||||
// Compact output fields:
|
||||
// - type, event_id, timestamp
|
||||
// - id, message_id, create_time, update_time
|
||||
// - chat_id, chat_type, message_type
|
||||
// - sender_id, sender_type
|
||||
// - root_id, thread_id, reply_to
|
||||
// - content: human-readable text converted via convertlib
|
||||
// - mentions: compact mentions array with key, id, name
|
||||
// - type, id, message_id, create_time, timestamp
|
||||
// - chat_id, chat_type, message_type, sender_id
|
||||
// - content: human-readable text converted via convertlib (supports text, post, image, file, card, etc.)
|
||||
type ImMessageProcessor struct{}
|
||||
|
||||
func (p *ImMessageProcessor) EventType() string { return "im.message.receive_v1" }
|
||||
@@ -36,20 +32,15 @@ func (p *ImMessageProcessor) Transform(_ context.Context, raw *RawEvent, mode Tr
|
||||
var ev struct {
|
||||
Message struct {
|
||||
MessageID string `json:"message_id"`
|
||||
RootID string `json:"root_id"`
|
||||
ParentID string `json:"parent_id"`
|
||||
ThreadID string `json:"thread_id"`
|
||||
ChatID string `json:"chat_id"`
|
||||
ChatType string `json:"chat_type"`
|
||||
MessageType string `json:"message_type"`
|
||||
Content string `json:"content"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Mentions []interface{} `json:"mentions"`
|
||||
} `json:"message"`
|
||||
Sender struct {
|
||||
SenderType string `json:"sender_type"`
|
||||
SenderID struct {
|
||||
SenderID struct {
|
||||
OpenID string `json:"open_id"`
|
||||
} `json:"sender_id"`
|
||||
} `json:"sender"`
|
||||
@@ -76,9 +67,6 @@ func (p *ImMessageProcessor) Transform(_ context.Context, raw *RawEvent, mode Tr
|
||||
out := map[string]interface{}{
|
||||
"type": raw.Header.EventType,
|
||||
}
|
||||
if raw.Header.EventID != "" {
|
||||
out["event_id"] = raw.Header.EventID
|
||||
}
|
||||
if ev.Message.MessageID != "" {
|
||||
out["id"] = ev.Message.MessageID
|
||||
out["message_id"] = ev.Message.MessageID
|
||||
@@ -92,9 +80,6 @@ func (p *ImMessageProcessor) Transform(_ context.Context, raw *RawEvent, mode Tr
|
||||
} else if ev.Message.CreateTime != "" {
|
||||
out["timestamp"] = ev.Message.CreateTime
|
||||
}
|
||||
if ev.Message.UpdateTime != "" && ev.Message.UpdateTime != ev.Message.CreateTime {
|
||||
out["update_time"] = ev.Message.UpdateTime
|
||||
}
|
||||
if ev.Message.ChatID != "" {
|
||||
out["chat_id"] = ev.Message.ChatID
|
||||
}
|
||||
@@ -107,24 +92,9 @@ func (p *ImMessageProcessor) Transform(_ context.Context, raw *RawEvent, mode Tr
|
||||
if ev.Sender.SenderID.OpenID != "" {
|
||||
out["sender_id"] = ev.Sender.SenderID.OpenID
|
||||
}
|
||||
if ev.Sender.SenderType != "" {
|
||||
out["sender_type"] = ev.Sender.SenderType
|
||||
}
|
||||
if ev.Message.RootID != "" {
|
||||
out["root_id"] = ev.Message.RootID
|
||||
}
|
||||
if ev.Message.ThreadID != "" {
|
||||
out["thread_id"] = ev.Message.ThreadID
|
||||
}
|
||||
if ev.Message.ParentID != "" {
|
||||
out["reply_to"] = ev.Message.ParentID
|
||||
}
|
||||
if content != "" {
|
||||
out["content"] = content
|
||||
}
|
||||
if mentions := compactMentions(ev.Message.Mentions); len(mentions) > 0 {
|
||||
out["mentions"] = mentions
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -792,6 +792,7 @@ func TestImMessageProcessor_CompactInteractiveFallsBackToRaw(t *testing.T) {
|
||||
t.Fatalf("stderr hint = %q, want interactive fallback message", string(hint))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericProcessor_CompactUnmarshalError(t *testing.T) {
|
||||
p := &GenericProcessor{}
|
||||
raw := makeRawEvent("some.type", `not valid json`)
|
||||
|
||||
@@ -504,84 +504,6 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validateIdempotencyKey empty string passes", func(t *testing.T) {
|
||||
if err := validateIdempotencyKey(""); err != nil {
|
||||
t.Fatalf("validateIdempotencyKey() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validateIdempotencyKey 50 chars passes", func(t *testing.T) {
|
||||
if err := validateIdempotencyKey(strings.Repeat("a", 50)); err != nil {
|
||||
t.Fatalf("validateIdempotencyKey() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validateIdempotencyKey 51 chars fails", func(t *testing.T) {
|
||||
err := validateIdempotencyKey(strings.Repeat("a", 51))
|
||||
if err == nil || !strings.Contains(err.Error(), "--idempotency-key exceeds the maximum of 50 characters") {
|
||||
t.Fatalf("validateIdempotencyKey() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validateIdempotencyKey 50 Chinese chars passes", func(t *testing.T) {
|
||||
if err := validateIdempotencyKey(strings.Repeat("中", 50)); err != nil {
|
||||
t.Fatalf("validateIdempotencyKey() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validateIdempotencyKey 51 Chinese chars fails", func(t *testing.T) {
|
||||
err := validateIdempotencyKey(strings.Repeat("中", 51))
|
||||
if err == nil || !strings.Contains(err.Error(), "--idempotency-key exceeds the maximum of 50 characters") {
|
||||
t.Fatalf("validateIdempotencyKey() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesSend idempotency key too long", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"chat-id": "oc_123",
|
||||
"text": "hello",
|
||||
"idempotency-key": strings.Repeat("a", 51),
|
||||
}, nil)
|
||||
err := ImMessagesSend.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "--idempotency-key exceeds the maximum of 50 characters") {
|
||||
t.Fatalf("ImMessagesSend.Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesSend idempotency key valid", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"chat-id": "oc_123",
|
||||
"text": "hello",
|
||||
"idempotency-key": "my-key-001",
|
||||
}, nil)
|
||||
if err := ImMessagesSend.Validate(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("ImMessagesSend.Validate() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesReply idempotency key too long", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"message-id": "om_123",
|
||||
"text": "hello",
|
||||
"idempotency-key": strings.Repeat("b", 51),
|
||||
}, nil)
|
||||
err := ImMessagesReply.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "--idempotency-key exceeds the maximum of 50 characters") {
|
||||
t.Fatalf("ImMessagesReply.Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesReply idempotency key valid", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"message-id": "om_123",
|
||||
"text": "hello",
|
||||
"idempotency-key": "reply-key-001",
|
||||
}, nil)
|
||||
if err := ImMessagesReply.Validate(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("ImMessagesReply.Validate() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesReply invalid message id", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"message-id": "bad_id",
|
||||
@@ -957,7 +879,7 @@ func TestShortcutDryRunShapes(t *testing.T) {
|
||||
"message-ids": "om_1,om_2",
|
||||
}, nil)
|
||||
got := mustMarshalDryRun(t, ImMessagesMGet.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"/open-apis/im/v1/messages/mget?card_msg_content_type=raw_card_content\u0026with_sender_name=true\u0026message_ids=om_1\u0026message_ids=om_2"`) {
|
||||
if !strings.Contains(got, `"/open-apis/im/v1/messages/mget?card_msg_content_type=raw_card_content\u0026message_ids=om_1\u0026message_ids=om_2"`) {
|
||||
t.Fatalf("ImMessagesMGet.DryRun() = %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,11 +6,14 @@ package convertlib
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// ParseJSONObject parses a raw JSON string into a map.
|
||||
@@ -69,66 +72,161 @@ func formatTimestamp(ts string) string {
|
||||
return time.Unix(n, 0).Local().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// pickSenderName returns the server-provided display name from a message sender:
|
||||
// the plain `sender_name` (the server's default-locale name). Callers wanting a
|
||||
// specific locale should read the full `sender_i18n_names` map, which is preserved
|
||||
// on the sender. Returns "" when the server supplied no name, so the caller can
|
||||
// fall back to the raw id.
|
||||
func pickSenderName(sender map[string]interface{}) string {
|
||||
name, _ := sender["sender_name"].(string)
|
||||
return name
|
||||
}
|
||||
|
||||
// ResolveSenderNames harvests the server-provided sender_name for each message
|
||||
// sender into the shared cache (keyed by sender id), so a sender appearing across
|
||||
// the render tree (e.g. merge_forward sub-items, thread replies) resolves once.
|
||||
// The message read API is the single source of truth for names (opt in via
|
||||
// with_sender_name=true); there is NO contact/mention fallback — a sender the
|
||||
// server did not name resolves to its id downstream. Pass an empty map if none exists.
|
||||
func ResolveSenderNames(_ *common.RuntimeContext, messages []map[string]interface{}, cache map[string]string) map[string]string {
|
||||
// ResolveSenderNames batch-resolves sender open_ids to display names.
|
||||
// The cache map is used to share already-resolved IDs across calls; newly resolved
|
||||
// names are written back into it. Pass an empty map if no prior cache exists.
|
||||
//
|
||||
// Step 1: extract names from message mentions (free, no API call).
|
||||
// Step 2: for remaining unresolved IDs, call contact batch API (requires contact:user.base:readonly).
|
||||
// Silently returns partial results on API error.
|
||||
//
|
||||
// [#22] Changed from variadic `cache ...map[string]string` to a required parameter.
|
||||
// The variadic form was misleading: every caller passed exactly one map, and the function
|
||||
// body both modified it and returned it, making the dual semantics confusing.
|
||||
func ResolveSenderNames(runtime *common.RuntimeContext, messages []map[string]interface{}, cache map[string]string) map[string]string {
|
||||
nameMap := cache
|
||||
if nameMap == nil {
|
||||
nameMap = make(map[string]string)
|
||||
}
|
||||
|
||||
// Step 1: extract names from mentions (free)
|
||||
for _, msg := range messages {
|
||||
switch mentions := msg["mentions"].(type) {
|
||||
case []interface{}:
|
||||
for _, raw := range mentions {
|
||||
m, _ := raw.(map[string]interface{})
|
||||
id, _ := m["id"].(string)
|
||||
name, _ := m["name"].(string)
|
||||
if id != "" && name != "" && strings.HasPrefix(id, "ou_") {
|
||||
nameMap[id] = name
|
||||
}
|
||||
}
|
||||
case []map[string]interface{}:
|
||||
// Backward-compatible path for tests/callers that construct typed slices.
|
||||
for _, m := range mentions {
|
||||
id, _ := m["id"].(string)
|
||||
name, _ := m["name"].(string)
|
||||
if id != "" && name != "" && strings.HasPrefix(id, "ou_") {
|
||||
nameMap[id] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect sender IDs still missing a name
|
||||
seen := make(map[string]bool)
|
||||
var missingIDs []string
|
||||
for _, msg := range messages {
|
||||
sender, ok := msg["sender"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, _ := sender["id"].(string)
|
||||
if id == "" {
|
||||
senderType, _ := sender["sender_type"].(string)
|
||||
if senderType != "user" {
|
||||
continue
|
||||
}
|
||||
if name := pickSenderName(sender); name != "" {
|
||||
nameMap[id] = name
|
||||
id, _ := sender["id"].(string)
|
||||
if id == "" || !strings.HasPrefix(id, "ou_") || seen[id] || nameMap[id] != "" {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
missingIDs = append(missingIDs, id)
|
||||
}
|
||||
if len(missingIDs) == 0 {
|
||||
return nameMap
|
||||
}
|
||||
|
||||
// Step 2: batch resolve remaining via contact API.
|
||||
// Use basic_batch for user identity (lighter permission requirement),
|
||||
// full batch for bot identity.
|
||||
if runtime.As().IsBot() {
|
||||
batchResolveUsers(runtime, missingIDs, nameMap)
|
||||
} else {
|
||||
batchResolveByBasicContact(runtime, missingIDs, nameMap)
|
||||
}
|
||||
|
||||
return nameMap
|
||||
}
|
||||
|
||||
// AttachSenderNames enriches message sender objects with a single resolved display
|
||||
// name in `name`, taken from the server-provided sender_name (via the sender itself
|
||||
// or the shared cache). Senders the server did not name keep no `name` (id is
|
||||
// preserved for downstream id fallback) — there is no contact/mention lookup.
|
||||
//
|
||||
// The raw `sender_name` is stripped from the output because it exactly duplicates
|
||||
// `name`; `sender_i18n_names` (the full i18n set, all locales) and `open_bot_id`
|
||||
// are preserved for consumers that need a specific locale or the id alignment.
|
||||
// batchResolveByBasicContact resolves user names via POST /contact/v3/users/basic_batch.
|
||||
// This API has lighter permission requirements and works with user identity
|
||||
// even when the target user is not in the app's visible range.
|
||||
// Response uses "users" (not "items") and "user_id" (not "open_id").
|
||||
// The basic_batch endpoint caps user_ids at 10 per request.
|
||||
func batchResolveByBasicContact(runtime *common.RuntimeContext, missingIDs []string, nameMap map[string]string) {
|
||||
const batchSize = 10
|
||||
for i := 0; i < len(missingIDs); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(missingIDs) {
|
||||
end = len(missingIDs)
|
||||
}
|
||||
batch := missingIDs[i:end]
|
||||
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodPost,
|
||||
"/open-apis/contact/v3/users/basic_batch",
|
||||
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
|
||||
map[string]interface{}{"user_ids": batch},
|
||||
)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
users, _ := data["users"].([]interface{})
|
||||
for _, item := range users {
|
||||
user, _ := item.(map[string]interface{})
|
||||
userID, _ := user["user_id"].(string)
|
||||
name, _ := user["name"].(string)
|
||||
if userID != "" && name != "" {
|
||||
nameMap[userID] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func batchResolveUsers(runtime *common.RuntimeContext, missingIDs []string, nameMap map[string]string) {
|
||||
const batchSize = 50
|
||||
for i := 0; i < len(missingIDs); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(missingIDs) {
|
||||
end = len(missingIDs)
|
||||
}
|
||||
batch := missingIDs[i:end]
|
||||
|
||||
parts := []string{"user_id_type=open_id"}
|
||||
for _, uid := range batch {
|
||||
parts = append(parts, "user_ids="+url.QueryEscape(uid))
|
||||
}
|
||||
apiURL := "/open-apis/contact/v3/users/batch?" + strings.Join(parts, "&")
|
||||
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodGet, apiURL, nil, nil)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
items, _ := data["items"].([]interface{})
|
||||
for _, item := range items {
|
||||
user, _ := item.(map[string]interface{})
|
||||
openID, _ := user["open_id"].(string)
|
||||
name, _ := user["name"].(string)
|
||||
if openID != "" && name != "" {
|
||||
nameMap[openID] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AttachSenderNames enriches message sender objects with resolved display names.
|
||||
// Senders whose name could not be resolved are left unchanged (id is preserved).
|
||||
func AttachSenderNames(messages []map[string]interface{}, nameMap map[string]string) {
|
||||
for _, msg := range messages {
|
||||
sender, ok := msg["sender"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if name := pickSenderName(sender); name != "" {
|
||||
id, _ := sender["id"].(string)
|
||||
if name, ok := nameMap[id]; ok {
|
||||
sender["name"] = name
|
||||
} else if id, _ := sender["id"].(string); id != "" {
|
||||
if name, ok := nameMap[id]; ok {
|
||||
sender["name"] = name
|
||||
}
|
||||
}
|
||||
// sender_name exactly duplicates `name`; drop it. Keep sender_i18n_names + open_bot_id.
|
||||
delete(sender, "sender_name")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
package convertlib
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -126,172 +129,114 @@ func TestExtractPostBlocksText(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveSenderNames(t *testing.T) {
|
||||
// Server-provided sender_name is harvested into the cache for both user and bot;
|
||||
// senders the server did not name are absent (id fallback downstream). There is no
|
||||
// contact/mention lookup, so no API call is ever made.
|
||||
rt := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("no API call expected: %s", req.URL.String())
|
||||
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/open-apis/contact/v3/users/batch"):
|
||||
if got := req.URL.Query()["user_ids"]; !reflect.DeepEqual(got, []string{"ou_api", "ou_missing"}) {
|
||||
t.Fatalf("contact batch user_ids = %#v, want %#v", got, []string{"ou_api", "ou_missing"})
|
||||
}
|
||||
return convertlibJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"open_id": "ou_api", "name": "API User"},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
}))
|
||||
|
||||
messages := []map[string]interface{}{
|
||||
{"sender": map[string]interface{}{"sender_type": "user", "id": "ou_named", "sender_name": "Named User"}},
|
||||
{"sender": map[string]interface{}{"sender_type": "app", "id": "cli_bot", "sender_name": "Bot Alpha"}},
|
||||
{"sender": map[string]interface{}{"sender_type": "user", "id": "ou_unnamed"}},
|
||||
}
|
||||
|
||||
got := ResolveSenderNames(rt, messages, nil)
|
||||
if got["ou_named"] != "Named User" {
|
||||
t.Fatalf("named user = %#v, want %#v", got["ou_named"], "Named User")
|
||||
}
|
||||
if got["cli_bot"] != "Bot Alpha" {
|
||||
t.Fatalf("named bot = %#v, want %#v", got["cli_bot"], "Bot Alpha")
|
||||
}
|
||||
if _, has := got["ou_unnamed"]; has {
|
||||
t.Fatalf("unnamed sender must not be resolved (no contact fallback), got %#v", got["ou_unnamed"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveSenderNamesServerNameBeatsMention locks the priority: when a sender's id
|
||||
// also appears as a mention, the server-provided sender_name must win over the mention
|
||||
// name (which can be a remark/nickname), and no contact call is made.
|
||||
func TestResolveSenderNamesServerNameBeatsMention(t *testing.T) {
|
||||
rt := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("no contact call expected: %s", req.URL.String())
|
||||
}))
|
||||
messages := []map[string]interface{}{
|
||||
{
|
||||
"sender": map[string]interface{}{"sender_type": "user", "id": "ou_dual", "sender_name": "Server Name"},
|
||||
"sender": map[string]interface{}{"sender_type": "user", "id": "ou_mention"},
|
||||
"mentions": []interface{}{
|
||||
map[string]interface{}{"id": "ou_dual", "name": "Mention Remark"},
|
||||
map[string]interface{}{"id": "ou_mention", "name": "Mention User"},
|
||||
},
|
||||
},
|
||||
{"sender": map[string]interface{}{"sender_type": "user", "id": "ou_api"}},
|
||||
{"sender": map[string]interface{}{"sender_type": "user", "id": "ou_missing"}},
|
||||
{"sender": map[string]interface{}{"sender_type": "bot", "id": "cli_1"}},
|
||||
}
|
||||
got := ResolveSenderNames(rt, messages, nil)
|
||||
if got["ou_dual"] != "Server Name" {
|
||||
t.Fatalf("server sender_name must beat mention name: got %#v, want %#v", got["ou_dual"], "Server Name")
|
||||
|
||||
got := ResolveSenderNames(runtime, messages, nil)
|
||||
if got["ou_mention"] != "Mention User" {
|
||||
t.Fatalf("mention-resolved sender = %#v, want %#v", got["ou_mention"], "Mention User")
|
||||
}
|
||||
if got["ou_api"] != "API User" {
|
||||
t.Fatalf("api-resolved sender = %#v, want %#v", got["ou_api"], "API User")
|
||||
}
|
||||
if got["ou_missing"] != "" {
|
||||
t.Fatalf("missing sender = %#v, want empty", got["ou_missing"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatMessageItemSenderPassthrough covers AC5: the formatted message must
|
||||
// carry the sender object through verbatim — retaining open_bot_id and leaving
|
||||
// id / id_type unchanged after enrichment.
|
||||
func TestFormatMessageItemSenderPassthrough(t *testing.T) {
|
||||
func TestBatchResolveByBasicContactRespectsAPILimit(t *testing.T) {
|
||||
// basic_batch allows at most 10 user_ids per request. Given 25 missing IDs,
|
||||
// expect three requests with sizes 10 / 10 / 5.
|
||||
var batchSizes []int
|
||||
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return convertlibJSONResponse(200, map[string]interface{}{"code": 0, "data": map[string]interface{}{}}), nil
|
||||
}))
|
||||
m := map[string]interface{}{
|
||||
"message_id": "om_1",
|
||||
"msg_type": "text",
|
||||
"body": map[string]interface{}{"content": `{"text":"hi"}`},
|
||||
"sender": map[string]interface{}{
|
||||
"id": "cli_bot",
|
||||
"id_type": "app_id",
|
||||
"sender_type": "app",
|
||||
"sender_name": "Bot Alpha",
|
||||
"open_bot_id": "ou_bot",
|
||||
},
|
||||
}
|
||||
if !strings.Contains(req.URL.Path, "/open-apis/contact/v3/users/basic_batch") {
|
||||
return nil, fmt.Errorf("unexpected path: %s", req.URL.Path)
|
||||
}
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userIDs, _ := payload["user_ids"].([]interface{})
|
||||
if len(userIDs) > 10 {
|
||||
t.Fatalf("batch exceeded API limit: size = %d", len(userIDs))
|
||||
}
|
||||
batchSizes = append(batchSizes, len(userIDs))
|
||||
|
||||
out := FormatMessageItem(m, runtime)
|
||||
sender, ok := out["sender"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("formatted sender missing/mistyped: %#v", out["sender"])
|
||||
}
|
||||
if sender["open_bot_id"] != "ou_bot" {
|
||||
t.Fatalf("open_bot_id passthrough = %#v, want %#v", sender["open_bot_id"], "ou_bot")
|
||||
}
|
||||
if sender["id"] != "cli_bot" || sender["id_type"] != "app_id" {
|
||||
t.Fatalf("id/id_type must be unchanged, got id=%#v id_type=%#v", sender["id"], sender["id_type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickSenderName(t *testing.T) {
|
||||
// Uses the server-provided sender_name.
|
||||
if got := pickSenderName(map[string]interface{}{"sender_name": "Bot Alpha"}); got != "Bot Alpha" {
|
||||
t.Fatalf("pickSenderName(sender_name) = %q, want %q", got, "Bot Alpha")
|
||||
}
|
||||
// sender_i18n_names is NOT consulted for the display name (it stays in output for
|
||||
// consumers that want a specific locale); no sender_name -> empty (caller uses id).
|
||||
i18nOnly := map[string]interface{}{
|
||||
"sender_i18n_names": map[string]interface{}{"en_us": "Bot Beta", "zh_cn": "机器人乙", "ja_jp": "ロボット"},
|
||||
}
|
||||
if got := pickSenderName(i18nOnly); got != "" {
|
||||
t.Fatalf("pickSenderName(i18n only, no sender_name) = %q, want empty", got)
|
||||
}
|
||||
// Empty sender_name -> empty (no i18n fallthrough).
|
||||
if got := pickSenderName(map[string]interface{}{"sender_name": ""}); got != "" {
|
||||
t.Fatalf("pickSenderName(empty sender_name) = %q, want empty", got)
|
||||
}
|
||||
// Nothing available -> empty (caller falls back to id).
|
||||
if got := pickSenderName(map[string]interface{}{"id": "cli_x"}); got != "" {
|
||||
t.Fatalf("pickSenderName(no name) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttachSenderNamesPrefersProducerName covers AC1 (bot display name), AC2
|
||||
// (user producer name), AC5 (open_bot_id passthrough) and AC3 (id fallback).
|
||||
func TestAttachSenderNamesPrefersProducerName(t *testing.T) {
|
||||
i18n := map[string]interface{}{"en_us": "Bot Alpha", "zh_cn": "机器人甲"}
|
||||
messages := []map[string]interface{}{
|
||||
// bot sender with producer-filled sender_name (AC1) + sender_i18n_names + open_bot_id (AC5)
|
||||
{"sender": map[string]interface{}{"sender_type": "app", "id": "cli_bot", "sender_name": "机器人甲", "sender_i18n_names": i18n, "open_bot_id": "ou_bot"}},
|
||||
// user sender with producer-filled sender_name (AC2, unified read)
|
||||
{"sender": map[string]interface{}{"sender_type": "user", "id": "ou_user1", "sender_name": "Producer User"}},
|
||||
// user sender without producer name -> resolved from the shared name cache (nameMap)
|
||||
{"sender": map[string]interface{}{"sender_type": "user", "id": "ou_user2"}},
|
||||
// bot sender without any name -> stays id (AC3)
|
||||
{"sender": map[string]interface{}{"sender_type": "app", "id": "cli_unknown"}},
|
||||
}
|
||||
nameMap := map[string]string{"ou_user2": "Contact User"}
|
||||
|
||||
AttachSenderNames(messages, nameMap)
|
||||
|
||||
s0 := messages[0]["sender"].(map[string]interface{})
|
||||
if s0["name"] != "机器人甲" {
|
||||
t.Fatalf("bot sender name = %#v, want %#v", s0["name"], "机器人甲")
|
||||
}
|
||||
if s0["open_bot_id"] != "ou_bot" {
|
||||
t.Fatalf("bot open_bot_id passthrough = %#v, want %#v", s0["open_bot_id"], "ou_bot")
|
||||
}
|
||||
// sender_name is dropped (duplicate of name); sender_i18n_names is kept.
|
||||
if _, has := s0["sender_name"]; has {
|
||||
t.Fatalf("sender_name should be stripped from output, got %#v", s0["sender_name"])
|
||||
}
|
||||
if _, has := s0["sender_i18n_names"]; !has {
|
||||
t.Fatalf("sender_i18n_names should be preserved in output")
|
||||
}
|
||||
if s := messages[1]["sender"].(map[string]interface{}); s["name"] != "Producer User" {
|
||||
t.Fatalf("user producer name = %#v, want %#v", s["name"], "Producer User")
|
||||
}
|
||||
if s := messages[2]["sender"].(map[string]interface{}); s["name"] != "Contact User" {
|
||||
t.Fatalf("user contact-fallback name = %#v, want %#v", s["name"], "Contact User")
|
||||
}
|
||||
if s := messages[3]["sender"].(map[string]interface{}); s["name"] != nil {
|
||||
t.Fatalf("unresolved bot sender should keep no name (id fallback), got %#v", s["name"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemMessageNeedsNoName documents that system messages — identified by
|
||||
// msg_type=="system", not by any sender id — need no display name: the producer
|
||||
// fills none and their sender carries no ou_ id, so they never hit the contact API
|
||||
// and are left without a name (no error). An empty sender name is normal here.
|
||||
func TestSystemMessageNeedsNoName(t *testing.T) {
|
||||
failIfContactCalled := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("system message must not trigger any API call: %s", req.URL.String())
|
||||
users := make([]interface{}, 0, len(userIDs))
|
||||
for _, raw := range userIDs {
|
||||
id, _ := raw.(string)
|
||||
users = append(users, map[string]interface{}{
|
||||
"user_id": id,
|
||||
"name": "name-" + id,
|
||||
})
|
||||
}
|
||||
return convertlibJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"users": users},
|
||||
}), nil
|
||||
}))
|
||||
|
||||
messages := []map[string]interface{}{
|
||||
{"msg_type": "system", "sender": map[string]interface{}{"sender_type": "system"}},
|
||||
{"msg_type": "system"}, // system message without a sender object at all
|
||||
missingIDs := make([]string, 25)
|
||||
for i := range missingIDs {
|
||||
missingIDs[i] = fmt.Sprintf("ou_%02d", i)
|
||||
}
|
||||
nameMap := map[string]string{}
|
||||
batchResolveByBasicContact(runtime, missingIDs, nameMap)
|
||||
|
||||
got := ResolveSenderNames(failIfContactCalled, messages, nil)
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("system messages resolved names = %#v, want empty", got)
|
||||
if want := []int{10, 10, 5}; !reflect.DeepEqual(batchSizes, want) {
|
||||
t.Fatalf("batch sizes = %v, want %v", batchSizes, want)
|
||||
}
|
||||
|
||||
AttachSenderNames(messages, got)
|
||||
if s := messages[0]["sender"].(map[string]interface{}); s["name"] != nil {
|
||||
t.Fatalf("system message sender should keep no name, got %#v", s["name"])
|
||||
if len(nameMap) != 25 {
|
||||
t.Fatalf("resolved name count = %d, want 25", len(nameMap))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSenderNamesAPIFailure(t *testing.T) {
|
||||
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/open-apis/contact/v3/users/batch"):
|
||||
return nil, fmt.Errorf("contact api failed")
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
}))
|
||||
|
||||
got := ResolveSenderNames(runtime, []map[string]interface{}{
|
||||
{"sender": map[string]interface{}{"sender_type": "user", "id": "ou_fail"}},
|
||||
}, map[string]string{})
|
||||
if got["ou_fail"] != "" {
|
||||
t.Fatalf("failed sender resolution = %#v, want empty", got["ou_fail"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,10 +209,6 @@ func fetchMergeForwardSubMessages(messageID string, runtime *common.RuntimeConte
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodGet, mergeForwardMessagesPath(messageID), larkcore.QueryParams{
|
||||
"user_id_type": []string{"open_id"},
|
||||
"card_msg_content_type": []string{"raw_card_content"},
|
||||
// Opt in to server-side sender names: without it, senders that appear
|
||||
// only inside this merge_forward carry no sender_name and — since there
|
||||
// is no contact/mention fallback — render as their raw id.
|
||||
"with_sender_name": []string{"true"},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -65,12 +65,6 @@ func TestFetchMergeForwardSubMessages(t *testing.T) {
|
||||
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_root"):
|
||||
// Sub-item senders that appear only inside the merge_forward
|
||||
// have no name unless we opt into server-side sender names;
|
||||
// there is no contact/mention fallback anymore.
|
||||
if got := req.URL.Query().Get("with_sender_name"); got != "true" {
|
||||
t.Fatalf("with_sender_name = %q, want %q", got, "true")
|
||||
}
|
||||
return convertlibJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
|
||||
@@ -258,10 +258,6 @@ func fetchThreadReplies(runtime *common.RuntimeContext, threadID string, limit i
|
||||
"sort_type": []string{"ByCreateTimeAsc"},
|
||||
"page_size": []string{fmt.Sprint(limit)},
|
||||
"card_msg_content_type": []string{"raw_card_content"},
|
||||
// Opt in to server-side sender names: without it, reply senders that
|
||||
// appear only inside this thread carry no sender_name and — since there
|
||||
// is no contact/mention fallback — render as their raw id.
|
||||
"with_sender_name": []string{"true"},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("fetch thread replies for %s: %w", threadID, err) //nolint:forbidigo // best-effort internal thread fetch; never surfaced as a final shortcut error (ExpandThreadReplies is void)
|
||||
|
||||
@@ -18,12 +18,6 @@ func TestExpandThreadReplies(t *testing.T) {
|
||||
if req.URL.Query().Get("container_id") != "omt_1" {
|
||||
return nil, fmt.Errorf("unexpected thread lookup: %s", req.URL.String())
|
||||
}
|
||||
// Reply senders that appear only inside the thread have no name
|
||||
// unless we opt into server-side sender names; there is no
|
||||
// contact/mention fallback anymore.
|
||||
if got := req.URL.Query().Get("with_sender_name"); got != "true" {
|
||||
t.Fatalf("with_sender_name = %q, want %q", got, "true")
|
||||
}
|
||||
return convertlibJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
|
||||
@@ -213,7 +213,6 @@ func TestBuildChatMessageListRequest(t *testing.T) {
|
||||
"page_size": {"50"},
|
||||
"only_thread_root_messages": {"true"},
|
||||
"card_msg_content_type": {"raw_card_content"},
|
||||
"with_sender_name": {"true"},
|
||||
"start_time": {"1772294400"},
|
||||
"end_time": {"1772467199"},
|
||||
"page_token": {"next"},
|
||||
|
||||
@@ -54,33 +54,14 @@ func normalizeAtMentions(content string) string {
|
||||
// Uses repeated params (?message_ids=x&message_ids=y) — RFC 6570 standard array
|
||||
// encoding, shorter and more broadly compatible than indexed params ([0]=x).
|
||||
func buildMGetURL(ids []string) string {
|
||||
parts := make([]string, 0, len(ids)+2)
|
||||
parts := make([]string, 0, len(ids)+1)
|
||||
parts = append(parts, "card_msg_content_type=raw_card_content")
|
||||
// Opt into server-side sender name filling (user + bot); without it the server
|
||||
// omits sender_name / sender_i18n_names / open_bot_id. Used by messages-mget and
|
||||
// the mget step of messages-search.
|
||||
parts = append(parts, "with_sender_name=true")
|
||||
for _, id := range ids {
|
||||
parts = append(parts, "message_ids="+url.QueryEscape(id))
|
||||
}
|
||||
return "/open-apis/im/v1/messages/mget?" + strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
// senderDisplay returns the human-readable sender column value: the resolved
|
||||
// display name (set by convertlib.AttachSenderNames from the producer-filled
|
||||
// sender_name / sender_i18n_names or contact enrichment) when available,
|
||||
// otherwise the sender id as a fallback (AC3). System messages carrying neither
|
||||
// yield an empty string — an absent sender name is normal, not an error.
|
||||
func senderDisplay(sender map[string]interface{}) string {
|
||||
if name, _ := sender["name"].(string); name != "" {
|
||||
return name
|
||||
}
|
||||
if id, _ := sender["id"].(string); id != "" {
|
||||
return id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func validateMessageID(input string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
|
||||
@@ -675,27 +675,3 @@ func TestShortcuts(t *testing.T) {
|
||||
t.Fatalf("Shortcuts() commands = %#v, want %#v", commands, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSenderDisplay covers the human-readable sender column: a resolved name wins,
|
||||
// otherwise the sender id is shown (AC3 fallback), and a system/senderless message
|
||||
// with neither yields an empty string (no name is normal, not an error).
|
||||
func TestSenderDisplay(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
sender map[string]interface{}
|
||||
want string
|
||||
}{
|
||||
{"name wins", map[string]interface{}{"name": "Bot Alpha", "id": "cli_bot"}, "Bot Alpha"},
|
||||
{"id fallback when no name (AC3)", map[string]interface{}{"id": "cli_bot", "open_bot_id": "ou_bot"}, "cli_bot"},
|
||||
{"user id fallback", map[string]interface{}{"id": "ou_user"}, "ou_user"},
|
||||
{"empty name falls back to id", map[string]interface{}{"name": "", "id": "cli_x"}, "cli_x"},
|
||||
{"no name no id (system)", map[string]interface{}{"sender_type": "system"}, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := senderDisplay(c.sender); got != c.want {
|
||||
t.Fatalf("senderDisplay(%#v) = %q, want %q", c.sender, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ var ImChatMessageList = common.Shortcut{
|
||||
Description: "List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.base:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
@@ -168,8 +168,8 @@ var ImChatMessageList = common.Shortcut{
|
||||
"type": msg["msg_type"],
|
||||
}
|
||||
if sender, ok := msg["sender"].(map[string]interface{}); ok {
|
||||
if disp := senderDisplay(sender); disp != "" {
|
||||
row["sender"] = disp
|
||||
if name, _ := sender["name"].(string); name != "" {
|
||||
row["sender"] = name
|
||||
}
|
||||
}
|
||||
if content, _ := msg["content"].(string); content != "" {
|
||||
@@ -206,9 +206,6 @@ func buildChatMessageListParams(sortFlag, pageSizeStr, chatId string) larkcore.Q
|
||||
"page_size": []string{strconv.Itoa(pageSize)},
|
||||
"card_msg_content_type": []string{"raw_card_content"},
|
||||
"only_thread_root_messages": []string{"true"},
|
||||
// Opt into server-side sender name filling (sender_name / sender_i18n_names /
|
||||
// open_bot_id) for both user and bot senders; without it the server omits them.
|
||||
"with_sender_name": []string{"true"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ var ImMessagesMGet = common.Shortcut{
|
||||
Description: "Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
@@ -112,8 +112,8 @@ var ImMessagesMGet = common.Shortcut{
|
||||
"type": msg["msg_type"],
|
||||
}
|
||||
if sender, ok := msg["sender"].(map[string]interface{}); ok {
|
||||
if disp := senderDisplay(sender); disp != "" {
|
||||
row["sender"] = disp
|
||||
if name, _ := sender["name"].(string); name != "" {
|
||||
row["sender"] = name
|
||||
}
|
||||
}
|
||||
if content, _ := msg["content"].(string); content != "" {
|
||||
|
||||
@@ -35,7 +35,7 @@ var ImMessagesReply = common.Shortcut{
|
||||
{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
|
||||
{Name: "audio", Desc: audioMessageInputDesc},
|
||||
{Name: "reply-in-thread", Type: "bool", Desc: "reply in thread (message appears in thread stream instead of main chat)"},
|
||||
{Name: "idempotency-key", Desc: "idempotency key, max 50 characters (prevents duplicate sends)"},
|
||||
{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
messageId := runtime.Str("message-id")
|
||||
@@ -85,7 +85,6 @@ var ImMessagesReply = common.Shortcut{
|
||||
content := runtime.Str("content")
|
||||
text := runtime.Str("text")
|
||||
markdown := runtime.Str("markdown")
|
||||
idempotencyKey := runtime.Str("idempotency-key")
|
||||
imageKey := runtime.Str("image")
|
||||
fileKey := runtime.Str("file")
|
||||
videoKey := runtime.Str("video")
|
||||
@@ -115,9 +114,6 @@ var ImMessagesReply = common.Shortcut{
|
||||
if msg := validateContentFlags(text, markdown, content, imageKey, fileKey, videoKey, videoCoverKey, audioKey); msg != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg)
|
||||
}
|
||||
if err := validateIdempotencyKey(idempotencyKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if content != "" && !json.Valid([]byte(content)) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content).WithParam("--content")
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ var ImMessagesSearch = common.Shortcut{
|
||||
Command: "+messages-search",
|
||||
Description: "Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, enriches results via mget and chats batch_query",
|
||||
Risk: "read",
|
||||
Scopes: []string{"search:message", "im:message.reactions:read"},
|
||||
Scopes: []string{"search:message", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
@@ -227,8 +227,8 @@ var ImMessagesSearch = common.Shortcut{
|
||||
"type": msg["msg_type"],
|
||||
}
|
||||
if sender, ok := msg["sender"].(map[string]interface{}); ok {
|
||||
if disp := senderDisplay(sender); disp != "" {
|
||||
row["sender"] = disp
|
||||
if name, _ := sender["name"].(string); name != "" {
|
||||
row["sender"] = name
|
||||
}
|
||||
}
|
||||
if chatName, ok := msg["chat_name"].(string); ok && chatName != "" {
|
||||
|
||||
@@ -32,7 +32,7 @@ var ImMessagesSend = common.Shortcut{
|
||||
{Name: "content", Desc: "(one of --content/--text/--markdown/--image/--file/--video/--audio required) message content JSON"},
|
||||
{Name: "text", Desc: "plain text message (auto-wrapped as JSON)"},
|
||||
{Name: "markdown", Desc: "markdown text (auto-wrapped as post format with style optimization; image URLs auto-resolved)"},
|
||||
{Name: "idempotency-key", Desc: "idempotency key, max 50 characters (prevents duplicate sends)"},
|
||||
{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
|
||||
{Name: "image", Desc: "image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
|
||||
@@ -97,7 +97,6 @@ var ImMessagesSend = common.Shortcut{
|
||||
content := runtime.Str("content")
|
||||
text := runtime.Str("text")
|
||||
markdown := runtime.Str("markdown")
|
||||
idempotencyKey := runtime.Str("idempotency-key")
|
||||
imageKey := runtime.Str("image")
|
||||
fileKey := runtime.Str("file")
|
||||
videoKey := runtime.Str("video")
|
||||
@@ -136,9 +135,6 @@ var ImMessagesSend = common.Shortcut{
|
||||
if msg := validateContentFlags(text, markdown, content, imageKey, fileKey, videoKey, videoCoverKey, audioKey); msg != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, msg)
|
||||
}
|
||||
if err := validateIdempotencyKey(idempotencyKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if content != "" && !json.Valid([]byte(content)) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content).WithParam("--content")
|
||||
}
|
||||
@@ -215,15 +211,6 @@ var ImMessagesSend = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
const maxIdempotencyKeyChars = 50
|
||||
|
||||
func validateIdempotencyKey(value string) error {
|
||||
if chars := len([]rune(value)); chars > maxIdempotencyKeyChars {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--idempotency-key exceeds the maximum of %d characters (got %d)", maxIdempotencyKeyChars, chars).WithParam("--idempotency-key")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isMediaKey returns true if the value looks like an existing API key rather than a local file path.
|
||||
func isMediaKey(value string) bool {
|
||||
return strings.HasPrefix(value, "img_") || strings.HasPrefix(value, "file_")
|
||||
|
||||
@@ -25,8 +25,8 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
Description: "List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
@@ -142,8 +142,8 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
"type": msg["msg_type"],
|
||||
}
|
||||
if sender, ok := msg["sender"].(map[string]interface{}); ok {
|
||||
if disp := senderDisplay(sender); disp != "" {
|
||||
row["sender"] = disp
|
||||
if name, _ := sender["name"].(string); name != "" {
|
||||
row["sender"] = name
|
||||
}
|
||||
}
|
||||
if content, _ := msg["content"].(string); content != "" {
|
||||
@@ -176,8 +176,6 @@ func buildThreadsMessagesListParams(dir, containerID string, pageSize int, pageT
|
||||
"sort_type": {sortType},
|
||||
"page_size": {strconv.Itoa(pageSize)},
|
||||
"card_msg_content_type": {"raw_card_content"},
|
||||
// Opt into server-side sender name filling (user + bot); see buildChatMessageListParams.
|
||||
"with_sender_name": {"true"},
|
||||
}
|
||||
if pageToken != "" {
|
||||
params["page_token"] = []string{pageToken}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestReadRequestsSendWithSenderName asserts every message-read request opts into
|
||||
// server-side sender name filling by sending with_sender_name=true. Without this
|
||||
// the producer never fills sender_name/sender_i18n_names/open_bot_id, so bot names
|
||||
// never appear (AC1/AC5). Covers chat-messages-list, threads-messages-list, and the
|
||||
// shared mget URL used by messages-mget and messages-search.
|
||||
func TestReadRequestsSendWithSenderName(t *testing.T) {
|
||||
if got := buildChatMessageListParams("desc", "50", "oc_x")["with_sender_name"]; len(got) != 1 || got[0] != "true" {
|
||||
t.Fatalf("chat-messages-list with_sender_name = %#v, want [true]", got)
|
||||
}
|
||||
if got := buildThreadsMessagesListParams("desc", "t_x", 50, "")["with_sender_name"]; len(got) != 1 || got[0] != "true" {
|
||||
t.Fatalf("threads-messages-list with_sender_name = %#v, want [true]", got)
|
||||
}
|
||||
if u := buildMGetURL([]string{"om_1"}); !strings.Contains(u, "with_sender_name=true") {
|
||||
t.Fatalf("buildMGetURL = %q, want to contain with_sender_name=true", u)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts/application"
|
||||
"github.com/larksuite/cli/shortcuts/apps"
|
||||
"github.com/larksuite/cli/shortcuts/base"
|
||||
"github.com/larksuite/cli/shortcuts/calendar"
|
||||
@@ -62,7 +61,6 @@ var allShortcuts []common.Shortcut
|
||||
|
||||
func init() {
|
||||
allShortcuts = append(allShortcuts, apps.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, application.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, calendar.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, doc.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, drive.Shortcuts()...)
|
||||
@@ -207,18 +205,16 @@ func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core
|
||||
svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand)
|
||||
}
|
||||
|
||||
// Sheets backward-compatibility grouping.
|
||||
// Sheets backward-compatibility help grouping.
|
||||
//
|
||||
// shortcuts/sheets/backward keeps the pre-refactor command names alive so that
|
||||
// users whose lark-sheets skill predates the refactor keep working even after
|
||||
// upgrading only the binary. applySheetsCompatGroups tags each alias into a
|
||||
// dedicated deprecated cobra group. The refactored commands have been the
|
||||
// default for over a month, so `sheets --help` no longer lists these aliases:
|
||||
// sheetsUsageTemplate renders every group except the deprecated one. The
|
||||
// grouping is still applied for two reasons — the unknown-subcommand path
|
||||
// (cmd/root.go) keys off it to classify a mistyped legacy alias, and each
|
||||
// alias's own `sheets <alias> --help` still surfaces the "(→ +new-command)"
|
||||
// migration pointer appended below. The aliases stay fully executable.
|
||||
// upgrading only the binary. In `sheets --help` those aliases would otherwise
|
||||
// sort alphabetically into the same flat list as the current commands,
|
||||
// indistinguishable from them. applySheetsCompatGroups splits them into a
|
||||
// dedicated cobra group whose heading tells the user to update their skill, and
|
||||
// appends a "(→ +new-command)" pointer to each alias so the migration target is
|
||||
// obvious. Pure presentation — the aliases stay fully executable.
|
||||
const (
|
||||
sheetsCurrentGroupID = "sheets-current"
|
||||
// sheetsDeprecatedGroupID aliases the shared deprecated-group id so both
|
||||
@@ -228,10 +224,9 @@ const (
|
||||
)
|
||||
|
||||
// sheetsAliasReplacement maps each pre-refactor sheets alias to the current
|
||||
// command(s) that replace it, shown as a "(→ ...)" suffix in the alias's own
|
||||
// --help and reused by wrapSheetsBackwardDeprecation for the on-execution
|
||||
// _notice. Aliases absent from this map still land in the deprecated group,
|
||||
// just without a pointer, so a missing entry degrades gracefully.
|
||||
// command(s) that replace it, shown as a "(→ ...)" suffix in --help. Aliases
|
||||
// absent from this map still land in the deprecated group, just without a
|
||||
// pointer, so a missing entry degrades gracefully rather than misgrouping.
|
||||
var sheetsAliasReplacement = map[string]string{
|
||||
// spreadsheet / sheet management
|
||||
"+create": "+workbook-create",
|
||||
@@ -284,43 +279,6 @@ var sheetsAliasReplacement = map[string]string{
|
||||
"+delete-float-image": "+float-image-delete",
|
||||
}
|
||||
|
||||
// sheetsUsageTemplate is cobra v1.10.2's stock usage template with a single
|
||||
// change: the group loop is guarded by {{if ne $group.ID "deprecated"}} so the
|
||||
// deprecated pre-refactor aliases are omitted from `sheets --help` altogether.
|
||||
// Everything else — current commands, ungrouped metaapi subcommands under
|
||||
// "Additional Commands", flags — renders exactly as cobra's default. Keep in
|
||||
// sync with cobra's defaultUsageTemplate on upgrade.
|
||||
var sheetsUsageTemplate = fmt.Sprintf(`Usage:{{if .Runnable}}
|
||||
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}
|
||||
|
||||
Aliases:
|
||||
{{.NameAndAliases}}{{end}}{{if .HasExample}}
|
||||
|
||||
Examples:
|
||||
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
|
||||
|
||||
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}{{if ne $group.ID %q}}
|
||||
|
||||
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
|
||||
|
||||
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
|
||||
|
||||
Flags:
|
||||
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
|
||||
|
||||
Global Flags:
|
||||
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
|
||||
|
||||
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
|
||||
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
|
||||
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}
|
||||
`, sheetsDeprecatedGroupID)
|
||||
|
||||
func applySheetsCompatGroups(svc *cobra.Command) {
|
||||
svc.AddGroup(
|
||||
&cobra.Group{ID: sheetsCurrentGroupID, Title: "Available Commands:"},
|
||||
@@ -352,11 +310,6 @@ func applySheetsCompatGroups(svc *cobra.Command) {
|
||||
c.GroupID = sheetsCurrentGroupID
|
||||
}
|
||||
}
|
||||
|
||||
// Refactored commands have been the default for over a month: drop the
|
||||
// deprecated group from `sheets --help` (see sheetsUsageTemplate). The
|
||||
// aliases remain grouped and executable, just no longer advertised here.
|
||||
svc.SetUsageTemplate(sheetsUsageTemplate)
|
||||
}
|
||||
|
||||
// wrapSheetsBackwardDeprecation decorates each backward-compatibility sheets
|
||||
|
||||
@@ -532,11 +532,10 @@ func TestApplySheetsCompatGroups(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end: `sheets --help` must list refactored shortcuts under Available
|
||||
// Commands, but no longer advertise the deprecated pre-refactor aliases or the
|
||||
// deprecated group heading (sheetsUsageTemplate skips that group). The aliases
|
||||
// stay registered and executable — hidden from the parent listing, not removed.
|
||||
func TestRegisterShortcutsSheetsHelpHidesDeprecatedAliases(t *testing.T) {
|
||||
// End-to-end: the rendered `sheets --help` must surface the deprecated-group
|
||||
// heading (telling users to update their skill) plus the per-alias migration
|
||||
// pointers, while keeping the refactored shortcuts under Available Commands.
|
||||
func TestRegisterShortcutsSheetsHelpGroupsDeprecatedAliases(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
@@ -552,25 +551,19 @@ func TestRegisterShortcutsSheetsHelpHidesDeprecatedAliases(t *testing.T) {
|
||||
}
|
||||
got := out.String()
|
||||
|
||||
for _, want := range []string{"Available Commands:", "+cells-get"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("sheets help missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
for _, want := range []string{
|
||||
"Available Commands:",
|
||||
"Deprecated pre-refactor commands",
|
||||
"update your lark-sheets skill",
|
||||
"+read",
|
||||
"(→ +cells-get)",
|
||||
"+write",
|
||||
"(→ +cells-set)",
|
||||
} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Fatalf("sheets help still shows deprecated content %q:\n%s", unwanted, got)
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("sheets help missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
if alias, _, ferr := sheetsCmd.Find([]string{"+read"}); ferr != nil || alias == nil {
|
||||
t.Fatalf("deprecated alias +read should stay registered, got err=%v cmd=%v", ferr, alias)
|
||||
}
|
||||
}
|
||||
|
||||
// wrapSheetsBackwardDeprecation must decorate each alias's Execute so that
|
||||
|
||||
@@ -5,7 +5,6 @@ package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -18,30 +17,20 @@ import (
|
||||
|
||||
// Drive media parent_type values for uploading an image into a spreadsheet.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
|
||||
// synthetic token prefixed with "fake_office_" (being renamed to
|
||||
// "local_office_") and the backend requires "office_sheet_file" instead.
|
||||
// synthetic token prefixed with "fake_office_" and the backend requires
|
||||
// "office_sheet_file" instead.
|
||||
const (
|
||||
sheetImageParentType = "sheet_image"
|
||||
officeSheetFileParentType = "office_sheet_file"
|
||||
fakeOfficePrefix = "fake_office_"
|
||||
localOfficePrefix = "local_office_"
|
||||
fakeOfficeTokenPrefix = "fake_office_"
|
||||
)
|
||||
|
||||
// officePrefixes are the synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
|
||||
// "local_office_"; accept either so image uploads keep working across the
|
||||
// rename.
|
||||
var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix}
|
||||
|
||||
// sheetMediaParentType returns the drive media parent_type to use when
|
||||
// uploading an image whose parent_node is spreadsheetToken, mapping either the
|
||||
// "fake_office_" or "local_office_" imported-spreadsheet token prefix to
|
||||
// "office_sheet_file".
|
||||
// uploading an image whose parent_node is spreadsheetToken, mapping the
|
||||
// "fake_office_" imported-spreadsheet token prefix to "office_sheet_file".
|
||||
func sheetMediaParentType(spreadsheetToken string) string {
|
||||
for _, prefix := range officePrefixes {
|
||||
if strings.HasPrefix(spreadsheetToken, prefix) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
return sheetImageParentType
|
||||
}
|
||||
@@ -146,8 +135,7 @@ func validateSheetMediaUploadFile(runtime *common.RuntimeContext, filePath strin
|
||||
stat, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil {
|
||||
wrapped := common.WrapInputStatErrorTyped(err, "file not found")
|
||||
var v *errs.ValidationError
|
||||
if errors.As(wrapped, &v) {
|
||||
if v, ok := wrapped.(*errs.ValidationError); ok {
|
||||
return "", nil, v.WithParam("--file")
|
||||
}
|
||||
return "", nil, wrapped
|
||||
|
||||
@@ -102,8 +102,8 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
|
||||
{
|
||||
shortcut: "+rows-resize",
|
||||
sc: RowsResize,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "1", "--height", "30"},
|
||||
subInput: `{"sheet-id":"sh1","range":"1","height":30}`,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "1", "--type", "pixel", "--size", "30"},
|
||||
subInput: `{"sheet-id":"sh1","range":"1","type":"pixel","size":30}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+cols-resize",
|
||||
@@ -409,12 +409,12 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
|
||||
wantContains: "--count must be > 0",
|
||||
},
|
||||
{
|
||||
name: "+rows-resize --height with --type standard",
|
||||
name: "+rows-resize --type pixel without --size",
|
||||
shortcut: RowsResize,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "1:2", "--height", "30", "--type", "standard"},
|
||||
args: []string{"--sheet-id", "sh1", "--range", "1:2", "--type", "pixel"},
|
||||
subShortcut: "+rows-resize",
|
||||
subInput: `{"sheet-id":"sh1","range":"1:2","height":30,"type":"standard"}`,
|
||||
wantContains: "--height cannot be combined with --type standard",
|
||||
subInput: `{"sheet-id":"sh1","range":"1:2","type":"pixel"}`,
|
||||
wantContains: "--type pixel requires --size",
|
||||
},
|
||||
{
|
||||
name: "+sheet-delete missing sheet selector",
|
||||
@@ -469,34 +469,6 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchOp_RejectsResizeMapForm locks the nesting guard: the map form
|
||||
// (--widths/--heights) expands into its own batch_update, and batch_update
|
||||
// cannot nest, so a +batch-update sub-op carrying `widths`/`heights` must be
|
||||
// rejected with a pointer to the standalone form — it is standalone-valid,
|
||||
// so this case cannot live in the standalone-vs-batch equivalence table.
|
||||
func TestBatchOp_RejectsResizeMapForm(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
shortcut string
|
||||
input string
|
||||
}{
|
||||
{"+cols-resize", `{"sheet-id":"sh1","widths":{"A":100}}`},
|
||||
{"+rows-resize", `{"sheet-id":"sh1","heights":{"1":50}}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.shortcut, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var subInput map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(tc.input), &subInput); err != nil {
|
||||
t.Fatalf("bad input JSON: %v", err)
|
||||
}
|
||||
rawOp := map[string]interface{}{"shortcut": tc.shortcut, "input": subInput}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
requireValidation(t, err, "not supported inside +batch-update")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchOp_RejectsWrongScalarType locks the type-check that closes the
|
||||
// silent-coercion gap: `operations` skips parse-time schema validation, and
|
||||
// mapFlagView coerces a mismatched scalar to its zero value, so a sub-op field
|
||||
@@ -639,10 +611,10 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) {
|
||||
"--position is required",
|
||||
},
|
||||
{
|
||||
"+rows-resize missing both --height and --type",
|
||||
"+rows-resize missing --type",
|
||||
"+rows-resize",
|
||||
`{"sheet-id":"sh1","range":"1:1"}`,
|
||||
"give --height <px> for a pixel size, or --type standard / auto",
|
||||
"--type is required",
|
||||
},
|
||||
{
|
||||
"+range-copy missing --target-range",
|
||||
@@ -830,7 +802,7 @@ func TestBatchOp_DispatchCoversReportedBugs(t *testing.T) {
|
||||
// bare single-element ranges.
|
||||
body = parseDryRunBody(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","range":"23","height":40}}]`,
|
||||
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","range":"23","type":"pixel","size":40}}]`,
|
||||
"--yes",
|
||||
})
|
||||
ops = decodeToolInput(t, body, "batch_update")["operations"].([]interface{})
|
||||
@@ -915,99 +887,3 @@ func TestBatchOp_RequiredFlagParity(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchOp_EnumParity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("canonical casing is normalized before translation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := translateBatchOp(map[string]interface{}{
|
||||
"shortcut": "+cells-clear",
|
||||
"input": map[string]interface{}{
|
||||
"sheet-id": "sh1",
|
||||
"range": "A1:B2",
|
||||
"scope": "FORMATS",
|
||||
},
|
||||
}, testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("translateBatchOp: %v", err)
|
||||
}
|
||||
input, _ := got["input"].(map[string]interface{})
|
||||
if input["clear_type"] != "formats" {
|
||||
t.Fatalf("clear_type = %v, want formats", input["clear_type"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cross-vocabulary alias is normalized", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := translateBatchOp(map[string]interface{}{
|
||||
"shortcut": "+cells-set-style",
|
||||
"input": map[string]interface{}{
|
||||
"sheet-id": "sh1", "range": "A1", "vertical-alignment": "center",
|
||||
},
|
||||
}, testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("translateBatchOp: %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["vertical_alignment"] != "middle" {
|
||||
t.Fatalf("vertical_alignment = %v, want middle", style["vertical_alignment"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("underscore input keys are accepted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := translateBatchOp(map[string]interface{}{
|
||||
"shortcut": "+range-copy",
|
||||
"input": map[string]interface{}{
|
||||
"sheet_id": "sh1", "source_range": "A1:B2", "target_range": "D1", "paste_type": "values",
|
||||
},
|
||||
}, testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("translateBatchOp: %v", err)
|
||||
}
|
||||
input := got["input"].(map[string]interface{})
|
||||
if input["range"] != "A1:B2" || input["destination_range"] != "D1" || input["paste_type"] != "value_only" {
|
||||
t.Fatalf("translated underscore-key input = %#v", input)
|
||||
}
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut string
|
||||
input map[string]interface{}
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "invalid clear scope",
|
||||
shortcut: "+cells-clear",
|
||||
input: map[string]interface{}{
|
||||
"sheet-id": "sh1", "range": "A1:B2", "scope": "formtas",
|
||||
},
|
||||
want: "invalid value \"formtas\" for --scope",
|
||||
},
|
||||
{
|
||||
name: "invalid copy paste type",
|
||||
shortcut: "+range-copy",
|
||||
input: map[string]interface{}{
|
||||
"sheet-id": "sh1", "source-range": "A1:B2", "target-range": "D1", "paste-type": "valuez",
|
||||
},
|
||||
want: "invalid value \"valuez\" for --paste-type",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(map[string]interface{}{
|
||||
"shortcut": tt.shortcut,
|
||||
"input": tt.input,
|
||||
}, testToken, 0)
|
||||
validationErr := requireValidation(t, err, tt.want)
|
||||
if validationErr.Param != "--operations" {
|
||||
t.Errorf("param = %q, want --operations", validationErr.Param)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -119,19 +118,10 @@ var batchOpDispatch = map[string]batchOpMapping{
|
||||
}},
|
||||
|
||||
// ─── 行高列宽 (resize_range, 无 operation 字段) ─────────────────
|
||||
// The map form (--heights/--widths) fans out into its own batch_update
|
||||
// and cannot nest inside +batch-update; sub-ops must use the uniform
|
||||
// single-range form (range + height/width or type).
|
||||
"+rows-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
if err := rejectResizeMapInBatch(fv, "row"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resizeInput(fv, token, sid, sname, "row")
|
||||
}},
|
||||
"+cols-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
if err := rejectResizeMapInBatch(fv, "column"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resizeInput(fv, token, sid, sname, "column")
|
||||
}},
|
||||
|
||||
@@ -207,54 +197,6 @@ var batchOpDispatch = map[string]batchOpMapping{
|
||||
"+float-image-delete": {"manage_float_image_object", objDeleteTranslate(floatImageDeleteSpec)},
|
||||
}
|
||||
|
||||
// allowedBatchShortcuts lists every shortcut accepted inside +batch-update,
|
||||
// sorted, for the not-allowed error hint.
|
||||
func allowedBatchShortcuts() []string {
|
||||
out := make([]string, 0, len(batchOpDispatch))
|
||||
for sc := range batchOpDispatch {
|
||||
out = append(out, sc)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// subOpInputContract renders one shortcut's complete sub-op key vocabulary
|
||||
// (wire-style underscore names) for the translator-failure hint: required
|
||||
// flags are marked, the sheet selector pair collapses to a choose-one, and
|
||||
// spreadsheet locators are omitted (reserved for the batch top level).
|
||||
// Returns "" for shortcuts without a flag-defs entry.
|
||||
func subOpInputContract(sc string) string {
|
||||
defs, _ := loadFlagDefs()
|
||||
spec, ok := defs[sc]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
idFlag, nameFlag := sheetSelectorFlagsForSubOp(sc)
|
||||
var keys []string
|
||||
sheetSelector := ""
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind == "system" || df.Hidden {
|
||||
continue
|
||||
}
|
||||
switch df.Name {
|
||||
case "url", "spreadsheet-token":
|
||||
continue // reserved: supplied by +batch-update top level
|
||||
case idFlag, nameFlag:
|
||||
sheetSelector = strings.ReplaceAll(idFlag, "-", "_") + "|" + strings.ReplaceAll(nameFlag, "-", "_") + " (choose one)"
|
||||
continue
|
||||
}
|
||||
key := strings.ReplaceAll(df.Name, "-", "_")
|
||||
if df.Required == "required" {
|
||||
key += " (required)"
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if sheetSelector != "" {
|
||||
keys = append([]string{sheetSelector}, keys...)
|
||||
}
|
||||
return strings.Join(keys, ", ")
|
||||
}
|
||||
|
||||
// rejectLocalImageInBatch blocks the local-file --image source inside
|
||||
// +batch-update: a batch sub-op has no upload phase, so the file could not be
|
||||
// turned into a file_token. Callers must pass --image-token / --image-uri.
|
||||
@@ -320,8 +262,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
}
|
||||
scRaw, present := op["shortcut"]
|
||||
if !present {
|
||||
return nil, sheetsValidationForFlag("operations", "operations[%d]: 'shortcut' field is required", index).
|
||||
WithHint(`each entry must look like {"shortcut":"+cells-set","input":{"sheet_name":"…","range":"A1:B2","cells":[[…]]}} — input uses the shortcut's own flag names`)
|
||||
return nil, sheetsValidationForFlag("operations", "operations[%d]: 'shortcut' field is required", index)
|
||||
}
|
||||
sc, ok := scRaw.(string)
|
||||
if !ok || sc == "" {
|
||||
@@ -329,15 +270,13 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
}
|
||||
mapping, ok := batchOpDispatch[sc]
|
||||
if !ok {
|
||||
// Inline the full allow-list: an agent that guessed a read op or a
|
||||
// fan-out wrapper can pick the right shortcut immediately instead of
|
||||
// spending a --print-schema round trip on the operations enum.
|
||||
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 / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded; "+
|
||||
"run `lark-cli sheets +batch-update --print-schema --flag-name operations` to see the full enum)",
|
||||
index, sc,
|
||||
).WithHint("allowed shortcuts: %s", strings.Join(allowedBatchShortcuts(), ", "))
|
||||
)
|
||||
}
|
||||
inputRaw, hasInput := op["input"]
|
||||
var input map[string]interface{}
|
||||
@@ -380,22 +319,12 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
if err := fv.validateRawTypes(); err != nil {
|
||||
return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err)
|
||||
}
|
||||
if err := fv.normalizeAndValidateEnums(); err != nil {
|
||||
return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err)
|
||||
}
|
||||
sheetIDFlag, sheetNameFlag := sheetSelectorFlagsForSubOp(sc)
|
||||
sheetID := strings.TrimSpace(fv.Str(sheetIDFlag))
|
||||
sheetName := strings.TrimSpace(fv.Str(sheetNameFlag))
|
||||
body, err := mapping.translate(fv, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
// The inner error names one problem at a time (first missing flag);
|
||||
// the hint lists the sub-op's complete key contract so an agent fixes
|
||||
// every gap in a single retry instead of iterating flag by flag.
|
||||
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
|
||||
return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"tool_name": mapping.mcpToolName,
|
||||
@@ -403,59 +332,18 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
}, nil
|
||||
}
|
||||
|
||||
// maxBatchOperations caps how many sub-operations a single +batch-update may
|
||||
// carry. Every translated op (with its own cells/properties payload) is held in
|
||||
// the out slice at once before the whole batch is marshaled, so an unbounded
|
||||
// operation count is the same unbounded-materialization hazard as the fan-out
|
||||
// matrix, on the operations axis.
|
||||
const maxBatchOperations = 100
|
||||
|
||||
// translateBatchOperations 翻译整个 ops 数组;fail-fast,遇错立即返回。
|
||||
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
|
||||
if len(rawOps) == 0 {
|
||||
return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array")
|
||||
}
|
||||
if len(rawOps) > maxBatchOperations {
|
||||
batches := (len(rawOps) + maxBatchOperations - 1) / maxBatchOperations
|
||||
return nil, sheetsValidationForFlag("operations", "--operations accepts at most %d entries; got %d", maxBatchOperations, len(rawOps)).
|
||||
WithHint("split the operations into %d separate +batch-update calls of at most %d entries each", batches, maxBatchOperations)
|
||||
}
|
||||
out := make([]interface{}, 0, len(rawOps))
|
||||
var totalCells int64
|
||||
for i, raw := range rawOps {
|
||||
translated, err := translateBatchOp(raw, token, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalCells += translatedCellCount(translated)
|
||||
if totalCells > maxStampMatrixCells {
|
||||
return nil, sheetsValidationForFlag("operations",
|
||||
"--operations materialize %d cells total, over the %d-cell safety cap; reduce the number or size of cell operations",
|
||||
totalCells, maxStampMatrixCells)
|
||||
}
|
||||
out = append(out, translated)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func translatedCellCount(op map[string]interface{}) int64 {
|
||||
input, _ := op["input"].(map[string]interface{})
|
||||
switch cells := input["cells"].(type) {
|
||||
case [][]interface{}:
|
||||
var total int64
|
||||
for _, row := range cells {
|
||||
total += int64(len(row))
|
||||
}
|
||||
return total
|
||||
case []interface{}:
|
||||
var total int64
|
||||
for _, rawRow := range cells {
|
||||
if row, ok := rawRow.([]interface{}); ok {
|
||||
total += int64(len(row))
|
||||
}
|
||||
}
|
||||
return total
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,4 @@
|
||||
{
|
||||
"+formula-verify": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-id",
|
||||
"kind": "public",
|
||||
"type": "string_slice",
|
||||
"required": "optional",
|
||||
"desc": "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."
|
||||
},
|
||||
{
|
||||
"name": "sheet-name",
|
||||
"kind": "public",
|
||||
"type": "string_slice",
|
||||
"required": "optional",
|
||||
"desc": "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."
|
||||
},
|
||||
{
|
||||
"name": "range",
|
||||
"kind": "own",
|
||||
"type": "string_slice",
|
||||
"required": "optional",
|
||||
"desc": "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region."
|
||||
},
|
||||
{
|
||||
"name": "max-locations",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Max locations / samples per error type; default 20.",
|
||||
"default": "20"
|
||||
},
|
||||
{
|
||||
"name": "exit-on-error",
|
||||
"kind": "own",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes."
|
||||
}
|
||||
]
|
||||
},
|
||||
"+workbook-info": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
@@ -80,32 +25,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+revision-get": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-create": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
@@ -154,17 +73,6 @@
|
||||
"desc": "Initial column count (default 20, max 200)",
|
||||
"default": "20"
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "New sub-sheet type: sheet (spreadsheet); default sheet.",
|
||||
"default": "sheet",
|
||||
"enum": [
|
||||
"sheet"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
@@ -311,7 +219,7 @@
|
||||
"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",
|
||||
"desc": "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`",
|
||||
"default": "-1"
|
||||
},
|
||||
{
|
||||
@@ -607,7 +515,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected (dates / numbers land as text — use --sheets to preserve types), through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
|
||||
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -1161,7 +1069,7 @@
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)",
|
||||
"desc": "Group nesting level to ungroup; default 1 (outermost)",
|
||||
"default": "1"
|
||||
},
|
||||
{
|
||||
@@ -1803,13 +1711,6 @@
|
||||
"required": "optional",
|
||||
"desc": "Font color (hex, e.g. `#000000`)"
|
||||
},
|
||||
{
|
||||
"name": "font-family",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)"
|
||||
},
|
||||
{
|
||||
"name": "font-size",
|
||||
"kind": "own",
|
||||
@@ -2393,43 +2294,32 @@
|
||||
"required": "xor",
|
||||
"desc": "Sheet name (XOR with `--sheet-id`)"
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"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`",
|
||||
"required": "required",
|
||||
"desc": "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default row height) / `auto` (fit content)",
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard",
|
||||
"auto"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Row height in pixels (e.g. 30 / 40 / 60); required when `--type pixel`, ignored otherwise",
|
||||
"default": "0"
|
||||
},
|
||||
{
|
||||
"name": "range",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "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`)"
|
||||
"required": "required",
|
||||
"desc": "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row)"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
@@ -2471,42 +2361,31 @@
|
||||
"required": "xor",
|
||||
"desc": "Sheet name (XOR with `--sheet-id`)"
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"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`",
|
||||
"required": "required",
|
||||
"desc": "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default column width)",
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise",
|
||||
"default": "0"
|
||||
},
|
||||
{
|
||||
"name": "range",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "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`)"
|
||||
"required": "required",
|
||||
"desc": "Column closed range to resize; column letters like `A:E` or `C` (single column)"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
@@ -2860,7 +2739,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range",
|
||||
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -2880,13 +2759,6 @@
|
||||
"required": "optional",
|
||||
"desc": "Font color (hex, e.g. `#000000`)"
|
||||
},
|
||||
{
|
||||
"name": "font-family",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)"
|
||||
},
|
||||
{
|
||||
"name": "font-size",
|
||||
"kind": "own",
|
||||
@@ -3013,7 +2885,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id",
|
||||
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -3093,7 +2965,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!E2:E6\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id",
|
||||
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -3137,7 +3009,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range",
|
||||
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -3255,7 +3127,7 @@
|
||||
"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.",
|
||||
"desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -4194,7 +4066,7 @@
|
||||
"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`"
|
||||
"desc": "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
@@ -4638,7 +4510,7 @@
|
||||
"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"
|
||||
"desc": "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"
|
||||
},
|
||||
{
|
||||
"name": "position-row",
|
||||
@@ -4754,15 +4626,15 @@
|
||||
"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`"
|
||||
"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": "optional",
|
||||
"desc": "Optional image URI handle returned by the upload flow (not a sheet object reference_id); mutually exclusive with `--image-token`; omit both to keep the current image; converted to file_token automatically"
|
||||
"required": "xor",
|
||||
"desc": "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"
|
||||
},
|
||||
{
|
||||
"name": "position-row",
|
||||
@@ -4875,138 +4747,5 @@
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+history-list": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "end-version",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Max version to query (descending pagination). Omit on the first call; pass next_end_version from the previous response."
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+history-revert": {
|
||||
"risk": "high-risk-write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "history-version-id",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "History version to revert to (from +history-list)."
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+history-revert-status": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "transaction-id",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Async revert transaction id (from +history-revert)."
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+changeset-get": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "start-revision",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "required",
|
||||
"desc": "Start version (CS revision); the before baseline for review (must be >= 1)"
|
||||
},
|
||||
{
|
||||
"name": "end-revision",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20",
|
||||
"default": "-1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -93,36 +93,6 @@ func TestExecute_WikiURLResolvesToSheet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_RevisionGet_WikiURL guards RevisionGet's custom Execute hook:
|
||||
// the wiki node token must be resolved before get_workbook_structure runs.
|
||||
func TestExecute_RevisionGet_WikiURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
getNode := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": testToken,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
tool := toolOutputStub(testToken, "read", `{"revision":60}`)
|
||||
out, err := runShortcutWithStubs(t, RevisionGet,
|
||||
[]string{"--url", "https://example.feishu.cn/wiki/wikTestNODE"}, getNode, tool)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if data["revision"] != float64(60) {
|
||||
t.Fatalf("revision = %v, want 60; out=%s", data["revision"], out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_WikiURLWrongObjType rejects a wiki node that resolves to a
|
||||
// non-spreadsheet obj_type before any tool invoke.
|
||||
func TestExecute_WikiURLWrongObjType(t *testing.T) {
|
||||
|
||||
@@ -27,7 +27,7 @@ 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: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
|
||||
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); batch clear is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -38,10 +38,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: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
|
||||
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
|
||||
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
|
||||
{Name: "font-family", Kind: "own", Type: "string", Required: "optional", Desc: "Font family name (e.g. `Arial`, `Microsoft YaHei`)"},
|
||||
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
|
||||
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
|
||||
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
|
||||
@@ -166,7 +165,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A1:B2`)"},
|
||||
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
|
||||
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
|
||||
{Name: "font-family", Kind: "own", Type: "string", Required: "optional", Desc: "Font family name (e.g. `Arial`, `Microsoft YaHei`)"},
|
||||
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
|
||||
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
|
||||
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
|
||||
@@ -190,15 +188,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+changeset-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "start-revision", Kind: "own", Type: "int", Required: "required", Desc: "Start version (CS revision); the before baseline for review (must be >= 1)"},
|
||||
{Name: "end-revision", Kind: "own", Type: "int", Required: "optional", Desc: "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20", Default: "-1"},
|
||||
},
|
||||
},
|
||||
"+chart-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
@@ -206,7 +195,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); 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: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
|
||||
},
|
||||
},
|
||||
@@ -252,10 +241,9 @@ var flagDefs = map[string]commandDef{
|
||||
{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: "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: "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: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default column width)", Enum: []string{"pixel", "standard"}},
|
||||
{Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise", Default: "0"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Column closed range to resize; column letters like `A:E` or `C` (single column)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -417,7 +405,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)", Default: "1"},
|
||||
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (outermost)", Default: "1"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -438,7 +426,7 @@ 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: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!E2:E6\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", Input: []string{"file", "stdin"}},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -475,7 +463,7 @@ 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: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", Input: []string{"file", "stdin"}},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
|
||||
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select"},
|
||||
@@ -538,7 +526,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; required on create and must cover the header row"},
|
||||
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`"},
|
||||
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -588,7 +576,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"},
|
||||
{Name: "image-token", Kind: "own", Type: "string", Required: "xor", Desc: "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"},
|
||||
{Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image URI handle returned by the upload flow (not a sheet object reference_id; XOR with `--image-token`); converted to file_token automatically"},
|
||||
{Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"},
|
||||
{Name: "position-row", Kind: "own", Type: "int", Required: "required", Desc: "Row anchor of the image's top-left corner (0-based)"},
|
||||
{Name: "position-col", Kind: "own", Type: "string", Required: "required", Desc: "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)"},
|
||||
{Name: "size-width", Kind: "own", Type: "int", Required: "required", Desc: "Image width in pixels"},
|
||||
@@ -632,8 +620,8 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"},
|
||||
{Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"},
|
||||
{Name: "image-token", Kind: "own", Type: "string", Required: "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`"},
|
||||
{Name: "image-uri", Kind: "own", Type: "string", Required: "optional", Desc: "Optional image URI handle returned by the upload flow (not a sheet object reference_id); mutually exclusive with `--image-token`; omit both to keep the current image; converted to file_token automatically"},
|
||||
{Name: "image-token", Kind: "own", Type: "string", Required: "xor", Desc: "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"},
|
||||
{Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"},
|
||||
{Name: "position-row", Kind: "own", Type: "int", Required: "required", Desc: "Row anchor of the image's top-left corner (0-based)"},
|
||||
{Name: "position-col", Kind: "own", Type: "string", Required: "required", Desc: "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)"},
|
||||
{Name: "size-width", Kind: "own", Type: "int", Required: "required", Desc: "Image width in pixels"},
|
||||
@@ -644,45 +632,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+formula-verify": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string_slice", Required: "optional", Desc: "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string_slice", Required: "optional", Desc: "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."},
|
||||
{Name: "range", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region."},
|
||||
{Name: "max-locations", Kind: "own", Type: "int", Required: "optional", Desc: "Max locations / samples per error type; default 20.", Default: "20"},
|
||||
{Name: "exit-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes."},
|
||||
},
|
||||
},
|
||||
"+history-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "end-version", Kind: "own", Type: "int", Required: "optional", Desc: "Max version to query (descending pagination). Omit on the first call; pass next_end_version from the previous response."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+history-revert": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "history-version-id", Kind: "own", Type: "string", Required: "required", Desc: "History version to revert to (from +history-list)."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+history-revert-status": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "transaction-id", Kind: "own", Type: "string", Required: "required", Desc: "Async revert transaction id (from +history-revert)."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+pivot-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
@@ -785,14 +734,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+revision-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+rows-resize": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
@@ -800,10 +741,9 @@ var flagDefs = map[string]commandDef{
|
||||
{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: "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: "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: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default row height) / `auto` (fit content)", Enum: []string{"pixel", "standard", "auto"}},
|
||||
{Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Row height in pixels (e.g. 30 / 40 / 60); required when `--type pixel`, ignored otherwise", Default: "0"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -828,7 +768,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position (0-based); appended to the end when omitted", Default: "-1"},
|
||||
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
|
||||
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
|
||||
{Name: "type", Kind: "own", Type: "string", Required: "optional", Desc: "New sub-sheet type: sheet (spreadsheet); default sheet.", Default: "sheet", Enum: []string{"sheet"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -883,7 +822,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "required", Desc: "Target position (0-based)"},
|
||||
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional 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: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`", Default: "-1"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -1002,7 +941,7 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
|
||||
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
|
||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected (dates / numbers land as text — use --sheets to preserve types), through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
|
||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
|
||||
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
|
||||
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// ─── sheets flag ergonomics ─────────────────────────────────────────────
|
||||
//
|
||||
// Eval traces show two recovery loops that burn agent round-trips on the
|
||||
// sheets domain specifically: hallucinated flag names (--cols for --range,
|
||||
// --file for --csv) whose unknown-flag error only points at --help, and
|
||||
// enum values imported from CSS / Excel vocabulary ("center" for the
|
||||
// vertical alignment Lark spells "middle"). Both fixes are wired through
|
||||
// the existing PostMount hook — composed onto any prior PostMount in
|
||||
// Shortcuts(), same pattern as withTokenAlias — so the common framework
|
||||
// needs no change at all and no other domain's behavior shifts.
|
||||
|
||||
// withFlagErgonomics wraps an optional PostMount so that, after it runs,
|
||||
// the command gets the sheets-specific unknown-flag error (valid flags
|
||||
// inlined) and enum-value normalization (canonical vocabulary auto-applied,
|
||||
// typos suggested).
|
||||
func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
|
||||
return func(cmd *cobra.Command) {
|
||||
if prev != nil {
|
||||
prev(cmd)
|
||||
}
|
||||
cmd.SetFlagErrorFunc(sheetsFlagErrorFunc)
|
||||
chainEnumNormalization(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// set: hallucinated sheets flags are usually semantic guesses (--cols for
|
||||
// --range) that edit distance can't rank, and a --help round trip costs an
|
||||
// agent a full extra call. One line here lets it re-issue the command
|
||||
// immediately.
|
||||
func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
|
||||
name, isUnknown := unknownFlagFromParseError(ferr)
|
||||
if !isUnknown {
|
||||
return common.ValidationErrorf("%s", ferr.Error()).
|
||||
WithHint("run `%s --help` for valid flags", c.CommandPath())
|
||||
}
|
||||
valid := visibleFlagNames(c)
|
||||
suggestions := suggest.Closest(name, valid, 3)
|
||||
for i := range suggestions {
|
||||
suggestions[i] = "--" + suggestions[i]
|
||||
}
|
||||
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
|
||||
if list := inlineFlagList(valid); list != "" {
|
||||
hint = "valid flags: " + list
|
||||
if len(suggestions) > 0 {
|
||||
hint = fmt.Sprintf("did you mean %s? valid flags: %s",
|
||||
strings.Join(suggestions, ", "), list)
|
||||
}
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown flag %q for %q", "--"+name, c.CommandPath()).
|
||||
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
|
||||
WithHint("%s", hint)
|
||||
}
|
||||
|
||||
// unknownFlagFromParseError extracts the offending long-flag name from
|
||||
// cobra's flag-parse error text ("unknown flag: --query" → "query").
|
||||
// Returns ok=false for anything else (missing argument, invalid value,
|
||||
// unknown shorthand) so those stay structured but generic. Mirrors the
|
||||
// root-level parser in cmd; the prefix contract is cobra's English wording.
|
||||
func unknownFlagFromParseError(err error) (string, bool) {
|
||||
const p = "unknown flag: --"
|
||||
msg := err.Error()
|
||||
i := strings.Index(msg, p)
|
||||
if i < 0 {
|
||||
return "", false
|
||||
}
|
||||
rest := msg[i+len(p):]
|
||||
if j := strings.IndexAny(rest, " \t"); j >= 0 {
|
||||
rest = rest[:j]
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
// visibleFlagNames lists the non-hidden flag names registered on c, sorted.
|
||||
func visibleFlagNames(c *cobra.Command) []string {
|
||||
var names []string
|
||||
c.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
if !f.Hidden {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
})
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// inlineFlagListLimit caps how many flag names ride inline on an
|
||||
// unknown-flag hint. Sheets shortcuts stay well under it.
|
||||
const inlineFlagListLimit = 25
|
||||
|
||||
// inlineFlagList renders valid flag names as one comma-separated line for
|
||||
// the unknown-flag hint, truncating past inlineFlagListLimit. Empty when
|
||||
// there is nothing to list.
|
||||
func inlineFlagList(names []string) string {
|
||||
if len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
shown := names
|
||||
var suffix string
|
||||
if len(names) > inlineFlagListLimit {
|
||||
shown = names[:inlineFlagListLimit]
|
||||
suffix = fmt.Sprintf(", … (%d more; see --help)", len(names)-inlineFlagListLimit)
|
||||
}
|
||||
parts := make([]string, len(shown))
|
||||
for i, n := range shown {
|
||||
parts[i] = "--" + n
|
||||
}
|
||||
return strings.Join(parts, ", ") + suffix
|
||||
}
|
||||
|
||||
// ─── enum vocabulary normalization ──────────────────────────────────────
|
||||
|
||||
// enumAliases maps habitual values agents import from CSS / Excel / Google
|
||||
// Sheets onto the value the Lark API actually uses, keyed by the wrong
|
||||
// value. Applied only when the alias target is in the enum (and the wrong
|
||||
// value is not), so e.g. "center" still stands for horizontal alignment
|
||||
// (where it is valid) and only maps to "middle" for vertical alignment.
|
||||
var enumAliases = map[string]string{
|
||||
"center": "middle", // CSS vertical-align: center → Lark "middle"
|
||||
"centre": "center",
|
||||
"middle": "center", // CSS-style middle → Lark horizontal "center"
|
||||
}
|
||||
|
||||
// canonicalEnumValue returns the enum entry an off-vocabulary value
|
||||
// unambiguously means — exact case-insensitive match first, then the
|
||||
// cross-vocabulary alias table. Unlike an edit-distance guess, the result
|
||||
// is safe to apply on the caller's behalf. Returns "" when the value has
|
||||
// no unambiguous canonical form in this enum.
|
||||
func canonicalEnumValue(val string, enum []string) string {
|
||||
lower := strings.ToLower(val)
|
||||
for _, allowed := range enum {
|
||||
if strings.ToLower(allowed) == lower {
|
||||
return allowed
|
||||
}
|
||||
}
|
||||
if target, ok := enumAliases[lower]; ok {
|
||||
if slices.Contains(enum, target) {
|
||||
return target
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// closestEnumValue picks the best "did you mean" candidate for an invalid
|
||||
// enum value: the unambiguous canonical form first, then edit distance.
|
||||
// For prose suggestions only — an edit-distance match must never be
|
||||
// auto-applied. Returns "" when nothing is close.
|
||||
func closestEnumValue(val string, enum []string) string {
|
||||
if canon := canonicalEnumValue(val, enum); canon != "" {
|
||||
return canon
|
||||
}
|
||||
if match := suggest.Closest(val, enum, 1); len(match) > 0 {
|
||||
return match[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// chainEnumNormalization installs a PreRunE stage (composed onto any
|
||||
// framework-set PreRunE, which runs first so OnInvoke side effects and the
|
||||
// --print-schema required-flag relaxation keep their contracts) that
|
||||
// normalizes the command's flat enum flags before the common runner
|
||||
// validates them:
|
||||
//
|
||||
// - an unambiguous vocabulary mismatch (casing, or a known alias like CSS
|
||||
// "center" for Lark's vertical "middle") IS the value the caller meant —
|
||||
// rewrite it in place and proceed instead of failing the call just to
|
||||
// have the agent retype the canonical spelling;
|
||||
// - anything else fails here with the allowed list plus a "did you mean"
|
||||
// hint for edit-distance typos — a guess is never auto-applied.
|
||||
//
|
||||
// No-op for commands whose flag defs declare no enums.
|
||||
func chainEnumNormalization(cmd *cobra.Command) {
|
||||
defs, _ := loadFlagDefs()
|
||||
spec, ok := defs[cmd.Name()]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var enumFlags []flagDef
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind != "system" && len(df.Enum) > 0 && df.Type == "string" {
|
||||
enumFlags = append(enumFlags, df)
|
||||
}
|
||||
}
|
||||
if len(enumFlags) == 0 {
|
||||
return
|
||||
}
|
||||
prev := cmd.PreRunE
|
||||
cmd.PreRunE = func(c *cobra.Command, args []string) error {
|
||||
if prev != nil {
|
||||
if err := prev(c, args); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// --print-schema is pure local introspection; the runner never enum-
|
||||
// validates that path, so don't start here.
|
||||
if want, err := c.Flags().GetBool("print-schema"); err == nil && want {
|
||||
return nil
|
||||
}
|
||||
for _, df := range enumFlags {
|
||||
val, err := c.Flags().GetString(df.Name)
|
||||
if err != nil || val == "" || slices.Contains(df.Enum, val) {
|
||||
continue
|
||||
}
|
||||
if canon := canonicalEnumValue(val, df.Enum); canon != "" {
|
||||
c.Flags().Set(df.Name, canon)
|
||||
continue
|
||||
}
|
||||
verr := common.ValidationErrorf("invalid value %q for --%s, allowed: %s",
|
||||
val, df.Name, strings.Join(df.Enum, ", ")).
|
||||
WithParam("--" + df.Name)
|
||||
if match := suggest.Closest(val, df.Enum, 1); len(match) > 0 {
|
||||
verr = verr.WithHint("did you mean %q?", match[0])
|
||||
}
|
||||
return verr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestUnknownFlagFromParseError(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in string
|
||||
name string
|
||||
ok bool
|
||||
}{
|
||||
{"unknown flag: --cols", "cols", true},
|
||||
{"unknown flag: --with-styles", "with-styles", true},
|
||||
{"unknown shorthand flag: 'z' in -z", "", false},
|
||||
{"flag needs an argument: --find", "", false},
|
||||
{`invalid argument "x" for "--count"`, "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
name, ok := unknownFlagFromParseError(errors.New(c.in))
|
||||
if name != c.name || ok != c.ok {
|
||||
t.Errorf("unknownFlagFromParseError(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetsFlagErrorFunc_SemanticGuessListsValidFlags pins the sheets
|
||||
// override of the root unknown-flag error: --cols is a semantic guess for
|
||||
// --range that edit distance can't rank, so the hint must inline the full
|
||||
// valid-flag list instead of deferring to a --help round trip.
|
||||
func TestSheetsFlagErrorFunc_SemanticGuessListsValidFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
c.Flags().String("range", "", "")
|
||||
c.Flags().Int("width", 0, "")
|
||||
|
||||
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --cols"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
|
||||
}
|
||||
if len(verr.Params) != 1 || verr.Params[0].Name != "--cols" {
|
||||
t.Errorf("Params = %v, want one entry named --cols", verr.Params)
|
||||
}
|
||||
if strings.Contains(verr.Hint, "--help") {
|
||||
t.Errorf("hint should not defer to --help when flags fit inline, got %q", verr.Hint)
|
||||
}
|
||||
for _, want := range []string{"--range", "--width"} {
|
||||
if !strings.Contains(verr.Hint, want) {
|
||||
t.Errorf("hint should inline valid flag %s, got %q", want, verr.Hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetsFlagErrorFunc_TypoKeepsSuggestion pins that the root behavior
|
||||
// (did-you-mean suggestion, machine-readable Suggestions) is preserved by
|
||||
// the sheets override, with the valid-flag list appended.
|
||||
func TestSheetsFlagErrorFunc_TypoKeepsSuggestion(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
c.Flags().String("range", "", "")
|
||||
c.Flags().Bool("dry-run", false, "")
|
||||
|
||||
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --rang"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
found := false
|
||||
for _, s := range verr.Params[0].Suggestions {
|
||||
if s == "--range" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Suggestions should include --range, got %v", verr.Params[0].Suggestions)
|
||||
}
|
||||
for _, want := range []string{"did you mean", "--range", "--dry-run"} {
|
||||
if !strings.Contains(verr.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, verr.Hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetsFlagErrorFunc_OtherErrorStaysGeneric(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
err := sheetsFlagErrorFunc(c, errors.New("flag needs an argument: --find"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if verr.Param != "" || len(verr.Params) != 0 {
|
||||
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
|
||||
}
|
||||
if strings.Contains(verr.Hint, "did you mean") {
|
||||
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineFlagList_TruncatesPastLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := inlineFlagList(nil); got != "" {
|
||||
t.Errorf("inlineFlagList(nil) = %q, want empty", got)
|
||||
}
|
||||
names := make([]string, inlineFlagListLimit+5)
|
||||
for i := range names {
|
||||
names[i] = fmt.Sprintf("flag-%02d", i)
|
||||
}
|
||||
got := inlineFlagList(names)
|
||||
if !strings.Contains(got, "5 more") || !strings.Contains(got, "--help") {
|
||||
t.Errorf("truncated list should count the overflow and defer to --help, got %q", got)
|
||||
}
|
||||
if strings.Contains(got, names[inlineFlagListLimit]) {
|
||||
t.Errorf("list should stop at the limit, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalEnumValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
val string
|
||||
enum []string
|
||||
want string
|
||||
}{
|
||||
{"SUM", []string{"sum", "count"}, "sum"}, // casing
|
||||
{"center", []string{"top", "middle", "bottom"}, "middle"}, // alias: CSS vertical center
|
||||
{"middle", []string{"left", "center", "right"}, "center"}, // alias: horizontal middle
|
||||
{"overwite", []string{"append", "overwrite"}, ""}, // typo is NOT canonical
|
||||
{"delete", []string{"append", "overwrite"}, ""}, // nothing close
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := canonicalEnumValue(c.val, c.enum); got != c.want {
|
||||
t.Errorf("canonicalEnumValue(%q, %v) = %q, want %q", c.val, c.enum, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClosestEnumValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
val string
|
||||
enum []string
|
||||
want string
|
||||
}{
|
||||
{"SUM", []string{"sum", "count"}, "sum"}, // casing
|
||||
{"center", []string{"top", "middle", "bottom"}, "middle"}, // alias
|
||||
{"overwite", []string{"append", "overwrite"}, "overwrite"}, // edit distance
|
||||
{"delete", []string{"append", "overwrite"}, ""}, // nothing close
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := closestEnumValue(c.val, c.enum); got != c.want {
|
||||
t.Errorf("closestEnumValue(%q, %v) = %q, want %q", c.val, c.enum, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestChainEnumNormalization_UnitContract pins the PreRunE stage in
|
||||
// isolation: canonical vocabulary is auto-applied, typos error with a
|
||||
// suggestion (never applied), the framework PreRunE keeps running first,
|
||||
// and --print-schema skips enum gating entirely.
|
||||
func TestChainEnumNormalization_UnitContract(t *testing.T) {
|
||||
t.Parallel()
|
||||
newCmd := func() (*cobra.Command, *bool) {
|
||||
cmd := &cobra.Command{Use: "+cells-set-style"}
|
||||
cmd.Flags().String("vertical-alignment", "", "")
|
||||
cmd.Flags().Bool("print-schema", false, "")
|
||||
prevCalled := false
|
||||
cmd.PreRunE = func(*cobra.Command, []string) error {
|
||||
prevCalled = true
|
||||
return nil
|
||||
}
|
||||
chainEnumNormalization(cmd)
|
||||
return cmd, &prevCalled
|
||||
}
|
||||
|
||||
// Alias auto-applied, framework PreRunE preserved.
|
||||
cmd, prevCalled := newCmd()
|
||||
cmd.Flags().Set("vertical-alignment", "center")
|
||||
if err := cmd.PreRunE(cmd, nil); err != nil {
|
||||
t.Fatalf("center should normalize and pass, got: %v", err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("vertical-alignment"); got != "middle" {
|
||||
t.Errorf("vertical-alignment = %q, want rewritten to %q", got, "middle")
|
||||
}
|
||||
if !*prevCalled {
|
||||
t.Error("framework PreRunE must keep running first")
|
||||
}
|
||||
|
||||
// Typo: error with suggestion, value untouched.
|
||||
cmd, _ = newCmd()
|
||||
cmd.Flags().Set("vertical-alignment", "botom")
|
||||
err := cmd.PreRunE(cmd, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("typo should fail with *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, `"bottom"`) {
|
||||
t.Errorf("hint should suggest bottom for the typo, got %q", verr.Hint)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("vertical-alignment"); got != "botom" {
|
||||
t.Errorf("typo must not be rewritten, got %q", got)
|
||||
}
|
||||
|
||||
// --print-schema skips enum gating (pure local introspection).
|
||||
cmd, _ = newCmd()
|
||||
cmd.Flags().Set("vertical-alignment", "not-a-value")
|
||||
cmd.Flags().Set("print-schema", "true")
|
||||
if err := cmd.PreRunE(cmd, nil); err != nil {
|
||||
t.Errorf("--print-schema must skip enum gating, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// shortcutFromRegistry returns the fully wired shortcut (PostMount
|
||||
// ergonomics included) as Shortcuts() exposes it to the framework.
|
||||
func shortcutFromRegistry(t *testing.T, command string) common.Shortcut {
|
||||
t.Helper()
|
||||
for _, sc := range Shortcuts() {
|
||||
if sc.Command == command {
|
||||
return sc
|
||||
}
|
||||
}
|
||||
t.Fatalf("shortcut %q not found in Shortcuts()", command)
|
||||
return common.Shortcut{}
|
||||
}
|
||||
|
||||
// TestShortcuts_FlagErgonomicsMounted verifies the ergonomics ride every
|
||||
// mounted sheets command end-to-end: enum vocabulary normalizes on a real
|
||||
// invocation, and unknown flags answer with the inlined valid-flag list.
|
||||
func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("enum alias normalizes through a real run", 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",
|
||||
"--vertical-alignment", "center",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("center should normalize to middle and pass, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "middle") || strings.Contains(stdout, "center") {
|
||||
t.Errorf("dry-run body should carry the normalized value, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("enum typo errors with suggestion", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set-style")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1:A1",
|
||||
"--vertical-alignment", "botom",
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, `invalid value "botom" for --vertical-alignment`)
|
||||
if !strings.Contains(ve.Hint, `"bottom"`) {
|
||||
t.Errorf("hint should suggest bottom, got %q", ve.Hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown flag inlines valid flags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cols-resize")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--cols", "A:D",
|
||||
})
|
||||
ve := requireValidation(t, err, `unknown flag "--cols"`)
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package sheets
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
@@ -53,7 +54,7 @@ func loadFlagSchemas() (*flagSchemaIndex, error) {
|
||||
flagSchemasOnce.Do(func() {
|
||||
var idx flagSchemaIndex
|
||||
if err := json.Unmarshal(flagSchemasJSON, &idx); err != nil {
|
||||
parseFlagErr = errs.NewInternalError(errs.SubtypeUnknown, "flag-schemas.json: %v", err).WithCause(err)
|
||||
parseFlagErr = fmt.Errorf("flag-schemas.json: %w", err)
|
||||
return
|
||||
}
|
||||
if idx.Flags == nil {
|
||||
|
||||
@@ -5,7 +5,6 @@ package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -98,22 +97,11 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
|
||||
// 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.
|
||||
// A shallow type mismatch means the caller misremembered the overall
|
||||
// container shape (the classic {"cells": ...} wrapper around what
|
||||
// must be a bare 2D array), so inline a skeleton of the expected
|
||||
// shape — that fixes the retry without a --print-schema round trip.
|
||||
// Deeper failures keep the --print-schema pointer, which dumps the
|
||||
// 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.
|
||||
var tm *typeMismatchError
|
||||
if errors.As(vErr, &tm) && 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)
|
||||
}
|
||||
}
|
||||
// Point them straight at --print-schema, which dumps the exact JSON
|
||||
// Schema for this (command, flag) pair. The hint is always actionable:
|
||||
// reaching this branch means entry[name] resolved a schema from the
|
||||
// embedded index, and --print-schema reads that same index, so the
|
||||
// suggested command is guaranteed to print it.
|
||||
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)
|
||||
@@ -255,7 +243,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
|
||||
if schema.Type != "" {
|
||||
if !matchesJSONType(value, schema.Type) {
|
||||
return &typeMismatchError{path: path, expected: schema.Type, got: jsType(value)}
|
||||
return fmt.Errorf("%sexpected type %q, got %q", pathPrefix(path), schema.Type, jsType(value))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,20 +251,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
|
||||
return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum)
|
||||
}
|
||||
if schema.Maximum != nil && num > *schema.Maximum {
|
||||
return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum)
|
||||
}
|
||||
}
|
||||
|
||||
// Array length bounds — only checked when value is an array.
|
||||
if arr, ok := value.([]interface{}); ok {
|
||||
if schema.MinItems != nil && len(arr) < *schema.MinItems {
|
||||
return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems)
|
||||
}
|
||||
if schema.MaxItems != nil && len(arr) > *schema.MaxItems {
|
||||
return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,10 +279,10 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
if !matched {
|
||||
msg := fmt.Sprintf("%svalue %s is not in enum %s",
|
||||
pathPrefix(path), formatJSONValue(value), formatEnum(schema.Enum))
|
||||
if hint := suggestEnumForError(value, schema.Enum); hint != "" {
|
||||
if hint := suggestEnumMatch(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
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,7 +295,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
}
|
||||
}
|
||||
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
|
||||
return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,7 +305,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
if obj, ok := value.(map[string]interface{}); ok {
|
||||
for _, key := range schema.Required {
|
||||
if _, present := obj[key]; !present {
|
||||
return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path))
|
||||
}
|
||||
}
|
||||
if schema.Properties != nil {
|
||||
@@ -369,7 +357,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
sort.Strings(extras)
|
||||
for _, key := range extras {
|
||||
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
|
||||
return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key)
|
||||
}
|
||||
if schema.AdditionalProperties.Schema != nil {
|
||||
child := key
|
||||
@@ -400,126 +388,6 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
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.
|
||||
type typeMismatchError struct {
|
||||
path string
|
||||
expected string
|
||||
got string
|
||||
}
|
||||
|
||||
func (e *typeMismatchError) Error() string {
|
||||
return fmt.Sprintf("%sexpected type %q, got %q", pathPrefix(e.path), e.expected, e.got)
|
||||
}
|
||||
|
||||
// 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
|
||||
// segment; a leading bare key (no bracket) is one segment of its own.
|
||||
func pathDepth(path string) int {
|
||||
depth := strings.Count(path, "[") + strings.Count(path, ".")
|
||||
if path != "" && path[0] != '[' {
|
||||
depth++
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
// Skeleton rendering bounds: a mismatch at depth ≤ 2 is container-shape
|
||||
// confusion worth a skeleton; deeper mismatches are value-level and the
|
||||
// full schema pointer serves better. The skeleton itself stops after
|
||||
// four levels and eight keys per object so it stays one line; a wide
|
||||
// object (> skeletonWideObject keys) collapses its children to type
|
||||
// placeholders so every key stays visible instead of the first branch
|
||||
// eating the whole line.
|
||||
const (
|
||||
skeletonPathDepthLimit = 2
|
||||
skeletonMaxDepth = 4
|
||||
skeletonMaxKeys = 8
|
||||
skeletonWideObject = 2
|
||||
)
|
||||
|
||||
// schemaSkeleton renders a compact single-line sketch of the shape a
|
||||
// schema expects, e.g. [[{"value": …, "formula": "…", …}]] for
|
||||
// +cells-set --cells. Required keys come first, then alphabetical,
|
||||
// capped at skeletonMaxKeys with a trailing … marker. Values render as
|
||||
// their type placeholder; enum strings show the first allowed value.
|
||||
func schemaSkeleton(s *schemaProperty, depth int) string {
|
||||
if s == nil {
|
||||
return "…"
|
||||
}
|
||||
if len(s.OneOf) > 0 && s.Type == "" {
|
||||
return schemaSkeleton(s.OneOf[0], depth)
|
||||
}
|
||||
switch s.Type {
|
||||
case "array":
|
||||
if depth <= 0 {
|
||||
return "[…]"
|
||||
}
|
||||
return "[" + schemaSkeleton(s.Items, depth-1) + "]"
|
||||
case "object":
|
||||
if depth <= 0 || len(s.Properties) == 0 {
|
||||
return "{…}"
|
||||
}
|
||||
keys := skeletonKeys(s)
|
||||
childDepth := depth - 1
|
||||
if len(s.Properties) > skeletonWideObject {
|
||||
childDepth = 0
|
||||
}
|
||||
parts := make([]string, 0, len(keys)+1)
|
||||
for _, k := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%q: %s", k, schemaSkeleton(s.Properties[k], childDepth)))
|
||||
}
|
||||
if len(s.Properties) > len(keys) {
|
||||
parts = append(parts, "…")
|
||||
}
|
||||
return "{" + strings.Join(parts, ", ") + "}"
|
||||
case "string":
|
||||
if len(s.Enum) > 0 {
|
||||
return formatJSONValue(s.Enum[0])
|
||||
}
|
||||
return `"…"`
|
||||
case "number", "integer":
|
||||
return "0"
|
||||
case "boolean":
|
||||
return "false"
|
||||
}
|
||||
return "…"
|
||||
}
|
||||
|
||||
// skeletonKeys picks which object keys a skeleton shows: required keys
|
||||
// first (schema order), then remaining keys alphabetically, capped at
|
||||
// skeletonMaxKeys.
|
||||
func skeletonKeys(s *schemaProperty) []string {
|
||||
keys := make([]string, 0, skeletonMaxKeys)
|
||||
seen := make(map[string]struct{}, skeletonMaxKeys)
|
||||
for _, k := range s.Required {
|
||||
if _, ok := s.Properties[k]; !ok {
|
||||
continue
|
||||
}
|
||||
if len(keys) == skeletonMaxKeys {
|
||||
return keys
|
||||
}
|
||||
keys = append(keys, k)
|
||||
seen[k] = struct{}{}
|
||||
}
|
||||
rest := make([]string, 0, len(s.Properties))
|
||||
for k := range s.Properties {
|
||||
if _, dup := seen[k]; !dup {
|
||||
rest = append(rest, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(rest)
|
||||
for _, k := range rest {
|
||||
if len(keys) == skeletonMaxKeys {
|
||||
break
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func matchesJSONType(value interface{}, expected string) bool {
|
||||
switch expected {
|
||||
case "object":
|
||||
@@ -605,48 +473,25 @@ func joinFormatted(values []interface{}) string {
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// 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
|
||||
// "middle"). Callers auto-apply the result, so it must stay restricted
|
||||
// to unambiguous matches (edit-distance guesses belong in
|
||||
// suggestEnumForError only). Non-string values have no vocabulary
|
||||
// notion. Returns "" when no unambiguous match exists.
|
||||
// suggestEnumMatch returns a "did you mean" candidate when the user's
|
||||
// value differs from an allowed enum entry only in casing — the most
|
||||
// common real-world mistake ("SUM" vs "sum", "True" vs "true"). The
|
||||
// match is restricted to strings; non-string enums (numbers, etc.)
|
||||
// don't have a casing notion. Returns "" when no near-miss exists.
|
||||
func suggestEnumMatch(value interface{}, values []interface{}) string {
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
canon := canonicalEnumValue(s, stringEnumEntries(values))
|
||||
if canon == "" || canon == s { // skip exact-equal (already would have matched).
|
||||
return ""
|
||||
}
|
||||
return canon
|
||||
}
|
||||
|
||||
// stringEnumEntries extracts the string members of a JSON-schema enum
|
||||
// list (mixed-type enums keep only their string entries).
|
||||
func stringEnumEntries(values []interface{}) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
lower := strings.ToLower(s)
|
||||
for _, v := range values {
|
||||
if vs, ok := v.(string); ok {
|
||||
out = append(out, vs)
|
||||
if vs, ok := v.(string); ok && strings.ToLower(vs) == lower {
|
||||
if vs != s { // skip exact-equal (already would have matched).
|
||||
return vs
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// suggestEnumForError picks the "did you mean" candidate for an enum
|
||||
// error message. Unlike suggestEnumMatch (whose result is auto-applied,
|
||||
// so it must stay unambiguous), this one may also draw on edit distance
|
||||
// — the suggestion is only prose, the user still has to re-issue the
|
||||
// value explicitly.
|
||||
func suggestEnumForError(value interface{}, values []interface{}) string {
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return closestEnumValue(s, stringEnumEntries(values))
|
||||
return ""
|
||||
}
|
||||
|
||||
func pathPrefix(path string) string {
|
||||
|
||||
@@ -360,142 +360,6 @@ func TestValidateInputAgainstSchema_RealEnumCaseNormalized(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_EnumAliasNormalized pins the cross-vocabulary
|
||||
// auto-fix: CSS-habit "center" for a vertical alignment unambiguously means
|
||||
// Lark's "middle", so the payload is normalized in place and the call
|
||||
// proceeds — same treatment as the "SUM" vs "sum" casing class.
|
||||
func TestValidateAgainstSchema_EnumAliasNormalized(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"properties":{"vertical_alignment":{"type":"string","enum":["top","middle","bottom"]}}
|
||||
}`)
|
||||
obj := map[string]interface{}{"vertical_alignment": "center"}
|
||||
if err := validateAgainstSchema(obj, schema, ""); err != nil {
|
||||
t.Fatalf("center should normalize to middle and pass, got: %v", err)
|
||||
}
|
||||
if got := obj["vertical_alignment"]; got != "middle" {
|
||||
t.Errorf("vertical_alignment = %q, want normalized to %q", got, "middle")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_EnumTypoSuggestedNotApplied pins the auto-apply
|
||||
// boundary on the error path: an edit-distance typo stays an error with a
|
||||
// "did you mean" suggestion, never a silent rewrite.
|
||||
func TestValidateAgainstSchema_EnumTypoSuggestedNotApplied(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"properties":{"order":{"type":"string","enum":["asc","desc"]}}
|
||||
}`)
|
||||
obj := map[string]interface{}{"order": "ascc"}
|
||||
err := validateAgainstSchema(obj, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("typo must be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `did you mean "asc"?`) {
|
||||
t.Errorf("enum error should suggest asc for the typo, got %q", err.Error())
|
||||
}
|
||||
if got := obj["order"]; got != "ascc" {
|
||||
t.Errorf("typo must not be rewritten, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_ShapeSkeletonOnShallowTypeMismatch
|
||||
// pins the highest-frequency eval failure: passing an object where
|
||||
// --cells expects a 2D array must inline a skeleton of the expected
|
||||
// shape (with the "value" key visible) so an agent fixes the retry
|
||||
// without a --print-schema round trip.
|
||||
func TestValidateValueAgainstSchema_ShapeSkeletonOnShallowTypeMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+cells-set"}
|
||||
err := validateValueAgainstSchema(fv, "cells",
|
||||
map[string]interface{}{"cells": []interface{}{}})
|
||||
if err == nil {
|
||||
t.Fatal("object where array expected must fail")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{`expected type "array", got "object"`, "expected shape: [[{", `"value"`} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("error should contain %q, got %q", want, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Deep value-level mismatch keeps the plain --print-schema pointer
|
||||
// (a whole-shape skeleton would not address the actual problem).
|
||||
deep := []interface{}{[]interface{}{
|
||||
map[string]interface{}{"note": 12.5},
|
||||
}}
|
||||
err = validateValueAgainstSchema(fv, "cells", deep)
|
||||
if err == nil {
|
||||
t.Fatal("wrong type for note must fail")
|
||||
}
|
||||
if strings.Contains(err.Error(), "expected shape:") {
|
||||
t.Errorf("deep mismatch should not inline a skeleton, got %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--print-schema") {
|
||||
t.Errorf("deep mismatch should keep the --print-schema pointer, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathDepth(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
{"", 0},
|
||||
{"[0]", 1},
|
||||
{"[0][3]", 2},
|
||||
{"[0][3].value", 3},
|
||||
{"legend", 1},
|
||||
{"snapshot.axes", 2},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := pathDepth(c.path); got != c.want {
|
||||
t.Errorf("pathDepth(%q) = %d, want %d", c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaSkeleton(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{
|
||||
"type":"array",
|
||||
"items":{
|
||||
"type":"array",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"value":{},
|
||||
"formula":{"type":"string"},
|
||||
"align":{"type":"string","enum":["top","middle","bottom"]},
|
||||
"styles":{"type":"object","properties":{"bold":{"type":"boolean"}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
got := schemaSkeleton(schema, skeletonMaxDepth)
|
||||
// Wide object (>2 keys) collapses nested containers to placeholders;
|
||||
// enum strings surface their first allowed value.
|
||||
want := `[[{"align": "top", "formula": "…", "styles": {…}, "value": …}]]`
|
||||
if got != want {
|
||||
t.Errorf("skeleton = %s, want %s", got, want)
|
||||
}
|
||||
|
||||
narrow := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"required":["sheets"],
|
||||
"properties":{"sheets":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"}}}}}
|
||||
}`)
|
||||
got = schemaSkeleton(narrow, skeletonMaxDepth)
|
||||
// Narrow object (≤2 keys) keeps descending so the inner shape shows.
|
||||
want = `{"sheets": [{"name": "…"}]}`
|
||||
if got != want {
|
||||
t.Errorf("narrow skeleton = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_NilSchemaSafe pins the defensive
|
||||
// `if schema == nil { return nil }` guard. Current production callers
|
||||
// always hand validator a real schema, but the guard means future
|
||||
|
||||
@@ -19,7 +19,6 @@ var commandsWithSchema = map[string]struct{}{
|
||||
"+cells-set-style": {},
|
||||
"+chart-create": {},
|
||||
"+chart-update": {},
|
||||
"+cols-resize": {},
|
||||
"+cond-format-create": {},
|
||||
"+cond-format-update": {},
|
||||
"+dropdown-set": {},
|
||||
@@ -31,7 +30,6 @@ var commandsWithSchema = map[string]struct{}{
|
||||
"+pivot-create": {},
|
||||
"+pivot-update": {},
|
||||
"+range-sort": {},
|
||||
"+rows-resize": {},
|
||||
"+sparkline-create": {},
|
||||
"+sparkline-update": {},
|
||||
"+table-put": {},
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -119,21 +118,20 @@ func (m mapFlagView) lookup(name string) (interface{}, bool) {
|
||||
// lookupRaw resolves a flag name against the user-supplied input only, trying
|
||||
// the exact key then the hyphen↔underscore variants.
|
||||
func (m mapFlagView) lookupRaw(name string) (interface{}, bool) {
|
||||
_, v, ok := m.lookupRawWithKey(name)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (m mapFlagView) lookupRawWithKey(name string) (string, interface{}, bool) {
|
||||
for _, key := range []string{
|
||||
name,
|
||||
strings.ReplaceAll(name, "-", "_"),
|
||||
strings.ReplaceAll(name, "_", "-"),
|
||||
} {
|
||||
if v, ok := m.raw[key]; ok {
|
||||
return key, v, true
|
||||
if v, ok := m.raw[name]; ok {
|
||||
return v, true
|
||||
}
|
||||
if alt := strings.ReplaceAll(name, "-", "_"); alt != name {
|
||||
if v, ok := m.raw[alt]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return "", nil, false
|
||||
if alt := strings.ReplaceAll(name, "_", "-"); alt != name {
|
||||
if v, ok := m.raw[alt]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m mapFlagView) Str(name string) string {
|
||||
@@ -283,65 +281,24 @@ func (m mapFlagView) validateRawTypes() error {
|
||||
// parse time; reject here too to keep batch/standalone parity.
|
||||
f, isNum := val.(float64)
|
||||
if !isNum {
|
||||
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val))
|
||||
}
|
||||
if math.Trunc(f) != f {
|
||||
return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64))
|
||||
}
|
||||
case "float64":
|
||||
if _, isNum := val.(float64); !isNum {
|
||||
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val))
|
||||
}
|
||||
case "bool":
|
||||
if _, isBool := val.(bool); !isBool {
|
||||
return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeAndValidateEnums applies the same flat string-enum contract as the
|
||||
// standalone cobra path. Canonical casing and known aliases are rewritten in
|
||||
// place; unknown values are rejected before a translator can silently fall
|
||||
// back to a different operation.
|
||||
func (m *mapFlagView) normalizeAndValidateEnums() error {
|
||||
defs, err := loadFlagDefs()
|
||||
if err != nil {
|
||||
return nil //nolint:nilerr // match validateRawTypes: missing embedded metadata must not block the batch
|
||||
}
|
||||
spec, ok := defs[m.command]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind == "system" || df.Type != "string" || len(df.Enum) == 0 {
|
||||
continue
|
||||
}
|
||||
rawKey, raw, changed := m.lookupRawWithKey(df.Name)
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
value, ok := raw.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("--%s must be a string, got %s", df.Name, jsonTypeName(raw)) //nolint:forbidigo // intermediate error; batch dispatcher adds typed operations context
|
||||
}
|
||||
if value == "" || slices.Contains(df.Enum, value) {
|
||||
continue
|
||||
}
|
||||
if canonical := canonicalEnumValue(value, df.Enum); canonical != "" {
|
||||
m.raw[rawKey] = canonical
|
||||
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)
|
||||
}
|
||||
return fmt.Errorf("%s", message) //nolint:forbidigo // intermediate error; batch dispatcher adds typed operations context
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// jsonTypeName names the JSON kind of a value decoded by encoding/json, for
|
||||
// type-mismatch error messages.
|
||||
func jsonTypeName(v interface{}) string {
|
||||
|
||||
@@ -10,7 +10,6 @@ package sheets
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
neturl "net/url"
|
||||
"strings"
|
||||
@@ -45,8 +44,7 @@ func sheetsValidationCauseForFlag(name string, cause error) *errs.ValidationErro
|
||||
// classification and only adds the domain's flag param.
|
||||
func sheetsInputStatError(flag string, err error) error {
|
||||
wrapped := common.WrapInputStatErrorTyped(err)
|
||||
var v *errs.ValidationError
|
||||
if errors.As(wrapped, &v) {
|
||||
if v, ok := wrapped.(*errs.ValidationError); ok {
|
||||
return v.WithParam(sheetsFlagParam(flag))
|
||||
}
|
||||
return wrapped
|
||||
@@ -54,30 +52,21 @@ func sheetsInputStatError(flag string, err error) error {
|
||||
|
||||
// Drive media parent_type values for uploading an image into a spreadsheet.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
|
||||
// synthetic token prefixed with "fake_office_" (being renamed to
|
||||
// "local_office_") and the backend requires "office_sheet_file" instead.
|
||||
// synthetic token prefixed with "fake_office_" and the backend requires
|
||||
// "office_sheet_file" instead.
|
||||
const (
|
||||
sheetImageParentType = "sheet_image"
|
||||
officeSheetFileParentType = "office_sheet_file"
|
||||
fakeOfficePrefix = "fake_office_"
|
||||
localOfficePrefix = "local_office_"
|
||||
fakeOfficeTokenPrefix = "fake_office_"
|
||||
)
|
||||
|
||||
// officePrefixes are the synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
|
||||
// "local_office_"; accept either so image uploads keep working across the
|
||||
// rename.
|
||||
var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix}
|
||||
|
||||
// sheetMediaParentType returns the drive media parent_type to use when
|
||||
// uploading an image whose parent_node is spreadsheetToken. It is the single
|
||||
// place that maps a spreadsheet token to its parent_type so every image-upload
|
||||
// entry point (and its dry-run preview) stays consistent.
|
||||
func sheetMediaParentType(spreadsheetToken string) string {
|
||||
for _, prefix := range officePrefixes {
|
||||
if strings.HasPrefix(spreadsheetToken, prefix) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
return sheetImageParentType
|
||||
}
|
||||
@@ -451,7 +440,7 @@ func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
|
||||
|
||||
// ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─
|
||||
|
||||
// buildCellStyleFromFlags reads the 12 flat style flags and returns the
|
||||
// buildCellStyleFromFlags reads the 11 flat style flags and returns the
|
||||
// cell_styles map expected by set_cell_range. Skips any flag the user
|
||||
// didn't set so partial styles work.
|
||||
func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
|
||||
@@ -462,9 +451,6 @@ func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -215,8 +215,7 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
|
||||
if borderStyles != nil {
|
||||
prototype["border_styles"] = borderStyles
|
||||
}
|
||||
ops := make([]interface{}, 0, len(ranges))
|
||||
var totalCells int64
|
||||
var ops []interface{}
|
||||
for _, rng := range ranges {
|
||||
sheet, sub, err := splitSheetPrefixedRange(rng)
|
||||
if err != nil {
|
||||
@@ -226,13 +225,6 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err)
|
||||
}
|
||||
if err := checkStampMatrixBudget("ranges", rng, rows, cols); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalCells += int64(rows) * int64(cols)
|
||||
if err := checkBatchStampBudget(totalCells); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cells := fillCellsMatrix(rows, cols, prototype)
|
||||
ops = append(ops, map[string]interface{}{
|
||||
"tool_name": "set_cell_range",
|
||||
@@ -307,7 +299,7 @@ func cellsBatchClearInput(runtime *common.RuntimeContext, token string) (map[str
|
||||
return nil, err
|
||||
}
|
||||
clearType := normalizeClearType(runtime.Str("scope"))
|
||||
ops := make([]interface{}, 0, len(ranges))
|
||||
var ops []interface{}
|
||||
for _, rng := range ranges {
|
||||
sheet, sub, err := splitSheetPrefixedRange(rng)
|
||||
if err != nil {
|
||||
@@ -390,10 +382,13 @@ var DropdownDelete = common.Shortcut{
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
// validateDropdownRanges enforces the shared maxBatchRanges cap.
|
||||
if _, err := validateDropdownRanges(runtime); err != nil {
|
||||
ranges, err := validateDropdownRanges(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ranges) > 100 {
|
||||
return sheetsValidationForFlag("ranges", "--ranges accepts at most 100 entries; got %d", len(ranges))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -437,8 +432,7 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
|
||||
}
|
||||
prototype = map[string]interface{}{"data_validation": validation}
|
||||
}
|
||||
ops := make([]interface{}, 0, len(ranges))
|
||||
var totalCells int64
|
||||
var ops []interface{}
|
||||
for _, rng := range ranges {
|
||||
sheet, sub, err := splitSheetPrefixedRange(rng)
|
||||
if err != nil {
|
||||
@@ -448,13 +442,6 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err)
|
||||
}
|
||||
if err := checkStampMatrixBudget("ranges", rng, rows, cols); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalCells += int64(rows) * int64(cols)
|
||||
if err := checkBatchStampBudget(totalCells); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cells := fillCellsMatrix(rows, cols, prototype)
|
||||
ops = append(ops, map[string]interface{}{
|
||||
"tool_name": "set_cell_range",
|
||||
@@ -474,25 +461,6 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
|
||||
|
||||
// ─── helpers resurrected from B3 (used here + future skills) ──────────
|
||||
|
||||
// maxBatchRanges caps how many ranges a fan-out batch (+cells-batch-set-style /
|
||||
// +cells-batch-clear / +dropdown-update / +dropdown-delete) may carry, bounding
|
||||
// the number of ops materialized into one batch_update.
|
||||
const maxBatchRanges = 100
|
||||
|
||||
// checkBatchStampBudget rejects a fan-out batch whose ranges materialize more
|
||||
// than maxStampMatrixCells cells in aggregate. A batch builds every range's
|
||||
// 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 {
|
||||
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",
|
||||
totalCells, maxStampMatrixCells)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDropdownRanges parses --ranges, requires every entry to carry a
|
||||
// sheet prefix, and returns the parsed list.
|
||||
func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
|
||||
@@ -522,9 +490,6 @@ func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
if len(out) > maxBatchRanges {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges accepts at most %d entries; got %d", maxBatchRanges, len(out))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -420,116 +419,6 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestBatchUpdate_PrescriptiveHints(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
opsJSON string
|
||||
wantMatch string
|
||||
wantInHint []string
|
||||
}{
|
||||
{
|
||||
name: "missing shortcut gets entry template",
|
||||
opsJSON: `[{"input":{"range":"A1"}}]`,
|
||||
wantMatch: "'shortcut' field is required",
|
||||
wantInHint: []string{`{"shortcut":"+cells-set"`, `"input"`},
|
||||
},
|
||||
{
|
||||
name: "disallowed shortcut lists the allow-list inline",
|
||||
opsJSON: `[{"shortcut":"+cells-batch-set-style","input":{}}]`,
|
||||
wantMatch: "not allowed in +batch-update",
|
||||
wantInHint: []string{"allowed shortcuts:", "+cells-set-style", "+range-copy"},
|
||||
},
|
||||
{
|
||||
name: "translator failure lists full key contract",
|
||||
opsJSON: `[{"shortcut":"+dim-insert","input":{"sheet_name":"s"}}]`,
|
||||
wantMatch: "--position is required",
|
||||
wantInHint: []string{"+dim-insert input keys:", "sheet_id|sheet_name (choose one)", "position (required)", "count (required)", "inherit_style"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", tc.opsJSON,
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, tc.wantMatch)
|
||||
for _, want := range tc.wantInHint {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranslateBatchOperations_OverLimitSplitHint pins the split
|
||||
// prescription on the 100-entry cap: the hint must say how many batches
|
||||
// the caller should re-issue.
|
||||
func TestTranslateBatchOperations_OverLimitSplitHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops := make([]interface{}, 185)
|
||||
for i := range ops {
|
||||
ops[i] = map[string]interface{}{"shortcut": "+cells-set", "input": map[string]interface{}{}}
|
||||
}
|
||||
_, err := translateBatchOperations(ops, "shtcnX")
|
||||
ve := requireValidation(t, err, "accepts at most 100 entries; got 185")
|
||||
for _, want := range []string{"2 separate +batch-update calls", "at most 100 entries each"} {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateBatchOperations_AggregateCellCap(t *testing.T) {
|
||||
ops := []interface{}{
|
||||
map[string]interface{}{
|
||||
"shortcut": "+cells-set-style",
|
||||
"input": map[string]interface{}{
|
||||
"sheet-id": "sh1", "range": "A1:A100001", "font-weight": "bold",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"shortcut": "+cells-set-style",
|
||||
"input": map[string]interface{}{
|
||||
"sheet-id": "sh1", "range": "A1:A100000", "font-weight": "bold",
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err := translateBatchOperations(ops, "shtcnX")
|
||||
ve := requireValidation(t, err, "materialize 200001 cells total")
|
||||
if ve.Param != "--operations" {
|
||||
t.Fatalf("param = %q, want --operations", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubOpInputContract pins the contract line derivation from flag-defs:
|
||||
// reserved spreadsheet locators are omitted, the sheet selector collapses
|
||||
// to a choose-one, and required flags are marked.
|
||||
func TestSubOpInputContract(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := subOpInputContract("+dim-insert")
|
||||
for _, want := range []string{"sheet_id|sheet_name (choose one)", "position (required)", "count (required)", "inherit_style"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("contract should contain %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
for _, banned := range []string{"url", "spreadsheet_token", "dry_run"} {
|
||||
if strings.Contains(got, banned) {
|
||||
t.Errorf("contract must not expose %q, got %q", banned, got)
|
||||
}
|
||||
}
|
||||
if got := subOpInputContract("+no-such-shortcut"); got != "" {
|
||||
t.Errorf("unknown shortcut should yield empty contract, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchUpdate_DimFreezeInjectsFreeze covers the static-freeze-only
|
||||
// path: +dim-freeze always injects operation=freeze (count==0 unfreeze
|
||||
// path of the single shortcut is intentionally not supported in batch).
|
||||
@@ -558,7 +447,7 @@ func TestBatchUpdate_ResizeNoOperationField(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet_id":"sh1","range":"1:3","height":30}}]`,
|
||||
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet_id":"sh1","range":"1:3","type":"pixel","size":30}}]`,
|
||||
"--yes",
|
||||
})
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_changeset ─────────────────────────────────────────────
|
||||
//
|
||||
// +changeset-get wraps the get_changeset read tool: fetch the raw changeset
|
||||
// (the list of edit actions) between two CS revisions of a spreadsheet, so a
|
||||
// human or reviewing agent can verify whether an AI edit actually fulfilled
|
||||
// the user's request.
|
||||
//
|
||||
// - --start-revision is the "before" baseline (required, >= 1).
|
||||
// - --end-revision is optional; when omitted it defaults to the latest
|
||||
// revision, returning every changeset from start up to now.
|
||||
// - The version gap is capped at 20 (end - start + 1 <= 20); the same cap
|
||||
// is enforced server-side (sheet-facade-agg maxChangesetRevGap).
|
||||
|
||||
const changesetMaxRevGap = 20
|
||||
|
||||
// ChangesetGet fetches the raw changesets between two spreadsheet versions.
|
||||
var ChangesetGet = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+changeset-get",
|
||||
Description: "Fetch the raw changeset (edit actions) between two versions, to review whether an AI edit fulfilled the request.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+changeset-get"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := changesetRevisions(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
input, _ := changesetInput(runtime, token)
|
||||
return invokeToolDryRun(token, ToolKindRead, "get_changeset", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := changesetInput(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_changeset", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"Pass only --start-revision to diff against the latest version; add --end-revision to bound the range.",
|
||||
"The version gap is capped at 20 revisions (end - start + 1 <= 20).",
|
||||
},
|
||||
}
|
||||
|
||||
// changesetRevisions reads and validates the start / end revision flags.
|
||||
// end <= 0 means "not provided" (default to latest, resolved server-side); a
|
||||
// provided end must be >= start and within the 20-revision gap.
|
||||
func changesetRevisions(runtime flagView) (start int, end int, err error) {
|
||||
start = runtime.Int("start-revision")
|
||||
end = runtime.Int("end-revision")
|
||||
if start < 1 {
|
||||
return 0, 0, sheetsValidationForFlag("start-revision", "--start-revision must be >= 1")
|
||||
}
|
||||
if end > 0 {
|
||||
if end < start {
|
||||
return 0, 0, sheetsValidationForFlag("end-revision", "--end-revision (%d) must be >= --start-revision (%d)", end, start)
|
||||
}
|
||||
if end-start+1 > changesetMaxRevGap {
|
||||
return 0, 0, sheetsValidationForFlag("end-revision", "version gap exceeds limit %d (start=%d, end=%d)", changesetMaxRevGap, start, end)
|
||||
}
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
// changesetInput builds the get_changeset tool input. end_revision is only
|
||||
// sent when explicitly provided; otherwise the server defaults to latest.
|
||||
func changesetInput(runtime flagView, token string) (map[string]interface{}, error) {
|
||||
start, end, err := changesetRevisions(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"start_revision": start,
|
||||
}
|
||||
if end > 0 {
|
||||
input["end_revision"] = end
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestChangesetGet_DryRun locks the get_changeset tool input: --end-revision
|
||||
// is only sent when explicitly provided, otherwise the server defaults to the
|
||||
// latest revision.
|
||||
func TestChangesetGet_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantInput map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "start + end bounded range",
|
||||
args: []string{"--url", testURL, "--start-revision", "120", "--end-revision", "135"},
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"start_revision": float64(120),
|
||||
"end_revision": float64(135),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "start only → end omitted (server defaults to latest)",
|
||||
args: []string{"--url", testURL, "--start-revision", "120"},
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"start_revision": float64(120),
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, ChangesetGet, tt.args)
|
||||
got := decodeToolInput(t, body, "get_changeset")
|
||||
assertInputEquals(t, got, tt.wantInput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChangesetGet_Validation covers the client-side revision guards, which
|
||||
// mirror the server cap (sheet-facade-agg maxChangesetRevGap = 20).
|
||||
func TestChangesetGet_Validation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantMsg string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "start-revision must be >= 1",
|
||||
args: []string{"--url", testURL, "--start-revision", "0"},
|
||||
wantMsg: "start-revision must be >= 1",
|
||||
wantParam: "--start-revision",
|
||||
},
|
||||
{
|
||||
name: "end before start rejected",
|
||||
args: []string{"--url", testURL, "--start-revision", "100", "--end-revision", "50"},
|
||||
wantMsg: "end-revision",
|
||||
wantParam: "--end-revision",
|
||||
},
|
||||
{
|
||||
name: "gap over 20 rejected",
|
||||
args: []string{"--url", testURL, "--start-revision", "1", "--end-revision", "30"},
|
||||
wantMsg: "version gap exceeds limit",
|
||||
wantParam: "--end-revision",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, ChangesetGet, append(c.args, "--dry-run"))
|
||||
validationErr := requireValidation(t, err, c.wantMsg)
|
||||
if validationErr.Param != c.wantParam {
|
||||
t.Errorf("param = %q, want %q", validationErr.Param, c.wantParam)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_formula_verify ───────────────────────────────────────
|
||||
//
|
||||
// Wraps verify_formula (read): scan formulas + cell error states across one
|
||||
// or more sub-sheets and aggregate Excel errors (#REF! / #DIV/0! / #VALUE! /
|
||||
// #NAME? / #NULL! / #NUM! / #N/A) plus compile failures (formula_errors)
|
||||
// into a recalc.py-shaped JSON status report. The contract is the single
|
||||
// AI self-check entry point for the R10 "write → verify zero-error"
|
||||
// invariant — see canonical-spec/references/lark_sheet_formula_verify/.
|
||||
|
||||
// FormulaVerify wraps verify_formula. Sheet selection is optional (both
|
||||
// --sheet-id and --sheet-name are repeatable); when omitted, the tool scans
|
||||
// every visible sub-sheet's current_region.
|
||||
var FormulaVerify = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+formula-verify",
|
||||
Description: "Scan formulas / cell errors and return a recalc.py-shaped status report (success / errors_found / partial).",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+formula-verify"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateFormulaVerifySheetSelector(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateFormulaVerifyLimits(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
return invokeToolDryRun(token, ToolKindRead, "verify_formula", formulaVerifyInput(runtime, token))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "verify_formula", formulaVerifyInput(runtime, token))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
if runtime.Bool("exit-on-error") {
|
||||
return formulaVerifyExitOnError(out)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// validateFormulaVerifySheetSelector enforces XOR-like guarantees on the
|
||||
// two multi-value selectors: at most one of --sheet-id / --sheet-name may be
|
||||
// non-empty (passing both is the high-frequency reflex confusion when the
|
||||
// caller cargo-cults the single-sheet shortcut signature). Both empty is the
|
||||
// documented "scan every visible sub-sheet" path. Control-char checks reuse
|
||||
// requireSheetSelector's logic on each item.
|
||||
func validateFormulaVerifySheetSelector(runtime *common.RuntimeContext) error {
|
||||
ids := nonEmptySliceItems(runtime.StrSlice("sheet-id"))
|
||||
names := nonEmptySliceItems(runtime.StrSlice("sheet-name"))
|
||||
if len(ids) > 0 && len(names) > 0 {
|
||||
return common.ValidationErrorf("--sheet-id and --sheet-name are mutually exclusive; pick one selector to identify sub-sheets").
|
||||
WithParams(
|
||||
sheetsInvalidParam("sheet-id", "mutually exclusive"),
|
||||
sheetsInvalidParam("sheet-name", "mutually exclusive"),
|
||||
)
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := requireSheetSelector(id, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, name := range names {
|
||||
if err := requireSheetSelector("", name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFormulaVerifyLimits rejects non-positive caps so a misplaced 0 or
|
||||
// negative flag value can't silently degrade the scan (the server-side
|
||||
// default would otherwise mask the typo).
|
||||
func validateFormulaVerifyLimits(runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("max-locations") && runtime.Int("max-locations") <= 0 {
|
||||
return sheetsValidationForFlag("max-locations", "--max-locations must be > 0")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nonEmptySliceItems trims and drops blanks from a repeated-flag value so
|
||||
// `--sheet-id ""` doesn't masquerade as a real entry.
|
||||
func nonEmptySliceItems(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for _, v := range in {
|
||||
if trimmed := strings.TrimSpace(v); trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// formulaVerifyInput builds the verify_formula tool input map from CLI flags.
|
||||
// excel_id is required; everything else is optional per the schema.
|
||||
func formulaVerifyInput(runtime *common.RuntimeContext, token string) map[string]interface{} {
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
}
|
||||
if ids := nonEmptySliceItems(runtime.StrSlice("sheet-id")); len(ids) > 0 {
|
||||
input["sheet_ids"] = ids
|
||||
} else if names := nonEmptySliceItems(runtime.StrSlice("sheet-name")); len(names) > 0 {
|
||||
// The verify_formula schema only declares sheet_ids; the facade
|
||||
// accepts sheet_names as a parallel optional field so name-based
|
||||
// selection works without forcing the caller to pre-resolve. Mirrors
|
||||
// how the other read shortcuts pack both fields via
|
||||
// sheetSelectorForToolInput.
|
||||
input["sheet_names"] = names
|
||||
}
|
||||
if ranges := nonEmptySliceItems(runtime.StrSlice("range")); len(ranges) > 0 {
|
||||
input["ranges"] = ranges
|
||||
}
|
||||
if runtime.Changed("max-locations") {
|
||||
input["max_locations_per_error"] = runtime.Int("max-locations")
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// formulaVerifyExitOnError converts a verify_formula status into a non-zero
|
||||
// CLI exit when the caller passed --exit-on-error. status="errors_found"
|
||||
// is the only failure mode for this flag: "partial" means truncated but the
|
||||
// scanned slice is clean, and "success" is obviously clean. A missing /
|
||||
// unknown status is treated as a typed internal error because the tool's
|
||||
// schema guarantees the field and we don't want a silent zero-exit.
|
||||
func formulaVerifyExitOnError(out interface{}) error {
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"verify_formula: missing status field in tool output")
|
||||
}
|
||||
status, _ := m["status"].(string)
|
||||
switch status {
|
||||
case "success", "partial":
|
||||
return nil
|
||||
case "errors_found":
|
||||
total, _ := util.ToFloat64(m["total_errors"])
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"verify_formula: %d formula error(s) detected; resolve and re-run", int(total)).
|
||||
WithHint("inspect error_summary[*] / compile_errors[*] in the JSON output, fix or wrap with IFERROR, then re-run +formula-verify until status=success")
|
||||
default:
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"verify_formula: unexpected status %q", status)
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// TestFormulaVerify_DryRun pins the wire shape verify_formula sends for the
|
||||
// common input combinations: no selector (workbook-wide scan), explicit
|
||||
// sheet_ids, explicit ranges, and the optional max_locations_per_error
|
||||
// field. The test exercises the One-OpenAPI body
|
||||
// directly so the schema field names stay locked to the canonical
|
||||
// tool-schemas.json verify_formula node.
|
||||
func TestFormulaVerify_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantInput map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "no selector — workbook-wide scan defaults",
|
||||
args: []string{"--url", testURL},
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sheet_ids multi via repeat",
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--sheet-id", testSheetID2},
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_ids": []interface{}{testSheetID, testSheetID2},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sheet_names multi via comma",
|
||||
args: []string{"--url", testURL, "--sheet-name", "Sheet1,Sheet2"},
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_names": []interface{}{"Sheet1", "Sheet2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ranges + max_locations",
|
||||
args: []string{
|
||||
"--url", testURL,
|
||||
"--range", "A1:Z200",
|
||||
"--range", "AA1:AZ100",
|
||||
"--max-locations", "5",
|
||||
},
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"ranges": []interface{}{"A1:Z200", "AA1:AZ100"},
|
||||
"max_locations_per_error": float64(5),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, FormulaVerify, tt.args)
|
||||
got := decodeToolInput(t, body, "verify_formula")
|
||||
assertInputEquals(t, got, tt.wantInput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormulaVerify_DryRunInvokeReadPath confirms the request hits
|
||||
// invoke_read (read scope) and not invoke_write — a scope mismatch here would
|
||||
// surface as a 403 from the gateway.
|
||||
func TestFormulaVerify_DryRunInvokeReadPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, FormulaVerify, []string{"--url", testURL})
|
||||
if len(calls) == 0 {
|
||||
t.Fatalf("dry-run produced no api calls")
|
||||
}
|
||||
call, _ := calls[0].(map[string]interface{})
|
||||
url, _ := call["url"].(string)
|
||||
if !strings.HasSuffix(url, "/tools/invoke_read") {
|
||||
t.Errorf("verify_formula must hit invoke_read; got url=%q", url)
|
||||
}
|
||||
if want := "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_read"; url != want {
|
||||
t.Errorf("url = %q, want %q", url, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormulaVerify_RejectsBothSelectors locks the "at most one selector"
|
||||
// rule on the two multi-value flags. Both empty is the documented
|
||||
// workbook-wide scan path, so we only reject the both-supplied case.
|
||||
func TestFormulaVerify_RejectsBothSelectors(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, FormulaVerify, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-id", testSheetID,
|
||||
"--sheet-name", "Sheet1",
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, "mutually exclusive")
|
||||
gotParams := map[string]bool{}
|
||||
for _, p := range ve.Params {
|
||||
gotParams[p.Name] = true
|
||||
}
|
||||
if !gotParams["--sheet-id"] || !gotParams["--sheet-name"] {
|
||||
t.Errorf("params = %#v, want both --sheet-id and --sheet-name flagged", ve.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormulaVerify_RejectsNonPositiveLimits guards against typos like
|
||||
// `--max-locations 0`, which would otherwise be silently swallowed by the
|
||||
// "explicit value but unset" comparison in the input builder.
|
||||
func TestFormulaVerify_RejectsNonPositiveLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "max-locations=0",
|
||||
args: []string{"--url", testURL, "--max-locations", "0"},
|
||||
want: "--max-locations must be > 0",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, FormulaVerify, append(c.args, "--dry-run"))
|
||||
requireValidation(t, err, c.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormulaVerifyExitOnError_StatusMatrix locks the --exit-on-error
|
||||
// contract: success/partial → no error; errors_found → typed validation
|
||||
// error with SubtypeFailedPrecondition; missing or unknown status →
|
||||
// typed internal error so a silent zero-exit can never happen.
|
||||
func TestFormulaVerifyExitOnError_StatusMatrix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("success returns no error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := formulaVerifyExitOnError(map[string]interface{}{"status": "success"}); err != nil {
|
||||
t.Fatalf("success path returned err: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("partial returns no error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := formulaVerifyExitOnError(map[string]interface{}{"status": "partial", "has_more": true}); err != nil {
|
||||
t.Fatalf("partial path returned err: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("errors_found yields failed_precondition with count", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := formulaVerifyExitOnError(map[string]interface{}{
|
||||
"status": "errors_found",
|
||||
"total_errors": float64(7),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if !strings.Contains(ve.Message, "7 formula error") {
|
||||
t.Errorf("message %q must surface the error count", ve.Message)
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Errorf("hint must be set so AI agents know to re-run after fixes")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown status maps to internal/invalid_response", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := formulaVerifyExitOnError(map[string]interface{}{"status": "weird"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("category/subtype = %q/%q, want internal/invalid_response", p.Category, p.Subtype)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-object output maps to internal/invalid_response", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := formulaVerifyExitOnError("oops")
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("category/subtype = %q/%q, want internal/invalid_response", p.Category, p.Subtype)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_history (BE-1: +history-list) ─────────────────────────
|
||||
//
|
||||
// Wraps the facade-agg `history_list` tool (read) behind the One-OpenAPI
|
||||
// invoke_read endpoint. The tool returns a sheet's version history. The
|
||||
// facade-agg tool already performs the response transform (minor_histories
|
||||
// trim / id → history_version_id / 4-field projection / RFC3339 create_time),
|
||||
// so the CLI passes the tool output straight through and does NOT re-implement
|
||||
// the transform client-side.
|
||||
//
|
||||
// History is workbook-level (no sheet selector), mirroring +workbook-info:
|
||||
// the only locator is --url / --spreadsheet-token (XOR), with --token accepted
|
||||
// as a parse-time alias for --spreadsheet-token via the shared PostMount hook.
|
||||
|
||||
// historyLocatorFlags is the --url / --spreadsheet-token XOR locator pair
|
||||
// shared by the three history shortcuts. Mirrors +workbook-info's flag-defs
|
||||
// entry; XOR is enforced in Validate via parseSpreadsheetRef, not by Required.
|
||||
func historyLocatorFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "url", Type: "string", Desc: "Spreadsheet locator (a /sheets/ or /wiki/ URL)."},
|
||||
{Name: "spreadsheet-token", Type: "string", Desc: "Spreadsheet locator (raw spreadsheet token)."},
|
||||
}
|
||||
}
|
||||
|
||||
// HistoryList wraps the history_list tool: list a spreadsheet's history
|
||||
// versions. Each item carries history_version_id / create_time / action /
|
||||
// all_block_revision (projected server-side). An empty sheet yields an empty
|
||||
// list and exit 0.
|
||||
//
|
||||
// Backward pagination: --end-version (optional int) maps to the tool's
|
||||
// `end_version` parameter. Omit on the first call to fetch the latest page.
|
||||
// On subsequent pages pass the previous response's next_end_version as
|
||||
// --end-version. The tool returns next_end_version + has_more only when
|
||||
// more history exists; both fields are absent at the earliest page.
|
||||
var HistoryList = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+history-list",
|
||||
Description: "List a spreadsheet's edit history versions (history_version_id, create_time, action, all_block_revision).",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: append(historyLocatorFlags(),
|
||||
common.Flag{Name: "end-version", Type: "int", Desc: "Max version to query (descending pagination). Omit on the first call; pass the previous response's next_end_version on subsequent pages."},
|
||||
),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := resolveSpreadsheetToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
return invokeToolDryRun(token, ToolKindRead, "history_list", historyListInput(runtime, token))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "history_list", historyListInput(runtime, token))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Pass the tool output through verbatim — facade-agg already shaped it.
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"Capture a history_version_id from the result to feed +history-revert.",
|
||||
"For older history, capture next_end_version from the response and pass it as --end-version on the next call (omitted by the server when the earliest page is reached).",
|
||||
},
|
||||
}
|
||||
|
||||
// historyListInput composes the history_list tool input. --end-version is
|
||||
// optional: include it only when explicitly set so the server treats absence
|
||||
// as "first page (latest)".
|
||||
func historyListInput(runtime *common.RuntimeContext, token string) map[string]interface{} {
|
||||
in := map[string]interface{}{"excel_id": token}
|
||||
if runtime.Changed("end-version") {
|
||||
in["end_version"] = runtime.Int("end-version")
|
||||
}
|
||||
return in
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_history (BE-2: +history-revert / +history-revert-status) ──
|
||||
//
|
||||
// Two thin callTool wrappers over the facade-agg history tools:
|
||||
// - +history-revert → history_revert (write) — async revert
|
||||
// - +history-revert-status → history_revert_status (read) — poll outcome
|
||||
//
|
||||
// Both target a single history version via --history-version-id (the id
|
||||
// surfaced by +history-list). Revert is asynchronous: it returns a receipt /
|
||||
// transaction id that +history-revert-status then polls, distinguishing
|
||||
// in-progress / success / failure from the tool output (passed through
|
||||
// verbatim — no client-side shaping).
|
||||
//
|
||||
// ⚠️ Backend state: the facade-agg history_revert / history_revert_status
|
||||
// tools are registered but their downstream RPC wiring is a DEFERRED
|
||||
// follow-up; today they return a "not wired yet" guard error from the gateway,
|
||||
// which surfaces here as a normal tool error. These CLI shortcuts are correct
|
||||
// thin wrappers and will work end-to-end once the backend follow-up lands —
|
||||
// this is NOT a CLI blocker. See self_check.md.
|
||||
|
||||
func historyRevertFlags() []common.Flag {
|
||||
return flagsFor("+history-revert")
|
||||
}
|
||||
|
||||
// validateHistoryVersionID enforces the required, control-char-clean
|
||||
// --history-version-id. Returns the trimmed value so callers reuse it.
|
||||
func validateHistoryVersionID(runtime *common.RuntimeContext) (string, error) {
|
||||
id := strings.TrimSpace(runtime.Str("history-version-id"))
|
||||
if id == "" {
|
||||
return "", sheetsValidationForFlag("history-version-id", "--history-version-id is required")
|
||||
}
|
||||
if err := validate.RejectControlChars(id, "--history-version-id"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func historyRevertInput(token, versionID string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"history_version_id": versionID,
|
||||
}
|
||||
}
|
||||
|
||||
func historyRevertStatusFlags() []common.Flag {
|
||||
return flagsFor("+history-revert-status")
|
||||
}
|
||||
|
||||
// validateTransactionID enforces the required, trimmed --transaction-id and
|
||||
// returns it for reuse.
|
||||
func validateTransactionID(runtime *common.RuntimeContext) (string, error) {
|
||||
id := strings.TrimSpace(runtime.Str("transaction-id"))
|
||||
if id == "" {
|
||||
return "", sheetsValidationForFlag("transaction-id", "--transaction-id is required")
|
||||
}
|
||||
if err := validate.RejectControlChars(id, "--transaction-id"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func historyRevertStatusInput(token, transactionID string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"transaction_id": transactionID,
|
||||
}
|
||||
}
|
||||
|
||||
// HistoryRevert wraps the history_revert tool (write): asynchronously revert a
|
||||
// spreadsheet to the given history version. --history-version-id is required
|
||||
// at the cli surface (cobra MarkFlagRequired); a missing flag fails before
|
||||
// Validate runs with cobra's standard "required flag(s)" error (which the
|
||||
// dispatcher classifies as a typed *errs.ValidationError, exit 2). We still
|
||||
// trim + reject empty / control-char values in Validate to catch the
|
||||
// case where cobra accepts --history-version-id with an empty-string value.
|
||||
var HistoryRevert = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+history-revert",
|
||||
Description: "Revert a spreadsheet to a given history version (asynchronous; poll with +history-revert-status).",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: historyRevertFlags(),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := validateHistoryVersionID(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
versionID := strings.TrimSpace(runtime.Str("history-version-id"))
|
||||
return invokeToolDryRun(token, ToolKindWrite, "history_revert", historyRevertInput(token, versionID))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
versionID, err := validateHistoryVersionID(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "history_revert", historyRevertInput(token, versionID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"Revert overwrites the current spreadsheet content. Always run with --dry-run first to verify the target spreadsheet and history_version_id.",
|
||||
"Revert is asynchronous — pass the returned id to +history-revert-status to track in-progress / success / failure.",
|
||||
},
|
||||
}
|
||||
|
||||
// HistoryRevertStatus wraps the history_revert_status tool (read): poll the
|
||||
// outcome of a prior +history-revert. The tool output distinguishes
|
||||
// in-progress / success / failure and is passed through verbatim.
|
||||
var HistoryRevertStatus = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+history-revert-status",
|
||||
Description: "Poll the status of a history revert (in-progress / success / failure).",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: historyRevertStatusFlags(),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := validateTransactionID(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
txnID := strings.TrimSpace(runtime.Str("transaction-id"))
|
||||
return invokeToolDryRun(token, ToolKindRead, "history_revert_status", historyRevertStatusInput(token, txnID))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
txnID, err := validateTransactionID(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "history_revert_status", historyRevertStatusInput(token, txnID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestHistoryShortcuts_DryRun asserts each history shortcut targets the right
|
||||
// facade-agg tool, routes through the correct read/write invoke endpoint, and
|
||||
// builds the expected tool input (excel_id always; history_version_id for the
|
||||
// revert pair).
|
||||
func TestHistoryShortcuts_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const versionID = "histVER123"
|
||||
const txnID = "txn-abc-123"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sc common.Shortcut
|
||||
args []string
|
||||
toolName string
|
||||
wantPath string // invoke_read | invoke_write suffix
|
||||
wantInput map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "+history-list via --url",
|
||||
sc: HistoryList,
|
||||
args: []string{"--url", testURL},
|
||||
toolName: "history_list",
|
||||
wantPath: "invoke_read",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+history-list via --spreadsheet-token",
|
||||
sc: HistoryList,
|
||||
args: []string{"--spreadsheet-token", testToken},
|
||||
toolName: "history_list",
|
||||
wantPath: "invoke_read",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+history-list paginates with --end-version",
|
||||
sc: HistoryList,
|
||||
args: []string{"--url", testURL, "--end-version", "12345"},
|
||||
toolName: "history_list",
|
||||
wantPath: "invoke_read",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"end_version": float64(12345), // post-JSON-unmarshal numeric type
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+history-revert routes to invoke_write with version id",
|
||||
sc: HistoryRevert,
|
||||
args: []string{"--url", testURL, "--history-version-id", versionID},
|
||||
toolName: "history_revert",
|
||||
wantPath: "invoke_write",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"history_version_id": versionID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+history-revert-status routes to invoke_read with transaction id",
|
||||
sc: HistoryRevertStatus,
|
||||
args: []string{"--url", testURL, "--transaction-id", txnID},
|
||||
toolName: "history_revert_status",
|
||||
wantPath: "invoke_read",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"transaction_id": txnID,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
callURL := dryRunFirstCallURL(t, tt.sc, tt.args)
|
||||
if !containsSuffix(callURL, tt.wantPath) {
|
||||
t.Errorf("invoke url = %q, want suffix %q", callURL, tt.wantPath)
|
||||
}
|
||||
body := parseDryRunBody(t, tt.sc, tt.args)
|
||||
got := decodeToolInput(t, body, tt.toolName)
|
||||
assertInputEquals(t, got, tt.wantInput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryRevert_MissingRequiredFlag asserts each shortcut rejects a
|
||||
// missing required selector before any request is sent, with two distinct
|
||||
// gates by design:
|
||||
//
|
||||
// - +history-revert: --history-version-id is cobra-required (Required=true
|
||||
// in the flag def → MarkFlagRequired). cobra refuses the call before
|
||||
// Validate runs with a plain "required flag(s)" error; the cmd dispatcher
|
||||
// classifies it as a typed *errs.ValidationError (invalid_argument, exit 2).
|
||||
// The test rig invokes the shortcut via cmd.Execute and observes the raw
|
||||
// cobra error directly (no dispatcher wrap), so we assert the cobra text
|
||||
// contract instead of the typed envelope.
|
||||
//
|
||||
// - +history-revert-status: --transaction-id is cobra-optional;
|
||||
// requiredness is enforced inside Validate so we still get a typed,
|
||||
// flag-tagged *errs.ValidationError with Param="--transaction-id".
|
||||
func TestHistoryRevert_MissingRequiredFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(HistoryRevert.Command, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, HistoryRevert, []string{"--url", testURL})
|
||||
if err == nil {
|
||||
t.Fatalf("%s: expected error for missing --history-version-id", HistoryRevert.Command)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "required flag(s)") || !strings.Contains(msg, "history-version-id") {
|
||||
t.Fatalf("%s: cobra error = %q, want substrings 'required flag(s)' and 'history-version-id'", HistoryRevert.Command, msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(HistoryRevertStatus.Command, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, HistoryRevertStatus, []string{"--url", testURL})
|
||||
if err == nil {
|
||||
t.Fatalf("%s: expected error for missing --transaction-id", HistoryRevertStatus.Command)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "required flag(s)") || !strings.Contains(msg, "transaction-id") {
|
||||
t.Fatalf("%s: cobra error = %q, want substrings 'required flag(s)' and 'transaction-id'", HistoryRevertStatus.Command, msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHistoryRevert_HighRiskWriteRequiresYes(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, HistoryRevert, []string{
|
||||
"--url", testURL,
|
||||
"--history-version-id", "histVER123",
|
||||
})
|
||||
requireProblem(t, err, errs.CategoryConfirmation, errs.SubtypeConfirmationRequired, "")
|
||||
}
|
||||
|
||||
// dryRunFirstCallURL runs the shortcut in --dry-run and returns the first
|
||||
// api call's url, so tests can assert read vs. write endpoint routing.
|
||||
func dryRunFirstCallURL(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)
|
||||
}
|
||||
dryRun := decodeDryRunRaw(t, out)
|
||||
calls, ok := dryRun["api"].([]interface{})
|
||||
if !ok || len(calls) == 0 {
|
||||
t.Fatalf("dry-run api array empty or wrong shape: %#v", dryRun)
|
||||
}
|
||||
call, _ := calls[0].(map[string]interface{})
|
||||
url, _ := call["url"].(string)
|
||||
return url
|
||||
}
|
||||
|
||||
func containsSuffix(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -5,8 +5,6 @@ package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -210,81 +208,37 @@ func mergeInput(runtime flagView, token, sheetID, sheetName, op string, withMerg
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// resize_range exposes two CLI shortcuts, each with two input forms:
|
||||
// resize_range exposes two CLI shortcuts:
|
||||
//
|
||||
// +rows-resize / +cols-resize — set row heights / column widths.
|
||||
//
|
||||
// Uniform form: --range + --height/--width <px>; the pixel mode is implied
|
||||
// so --type can be omitted (or set to `pixel` — equivalent). Non-pixel
|
||||
// modes go through --type standard / --type auto (rows only) and cannot be
|
||||
// combined with the pixel flag. --range is an A1 closed range ("2:10" /
|
||||
// "5" rows or "A:E" / "C" columns); single-element form is expanded to
|
||||
// "N:N" before send because resize_range rejects bare single-element
|
||||
// ranges.
|
||||
//
|
||||
// Map form: --heights / --widths carries a JSON object of per-row/column
|
||||
// sizes ({"A": 100, "C:E": 120, "G": "standard"}) and fans out into one
|
||||
// atomic batch_update of resize_range ops — different sizes for many
|
||||
// rows/columns in a single CLI call, no +batch-update needed. Mutually
|
||||
// exclusive with --range/--height/--width/--type, and not accepted as a
|
||||
// +batch-update sub-op (nested batch_update is unsupported upstream).
|
||||
// +rows-resize / +cols-resize — set row heights / column widths. --type
|
||||
// enum (pixel / standard / [auto]) controls how: --type pixel needs --size,
|
||||
// --type standard restores the sheet default, --type auto auto-fits row
|
||||
// heights (rows only). --range is an A1 closed range ("2:10" / "5" rows or
|
||||
// "A:E" / "C" columns); single-element form is expanded to "N:N" before
|
||||
// send because resize_range rejects bare single-element ranges.
|
||||
//
|
||||
// Wire shape: resize_height / resize_width carries { type, value? }, e.g.
|
||||
// { "type": "pixel", "value": 30 } or { "type": "standard" }.
|
||||
//
|
||||
// Units are pixels. Column widths in Excel character units (openpyxl /
|
||||
// xlsxwriter mental model, px ≈ chars × 8 + 16) are a real agent trap, so
|
||||
// widths below minSaneColumnWidthPx are rejected with a conversion hint.
|
||||
|
||||
// RowsResize wraps resize_range for row heights. Pass --range + --height
|
||||
// <px> for a uniform pixel height, --heights '{"1":50,"2:20":30}' for
|
||||
// per-row heights, or --type standard/auto for non-pixel modes.
|
||||
// RowsResize wraps resize_range for row heights. --type auto enables
|
||||
// auto-fit (rows only); --type pixel requires --size.
|
||||
var RowsResize = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+rows-resize",
|
||||
Description: "Resize rows 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 by pixel / standard / auto (--type pixel needs --size; --range is 1-based A1 like \"2:10\" or \"5\").",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+rows-resize"),
|
||||
Validate: validateViaResize("row"),
|
||||
DryRun: resizeDryRun("row"),
|
||||
Execute: resizeExecute("row"),
|
||||
}
|
||||
|
||||
// ColsResize wraps resize_range for column widths. Pass --range + --width
|
||||
// <px> for a uniform pixel width, --widths '{"A":100,"C:E":120}' for
|
||||
// per-column widths, or --type standard for the default width. Column
|
||||
// widths do not support auto-fit — --type does not accept auto.
|
||||
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).",
|
||||
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"),
|
||||
}
|
||||
|
||||
// resizeDryRun / resizeExecute route a resize shortcut through resizeToolCall
|
||||
// so the uniform form hits resize_range and the map form hits batch_update
|
||||
// with identical inputs in preview and execution.
|
||||
func resizeDryRun(dimension string) func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
toolName, input, _ := resizeToolCall(runtime, token, sheetID, sheetName, dimension)
|
||||
return invokeToolDryRun(token, ToolKindWrite, toolName, input)
|
||||
}
|
||||
}
|
||||
|
||||
func resizeExecute(dimension string) func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
input, _ := resizeInput(runtime, token, sheetID, sheetName, "row")
|
||||
return invokeToolDryRun(token, ToolKindWrite, "resize_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -293,21 +247,62 @@ func resizeExecute(dimension string) func(ctx context.Context, runtime *common.R
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolName, input, err := resizeToolCall(runtime, token, sheetID, sheetName, dimension)
|
||||
input, err := resizeInput(runtime, token, sheetID, sheetName, "row")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, toolName, input)
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "resize_range", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// validateViaResize wires the standalone Validate to resizeToolCall so both
|
||||
// forms (uniform + map) are fully validated before execution.
|
||||
// ColsResize wraps resize_range for column widths. Column widths do not
|
||||
// support auto-fit — --type only accepts pixel / standard.
|
||||
var ColsResize = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cols-resize",
|
||||
Description: "Resize columns by pixel / standard (--type pixel needs --size; --range is column letters like \"A:E\" or \"C\"; no auto for cols).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cols-resize"),
|
||||
Validate: validateViaResize("column"),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := resizeInput(runtime, token, sheetID, sheetName, "column")
|
||||
return invokeToolDryRun(token, ToolKindWrite, "resize_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := resizeInput(runtime, token, sheetID, sheetName, "column")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "resize_range", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// validateViaResize wires the standalone Validate to resizeInput so both
|
||||
// paths (standalone + batch sub-op) emit the same error for missing --type,
|
||||
// malformed --range, or --type auto on columns.
|
||||
func validateViaResize(dimension string) func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
@@ -316,82 +311,17 @@ func validateViaResize(dimension string) func(ctx context.Context, runtime *comm
|
||||
}
|
||||
sheetID := strings.TrimSpace(runtime.Str("sheet-id"))
|
||||
sheetName := strings.TrimSpace(runtime.Str("sheet-name"))
|
||||
_, _, err = resizeToolCall(runtime, token, sheetID, sheetName, dimension)
|
||||
_, err = resizeInput(runtime, token, sheetID, sheetName, dimension)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// resizeToolCall picks the input form: map form (--heights/--widths) builds a
|
||||
// batch_update of resize_range ops; uniform form builds a single resize_range
|
||||
// input. Returns the tool name to invoke alongside its input.
|
||||
func resizeToolCall(runtime flagView, token, sheetID, sheetName, dimension string) (string, map[string]interface{}, error) {
|
||||
if runtime.Changed(sizeMapFlag(dimension)) {
|
||||
input, err := resizeMapInput(runtime, token, sheetID, sheetName, dimension)
|
||||
return "batch_update", input, err
|
||||
}
|
||||
input, err := resizeInput(runtime, token, sheetID, sheetName, dimension)
|
||||
return "resize_range", input, err
|
||||
}
|
||||
|
||||
// nonPixelTypes lists the --type values a given dimension accepts (rows also
|
||||
// accept auto; columns only accept standard). Used to shape the hint printed
|
||||
// when --type is missing or invalid.
|
||||
func nonPixelTypes(dimension string) string {
|
||||
// autoSuffix appends " / auto" to the enum hint for rows.
|
||||
func autoSuffix(dimension string) string {
|
||||
if dimension == "row" {
|
||||
return "standard / auto"
|
||||
return " / auto"
|
||||
}
|
||||
return "standard"
|
||||
}
|
||||
|
||||
// pixelFlag maps a dimension to its pixel-value flag name (--height for rows,
|
||||
// --width for cols). The wire block always emits "pixel" as the mode; the
|
||||
// per-dimension flag name is just the surface knob.
|
||||
func pixelFlag(dimension string) string {
|
||||
if dimension == "row" {
|
||||
return "height"
|
||||
}
|
||||
return "width"
|
||||
}
|
||||
|
||||
// sizeMapFlag maps a dimension to its map-form flag name (--heights for rows,
|
||||
// --widths for cols).
|
||||
func sizeMapFlag(dimension string) string {
|
||||
return pixelFlag(dimension) + "s"
|
||||
}
|
||||
|
||||
// rejectResizeMapInBatch blocks the map form inside +batch-update sub-ops:
|
||||
// it expands into its own batch_update and nesting batch_update is
|
||||
// unsupported upstream. Called by the batch dispatch closures only — the
|
||||
// standalone path routes the map form through resizeMapInput instead.
|
||||
func rejectResizeMapInBatch(fv flagView, dimension string) error {
|
||||
mapFlag := sizeMapFlag(dimension)
|
||||
if !fv.Changed(mapFlag) {
|
||||
return nil
|
||||
}
|
||||
return sheetsValidationForFlag(mapFlag,
|
||||
"%q is not supported inside +batch-update (it expands into its own atomic batch); call %s --%s standalone, or give each sub-op the single-range form (range + %s/type)",
|
||||
mapFlag, commandForDimension(dimension), mapFlag, pixelFlag(dimension))
|
||||
}
|
||||
|
||||
// minSaneColumnWidthPx is the floor below which a column width almost
|
||||
// certainly means the caller thought in Excel character units (openpyxl /
|
||||
// xlsxwriter widths run 8-30 chars) instead of pixels. 10px columns are
|
||||
// unusable; real pixel spacer columns start around 20px.
|
||||
const minSaneColumnWidthPx = 20
|
||||
|
||||
// checkPixelSize validates a pixel value for one dimension. label names the
|
||||
// offending input in the error ("--width" for the uniform flag, "--widths
|
||||
// key \"A\"" for a map entry).
|
||||
func checkPixelSize(dimension, flagName, label string, px int) error {
|
||||
if px <= 0 {
|
||||
return sheetsValidationForFlag(flagName, "%s must be > 0", label)
|
||||
}
|
||||
if dimension == "column" && px < minSaneColumnWidthPx {
|
||||
return sheetsValidationForFlag(flagName,
|
||||
"%s = %dpx is below %dpx and looks like an Excel character-unit width — column widths here are pixels (px ≈ chars × 8 + 16, so %d chars ≈ %dpx)",
|
||||
label, px, minSaneColumnWidthPx, px, px*8+16)
|
||||
}
|
||||
return nil
|
||||
return ""
|
||||
}
|
||||
|
||||
// commandForDimension returns the shortcut command name a given dimension
|
||||
@@ -409,11 +339,6 @@ func commandForDimension(dimension string) string {
|
||||
// dimension (row → digits like "2:10" / "5"; column → letters like "A:E" /
|
||||
// "C"). Single-element form is expanded to "N:N" because resize_range
|
||||
// rejects bare single-element ranges.
|
||||
//
|
||||
// Surface: pixel size goes through --height / --width (dimension-specific).
|
||||
// --type is optional when the pixel flag is present (defaults to "pixel");
|
||||
// explicit --type pixel is accepted and equivalent. --type standard / auto
|
||||
// select non-pixel modes and cannot be combined with the pixel flag.
|
||||
func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
@@ -436,42 +361,29 @@ func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string)
|
||||
if !strings.Contains(rangeStr, ":") {
|
||||
rangeStr = rangeStr + ":" + rangeStr
|
||||
}
|
||||
|
||||
sizeFlag := pixelFlag(dimension)
|
||||
hasSize := runtime.Changed(sizeFlag)
|
||||
typ := strings.TrimSpace(runtime.Str("type"))
|
||||
hasType := typ != ""
|
||||
|
||||
if !hasSize && !hasType {
|
||||
return nil, common.ValidationErrorf("give --%s <px> for a pixel size, or --type %s", sizeFlag, nonPixelTypes(dimension)).WithParams(sheetsInvalidParam(sizeFlag, "required"), sheetsInvalidParam("type", "required"))
|
||||
if typ == "" {
|
||||
return nil, sheetsValidationForFlag("type", "--type is required (pixel / standard%s)", autoSuffix(dimension))
|
||||
}
|
||||
if hasSize && hasType && typ != "pixel" {
|
||||
return nil, common.ValidationErrorf("--%s cannot be combined with --type %s", sizeFlag, typ).WithParams(sheetsInvalidParam(sizeFlag, "mutually exclusive"), sheetsInvalidParam("type", "mutually exclusive"))
|
||||
}
|
||||
if hasType && dimension == "column" && typ == "auto" {
|
||||
if dimension == "column" && typ == "auto" {
|
||||
return nil, sheetsValidationForFlag("type", "--type auto is rows-only (column widths do not support auto-fit); use +rows-resize")
|
||||
}
|
||||
if hasType && typ == "pixel" && !hasSize {
|
||||
return nil, common.ValidationErrorf("--type pixel requires --%s <px>", sizeFlag).WithParams(sheetsInvalidParam("type", "required"), sheetsInvalidParam(sizeFlag, "required"))
|
||||
hasSize := runtime.Changed("size") && runtime.Int("size") > 0
|
||||
if typ == "pixel" && !hasSize {
|
||||
return nil, common.ValidationErrorf("--type pixel requires --size <px>").WithParams(sheetsInvalidParam("type", "required"), sheetsInvalidParam("size", "required"))
|
||||
}
|
||||
|
||||
sizeBlock := map[string]interface{}{}
|
||||
if hasSize {
|
||||
px := runtime.Int(sizeFlag)
|
||||
if err := checkPixelSize(dimension, sizeFlag, "--"+sizeFlag, px); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sizeBlock["type"] = "pixel"
|
||||
sizeBlock["value"] = px
|
||||
} else {
|
||||
sizeBlock["type"] = typ
|
||||
if typ != "pixel" && hasSize {
|
||||
return nil, common.ValidationErrorf("--size is only valid with --type pixel").WithParams(sheetsInvalidParam("size", "mutually exclusive"), sheetsInvalidParam("type", "mutually exclusive"))
|
||||
}
|
||||
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"range": rangeStr,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
sizeBlock := map[string]interface{}{"type": typ}
|
||||
if typ == "pixel" {
|
||||
sizeBlock["value"] = runtime.Int("size")
|
||||
}
|
||||
if dimension == "row" {
|
||||
input["resize_height"] = sizeBlock
|
||||
} else {
|
||||
@@ -480,146 +392,6 @@ func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string)
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// resizeMapInput builds the batch_update input for the map form: every
|
||||
// --heights/--widths entry becomes one resize_range op inside a single atomic
|
||||
// batch. Keys are single rows/columns ("5" / "A") or closed ranges ("2:8" /
|
||||
// "C:E") matching the command's dimension; values are positive pixel ints or
|
||||
// the non-pixel mode strings ("standard", and "auto" for rows). Ops are
|
||||
// sorted by start position so dry-run output and execution order are
|
||||
// deterministic (JSON object order is not preserved by Go maps).
|
||||
func resizeMapInput(runtime flagView, token, sheetID, sheetName, dimension string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mapFlag := sizeMapFlag(dimension)
|
||||
for _, other := range []string{"range", pixelFlag(dimension), "type"} {
|
||||
if runtime.Changed(other) {
|
||||
return nil, common.ValidationErrorf("--%s is a self-contained map; do not combine it with --%s", mapFlag, other).WithParams(sheetsInvalidParam(mapFlag, "mutually exclusive"), sheetsInvalidParam(other, "mutually exclusive"))
|
||||
}
|
||||
}
|
||||
parsed, err := parseJSONFlag(runtime, mapFlag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, ok := parsed.(map[string]interface{})
|
||||
if !ok || parsed == nil {
|
||||
return nil, sheetsValidationForFlag(mapFlag, "--%s must be a JSON object like {\"%s\": 100}", mapFlag, exampleMapKey(dimension))
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil, sheetsValidationForFlag(mapFlag, "--%s must contain at least one entry", mapFlag)
|
||||
}
|
||||
if len(entries) > maxBatchOperations {
|
||||
return nil, sheetsValidationForFlag(mapFlag, "--%s accepts at most %d entries; got %d", mapFlag, maxBatchOperations, len(entries))
|
||||
}
|
||||
|
||||
type resizeOp struct {
|
||||
start int
|
||||
end int
|
||||
rangeKey string
|
||||
input map[string]interface{}
|
||||
}
|
||||
ops := make([]resizeOp, 0, len(entries))
|
||||
seen := make(map[string]string, len(entries)) // normalized range → original key
|
||||
for key, raw := range entries {
|
||||
parsedDim, startIdx, endIdx, err := parseA1Range(key)
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag(mapFlag, "--%s key %q: %v", mapFlag, key, err)
|
||||
}
|
||||
if parsedDim != dimension {
|
||||
want := "row numbers (e.g. \"2:10\")"
|
||||
if dimension == "column" {
|
||||
want = "column letters (e.g. \"A:E\")"
|
||||
}
|
||||
return nil, sheetsValidationForFlag(mapFlag, "--%s key %q is a %s range; %s expects %s", mapFlag, key, parsedDim, commandForDimension(dimension), want)
|
||||
}
|
||||
normalized := strings.TrimSpace(key)
|
||||
if !strings.Contains(normalized, ":") {
|
||||
normalized = normalized + ":" + normalized
|
||||
}
|
||||
if prev, dup := seen[normalized]; dup {
|
||||
return nil, sheetsValidationForFlag(mapFlag, "--%s keys %q and %q target the same range %s; merge them into one entry", mapFlag, prev, key, normalized)
|
||||
}
|
||||
seen[normalized] = key
|
||||
|
||||
sizeBlock := map[string]interface{}{}
|
||||
switch v := raw.(type) {
|
||||
case float64:
|
||||
px := int(v)
|
||||
if float64(px) != v {
|
||||
return nil, sheetsValidationForFlag(mapFlag, "--%s[%q] must be an integer pixel value, got %v", mapFlag, key, v)
|
||||
}
|
||||
if err := checkPixelSize(dimension, mapFlag, fmt.Sprintf("--%s[%q]", mapFlag, key), px); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sizeBlock["type"] = "pixel"
|
||||
sizeBlock["value"] = px
|
||||
case string:
|
||||
mode := strings.TrimSpace(v)
|
||||
if mode == "auto" && dimension == "column" {
|
||||
return nil, sheetsValidationForFlag(mapFlag, "--%s[%q]: \"auto\" is rows-only (column widths do not support auto-fit); estimate a pixel width instead (px ≈ chars × 8 + 16)", mapFlag, key)
|
||||
}
|
||||
if mode != "standard" && !(mode == "auto" && dimension == "row") {
|
||||
return nil, sheetsValidationForFlag(mapFlag, "--%s[%q] = %q is invalid; use a pixel integer or %s", mapFlag, key, v, nonPixelTypes(dimension))
|
||||
}
|
||||
sizeBlock["type"] = mode
|
||||
default:
|
||||
return nil, sheetsValidationForFlag(mapFlag, "--%s[%q] must be a pixel integer or a mode string (%s), got %s", mapFlag, key, nonPixelTypes(dimension), jsonTypeName(raw))
|
||||
}
|
||||
|
||||
opInput := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"range": normalized,
|
||||
}
|
||||
sheetSelectorForToolInput(opInput, sheetID, sheetName)
|
||||
if dimension == "row" {
|
||||
opInput["resize_height"] = sizeBlock
|
||||
} else {
|
||||
opInput["resize_width"] = sizeBlock
|
||||
}
|
||||
ops = append(ops, resizeOp{
|
||||
start: startIdx,
|
||||
end: endIdx,
|
||||
rangeKey: normalized,
|
||||
input: opInput,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(ops, func(i, j int) bool {
|
||||
if ops[i].start != ops[j].start {
|
||||
return ops[i].start < ops[j].start
|
||||
}
|
||||
return ops[i].end < ops[j].end
|
||||
})
|
||||
for i := 1; i < len(ops); i++ {
|
||||
if ops[i].start <= ops[i-1].end {
|
||||
return nil, sheetsValidationForFlag(
|
||||
mapFlag,
|
||||
"--%s ranges %q and %q overlap; use non-overlapping ranges",
|
||||
mapFlag, ops[i-1].rangeKey, ops[i].rangeKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
operations := make([]interface{}, 0, len(ops))
|
||||
for _, op := range ops {
|
||||
operations = append(operations, map[string]interface{}{
|
||||
"tool_name": "resize_range",
|
||||
"input": op.input,
|
||||
})
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": operations,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// exampleMapKey renders a dimension-appropriate sample key for error hints.
|
||||
func exampleMapKey(dimension string) string {
|
||||
if dimension == "row" {
|
||||
return "2:10"
|
||||
}
|
||||
return "A"
|
||||
}
|
||||
|
||||
// ─── transform_range (4 shortcuts) ────────────────────────────────────
|
||||
//
|
||||
// move / copy take --source-range + --target-range (+ optional cross-sheet
|
||||
|
||||
@@ -113,9 +113,9 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+rows-resize --range 1:5 --height 200",
|
||||
name: "+rows-resize --range 1:5 pixel 200",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--height", "200"},
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "pixel", "--size", "200"},
|
||||
toolName: "resize_range",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
@@ -138,7 +138,7 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+cols-resize --range B:D --type standard",
|
||||
name: "+cols-resize --range B:D standard",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "B:D", "--type", "standard"},
|
||||
toolName: "resize_range",
|
||||
@@ -152,22 +152,9 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+cols-resize --range A:C --width 120",
|
||||
name: "+cols-resize --range A:C pixel 120",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--width", "120"},
|
||||
toolName: "resize_range",
|
||||
wantInput: map[string]interface{}{
|
||||
"range": "A:C",
|
||||
"resize_width": map[string]interface{}{
|
||||
"type": "pixel",
|
||||
"value": float64(120),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+cols-resize --type pixel with --width 120 (explicit == implicit)",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "pixel", "--width", "120"},
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "pixel", "--size", "120"},
|
||||
toolName: "resize_range",
|
||||
wantInput: map[string]interface{}{
|
||||
"range": "A:C",
|
||||
@@ -309,194 +296,6 @@ func TestRangeSort_RejectsMalformedKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResize_MapForm covers the --widths/--heights map form: entries fan out
|
||||
// into one atomic batch_update of resize_range ops, sorted by start position
|
||||
// regardless of JSON key order.
|
||||
func TestResize_MapForm(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("+cols-resize --widths mixes pixels, ranges and standard", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, ColsResize, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--widths", `{"G": "standard", "A": 100, "C:E": 120}`,
|
||||
})
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
wantOps := []interface{}{
|
||||
map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{
|
||||
"excel_id": testToken, "sheet_id": testSheetID, "range": "A:A",
|
||||
"resize_width": map[string]interface{}{"type": "pixel", "value": float64(100)},
|
||||
}},
|
||||
map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{
|
||||
"excel_id": testToken, "sheet_id": testSheetID, "range": "C:E",
|
||||
"resize_width": map[string]interface{}{"type": "pixel", "value": float64(120)},
|
||||
}},
|
||||
map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{
|
||||
"excel_id": testToken, "sheet_id": testSheetID, "range": "G:G",
|
||||
"resize_width": map[string]interface{}{"type": "standard"},
|
||||
}},
|
||||
}
|
||||
assertInputEquals(t, input, map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operations": wantOps,
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("+rows-resize --heights mixes pixels, auto and standard", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, RowsResize, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--heights", `{"21": "auto", "1": 50, "2:20": 30}`,
|
||||
})
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
wantOps := []interface{}{
|
||||
map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{
|
||||
"excel_id": testToken, "sheet_id": testSheetID, "range": "1:1",
|
||||
"resize_height": map[string]interface{}{"type": "pixel", "value": float64(50)},
|
||||
}},
|
||||
map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{
|
||||
"excel_id": testToken, "sheet_id": testSheetID, "range": "2:20",
|
||||
"resize_height": map[string]interface{}{"type": "pixel", "value": float64(30)},
|
||||
}},
|
||||
map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{
|
||||
"excel_id": testToken, "sheet_id": testSheetID, "range": "21:21",
|
||||
"resize_height": map[string]interface{}{"type": "auto"},
|
||||
}},
|
||||
}
|
||||
assertInputEquals(t, input, map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operations": wantOps,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestResize_MapFormGuards covers map-form validation: exclusivity with the
|
||||
// uniform flags, key/value shape errors, the char-unit width floor, and the
|
||||
// +batch-update nesting rejection.
|
||||
func TestResize_MapFormGuards(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
sc common.Shortcut
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "--widths rejects --range",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A": 100}`, "--range", "A:C"},
|
||||
want: "--widths is a self-contained map; do not combine it with --range",
|
||||
},
|
||||
{
|
||||
name: "--widths rejects --width",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A": 100}`, "--width", "120"},
|
||||
want: "--widths is a self-contained map; do not combine it with --width",
|
||||
},
|
||||
{
|
||||
name: "--heights rejects --type",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--heights", `{"1": 50}`, "--type", "auto"},
|
||||
want: "--heights is a self-contained map; do not combine it with --type",
|
||||
},
|
||||
{
|
||||
name: "--widths empty object",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{}`},
|
||||
want: "must contain at least one entry",
|
||||
},
|
||||
{
|
||||
name: "--widths row key on cols command",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"2:8": 100}`},
|
||||
want: "+cols-resize expects column letters",
|
||||
},
|
||||
{
|
||||
name: "--heights column key on rows command",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--heights", `{"A": 50}`},
|
||||
want: "+rows-resize expects row numbers",
|
||||
},
|
||||
{
|
||||
name: "--widths duplicate keys A and A:A",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A": 100, "A:A": 120}`},
|
||||
want: "target the same range A:A",
|
||||
},
|
||||
{
|
||||
name: "--widths rejects overlapping ranges with the same start",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A:C": 100, "A:F": 120}`},
|
||||
want: `ranges "A:C" and "A:F" overlap`,
|
||||
},
|
||||
{
|
||||
name: "--heights rejects overlapping ranges with different starts",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--heights", `{"2:10": 30, "5:20": 40}`},
|
||||
want: `ranges "2:10" and "5:20" overlap`,
|
||||
},
|
||||
{
|
||||
name: "--widths char-unit width rejected with conversion hint",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A": 10}`},
|
||||
want: "looks like an Excel character-unit width",
|
||||
},
|
||||
{
|
||||
// The embedded schema (enum ["standard"]) rejects "auto" before the
|
||||
// Go-level rows-only hint; the error steers to --print-schema whose
|
||||
// description explains columns don't support auto.
|
||||
name: "--widths rejects auto (rows-only) via schema",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A": "auto"}`},
|
||||
want: "does not match any of oneOf alternatives",
|
||||
},
|
||||
{
|
||||
name: "--heights rejects unknown mode string via schema",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--heights", `{"1": "fit"}`},
|
||||
want: "does not match any of oneOf alternatives",
|
||||
},
|
||||
{
|
||||
name: "--heights rejects boolean value via schema",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--heights", `{"1": true}`},
|
||||
want: "does not match any of oneOf alternatives",
|
||||
},
|
||||
{
|
||||
name: "--widths bad key syntax",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A1:B2": 100}`},
|
||||
want: "expected pure digits (row number) or letters",
|
||||
},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run"))
|
||||
requireValidation(t, err, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResize_MapFormEntryCap(t *testing.T) {
|
||||
var widths strings.Builder
|
||||
widths.WriteByte('{')
|
||||
for i := 0; i <= maxBatchOperations; i++ {
|
||||
if i > 0 {
|
||||
widths.WriteByte(',')
|
||||
}
|
||||
widths.WriteByte('"')
|
||||
widths.WriteString(columnIndexToLetter(i))
|
||||
widths.WriteString(`":100`)
|
||||
}
|
||||
widths.WriteByte('}')
|
||||
_, _, err := runShortcutCapturingErr(t, ColsResize, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--widths", widths.String(), "--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "accepts at most 100 entries; got 101")
|
||||
}
|
||||
|
||||
func TestResize_TypeAndSizeGuards(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
@@ -506,58 +305,22 @@ func TestResize_TypeAndSizeGuards(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "+rows-resize missing both --height and --type",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5"},
|
||||
want: "give --height <px> for a pixel size, or --type standard / auto",
|
||||
},
|
||||
{
|
||||
name: "+cols-resize missing both --width and --type",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C"},
|
||||
want: "give --width <px> for a pixel size, or --type standard",
|
||||
},
|
||||
{
|
||||
name: "+rows-resize --height rejects --type standard",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--height", "30", "--type", "standard"},
|
||||
want: "--height cannot be combined with --type standard",
|
||||
},
|
||||
{
|
||||
name: "+cols-resize --width rejects --type standard",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--width", "120", "--type", "standard"},
|
||||
want: "--width cannot be combined with --type standard",
|
||||
},
|
||||
{
|
||||
name: "+rows-resize --type pixel without --height",
|
||||
name: "+rows-resize --type pixel without --size",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "pixel"},
|
||||
want: "--type pixel requires --height",
|
||||
want: "--type pixel requires --size",
|
||||
},
|
||||
{
|
||||
name: "+cols-resize --type pixel without --width",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "pixel"},
|
||||
want: "--type pixel requires --width",
|
||||
},
|
||||
{
|
||||
name: "+rows-resize --height must be positive",
|
||||
name: "+rows-resize --type standard with --size",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--height", "0"},
|
||||
want: "--height must be > 0",
|
||||
},
|
||||
{
|
||||
name: "+cols-resize --width below 20px rejected with char-unit hint",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--width", "12"},
|
||||
want: "looks like an Excel character-unit width",
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "standard", "--size", "30"},
|
||||
want: "--size is only valid with --type pixel",
|
||||
},
|
||||
{
|
||||
name: "+cols-resize rejects --type auto",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "auto"},
|
||||
want: "auto", // cobra Enum gate kicks first with "valid values are: standard"
|
||||
want: "auto", // cobra Enum gate kicks first with "valid values are: pixel, standard"
|
||||
},
|
||||
{
|
||||
name: "+rows-resize given column range",
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_revision_get ───────────────────────────────────────────
|
||||
//
|
||||
// RevisionGet is a read-only derivative over get_workbook_structure that
|
||||
// projects out only the document revision (version number). The backend
|
||||
// surfaces `revision` on every read/write tool response, so this shortcut
|
||||
// needs no dedicated backend tool — it issues the lightest existing read
|
||||
// (no range, just the workbook token) and narrows the payload to the single
|
||||
// field callers want.
|
||||
//
|
||||
// The revision is the anchor for recover / undo. Callers that have just run a
|
||||
// write already have it in that write's response; +revision-get is the
|
||||
// explicit, zero-side-effect way to fetch the current value on its own.
|
||||
var RevisionGet = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+revision-get",
|
||||
Description: "Get the spreadsheet's current document revision (version number).",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+revision-get"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := resolveSpreadsheetToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
return invokeToolDryRun(token, ToolKindRead, "get_workbook_structure", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_workbook_structure", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rev, err := projectRevision(out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"revision": rev}, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"The revision is the version anchor for recover / undo; every read and write tool response already carries it.",
|
||||
},
|
||||
}
|
||||
|
||||
// projectRevision narrows a get_workbook_structure response to its `revision`
|
||||
// field. An absent revision means the backend predates revision injection on
|
||||
// read responses; surface that as an explicit error rather than emitting a
|
||||
// silent null.
|
||||
func projectRevision(out interface{}) (interface{}, error) {
|
||||
obj, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"get_workbook_structure returned non-object output")
|
||||
}
|
||||
rev, ok := obj["revision"]
|
||||
if !ok {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"get_workbook_structure did not return a revision (backend may not support it yet)")
|
||||
}
|
||||
return rev, nil
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestRevisionGetProjectRevision(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("extracts revision from a workbook-structure object", func(t *testing.T) {
|
||||
out := map[string]interface{}{
|
||||
"revision": float64(60),
|
||||
"sheets": []interface{}{map[string]interface{}{"sheet_id": "Nh34WX"}},
|
||||
}
|
||||
got, err := projectRevision(out)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != float64(60) {
|
||||
t.Errorf("revision = %v, want 60", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("errors when revision is absent", func(t *testing.T) {
|
||||
out := map[string]interface{}{"sheets": []interface{}{}}
|
||||
_, err := projectRevision(out)
|
||||
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse, "revision")
|
||||
})
|
||||
|
||||
t.Run("errors on a non-object output", func(t *testing.T) {
|
||||
_, err := projectRevision("not-an-object")
|
||||
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse, "non-object")
|
||||
})
|
||||
}
|
||||
@@ -483,11 +483,11 @@ func dimGroupInput(runtime flagView, token, sheetID, sheetName, op string) (map[
|
||||
func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "", 0, 0, fmt.Errorf("range is empty") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
|
||||
return "", 0, 0, fmt.Errorf("range is empty")
|
||||
}
|
||||
parts := strings.Split(s, ":")
|
||||
if len(parts) > 2 {
|
||||
return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
|
||||
return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element")
|
||||
}
|
||||
dim1, idx1, err := parseA1Position(parts[0])
|
||||
if err != nil {
|
||||
@@ -501,10 +501,10 @@ func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error)
|
||||
return "", 0, 0, err
|
||||
}
|
||||
if dim1 != dim2 {
|
||||
return "", 0, 0, fmt.Errorf("cannot mix row (digits) and column (letters) in one range") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
|
||||
return "", 0, 0, fmt.Errorf("cannot mix row (digits) and column (letters) in one range")
|
||||
}
|
||||
if idx2 < idx1 {
|
||||
return "", 0, 0, fmt.Errorf("end position is before start") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
|
||||
return "", 0, 0, fmt.Errorf("end position is before start")
|
||||
}
|
||||
return dim1, idx1, idx2, nil
|
||||
}
|
||||
@@ -515,7 +515,7 @@ func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error)
|
||||
func parseA1Position(s string) (dimension string, idx int, err error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "", 0, fmt.Errorf("position is empty") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
|
||||
return "", 0, fmt.Errorf("position is empty")
|
||||
}
|
||||
isDigits := true
|
||||
isLetters := true
|
||||
@@ -530,14 +530,14 @@ func parseA1Position(s string) (dimension string, idx int, err error) {
|
||||
if isDigits {
|
||||
n, _ := strconv.Atoi(s)
|
||||
if n <= 0 {
|
||||
return "", 0, fmt.Errorf("row number must be >= 1 (got %q)", s) //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
|
||||
return "", 0, fmt.Errorf("row number must be >= 1 (got %q)", s)
|
||||
}
|
||||
return "row", n - 1, nil
|
||||
}
|
||||
if isLetters {
|
||||
return "column", letterToColumnIndex(s), nil
|
||||
}
|
||||
return "", 0, fmt.Errorf("expected pure digits (row number) or letters (column letter), got %q", s) //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
|
||||
return "", 0, fmt.Errorf("expected pure digits (row number) or letters (column letter), got %q", s)
|
||||
}
|
||||
|
||||
// columnIndexToLetter converts a 0-based column index to the spreadsheet
|
||||
|
||||
@@ -63,11 +63,8 @@ var TablePut = common.Shortcut{
|
||||
// --styles is parsed (and aligned against the payload's sheets) up front
|
||||
// so a malformed style item fails before any write lands — mirroring
|
||||
// +workbook-create's Validate.
|
||||
styles, err := parseWorkbookCreateSheetStyles(runtime, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return payload.checkCellBudgetWithStyles(styles)
|
||||
_, err = parseWorkbookCreateSheetStyles(runtime, payload)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return tablePutDryRun(runtime)
|
||||
@@ -320,52 +317,17 @@ func (in *tableSheetIn) normalize(idx int) (tableSheetSpec, error) {
|
||||
// compare against the canonical set.
|
||||
for k := range in.Dtypes {
|
||||
if !seenCol[k] {
|
||||
return tableSheetSpec{}, common.ValidationErrorf("--sheets[%d] %q: dtypes references unknown column %q", idx, in.Name, k).
|
||||
WithHint("%s", columnKeyHint("dtypes", k, in.Columns))
|
||||
return tableSheetSpec{}, common.ValidationErrorf("--sheets[%d] %q: dtypes references unknown column %q", idx, in.Name, k)
|
||||
}
|
||||
}
|
||||
for k := range in.Formats {
|
||||
if !seenCol[k] {
|
||||
return tableSheetSpec{}, common.ValidationErrorf("--sheets[%d] %q: formats references unknown column %q", idx, in.Name, k).
|
||||
WithHint("%s", columnKeyHint("formats", k, in.Columns))
|
||||
return tableSheetSpec{}, common.ValidationErrorf("--sheets[%d] %q: formats references unknown column %q", idx, in.Name, k)
|
||||
}
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// columnKeyHint explains a dtypes/formats key that matched no column. The
|
||||
// dominant failure is Excel habit — keying by column letter (A/B/AA) instead
|
||||
// of the column name — so call that out explicitly; either way, inline the
|
||||
// declared column names so the retry needs no second look at the payload.
|
||||
func columnKeyHint(field, key string, columns []string) string {
|
||||
shown := columns
|
||||
const maxShown = 12
|
||||
suffix := ""
|
||||
if len(shown) > maxShown {
|
||||
shown = shown[:maxShown]
|
||||
suffix = ", …"
|
||||
}
|
||||
list := `"` + strings.Join(shown, `", "`) + `"` + suffix
|
||||
if isColumnLetterKey(key) {
|
||||
return fmt.Sprintf("%s keys must be column names from `columns`, not A1-style column letters; this sheet's columns: %s", field, list)
|
||||
}
|
||||
return fmt.Sprintf("%s keys must exactly match a name in `columns`: %s", field, list)
|
||||
}
|
||||
|
||||
// isColumnLetterKey reports whether key looks like an A1-style column letter
|
||||
// (A, B, AA, …) rather than a real column name.
|
||||
func isColumnLetterKey(key string) bool {
|
||||
if key == "" || len(key) > 3 {
|
||||
return false
|
||||
}
|
||||
for _, r := range key {
|
||||
if r < 'A' || r > 'Z' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *tablePayload) validate() error {
|
||||
if len(p.Sheets) == 0 {
|
||||
return common.ValidationErrorf("--sheets: must contain at least one sheet")
|
||||
@@ -420,55 +382,6 @@ func (p *tablePayload) validate() error {
|
||||
return common.ValidationErrorf("--sheets[%d] %q: mode %q is invalid (want \"overwrite\" or \"append\")", i, s.Name, s.Mode)
|
||||
}
|
||||
}
|
||||
return p.checkCellBudget()
|
||||
}
|
||||
|
||||
// maxTablePutCells bounds how many cells a single +table-put / +workbook-create
|
||||
// write may materialize. Unlike the fan-out stamp cap (maxStampMatrixCells),
|
||||
// these cells come from the caller's own --sheets/--values payload rather than a
|
||||
// range blow-up, so this is a generous OOM guardrail, not a usability limit:
|
||||
// buildSheetMatrix builds the whole rows×cols matrix of per-cell maps in memory
|
||||
// before slicing it into tablePutMaxCellsPerWrite-sized writes, so an unbounded
|
||||
// payload (2.6M cells ≈ 900MB heap, doubled again by json.Marshal) OOMs the
|
||||
// process before the first write leaves.
|
||||
const maxTablePutCells = 1_000_000
|
||||
|
||||
// checkCellBudget rejects a payload whose total materialized cell count across
|
||||
// all sheets exceeds maxTablePutCells. Counted in int64 to stay overflow-safe on
|
||||
// pathological row/column counts.
|
||||
func (p *tablePayload) checkCellBudget() error {
|
||||
var total int64
|
||||
for i := range p.Sheets {
|
||||
total += int64(len(p.Sheets[i].Rows)) * int64(len(p.Sheets[i].Columns))
|
||||
}
|
||||
return checkTablePutCellBudget(total)
|
||||
}
|
||||
|
||||
// checkCellBudgetWithStyles includes the blank cells that cell_styles will add
|
||||
// to each sheet's matrix. It must run before DryRun / Execute pads any matrix.
|
||||
func (p *tablePayload) checkCellBudgetWithStyles(styles *workbookCreateSheetStyles) error {
|
||||
var total int64
|
||||
for i := range p.Sheets {
|
||||
s := &p.Sheets[i]
|
||||
rows, cols := len(s.Rows), len(s.Columns)
|
||||
_, baseCol, baseRow, _ := sheetAnchor(s)
|
||||
if s.Mode == "append" {
|
||||
// Append resolves its real row at Execute time. Zero is a safe upper
|
||||
// bound for the style-driven extent and keeps validation allocation-free.
|
||||
baseRow = 0
|
||||
}
|
||||
rows, cols = matrixDimensionsForStyles(rows, cols, styles.styleFor(i), baseCol, baseRow)
|
||||
total += int64(rows) * int64(cols)
|
||||
}
|
||||
return checkTablePutCellBudget(total)
|
||||
}
|
||||
|
||||
func checkTablePutCellBudget(total int64) error {
|
||||
if total > maxTablePutCells {
|
||||
return common.ValidationErrorf(
|
||||
"--sheets/--values cover %d cells total, over the %d-cell safety cap; split the write across smaller payloads",
|
||||
total, maxTablePutCells)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -645,12 +558,6 @@ var excelEpoch = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)
|
||||
// parser still rejects it cleanly.
|
||||
func isoDateToSerial(s string) (int, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
// Empty cells in a date-typed column are the classic header/total-row
|
||||
// clash with the column-wide dtype declaration; name the three ways
|
||||
// out so the caller does not have to guess what "bad format" means.
|
||||
return 0, fmt.Errorf("date column has an empty cell — drop the empty rows, fill real yyyy-mm-dd dates, or declare the column dtype as object (text)") //nolint:forbidigo // intermediate error; callers wrap it into a typed --sheets/--values validation error with row/column context
|
||||
}
|
||||
if i := strings.Index(s, "T"); i > 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
@@ -736,8 +643,7 @@ func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matrix, err = applyWorkbookCreateStylesToMatrix(matrix, styles, col0, baseRow, fmt.Sprintf("--styles for sheet %q", s.Name))
|
||||
if err != nil {
|
||||
if err := applyWorkbookCreateStylesToMatrix(matrix, styles, col0, baseRow, fmt.Sprintf("--styles for sheet %q", s.Name)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -749,15 +655,11 @@ func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// styles can pad the matrix wider than the declared columns (cell_styles on
|
||||
// blank cells past the data extent), so the written width comes from the
|
||||
// padded matrix, not ncols.
|
||||
writeCols := len(matrix[0])
|
||||
startCol := columnIndexToLetter(col0)
|
||||
endCol := columnIndexToLetter(col0 + writeCols - 1)
|
||||
endCol := columnIndexToLetter(col0 + ncols - 1)
|
||||
allowOverwrite := s.AllowOverwrite == nil || *s.AllowOverwrite
|
||||
|
||||
rowsPerBatch := tablePutMaxCellsPerWrite / writeCols
|
||||
rowsPerBatch := tablePutMaxCellsPerWrite / ncols
|
||||
if rowsPerBatch < 1 {
|
||||
rowsPerBatch = 1
|
||||
}
|
||||
@@ -791,7 +693,7 @@ func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token,
|
||||
"sheet_id": sheetID,
|
||||
"range": fmt.Sprintf("%s%d:%s%d", startCol, baseRow+1, endCol, baseRow+len(matrix)),
|
||||
"data_rows": len(s.Rows),
|
||||
"columns": writeCols,
|
||||
"columns": ncols,
|
||||
"writes": writes,
|
||||
"mode": writeModeName(s),
|
||||
}, nil
|
||||
@@ -883,7 +785,7 @@ func writeTypedSheets(ctx context.Context, runtime *common.RuntimeContext, token
|
||||
s := &payload.Sheets[i]
|
||||
sheetID, ok := byName[s.Name]
|
||||
if !ok {
|
||||
rows, cols := sheetCreateDims(s, styles.styleFor(i))
|
||||
rows, cols := sheetCreateDims(s)
|
||||
sheetID, err = createSheet(ctx, runtime, token, s.Name, rows, cols)
|
||||
if err != nil {
|
||||
return written, fmt.Errorf("creating sheet %q failed: %w", s.Name, err) //nolint:forbidigo // intermediate error; surfaced as a partial_success message string via tablePutPartial, not a typed final error
|
||||
@@ -962,12 +864,10 @@ func createSheet(ctx context.Context, runtime *common.RuntimeContext, token, nam
|
||||
|
||||
// sheetCreateDims sizes a to-be-created sheet to the spec's write range so the
|
||||
// follow-up set_cell_range can't exceed sheet bounds. It accounts for the
|
||||
// start_cell offset, the optional header row, and any --styles extent (so a
|
||||
// cell_styles / merge / resize op past the data still fits the grid). The
|
||||
// backend's 20×200 defaults are kept as floors (ordinary small tables are
|
||||
// created exactly as before) and its hard limits (200 cols, 50000 rows) as
|
||||
// ceilings.
|
||||
func sheetCreateDims(s *tableSheetSpec, styles *workbookCreateStylePayload) (rows, cols int) {
|
||||
// start_cell offset and the optional header row. The backend's 20×200 defaults
|
||||
// are kept as floors (ordinary small tables are created exactly as before) and
|
||||
// its hard limits (200 cols, 50000 rows) as ceilings.
|
||||
func sheetCreateDims(s *tableSheetSpec) (rows, cols int) {
|
||||
_, col0, row0, _ := sheetAnchor(s)
|
||||
cols = col0 + len(s.Columns)
|
||||
rows = row0 + len(s.Rows)
|
||||
@@ -982,19 +882,6 @@ func sheetCreateDims(s *tableSheetSpec, styles *workbookCreateStylePayload) (row
|
||||
if headerOn(s) || (s.Mode == "append" && s.Header == nil) {
|
||||
rows++
|
||||
}
|
||||
// --styles can reach past the data (cell_styles on blank cells get padded
|
||||
// into the matrix and written; merges / resizes run as separate ops). Size
|
||||
// the grid to cover them too. workbookCreateStyleDimensions returns the
|
||||
// extent relative to the anchor, so add the anchor offset back.
|
||||
if styles != nil {
|
||||
styleRows, styleCols := workbookCreateStyleDimensions(styles, col0, row0)
|
||||
if col0+styleCols > cols {
|
||||
cols = col0 + styleCols
|
||||
}
|
||||
if row0+styleRows > rows {
|
||||
rows = row0 + styleRows
|
||||
}
|
||||
}
|
||||
if cols < 20 {
|
||||
cols = 20
|
||||
}
|
||||
@@ -1092,6 +979,8 @@ func tablePutDryRun(runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
for i := range payload.Sheets {
|
||||
s := &payload.Sheets[i]
|
||||
matrix, _ := buildSheetMatrix(s, headerOn(s))
|
||||
desc := fmt.Sprintf("write sheet %q (%d data rows × %d cols, mode=%s) via set_cell_range",
|
||||
s.Name, len(s.Rows), len(s.Columns), writeModeName(s))
|
||||
rng := tablePutFullRange(s, len(matrix))
|
||||
if s.Mode == "append" {
|
||||
rng = "<append below existing data>"
|
||||
@@ -1099,23 +988,10 @@ func tablePutDryRun(runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
// cell_styles are merged into the matrix only for overwrite mode,
|
||||
// where the anchor row is known statically; append's base row is
|
||||
// resolved at execute time, so the preview leaves the matrix bare
|
||||
// (the merges / sizes ops below still render). Padding can widen /
|
||||
// lengthen the matrix past the data, so recompute the range from the
|
||||
// padded dims to match what Execute writes.
|
||||
// (the merges / sizes ops below still render).
|
||||
_, col0, row0, _ := sheetAnchor(s)
|
||||
matrix, _ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
|
||||
if len(matrix) > 0 {
|
||||
rng = fmt.Sprintf("%s%d:%s%d",
|
||||
columnIndexToLetter(col0), row0+1,
|
||||
columnIndexToLetter(col0+len(matrix[0])-1), row0+len(matrix))
|
||||
}
|
||||
_ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
|
||||
}
|
||||
writeCols := len(s.Columns)
|
||||
if len(matrix) > 0 {
|
||||
writeCols = len(matrix[0])
|
||||
}
|
||||
desc := fmt.Sprintf("write sheet %q (%d data rows × %d cols, mode=%s) via set_cell_range",
|
||||
s.Name, len(s.Rows), writeCols, writeModeName(s))
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"sheet_name": s.Name,
|
||||
|
||||
@@ -54,67 +54,6 @@ func TestTablePut_IsoDateToSerial(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_EmptyDatePrescription pins the empty-cell branch: the error
|
||||
// must name the three ways out (drop rows / fill dates / object dtype)
|
||||
// instead of the generic "must be ISO" parse failure.
|
||||
func TestTablePut_EmptyDatePrescription(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, in := range []string{"", " "} {
|
||||
_, err := isoDateToSerial(in)
|
||||
if err == nil {
|
||||
t.Fatalf("isoDateToSerial(%q) should fail", in)
|
||||
}
|
||||
for _, want := range []string{"empty cell", "object (text)"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("isoDateToSerial(%q) error should contain %q, got %q", in, want, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_ColumnKeyHint pins the dtypes/formats unknown-column hint:
|
||||
// A1-style letter keys get the Excel-habit callout, and the declared column
|
||||
// names ride inline either way.
|
||||
func TestTablePut_ColumnKeyHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
cols := []string{"姓名", "出生日期"}
|
||||
got := columnKeyHint("dtypes", "A", cols)
|
||||
for _, want := range []string{"not A1-style column letters", `"姓名", "出生日期"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("letter-key hint should contain %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
got = columnKeyHint("formats", "出生 日期", cols)
|
||||
if strings.Contains(got, "A1-style") {
|
||||
t.Errorf("non-letter key must not get the letter callout, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"姓名", "出生日期"`) {
|
||||
t.Errorf("hint should inline column names, got %q", got)
|
||||
}
|
||||
|
||||
many := make([]string, 20)
|
||||
for i := range many {
|
||||
many[i] = fmt.Sprintf("col%02d", i)
|
||||
}
|
||||
got = columnKeyHint("dtypes", "X", many)
|
||||
if !strings.Contains(got, ", …") || strings.Contains(got, "col19") {
|
||||
t.Errorf("hint should truncate long column lists, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsColumnLetterKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]bool{
|
||||
"A": true, "Z": true, "AA": true, "ABC": true,
|
||||
"": false, "ABCD": false, "a": false, "A1": false, "姓名": false,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := isColumnLetterKey(in); got != want {
|
||||
t.Errorf("isColumnLetterKey(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTablePut_BuildTypedCell(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -460,16 +399,6 @@ func TestTablePut_StylesNameMismatchRejected(t *testing.T) {
|
||||
requireValidation(t, err, "must match")
|
||||
}
|
||||
|
||||
func TestTablePut_StylePaddingBudgetRejectedBeforeDryRun(t *testing.T) {
|
||||
_, _, err := runShortcutCapturingErr(t, TablePut, []string{
|
||||
"--url", testURL,
|
||||
"--sheets", `{"sheets":[{"name":"数据","columns":["a"],"data":[["x"]]}]}`,
|
||||
"--styles", `{"styles":[{"name":"数据","cell_styles":[{"range":"A1:AX25000","font_weight":"bold"}]}]}`,
|
||||
"--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "over the 1000000-cell safety cap")
|
||||
}
|
||||
|
||||
// TestTablePut_ExecuteWithStyles drives the full write + visual-ops path: the
|
||||
// set_cell_range write carries the merged cell_styles, then merge_cells /
|
||||
// resize_range tool calls apply the structural styles in the same call.
|
||||
@@ -608,15 +537,14 @@ func TestTablePut_SheetCreateDims(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
spec tableSheetSpec
|
||||
styles *workbookCreateStylePayload
|
||||
wantRows, wantCols int
|
||||
}{
|
||||
{"small table keeps 20x200 floor", tableSheetSpec{Columns: cols(3), Rows: rows(5)}, nil, 200, 20},
|
||||
{"wide table grows columns", tableSheetSpec{Columns: cols(37), Rows: rows(22)}, nil, 200, 37},
|
||||
{"long table grows rows", tableSheetSpec{Columns: cols(3), Rows: rows(500)}, nil, 501, 20},
|
||||
{"start_cell offset adds to both", tableSheetSpec{StartCell: "C5", Columns: cols(40), Rows: rows(5)}, nil, 200, 42},
|
||||
{"header:false drops the header row", tableSheetSpec{Header: bp(false), Columns: cols(3), Rows: rows(500)}, nil, 500, 20},
|
||||
{"columns clamp at backend max 200", tableSheetSpec{Columns: cols(250), Rows: rows(5)}, nil, 200, 200},
|
||||
{"small table keeps 20x200 floor", tableSheetSpec{Columns: cols(3), Rows: rows(5)}, 200, 20},
|
||||
{"wide table grows columns", tableSheetSpec{Columns: cols(37), Rows: rows(22)}, 200, 37},
|
||||
{"long table grows rows", tableSheetSpec{Columns: cols(3), Rows: rows(500)}, 501, 20},
|
||||
{"start_cell offset adds to both", tableSheetSpec{StartCell: "C5", Columns: cols(40), Rows: rows(5)}, 200, 42},
|
||||
{"header:false drops the header row", tableSheetSpec{Header: bp(false), Columns: cols(3), Rows: rows(500)}, 500, 20},
|
||||
{"columns clamp at backend max 200", tableSheetSpec{Columns: cols(250), Rows: rows(5)}, 200, 200},
|
||||
// Default headerOn() is false for append mode, but writeSheetData forces
|
||||
// a header when append hits an empty sheet with no explicit Header
|
||||
// choice (so column names aren't lost). sheetCreateDims runs only on
|
||||
@@ -624,22 +552,14 @@ func TestTablePut_SheetCreateDims(t *testing.T) {
|
||||
// match: append + Header=nil ⇒ +1 row. Otherwise an append-near-50000
|
||||
// payload would be created one row short.
|
||||
{"append on new sheet sizes for the forced header row (49999 data rows + 1 header = 50000)",
|
||||
tableSheetSpec{Mode: "append", Columns: cols(3), Rows: rows(49999)}, nil, 50000, 20},
|
||||
tableSheetSpec{Mode: "append", Columns: cols(3), Rows: rows(49999)}, 50000, 20},
|
||||
{"append + Header=false (explicit) does NOT add the forced header row",
|
||||
tableSheetSpec{Mode: "append", Header: bp(false), Columns: cols(3), Rows: rows(50)}, nil, 200, 20},
|
||||
// --styles reaching past the data grows the grid so a cell_styles op on a
|
||||
// blank cell (or a merge / resize) still fits after the create.
|
||||
{"styles past the data grow the grid",
|
||||
tableSheetSpec{Columns: cols(3), Rows: rows(5)},
|
||||
&workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{{Range: "A1:Z400"}}}, 400, 26},
|
||||
{"styles inside the data don't shrink the grid",
|
||||
tableSheetSpec{Columns: cols(3), Rows: rows(5)},
|
||||
&workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{{Range: "A1:B2"}}}, 200, 20},
|
||||
tableSheetSpec{Mode: "append", Header: bp(false), Columns: cols(3), Rows: rows(50)}, 200, 20},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
gotRows, gotCols := sheetCreateDims(&tt.spec, tt.styles)
|
||||
gotRows, gotCols := sheetCreateDims(&tt.spec)
|
||||
if gotRows != tt.wantRows || gotCols != tt.wantCols {
|
||||
t.Errorf("sheetCreateDims = (%d rows, %d cols), want (%d, %d)", gotRows, gotCols, tt.wantRows, tt.wantCols)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -124,13 +123,6 @@ func sheetCreateInput(runtime flagView, token string) (map[string]interface{}, e
|
||||
if strings.TrimSpace(runtime.Str("title")) == "" {
|
||||
return nil, common.ValidationErrorf("--title is required")
|
||||
}
|
||||
sheetType := strings.TrimSpace(runtime.Str("type"))
|
||||
if sheetType == "" {
|
||||
sheetType = "sheet"
|
||||
}
|
||||
if sheetType != "sheet" {
|
||||
return nil, common.ValidationErrorf("--type must be 'sheet'")
|
||||
}
|
||||
if n := runtime.Int("row-count"); n < 0 || n > 50000 {
|
||||
return nil, common.ValidationErrorf("--row-count must be between 0 and 50000")
|
||||
}
|
||||
@@ -598,11 +590,8 @@ var WorkbookCreate = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
styles, err := parseWorkbookCreateSheetStyles(runtime, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return payload.checkCellBudgetWithStyles(styles)
|
||||
_, err = parseWorkbookCreateSheetStyles(runtime, payload)
|
||||
return err
|
||||
}
|
||||
// Untyped --values path: parse (and validate) --styles as a single sheet
|
||||
// style item, then synthesize --values into a type-less typed payload —
|
||||
@@ -645,26 +634,16 @@ var WorkbookCreate = common.Shortcut{
|
||||
s := &payload.Sheets[i]
|
||||
matrix, _ := buildSheetMatrix(s, headerOn(s))
|
||||
_, col0, row0, _ := sheetAnchor(s)
|
||||
matrix, _ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
|
||||
// Padding can widen / lengthen the matrix past the data, so build the
|
||||
// range from the padded dims to match what Execute writes.
|
||||
rng := tablePutFullRange(s, len(matrix))
|
||||
writeCols := len(s.Columns)
|
||||
if len(matrix) > 0 {
|
||||
writeCols = len(matrix[0])
|
||||
rng = fmt.Sprintf("%s%d:%s%d",
|
||||
columnIndexToLetter(col0), row0+1,
|
||||
columnIndexToLetter(col0+writeCols-1), row0+len(matrix))
|
||||
}
|
||||
_ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
|
||||
input := map[string]interface{}{
|
||||
"excel_id": "<new-token>",
|
||||
"sheet_name": s.Name,
|
||||
"range": rng,
|
||||
"range": tablePutFullRange(s, len(matrix)),
|
||||
"cells": matrix,
|
||||
}
|
||||
wireBody, _ := buildToolBody("set_cell_range", input)
|
||||
dry.POST("/open-apis/sheet_ai/v2/spreadsheets/<new-token>/tools/invoke_write").
|
||||
Desc(fmt.Sprintf("write sheet %q (%d data rows × %d cols) via set_cell_range", s.Name, len(s.Rows), writeCols)).
|
||||
Desc(fmt.Sprintf("write sheet %q (%d data rows × %d cols) via set_cell_range", s.Name, len(s.Rows), len(s.Columns))).
|
||||
Body(wireBody)
|
||||
appendWorkbookCreateVisualOpsDryRun(dry, "<new-token>", "", s.Name, sheetStyles.styleFor(i))
|
||||
}
|
||||
@@ -843,9 +822,6 @@ func buildValuesPayload(runtime flagView, sheetStyles *workbookCreateSheetStyles
|
||||
if maxCols == 0 || nrows == 0 {
|
||||
return nil, nil // nothing to write (e.g. --values '[]' with no styles)
|
||||
}
|
||||
if err := checkTablePutCellBudget(int64(nrows) * int64(maxCols)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Pad to a rectangle; nil cells become empty cells in buildTypedCell.
|
||||
for len(rows) < nrows {
|
||||
rows = append(rows, nil)
|
||||
@@ -860,19 +836,13 @@ func buildValuesPayload(runtime flagView, sheetStyles *workbookCreateSheetStyles
|
||||
cols[i] = tableColumnSpec{Name: fmt.Sprintf("col%d", i+1)} // type-less
|
||||
}
|
||||
noHeader := false
|
||||
payload := &tablePayload{Sheets: []tableSheetSpec{{
|
||||
return &tablePayload{Sheets: []tableSheetSpec{{
|
||||
Name: valuesSheetName,
|
||||
Mode: "overwrite",
|
||||
Header: &noHeader,
|
||||
Columns: cols,
|
||||
Rows: rows,
|
||||
}}}
|
||||
// --values bypasses tablePayload.validate(), so enforce the cell budget here
|
||||
// too — otherwise a giant --values array materializes unbounded.
|
||||
if err := payload.checkCellBudget(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}}}, nil
|
||||
}
|
||||
|
||||
// parseValuesRows decodes --values (JSON 2D array, with @file/stdin already
|
||||
@@ -1169,14 +1139,10 @@ func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]work
|
||||
}
|
||||
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 nil, common.ValidationErrorf("%s[%d].type is required (pixel/standard%s)", path, i, autoSuffix(dimension))
|
||||
}
|
||||
if dimension == "column" && resizeType == "auto" {
|
||||
return nil, common.ValidationErrorf("%s[%d].type auto is rows-only", path, i)
|
||||
@@ -1184,7 +1150,7 @@ func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]work
|
||||
switch resizeType {
|
||||
case "pixel", "standard", "auto":
|
||||
default:
|
||||
return nil, common.ValidationErrorf("%s[%d].type %q is invalid (want %s)", path, i, resizeType, typeHint)
|
||||
return nil, common.ValidationErrorf("%s[%d].type %q is invalid (want pixel/standard%s)", path, i, resizeType, autoSuffix(dimension))
|
||||
}
|
||||
size := 0
|
||||
if raw, ok := op["size"]; ok {
|
||||
@@ -1280,7 +1246,7 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
|
||||
|
||||
func workbookCreateCellStyleField(name string) bool {
|
||||
switch name {
|
||||
case "font_color", "font_family", "font_size", "font_weight", "font_style", "font_line",
|
||||
case "font_color", "font_size", "font_weight", "font_style", "font_line",
|
||||
"background_color", "horizontal_alignment", "vertical_alignment",
|
||||
"number_format", "word_wrap":
|
||||
return true
|
||||
@@ -1391,76 +1357,20 @@ func workbookCreateStyleDimensions(styles *workbookCreateStylePayload, baseCol,
|
||||
return rows, cols
|
||||
}
|
||||
|
||||
// matrixDimensionsForStyles projects the padded matrix size without allocating
|
||||
// it. Only cell_styles contribute; merges and row/column sizes use separate API
|
||||
// calls. Ranges up/left of the write anchor are left for the caller to reject.
|
||||
func matrixDimensionsForStyles(rows, cols int, styles *workbookCreateStylePayload, baseCol, baseRow int) (int, int) {
|
||||
func applyWorkbookCreateStylesToMatrix(rows [][]interface{}, styles *workbookCreateStylePayload, baseCol, baseRow int, label string) error {
|
||||
if styles == nil {
|
||||
return rows, cols
|
||||
return nil
|
||||
}
|
||||
for _, op := range styles.CellStyles {
|
||||
startCol, startRow, endCol, endRow, err := workbookCreateStyleRangeBounds(op.Range)
|
||||
if err != nil || startCol < baseCol || startRow < baseRow {
|
||||
continue // unparsable, or up/left of the anchor: not paddable
|
||||
}
|
||||
if endCol-baseCol+1 > cols {
|
||||
cols = endCol - baseCol + 1
|
||||
}
|
||||
if endRow-baseRow+1 > rows {
|
||||
rows = endRow - baseRow + 1
|
||||
}
|
||||
}
|
||||
return rows, cols
|
||||
}
|
||||
|
||||
// padMatrixForStyles grows the matrix down and right to the projected style
|
||||
// extent, appending empty cells that cell_styles can mutate in place.
|
||||
func padMatrixForStyles(rows [][]interface{}, styles *workbookCreateStylePayload, baseCol, baseRow int) [][]interface{} {
|
||||
needCols := 0
|
||||
if len(rows) > 0 {
|
||||
needCols = len(rows[0])
|
||||
}
|
||||
needRows, needCols := matrixDimensionsForStyles(len(rows), needCols, styles, baseCol, baseRow)
|
||||
// Widen existing rows to needCols.
|
||||
for r := range rows {
|
||||
for len(rows[r]) < needCols {
|
||||
rows[r] = append(rows[r], map[string]interface{}{})
|
||||
}
|
||||
}
|
||||
// Append full empty rows to reach needRows.
|
||||
for len(rows) < needRows {
|
||||
row := make([]interface{}, needCols)
|
||||
for c := range row {
|
||||
row[c] = map[string]interface{}{}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// applyWorkbookCreateStylesToMatrix pads the matrix to cover the cell_styles
|
||||
// ranges (see padMatrixForStyles), merges each op's style into the covered
|
||||
// cells, and returns the padded matrix. A range that starts left of / above the
|
||||
// write anchor can't be padded to and is rejected.
|
||||
func applyWorkbookCreateStylesToMatrix(rows [][]interface{}, styles *workbookCreateStylePayload, baseCol, baseRow int, label string) ([][]interface{}, error) {
|
||||
if styles == nil {
|
||||
return rows, nil
|
||||
}
|
||||
rows = padMatrixForStyles(rows, styles, baseCol, baseRow)
|
||||
for i, op := range styles.CellStyles {
|
||||
startCol, startRow, endCol, endRow, err := workbookCreateStyleRangeBounds(op.Range)
|
||||
if err != nil {
|
||||
return rows, common.ValidationErrorf("%s[%d].range %q: %v", label, i, op.Range, err)
|
||||
return common.ValidationErrorf("%s[%d].range %q: %v", label, i, op.Range, err)
|
||||
}
|
||||
// After padding, the matrix reaches every range that starts at or after
|
||||
// the anchor; a start left of / above it can't be covered. The endRow /
|
||||
// endCol checks stay as a defensive backstop (padding should have made
|
||||
// them unreachable).
|
||||
if startCol < baseCol || startRow < baseRow || len(rows) == 0 ||
|
||||
endRow-baseRow >= len(rows) || endCol-baseCol >= len(rows[0]) {
|
||||
return rows, common.ValidationErrorf("%s[%d].range %q starts outside the write range (its top-left must be at or after %s%d)",
|
||||
if startCol < baseCol || startRow < baseRow || endRow-baseRow >= len(rows) || len(rows) == 0 || endCol-baseCol >= len(rows[0]) {
|
||||
return common.ValidationErrorf("%s[%d].range %q is outside the write range %s%d:%s%d",
|
||||
label, i, op.Range,
|
||||
columnIndexToLetter(baseCol), baseRow+1)
|
||||
columnIndexToLetter(baseCol), baseRow+1,
|
||||
columnIndexToLetter(baseCol+len(rows[0])-1), baseRow+len(rows))
|
||||
}
|
||||
for r := startRow - baseRow; r <= endRow-baseRow; r++ {
|
||||
for c := startCol - baseCol; c <= endCol-baseCol; c++ {
|
||||
@@ -1468,7 +1378,7 @@ func applyWorkbookCreateStylesToMatrix(rows [][]interface{}, styles *workbookCre
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendWorkbookCreateVisualOpsDryRun(dry *common.DryRunAPI, token, sheetID, sheetName string, styles *workbookCreateStylePayload) {
|
||||
@@ -1820,13 +1730,9 @@ func lookupFirstSheetID(ctx context.Context, runtime *common.RuntimeContext, tok
|
||||
//
|
||||
// Imports a local xlsx/xls/csv file as a brand-new spreadsheet. The full
|
||||
// upload → create-task → poll flow is the shared drive import core
|
||||
// (drive.RunImport); this shortcut only pins the target type to "sheet",
|
||||
// omits the bitable-only --target-token, and — because spreadsheet source
|
||||
// files are routinely misnamed (an .xlsx exported/renamed to .xls, etc.) —
|
||||
// sniffs the file's real container so the drive import backend receives the
|
||||
// true file_extension instead of failing with a cryptic
|
||||
// "xml_version_not_support". Symmetric with +workbook-export. Not exposed as
|
||||
// an MCP tool.
|
||||
// (drive.RunImport); this shortcut only pins the target type to "sheet" and
|
||||
// omits the bitable-only --target-token. Symmetric with +workbook-export.
|
||||
// Not exposed as an MCP tool.
|
||||
|
||||
// WorkbookImport imports a local spreadsheet file as a new Feishu spreadsheet
|
||||
// by delegating to the shared drive import core with type fixed to "sheet".
|
||||
@@ -1840,119 +1746,24 @@ var WorkbookImport = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+workbook-import"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
params, err := workbookImportParams(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return drive.ValidateImport(params)
|
||||
return drive.ValidateImport(workbookImportParams(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params, err := workbookImportParams(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
dry := drive.PlanImportDryRun(runtime, params)
|
||||
if note := workbookImportMislabelNote(params); note != "" {
|
||||
dry.Desc(note)
|
||||
}
|
||||
return dry
|
||||
return drive.PlanImportDryRun(runtime, workbookImportParams(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
params, err := workbookImportParams(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if note := workbookImportMislabelNote(params); note != "" {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, note)
|
||||
}
|
||||
return drive.RunImport(ctx, runtime, params)
|
||||
return drive.RunImport(ctx, runtime, workbookImportParams(runtime))
|
||||
},
|
||||
}
|
||||
|
||||
// workbookImportParams builds the drive import request for +workbook-import,
|
||||
// pinning DocType to "sheet". The bitable-only --target-token is intentionally
|
||||
// not exposed here — use drive +import for non-sheet import targets. It also
|
||||
// resolves a corrected file extension via content sniffing (see
|
||||
// correctedWorkbookExtension) and surfaces it through ImportParams.FileExtension.
|
||||
func workbookImportParams(runtime *common.RuntimeContext) (drive.ImportParams, error) {
|
||||
file := runtime.Str("file")
|
||||
params := drive.ImportParams{
|
||||
File: file,
|
||||
// not exposed here — use drive +import for non-sheet import targets.
|
||||
func workbookImportParams(runtime *common.RuntimeContext) drive.ImportParams {
|
||||
return drive.ImportParams{
|
||||
File: runtime.Str("file"),
|
||||
DocType: "sheet",
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
}
|
||||
ext, err := correctedWorkbookExtension(runtime.FileIO(), file)
|
||||
if err != nil {
|
||||
return params, err
|
||||
}
|
||||
params.FileExtension = ext
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// correctedWorkbookExtension returns an override extension when the file's
|
||||
// declared .xls/.xlsx suffix disagrees with its real container, "" when the
|
||||
// declared suffix is correct (or the extension is not in the Excel family, or
|
||||
// the file cannot yet be read). A declared Excel file whose bytes match neither
|
||||
// container yields a prescriptive validation error rather than deferring to the
|
||||
// backend's opaque "xml_version_not_support".
|
||||
func correctedWorkbookExtension(fio fileio.FileIO, filePath string) (string, error) {
|
||||
declared := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".")
|
||||
if declared != "xls" && declared != "xlsx" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
sniffed, ok := sniffWorkbookContainer(fio, filePath)
|
||||
if !ok {
|
||||
// Not readable here; let the drive core's stat/upload surface any error.
|
||||
return "", nil
|
||||
}
|
||||
switch sniffed {
|
||||
case declared:
|
||||
return "", nil
|
||||
case "xls", "xlsx":
|
||||
return sniffed, nil
|
||||
default:
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"file %s has a .%s extension but its content is neither an OOXML (.xlsx) nor a legacy Excel (.xls) workbook; re-save it as a real .xlsx/.xls (or export to .csv) before importing",
|
||||
filePath, declared).WithParam("--file")
|
||||
}
|
||||
}
|
||||
|
||||
// sniffWorkbookContainer inspects a file's leading magic bytes to tell an OOXML
|
||||
// workbook (zip container -> .xlsx) apart from a legacy OLE2/BIFF workbook
|
||||
// (compound document -> .xls). The second return value is false when the file
|
||||
// cannot be read far enough to judge (open error or fewer than the four
|
||||
// discriminating bytes). When true, the format is "xlsx", "xls", or "" (bytes
|
||||
// matching neither container).
|
||||
func sniffWorkbookContainer(fio fileio.FileIO, filePath string) (string, bool) {
|
||||
f, err := fio.Open(filePath)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var head [8]byte
|
||||
n, _ := io.ReadFull(f, head[:])
|
||||
if n < 4 {
|
||||
return "", false
|
||||
}
|
||||
switch {
|
||||
case head[0] == 0x50 && head[1] == 0x4B: // "PK" -> ZIP, i.e. OOXML .xlsx
|
||||
return "xlsx", true
|
||||
case head[0] == 0xD0 && head[1] == 0xCF && head[2] == 0x11 && head[3] == 0xE0: // OLE2 compound doc -> legacy .xls
|
||||
return "xls", true
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
// workbookImportMislabelNote returns a user-facing note when content sniffing
|
||||
// overrode the declared extension, or "" when no correction was applied.
|
||||
func workbookImportMislabelNote(params drive.ImportParams) string {
|
||||
declared := strings.TrimPrefix(strings.ToLower(filepath.Ext(params.File)), ".")
|
||||
if params.FileExtension == "" || params.FileExtension == declared {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("Note: %s has a mislabeled .%s extension but is actually a .%s workbook; importing it as .%s.",
|
||||
filepath.Base(params.File), declared, params.FileExtension, params.FileExtension)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
// chdirTemp switches into a fresh temp dir for the duration of the test and
|
||||
@@ -32,7 +32,7 @@ func chdirTemp(t *testing.T) {
|
||||
// shared drive import core and hard-codes the import target type to "sheet".
|
||||
func TestWorkbookImport_DryRunPinsSheetType(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
if err := os.WriteFile("data.xlsx", []byte("PK\x03\x04fake-xlsx"), 0o644); err != nil {
|
||||
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
@@ -70,89 +70,6 @@ func TestWorkbookImport_RejectsNonSheetFile(t *testing.T) {
|
||||
requireValidation(t, err, "can only be imported")
|
||||
}
|
||||
|
||||
// TestCorrectedWorkbookExtension covers the content-sniffing that corrects (or
|
||||
// rejects) a mislabeled Excel file before its extension reaches the backend.
|
||||
func TestCorrectedWorkbookExtension(t *testing.T) {
|
||||
ooxml := []byte("PK\x03\x04rest") // zip -> .xlsx
|
||||
ole2 := []byte("\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1rest") // compound doc -> .xls
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fileName string
|
||||
content []byte
|
||||
wantExt string // expected override ("" == leave the declared extension)
|
||||
wantErrSub string // non-empty == expect a validation error containing this
|
||||
}{
|
||||
{name: "xlsx content mislabeled as xls corrects to xlsx", fileName: "book.xls", content: ooxml, wantExt: "xlsx"},
|
||||
{name: "xls content mislabeled as xlsx corrects to xls", fileName: "book.xlsx", content: ole2, wantExt: "xls"},
|
||||
{name: "genuine xlsx left untouched", fileName: "book.xlsx", content: ooxml, wantExt: ""},
|
||||
{name: "genuine xls left untouched", fileName: "book.xls", content: ole2, wantExt: ""},
|
||||
{name: "unrecognized content on .xls rejected", fileName: "book.xls", content: []byte("<html><table></table></html>"), wantErrSub: "neither an OOXML"},
|
||||
{name: "non-excel extension never sniffed", fileName: "notes.csv", content: []byte("a,b\n1,2\n"), wantExt: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
if err := os.WriteFile(tt.fileName, tt.content, 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
ext, err := correctedWorkbookExtension(&localfileio.LocalFileIO{}, "./"+tt.fileName)
|
||||
if tt.wantErrSub != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErrSub) {
|
||||
t.Fatalf("error = %v, want substring %q", err, tt.wantErrSub)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ext != tt.wantExt {
|
||||
t.Fatalf("override ext = %q, want %q", ext, tt.wantExt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookImport_DryRunCorrectsMislabeledXls verifies an .xls file whose
|
||||
// bytes are actually OOXML is imported with file_extension=xlsx end to end.
|
||||
func TestWorkbookImport_DryRunCorrectsMislabeledXls(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
if err := os.WriteFile("book.xls", []byte("PK\x03\x04zip-body"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
calls := parseDryRunAPI(t, WorkbookImport, []string{"--file", "./book.xls"})
|
||||
|
||||
var createBody map[string]interface{}
|
||||
for _, c := range calls {
|
||||
cm, _ := c.(map[string]interface{})
|
||||
if u, _ := cm["url"].(string); u == "/open-apis/drive/v1/import_tasks" {
|
||||
createBody, _ = cm["body"].(map[string]interface{})
|
||||
}
|
||||
}
|
||||
if createBody == nil {
|
||||
t.Fatalf("no import_tasks create call in dry-run: %#v", calls)
|
||||
}
|
||||
if createBody["file_extension"] != "xlsx" {
|
||||
t.Errorf("file_extension = %v, want xlsx (mislabeled .xls must be corrected)", createBody["file_extension"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookImport_RejectsUnrecognizedExcel ensures a file whose .xls/.xlsx
|
||||
// name matches neither Excel container is rejected locally with a prescriptive
|
||||
// error rather than deferring to the backend's opaque failure.
|
||||
func TestWorkbookImport_RejectsUnrecognizedExcel(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
if err := os.WriteFile("bogus.xls", []byte("<html>not excel</html>"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
_, _, err := runShortcutCapturingErr(t, WorkbookImport, []string{"--file", "./bogus.xls", "--dry-run"})
|
||||
requireValidation(t, err, "neither an OOXML")
|
||||
}
|
||||
|
||||
// TestWorkbookImport_ExecuteCreatesSheet runs the full upload → create → poll
|
||||
// flow against stubs and asserts the resulting URL is a /sheets/ link.
|
||||
func TestWorkbookImport_ExecuteCreatesSheet(t *testing.T) {
|
||||
|
||||
@@ -281,12 +281,6 @@ func TestWorkbook_Validation(t *testing.T) {
|
||||
args: []string{"--url", testURL, "--title", "X", "--row-count", "999999"},
|
||||
wantMsg: "--row-count must be between",
|
||||
},
|
||||
{
|
||||
name: "+sheet-create rejects hidden bitable type",
|
||||
sc: SheetCreate,
|
||||
args: []string{"--url", testURL, "--title", "Tasks", "--type", "bitable"},
|
||||
wantMsg: `invalid value "bitable" for --type`,
|
||||
},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -402,37 +396,6 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
|
||||
t.Errorf("horizontal_alignment occurrences = %d, want 4 in 2x2 range; cells=%s", got, raw)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cell style past the data pads empty cells so blank cells can be styled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Data is a single cell (A1), but the style targets A1:C3 — the matrix is
|
||||
// padded to 3x3 with empty cells so the style range fits, letting blank
|
||||
// cells carry styling. The written range must reflect the padded extent.
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "X",
|
||||
"--values", `[["a"]]`,
|
||||
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:C3","background_color":"#FFEEAA"}]}]}`,
|
||||
})
|
||||
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
if input["range"] != "A1:C3" {
|
||||
t.Errorf("range = %v, want A1:C3 (padded to the style extent)", input["range"])
|
||||
}
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
if len(cells) != 3 {
|
||||
t.Fatalf("cells rows = %d, want 3 (padded); cells=%#v", len(cells), input["cells"])
|
||||
}
|
||||
lastRow, _ := cells[2].([]interface{})
|
||||
if len(lastRow) != 3 {
|
||||
t.Fatalf("last row width = %d, want 3 (padded)", len(lastRow))
|
||||
}
|
||||
// A blank padded cell (C3) still carries the style.
|
||||
c3, _ := lastRow[2].(map[string]interface{})
|
||||
c3s, _ := c3["cell_styles"].(map[string]interface{})
|
||||
if c3s["background_color"] != "#FFEEAA" {
|
||||
t.Errorf("padded blank cell C3 style = %#v, want background_color", c3)
|
||||
}
|
||||
})
|
||||
t.Run("style-only payload (cell_merges) still fills and emits merge_cells", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Previously workbookCreateStyleDimensions only counted cell_styles, so a
|
||||
@@ -632,60 +595,3 @@ func deepEqualJSON(a, b interface{}) bool {
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
|
||||
// TestApplyWorkbookCreateStylesToMatrix covers the pad-then-style behavior
|
||||
// directly: a style range past the data grows the matrix with empty cells (so
|
||||
// blank cells can be styled), an in-range style leaves the matrix size alone,
|
||||
// and a range up/left of the anchor — which padding can't reach — is rejected.
|
||||
func TestApplyWorkbookCreateStylesToMatrix(t *testing.T) {
|
||||
t.Parallel()
|
||||
cell := func() interface{} { return map[string]interface{}{} }
|
||||
|
||||
t.Run("pads down and right for a style past the data", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
matrix := [][]interface{}{{cell()}} // 1x1 data
|
||||
styles := &workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{
|
||||
{Range: "A1:C3", Style: map[string]interface{}{"cell_styles": map[string]interface{}{"background_color": "#FFEEAA"}}},
|
||||
}}
|
||||
out, err := applyWorkbookCreateStylesToMatrix(matrix, styles, 0, 0, "--styles")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(out) != 3 || len(out[0]) != 3 || len(out[2]) != 3 {
|
||||
t.Fatalf("padded matrix = %d rows x %v cols, want 3x3", len(out), out)
|
||||
}
|
||||
// A blank padded corner (C3) carries the style.
|
||||
c3, _ := out[2][2].(map[string]interface{})
|
||||
c3s, _ := c3["cell_styles"].(map[string]interface{})
|
||||
if c3s["background_color"] != "#FFEEAA" {
|
||||
t.Errorf("padded cell C3 = %#v, want background_color", c3)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no pad when the style is within the data", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
matrix := [][]interface{}{{cell(), cell()}, {cell(), cell()}} // 2x2 data
|
||||
styles := &workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{
|
||||
{Range: "A1:B2", Style: map[string]interface{}{"cell_styles": map[string]interface{}{"font_weight": "bold"}}},
|
||||
}}
|
||||
out, err := applyWorkbookCreateStylesToMatrix(matrix, styles, 0, 0, "--styles")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(out) != 2 || len(out[0]) != 2 {
|
||||
t.Errorf("matrix = %dx%d, want 2x2 (no pad)", len(out), len(out[0]))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects a range up/left of the anchor", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
matrix := [][]interface{}{{cell()}} // anchored at C3 (col 2, row 2)
|
||||
styles := &workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{
|
||||
{Range: "A1", Style: map[string]interface{}{"cell_styles": map[string]interface{}{"font_weight": "bold"}}},
|
||||
}}
|
||||
_, err := applyWorkbookCreateStylesToMatrix(matrix, styles, 2, 2, "--styles")
|
||||
if err == nil || !strings.Contains(err.Error(), "starts outside the write range") {
|
||||
t.Errorf("err = %v, want 'starts outside the write range'", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -111,10 +111,10 @@ func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[stri
|
||||
|
||||
// CellsSetStyle stamps a single style block across every cell in --range.
|
||||
// Style is composed from a dozen flat flags (background-color, font-color,
|
||||
// font-family, font-size, font-style, font-weight, font-line,
|
||||
// horizontal-alignment, vertical-alignment, word-wrap, number-format) plus
|
||||
// --border-styles for the only field that still needs a nested object. At
|
||||
// least one flag must be set.
|
||||
// font-size, font-style, font-weight, font-line, horizontal-alignment,
|
||||
// vertical-alignment, word-wrap, number-format) plus --border-styles for
|
||||
// the only field that still needs a nested object. At least one flag must
|
||||
// be set.
|
||||
var CellsSetStyle = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cells-set-style",
|
||||
@@ -165,9 +165,6 @@ func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag("range", "--range %q: %v", rangeStr, err)
|
||||
}
|
||||
if err := checkStampMatrixBudget("range", rangeStr, rows, cols); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := requireAnyStyleFlag(runtime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -453,9 +450,6 @@ func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[s
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag("range", "--range %q: %v", rangeStr, err)
|
||||
}
|
||||
if err := checkStampMatrixBudget("range", rangeStr, rows, cols); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
validation, err := buildDropdownValidation(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -631,23 +625,23 @@ func rangeDimensions(rangeStr string) (rows, cols int, err error) {
|
||||
}
|
||||
rangeStr = strings.TrimSpace(rangeStr)
|
||||
if rangeStr == "" {
|
||||
return 0, 0, fmt.Errorf("empty range") //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
|
||||
return 0, 0, fmt.Errorf("empty range")
|
||||
}
|
||||
parts := strings.SplitN(rangeStr, ":", 2)
|
||||
if len(parts) == 1 {
|
||||
// single cell, e.g. "A1"
|
||||
if _, _, ok := splitCellRef(parts[0]); !ok {
|
||||
return 0, 0, fmt.Errorf("invalid cell ref %q", parts[0]) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
|
||||
return 0, 0, fmt.Errorf("invalid cell ref %q", parts[0])
|
||||
}
|
||||
return 1, 1, nil
|
||||
}
|
||||
startCol, startRow, ok1 := splitCellRef(parts[0])
|
||||
endCol, endRow, ok2 := splitCellRef(parts[1])
|
||||
if !ok1 || !ok2 {
|
||||
return 0, 0, fmt.Errorf("unsupported range form %q (need rectangular A1:B2)", rangeStr) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
|
||||
return 0, 0, fmt.Errorf("unsupported range form %q (need rectangular A1:B2)", rangeStr)
|
||||
}
|
||||
if endRow < startRow || endCol < startCol {
|
||||
return 0, 0, fmt.Errorf("end %q must be at or after start %q", parts[1], parts[0]) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
|
||||
return 0, 0, fmt.Errorf("end %q must be at or after start %q", parts[1], parts[0])
|
||||
}
|
||||
return endRow - startRow + 1, endCol - startCol + 1, nil
|
||||
}
|
||||
@@ -698,31 +692,9 @@ func letterToColumnIndex(letters string) int {
|
||||
return n - 1
|
||||
}
|
||||
|
||||
// maxStampMatrixCells bounds how many per-cell maps a fan-out / stamp shortcut
|
||||
// will materialize from a single A1 range. The backing tools take an explicit
|
||||
// cells matrix, so the CLI must expand a range like "A1:Z100000" into rows×cols
|
||||
// maps before sending it — an unbounded blow-up (2.6M cells ≈ 900MB heap, then
|
||||
// doubled again by json.Marshal) that OOMs the process before the request even
|
||||
// leaves. The 200000 ceiling is the selected fan-out guardrail; the separately
|
||||
// documented --max-cells flag defaults to 50000.
|
||||
const maxStampMatrixCells = 200000
|
||||
|
||||
// checkStampMatrixBudget rejects a range whose materialized cell count would
|
||||
// exceed maxStampMatrixCells, before fillCellsMatrix allocates it. rows*cols is
|
||||
// computed in int64 to stay safe against overflow on pathological ranges.
|
||||
func checkStampMatrixBudget(flagName, rangeStr string, rows, cols int) error {
|
||||
if total := int64(rows) * int64(cols); total > maxStampMatrixCells {
|
||||
return sheetsValidationForFlag(flagName,
|
||||
"range %q covers %d cells, over the %d-cell safety cap; narrow the range or split it across smaller ranges",
|
||||
rangeStr, total, maxStampMatrixCells)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fillCellsMatrix returns a rows×cols matrix where every cell is the same
|
||||
// (shallow-copied) prototype map. Use for fan-out shortcuts that stamp a
|
||||
// single attribute (style / data_validation) across an entire range.
|
||||
// Callers MUST gate the dimensions through checkStampMatrixBudget first.
|
||||
func fillCellsMatrix(rows, cols int, prototype map[string]interface{}) [][]interface{} {
|
||||
cells := make([][]interface{}, rows)
|
||||
for r := range cells {
|
||||
|
||||
@@ -116,18 +116,6 @@ func TestWriteCellsShortcuts_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCsvPut_MissingCSVFailsRequiredGate(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, CsvPut, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-id", testSheetID,
|
||||
"--start-cell", "A1",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "required flag(s) \"csv\" not set") {
|
||||
t.Fatalf("missing --csv error = %v, want cobra required-flag error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropdownSet_CellsShape inspects the 3×1 matrix produced from
|
||||
// --range A2:A4 to confirm the data_validation prototype is replicated.
|
||||
// Also covers --colors / --highlight emitting the canonical
|
||||
|
||||
@@ -25,8 +25,8 @@ import (
|
||||
|
||||
// TestSheetMediaParentType pins the token→parent_type mapping that every
|
||||
// sheets image-upload entry point funnels through. Native spreadsheet tokens
|
||||
// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_" or
|
||||
// "local_office_" synthetic token and must upload with "office_sheet_file".
|
||||
// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_"
|
||||
// synthetic token and must upload with "office_sheet_file".
|
||||
func TestSheetMediaParentType(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
@@ -36,12 +36,9 @@ func TestSheetMediaParentType(t *testing.T) {
|
||||
}{
|
||||
{"native spreadsheet token", "shtcnABC123", sheetImageParentType},
|
||||
{"empty token", "", sheetImageParentType},
|
||||
{"fake_office imported token", "fake_office_abc123", officeSheetFileParentType},
|
||||
{"fake_office token, only the prefix", fakeOfficePrefix, officeSheetFileParentType},
|
||||
{"local_office imported token", "local_office_abc123", officeSheetFileParentType},
|
||||
{"local_office token, only the prefix", localOfficePrefix, officeSheetFileParentType},
|
||||
{"fake_office prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
|
||||
{"local_office prefix mid-string is not matched", "shtlocal_office_abc", sheetImageParentType},
|
||||
{"office imported token", "fake_office_abc123", officeSheetFileParentType},
|
||||
{"office token, only the prefix", fakeOfficeTokenPrefix, officeSheetFileParentType},
|
||||
{"prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -65,8 +62,7 @@ func TestUploadSheetImage_ParentType(t *testing.T) {
|
||||
wantParentType string
|
||||
}{
|
||||
{"native spreadsheet", "shtcnTOK123", sheetImageParentType},
|
||||
{"fake_office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
|
||||
{"local_office imported spreadsheet", "local_office_abc123", officeSheetFileParentType},
|
||||
{"office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// These benchmarks back the memory review of the sheets fan-out paths. They
|
||||
// measure the cell matrices materialized by range-based shortcuts and table IO.
|
||||
//
|
||||
// 1. fillCellsMatrix — fan-out shortcuts (+cells-set-style, +dropdown-set,
|
||||
// +cells-batch-set-style, +dropdown-update) expand one A1 range into a
|
||||
// rows×cols matrix of per-cell maps. A tiny input string ("A1:Z100000")
|
||||
// explodes into millions of heap maps with no upper bound.
|
||||
// Run: go test ./shortcuts/sheets -run XXX -bench 'FillCellsMatrix|BuildSheetMatrix' -benchmem
|
||||
|
||||
var styleProto = map[string]interface{}{
|
||||
"cell_styles": map[string]interface{}{"bold": true, "fg_color": "#FF0000"},
|
||||
"border_styles": map[string]interface{}{"top": map[string]interface{}{"style": "solid"}},
|
||||
}
|
||||
|
||||
func benchFillCellsMatrix(b *testing.B, rows, cols int) {
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
m := fillCellsMatrix(rows, cols, styleProto)
|
||||
if len(m) != rows {
|
||||
b.Fatalf("bad matrix")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFillCellsMatrix_100(b *testing.B) { benchFillCellsMatrix(b, 10, 10) } // A1:J10
|
||||
func BenchmarkFillCellsMatrix_10K(b *testing.B) { benchFillCellsMatrix(b, 1000, 10) } // A1:J1000
|
||||
func BenchmarkFillCellsMatrix_100K(b *testing.B) { benchFillCellsMatrix(b, 10000, 10) } // A1:J10000
|
||||
func BenchmarkFillCellsMatrix_2600K(b *testing.B) { benchFillCellsMatrix(b, 100000, 26) } // A1:Z100000
|
||||
|
||||
// TestFanoutMatrixPeakMemory reports the concrete resident-heap delta of
|
||||
// materializing a large fan-out matrix, so the review doc can quote real MB.
|
||||
// Not an assertion — it prints numbers under `go test -v -run PeakMemory`.
|
||||
func TestFanoutMatrixPeakMemory(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping memory probe in -short")
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
rows, cols int
|
||||
}{
|
||||
{"A1:Z10000 (260K cells)", 10000, 26},
|
||||
{"A1:Z100000 (2.6M cells)", 100000, 26},
|
||||
}
|
||||
for _, c := range cases {
|
||||
var before, after runtime.MemStats
|
||||
runtime.GC()
|
||||
runtime.ReadMemStats(&before)
|
||||
m := fillCellsMatrix(c.rows, c.cols, styleProto)
|
||||
runtime.ReadMemStats(&after)
|
||||
runtime.KeepAlive(m)
|
||||
t.Logf("%-26s heap +%6.1f MB (%d total allocs)",
|
||||
c.name,
|
||||
float64(after.HeapAlloc-before.HeapAlloc)/(1024*1024),
|
||||
after.Mallocs-before.Mallocs)
|
||||
}
|
||||
}
|
||||
|
||||
// --- +table-put / +workbook-create matrix materialization (sibling #1 path) ---
|
||||
//
|
||||
// buildSheetMatrix turns the caller's --sheets/--values into a rows×cols matrix
|
||||
// of per-cell maps, the same unbounded blow-up as fillCellsMatrix but on the
|
||||
// table-put ingress (tablePutMaxCellsPerWrite only slices the *write*, not this
|
||||
// in-memory build). checkCellBudget rejects oversized payloads before this runs.
|
||||
|
||||
func makeTypelessSpec(rows, cols int) *tableSheetSpec {
|
||||
c := make([]tableColumnSpec, cols)
|
||||
r := make([][]interface{}, rows)
|
||||
for i := range r {
|
||||
row := make([]interface{}, cols)
|
||||
for j := range row {
|
||||
row[j] = "x"
|
||||
}
|
||||
r[i] = row
|
||||
}
|
||||
return &tableSheetSpec{Columns: c, Rows: r}
|
||||
}
|
||||
|
||||
func benchBuildSheetMatrix(b *testing.B, rows, cols int) {
|
||||
spec := makeTypelessSpec(rows, cols)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
m, err := buildSheetMatrix(spec, true)
|
||||
if err != nil || len(m) != rows+1 {
|
||||
b.Fatalf("bad matrix")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkBuildSheetMatrix_100K(b *testing.B) { benchBuildSheetMatrix(b, 10000, 10) } // 100K cells
|
||||
func BenchmarkBuildSheetMatrix_2600K(b *testing.B) { benchBuildSheetMatrix(b, 100000, 26) } // 2.6M cells
|
||||
|
||||
// TestTablePutMatrixPeakMemory reports the resident-heap delta of materializing
|
||||
// a large table-put matrix (the cost checkCellBudget now prevents), so the
|
||||
// review doc can quote real MB. Not an assertion — prints under -v -run PeakMemory.
|
||||
func TestTablePutMatrixPeakMemory(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping memory probe in -short")
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
rows, cols int
|
||||
}{
|
||||
{"100000×26 (2.6M cells)", 100000, 26},
|
||||
} {
|
||||
spec := makeTypelessSpec(c.rows, c.cols)
|
||||
var before, after runtime.MemStats
|
||||
runtime.GC()
|
||||
runtime.ReadMemStats(&before)
|
||||
m, _ := buildSheetMatrix(spec, true)
|
||||
runtime.ReadMemStats(&after)
|
||||
runtime.KeepAlive(m)
|
||||
t.Logf("%-24s buildSheetMatrix heap +%6.1f MB (%d total allocs)",
|
||||
c.name,
|
||||
float64(after.HeapAlloc-before.HeapAlloc)/(1024*1024),
|
||||
after.Mallocs-before.Mallocs)
|
||||
}
|
||||
}
|
||||
|
||||
// --- fan-out cell-budget cap (fix for the unbounded matrix blow-up) ---
|
||||
|
||||
func TestStampMatrixBudgetCap(t *testing.T) {
|
||||
// 199992 cells (7692×26) sits just under the 200000 cap → allowed.
|
||||
if err := checkStampMatrixBudget("range", "A1:Z7692", 7692, 26); err != nil {
|
||||
t.Fatalf("199992 cells should pass, got: %v", err)
|
||||
}
|
||||
// Exactly at the cap → allowed.
|
||||
if err := checkStampMatrixBudget("range", "A1:A200000", 200000, 1); err != nil {
|
||||
t.Fatalf("200000 cells (== cap) should pass, got: %v", err)
|
||||
}
|
||||
// Just over the cap → rejected.
|
||||
if err := checkStampMatrixBudget("range", "A1:A200001", 200001, 1); err == nil {
|
||||
t.Fatal("200001 cells should be rejected")
|
||||
}
|
||||
// The pathological case from the review (2.6M cells) → rejected.
|
||||
if err := checkStampMatrixBudget("ranges", "Sheet1!A1:Z100000", 100000, 26); err == nil {
|
||||
t.Fatal("2.6M-cell fan-out should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// --- sibling cap gaps: +table-put/+workbook-create payload, batch aggregate,
|
||||
// batch-update operation count (follow-up to the single fan-out cap) ---
|
||||
|
||||
// TestTablePutCellBudgetCap covers the --sheets/--values materialization cap:
|
||||
// buildSheetMatrix builds the whole matrix in memory, so the total cell count is
|
||||
// bounded before that allocation, summed across all sheets.
|
||||
func TestTablePutCellBudgetCap(t *testing.T) {
|
||||
// 1000×1000 = 1,000,000 == cap → allowed.
|
||||
atCap := &tablePayload{Sheets: []tableSheetSpec{{
|
||||
Columns: make([]tableColumnSpec, 1000),
|
||||
Rows: make([][]interface{}, 1000),
|
||||
}}}
|
||||
if err := atCap.checkCellBudget(); err != nil {
|
||||
t.Fatalf("1,000,000 cells (== cap) should pass, got: %v", err)
|
||||
}
|
||||
// 1000×1001 = 1,001,000 > cap → rejected.
|
||||
over := &tablePayload{Sheets: []tableSheetSpec{{
|
||||
Columns: make([]tableColumnSpec, 1000),
|
||||
Rows: make([][]interface{}, 1001),
|
||||
}}}
|
||||
if err := over.checkCellBudget(); err == nil {
|
||||
t.Fatal("1,001,000 cells should be rejected")
|
||||
}
|
||||
// Budget is summed across sheets, not per-sheet: 600k + 600k = 1.2M > cap.
|
||||
twoSheets := &tablePayload{Sheets: []tableSheetSpec{
|
||||
{Columns: make([]tableColumnSpec, 1000), Rows: make([][]interface{}, 600)},
|
||||
{Columns: make([]tableColumnSpec, 1000), Rows: make([][]interface{}, 600)},
|
||||
}}
|
||||
if err := twoSheets.checkCellBudget(); err == nil {
|
||||
t.Fatal("1.2M cells across two sheets should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTablePutCellBudgetIncludesStylePadding(t *testing.T) {
|
||||
payload := &tablePayload{Sheets: []tableSheetSpec{{
|
||||
Columns: make([]tableColumnSpec, 1),
|
||||
Rows: make([][]interface{}, 1),
|
||||
}}}
|
||||
styles := &workbookCreateSheetStyles{ByIndex: []*workbookCreateStylePayload{{
|
||||
CellStyles: []workbookCreateCellStyleOp{{Range: "A1:AX25000"}},
|
||||
}}}
|
||||
if err := payload.checkCellBudgetWithStyles(styles); err == nil {
|
||||
t.Fatal("1x1 data padded by styles to 1.25M cells should be rejected before allocation")
|
||||
}
|
||||
|
||||
twoSheets := &tablePayload{Sheets: []tableSheetSpec{
|
||||
{Columns: make([]tableColumnSpec, 1), Rows: make([][]interface{}, 1)},
|
||||
{Columns: make([]tableColumnSpec, 1), Rows: make([][]interface{}, 1)},
|
||||
}}
|
||||
style := &workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{{Range: "A1:Z20000"}}}
|
||||
if err := twoSheets.checkCellBudgetWithStyles(&workbookCreateSheetStyles{ByIndex: []*workbookCreateStylePayload{style, style}}); err == nil {
|
||||
t.Fatal("style-padded cells should be summed across sheets")
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
t.Fatalf("aggregate == cap should pass, got: %v", err)
|
||||
}
|
||||
if err := checkBatchStampBudget(maxStampMatrixCells + 1); err == nil {
|
||||
t.Fatal("aggregate over cap should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchFanoutRangeCountCap drives a fan-out shortcut with > maxBatchRanges
|
||||
// ranges and expects the shared validateDropdownRanges cap to reject it.
|
||||
func TestBatchFanoutRangeCountCap(t *testing.T) {
|
||||
ranges := make([]string, maxBatchRanges+1)
|
||||
for i := range ranges {
|
||||
ranges[i] = "sheet1!A1"
|
||||
}
|
||||
rangesJSON, _ := json.Marshal(ranges)
|
||||
_, _, err := runShortcutCapturingErr(t, CellsBatchSetStyle, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", string(rangesJSON),
|
||||
"--font-weight", "bold",
|
||||
"--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "at most")
|
||||
}
|
||||
|
||||
// TestBatchOperationsCountCap covers the +batch-update sub-operation count cap.
|
||||
func TestBatchOperationsCountCap(t *testing.T) {
|
||||
ops := make([]interface{}, maxBatchOperations+1)
|
||||
for i := range ops {
|
||||
ops[i] = map[string]interface{}{"shortcut": "+cells-set", "input": map[string]interface{}{}}
|
||||
}
|
||||
_, err := translateBatchOperations(ops, testURL)
|
||||
if err == nil || !strings.Contains(err.Error(), "at most") {
|
||||
t.Fatalf("expected operations count cap error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkStampBudget_RejectsOversized is the "after" side of the fix: the same
|
||||
// A1:Z100000 input that BenchmarkFillCellsMatrix_2600K shows costing ~917MB /
|
||||
// 5.3M allocs is now rejected up front, allocating only the error string.
|
||||
func BenchmarkStampBudget_RejectsOversized(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := checkStampMatrixBudget("range", "A1:Z100000", 100000, 26); err == nil {
|
||||
b.Fatal("expected rejection")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,10 +35,6 @@ func Shortcuts() []common.Shortcut {
|
||||
if hasFlag(all[i].Flags, "spreadsheet-token") {
|
||||
all[i].PostMount = withTokenAlias(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.
|
||||
all[i].PostMount = withFlagErgonomics(all[i].PostMount)
|
||||
}
|
||||
return all
|
||||
}
|
||||
@@ -74,7 +70,6 @@ func shortcutList() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
// lark_sheet_workbook
|
||||
WorkbookInfo,
|
||||
RevisionGet,
|
||||
SheetCreate,
|
||||
SheetDelete,
|
||||
SheetRename,
|
||||
@@ -100,9 +95,6 @@ func shortcutList() []common.Shortcut {
|
||||
DimUngroup,
|
||||
DimMove,
|
||||
|
||||
// lark_sheet_changeset
|
||||
ChangesetGet,
|
||||
|
||||
// lark_sheet_read_data
|
||||
CellsGet,
|
||||
CsvGet,
|
||||
@@ -113,9 +105,6 @@ func shortcutList() []common.Shortcut {
|
||||
CellsSearch,
|
||||
CellsReplace,
|
||||
|
||||
// lark_sheet_formula_verify
|
||||
FormulaVerify,
|
||||
|
||||
// lark_sheet_write_cells
|
||||
CellsSet,
|
||||
CellsSetStyle,
|
||||
@@ -159,10 +148,5 @@ func shortcutList() []common.Shortcut {
|
||||
CellsBatchClear,
|
||||
DropdownUpdate,
|
||||
DropdownDelete,
|
||||
|
||||
// lark_sheet_history
|
||||
HistoryList,
|
||||
HistoryRevert,
|
||||
HistoryRevertStatus,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,14 +27,13 @@ Chat (oc_xxx)
|
||||
- `--as bot` means **bot identity** and uses `tenant_access_token`. Calls run as the app bot, so behavior depends on the bot's membership, app visibility, availability range, and bot-specific scopes.
|
||||
- If an IM API says it supports both `user` and `bot`, the token type changes who the operator is. The same API can succeed with one identity and fail with the other because owner/admin status, chat membership, tenant boundary, or app availability are checked against the current caller.
|
||||
|
||||
### Sender Name Resolution
|
||||
### Sender Name Resolution with Bot Identity
|
||||
|
||||
When fetching messages (`+chat-messages-list`, `+threads-messages-list`, `+messages-mget`, `+messages-search`), the CLI shows a display name for both user and bot senders:
|
||||
When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-list`, `+threads-messages-list`, `+messages-mget`), sender names may not be resolved (shown as open_id instead of display name). This happens when the bot cannot access the user's contact info.
|
||||
|
||||
- **Server-provided name**: the read APIs return `sender_name` (plus the full-i18n `sender_i18n_names` map) on each message `sender`; the CLI surfaces it as the sender's `name` for users and bots alike. No name lookup and no extra permission are needed — **no contact scope** and no `application:bot.basic_info:read`.
|
||||
- **Fallback to id**: when the server does not provide a name, the sender is shown by its id and the command still exits 0. There is no contact-directory fallback.
|
||||
**Root cause**: The bot's app visibility settings do not include the message sender, so the contact API returns no name.
|
||||
|
||||
The raw `sender_name` is not duplicated in output (its value is in `name`); the full `sender_i18n_names` map (all locales) is preserved for consumers that need a specific language, alongside an optional `open_bot_id` (`ou_`) for bot senders aligned with the message-receive event channel. System messages (`msg_type: system`) have no sender name — that is normal, not an error.
|
||||
**Solution**: Check the app's visibility settings in the Lark Developer Console — ensure the app's visible range covers the users whose names need to be resolved. Alternatively, use `--as user` to fetch messages with user identity, which typically has broader contact access.
|
||||
|
||||
### Default message enrichment (reactions / update_time)
|
||||
|
||||
|
||||
@@ -26,8 +26,7 @@ metadata:
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
|
||||
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`,owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。
|
||||
- 用户要**获取文档评论列表**时,优先使用 `lark-cli drive +list-comments --url '<url>'`,不要优先手写 `drive file.comments list`;具体使用方式先阅读 [`references/lark-drive-list-comments.md`](references/lark-drive-list-comments.md)。
|
||||
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `drive +list-comments --need-relation` 返回评论位置,其他类型会静默忽略该参数;具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。
|
||||
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `need_relation=true` 返回评论位置,其他类型暂不支持,具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。
|
||||
- 用户给出 doubao.com 的云空间资源 URL/token,或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时,仍按资源类型、URL 路径和 token 路由到本 skill;不要因为域名不是飞书而回退到 WebFetch。
|
||||
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
||||
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
|
||||
@@ -40,7 +39,6 @@ metadata:
|
||||
- 用户要在云空间(云盘/云存储)里新建文件夹,优先使用 `lark-cli drive +create-folder`。
|
||||
- 用户要查看某个文件有哪些可下载预览格式,或想下载 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` 域命令。
|
||||
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
||||
- 用户给的是 wiki URL / token,且后续还没明确底层资源类型时,先用 `lark-cli drive +inspect` 解包;`+inspect` 失败后不要自动切到别的写接口继续尝试,先按错误提示处理权限、scope 或链接问题。
|
||||
@@ -82,14 +80,13 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
|
||||
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc`/`docx`/`file` 的 wiki URL |
|
||||
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
|
||||
| 上传文件 | `folder_token` / `wiki_node_token` | 目标位置的 token |
|
||||
| 列出文档评论 | URL 或 `file_token` | 优先使用 `drive +list-comments --url '<url>'`;wiki URL/token 会自动解析到底层真实 token/type |
|
||||
| 列出文档评论 | `file_token` | 同添加评论 |
|
||||
|
||||
### 评论能力入口
|
||||
|
||||
- 添加评论优先使用 [`+add-comment`](references/lark-drive-add-comment.md):review / 审阅 / 校对场景默认尽量创建局部评论,不要把多个可定位问题合并为一条全文评论。
|
||||
- 获取评论列表优先使用 [`+list-comments`](references/lark-drive-list-comments.md):推荐传 `--url`,支持 wiki 自动解包;参数细节见 reference。
|
||||
- 评论查询、统计、排序、回复限制,先读 [`lark-drive-comments-guide.md`](references/lark-drive-comments-guide.md)。
|
||||
- 需要根据评论定位正文位置时,先确认目标是 `file_type=docx`,再读 [`lark-drive-comment-location.md`](references/lark-drive-comment-location.md),并使用 `drive +list-comments --need-relation`;其他文档类型会静默忽略该参数。
|
||||
- 需要根据评论定位正文位置时,先确认目标是 `file_type=docx`,再读 [`lark-drive-comment-location.md`](references/lark-drive-comment-location.md);其他文档类型暂不支持返回定位字段。
|
||||
- reaction / 表情相关操作先读 [`lark-drive-reactions.md`](references/lark-drive-reactions.md);只有用户明确需要 reaction 信息时才带 `need_reaction=true`。
|
||||
- `drive +add-comment` 的 `--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`。
|
||||
- `slides` 评论要求显式传 `--block-id <slide-block-type>!<xml-id>`;CLI 会将其拆分后写入 `anchor.block_id` 和 `anchor.slide_block_type`。其中 `<xml-id>` 是 PPT XML 协议中的元素 `id`;不支持 `--selection-with-ellipsis` 和 `--full-comment`。
|
||||
@@ -142,7 +139,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+push`](references/lark-drive-push.md) | 将本地目录推送到 Drive 文件夹,支持 skip / smart / overwrite 与确认后删除远端。 |
|
||||
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | 在另一个文件夹里创建现有 Drive 文件的快捷方式。 |
|
||||
| [`+add-comment`](references/lark-drive-add-comment.md) | 给 doc/docx/file/sheet/slides/base(bitable) 添加评论,也支持解析到这些类型的 wiki URL;评论统计、回复和 reaction 细则见 [`lark-drive-comments-guide.md`](references/lark-drive-comments-guide.md)。 |
|
||||
| [`+list-comments`](references/lark-drive-list-comments.md) | 获取 doc/docx/sheet/file/slides/base(bitable) 评论列表;优先传 URL,支持 wiki 自动解包。 |
|
||||
| [`+export`](references/lark-drive-export.md) | 将 doc/docx/sheet/bitable/slides 导出为本地文件。 |
|
||||
| [`+export-download`](references/lark-drive-export-download.md) | 根据导出产物的 file_token 下载文件。 |
|
||||
| [`+import`](references/lark-drive-import.md) | 将本地文件导入为飞书在线文档、表格、多维表格或幻灯片。 |
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
# 文档评论定位字段
|
||||
|
||||
当用户需要根据评论定位文档正文位置、对文档做 review、区分多处相同引用文本,或把评论落点映射到 `docs +fetch --detail with-ids` 的内容时,优先使用 `drive +list-comments --need-relation` 查询 docx 评论位置。
|
||||
当用户需要根据评论定位文档正文位置、对文档做 review、区分多处相同引用文本,或把评论落点映射到 `docs +fetch --detail with-ids` 的内容时,docx 文档的评论查询必须带 `need_relation=true`。
|
||||
|
||||
## 适用范围
|
||||
|
||||
- 当前只有 `file_type=docx` 支持通过 `need_relation=true` 查询评论的位置,并返回可用于定位正文 block 的 `relation`、`parent_type`、`parent_token` 等字段。
|
||||
- `drive +list-comments` 会在目标不是 docx 时静默忽略 `--need-relation`,避免把无效参数传给 OpenAPI。遇到 sheet、bitable、slides、普通文件等类型的评论时,不要承诺可以用 `need_relation` 精确定位正文位置,应退回普通评论字段、对应资源能力下钻或人工确认。
|
||||
- 其他文件类型暂不支持通过 `need_relation` 查询评论位置。遇到 sheet、bitable、slides、普通文件等类型的评论时,不要承诺可以用 `need_relation` 精确定位正文位置,应退回普通评论字段、对应资源能力下钻或人工确认。
|
||||
|
||||
## 调用方式
|
||||
|
||||
分页列出评论时,优先传 URL;Wiki URL / Wiki token 会自动解析到底层真实 token/type:
|
||||
|
||||
```bash
|
||||
lark-cli drive +list-comments --url '<docx_or_wiki_url>' --need-relation
|
||||
```
|
||||
|
||||
如果只有 Wiki token,显式传 `--type wiki`:
|
||||
|
||||
```bash
|
||||
lark-cli drive +list-comments --token '<wiki_token>' --type wiki --need-relation
|
||||
```
|
||||
|
||||
只有在需要未被 shortcut 暴露的底层参数时,才直接调用 raw OpenAPI。此时把 `need_relation` 放在 query params:
|
||||
分页列出评论时,把 `need_relation` 放在 query params:
|
||||
|
||||
```bash
|
||||
lark-cli drive file.comments list \
|
||||
@@ -138,7 +126,7 @@ lark-cli docs +fetch --doc '<doc_token_or_url>' --detail with-ids
|
||||
## 定位流程
|
||||
|
||||
1. 确认目标是 `file_type=docx`;只有 docx 文档支持通过 `need_relation` 查询评论位置。
|
||||
2. 用 `drive +list-comments --need-relation` 获取评论;已知评论 ID 且需要批量查询时,可用 `drive file.comments batch_query` 并带 `need_relation=true`。raw `drive file.comments list` 仅作为低层参数兜底。
|
||||
2. 用 `drive file.comments list` 或 `drive file.comments batch_query` 获取评论,并带 `need_relation=true`。
|
||||
3. 用 `docs +fetch --detail with-ids` 获取文档内容。
|
||||
4. 对每条评论先看 `relation`:
|
||||
- 如果存在 `relation.relation`,解析这个 JSON 字符串。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user