diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8311a3d637..8f45facf98 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,42 @@
All notable changes to this project will be documented in this file.
+## [v1.0.90] - 2026-08-25
+
+### Features
+
+- event organizer transfer bot to user (#2448)
+- **base**: document app default page reuse (#2436)
+- **slides**: un-deprecate +replace-pages shortcut (#2470)
+- **slides**: add media download shortcut (#2446)
+- **vc**: add agent meeting control shortcuts (#2466)
+- **slides**: add marginRight attribute to
element schema (#2493)
+- words replace and minutes fix (#2490)
+- **sheets**: combine chart workflows and special chart types (#2374)
+- **extension**: add business command extension v1 (#2308)
+- **drive**: support appid in member-remove (#2499)
+- **config**: support keychain-backed tenant access tokens (#2488)
+
+### Bug Fixes
+
+- **apps**: classify the online DDL ban and the file storage quota failure (#2460)
+- **base**: hide dashboard auto analysis setting (#2465)
+- **slides**: strip stale id in +update-slide to avoid backend crash (#2475)
+- **drive**: continue downloads on permission scope errors (#2494)
+- **wiki**: keep node-get stderr machine-readable (#2449)
+- **slides**: avoid PID variable in examples (#2496)
+- **docs**: continue media downloads on permission scope errors (#2498)
+- **skills**: scope markdown routing to Lark resources (#2497)
+- **auth**: exclude im:message.send_as_user from batch scope sets (#2471)
+
+### Refactoring
+
+- **slides**: assert dry-run parent_type instead of deriving it from a placeholder (#2461)
+
+### Misc
+
+- Support repeated mail compose flags (#2271)
+
## [v1.0.89] - 2026-08-21
### Features
@@ -1983,6 +2019,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
+[v1.0.90]: https://github.com/larksuite/cli/releases/tag/v1.0.90
[v1.0.89]: https://github.com/larksuite/cli/releases/tag/v1.0.89
[v1.0.88]: https://github.com/larksuite/cli/releases/tag/v1.0.88
[v1.0.87]: https://github.com/larksuite/cli/releases/tag/v1.0.87
diff --git a/package-lock.json b/package-lock.json
index cb3cfde3a6..e43f3c17d3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@larksuite/cli",
- "version": "1.0.89",
+ "version": "1.0.90",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
- "version": "1.0.89",
+ "version": "1.0.90",
"cpu": [
"x64",
"arm64",
diff --git a/package.json b/package.json
index 7338c55d39..a92a7c0ee7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
- "version": "1.0.89",
+ "version": "1.0.90",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"
diff --git a/shortcuts/base/record_ops.go b/shortcuts/base/record_ops.go
index 809b9446ec..097a162245 100644
--- a/shortcuts/base/record_ops.go
+++ b/shortcuts/base/record_ops.go
@@ -385,16 +385,16 @@ const maxShareBatchSize = 100
func validateRecordShareBatch(runtime *common.RuntimeContext) error {
recordIDs := deduplicateRecordIDs(runtime)
if len(recordIDs) == 0 {
- return baseFlagErrorf("--record-ids is required and must not be empty")
+ return baseFlagErrorf("--record-id is required and must not be empty")
}
if len(recordIDs) > maxShareBatchSize {
- return baseFlagErrorf("--record-ids exceeds maximum limit of %d (got %d)", maxShareBatchSize, len(recordIDs))
+ return baseFlagErrorf("--record-id exceeds maximum limit of %d (got %d)", maxShareBatchSize, len(recordIDs))
}
return nil
}
func deduplicateRecordIDs(runtime *common.RuntimeContext) []string {
- raw := runtime.StrSlice("record-ids")
+ raw := runtime.StrSlice("record-id")
seen := make(map[string]bool, len(raw))
result := make([]string, 0, len(raw))
for _, id := range raw {
diff --git a/shortcuts/base/record_share_link_create.go b/shortcuts/base/record_share_link_create.go
index 522369fcbc..67f4e3bd56 100644
--- a/shortcuts/base/record_share_link_create.go
+++ b/shortcuts/base/record_share_link_create.go
@@ -19,10 +19,10 @@ var BaseRecordShareLinkCreate = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
- {Name: "record-ids", Type: "string_slice", Desc: "record IDs to generate share links for (comma-separated or repeatable, max 100)", Required: true},
+ {Name: "record-id", Aliases: []string{"record-ids"}, Type: "string_slice", Desc: "record ID to generate a share link for (comma-separated or repeatable, max 100)", Required: true},
},
Tips: []string{
- `Example: lark-cli base +record-share-link-create --base-token --table-id --record-ids `,
+ `Example: lark-cli base +record-share-link-create --base-token --table-id --record-id `,
"Max 100 record IDs per call; duplicate IDs are ignored.",
"Output record_share_links maps record_id to URL; records without permission or missing records may be absent.",
},
diff --git a/shortcuts/base/record_share_link_create_test.go b/shortcuts/base/record_share_link_create_test.go
new file mode 100644
index 0000000000..5f19608e08
--- /dev/null
+++ b/shortcuts/base/record_share_link_create_test.go
@@ -0,0 +1,62 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package base
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/larksuite/cli/internal/httpmock"
+)
+
+func TestBaseRecordShareLinkCreateAcceptsSingularAndPluralRecordIDFlags(t *testing.T) {
+ factory, stdout, registry := newExecuteFactory(t)
+ stub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/share_links/batch",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "record_share_links": map[string]interface{}{
+ "rec_1": "https://example.com/rec_1",
+ "rec_2": "https://example.com/rec_2",
+ "rec_3": "https://example.com/rec_3",
+ },
+ },
+ },
+ }
+ registry.Register(stub)
+
+ err := runShortcut(t, BaseRecordShareLinkCreate, []string{
+ "+record-share-link-create",
+ "--base-token", "app_x",
+ "--table-id", "tbl_x",
+ "--record-id", "rec_1",
+ "--record-ids", "rec_2,rec_3",
+ }, factory, stdout)
+ if err != nil {
+ t.Fatalf("run shortcut: %v", err)
+ }
+
+ body := decodeCapturedJSONBody(t, stub)
+ want := []interface{}{"rec_1", "rec_2", "rec_3"}
+ if got := body["record_ids"]; !reflect.DeepEqual(got, want) {
+ t.Fatalf("record_ids = %#v, want %#v", got, want)
+ }
+}
+
+func TestBaseRecordShareLinkCreateHelpShowsRecordIDAndHidesRecordIDsAlias(t *testing.T) {
+ cmd := mountBaseShortcutFlags(t, BaseRecordShareLinkCreate, "+record-share-link-create")
+ usage := cmd.Flags().FlagUsages()
+ if !strings.Contains(usage, "--record-id strings") {
+ t.Fatalf("help does not show canonical --record-id flag:\n%s", usage)
+ }
+ if strings.Contains(usage, "--record-ids") {
+ t.Fatalf("help exposes hidden --record-ids alias:\n%s", usage)
+ }
+ if alias := cmd.Flags().Lookup("record-ids"); alias == nil || alias.Name != "record-id" {
+ t.Fatalf("Lookup(record-ids) = %#v, want canonical --record-id", alias)
+ }
+}
diff --git a/shortcuts/calendar/calendar_join_event.go b/shortcuts/calendar/calendar_join_event.go
new file mode 100644
index 0000000000..fc0f841a99
--- /dev/null
+++ b/shortcuts/calendar/calendar_join_event.go
@@ -0,0 +1,70 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package calendar
+
+import (
+ "context"
+ "strings"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+// joinEventPath is the unified "join by share token" endpoint. Every share
+// form (link, QR code, share card, RSVP card) collapses to a single opaque
+// share_token here; there is deliberately no "join by event_id" path, so the
+// caller can never forge a plaintext event id to join an arbitrary event.
+const joinEventPath = "/open-apis/calendar/v4/calendars/join_event"
+
+var CalendarJoinEvent = common.Shortcut{
+ Service: "calendar",
+ Command: "+join-event",
+ Description: "Join a calendar event via a share token (from a share link/QR code or a im share/RSVP card)",
+ Risk: "write",
+ Scopes: []string{"calendar:calendar.event:join"},
+ AuthTypes: []string{"user", "bot"},
+ Flags: []common.Flag{
+ {
+ Name: "token",
+ Aliases: []string{"share-token"},
+ Desc: "share token from a share link/QR code or an IM share/RSVP card",
+ Required: true,
+ },
+ },
+ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
+ token := strings.TrimSpace(runtime.Str("token"))
+ return common.NewDryRunAPI().
+ POST(joinEventPath).
+ Body(map[string]any{"share_token": token})
+ },
+ Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ if err := rejectCalendarAutoBotFallback(runtime); err != nil {
+ return err
+ }
+ token := strings.TrimSpace(runtime.Str("token"))
+ if token == "" {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "share token cannot be empty").WithParam("--token")
+ }
+ if err := common.RejectDangerousCharsTyped("--token", token); err != nil {
+ return err
+ }
+ return nil
+ },
+ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ token := strings.TrimSpace(runtime.Str("token"))
+
+ _, err := runtime.CallAPITyped("POST", joinEventPath, nil,
+ map[string]any{"share_token": token})
+ if err != nil {
+ return err
+ }
+
+ // The API returns an empty body on success; echo the join outcome so
+ // the JSON contract stays a self-describing object rather than a bare ack.
+ runtime.Out(map[string]any{
+ "joined": true,
+ }, nil)
+ return nil
+ },
+}
diff --git a/shortcuts/calendar/calendar_test.go b/shortcuts/calendar/calendar_test.go
index 3139e5d8f3..a616be401b 100644
--- a/shortcuts/calendar/calendar_test.go
+++ b/shortcuts/calendar/calendar_test.go
@@ -2596,17 +2596,17 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
// Shortcuts() registration test
// ---------------------------------------------------------------------------
-func TestShortcuts_Returns11(t *testing.T) {
+func TestShortcuts_Returns12(t *testing.T) {
shortcuts := Shortcuts()
- if len(shortcuts) != 11 {
- t.Fatalf("expected 11 shortcuts, got %d", len(shortcuts))
+ if len(shortcuts) != 12 {
+ t.Fatalf("expected 12 shortcuts, got %d", len(shortcuts))
}
names := map[string]bool{}
for _, s := range shortcuts {
names[s.Command] = true
}
- for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion", "+get", "+transfer"} {
+ for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion", "+get", "+transfer", "+join-event"} {
if !names[want] {
t.Errorf("missing shortcut %s", want)
}
@@ -4675,3 +4675,169 @@ func TestUpdate_RoomCheck_RequisitionMissingBoundsStillCoherent(t *testing.T) {
t.Errorf("message should always include recovery hint, got: %q", ve.Message)
}
}
+
+// ---------------------------------------------------------------------------
+// CalendarJoinEvent tests
+// ---------------------------------------------------------------------------
+
+func TestJoinEvent_Success(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
+
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/calendar/v4/calendars/join_event",
+ Body: map[string]interface{}{"code": 0, "msg": "ok"},
+ })
+
+ err := mountAndRun(t, CalendarJoinEvent, []string{
+ "+join-event",
+ "--token", "md5tok_abc",
+ "--as", "bot",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(stdout.String(), `"joined": true`) {
+ t.Errorf("stdout should confirm join, got: %s", stdout.String())
+ }
+}
+
+// The share_token is the only body field, and the endpoint carries no path
+// params — assert the request body so a regression to per-field joins fails.
+func TestJoinEvent_SendsShareTokenBody(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
+
+ stub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/calendar/v4/calendars/join_event",
+ Body: map[string]interface{}{"code": 0, "msg": "ok"},
+ }
+ reg.Register(stub)
+
+ err := mountAndRun(t, CalendarJoinEvent, []string{
+ "+join-event",
+ "--token", " md5tok_trim ",
+ "--as", "user",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var gotBody map[string]interface{}
+ if err := json.Unmarshal(stub.CapturedBody, &gotBody); err != nil {
+ t.Fatalf("unmarshal request body: %v", err)
+ }
+ if got := gotBody["share_token"]; got != "md5tok_trim" {
+ t.Errorf("share_token=%v, want trimmed md5tok_trim", got)
+ }
+ if _, extra := gotBody["event_id"]; extra {
+ t.Error("body must not carry event_id; join is share-token only")
+ }
+}
+
+// Alias --share-token maps to the same canonical --token flag.
+func TestJoinEvent_ShareTokenAlias(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
+
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/calendar/v4/calendars/join_event",
+ Body: map[string]interface{}{"code": 0, "msg": "ok"},
+ })
+
+ err := mountAndRun(t, CalendarJoinEvent, []string{
+ "+join-event",
+ "--share-token", "enc_msgid_xyz",
+ "--as", "bot",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error with --share-token alias: %v", err)
+ }
+ if !strings.Contains(stdout.String(), `"joined": true`) {
+ t.Errorf("stdout should confirm join, got: %s", stdout.String())
+ }
+}
+
+func TestJoinEvent_DryRun(t *testing.T) {
+ f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
+
+ err := mountAndRun(t, CalendarJoinEvent, []string{
+ "+join-event",
+ "--token", "md5tok_dry",
+ "--dry-run",
+ "--as", "bot",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ for _, want := range []string{
+ "/open-apis/calendar/v4/calendars/join_event",
+ `"share_token": "md5tok_dry"`,
+ } {
+ if !strings.Contains(stdout.String(), want) {
+ t.Errorf("dry-run output should contain %q, got: %s", want, stdout.String())
+ }
+ }
+}
+
+func TestJoinEvent_EmptyToken_Typed(t *testing.T) {
+ f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
+
+ err := mountAndRun(t, CalendarJoinEvent, []string{
+ "+join-event",
+ "--token", " ",
+ "--as", "bot",
+ }, f, nil)
+ if err == nil {
+ t.Fatal("expected validation error for empty token, got nil")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) {
+ t.Fatalf("want *errs.ValidationError, got %T", err)
+ }
+ if ve.Subtype != errs.SubtypeInvalidArgument {
+ t.Errorf("subtype=%q, want invalid_argument", ve.Subtype)
+ }
+ if ve.Param != "--token" {
+ t.Errorf("param=%q, want --token", ve.Param)
+ }
+}
+
+func TestJoinEvent_RejectsDangerousChars(t *testing.T) {
+ f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
+
+ err := mountAndRun(t, CalendarJoinEvent, []string{
+ "+join-event",
+ "--token", "md5tok\u202e",
+ "--as", "bot",
+ }, f, nil)
+ if err == nil {
+ t.Fatal("expected validation error for dangerous characters, got nil")
+ }
+ var ve *errs.ValidationError
+ if !errors.As(err, &ve) {
+ t.Fatalf("want *errs.ValidationError, got %T", err)
+ }
+ if ve.Param != "--token" {
+ t.Errorf("param=%q, want --token", ve.Param)
+ }
+}
+
+func TestJoinEvent_APIError(t *testing.T) {
+ f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
+
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/calendar/v4/calendars/join_event",
+ Body: map[string]interface{}{"code": 3201, "msg": "no permission to join event"},
+ })
+
+ err := mountAndRun(t, CalendarJoinEvent, []string{
+ "+join-event",
+ "--token", "md5tok_denied",
+ "--as", "bot",
+ }, f, nil)
+ if err == nil {
+ t.Fatal("expected error for API failure, got nil")
+ }
+}
diff --git a/shortcuts/calendar/shortcuts.go b/shortcuts/calendar/shortcuts.go
index 0991c43bb5..23df688de5 100644
--- a/shortcuts/calendar/shortcuts.go
+++ b/shortcuts/calendar/shortcuts.go
@@ -19,5 +19,6 @@ func Shortcuts() []common.Shortcut {
CalendarSearchEvent,
CalendarGet,
CalendarTransfer,
+ CalendarJoinEvent,
}
}
diff --git a/shortcuts/doc/docs_script.go b/shortcuts/doc/docs_script.go
index 76bef5e591..10d42fe313 100644
--- a/shortcuts/doc/docs_script.go
+++ b/shortcuts/doc/docs_script.go
@@ -34,6 +34,7 @@ const (
docsScriptDraftRandomHexLength = 8
docsScriptDecisionFile = ".presentation-decision.json"
docsScriptDraftTip = "The workspace directory has been created successfully. draft_path points to a new XML file that does not exist yet. Create and write the file directly without reading it first."
+ docsScriptDecisionShellHint = "restore the original JSON quotes; if shell quote loss made a string ambiguous, save the original JSON as UTF-8 and pass --presentation-decision \"@./decision.json\""
docsScriptListBlockType = "list"
docsScriptAssessmentPassed = "passed"
docsScriptAssessmentFailed = "failed"
@@ -76,7 +77,7 @@ var DocsScript = common.Shortcut{
},
{
Name: "presentation-decision",
- Desc: "Presentation Decision JSON required by init-draft and saved as the draft profile baseline; genre_contract and adapter accept a short name, \"none\", or null; accepts inline JSON (recommended for init-draft), @relative-file, or - for stdin",
+ Desc: "Presentation Decision JSON required by init-draft and saved as the draft profile baseline; genre_contract and adapter accept a short name, \"none\", or null; accepts inline JSON (recommended for init-draft), @relative-file, or - for stdin; direct inline input also recovers an intact outer single-quote pair or unambiguous schema fields and scalar values dequoted by Windows PowerShell 5.x",
Input: []string{common.File, common.Stdin},
},
},
@@ -200,7 +201,7 @@ func validateDocsScript(_ context.Context, runtime *common.RuntimeContext) error
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--presentation-decision is only supported with --command init-draft or parse").WithParam("--presentation-decision")
}
- if _, err := parseDocsScriptPresentationDecision(presentationDecision); err != nil {
+ if _, _, err := parseDocsScriptPresentationDecisionFlag(runtime); err != nil {
return err
}
}
@@ -463,7 +464,7 @@ func docsScriptRemoteImageReason(message string, occurrence int) string {
func resolveDocsScriptPresentationDecision(runtime *common.RuntimeContext) (docsScriptPresentationDecision, bool, error) {
if rawDecision := strings.TrimSpace(runtime.Str("presentation-decision")); rawDecision != "" {
- decision, err := parseDocsScriptPresentationDecision(rawDecision)
+ decision, _, err := parseDocsScriptPresentationDecisionFlag(runtime)
return decision, err == nil, err
}
contentPath, ok := runtime.Cmd.Annotations[docsContentPathAnnotation]
@@ -488,6 +489,39 @@ func resolveDocsScriptPresentationDecision(runtime *common.RuntimeContext) (docs
return decision, true, nil
}
+func parseDocsScriptPresentationDecisionFlag(runtime *common.RuntimeContext) (docsScriptPresentationDecision, string, error) {
+ raw := strings.TrimSpace(runtime.Str("presentation-decision"))
+ decision, err := parseDocsScriptPresentationDecision(raw)
+ if err == nil || runtime.InputResolvedFromSource("presentation-decision") {
+ return decision, raw, err
+ }
+
+ // Keep the original strict parse as the primary path. Recovery is limited to
+ // direct input because file and stdin sources preserve the original bytes.
+ if len(raw) >= 2 && raw[0] == '\'' && raw[len(raw)-1] == '\'' {
+ raw = strings.TrimSpace(raw[1 : len(raw)-1])
+ decision, err = parseDocsScriptPresentationDecision(raw)
+ if err == nil {
+ return decision, raw, nil
+ }
+ }
+ if docsScriptPresentationDecisionLooksShellMangled(raw) {
+ normalized, recoveryErr := recoverDocsScriptPresentationDecisionJSON(raw)
+ if recoveryErr == nil {
+ decision, err = parseDocsScriptPresentationDecision(normalized)
+ if err == nil {
+ return decision, normalized, nil
+ }
+ return docsScriptPresentationDecision{}, normalized, err
+ }
+ var validationErr *errs.ValidationError
+ if errors.As(err, &validationErr) {
+ err = validationErr.WithHint(docsScriptDecisionShellHint)
+ }
+ }
+ return docsScriptPresentationDecision{}, raw, err
+}
+
func parseDocsScriptPresentationDecision(raw string) (docsScriptPresentationDecision, error) {
var decision docsScriptPresentationDecision
decoder := json.NewDecoder(strings.NewReader(raw))
@@ -657,6 +691,26 @@ func parseDocsScriptPresentationDecision(raw string) (docsScriptPresentationDeci
return decision, nil
}
+func docsScriptPresentationDecisionLooksShellMangled(raw string) bool {
+ raw = strings.TrimSpace(raw)
+ if len(raw) < 2 || raw[0] != '{' {
+ return false
+ }
+ body := strings.TrimSpace(raw[1:])
+ if body == "" || body[0] == '}' {
+ return false
+ }
+ if body[0] != '"' {
+ return true
+ }
+
+ // PowerShell can preserve object-key quotes while removing quotes from
+ // scalar values. Only classify that shape as recoverable when the schema
+ // parser can rebuild it without guessing.
+ normalized, err := recoverDocsScriptPresentationDecisionJSON(raw)
+ return err == nil && normalized != raw
+}
+
func normalizeDocsScriptOptionalRoute(field string, value *string) (*string, error) {
if value == nil {
return nil, nil
@@ -732,11 +786,14 @@ func docsScriptBlockCount(blocks []docxparse.BlockShare, blockType string) int {
}
func initDocsScriptDraft(runtime *common.RuntimeContext) error {
+ _, rawDecision, err := parseDocsScriptPresentationDecisionFlag(runtime)
+ if err != nil {
+ return err
+ }
workspace, err := newDocsScriptWorkspace(runtime)
if err != nil {
return err
}
- rawDecision := strings.TrimSpace(runtime.Str("presentation-decision"))
if err := workspace.savePresentationDecision(rawDecision); err != nil {
return workspace.fail(common.WrapSaveErrorTyped(err))
}
diff --git a/shortcuts/doc/docs_script_shell_json.go b/shortcuts/doc/docs_script_shell_json.go
new file mode 100644
index 0000000000..fa86c97e26
--- /dev/null
+++ b/shortcuts/doc/docs_script_shell_json.go
@@ -0,0 +1,331 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package doc
+
+import (
+ "encoding/json"
+ "fmt"
+ "reflect"
+ "strconv"
+ "strings"
+ "unicode/utf8"
+)
+
+var docsScriptPresentationDecisionType = reflect.TypeOf(docsScriptPresentationDecision{})
+
+// docsScriptShellJSONError is an intermediate recovery-parser error. The
+// command boundary keeps the original typed strict-JSON error when recovery
+// cannot be completed without guessing.
+type docsScriptShellJSONError struct {
+ message string
+}
+
+func (parseError *docsScriptShellJSONError) Error() string { return parseError.message }
+
+func newDocsScriptShellJSONError(format string, args ...any) error {
+ return &docsScriptShellJSONError{message: fmt.Sprintf(format, args...)}
+}
+
+// recoverDocsScriptPresentationDecisionJSON rebuilds only the JSON syntax that
+// legacy native-command argument passing can remove. The Go type remains the
+// source of truth for object fields and scalar types; ambiguous bare strings
+// containing JSON delimiters are rejected instead of guessed.
+func recoverDocsScriptPresentationDecisionJSON(raw string) (string, error) {
+ if !utf8.ValidString(raw) {
+ return "", newDocsScriptShellJSONError("presentation decision is not valid UTF-8")
+ }
+ parser := docsScriptShellJSONParser{raw: raw}
+ normalized, err := parser.parseValue(docsScriptPresentationDecisionType)
+ if err != nil {
+ return "", err
+ }
+ parser.skipSpace()
+ if parser.offset != len(parser.raw) {
+ return "", newDocsScriptShellJSONError("unexpected trailing input at byte %d", parser.offset)
+ }
+ return normalized, nil
+}
+
+type docsScriptShellJSONParser struct {
+ raw string
+ offset int
+}
+
+func (parser *docsScriptShellJSONParser) parseValue(valueType reflect.Type) (string, error) {
+ parser.skipSpace()
+ if valueType.Kind() == reflect.Pointer {
+ if parser.consumeLiteral("null") {
+ return "null", nil
+ }
+ return parser.parseValue(valueType.Elem())
+ }
+
+ switch valueType.Kind() {
+ case reflect.Struct:
+ if parser.consumeLiteral("null") {
+ return "null", nil
+ }
+ return parser.parseStruct(valueType)
+ case reflect.Slice:
+ if parser.consumeLiteral("null") {
+ return "null", nil
+ }
+ return parser.parseSlice(valueType.Elem())
+ case reflect.String:
+ return parser.parseString()
+ case reflect.Int:
+ return parser.parseInt(valueType.Bits())
+ default:
+ return "", newDocsScriptShellJSONError("unsupported presentation decision field type %s", valueType)
+ }
+}
+
+func (parser *docsScriptShellJSONParser) parseStruct(structType reflect.Type) (string, error) {
+ if err := parser.expectByte('{'); err != nil {
+ return "", err
+ }
+ fields := docsScriptShellJSONStructFields(structType)
+ seenFields := make(map[string]struct{}, len(fields))
+ var normalized strings.Builder
+ normalized.WriteByte('{')
+
+ for fieldIndex := 0; ; fieldIndex++ {
+ parser.skipSpace()
+ if parser.consumeByte('}') {
+ normalized.WriteByte('}')
+ return normalized.String(), nil
+ }
+ if fieldIndex > 0 {
+ if err := parser.expectByte(','); err != nil {
+ return "", err
+ }
+ parser.skipSpace()
+ }
+
+ fieldName, err := parser.parseFieldName()
+ if err != nil {
+ return "", err
+ }
+ fieldType, ok := fields[fieldName]
+ if !ok {
+ return "", newDocsScriptShellJSONError("field %q is not defined by %s", fieldName, structType.Name())
+ }
+ if _, duplicated := seenFields[fieldName]; duplicated {
+ return "", newDocsScriptShellJSONError("field %q is duplicated", fieldName)
+ }
+ seenFields[fieldName] = struct{}{}
+ if err := parser.expectByte(':'); err != nil {
+ return "", err
+ }
+ fieldValue, err := parser.parseValue(fieldType)
+ if err != nil {
+ return "", newDocsScriptShellJSONError("field %s: %v", fieldName, err)
+ }
+
+ if fieldIndex > 0 {
+ normalized.WriteByte(',')
+ }
+ encodedFieldName, _ := json.Marshal(fieldName)
+ normalized.Write(encodedFieldName)
+ normalized.WriteByte(':')
+ normalized.WriteString(fieldValue)
+ }
+}
+
+func (parser *docsScriptShellJSONParser) parseSlice(elementType reflect.Type) (string, error) {
+ if err := parser.expectByte('['); err != nil {
+ return "", err
+ }
+ var normalized strings.Builder
+ normalized.WriteByte('[')
+
+ for elementIndex := 0; ; elementIndex++ {
+ parser.skipSpace()
+ if parser.consumeByte(']') {
+ normalized.WriteByte(']')
+ return normalized.String(), nil
+ }
+ if elementIndex > 0 {
+ if err := parser.expectByte(','); err != nil {
+ return "", err
+ }
+ }
+ element, err := parser.parseValue(elementType)
+ if err != nil {
+ return "", newDocsScriptShellJSONError("element %d: %v", elementIndex, err)
+ }
+ if elementIndex > 0 {
+ normalized.WriteByte(',')
+ }
+ normalized.WriteString(element)
+ }
+}
+
+func (parser *docsScriptShellJSONParser) parseFieldName() (string, error) {
+ parser.skipSpace()
+ if parser.peekByte() == '"' {
+ return parser.parseJSONString()
+ }
+ start := parser.offset
+ for parser.offset < len(parser.raw) && docsScriptShellJSONFieldByte(parser.raw[parser.offset]) {
+ parser.offset++
+ }
+ fieldName := parser.raw[start:parser.offset]
+ if fieldName == "" {
+ return "", newDocsScriptShellJSONError("expected an object field at byte %d", start)
+ }
+ parser.skipSpace()
+ return fieldName, nil
+}
+
+func (parser *docsScriptShellJSONParser) parseString() (string, error) {
+ parser.skipSpace()
+ if parser.peekByte() == '"' {
+ value, err := parser.parseJSONString()
+ if err != nil {
+ return "", err
+ }
+ encoded, _ := json.Marshal(value)
+ return string(encoded), nil
+ }
+
+ start := parser.offset
+ for parser.offset < len(parser.raw) {
+ switch parser.raw[parser.offset] {
+ case ',', '}', ']':
+ value := strings.TrimSpace(parser.raw[start:parser.offset])
+ if value == "" {
+ return "", newDocsScriptShellJSONError("expected a string at byte %d", start)
+ }
+ if strings.ContainsAny(value, `"\{[`) {
+ return "", newDocsScriptShellJSONError("bare string contains ambiguous JSON syntax")
+ }
+ encoded, _ := json.Marshal(value)
+ return string(encoded), nil
+ default:
+ parser.offset++
+ }
+ }
+ return "", newDocsScriptShellJSONError("unterminated bare string at byte %d", start)
+}
+
+func (parser *docsScriptShellJSONParser) parseJSONString() (string, error) {
+ start := parser.offset
+ if err := parser.expectByte('"'); err != nil {
+ return "", err
+ }
+ escaped := false
+ for parser.offset < len(parser.raw) {
+ current := parser.raw[parser.offset]
+ parser.offset++
+ if escaped {
+ escaped = false
+ continue
+ }
+ if current == '\\' {
+ escaped = true
+ continue
+ }
+ if current == '"' {
+ var value string
+ if err := json.Unmarshal([]byte(parser.raw[start:parser.offset]), &value); err != nil {
+ return "", newDocsScriptShellJSONError("invalid JSON string at byte %d: %v", start, err)
+ }
+ return value, nil
+ }
+ }
+ return "", newDocsScriptShellJSONError("unterminated JSON string at byte %d", start)
+}
+
+func (parser *docsScriptShellJSONParser) parseInt(bits int) (string, error) {
+ parser.skipSpace()
+ start := parser.offset
+ for parser.offset < len(parser.raw) {
+ switch parser.raw[parser.offset] {
+ case ',', '}', ']':
+ value := strings.TrimSpace(parser.raw[start:parser.offset])
+ parsed, err := strconv.ParseInt(value, 10, bits)
+ if err != nil {
+ return "", newDocsScriptShellJSONError("invalid integer %q", value)
+ }
+ return strconv.FormatInt(parsed, 10), nil
+ default:
+ parser.offset++
+ }
+ }
+ return "", newDocsScriptShellJSONError("unterminated integer at byte %d", start)
+}
+
+func (parser *docsScriptShellJSONParser) expectByte(expected byte) error {
+ parser.skipSpace()
+ if !parser.consumeByte(expected) {
+ return newDocsScriptShellJSONError("expected %q at byte %d", expected, parser.offset)
+ }
+ return nil
+}
+
+func (parser *docsScriptShellJSONParser) consumeByte(expected byte) bool {
+ if parser.offset >= len(parser.raw) || parser.raw[parser.offset] != expected {
+ return false
+ }
+ parser.offset++
+ return true
+}
+
+func (parser *docsScriptShellJSONParser) consumeLiteral(literal string) bool {
+ parser.skipSpace()
+ if !strings.HasPrefix(parser.raw[parser.offset:], literal) {
+ return false
+ }
+ end := parser.offset + len(literal)
+ if end < len(parser.raw) && !docsScriptShellJSONDelimiterByte(parser.raw[end]) {
+ return false
+ }
+ parser.offset = end
+ return true
+}
+
+func (parser *docsScriptShellJSONParser) peekByte() byte {
+ if parser.offset >= len(parser.raw) {
+ return 0
+ }
+ return parser.raw[parser.offset]
+}
+
+func (parser *docsScriptShellJSONParser) skipSpace() {
+ for parser.offset < len(parser.raw) {
+ switch parser.raw[parser.offset] {
+ case ' ', '\t', '\r', '\n':
+ parser.offset++
+ default:
+ return
+ }
+ }
+}
+
+func docsScriptShellJSONStructFields(structType reflect.Type) map[string]reflect.Type {
+ fields := make(map[string]reflect.Type, structType.NumField())
+ for index := 0; index < structType.NumField(); index++ {
+ field := structType.Field(index)
+ fieldName := strings.Split(field.Tag.Get("json"), ",")[0]
+ if fieldName != "" && fieldName != "-" {
+ fields[fieldName] = field.Type
+ }
+ }
+ return fields
+}
+
+func docsScriptShellJSONFieldByte(value byte) bool {
+ return value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' ||
+ value >= '0' && value <= '9' || value == '_' || value == '-'
+}
+
+func docsScriptShellJSONDelimiterByte(value byte) bool {
+ switch value {
+ case ' ', '\t', '\r', '\n', ',', '}', ']':
+ return true
+ default:
+ return false
+ }
+}
diff --git a/shortcuts/doc/docs_script_test.go b/shortcuts/doc/docs_script_test.go
index acf9463bbe..6692393b2e 100644
--- a/shortcuts/doc/docs_script_test.go
+++ b/shortcuts/doc/docs_script_test.go
@@ -53,7 +53,12 @@ func TestDocsScriptPresentationDecisionFlagAcceptsFileAndStdin(t *testing.T) {
if len(flag.Input) != 2 || flag.Input[0] != common.File || flag.Input[1] != common.Stdin {
t.Fatalf("presentation-decision Input = %#v, want file and stdin", flag.Input)
}
- for _, want := range []string{"genre_contract and adapter", `"none"`, "or null"} {
+ for _, want := range []string{
+ "genre_contract and adapter",
+ `"none"`,
+ "or null",
+ "unambiguous schema fields",
+ } {
if !strings.Contains(flag.Desc, want) {
t.Fatalf("presentation-decision help = %q, want it to contain %q", flag.Desc, want)
}
@@ -374,6 +379,176 @@ func TestDocsScriptInitDraftPersistsDecisionForAutomaticParse(t *testing.T) {
}
}
+func TestDocsScriptInitDraftNormalizesWindowsCommandShimQuotes(t *testing.T) {
+ workDir := t.TempDir()
+ withDocsWorkingDir(t, workDir)
+ f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-init-draft-shell-quotes"))
+ decision := `{"audience":"普通读者","reader_task":"复现实验","genre_contract":null,"adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"复现实验","blocks":[]}}`
+
+ err := mountAndRunDocs(t, DocsScript, []string{
+ "+script",
+ "--command", docsScriptInitDraft,
+ "--presentation-decision", "'" + decision + "'",
+ "--as", "bot",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("initialize draft with Windows command-shim quotes: %v", err)
+ }
+
+ var initialized struct {
+ Data docsScriptDraftResult `json:"data"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &initialized); err != nil {
+ t.Fatalf("decode init output: %v\n%s", err, stdout)
+ }
+ savedDecision, err := os.ReadFile(filepath.Join(initialized.Data.Workspace, docsScriptDecisionFile))
+ if err != nil {
+ t.Fatalf("read saved decision: %v", err)
+ }
+ if got := string(savedDecision); got != decision {
+ t.Fatalf("saved decision = %q, want normalized JSON %q", got, decision)
+ }
+}
+
+func TestDocsScriptInitDraftRecoversPowerShellDequotedDecisionFromSchema(t *testing.T) {
+ tests := []struct {
+ name string
+ dequoted string
+ wantNormalized string
+ }{
+ {
+ name: "keys and values",
+ dequoted: `{audience:a,reader_task:b,genre_contract:null,adapter:null,presentation_mode:normal,word_count:{min:10,max:null},visual_plan:{reason:c,blocks:[{type:img,min_count:1,purpose:d}]}}`,
+ wantNormalized: `{"audience":"a","reader_task":"b","genre_contract":null,"adapter":null,"presentation_mode":"normal","word_count":{"min":10,"max":null},"visual_plan":{"reason":"c","blocks":[{"type":"img","min_count":1,"purpose":"d"}]}}`,
+ },
+ {
+ name: "values only",
+ dequoted: `{"audience":a,"reader_task":b,"genre_contract":null,"adapter":null,"presentation_mode":normal,"visual_plan":{"reason":c,"blocks":[]}}`,
+ wantNormalized: `{"audience":"a","reader_task":"b","genre_contract":null,"adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"c","blocks":[]}}`,
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ workDir := t.TempDir()
+ withDocsWorkingDir(t, workDir)
+ f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-init-draft-dequoted-json"))
+
+ err := mountAndRunDocs(t, DocsScript, []string{
+ "+script",
+ "--command", docsScriptInitDraft,
+ "--presentation-decision", test.dequoted,
+ "--as", "bot",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("initialize draft with PowerShell-dequoted JSON: %v", err)
+ }
+
+ var initialized struct {
+ Data docsScriptDraftResult `json:"data"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &initialized); err != nil {
+ t.Fatalf("decode init output: %v\n%s", err, stdout)
+ }
+ savedDecision, err := os.ReadFile(filepath.Join(initialized.Data.Workspace, docsScriptDecisionFile))
+ if err != nil {
+ t.Fatalf("read saved decision: %v", err)
+ }
+ if got := string(savedDecision); got != test.wantNormalized {
+ t.Fatalf("saved decision = %q, want schema-normalized JSON %q", got, test.wantNormalized)
+ }
+ })
+ }
+}
+
+func TestDocsScriptPowerShellDequotedRecoveryUsesOriginalValidation(t *testing.T) {
+ workDir := t.TempDir()
+ withDocsWorkingDir(t, workDir)
+ f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-dequoted-original-validation"))
+ dequoted := `{audience:a,reader_task:b,genre_contract:null,adapter:null,presentation_mode:decorative,visual_plan:{reason:c,blocks:[]}}`
+
+ err := mountAndRunDocs(t, DocsScript, []string{
+ "+script",
+ "--command", docsScriptInitDraft,
+ "--presentation-decision", dequoted,
+ "--as", "bot",
+ }, f, nil)
+ assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--presentation-decision")
+ if !strings.Contains(err.Error(), "presentation_mode must be formal, normal, or rich") {
+ t.Fatalf("error = %v, want original Presentation Decision validation", err)
+ }
+}
+
+func TestDocsScriptPresentationDecisionQuoteRecoveryUsesOriginalSchema(t *testing.T) {
+ workDir := t.TempDir()
+ withDocsWorkingDir(t, workDir)
+ f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-presentation-quote-schema"))
+ decision := `{"audience":"reader","reader_task":"understand","genre_contract":null,"adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"plain text is sufficient","blocks":[]},"unexpected":true}`
+
+ err := mountAndRunDocs(t, DocsScript, []string{
+ "+script",
+ "--command", docsScriptInitDraft,
+ "--presentation-decision", "'" + decision + "'",
+ "--as", "bot",
+ }, f, nil)
+ assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--presentation-decision")
+ if !strings.Contains(err.Error(), `json: unknown field "unexpected"`) {
+ t.Fatalf("error = %v, want recovered JSON to use the original strict schema", err)
+ }
+ entries, readErr := os.ReadDir(workDir)
+ if readErr != nil {
+ t.Fatalf("read work directory: %v", readErr)
+ }
+ if len(entries) != 0 {
+ t.Fatalf("failed quote recovery created files: %#v", entries)
+ }
+}
+
+func TestDocsScriptPresentationDecisionFileRemainsStrictJSON(t *testing.T) {
+ workDir := t.TempDir()
+ withDocsWorkingDir(t, workDir)
+ if err := os.WriteFile("decision.json", []byte(`{audience:reader,reader_task:understand}`), 0o600); err != nil {
+ t.Fatalf("write decision: %v", err)
+ }
+ f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-decision-file-strict-json"))
+
+ err := mountAndRunDocs(t, DocsScript, []string{
+ "+script",
+ "--command", docsScriptInitDraft,
+ "--presentation-decision", "@./decision.json",
+ "--as", "bot",
+ }, f, nil)
+ assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--presentation-decision")
+ problem, ok := errs.ProblemOf(err)
+ if !ok {
+ t.Fatalf("error does not expose a typed problem: %v", err)
+ }
+ if problem.Hint != "" {
+ t.Fatalf("hint = %q, want no shell-mangling guidance for strict @file JSON", problem.Hint)
+ }
+}
+
+func TestDocsScriptPresentationDecisionFileAcceptsUTF8BOM(t *testing.T) {
+ workDir := t.TempDir()
+ withDocsWorkingDir(t, workDir)
+ decision := `{"audience":"普通读者","reader_task":"复现实验","genre_contract":null,"adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"复现实验","blocks":[]}}`
+ if err := os.WriteFile("decision.json", []byte("\uFEFF"+decision), 0o600); err != nil {
+ t.Fatalf("write decision: %v", err)
+ }
+ f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-decision-file-bom"))
+
+ err := mountAndRunDocs(t, DocsScript, []string{
+ "+script",
+ "--command", docsScriptInitDraft,
+ "--presentation-decision", "@./decision.json",
+ "--dry-run",
+ "--as", "bot",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("dry-run init with BOM-prefixed decision file: %v", err)
+ }
+}
+
func TestDocsScriptInitDraftRequiresPresentationDecision(t *testing.T) {
workDir := t.TempDir()
withDocsWorkingDir(t, workDir)
@@ -578,6 +753,30 @@ func TestDocsScriptRejectsInvalidPresentationDecision(t *testing.T) {
}
}
+func TestDocsScriptPresentationDecisionMangledInlineJSONSuggestsFileInput(t *testing.T) {
+ workDir := t.TempDir()
+ withDocsWorkingDir(t, workDir)
+ f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-presentation-shell-mangled"))
+ err := mountAndRunDocs(t, DocsScript, []string{
+ "+script",
+ "--command", docsScriptInitDraft,
+ "--presentation-decision", `{audience:reader,reviewer,reader_task:understand}`,
+ "--as", "bot",
+ }, f, nil)
+ assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--presentation-decision")
+ problem, ok := errs.ProblemOf(err)
+ if !ok {
+ t.Fatalf("error does not expose a typed problem: %v", err)
+ }
+ if got := problem.Hint; got != docsScriptDecisionShellHint {
+ t.Fatalf("hint = %q, want %q", got, docsScriptDecisionShellHint)
+ }
+ var syntaxErr *json.SyntaxError
+ if !errors.As(err, &syntaxErr) {
+ t.Fatalf("error = %T (%v), want preserved *json.SyntaxError cause", err, err)
+ }
+}
+
func TestDocsScriptPresentationDecisionAllowsNoneOrNullRoutes(t *testing.T) {
tests := []struct {
name string
diff --git a/shortcuts/im/chat_app_link.go b/shortcuts/im/chat_app_link.go
new file mode 100644
index 0000000000..a07522aaee
--- /dev/null
+++ b/shortcuts/im/chat_app_link.go
@@ -0,0 +1,50 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package im
+
+import (
+ "net/url"
+ "strings"
+
+ "github.com/larksuite/cli/internal/core"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+func addChatAppLinks(chats []map[string]interface{}, runtime *common.RuntimeContext) {
+ if runtime == nil || runtime.Config == nil {
+ return
+ }
+ for _, chat := range chats {
+ if link := assembleChatAppLink(chat["chat_id"], runtime.Config.Brand); link != "" {
+ chat["chat_app_link"] = link
+ }
+ }
+}
+
+func assembleChatAppLink(rawChatID interface{}, brand core.LarkBrand) string {
+ chatID, _ := rawChatID.(string)
+ chatID = strings.TrimSpace(chatID)
+ if !strings.HasPrefix(chatID, "oc_") {
+ return ""
+ }
+ domain := resolveChatAppLinkDomain(brand)
+ if domain == "" {
+ return ""
+ }
+
+ u := &url.URL{Scheme: "https", Host: domain, Path: "/client/chat/open"}
+ q := url.Values{}
+ q.Set("openChatId", chatID)
+ u.RawQuery = q.Encode()
+ return u.String()
+}
+
+func resolveChatAppLinkDomain(brand core.LarkBrand) string {
+ appLink := core.ResolveEndpoints(brand).AppLink
+ u, err := url.Parse(appLink)
+ if err != nil {
+ return ""
+ }
+ return u.Host
+}
diff --git a/shortcuts/im/chat_app_link_test.go b/shortcuts/im/chat_app_link_test.go
new file mode 100644
index 0000000000..307219ea14
--- /dev/null
+++ b/shortcuts/im/chat_app_link_test.go
@@ -0,0 +1,148 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package im
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/larksuite/cli/internal/core"
+)
+
+func TestAssembleChatAppLink(t *testing.T) {
+ tests := []struct {
+ name string
+ chatID interface{}
+ brand core.LarkBrand
+ want string
+ }{
+ {
+ name: "feishu open chat id",
+ chatID: "oc_a0553eda9014c201e6969b478895c230",
+ brand: core.BrandFeishu,
+ want: "https://applink.feishu.cn/client/chat/open?openChatId=oc_a0553eda9014c201e6969b478895c230",
+ },
+ {
+ name: "lark open chat id",
+ chatID: "oc_a0553eda9014c201e6969b478895c230",
+ brand: core.BrandLark,
+ want: "https://applink.larksuite.com/client/chat/open?openChatId=oc_a0553eda9014c201e6969b478895c230",
+ },
+ {
+ name: "numeric internal chat id omitted",
+ chatID: "7670440925243608339",
+ brand: core.BrandFeishu,
+ },
+ {
+ name: "non string chat id omitted",
+ chatID: 123,
+ brand: core.BrandFeishu,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := assembleChatAppLink(tt.chatID, tt.brand); got != tt.want {
+ t.Fatalf("assembleChatAppLink() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestImChatListExecuteAddsChatAppLink(t *testing.T) {
+ rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
+ body := `{"code":0,"msg":"ok","data":{"items":[{"chat_id":"oc_g","name":"G","chat_mode":"group"}],"has_more":false,"page_token":""}}`
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(body)),
+ Header: make(http.Header),
+ }, nil
+ }))
+ attachChatListCmd(t, rt, map[string]string{"types": "group"}, nil)
+
+ if err := ImChatList.Execute(context.Background(), rt); err != nil {
+ t.Fatalf("Execute() err = %v", err)
+ }
+
+ var envelope struct {
+ Data struct {
+ Chats []map[string]interface{} `json:"chats"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(chatListOutBuf(t, rt).Bytes(), &envelope); err != nil {
+ t.Fatalf("stdout is not JSON: %v", err)
+ }
+ if len(envelope.Data.Chats) != 1 {
+ t.Fatalf("chats length = %d, want 1", len(envelope.Data.Chats))
+ }
+ want := "https://applink.feishu.cn/client/chat/open?openChatId=oc_g"
+ if got, _ := envelope.Data.Chats[0]["chat_app_link"].(string); got != want {
+ t.Fatalf("chat_app_link = %q, want %q", got, want)
+ }
+}
+
+func TestImChatSearchExecuteOmitsChatAppLink(t *testing.T) {
+ rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
+ body := `{"code":0,"msg":"ok","data":{"items":[{"meta_data":{"chat_id":"oc_visible","name":"Visible","chat_mode":"group"}}],"has_more":false,"page_token":""}}`
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(body)),
+ Header: make(http.Header),
+ }, nil
+ }))
+ rt.Cmd = newChatSearchNoticeTestCommand(t, "Joined")
+ rt.Format = "json"
+
+ if err := ImChatSearch.Execute(context.Background(), rt); err != nil {
+ t.Fatalf("Execute() err = %v", err)
+ }
+
+ var envelope struct {
+ Data struct {
+ Chats []map[string]interface{} `json:"chats"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(chatListOutBuf(t, rt).Bytes(), &envelope); err != nil {
+ t.Fatalf("stdout is not JSON: %v", err)
+ }
+ if _, ok := envelope.Data.Chats[0]["chat_app_link"]; ok {
+ t.Fatalf("chat_app_link must be omitted for search results: %#v", envelope.Data.Chats[0])
+ }
+}
+
+func TestImChatSearchExecuteOmitsChatAppLinkForNonMemberResults(t *testing.T) {
+ rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
+ body := `{"code":0,"msg":"ok","data":{"items":[{"meta_data":{"chat_id":"oc_not_joined","name":"Not Joined","chat_mode":"group"}}],"has_more":false,"page_token":""}}`
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(body)),
+ Header: make(http.Header),
+ }, nil
+ }))
+ rt.Cmd = newChatSearchNoticeTestCommand(t, "Not Joined")
+ if err := rt.Cmd.Flags().Set("search-types", "public_not_joined"); err != nil {
+ t.Fatalf("Flags().Set(search-types) error = %v", err)
+ }
+ rt.Format = "json"
+
+ if err := ImChatSearch.Execute(context.Background(), rt); err != nil {
+ t.Fatalf("Execute() err = %v", err)
+ }
+
+ var envelope struct {
+ Data struct {
+ Chats []map[string]interface{} `json:"chats"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(chatListOutBuf(t, rt).Bytes(), &envelope); err != nil {
+ t.Fatalf("stdout is not JSON: %v", err)
+ }
+ if _, ok := envelope.Data.Chats[0]["chat_app_link"]; ok {
+ t.Fatalf("chat_app_link must be omitted for public_not_joined search results: %#v", envelope.Data.Chats[0])
+ }
+}
diff --git a/shortcuts/im/convert_lib/content_media_misc_test.go b/shortcuts/im/convert_lib/content_media_misc_test.go
index b6b2be25db..c207b77c85 100644
--- a/shortcuts/im/convert_lib/content_media_misc_test.go
+++ b/shortcuts/im/convert_lib/content_media_misc_test.go
@@ -500,8 +500,10 @@ func TestMiscConverters(t *testing.T) {
{name: "location", got: (locationConverter{}).Convert(&ConvertContext{RawContent: `{"name":"Shanghai"}`}), want: "[Location: Shanghai]"},
{name: "folder", got: (folderConverter{}).Convert(&ConvertContext{RawContent: `{"file_key":"fld_1","file_name":"Docs"}`}), want: ``},
{name: "calendar share", got: (calendarEventConverter{}).Convert(&ConvertContext{RawContent: `{"summary":"Review","start_time":"1710500000","end_time":"1710503600","open_calendar_id":"cal_1","open_event_id":"evt_1"}`}), want: "\nReview\n" + formatTimestamp("1710500000") + " ~ " + formatTimestamp("1710503600") + "\n"},
+ {name: "calendar share with share token", got: (calendarEventConverter{}).Convert(&ConvertContext{RawContent: `{"summary":"Review","open_calendar_id":"cal_1","open_event_id":"evt_1","share_token":"cse_token_1"}`}), want: "\nReview\n"},
{name: "calendar invite", got: (calendarInviteConverter{}).Convert(&ConvertContext{RawContent: `{"summary":"Invite","start_time":"1710500000"}`}), want: "\nInvite\n" + formatTimestamp("1710500000") + "\n"},
{name: "general calendar", got: (generalCalendarConverter{}).Convert(&ConvertContext{RawContent: `{"summary":"All Hands"}`}), want: "\nAll Hands\n"},
+ {name: "general calendar with share token", got: (generalCalendarConverter{}).Convert(&ConvertContext{RawContent: `{"summary":"All Hands","share_token":"cse_token_2"}`}), want: "\nAll Hands\n"},
{name: "vote", got: (voteConverter{}).Convert(&ConvertContext{RawContent: `{"topic":"Lunch","options":["A","B"],"status":1}`}), want: "\nLunch\n• A\n• B\n(Closed)\n"},
{name: "hongbao", got: (hongbaoConverter{}).Convert(&ConvertContext{RawContent: `{"text":"恭喜发财"}`}), want: ``},
{name: "system", got: (systemConverter{}).Convert(&ConvertContext{RawContent: `{"template":"{from_user} invited {to_chatters} to {name}","from_user":["Alice"],"to_chatters":["Bob","Carol"],"name":"Room A"}`}), want: "Alice invited Bob, Carol to Room A"},
diff --git a/shortcuts/im/convert_lib/misc.go b/shortcuts/im/convert_lib/misc.go
index 9ee9c53f20..15a9f1f378 100644
--- a/shortcuts/im/convert_lib/misc.go
+++ b/shortcuts/im/convert_lib/misc.go
@@ -96,6 +96,7 @@ func (calendarEventConverter) Convert(ctx *ConvertContext) string {
if eventID != "" {
attrs += fmt.Sprintf(` open_event_id="%s"`, cardEscapeAttr(eventID))
}
+ attrs += calendarShareTokenAttr(parsed)
return formatCalendarContent(parsed, "calendar_share", attrs)
}
@@ -117,7 +118,15 @@ func (generalCalendarConverter) Convert(ctx *ConvertContext) string {
if err != nil {
return invalidJSONPlaceholder("calendar")
}
- return formatCalendarContent(parsed, "calendar", "")
+ return formatCalendarContent(parsed, "calendar", calendarShareTokenAttr(parsed))
+}
+
+func calendarShareTokenAttr(parsed map[string]interface{}) string {
+ shareToken, _ := parsed["share_token"].(string)
+ if shareToken == "" {
+ return ""
+ }
+ return fmt.Sprintf(` share_token="%s"`, cardEscapeAttr(shareToken))
}
// formatCalendarContent builds a human-readable string from a calendar JSON object.
diff --git a/shortcuts/im/im_chat_create.go b/shortcuts/im/im_chat_create.go
index 0f8a35431e..28d6b61351 100644
--- a/shortcuts/im/im_chat_create.go
+++ b/shortcuts/im/im_chat_create.go
@@ -125,6 +125,11 @@ var ImChatCreate = common.Shortcut{
"owner_id": resData["owner_id"],
"external": resData["external"],
}
+ if runtime.Config != nil {
+ if link := assembleChatAppLink(resData["chat_id"], runtime.Config.Brand); link != "" {
+ outData["chat_app_link"] = link
+ }
+ }
// Try to fetch the group share link without blocking on failure.
if chatID, ok := resData["chat_id"].(string); ok && chatID != "" {
diff --git a/shortcuts/im/im_chat_list.go b/shortcuts/im/im_chat_list.go
index f4a07c66e9..4e3536e7df 100644
--- a/shortcuts/im/im_chat_list.go
+++ b/shortcuts/im/im_chat_list.go
@@ -142,6 +142,7 @@ var ImChatList = common.Shortcut{
}
items = mfOut.Chats
pagination.Items = len(items)
+ addChatAppLinks(items, runtime)
// Presentation stage: business data stays backward compatible while the
// output layer carries the authoritative pagination outcome for every
diff --git a/shortcuts/sheets/backward/lark_sheets_float_images.go b/shortcuts/sheets/backward/lark_sheets_float_images.go
index e0ebc79e79..fc7364bbb5 100644
--- a/shortcuts/sheets/backward/lark_sheets_float_images.go
+++ b/shortcuts/sheets/backward/lark_sheets_float_images.go
@@ -38,7 +38,7 @@ func isOfficeSpreadsheet(spreadsheetToken string) bool {
return true
}
}
- if len(spreadsheetToken) != 28 {
+ if len(spreadsheetToken) < 25 {
return false
}
// The five-character marker occupies positions 5, 10, 15, 20, and 25
diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json
index 2299f1aa64..2221c07e36 100644
--- a/shortcuts/sheets/data/flag-defs.json
+++ b/shortcuts/sheets/data/flag-defs.json
@@ -1335,10 +1335,11 @@
"kind": "own",
"type": "string_slice",
"required": "optional",
- "desc": "Comma-separated info categories to include. `truncation` additionally estimates whether each cell's content is clipped (by row height / col width / font size / wrap) and returns `isRowTruncated` / `isColTruncated` (extra compute; enable only for layout checks or before adjusting row heights / column widths)",
+ "desc": "Comma-separated info categories to include. `raw_value` returns the original cell value or raw formula result, preserving number / string types, and is mutually exclusive with `formula`. `truncation` additionally estimates whether each cell's content is clipped (by row height / col width / font size / wrap) and returns `isRowTruncated` / `isColTruncated` (extra compute; enable only for layout checks or before adjusting row heights / column widths)",
"enum": [
"value",
"formula",
+ "raw_value",
"style",
"comment",
"data_validation",
@@ -2153,7 +2154,7 @@
"kind": "own",
"type": "string",
"required": "xor",
- "desc": "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress."
+ "desc": "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI reports the warning in the result's `data.warnings`. Pass `--highlight=false` to suppress."
},
{
"name": "dry-run",
@@ -3286,7 +3287,7 @@
"kind": "own",
"type": "string",
"required": "xor",
- "desc": "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress."
+ "desc": "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI reports the warning in the result's `data.warnings`. Pass `--highlight=false` to suppress."
},
{
"name": "dry-run",
@@ -3522,6 +3523,13 @@
"row"
]
},
+ {
+ "name": "aggregate-categories",
+ "kind": "own",
+ "type": "bool",
+ "required": "optional",
+ "desc": "Whether to aggregate duplicate categories; use --aggregate-categories=false for sparse markers or row-level data points, and omit it to use the chart default"
+ },
{
"name": "x-axis-numbers-as",
"kind": "own",
@@ -3553,14 +3561,14 @@
"kind": "own",
"type": "float64",
"required": "optional",
- "desc": "Lower display bound for the left Y-axis; must be less than --y-axis-max"
+ "desc": "Lower display bound for the left Y-axis; omit by default and pass only when the user explicitly requests fixed bounds; do not copy a raw source-column minimum, and keep it below --y-axis-max"
},
{
"name": "y-axis-max",
"kind": "own",
"type": "float64",
"required": "optional",
- "desc": "Upper display bound for the left Y-axis; must be greater than --y-axis-min"
+ "desc": "Upper display bound for the left Y-axis; omit by default and pass only when the user explicitly requests fixed bounds; calculate it from rendered chart semantics, and keep it above --y-axis-min"
},
{
"name": "dim1-index",
@@ -3581,14 +3589,14 @@
"kind": "own",
"type": "string",
"required": "optional",
- "desc": "Combo charts only; comma-separated series types aligned with --dim2-indexes; use column, line, or area and provide one value per selected series"
+ "desc": "Combo charts only; comma-separated series types aligned with --dim2-indexes; use column, line, area, or scatter and provide one value per selected series"
},
{
"name": "series-y-axes",
"kind": "own",
"type": "string",
"required": "optional",
- "desc": "Combo charts only; comma-separated left or right Y-axis assignments aligned with --dim2-indexes; provide one value per selected series"
+ "desc": "Combo charts only; compare series units and magnitudes first and place a series that would be flattened on the right axis; pass left or right aligned with --dim2-indexes, one value per selected series"
},
{
"name": "key-index",
@@ -3644,7 +3652,7 @@
"kind": "own",
"type": "string",
"required": "optional",
- "desc": "Legend position; hidden removes the legend",
+ "desc": "Legend position; use bottom by default for pie and doughnut charts; hidden removes the legend",
"enum": [
"top",
"bottom",
@@ -3707,7 +3715,7 @@
"kind": "own",
"type": "string",
"required": "optional",
- "desc": "Data label content; combine value, category, and percentage in value_category_percentage order for any non-empty combination; series shows series names and none removes labels",
+ "desc": "Data label content; pass value by default for a basic chart, but omit it when many data points or series would make labels crowded; combine value, category, and percentage in value_category_percentage order for any non-empty combination; series shows series names and none removes labels",
"enum": [
"none",
"value",
@@ -3762,7 +3770,7 @@
"kind": "own",
"type": "bool",
"required": "optional",
- "desc": "Use smooth curves; accepts both --smooth=false and --smooth false"
+ "desc": "Use smooth curves; pass --smooth=false to disable explicitly"
},
{
"name": "color-palette",
@@ -3804,7 +3812,7 @@
"kind": "own",
"type": "int",
"required": "optional",
- "desc": "Optional chart width; must be paired with --height"
+ "desc": "Optional chart width; must be paired with --height; widen pie or doughnut charts and charts with long category labels to avoid truncation"
},
{
"name": "height",
@@ -3956,14 +3964,14 @@
"kind": "own",
"type": "float64",
"required": "optional",
- "desc": "Lower display bound for the left Y-axis; must be less than --y-axis-max"
+ "desc": "Lower display bound for the left Y-axis; omit by default and pass only when the user explicitly requests fixed bounds; do not copy a raw source-column minimum, and keep it below --y-axis-max"
},
{
"name": "y-axis-max",
"kind": "own",
"type": "float64",
"required": "optional",
- "desc": "Upper display bound for the left Y-axis; must be greater than --y-axis-min"
+ "desc": "Upper display bound for the left Y-axis; omit by default and pass only when the user explicitly requests fixed bounds; calculate it from rendered chart semantics, and keep it above --y-axis-min"
},
{
"name": "data-labels",
@@ -4001,11 +4009,11 @@
]
},
{
- "name": "last-point-label",
+ "name": "aggregate-categories",
"kind": "own",
"type": "bool",
"required": "optional",
- "desc": "For line, area, radar, and linear combo series only; true shows a value label on the last data point of every series, while false removes those point labels"
+ "desc": "Whether to aggregate duplicate categories; use --aggregate-categories=false for sparse markers or row-level data points, and omit it to preserve the current setting"
},
{
"name": "stack",
@@ -4032,7 +4040,7 @@
"kind": "own",
"type": "bool",
"required": "optional",
- "desc": "Use smooth curves; accepts both --smooth=false and --smooth false"
+ "desc": "Use smooth curves; pass --smooth=false to disable explicitly"
},
{
"name": "color-palette",
diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json
index 311d19b100..b52f536542 100644
--- a/shortcuts/sheets/data/flag-schemas.json
+++ b/shortcuts/sheets/data/flag-schemas.json
@@ -1040,10 +1040,6 @@
"height"
]
},
- "last_point_label": {
- "type": "boolean",
- "description": "update 使用。true 为折线图、面积图、雷达图及组合图中的线性系列开启最后一个数据点的数值标签;false 关闭这些单点标签。"
- },
"snapshot": {
"description": "图表快照配置。create 需要完整配置;update 可只提供需要变更的字段,对象字段递归合并,数组整段替换,未提供的字段保持现状。",
"anyOf": [
@@ -1316,7 +1312,8 @@
"enum": [
"column",
"line",
- "area"
+ "area",
+ "scatter"
]
},
"yAxisPosition": {
@@ -1874,70 +1871,6 @@
"type": "number",
"description": "大小"
},
- "labels": {
- "type": "object",
- "description": "单点数据标签配置,仅折线图、面积图、雷达图及组合图中的线性系列生效。labels 对象的存在性即开关;显示最后一点的值时传 {\"value\": true}。",
- "properties": {
- "position": {
- "type": "string",
- "description": "标签位置",
- "enum": [
- "auto",
- "top",
- "bottom",
- "left",
- "right",
- "center",
- "inside",
- "outside"
- ]
- },
- "series": {
- "type": "boolean",
- "description": "是否显示系列名"
- },
- "category": {
- "type": "boolean",
- "description": "是否显示类别名"
- },
- "value": {
- "type": "boolean",
- "description": "是否显示值"
- },
- "percentage": {
- "type": "boolean",
- "description": "是否显示百分比"
- },
- "format": {
- "type": "string",
- "description": "标签数字格式"
- },
- "fontSize": {
- "type": "number",
- "description": "字体大小"
- },
- "bold": {
- "type": "boolean",
- "description": "是否加粗"
- },
- "italic": {
- "type": "boolean",
- "description": "是否斜体"
- },
- "underline": {
- "type": "boolean",
- "description": "是否下划线"
- },
- "strikethrough": {
- "type": "boolean",
- "description": "是否删除线"
- },
- "color": {
- "type": "string",
- "description": "字体颜色"
- }
- }
- },
"fillGradient": {
"type": "object",
"description": "单个数据点填充渐变配置",
@@ -2493,7 +2426,8 @@
},
"labels": {
"type": "object",
- "description": "数据标签配置。labels 对象的存在性即开关:不显示数据标签时省略整个 labels 字段;一旦传入 labels(即便 value/category/series/percentage 全部置为 false),数据标签仍会显示,且默认兜底显示 value。",
+ "nullable": true,
+ "description": "数据标签配置。labels 对象的存在性即开关:创建时不显示数据标签应省略整个 labels 字段;更新时传 null 可删除已有全局标签。一旦传入 labels 对象(即便 value/category/series/percentage 全部置为 false),数据标签仍会显示,且默认兜底显示 value。",
"properties": {
"position": {
"type": "string",
@@ -2568,7 +2502,8 @@
"enum": [
"column",
"line",
- "area"
+ "area",
+ "scatter"
]
},
"yAxisPosition": {
@@ -3340,10 +3275,6 @@
"height"
]
},
- "last_point_label": {
- "type": "boolean",
- "description": "update 使用。true 为折线图、面积图、雷达图及组合图中的线性系列开启最后一个数据点的数值标签;false 关闭这些单点标签。"
- },
"snapshot": {
"description": "图表快照配置。create 需要完整配置;update 可只提供需要变更的字段,对象字段递归合并,数组整段替换,未提供的字段保持现状。",
"anyOf": [
@@ -3616,7 +3547,8 @@
"enum": [
"column",
"line",
- "area"
+ "area",
+ "scatter"
]
},
"yAxisPosition": {
@@ -4174,70 +4106,6 @@
"type": "number",
"description": "大小"
},
- "labels": {
- "type": "object",
- "description": "单点数据标签配置,仅折线图、面积图、雷达图及组合图中的线性系列生效。labels 对象的存在性即开关;显示最后一点的值时传 {\"value\": true}。",
- "properties": {
- "position": {
- "type": "string",
- "description": "标签位置",
- "enum": [
- "auto",
- "top",
- "bottom",
- "left",
- "right",
- "center",
- "inside",
- "outside"
- ]
- },
- "series": {
- "type": "boolean",
- "description": "是否显示系列名"
- },
- "category": {
- "type": "boolean",
- "description": "是否显示类别名"
- },
- "value": {
- "type": "boolean",
- "description": "是否显示值"
- },
- "percentage": {
- "type": "boolean",
- "description": "是否显示百分比"
- },
- "format": {
- "type": "string",
- "description": "标签数字格式"
- },
- "fontSize": {
- "type": "number",
- "description": "字体大小"
- },
- "bold": {
- "type": "boolean",
- "description": "是否加粗"
- },
- "italic": {
- "type": "boolean",
- "description": "是否斜体"
- },
- "underline": {
- "type": "boolean",
- "description": "是否下划线"
- },
- "strikethrough": {
- "type": "boolean",
- "description": "是否删除线"
- },
- "color": {
- "type": "string",
- "description": "字体颜色"
- }
- }
- },
"fillGradient": {
"type": "object",
"description": "单个数据点填充渐变配置",
@@ -4793,7 +4661,8 @@
},
"labels": {
"type": "object",
- "description": "数据标签配置。labels 对象的存在性即开关:不显示数据标签时省略整个 labels 字段;一旦传入 labels(即便 value/category/series/percentage 全部置为 false),数据标签仍会显示,且默认兜底显示 value。",
+ "nullable": true,
+ "description": "数据标签配置。labels 对象的存在性即开关:创建时不显示数据标签应省略整个 labels 字段;更新时传 null 可删除已有全局标签。一旦传入 labels 对象(即便 value/category/series/percentage 全部置为 false),数据标签仍会显示,且默认兜底显示 value。",
"properties": {
"position": {
"type": "string",
@@ -4868,7 +4737,8 @@
"enum": [
"column",
"line",
- "area"
+ "area",
+ "scatter"
]
},
"yAxisPosition": {
diff --git a/shortcuts/sheets/execute_paths_test.go b/shortcuts/sheets/execute_paths_test.go
index 74d2cbfd50..880eacb8fd 100644
--- a/shortcuts/sheets/execute_paths_test.go
+++ b/shortcuts/sheets/execute_paths_test.go
@@ -939,7 +939,7 @@ func TestExecute_ChartConfigUpdate_ReadsSnapshotAndWritesPartialPatch(t *testing
"title":{"text":"Old"},
"plotArea":{
"axes":[
- {"type":"x","position":"bottom","title":{"text":"Month"}},
+ {"type":"x","valueType":"linear","axisLine":true,"label":{},"title":{"text":"Month"}},
{"type":"y","position":"left","title":{"text":"Amount"}}
],
"plot":{"type":"line","extra":{"smooth":false}}
@@ -950,33 +950,14 @@ func TestExecute_ChartConfigUpdate_ReadsSnapshotAndWritesPartialPatch(t *testing
}]
}`)
write := toolOutputStub(testToken, "write", `{"chart_id":"chart-1"}`)
- readAfter := toolOutputStub(testToken, "read", `{
- "sheets":[{
- "sheet_id":"shtSubA",
- "charts":[{
- "chart_id":"chart-1",
- "details":{"snapshot":{
- "title":{"text":"New"},
- "plotArea":{
- "axes":[
- {"type":"x","position":"bottom","title":{"text":"Month"}},
- {"type":"y","position":"left","title":{"text":"Revenue"}}
- ],
- "plot":{"type":"line","series":[{"index":1,"points":{"point":[{"index":4,"labels":{"value":true}}]}}]}
- },
- "data":{"direction":"column"}
- }}
- }]
- }]
- }`)
out, err := runShortcutWithStubs(t, ChartConfigUpdate, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-id", "chart-1",
"--title", "New",
+ "--x-axis-min", "2",
"--y-axis-title", "Revenue",
- "--last-point-label=true",
- }, readBefore, write, readAfter)
+ }, readBefore, write)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
@@ -986,19 +967,17 @@ func TestExecute_ChartConfigUpdate_ReadsSnapshotAndWritesPartialPatch(t *testing
t.Fatalf("read chart_id = %#v", readInput["chart_id"])
}
writeInput := decodeToolInput(t, decodeRawEnvelopeBody(t, write.CapturedBody), "manage_chart_object")
- if _, ok := writeInput["last_point_label"]; ok {
- t.Fatalf("last_point_label must not be written at the tool input root: %#v", writeInput)
- }
- writeProperties := writeInput["properties"].(map[string]interface{})
- if writeProperties["last_point_label"] != true {
- t.Fatalf("last_point_label = %#v, want true", writeProperties["last_point_label"])
- }
snapshot := chartDryRunSnapshot(t, writeInput)
if snapshot["title"].(map[string]interface{})["text"] != "New" {
t.Fatalf("partial title = %#v", snapshot["title"])
}
axes := snapshot["plotArea"].(map[string]interface{})["axes"].([]interface{})
- if len(axes) != 2 || axes[0].(map[string]interface{})["title"].(map[string]interface{})["text"] != "Month" ||
+ if len(axes) != 2 {
+ t.Fatalf("partial axes = %#v, want existing axes without a duplicate X axis", axes)
+ }
+ xAxis := axes[0].(map[string]interface{})
+ if xAxis["min"] != float64(2) || xAxis["axisLine"] != true ||
+ xAxis["title"].(map[string]interface{})["text"] != "Month" ||
axes[1].(map[string]interface{})["title"].(map[string]interface{})["text"] != "Revenue" {
t.Fatalf("partial axes = %#v", axes)
}
@@ -1007,12 +986,6 @@ func TestExecute_ChartConfigUpdate_ReadsSnapshotAndWritesPartialPatch(t *testing
if _, ok := viewModel["data"]; ok {
t.Fatal("config shortcut output viewModel must not include data")
}
- plot := viewModel["plotArea"].(map[string]interface{})["plot"].(map[string]interface{})
- series := plot["series"].([]interface{})
- point := series[0].(map[string]interface{})["points"].(map[string]interface{})["point"].([]interface{})[0].(map[string]interface{})
- if point["labels"].(map[string]interface{})["value"] != true {
- t.Fatalf("viewModel must come from the post-update readback: %#v", viewModel)
- }
}
func TestExecute_ChartDataUpdate_ReadsSnapshotAndReturnsData(t *testing.T) {
diff --git a/shortcuts/sheets/flag_defs_gen.go b/shortcuts/sheets/flag_defs_gen.go
index b5b97dc6ba..b5d83eb16b 100644
--- a/shortcuts/sheets/flag_defs_gen.go
+++ b/shortcuts/sheets/flag_defs_gen.go
@@ -95,7 +95,7 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
- {Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include. `truncation` additionally estimates whether each cell's content is clipped (by row height / col width / font size / wrap) and returns `isRowTruncated` / `isColTruncated` (extra compute; enable only for layout checks or before adjusting row heights / column widths)", Enum: []string{"value", "formula", "style", "comment", "data_validation", "truncation"}},
+ {Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include. `raw_value` returns the original cell value or raw formula result, preserving number / string types, and is mutually exclusive with `formula`. `truncation` additionally estimates whether each cell's content is clipped (by row height / col width / font size / wrap) and returns `isRowTruncated` / `isColTruncated` (extra compute; enable only for layout checks or before adjusting row heights / column widths)", Enum: []string{"value", "formula", "raw_value", "style", "comment", "data_validation", "truncation"}},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (the cap auto-raises to a bounded 20M chars — the read path is not streaming, this cap is the memory guard; pass an explicit --max-chars for more); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more. Passing 0 means \"no cap of my own\" and resolves to the same ceiling as leaving the flag alone (500000, or the offload limit with --output-path) — never down to the tool's smaller omitted-value fallback.", Default: "500000"},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap auto-raises to a bounded offload default (20M chars)** rather than unlimited — the read path is not streaming, so this cap is the memory guard; an explicit --max-chars overrides it. The stdout receipt reports `complete` (and `truncated` plus a warning when the cap was hit), so check it instead of assuming the file holds the whole sheet. Omit it to print to stdout as usual."},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
@@ -239,14 +239,14 @@ var flagDefs = map[string]commandDef{
{Name: "y-axis-label-angle", Kind: "own", Type: "int", Required: "optional", Desc: "Left Y-axis label angle", Enum: []string{"-90", "-45", "0", "45", "90"}},
{Name: "x-axis-min", Kind: "own", Type: "float64", Required: "optional", Desc: "Lower display bound for a continuous numeric X-axis; must be less than --x-axis-max"},
{Name: "x-axis-max", Kind: "own", Type: "float64", Required: "optional", Desc: "Upper display bound for a continuous numeric X-axis; must be greater than --x-axis-min"},
- {Name: "y-axis-min", Kind: "own", Type: "float64", Required: "optional", Desc: "Lower display bound for the left Y-axis; must be less than --y-axis-max"},
- {Name: "y-axis-max", Kind: "own", Type: "float64", Required: "optional", Desc: "Upper display bound for the left Y-axis; must be greater than --y-axis-min"},
+ {Name: "y-axis-min", Kind: "own", Type: "float64", Required: "optional", Desc: "Lower display bound for the left Y-axis; omit by default and pass only when the user explicitly requests fixed bounds; do not copy a raw source-column minimum, and keep it below --y-axis-max"},
+ {Name: "y-axis-max", Kind: "own", Type: "float64", Required: "optional", Desc: "Upper display bound for the left Y-axis; omit by default and pass only when the user explicitly requests fixed bounds; calculate it from rendered chart semantics, and keep it above --y-axis-min"},
{Name: "data-labels", Kind: "own", Type: "string", Required: "optional", Desc: "Data label content; combine value, category, and percentage in value_category_percentage order for any non-empty combination; series shows series names and none removes labels", Enum: []string{"none", "value", "category", "percentage", "value_category", "value_percentage", "category_percentage", "value_category_percentage", "series"}},
{Name: "data-label-position", Kind: "own", Type: "string", Required: "optional", Desc: "Pass only when the user explicitly requests a position; it only repositions existing data labels and does not enable labels by itself; omit it for chart-type-aware placement", Enum: []string{"auto", "top", "bottom", "left", "right", "center", "inside", "outside"}},
- {Name: "last-point-label", Kind: "own", Type: "bool", Required: "optional", Desc: "For line, area, radar, and linear combo series only; true shows a value label on the last data point of every series, while false removes those point labels"},
+ {Name: "aggregate-categories", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to aggregate duplicate categories; use --aggregate-categories=false for sparse markers or row-level data points, and omit it to preserve the current setting"},
{Name: "stack", Kind: "own", Type: "string", Required: "optional", Desc: "Stacking mode", Enum: []string{"none", "normal", "percent"}},
{Name: "stacked", Kind: "own", Type: "bool", Required: "optional", Desc: "Compatibility alias for --stack normal", Hidden: true},
- {Name: "smooth", Kind: "own", Type: "bool", Required: "optional", Desc: "Use smooth curves; accepts both --smooth=false and --smooth false"},
+ {Name: "smooth", Kind: "own", Type: "bool", Required: "optional", Desc: "Use smooth curves; pass --smooth=false to disable explicitly"},
{Name: "color-palette", Kind: "own", Type: "string", Required: "optional", Desc: "Preset chart-level color palette; mutually exclusive with --colors", Enum: []string{"brandColorSeries@v2", "rainbowColorSeries@v2", "complementaryColorSeries@v2", "converseColorSeries@v2", "primaryColorSeries@v2", "singleColorSeries-B-@v2", "singleColorSeries-W-@v2", "singleColorSeries-G-@v2", "singleColorSeries-Y-@v2", "singleColorSeries-O-@v2", "singleColorSeries-R-@v2", "singleColorSeries-D-@v2"}},
{Name: "colors", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Custom chart-level series colors as a comma-separated list of at least two hex colors; mutually exclusive with --color-palette"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
@@ -275,15 +275,16 @@ var flagDefs = map[string]commandDef{
{Name: "data-range", Kind: "own", Type: "string", Required: "required", Desc: "Data range; include headers unless --header-range is set, in which case pass data only; accepts comma-separated ranges across one or more sheets"},
{Name: "header-range", Kind: "own", Type: "string", Required: "optional", Desc: "Optional detached header range; use one row for column direction or one column for row direction, with one header per data dimension"},
{Name: "data-direction", Kind: "own", Type: "string", Required: "optional", Desc: "Data series direction; column uses the first column as categories, row uses the first row", Default: "column", Enum: []string{"column", "row"}},
+ {Name: "aggregate-categories", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to aggregate duplicate categories; use --aggregate-categories=false for sparse markers or row-level data points, and omit it to use the chart default"},
{Name: "x-axis-numbers-as", Kind: "own", Type: "string", Required: "optional", Desc: "How to interpret numeric X-axis values; text treats numbers as evenly spaced text categories, while values uses a continuous numeric scale and preserves true spacing", Default: "text", Enum: []string{"text", "values"}},
{Name: "x-axis-min", Kind: "own", Type: "float64", Required: "optional", Desc: "Lower display bound for a continuous numeric X-axis; requires --x-axis-numbers-as values"},
{Name: "x-axis-max", Kind: "own", Type: "float64", Required: "optional", Desc: "Upper display bound for a continuous numeric X-axis; requires --x-axis-numbers-as values"},
- {Name: "y-axis-min", Kind: "own", Type: "float64", Required: "optional", Desc: "Lower display bound for the left Y-axis; must be less than --y-axis-max"},
- {Name: "y-axis-max", Kind: "own", Type: "float64", Required: "optional", Desc: "Upper display bound for the left Y-axis; must be greater than --y-axis-min"},
+ {Name: "y-axis-min", Kind: "own", Type: "float64", Required: "optional", Desc: "Lower display bound for the left Y-axis; omit by default and pass only when the user explicitly requests fixed bounds; do not copy a raw source-column minimum, and keep it below --y-axis-max"},
+ {Name: "y-axis-max", Kind: "own", Type: "float64", Required: "optional", Desc: "Upper display bound for the left Y-axis; omit by default and pass only when the user explicitly requests fixed bounds; calculate it from rendered chart semantics, and keep it above --y-axis-min"},
{Name: "dim1-index", Kind: "own", Type: "int", Required: "optional", Desc: "1-based category/X-axis dimension index within the data range; defaults to 1"},
{Name: "dim2-indexes", Kind: "own", Type: "string", Required: "optional", Desc: "Comma-separated 1-based value/Y-axis dimension indexes; must exclude dim1, at most 50. Legacy bubble calls accept 2–4 indexes in `x,y[,group][,size]` order; new calls should use role-specific indexes. Pie and pareto charts accept exactly one"},
- {Name: "series-types", Kind: "own", Type: "string", Required: "optional", Desc: "Combo charts only; comma-separated series types aligned with --dim2-indexes; use column, line, or area and provide one value per selected series"},
- {Name: "series-y-axes", Kind: "own", Type: "string", Required: "optional", Desc: "Combo charts only; comma-separated left or right Y-axis assignments aligned with --dim2-indexes; provide one value per selected series"},
+ {Name: "series-types", Kind: "own", Type: "string", Required: "optional", Desc: "Combo charts only; comma-separated series types aligned with --dim2-indexes; use column, line, area, or scatter and provide one value per selected series"},
+ {Name: "series-y-axes", Kind: "own", Type: "string", Required: "optional", Desc: "Combo charts only; compare series units and magnitudes first and place a series that would be flattened on the right axis; pass left or right aligned with --dim2-indexes, one value per selected series"},
{Name: "key-index", Kind: "own", Type: "int", Required: "optional", Desc: "Bubble only: 1-based key/name dimension index; mutually exclusive with dim1/dim2 indexes; defaults to 1"},
{Name: "x-index", Kind: "own", Type: "int", Required: "optional", Desc: "Bubble only: 1-based X-value dimension index; must be provided with --y-index"},
{Name: "y-index", Kind: "own", Type: "int", Required: "optional", Desc: "Bubble only: 1-based Y-value dimension index; must be provided with --x-index"},
@@ -291,21 +292,21 @@ var flagDefs = map[string]commandDef{
{Name: "size-index", Kind: "own", Type: "int", Required: "optional", Desc: "Bubble only: optional 1-based bubble-size dimension index"},
{Name: "title", Kind: "own", Type: "string", Required: "optional", Desc: "Chart title"},
{Name: "subtitle", Kind: "own", Type: "string", Required: "optional", Desc: "Chart subtitle"},
- {Name: "legend-position", Kind: "own", Type: "string", Required: "optional", Desc: "Legend position; hidden removes the legend", Enum: []string{"top", "bottom", "left", "right", "hidden"}},
+ {Name: "legend-position", Kind: "own", Type: "string", Required: "optional", Desc: "Legend position; use bottom by default for pie and doughnut charts; hidden removes the legend", Enum: []string{"top", "bottom", "left", "right", "hidden"}},
{Name: "x-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "X-axis title"},
{Name: "y-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "Left Y-axis title"},
{Name: "secondary-y-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "Right Y-axis title"},
{Name: "x-axis-label-angle", Kind: "own", Type: "int", Required: "optional", Desc: "X-axis label angle", Enum: []string{"-90", "-45", "0", "45", "90"}},
{Name: "y-axis-label-angle", Kind: "own", Type: "int", Required: "optional", Desc: "Left Y-axis label angle", Enum: []string{"-90", "-45", "0", "45", "90"}},
- {Name: "data-labels", Kind: "own", Type: "string", Required: "optional", Desc: "Data label content; combine value, category, and percentage in value_category_percentage order for any non-empty combination; series shows series names and none removes labels", Enum: []string{"none", "value", "category", "percentage", "value_category", "value_percentage", "category_percentage", "value_category_percentage", "series"}},
+ {Name: "data-labels", Kind: "own", Type: "string", Required: "optional", Desc: "Data label content; pass value by default for a basic chart, but omit it when many data points or series would make labels crowded; combine value, category, and percentage in value_category_percentage order for any non-empty combination; series shows series names and none removes labels", Enum: []string{"none", "value", "category", "percentage", "value_category", "value_percentage", "category_percentage", "value_category_percentage", "series"}},
{Name: "data-label-position", Kind: "own", Type: "string", Required: "optional", Desc: "Pass only when the user explicitly requests a position; it only repositions existing data labels and does not enable labels by itself; omit it for chart-type-aware placement", Enum: []string{"auto", "top", "bottom", "left", "right", "center", "inside", "outside"}},
{Name: "stack", Kind: "own", Type: "string", Required: "optional", Desc: "Stacking mode", Enum: []string{"none", "normal", "percent"}},
{Name: "stacked", Kind: "own", Type: "bool", Required: "optional", Desc: "Compatibility alias for --stack normal", Hidden: true},
- {Name: "smooth", Kind: "own", Type: "bool", Required: "optional", Desc: "Use smooth curves; accepts both --smooth=false and --smooth false"},
+ {Name: "smooth", Kind: "own", Type: "bool", Required: "optional", Desc: "Use smooth curves; pass --smooth=false to disable explicitly"},
{Name: "color-palette", Kind: "own", Type: "string", Required: "optional", Desc: "Preset chart-level color palette; mutually exclusive with --colors", Enum: []string{"brandColorSeries@v2", "rainbowColorSeries@v2", "complementaryColorSeries@v2", "converseColorSeries@v2", "primaryColorSeries@v2", "singleColorSeries-B-@v2", "singleColorSeries-W-@v2", "singleColorSeries-G-@v2", "singleColorSeries-Y-@v2", "singleColorSeries-O-@v2", "singleColorSeries-R-@v2", "singleColorSeries-D-@v2"}},
{Name: "colors", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Custom chart-level series colors as a comma-separated list of at least two hex colors; mutually exclusive with --color-palette"},
{Name: "anchor-cell", Kind: "own", Type: "string", Required: "optional", Desc: "Optional chart anchor cell such as F2; defaults to the right of the data range"},
- {Name: "width", Kind: "own", Type: "int", Required: "optional", Desc: "Optional chart width; must be paired with --height"},
+ {Name: "width", Kind: "own", Type: "int", Required: "optional", Desc: "Optional chart width; must be paired with --height; widen pie or doughnut charts and charts with long category labels to avoid truncation"},
{Name: "height", Kind: "own", Type: "int", Required: "optional", Desc: "Optional chart height; must be paired with --width"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
},
@@ -591,7 +592,7 @@ var flagDefs = map[string]commandDef{
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
{Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select; default `false`"},
{Name: "highlight", Kind: "own", Type: "bool", Required: "optional", Desc: "Pill-highlight switch. **Omitted = ON** (options cycle through a 10-color palette). Pass `--highlight=false` for a plain dropdown. Override colors via `--colors`."},
- {Name: "source-range", Kind: "own", Type: "string", Required: "xor", Desc: "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress."},
+ {Name: "source-range", Kind: "own", Type: "string", Required: "xor", Desc: "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI reports the warning in the result's `data.warnings`. Pass `--highlight=false` to suppress."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -605,7 +606,7 @@ var flagDefs = map[string]commandDef{
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
{Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select"},
{Name: "highlight", Kind: "own", Type: "bool", Required: "optional", Desc: "Pill-highlight switch. **Omitted = ON** (options cycle through a 10-color palette). Pass `--highlight=false` for a plain dropdown. Override colors via `--colors`."},
- {Name: "source-range", Kind: "own", Type: "string", Required: "xor", Desc: "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress."},
+ {Name: "source-range", Kind: "own", Type: "string", Required: "xor", Desc: "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI reports the warning in the result's `data.warnings`. Pass `--highlight=false` to suppress."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
diff --git a/shortcuts/sheets/flag_schema_validate_test.go b/shortcuts/sheets/flag_schema_validate_test.go
index cdfbaec549..e8f897bbf5 100644
--- a/shortcuts/sheets/flag_schema_validate_test.go
+++ b/shortcuts/sheets/flag_schema_validate_test.go
@@ -1052,6 +1052,18 @@ func TestValidateInputAgainstSchema_ChartUpdateRecursivePartial(t *testing.T) {
if err := validateInputAgainstSchema(mapFlagView{command: "+chart-update"}, patch); err != nil {
t.Fatalf("nested chart update patch rejected: %v", err)
}
+ deleteLabels := map[string]interface{}{
+ "properties": map[string]interface{}{
+ "snapshot": map[string]interface{}{
+ "plotArea": map[string]interface{}{
+ "plot": map[string]interface{}{"labels": nil},
+ },
+ },
+ },
+ }
+ if err := validateInputAgainstSchema(mapFlagView{command: "+chart-update"}, deleteLabels); err != nil {
+ t.Fatalf("nullable chart labels deletion rejected: %v", err)
+ }
createErr := validateInputAgainstSchema(mapFlagView{command: "+chart-create"}, patch)
ve := requireValidation(t, createErr, `required property "type" is missing`)
if ve.Param != "--properties" {
diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go
index b44ef6c339..cc425be088 100644
--- a/shortcuts/sheets/helpers.go
+++ b/shortcuts/sheets/helpers.go
@@ -73,7 +73,7 @@ func isOfficeSpreadsheet(spreadsheetToken string) bool {
return true
}
}
- if len(spreadsheetToken) != 28 {
+ if len(spreadsheetToken) < 25 {
return false
}
// The five-character marker occupies positions 5, 10, 15, 20, and 25
diff --git a/shortcuts/sheets/lark_sheet_chart.go b/shortcuts/sheets/lark_sheet_chart.go
index 419d4f6365..07a4ef8c7c 100644
--- a/shortcuts/sheets/lark_sheet_chart.go
+++ b/shortcuts/sheets/lark_sheet_chart.go
@@ -210,15 +210,6 @@ var ChartConfigUpdate = common.Shortcut{
if err != nil {
return err
}
- if runtime.Changed("last-point-label") {
- updatedSnapshot, readErr := fetchChartSnapshot(
- ctx, runtime, token, sheetID, sheetName, runtime.Str("chart-id"),
- )
- if readErr != nil {
- return readErr
- }
- viewModel = chartViewModel(updatedSnapshot)
- }
runtime.Out(withChartShortcutResult(out, "viewModel", viewModel), nil)
return nil
},
@@ -380,7 +371,7 @@ func chartCreateBasicInput(rt flagView, token, sheetID, sheetName string) (map[s
}
var seriesTypes []string
if rt.Changed("series-types") {
- seriesTypes, err = parseChartEnumList(rt.Str("series-types"), "series-types", []string{"column", "line", "area"})
+ seriesTypes, err = parseChartEnumList(rt.Str("series-types"), "series-types", []string{"column", "line", "area", "scatter"})
if err != nil {
return nil, err
}
@@ -537,7 +528,7 @@ func chartConfigUpdateInput(rt flagView, token, sheetID, sheetName string) (map[
return nil, err
}
addChartSemanticConfig(rt, updates)
- if len(updates) == 0 && !rt.Changed("last-point-label") {
+ if len(updates) == 0 {
return nil, common.ValidationErrorf("at least one chart configuration flag is required")
}
patch, _ := applyChartConfigPatch(map[string]interface{}{}, updates)
@@ -549,9 +540,6 @@ func chartConfigUpdateInput(rt flagView, token, sheetID, sheetName string) (map[
"snapshot": patch,
},
}
- if rt.Changed("last-point-label") {
- input["properties"].(map[string]interface{})["last_point_label"] = rt.Bool("last-point-label")
- }
sheetSelectorForToolInput(input, sheetID, sheetName)
if err := validateInputAgainstSchema(rt, input); err != nil {
return nil, err
@@ -678,9 +666,6 @@ func chartConfigUpdateInputFromSnapshot(
"snapshot": patch,
},
}
- if rt.Changed("last-point-label") {
- input["properties"].(map[string]interface{})["last_point_label"] = rt.Bool("last-point-label")
- }
sheetSelectorForToolInput(input, sheetID, sheetName)
if err := validateInputAgainstSchema(rt, input); err != nil {
return nil, nil, err
@@ -1013,6 +998,20 @@ func applyChartConfigPatch(
next := cloneChartMap(current)
patch := map[string]interface{}{}
plotChanged := false
+ if value, ok := updates["aggregate_categories"].(bool); ok {
+ data := chartMap(next["data"])
+ dim1 := chartMap(data["dim1"])
+ serie := chartMap(dim1["serie"])
+ serie["aggregate"] = value
+ dim1["serie"] = serie
+ data["dim1"] = dim1
+ next["data"] = data
+ patch["data"] = map[string]interface{}{
+ "dim1": map[string]interface{}{
+ "serie": map[string]interface{}{"aggregate": value},
+ },
+ }
+ }
if value, ok := updates["title"].(string); ok {
title := chartMap(next["title"])
@@ -1042,6 +1041,7 @@ func applyChartConfigPatch(
plot := chartMap(plotArea["plot"])
plotArea["plot"] = plot
next["plotArea"] = plotArea
+ removeGlobalLabels := false
for _, item := range []struct {
key string
axisType string
@@ -1091,6 +1091,7 @@ func applyChartConfigPatch(
if value, ok := updates["data_labels"].(string); ok {
if value == "none" {
delete(plot, "labels")
+ removeGlobalLabels = true
} else {
labels := map[string]interface{}{
"series": value == "series",
@@ -1150,7 +1151,13 @@ func applyChartConfigPatch(
patch["style"] = map[string]interface{}{"colorTheme": colorTheme}
}
if plotChanged {
- patch["plotArea"] = plotArea
+ patchPlotArea := cloneChartMap(plotArea)
+ if removeGlobalLabels {
+ patchPlot := chartMap(patchPlotArea["plot"])
+ patchPlot["labels"] = nil
+ patchPlotArea["plot"] = patchPlot
+ }
+ patch["plotArea"] = patchPlotArea
}
return patch, chartViewModel(next)
}
@@ -1192,7 +1199,13 @@ func findChartAxisMap(plotArea map[string]interface{}, axisType, position string
axes, _ := plotArea["axes"].([]interface{})
for _, raw := range axes {
axis, _ := raw.(map[string]interface{})
- if axis["type"] == axisType && axis["position"] == position {
+ axisPosition, hasPosition := axis["position"]
+ positionMatches := axisPosition == position
+ // Chart readback omits position for the canonical bottom X axis.
+ if !hasPosition && axisType == "x" && position == "bottom" {
+ positionMatches = true
+ }
+ if axis["type"] == axisType && positionMatches {
return axis
}
}
@@ -1637,6 +1650,9 @@ func addChartSemanticConfig(rt flagView, out map[string]interface{}) {
if rt.Changed("smooth") {
out["smooth"] = rt.Bool("smooth")
}
+ if rt.Changed("aggregate-categories") {
+ out["aggregate_categories"] = rt.Bool("aggregate-categories")
+ }
if rt.Changed("colors") {
out["colors"] = normalizedChartColors(rt)
}
diff --git a/shortcuts/sheets/lark_sheet_chart_test.go b/shortcuts/sheets/lark_sheet_chart_test.go
index 65ee261e88..127aef2eb8 100644
--- a/shortcuts/sheets/lark_sheet_chart_test.go
+++ b/shortcuts/sheets/lark_sheet_chart_test.go
@@ -524,7 +524,13 @@ func TestChartConfigUpdate_XAxisBoundsRequireContinuousExistingAxis(t *testing.T
linear := map[string]interface{}{
"plotArea": map[string]interface{}{
"axes": []interface{}{
- map[string]interface{}{"type": "x", "position": "bottom", "valueType": "linear"},
+ map[string]interface{}{
+ "type": "x",
+ "valueType": "linear",
+ "axisLine": true,
+ "label": map[string]interface{}{"angle": 15},
+ },
+ map[string]interface{}{"type": "y", "position": "left", "valueType": "linear"},
},
},
}
@@ -532,9 +538,14 @@ func TestChartConfigUpdate_XAxisBoundsRequireContinuousExistingAxis(t *testing.T
if err != nil {
t.Fatalf("linear X axis rejected: %v", err)
}
- xAxis := chartDryRunSnapshot(t, input)["plotArea"].(map[string]interface{})["axes"].([]interface{})[0].(map[string]interface{})
- if xAxis["min"] != float64(237) {
- t.Fatalf("x axis = %#v, want min=237", xAxis)
+ axes := chartDryRunSnapshot(t, input)["plotArea"].(map[string]interface{})["axes"].([]interface{})
+ if len(axes) != 2 {
+ t.Fatalf("axes = %#v, want existing axes without a duplicate X axis", axes)
+ }
+ xAxis := axes[0].(map[string]interface{})
+ label := xAxis["label"].(map[string]interface{})
+ if xAxis["min"] != float64(237) || xAxis["axisLine"] != true || label["angle"] != float64(15) {
+ t.Fatalf("x axis = %#v, want min=237 with existing axis properties preserved", xAxis)
}
}
@@ -645,6 +656,23 @@ func TestChartSemanticShortcuts_DataLabelCombinations(t *testing.T) {
}
}
+func TestChartConfigUpdate_DataLabelsNoneSendsExplicitDeletion(t *testing.T) {
+ t.Parallel()
+ chartConfigUpdate := shortcutFromRegistry(t, "+chart-config-update")
+ body := parseDryRunBody(t, chartConfigUpdate, []string{
+ "--url", testURL,
+ "--sheet-id", testSheetID,
+ "--chart-id", "chart-1",
+ "--data-labels", "none",
+ })
+ snapshot := chartDryRunSnapshot(t, decodeToolInput(t, body, "manage_chart_object"))
+ plot := snapshot["plotArea"].(map[string]interface{})["plot"].(map[string]interface{})
+ labels, exists := plot["labels"]
+ if !exists || labels != nil {
+ t.Fatalf("labels = %#v (exists=%t), want explicit null deletion marker", labels, exists)
+ }
+}
+
func TestChartConfigUpdate_DataLabelPositionDoesNotEnableLabels(t *testing.T) {
t.Parallel()
current := map[string]interface{}{
@@ -684,33 +712,6 @@ func TestChartConfigUpdate_DataLabelPositionDoesNotEnableLabels(t *testing.T) {
}
}
-func TestChartSemanticShortcuts_LastPointLabel(t *testing.T) {
- t.Parallel()
- chartConfigUpdate := shortcutFromRegistry(t, "+chart-config-update")
- for _, tc := range []struct {
- arg string
- want bool
- }{
- {arg: "true", want: true},
- {arg: "false", want: false},
- } {
- body := parseDryRunBody(t, chartConfigUpdate, []string{
- "--url", testURL,
- "--sheet-id", testSheetID,
- "--chart-id", "chart-1",
- "--last-point-label=" + tc.arg,
- })
- input := decodeToolInput(t, body, "manage_chart_object")
- if _, ok := input["last_point_label"]; ok {
- t.Fatalf("--last-point-label=%s must not be written at the tool input root: %#v", tc.arg, input)
- }
- properties := input["properties"].(map[string]interface{})
- if properties["last_point_label"] != tc.want {
- t.Fatalf("--last-point-label=%s input = %#v, want %t", tc.arg, input, tc.want)
- }
- }
-}
-
func TestChartSemanticShortcuts_CompatibleAliasesInBatch(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, BatchChartUpdate, []string{
@@ -815,6 +816,57 @@ func TestChartCreateBasic_SelectsDimensionsAtCreation(t *testing.T) {
}
}
+func TestChartAggregateCategoriesFlags(t *testing.T) {
+ t.Parallel()
+
+ createBody := parseDryRunBody(t, ChartCreateBasic, []string{
+ "--url", testURL,
+ "--sheet-id", testSheetID,
+ "--chart-type", "line",
+ "--data-range", "A1:C7",
+ "--aggregate-categories=false",
+ })
+ basic := decodeToolInput(t, createBody, "manage_chart_object")["basic_chart"].(map[string]interface{})
+ if basic["aggregate_categories"] != false {
+ t.Fatalf("basic_chart.aggregate_categories = %#v, want false", basic["aggregate_categories"])
+ }
+
+ batchCreateBody := parseDryRunBody(t, BatchChartCreate, []string{
+ "--url", testURL,
+ "--operations", `[{"sheet_id":"sh1","chart_type":"line","data_range":"A1:C7","aggregate_categories":false}]`,
+ })
+ batchCreateInput := decodeToolInput(t, batchCreateBody, "batch_update")
+ batchCreateOperation := batchCreateInput["operations"].([]interface{})[0].(map[string]interface{})
+ batchCreateBasic := batchCreateOperation["input"].(map[string]interface{})["basic_chart"].(map[string]interface{})
+ if batchCreateBasic["aggregate_categories"] != false {
+ t.Fatalf("batch basic_chart.aggregate_categories = %#v, want false", batchCreateBasic["aggregate_categories"])
+ }
+
+ updateBody := parseDryRunBody(t, ChartConfigUpdate, []string{
+ "--url", testURL,
+ "--sheet-id", testSheetID,
+ "--chart-id", "chart-1",
+ "--aggregate-categories=false",
+ })
+ data := chartDryRunSnapshot(t, decodeToolInput(t, updateBody, "manage_chart_object"))["data"].(map[string]interface{})
+ serie := data["dim1"].(map[string]interface{})["serie"].(map[string]interface{})
+ if serie["aggregate"] != false {
+ t.Fatalf("snapshot.data.dim1.serie.aggregate = %#v, want false", serie["aggregate"])
+ }
+
+ batchBody := parseDryRunBody(t, BatchChartUpdate, []string{
+ "--url", testURL,
+ "--operations", `[{"shortcut":"+chart-config-update","input":{"sheet_id":"sh1","chart_id":"chart-1","aggregate_categories":false}}]`,
+ })
+ batchInput := decodeToolInput(t, batchBody, "batch_update")
+ operation := batchInput["operations"].([]interface{})[0].(map[string]interface{})
+ data = chartDryRunSnapshot(t, operation["input"].(map[string]interface{}))["data"].(map[string]interface{})
+ serie = data["dim1"].(map[string]interface{})["serie"].(map[string]interface{})
+ if serie["aggregate"] != false {
+ t.Fatalf("batch snapshot.data.dim1.serie.aggregate = %#v, want false", serie["aggregate"])
+ }
+}
+
func TestChartCreateBasic_ConfiguresComboSeriesSemantically(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartCreateBasic, []string{
@@ -823,11 +875,11 @@ func TestChartCreateBasic_ConfiguresComboSeriesSemantically(t *testing.T) {
"--chart-type", "combo",
"--data-range", "A1:D7",
"--dim2-indexes", "2,3,4",
- "--series-types", "column,column,line",
+ "--series-types", "column,line,scatter",
"--series-y-axes", "left,left,right",
})
basic := decodeToolInput(t, body, "manage_chart_object")["basic_chart"].(map[string]interface{})
- if got := basic["series_types"]; !reflect.DeepEqual(got, []interface{}{"column", "column", "line"}) {
+ if got := basic["series_types"]; !reflect.DeepEqual(got, []interface{}{"column", "line", "scatter"}) {
t.Fatalf("basic_chart.series_types = %#v", got)
}
if got := basic["series_y_axes"]; !reflect.DeepEqual(got, []interface{}{"left", "left", "right"}) {
@@ -839,12 +891,12 @@ func TestChartCreateBasic_ConfiguresComboSeriesSemanticallyInBatch(t *testing.T)
t.Parallel()
body := parseDryRunBody(t, BatchChartCreate, []string{
"--url", testURL,
- "--operations", `[{"sheet_id":"sh1","chart_type":"combo","data_range":"A1:D7","dim2_indexes":[2,3,4],"series_types":["column","column","line"],"series_y_axes":["left","left","right"]}]`,
+ "--operations", `[{"sheet_id":"sh1","chart_type":"combo","data_range":"A1:D7","dim2_indexes":[2,3,4],"series_types":["column","line","scatter"],"series_y_axes":["left","left","right"]}]`,
})
input := decodeToolInput(t, body, "batch_update")
ops := input["operations"].([]interface{})
basic := ops[0].(map[string]interface{})["input"].(map[string]interface{})["basic_chart"].(map[string]interface{})
- if got := basic["series_types"]; !reflect.DeepEqual(got, []interface{}{"column", "column", "line"}) {
+ if got := basic["series_types"]; !reflect.DeepEqual(got, []interface{}{"column", "line", "scatter"}) {
t.Fatalf("batch basic_chart.series_types = %#v", got)
}
if got := basic["series_y_axes"]; !reflect.DeepEqual(got, []interface{}{"left", "left", "right"}) {
diff --git a/shortcuts/sheets/lark_sheet_read_data.go b/shortcuts/sheets/lark_sheet_read_data.go
index f013af3ee5..7f625daddf 100644
--- a/shortcuts/sheets/lark_sheet_read_data.go
+++ b/shortcuts/sheets/lark_sheet_read_data.go
@@ -49,6 +49,14 @@ var CellsGet = common.Shortcut{
if strings.TrimSpace(runtime.Str("range")) == "" {
return sheetsValidationForFlag("range", "--range is required")
}
+ wantFormula, wantRawValue := false, false
+ for _, value := range runtime.StrSlice("include") {
+ wantFormula = wantFormula || value == "formula"
+ wantRawValue = wantRawValue || value == "raw_value"
+ }
+ if wantFormula && wantRawValue {
+ return sheetsValidationForFlag("include", "--include formula and raw_value are mutually exclusive")
+ }
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -97,7 +105,8 @@ func cellsGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName str
// tool's switches:
//
// - include_styles (bool) — toggled by "style" presence
-// - value_render_option (enum) — "formula" → formula; otherwise omitted
+// - value_render_option (enum) — "formula" → formula; "raw_value" →
+// raw_value; otherwise omitted
// - include_truncation_info (bool) — toggled by "truncation" presence; makes
// the tool estimate and return per-cell isRowTruncated / isColTruncated
//
@@ -117,7 +126,9 @@ func applyIncludeToCellsGet(input map[string]interface{}, include []string) {
} else {
input["include_styles"] = false
}
- if want["formula"] {
+ if want["raw_value"] {
+ input["value_render_option"] = "raw_value"
+ } else if want["formula"] {
input["value_render_option"] = "formula"
}
if want["truncation"] {
diff --git a/shortcuts/sheets/lark_sheet_read_data_test.go b/shortcuts/sheets/lark_sheet_read_data_test.go
index b45c8d03f6..774a8f8a0f 100644
--- a/shortcuts/sheets/lark_sheet_read_data_test.go
+++ b/shortcuts/sheets/lark_sheet_read_data_test.go
@@ -48,6 +48,20 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
"cell_limit": float64(unboundedReadLimit),
},
},
+ {
+ name: "+cells-get include=raw_value preserves source value types",
+ sc: CellsGet,
+ args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--include", "style,raw_value"},
+ toolName: "get_cell_ranges",
+ wantInput: map[string]interface{}{
+ "excel_id": testToken,
+ "sheet_id": testSheetID,
+ "ranges": []interface{}{"A1:B2"},
+ "include_styles": true,
+ "value_render_option": "raw_value",
+ "cell_limit": float64(unboundedReadLimit),
+ },
+ },
{
// --include truncation toggles include_truncation_info so the tool
// estimates and returns per-cell isRowTruncated / isColTruncated.
@@ -174,6 +188,15 @@ func TestReadData_RequiresRange(t *testing.T) {
}
}
+func TestCellsGet_FormulaAndRawValueAreMutuallyExclusive(t *testing.T) {
+ t.Parallel()
+ _, _, err := runShortcutCapturingErr(t, CellsGet, []string{
+ "--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2",
+ "--include", "formula,raw_value", "--dry-run",
+ })
+ requireValidation(t, err, "mutually exclusive")
+}
+
// TestCsvGet_RangeOptionalDefaultsToFullSheet pins the whole-sheet default:
// with --range omitted the request carries the over-wide clip range, so a
// full read needs no workbook-info pre-flight (eval: --range was the most
diff --git a/shortcuts/sheets/sheet_media_parent_type_test.go b/shortcuts/sheets/sheet_media_parent_type_test.go
index 395be8764a..c23dfd4427 100644
--- a/shortcuts/sheets/sheet_media_parent_type_test.go
+++ b/shortcuts/sheets/sheet_media_parent_type_test.go
@@ -46,8 +46,11 @@ func TestSheetMediaParentType(t *testing.T) {
{"interleaved shtcn native token", "abcdsefghhijkltmnopcqrstnuv", sheetImageParentType},
{"interleaved pptcn token", "abcdpefghpijkltmnopcqrstnuv", sheetImageParentType},
{"interleaved wodcn token", "abcdwefghoijkldmnopcqrstnuv", sheetImageParentType},
- {"interleaved OFL0X marker with short length", "aaaaOaaaaFaaaaLaaaa0aaaaXaa", sheetImageParentType},
- {"interleaved OFL0X marker with long length", "aaaaOaaaaFaaaaLaaaa0aaaaXaaaa", sheetImageParentType},
+ {"interleaved OFL0X marker with short length (25 char, at boundary)", "aaaaOaaaaFaaaaLaaaa0aaaaXaa", officeSheetFileParentType},
+ {"interleaved OFL0X marker with long length (29 char)", "aaaaOaaaaFaaaaLaaaa0aaaaXaaaa", officeSheetFileParentType},
+ {"new 27-char OFL0X excel token", "bbbbObbbbFbbbbLbbbb0bbbbXbbE", officeSheetFileParentType},
+ {"new 27-char OFL0X ppt token", "ccccOccccFccccLcccc0ccccXccP", officeSheetFileParentType},
+ {"new 27-char OFL0X word token", "ddddOddddFddddLdddd0ddddXddW", 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},
}
diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md
index 3e59b6f609..7727bc58b4 100644
--- a/skills/lark-base/SKILL.md
+++ b/skills/lark-base/SKILL.md
@@ -72,7 +72,7 @@ Block 的 `id` 按类型直接作为对应模块坐标:
## Table Block(The Core)
-Table 本身是 Base Block,也是 Base 的核心数据存储层;Field、Record、View 和 Form 是 Table 内部对象,不是 Base Block。业务数据查询、写入、关联、统计和分析都从 Table 开始;标准资源读取链路是 `+table-list → +field-list → +record-list` / `+record-search`,多表的 `+field-list` 可以并发执行;记录相关任务读取 [Record 查询与分析 SOP](references/lark-base-record-query-and-analysis-sop.md)。
+Table 本身是 Base Block,也是 Base 的核心数据存储层;Field、Record、View 和 Form 是 Table 内部对象,不是 Base Block。业务数据查询、写入、关联、统计和分析都从 Table 开始。先用 `+table-list` 定位 Table;字段名和目标已知的普通读取可直接进入 Record 命令,只有写入、筛选或关联等依赖字段类型/schema 的任务才补 `+field-list`。多表的 `+field-list` 可以并发执行。基础的 Record / CellValue 读写直接按下方路径;reference 只承载高级分析、完整协议和边界细节。
**读取 Table:** `+table-list` 定位表,`+table-get` 读取详情。Table 专属复制使用 `+table-copy`,异步状态用 `+table-copy-status`;schema 和 records 由下方内部对象操作。
@@ -88,11 +88,114 @@ Field 定义列 schema。`field_id` 是稳定列标识,`name` 是可修改的
Record 是 Table 中的一行数据,包含该记录在各个 Field 下的 CellValue。系统 `record_id` 是表内稳定、非空且唯一的主键,Table 的主字段只是展示字段。
-**读取 Record:** 记录预览、筛选、匹配、统计、聚合、TopN、多表或语义分析,以及写前定位记录和写后验收,都必须先完整读取 [Record 查询与分析 SOP](references/lark-base-record-query-and-analysis-sop.md),并由该 SOP 选择具体命令。**写入 Record:** 优先使用 [batch create](references/lark-base-record-batch-create.md) / [batch update](references/lark-base-record-batch-update.md) 创建或更新一条或多条记录,按其文档中的 CellValue 协议提交字段值。
+#### 1. 读取记录或单元格
+
+- 已知若干个 `record_id`:`+record-get --record-id --record-id `
+- 关键词搜索:`+record-search --keyword --search-field `;至少指定一个搜索字段。
+- 其余读取:`+record-list`;结构化条件和排序分别用 `--filter-json` / `--sort-json`。
+
+行数较大、需要服务端谓词下推时,`--filter-json` 使用 tuple condition;最常用的筛选与完整日期范围写法:
+
+```jsonc
+{
+ "logic": "and", // 全部条件成立;任一条件成立改为 "or"
+ "conditions": [
+ ["状态", "intersects", ["进行中", "暂停"]], // Select 命中任一选项
+ ["标题", "intersects", "urgent"], // 文本包含
+ ["备注", "non_empty"], // 非空;判断为空改用 "empty",两者都不传 value
+ ["金额", ">=", 100], // 数字比较;支持 ==、!=、>、>=、<、<=
+ ["关联项目", "intersects", [{ "id": "recxxx" }]], // Link 包含目标记录
+ ["业务日期", "==", "ExactDate(2026-08-07)"], // 具体一天:按 Base 时区匹配 2026-08-07 当天
+ ["发生时间", ">", "ExactDate(2024-01-31 23:59:59.999)"], // 日期不支持 >=;用 > 前一天最后一毫秒表达含当天的下界
+ ["发生时间", "<", "ExactDate(2024-03-01 00:00:00)"] // 2024 年 2 月范围上界:小于 3 月 1 日零点
+ ]
+}
+```
+
+完整操作符和各字段取值结构读取 [Filter 条件结构](references/lark-base-filter-condition.md)。
+
+所有读取都重复传 `--field-id` 做最小字段投影,并统一写入 NDJSON artifact:`--format ndjson --output .ndjson`。每行是一条 Record JSON,stdout 摘要包含 `records_count` 和 `has_more` 用于分页判断。
+
+```bash
+# Example: 行数较大时先筛选 Status 包含 Doing 的记录,再导出 20 条作为局部预览
+lark-cli base +record-list \
+ --base-token --table-id \
+ --filter-json '{"logic":"and","conditions":[["Status","intersects",["Doing"]]]}' \
+ --field-id Name --field-id Status --field-id Score --limit 20 \
+ --format ndjson --output ./records-preview.ndjson --as user
+
+PREVIEW_ROWS=5
+head -n "$PREVIEW_ROWS" ./records-preview.ndjson
+tail -n "$PREVIEW_ROWS" ./records-preview.ndjson
+```
+
+预计记录数少于 500 行时,建议不做谓词下推,直接拉取到本地用 jq 或 Python 处理;行数较大时可用 `--filter-json` 下推可表达的条件,正则、派生等无法下推的条件继续在本地处理。
+
+```bash
+# jq:对服务端筛选结果追加名称格式筛选,再投影必要字段
+jq -c 'select((.Name // "") | test("^Task-[0-9]+$")) | {record_id, Name}' ./records-preview.ndjson
+
+# Python:按行读取并做简单汇总
+python3 - <<'PY'
+import json
+
+with open("records-preview.ndjson", encoding="utf-8") as stream:
+ rows = (json.loads(line) for line in stream if line.strip())
+ print(sum((row.get("Score") or 0) for row in rows))
+PY
+```
+
+`--limit` 的缺省值是 2000,最大值是 2000,通常无需手动指定 limit 参数;支持 `--offset` 参数;只有 `has_more=false` 且查询范围符合问题时,才能当作完整结果。大表完整读取、View 范围读取、复杂 JOIN、集合/多值、时序、语义或专业统计分析时,读取 [Record 查询与分析 SOP](references/lark-base-record-query-and-analysis-sop.md)。
+
+#### 2. 新增记录或更新记录单元格
+
+一条 Record 是 `{字段名或 field_id: CellValue}`,常见 CellValue:
+
+```jsonc
+{
+ "标题": "Created from shortcut", // text: string
+ "官网": "[官网](https://example.com)", // text(url): 裸 URL 或 Markdown link
+ "联系电话": "13800000000", // text(phone): 合法电话号码字符串
+ "邮箱": "owner@example.com", // text(email): 合法邮箱字符串
+ "单选": ["Todo"], // select: array;单选时数组最多一个值;
+ "标签": ["高优", "外部依赖"], // 多选 select: array;必须是当前字段存在的选项;
+ "工时": 8, // number: double,不经过格式化的纯数字
+ "带时区时间": "2026-03-24T10:00:00+08:00", // datetime:带时区,遵循传入的时区
+ "不带时区时间": "2026-03-24 10:00", // datetime:不带时区,自动按当前 Base 时区转换
+ "毫秒时间戳": 1774317600000, // datetime:也支持 Unix 毫秒时间戳
+ "已完成": false, // checkbox: boolean
+ "负责人": [{ "id": "ou_123" }], // user(multiple=false): 数组最多一个元素
+ "协作人": [{ "id": "ou_123" }, { "id": "ou_456" }], // user(multiple=true): 数组可包含多个元素
+ "群聊": [{ "id": "oc_123" }, { "id": "oc_456" }], // group_chat(multiple=true)
+ "关联任务": [{ "id": "rec456" }], // link: array<{id}>,record_id 来自目标表
+ "坐标": { "lng": 116.397428, "lat": 39.90923 }, // location: {lng,lat}
+ "清空": null, // 清空单元格,传 null
+ "清空数组": [] // 清空数组类单元格,空数组和 null 都可以
+}
+```
+
+附件使用专用 shortcut 上传、下载或移除。created_at, updated_at, created_by, updated_by, auto_number, formula, lookup 类型字段只读,若误写入单元格会返回 `ignored_fields` 表示这些字段被静默过滤,其余字段正常写入。
+
+```bash
+# 新增:成功时返回 record_id_list
+lark-cli base +record-batch-create \
+ --base-token --table-id \
+ --json '{"create_records":[{"Name":"Task A","Status":["Todo"]},{"Name":"Task B","Score":20}]}' --as user
+
+# 更新:每条记录只提交要改变的字段
+lark-cli base +record-batch-update \
+ --base-token --table-id \
+ --json '{"update_records":{"":{"Status":["Done"]},"":{"Score":100}}}' --as user
+```
+
+大 payload 可用脚本生成 json 后用 `--json @file.json`。单批最多 200 条,超过后分批,同一 Table 串行写入;并行可能触发 `1254291` 并发冲突错误。
-**Record 生命周期:** `+record-delete` 删除记录;`+record-share-link-create` 创建记录分享链接;`+record-history-list` 查询单条记录的变更事件,读取 [历史记录协议](references/lark-base-record-history-list.md)。附件使用 `+record-upload-attachment` / `+record-download-attachment` / `+record-remove-attachment` 操作。
+#### 3. 其他 Record 操作
-Record 中的 Select、人员、群组、Link、附件的 CellValue 通常是多值;Link 的目标 `table_id` 来自 Field schema,CellValue 中的 `id` 对应目标表 `record_id`。
+- `+record-delete --base-token --table-id --record-id --record-id ` 删除若干个记录
+- `+record-share-link-create --base-token --table-id --record-id --record-id ` 创建记录分享链接
+- `+record-history-list` 查询单条记录的变更事件,读取 [历史记录协议](references/lark-base-record-history-list.md)
+- 附件必须使用 `+record-upload-attachment` / `+record-download-attachment` / `+record-remove-attachment` 操作。
### View
diff --git a/skills/lark-base/references/lark-base-cell-value.md b/skills/lark-base/references/lark-base-cell-value.md
deleted file mode 100644
index d09a9cf01e..0000000000
--- a/skills/lark-base/references/lark-base-cell-value.md
+++ /dev/null
@@ -1,165 +0,0 @@
-# base CellValue 规范(lark-base-cell-value)
-
-> 适用命令:`lark-cli base +record-batch-create`、`lark-cli base +record-batch-update`
-
-本文件定义 **shortcut 写记录** 时 `CellValue` 的推荐格式,目标是让 AI 一次写对。不同命令的外层 JSON 形状不同,但每个 cell 都以本文为 source of truth。
-
-## 1. 顶层规则(必须遵守)
-
-- `--json` 必须是 JSON 对象。
-- `+record-batch-create --json` 使用 `{"create_records":[{"字段名或字段ID": CellValue}, ...]}`,数组中的每个对象代表一条新 Record。
-- `+record-batch-update --json` 使用 `{"update_records":{"rec_xxx":{"字段名或字段ID": CellValue}, ...}}`,以 `record_id` 定位每条待更新 Record。
-- 一次 payload 里同一字段只用一种 key(字段名或字段 ID),不要重复。
-- 写入前先 `+field-list` 获取字段 `type/style/multiple`,再构造值。
-- 需要清空字段时优先传 `null`(字段允许清空时)。
-
-## 2. 各类型 CellValue
-
-### 2.1 text
-
-text 字段的 `style.type` 影响单元格检查逻辑:
-`type=plain` 传 Markdown 格式的字符串。
-`type=url` 传一个带 title 的 Markdown 格式链接,或单独传一个链接。
-`type=phone` 传合法电话号码。
-`type=email` 传合法邮箱字符串。
-
-```json
-{
- "标题": "Hello, [lark-cli](https://github.com/larksuite/cli)",
- "官网": "[官网](https://example.com)",
- "联系电话": "1380000000000",
- "邮箱": "owner@example.com"
-}
-```
-
-### 2.2 number
-
-用 JSON number,不要用带单位或千分位的字符串。货币、百分比、进度、评分等数字类字段也按数字写入,展示格式由字段配置决定。
-
-```json
-{
- "工时": 12.5,
- "预算": 3000,
- "完成度": 0.65,
- "评分": 4
-}
-```
-
-### 2.3 select(单选/多选)
-
-`select` 字段统一传选项名称数组。`multiple=false` 时数组只能包含一个元素,`multiple=true` 时可以包含多个元素。只支持写入字段中已有的选项;构造 CellValue 前先用 `+field-list` 或 `+field-search-options` 确认目标选项存在。
-
-```json
-{
- "单选": ["Todo"],
- "多选": ["后端", "高优"]
-}
-```
-
-读取单元格时与写入的数据结构一致。
-
-### 2.4 datetime
-
-写入可省略时区偏移量,系统会按 Base 时区解析输入字符串;优先使用 `YYYY-MM-DD HH:mm`。Base 默认按分钟展示,但底层以毫秒级精度存储时间
-
-```json
-{
- "截止时间": "2026-03-24 10:00"
-}
-```
-
-读取单元格时,日期时间输出为标准 RFC3339 字符串并固定保留三位毫秒,例如 `"2026-03-24T10:00:00.000+08:00"`。
-
-### 2.5 checkbox
-
-用 JSON boolean:`true` 或 `false`,不要用 `"true"`、`"是"`、`1`。
-
-```json
-{
- "已完成": true
-}
-```
-
-### 2.6 user / group_chat
-
-`user` 和 `group_chat` 字段统一传对象数组。`multiple=false` 时数组只能包含一个元素,`multiple=true` 时可以包含多个元素。每个元素至少包含 `id`;人员字段传用户 ID(如 `ou_xxx`),群字段传群 ID(如 `oc_xxx`)。
-
-> **人员字段:不要猜 ID。** 不知道 `open_id` 时,先用 `lark-contact` 查 id:`lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --as user`。
-
-> **群组字段:不要猜 ID。** 不知道 `chat_id` 时,先用 `lark-im` 搜群:`lark-cli im +chat-search --query "<群名关键词>" --as user`;取结果里的 `oc_xxx`。
-
-```json
-{
- "负责人": [
- { "id": "ou_xxx" },
- { "id": "ou_xxx2" }
- ],
- "协作群": [
- { "id": "oc_xxx" }
- ]
-}
-```
-
-读取单元格时仍为对象数组,每个元素为 `{id, name}`,例如 `[{"id":"ou_xxx","name":"张三"}]`。
-
-### 2.7 link
-
-用对象数组,元素包含 `id`,值为目标记录的 `record_id`。不要传记录标题;先用 `+record-list` / `+record-search` 找到目标记录 ID。
-
-```json
-{
- "关联任务": [
- { "id": "" }
- ]
-}
-```
-
-读取单元格时与写入的数据结构一致。
-
-### 2.8 location
-
-- 读取:`{lng, lat, full_address}`,三个成员均非空。
-- 写入:`{lng, lat}`,经纬度均为数字;`full_address` 由平台根据坐标解析,不允许手动指定。
-- 筛选行为:按照 `full_address` 做字符串筛选,将 Location 当作文本列使用文本 operator。
-
-```json
-{
- "坐标": {
- "lng": 116.397428,
- "lat": 39.90923
- }
-}
-```
-
-
-### 2.9 attachment(不作为普通 CellValue 写入)
-
-读取单元格时,附件为数组,每个元素为 `{file_token, size, name}`,例如 `[{"file_token":"box_xxx","size":1024,"name":"report.pdf"}]`。
-
-- 追加附件:使用 `lark-cli base +record-upload-attachment --record-id --field-id --file `;可重复 `--file` 一次追加多个附件,不能用普通记录操作接口写附件值。
-- 删除附件:使用 `lark-cli base +record-remove-attachment --record-id --field-id --file-token --yes`;可重复 `--file-token` 一次删除同一单元格里的多个附件。
-- 下载附件:使用 `lark-cli base +record-download-attachment --record-id --file-token --output `;不传 `--file-token` 时下载整行所有附件,也可重复 `--file-token` 只下载指定附件。Base 附件必须用这个命令下载,用其他下载入口可能失败。
-
-## 3. 只读字段(不要写)
-
-写记录时,`auto_number`、`lookup`、`formula`、`created_at/updated_at`、`created_by/updated_by` 均为只读字段。
-
-写入只读字段通常不会更新数据;返回里可能出现 `ignored_fields`,reason 会说明 `READONLY`。看到这种返回时,不要重试同一 payload,应移除只读字段,只写存储字段。
-
-读取单元格时,`auto_number`、`formula`、`lookup` 为 `string | null`;`created_at`、`updated_at` 为 RFC3339 字符串或 `null`;`created_by`、`updated_by` 为 `array<{id, name}>`。
-
-## 4. 完整示例
-
-```json
-{
- "标题": "Created from shortcut",
- "状态": ["Todo"],
- "标签": ["高优", "外部依赖"],
- "工时": 8,
- "截止时间": "2026-03-24 10:00",
- "已完成": false,
- "负责人": [{ "id": "ou_123" }],
- "关联任务": [{ "id": "rec_456" }],
- "坐标": { "lng": 116.397428, "lat": 39.90923 }
-}
-```
diff --git a/skills/lark-base/references/lark-base-data-analysis-pandas.md b/skills/lark-base/references/lark-base-data-analysis-pandas.md
deleted file mode 100644
index a6d4fb18f1..0000000000
--- a/skills/lark-base/references/lark-base-data-analysis-pandas.md
+++ /dev/null
@@ -1,93 +0,0 @@
-# Base NDJSON:pandas 示例
-
-仅在统一数据分析 SOP 已选择 pandas 后读取。本页不重复 Base 的粒度与关系规则,只展示对应实现。
-
-示例假设 `records.ndjson` 包含 `record_id`、`日期`、`状态`、`金额`、`负责人`、`标签`、`关联客户`;`customers.ndjson` 包含 `record_id`、`客户名称`。多值列使用统一数据分析 SOP 定义的数组结构。
-
-## 加载与日期解析
-
-```python
-import pandas as pd
-
-records = pd.read_json("records.ndjson", lines=True)
-raw_dates = records["日期"].astype("string")
-records["日期_local"] = pd.to_datetime(
- raw_dates.str.slice(0, 10), format="%Y-%m-%d", errors="coerce"
-)
-records["日期_instant"] = pd.to_datetime(
- raw_dates, format="ISO8601", utc=True, errors="coerce"
-)
-```
-
-按来源 Base 的日、周、月分组使用 `日期_local`;计算真实时长、排序或跨时区比较使用 `日期_instant`。实际任务只需构造所需的一列。
-
-## 集合谓词:保持 record 粒度
-
-筛选“状态”包含“进行中”的记录,并在 record 粒度汇总:
-
-```python
-active = records[records["状态"].map(lambda values: "进行中" in values)]
-summary = {
- "records_count": len(active),
- "amount_sum": active["金额"].sum(min_count=1),
-}
-```
-
-## 单数组展开:切换到人员粒度
-
-```python
-owners = records[["record_id", "负责人"]].explode("负责人", ignore_index=True)
-owners = owners[owners["负责人"].notna()].assign(
- user_id=lambda df: df["负责人"].map(lambda user: user["id"]),
- user_name=lambda df: df["负责人"].map(lambda user: user["name"]),
-)
-by_owner = (
- owners.groupby(["user_id", "user_name"], as_index=False)
- .agg(records_count=("record_id", "nunique"))
- .sort_values("records_count", ascending=False)
-)
-```
-
-## Link JOIN:先建立边表
-
-```python
-edges = (
- records[["record_id", "关联客户"]]
- .rename(columns={"record_id": "source_record_id"})
- .explode("关联客户", ignore_index=True)
-)
-edges = edges[edges["关联客户"].notna()].assign(
- target_record_id=lambda df: df["关联客户"].map(lambda link: link["id"])
-)[["source_record_id", "target_record_id"]]
-
-customers = pd.read_json("customers.ndjson", lines=True).rename(
- columns={"record_id": "target_record_id"}
-)
-joined = edges.merge(
- customers[["target_record_id", "客户名称"]],
- on="target_record_id",
- how="left",
-)
-```
-
-## 多数组共现:显式生成行内笛卡尔积
-
-连续两次 `explode` 表示同一 source record 内的 `负责人 × 标签`:
-
-```python
-pairs = (
- records[["record_id", "负责人", "标签"]]
- .explode("负责人", ignore_index=True)
- .explode("标签", ignore_index=True)
- .dropna(subset=["负责人", "标签"])
- .assign(
- user_id=lambda df: df["负责人"].map(lambda user: user["id"]),
- user_name=lambda df: df["负责人"].map(lambda user: user["name"]),
- )
-)
-cooccurrence = (
- pairs.groupby(["user_id", "user_name", "标签"], as_index=False)
- .agg(records_count=("record_id", "nunique"))
- .sort_values("records_count", ascending=False)
-)
-```
diff --git a/skills/lark-base/references/lark-base-data-analysis-python-stdlib.md b/skills/lark-base/references/lark-base-data-analysis-python-stdlib.md
deleted file mode 100644
index 559f91264a..0000000000
--- a/skills/lark-base/references/lark-base-data-analysis-python-stdlib.md
+++ /dev/null
@@ -1,120 +0,0 @@
-# Base NDJSON:Python 标准库示例
-
-仅在统一数据分析 SOP 已选择 Python 标准库后读取。本页不重复 Base 的粒度与关系规则,只展示对应实现。
-
-示例假设 `records.ndjson` 包含 `record_id`、`日期`、`状态`、`金额`、`负责人`、`标签`、`关联客户`;`customers.ndjson` 包含 `record_id`、`客户名称`。多值列使用统一数据分析 SOP 定义的数组结构。
-
-## 加载与日期解析
-
-NDJSON 每行是一条独立 JSON record;按行读取即可,不要先把文件整体载入字符串。
-
-```python
-import json
-from datetime import date, datetime
-
-
-def read_ndjson(path):
- with open(path, encoding="utf-8") as stream:
- for line in stream:
- if line.strip():
- yield json.loads(line)
-
-
-records = list(read_ndjson("records.ndjson"))
-for record in records:
- raw_date = record["日期"]
- record["日期_local"] = date.fromisoformat(raw_date[:10]) if raw_date else None
- record["日期_instant"] = datetime.fromisoformat(raw_date) if raw_date else None
-```
-
-按来源 Base 的日、周、月分组使用 `日期_local`;计算真实时长、排序或跨时区比较使用 `日期_instant`。实际任务只需构造所需的一项。
-
-## 集合谓词:保持 record 粒度
-
-筛选“状态”包含“进行中”的记录,并在 record 粒度汇总:
-
-```python
-active = [record for record in records if "进行中" in record["状态"]]
-amounts = [record["金额"] for record in active if record["金额"] is not None]
-summary = {
- "records_count": len(active),
- "amount_sum": sum(amounts) if amounts else None,
-}
-```
-
-## 单数组展开:切换到人员粒度
-
-用嵌套循环表达 lateral expansion;按人员 `id` 聚合,`name` 只用于展示。
-
-```python
-from collections import defaultdict
-
-record_ids_by_owner = defaultdict(set)
-owner_names = {}
-for record in records:
- for owner in record["负责人"]:
- record_ids_by_owner[owner["id"]].add(record["record_id"])
- owner_names[owner["id"]] = owner["name"]
-
-by_owner = sorted(
- (
- {
- "user_id": user_id,
- "user_name": owner_names[user_id],
- "records_count": len(record_ids),
- }
- for user_id, record_ids in record_ids_by_owner.items()
- ),
- key=lambda row: (-row["records_count"], row["user_id"]),
-)
-```
-
-## Link JOIN:先建立目标表索引
-
-目标表的 `record_id` 是唯一主键,可直接建立哈希索引;Link 的 `id` 用于索引查找。
-
-```python
-customers = {
- customer["record_id"]: customer
- for customer in read_ndjson("customers.ndjson")
-}
-
-joined = []
-for record in records:
- for link in record["关联客户"]:
- customer = customers.get(link["id"])
- joined.append(
- {
- "source_record_id": record["record_id"],
- "target_record_id": link["id"],
- "客户名称": customer["客户名称"] if customer else None,
- }
- )
-```
-
-## 多数组共现:显式生成行内笛卡尔积
-
-两层嵌套循环表示同一 source record 内的 `负责人 × 标签`:
-
-```python
-record_ids_by_pair = defaultdict(set)
-owner_names = {}
-for record in records:
- for owner in record["负责人"]:
- owner_names[owner["id"]] = owner["name"]
- for tag in record["标签"]:
- record_ids_by_pair[(owner["id"], tag)].add(record["record_id"])
-
-cooccurrence = sorted(
- (
- {
- "user_id": user_id,
- "user_name": owner_names[user_id],
- "标签": tag,
- "records_count": len(record_ids),
- }
- for (user_id, tag), record_ids in record_ids_by_pair.items()
- ),
- key=lambda row: (-row["records_count"], row["user_id"], row["标签"]),
-)
-```
diff --git a/skills/lark-base/references/lark-base-data-query.md b/skills/lark-base/references/lark-base-data-query.md
index a96b4ae975..7e1204a7b3 100644
--- a/skills/lark-base/references/lark-base-data-query.md
+++ b/skills/lark-base/references/lark-base-data-query.md
@@ -3,8 +3,6 @@
> **前置路由**: [Record 查询与分析 SOP](lark-base-record-query-and-analysis-sop.md) | **认证或授权问题**: [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)
-本文档合并常用 fewshot 与完整 DSL 协议。只有 [Record 查询与分析 SOP](lark-base-record-query-and-analysis-sop.md) 的 Cloud 路径选定 `+data-query` 后才读取,并优先定位与当前查询有关的示例、字段或错误协议。
-
## 限制
- **权限要求**(按文档类型分流):
@@ -75,7 +73,7 @@ lark-cli base +data-query \
| 参数 | 必填 | 说明 |
|------------------------|------|------|
| `--base-token ` | 是 | Base Token(base_token) |
-| `--dsl ` | 是 | LiteQuery Protocol JSON DSL 查询语句 |
+| `--dsl ` | 是 | LiteQuery Protocol JSON DSL 查询语句。注意,本工具 schema 与 record/view 查询的 schema 不同,需要充分阅读本文档后,编写正确的 DSL,避免与其他场景的 DSL 混淆。 |
## 如何从链接中解析参数
@@ -435,7 +433,7 @@ CLI 输出标准信封 `{ok, identity, data}`(失败时为 `{ok:false, identit
5. 若候选记录包含 link 字段,提取关联 `record_id` 后到关联表用 `+record-get` 批量读取展示字段。
6. 最终回答展示真实业务字段;内部 `record_id` 用于连接或定位。
-不要把 `data-query pagination.limit` 理解为分页扫描;它只限制 Base 云端查询服务返回的聚合结果行数,不支持 offset。需要逐条原始记录时按 Cloud SOP 的 `+record-list` / `+record-search` 回查规则处理。
+不要把 `data-query pagination.limit` 理解为分页扫描;它只限制 Base 云端查询服务返回的聚合结果行数,不支持 offset。需要逐条原始记录时按 [Record 查询与分析 SOP](lark-base-record-query-and-analysis-sop.md) 的完整读取或回查路径处理。
## 坑点
@@ -453,6 +451,4 @@ CLI 输出标准信封 `{ok, identity, data}`(失败时为 `{ok:false, identit
- [lark-base](../SKILL.md) — 多维表格全部命令
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
-- [Cloud SOP](lark-base-record-query-and-analysis-cloud-sop.md) — Cloud 路径的查询范围、下推、分页、`+record-list` / `+record-search` 回查和关系查询
-- [lark-base-cell-value.md](lark-base-cell-value.md) — CellValue 格式规范
- [Field Schema](lark-base-field-schema.md) — 字段类型与 JSON 结构
diff --git a/skills/lark-base/references/lark-base-field-lookup.md b/skills/lark-base/references/lark-base-field-lookup.md
index ef0961a0cb..249361afcd 100644
--- a/skills/lark-base/references/lark-base-field-lookup.md
+++ b/skills/lark-base/references/lark-base-field-lookup.md
@@ -157,7 +157,7 @@ The `value` inside `{ "type": "constant", "value": ... }` varies by field type:
| `number` | Number | `100`, `0.8` |
| `datetime` / `created_at` / `updated_at` | String | `"ExactDate(2025-01-01)"`, `"ExactDate(2025-01-01 09:30)"`, `"Today"`, `"Yesterday"`, `"Tomorrow"` |
| `select` (`multiple=false/true`) | Option name array | `["Todo"]`, `["Todo", "Done"]` |
-| `link` | Record reference array | `[{ "id": "rec_xxx" }]`, `[{ "id": "rec_xxx" }, { "id": "rec_yyy" }]` |
+| `link` | Record reference array | `[{ "id": "recxxx" }]`, `[{ "id": "recxxx" }, { "id": "recyyy" }]` |
| `user` / `created_by` / `updated_by` | User reference array | `[{ "id": "ou_xxx" }]`, `[{ "id": "ou_xxx" }, { "id": "ou_yyy" }]` |
| `checkbox` | Boolean | `true`, `false` |
| `attachment` / `location` | Only `empty` / `non_empty` | value must be `null` or omitted |
diff --git a/skills/lark-base/references/lark-base-filter-condition.md b/skills/lark-base/references/lark-base-filter-condition.md
index b6514f8077..b22666c023 100644
--- a/skills/lark-base/references/lark-base-filter-condition.md
+++ b/skills/lark-base/references/lark-base-filter-condition.md
@@ -40,7 +40,34 @@ Filter 是一组「字段/操作符/值」条件的组合,用 `logic`(`and`
}
```
-## 2. operator
+## 2. 单表谓词下推常用 example
+
+`+record-list` / `+record-search` 的 `--filter-json ''` 也支持使用与视图相同的 tuple condition。以下示例用注释说明各条件的含义;实际传参时删除注释并使用标准 JSON:
+
+```jsonc
+{
+ "logic": "and", // 所有 conditions 同时成立;任意一个成立时使用 "or"
+ "conditions": [
+ ["标题", "==", "Launch plan"], // 文本全等
+ ["标题", "!=", "Archived plan"], // 文本不全等
+ ["标题", "intersects", "urgent"], // 文本包含目标片段
+ ["标题", "disjoint", "internal"], // 文本不包含目标片段
+ ["金额", ">=", 100], // 数字比较;支持 ==、!=、>、>=、<、<=
+ ["状态", "intersects", ["进行中", "暂停"]], // Select 集合相交:包含“进行中”或“暂停”任意一个选项
+ ["状态", "disjoint", ["已终止"]], // Select 集合无交集
+ ["已完成", "==", true], // Checkbox
+ ["负责人", "intersects", [{ "id": "ou_xxx" }]], // 负责人包含某个人;intersects 表示包含数组中任意一个人员
+ ["负责人", "disjoint", [{ "id": "ou_yyy" }]], // 负责人不包含指定人员中的任何一个
+ ["关联项目", "intersects", [{ "id": "recxxx" }]], // 关联项目包含某个 record_id;intersects 表示包含数组中任意一条关联
+ ["备注", "non_empty"], // 格子非空;判断格子为空改用 ["备注", "empty"]
+ ["业务日期", "==", "ExactDate(2026-08-07)"], // 具体一天:按 Base 时区匹配 2026-08-07 当天
+ ["发生时间", ">", "ExactDate(2024-01-31 23:59:59.999)"], // 日期不支持 >=;用 > 前一天最后一毫秒表达含当天的下界
+ ["发生时间", "<", "ExactDate(2024-03-01 00:00:00)"] // 2024 年 2 月范围上界:小于 3 月 1 日零点
+ ]
+}
+```
+
+## 3. operator
可用 operator:
- `==`
@@ -54,7 +81,7 @@ Filter 是一组「字段/操作符/值」条件的组合,用 `logic`(`and`
- `empty`
- `non_empty`
-## 3. value 写法
+## 4. value 写法
value 类型取决于条件引用对象(字段 / 题目)的类型。
@@ -119,7 +146,7 @@ location 筛选只按 `full_address` 字符串匹配,不能直接按经纬度
用记录 id 对象数组:
```json
-["关联任务", "intersects", [{ "id": "rec_xxx" }]]
+["关联任务", "intersects", [{ "id": "recxxx" }]]
```
### `checkbox`
@@ -155,7 +182,7 @@ location 筛选只按 `full_address` 字符串匹配,不能直接按经纬度
value schema 随计算结果类型变化;拿不准时先读取字段定义,或根据错误提示修正 value 和 operator。
-## 4. 易错点
+## 5. 易错点
- 不要再写旧对象风格:`{"field_name":...,"operator":...}`。
- `user` / `group_chat` / `link` 不要写成单个标量。
@@ -163,5 +190,5 @@ value schema 随计算结果类型变化;拿不准时先读取字段定义,
- 日期条件稳定写法用 `ExactDate(...)` 或 `Today` / `Yesterday` / `Tomorrow`。
- `formula` / `lookup` 的 value schema 是动态的;拿不准 value 类型时先读字段定义,或根据错误提示修正类型。
-## 5. 参考
+## 6. 参考
- [Lookup Field](lark-base-field-lookup.md)
diff --git a/skills/lark-base/references/lark-base-form-detail.md b/skills/lark-base/references/lark-base-form-detail.md
index 969d7a9257..7dfa0e21f5 100644
--- a/skills/lark-base/references/lark-base-form-detail.md
+++ b/skills/lark-base/references/lark-base-form-detail.md
@@ -21,7 +21,7 @@ lark-cli base +form-detail --share-token --format pretty
| `base_token` | 表单所属 Base;提交附件时必须传给 `+form-submit --base-token` |
| `questions[].id` | 题目标识,通常对应字段 ID |
| `questions[].title` | 提交时使用的字段名/题目名,以真实返回为准 |
-| `questions[].type` | 决定值格式;与字段类型和 `lark-base-cell-value.md` 对齐 |
+| `questions[].type` | 决定值格式;提交结构见 [form-submit](lark-base-form-submit.md) |
| `questions[].required` | 判断必填项 |
| `questions[].filter` | 判断题目是否对当前提交可见;被隐藏的问题不要填写 |
diff --git a/skills/lark-base/references/lark-base-form-submit.md b/skills/lark-base/references/lark-base-form-submit.md
index 8f82ff4b8f..6f6c4c20bc 100644
--- a/skills/lark-base/references/lark-base-form-submit.md
+++ b/skills/lark-base/references/lark-base-form-submit.md
@@ -86,7 +86,7 @@ lark-cli base +form-submit \
#### fields(普通字段)
-`fields` 中的单元格值写法与 [`lark-base-cell-value.md`](lark-base-cell-value.md) 完全对齐,填写前应先阅读该文档了解各类型的构造规则:
+`fields` 中的常见单元格值按下方示例构造(与主 skill 一致):
```json
{
@@ -126,7 +126,7 @@ CLI 收到路径后会自动完成以下流程:
2. 并行上传到 Base Drive Media(并发上限 5,跨字段重复路径自动去重)
3. 获取 `file_token` 后合并到最终表单提交内容中
-> 与 [`lark-base-cell-value.md`](lark-base-cell-value.md) 中 Record 场景的附件写法不同:Record 写入时附件走独立的 `+record-upload-attachment` 命令;而 `+form-submit` 只需在 `attachments` 中传本地路径,上传由 CLI 内部自动完成。
+> Record 写入时附件走独立的 `+record-upload-attachment` 命令;`+form-submit` 则在 `attachments` 中传本地路径,由 CLI 自动上传。
### 从分享链接提取 share-token
diff --git a/skills/lark-base/references/lark-base-record-batch-create.md b/skills/lark-base/references/lark-base-record-batch-create.md
deleted file mode 100644
index ef30ba373a..0000000000
--- a/skills/lark-base/references/lark-base-record-batch-create.md
+++ /dev/null
@@ -1,63 +0,0 @@
-# base +record-batch-create
-
-> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
-
-批量创建记录。
-
-## 适用场景(重点)
-
-- 适合导入 CSV / Excel、外部系统一次性写入新数据。
-- 先把每条输入数据映射为独立的字段对象,再组装到 `create_records`。
-
-## 推荐命令
-
-```bash
-lark-cli base +record-batch-create --base-token --table-id \
- --json '{"create_records":[{"标题":"任务 A","状态":"Open"},{"标题":"任务 B","状态":"Done"}]}'
-
-lark-cli base +record-batch-create --base-token --table-id --json @batch-create.json
-```
-
-## 参数
-
-| 参数 | 必填 | 说明 |
-|------|------|------|
-| `--base-token ` | 是 | Base Token |
-| `--table-id ` | 是 | 表 ID 或表名 |
-| `--json ` | 是 | 批量创建请求体,必须是 JSON 对象。支持直接传 JSON 字符串,或 `@` 从文件读取 |
-
-## API
-
-`POST /open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_create`
-
-## `--json` 结构
-
-本节只说明 `+record-batch-create` 的外层 JSON 形状;CellValue 统一看 [lark-base-cell-value.md](lark-base-cell-value.md)。
-
-对象形态:
-
-```json
-{"create_records":[{"标题":"任务 A","状态":"Open"},{"标题":"任务 B","状态":"Done"}]}
-```
-
-| 字段 | 类型 | 必填 | 说明 |
-|------|------|------|------|
-| `create_records` | `Array