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>` | 是 | 记录字段对象数组;每条记录可以提交不同字段,单次最多 200 条 | - -## 返回重点 - -返回 `record_id_list` 和可选的 `ignored_fields`。 - -## 坑点 - -- 每个 `create_records` 元素都是独立的记录字段对象,只提交该记录需要写入的字段。 -- 单次最多 200 条;`1254104` 表示超过单批上限,拆成多个批次。 -- `1254045` 表示字段不存在,重新 `+field-list` 后使用真实字段名或 `field_id`。 -- `1254015` 表示 CellValue 类型不匹配,按真实 Field schema 和 CellValue 规范修正。 -- 返回 `ignored_fields` / `READONLY` 时,从普通 Record 写入中移除 Formula、Lookup、系统字段和自动编号等只读字段。 -- 同一 Table 连续批量写入使用串行执行;`1254291` 表示并发写冲突,短暂等待后重试当前批次。 -- `select` 字段只支持写入字段中已有的选项;构造 CellValue 前先用 `+field-list` 或 `+field-search-options` 确认目标选项存在。 - -## 参考 - -- [lark-base-cell-value.md](lark-base-cell-value.md) — CellValue 格式规范 diff --git a/skills/lark-base/references/lark-base-record-batch-update.md b/skills/lark-base/references/lark-base-record-batch-update.md deleted file mode 100644 index b677b22bf2..0000000000 --- a/skills/lark-base/references/lark-base-record-batch-update.md +++ /dev/null @@ -1,57 +0,0 @@ -# base +record-batch-update (batch update) - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -通过 `update_records` 为每条记录提交字段值。 - -## 推荐命令 - -```bash -lark-cli base +record-batch-update --base-token --table-id \ - --json '{"update_records":{"":{"状态":["完成"]},"":{"分数":20}}}' - -lark-cli base +record-batch-update --base-token --table-id --json @batch-update.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_update` - -## `--json` 结构 - -本节只说明 `+record-batch-update` 的外层 JSON 形状;CellValue 统一看 [lark-base-cell-value.md](lark-base-cell-value.md)。 - -对象形态: - -```json -{"update_records":{"recA":{"状态":["完成"]},"recB":{"分数":20}}} -``` - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `update_records` | `Map>` | 是 | record ID 到字段更新对象的映射(单次最多 200 条) | - -## 返回重点 - -成功响应只包含可选的 `ignored_fields`;没有忽略字段时 `data` 为空对象。请求不会预先校验 record ID 是否存在,因此需要确认实际写入结果时,应再用 `+record-get` 读回目标记录。 - -## 坑点 - -- 单次最多更新 200 条记录;`1254104` 表示超过单批上限,拆成多个批次。 -- `1254045` 表示字段不存在,重新 `+field-list` 后使用真实字段名或 `field_id`。 -- `1254015` 表示 CellValue 类型不匹配,按真实 Field schema 和 CellValue 规范修正。 -- 命令不会自动做字段/行映射转换,传什么就发什么。 -- 如果字段映射包含只读字段,返回里可能出现 `ignored_fields` / `READONLY`;移除 Formula、Lookup、系统字段和自动编号等只读字段。 -- 同一 Table 连续批量写入使用串行执行;`1254291` 表示并发写冲突,短暂等待后重试当前批次。 - -## 参考 - -- [lark-base-cell-value.md](lark-base-cell-value.md) — CellValue 格式规范 diff --git a/skills/lark-base/references/lark-base-record-history-list.md b/skills/lark-base/references/lark-base-record-history-list.md index 49ebbe8c3b..e7ea4ab2a4 100644 --- a/skills/lark-base/references/lark-base-record-history-list.md +++ b/skills/lark-base/references/lark-base-record-history-list.md @@ -10,7 +10,7 @@ 用 `+record-list` 展示候选时,可重复传入 `--field-id` 做最小投影。字段名包含空格时,需要给完整值加引号,例如 `--field-id "Project Owner"`。 -用户明确指定某个视图的第 N 行时,先用同一 `view_id` 调用 `+record-list`,并将 `--offset` 设为 N-1、`--limit` 设为 1。默认 Markdown 输出从 `_record_id` 列读取唯一记录 ID;显式使用 `--format json` 时从 `.data.record_id_list[0]` 读取。`_record_id` 不是 JSON 顶层字段;视图或排序上下文不明确时仍需先确认。 +用户明确指定某个视图的第 N 行时,先用同一 `view_id` 调用 `+record-list`,并将 `--offset` 设为 N-1、`--limit` 设为 1,再从唯一结果中取得 `record_id`。视图或排序上下文不明确时仍需先确认。 ## 推荐命令 diff --git a/skills/lark-base/references/lark-base-record-query-and-analysis-cloud-sop.md b/skills/lark-base/references/lark-base-record-query-and-analysis-cloud-sop.md deleted file mode 100644 index c7ca971f86..0000000000 --- a/skills/lark-base/references/lark-base-record-query-and-analysis-cloud-sop.md +++ /dev/null @@ -1,145 +0,0 @@ -# Base Record 查询与分析 Cloud SOP - -统一数据分析 SOP 将任务路由到 Cloud 时使用本 SOP。覆盖记录读取、筛选、排序、Top/Bottom N、聚合统计、分组聚合、多表关联和查询后写入前的目标定位。 - -本文只管查询选路和正确性边界;先按下方 Intent -> Tool Path 选择原始记录查询或聚合查询,再读真实结构和现状: - -- 视图筛选: [lark-base-view-set-filter.md](lark-base-view-set-filter.md) -- 记录读取: `+record-list` / `+record-search` / `+record-get`,先确认字段 ID、字段名、分页和投影范围 - -## 0. 执行约定 - -- “最高、最低、最新、最早、Top、Bottom、总数、全部、异常、最大、最小、最多、最少、优先级最高”等全局语义,在本路径中由 Base 云端查询服务完成筛选、排序或聚合。 -- 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort;聚合分析优先用 `+data-query`。 -- `+record-search` 用于关键词检索字段的展示文本;金额、状态、日期、空值、关联等结构化条件继续用 `--filter-json` 表达。 -- 不要依赖已有视图,除非用户明确指定该视图,或你已读取并验证其 filter/sort/projection 符合当前问题。 -- 内部 ID、`record_id`、关联记录 ID、open_id 和编码字段用于连接或定位;交付输出使用用户可读的真实字段值,用户明确要求 ID 时一并展示。 -- 每次读取必须做最小投影,并包含后续解释、回查或写入需要的业务 key。 - -## 1. Intent -> Tool Path - -| 用户意图 | 首选路径 | 关键规则 | -| --- | --- | --- | -| 看几条、预览、示例 | `+record-list --limit N --field-id ...` | 保持局部语义 | -| 已知 `record_id` | `+record-get` | 直接读取 | -| 明确关键词 | `+record-search --keyword ... --search-field ... --field-id ...` | 必须显式指定 `--search-field`;可叠加 `--filter-json` | -| 按条件找原始记录 | `+record-list --filter-json ...` | `filter-json` 与视图筛选结构一致,支持文本、数字、日期、选项、人员、群组、关联等值 | -| 排序 / TopN 原始记录 | `+record-list --filter-json ... --sort-json ... --limit N` | 最高/最新用 `desc:true`,最低/最早用 `desc:false`;数组顺序表达优先级;最多 10 个排序条件 | -| 聚合 / 分组 / 分组排序 | `+data-query` | 读取 [data-query DSL reference](lark-base-data-query.md),使用 filters/dimensions/measures/sort/limit | -| 聚合后输出逐条记录 | `+data-query` 得到业务 key 或候选字段组合 -> `+record-list --filter-json` / `+record-get` 回查 | `+data-query` 维度行按字段组合去重且不返回 `record_id` | -| 多表 / 多跳关联 | 以候选数最小的事实表为驱动表,沿业务 key 或 Link 逐跳回查 | 读出 Link 单元格的 `id`(目标表 `record_id`)后,到被关联表批量 `+record-get` 展示字段 | -| 查询后写入 / 视图化 | 先用本 SOP 得到可复核的目标记录 id 集合 | 再进入记录写入或视图配置;高价值可复用查询可沉淀为持久视图 | - -## 2. Execution Patterns - -### 2.1 结构化原始记录与 TopN - -使用 `+record-list` 的 filter/sort 路径: - -1. `+field-list` 确认筛选字段、排序字段、展示字段、业务 key。 -2. 筛选使用 `--filter-json ''`。 -3. 排序用 `--sort-json`。 -4. `--field-id` 做最小投影,`--limit` 控制返回数量。 - -Example: 结构化筛选 + TopN;示例展示文本包含、数字比较和 Select 集合相交三个常用谓词: - -```bash -lark-cli base +record-list \ - --base-token \ - --table-id \ - --filter-json '{"logic":"and","conditions":[["Title","intersects","Launch plan"],["Score",">=",80],["Status","intersects",["Doing"]]]}' \ - --sort-json '[{"field":"Updated","desc":true}]' \ - --field-id Name \ - --field-id Title \ - --field-id Score \ - --limit 20 -``` - -常用 `filter-json` condition fewshot 统一见 [Base Record 查询与分析 SOP](lark-base-record-query-and-analysis-sop.md);完整协议见 [Base Filter 条件结构](lark-base-filter-condition.md)。 - -`--sort-json` 传排序数组,数组顺序就是优先级,`desc:true` 为降序,`desc:false` 为升序,最多 10 个排序条件。 - -### 2.2 关键词检索后叠加结构化条件 - -使用 `+record-search` 做关键词命中,结构化条件仍用 `--filter-json` 下推: - -```bash -lark-cli base +record-search \ - --base-token \ - --table-id \ - --keyword Alice \ - --search-field Name \ - --filter-json '{"logic":"and","conditions":[["Status","intersects",["Doing"]]]}' \ - --sort-json '[{"field":"Updated","desc":true}]' \ - --field-id Name \ - --field-id Status \ - --limit 20 -``` - -金额、状态、日期、空值和关联字段等结构化条件使用 `--filter-json`;`+record-search` 处理展示文本关键词。 - -### 2.3 聚合分析与 TopN - -使用 `+data-query`: - -- 让 Base 云端查询服务完成 filters、dimensions、measures、sort、pagination.limit。 -- `pagination.limit` 是 Base 云端查询服务中的结果限制,不是本地分页扫描。 -- 读取 [data-query DSL reference](lark-base-data-query.md) 中与当前查询有关的 fewshot、字段和协议。 -- `+data-query` 可返回聚合结果或维度字段行;维度字段行按字段组合去重且不返回 `record_id`,不能当逐条原始记录结果使用。 -- 需要输出逐条记录、记录定位或完整行级字段时,先用 `+data-query` 得到业务 key、分组值或候选字段组合,再用 `+record-list --filter-json` / `+record-get` 回查。 - -Example: 分组计数: - -```bash -lark-cli base +data-query \ - --base-token \ - --dsl '{"datasource":{"type":"table","table":{"tableId":""}},"dimensions":[{"field_name":"Status","alias":"status"}],"measures":[{"field_name":"Status","aggregation":"count","alias":"count"}],"shaper":{"format":"flat"}}' -``` - -Example: 汇总后取 TopN;需要过滤时按 `+data-query` 的 LiteQuery DSL reference 增加 `filters`: - -```bash -lark-cli base +data-query \ - --base-token \ - --dsl '{"datasource":{"type":"table","table":{"tableId":""}},"dimensions":[{"field_name":"Owner","alias":"owner"}],"measures":[{"field_name":"Amount","aggregation":"sum","alias":"total_amount"}],"sort":[{"field_name":"total_amount","order":"desc"}],"pagination":{"limit":10},"shaper":{"format":"flat"}}' -``` - -### 2.4 视图化与复用 - -一次性查询先用 `+record-list` / `+record-search` 的 filter/sort 验证。需要用户长期打开、共享或复用时,再把同一套 filter/sort 沉淀为视图。 - -Example: 将已验证的筛选排序写入视图: - -```bash -lark-cli base +view-set-filter \ - --base-token \ - --table-id \ - --view-id \ - --json '{"logic":"and","conditions":[["Priority","intersects",["P0"]]]}' - -lark-cli base +view-set-sort \ - --base-token \ - --table-id \ - --view-id \ - --json '{"sort_config":[{"field":"Priority","desc":true}]}' -``` - -手动配置和视图配置的优先级: - -1. `--filter-json` 覆盖 `--view-id` 保存的 view filter JSON。 -2. `--sort-json` 覆盖 `--view-id` 保存的 view sort config。 -3. 没有手动 filter/sort 时,`--view-id` 使用视图自身保存的 filter/sort。 - -### 2.5 关系查询与回查 - -- Link 单元格中的元素形如 `{"id":"rec_xxx"}`;`id` 是目标表的 `record_id`,用于关系连接。 -- 先用 `+field-list` 确认 link 字段的 `link_table`、业务唯一键和展示字段。 -- 从驱动表拿到候选记录后,用 Link 元素的 `id` 到目标表 `+record-get` 批量读取记录内容。 -- 多跳关系逐跳建立 `record_id/key -> 用户可读字段` 映射,交付目标表返回的真实业务字段。 - -## 3. Range & Pagination Contract - -- `+record-list` 默认页、固定 `--limit` 和手工浏览输出都只覆盖已读取范围;模型上下文接收云端收敛后的最终小结果。 -- `has_more=true` 说明可能还有未读取数据,需要更新 offset 后继续读取,多次读取仍未读取完成时,采用其他方法完成任务需求,避免无限循环。 -- 对全局问题,只有 Base 云端查询服务已经通过 filter/sort/aggregate 收敛目标范围,或 `+data-query` 已在云端完成聚合、排序和限制时,才可以用有限返回形成结论。 -- 需要完整原始记录但云端能力无法把结果安全收敛到可返回范围时,明确说明能力边界;不要用手工分页、拆分下载或采样伪装成全局分析。 diff --git a/skills/lark-base/references/lark-base-record-query-and-analysis-sop.md b/skills/lark-base/references/lark-base-record-query-and-analysis-sop.md index 687b89d7fa..7b385f62d3 100644 --- a/skills/lark-base/references/lark-base-record-query-and-analysis-sop.md +++ b/skills/lark-base/references/lark-base-record-query-and-analysis-sop.md @@ -1,233 +1,123 @@ -# Base Record 查询、匹配与分析 SOP +# Base Record 数据语义与专业分析 SOP -任何 Record 读取、预览、搜索、筛选、匹配、统计、聚合、TopN、多表或语义分析,以及写操作中的记录定位和结果验收,都先完整读取本 SOP。先区分需要 LLM 理解原文的语义分析与可程序化计算的确定性分析,再按数据规模与计算复杂度选择路径;即使用户直接要求解释、编写或排错 `+data-query` 命令或 DSL,也先由本 SOP 确认口径和路径,再读取底层 reference。 +本 SOP 不讲解通用 jq / Python / pandas 语法、统计公式或数据科学算法。Agent 应使用已有的数据分析能力;本文只负责把 Base 的查询范围、NDJSON 物理结构、Field / Record / View / Link 语义和完整性约束,正确映射到专业分析任务。 -## 分流决策 +普通预览、已知记录读取、关键词搜索和小规模直接处理按主 skill 的 [Record 核心路径](../SKILL.md#record) 执行。以下情况读取本文:大表完整读取、`has_more=true`、View 范围读取、复杂多表 JOIN、集合或多值运算、分组与 Top-K、窗口或严格时序、时间周期对齐、层级递归、数据重塑、派生与指定规则清洗、临时语义转换,以及需要可靠样本范围的描述性或推断性分析。 -1. 明确所有需要参与分析的表;上下文已有整表 `records_count` 时用于提前分流,否则直接按下文导出或探测,不为获取规模单独枚举表。 -2. 如果结论必须依赖 LLM 理解原始内容,例如开放文本打标、情绪或意图识别、主题归纳、语义分类、相似性判断或实体消歧,进入下文“LLM 语义分析”路径。 -3. 对于其余确定性查询,任一分析表已知超过 2000 行或 NDJSON 探测返回 `has_more=true` 时,先从任务意图中提取可在单表内独立执行的日期、状态、关键词等谓词,逐表下推后用 `--field-id '<一个简单标量字段>' --limit 2000 --format ndjson --output .ndjson --minimal-stdout` 复查。目标是每张表都达到 `has_more=false`;任一表无法压缩到 2000 行以内时,转 [Cloud SOP](lark-base-record-query-and-analysis-cloud-sop.md) 用云端的数据分析能力。 -4. 所有分析表都不超过 2000 行后:若只有一张表且短 jq 可清晰完成筛选、计数、简单分组/聚合/排序、TopN 可以使用 jq。 -5. 其余确定性任务比如多表、日历计算和复杂数据分析,在 Python 可用时使用 Python,否则进入 [Cloud SOP](lark-base-record-query-and-analysis-cloud-sop.md)。 - -进入 Cloud 后先由 Cloud SOP 在原始记录查询与聚合查询之间选路;只有选定 `+data-query` 时才读取 [data-query DSL reference](lark-base-data-query.md)。 - -## 执行与交付 - -所有 records 读取统一使用 `--format ndjson --output .ndjson`。NDJSON 将大记录集写入 records 文件,并在 stdout 返回包含摘要、列 schema 和 stats 的 manifest,避免把过长用户数据直接加载进模型上下文。用 Python 或数据分析引擎直接处理 records 文件。未传 `--limit` 时最多读取 2000 条;仅在探测、预览或用户明确要求前 N 条时缩小限制。 - -### NDJSON 读取示例 - -按任务替换真实 token、ID、投影、条件和 artifact 名称;`+record-search` 和 `+record-get` 使用相同的 NDJSON 输出参数。 - -```bash -lark-cli base +record-list \ - --base-token \ - --table-id \ - --field-id \ - --format ndjson \ - --output ./records.ndjson \ - --as user -``` - -缩小大表记录范围时,展示文本关键词用 `+record-search`,日期、状态、数字、空值、选项、人员和关联等结构化条件用 `+record-list --filter-json`。 - -### 单表谓词下推常用 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": "rec_xxx"}]], // 关联项目包含某个 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 日零点 - ] -} -``` - -全表分析的常规资源链路是 `+table-list` 确认目标表,并用已有整表 `records_count` 或 NDJSON `has_more` 确认规模;对所有参与分析的表并发执行 `+field-list` 读取所需 schema,再按上述 NDJSON 契约用 `+record-list` 导出记录。已有可信的 `table_id` 时可直接并发读取各表 `+field-list`。`+view-get` 可按需读取,作为用户持久化访问习惯的可选参考;其中的 filter、sort 与字段范围可辅助理解用户常用的查询范围和排序偏好,并结合当前任务确定最终口径。 - -1. 每次读取使用任务所需的最小投影,并包含 JOIN、解释、回查或写入需要的业务 key。 -2. 全局结论以 `has_more=false` 的完整导出或 Cloud 聚合结果为依据;`has_more=true` 时继续收敛单表谓词或选择 Cloud 路径。 -3. 确定性分析选定一个分析引擎直接读取 NDJSON;模型上下文仅接收预览或最终小结果。 -4. Base 标量空值很常见;聚合前按用户口径确定空值是排除、按零计入还是进入分母。用户未指定且不同处理会实质改变结论时,说明空值数量、采用的口径及其影响;任务涉及业务键、展开、JOIN 或金额分摊时,同样明确目标粒度及与口径直接相关的重复或总量守恒。 -5. 最终结果保留真实表、查询范围和计算口径,展示用户可读字段;内部 ID 用于连接或定位。 - -## 复用本轮 NDJSON - -Agent 上下文曾下载过当前表的 NDJSON 时,按以下规则判断是否复用: - -1. 短时间内继续分析或表中数据低频变化时,谓词下推口径一致且已有列覆盖计算需求即可优先复用。 -2. 间隔较长或表中数据高频变化时,批量提取 manifests 的 `base_token/table_id/rev`,刷新相关表元数据并校验最新 `rev`;版本一致且谓词口径未变时复用,否则重新导出对应表。 - -## LLM 语义分析 - -先用任务中明确且不改变分析口径的确定性条件缩小数据范围;只有剩余判断必须依赖语义理解时,才将必要原文加载到模型上下文。 - -开放文本打标、情绪或意图识别、主题归纳、语义分类、相似性判断和实体消歧等任务必须理解原文,最终判断由当前 LLM 在本地上下文中逐条完成。代码只用于确定性范围筛选、分批、结果持久化和最终汇总;除非用户明确要求规则法,不用关键词命中、词频、正则或规则打分替代语义判断。 - -1. 先把日期、状态、来源等不改变任务语义的确定性范围条件下推到 Base,只导出 `record_id`、判断所需原文和最终解释所需的最小字段集。 -2. 在读取正文前,先看 manifest 的 `record_file_size_bytes`;结合 `records_count` 以及所选字符串列的 `null_count`、`max_length` 判断正文相对当前上下文的规模,拿不准时先读取前 3 行再决定读取范围。 -3. 文件较小且上下文充足时,将必要记录读入上下文并直接完成语义分析;文件较大但任务仍必须理解全部原文时,先向用户说明原因和预计耗时,在确认后按文本体量分批处理。各批沿用同一判断口径,将 `record_id`、结构化判断和必要依据持续写入本地 artifact,最后统一汇总。 - -## Manifest - -`--output .ndjson` 生成 `.ndjson` 与 `.manifest.json`;记录写入 NDJSON,stdout 返回 manifest。 - -分析 artifact 使用相对路径输出到当前工作目录,例如 `--output ./records.ndjson`。 - -```json -{ - "record_file": "/path/records.ndjson", - "record_file_size_bytes": 18432, - "manifest_file": "/path/records.manifest.json", - "records_count": 137, - "has_more": false, - "columns": { - "record_id": {"physical_type": "string", "stats": {"max_length": 15}}, - "状态": { - "field_id": "fld_status", - "field_type": "select", - "physical_type": "array", - "stats": {"empty_count": 3, "max_length": 2, "avg_length": 1.1}, - "example": ["进行中"] - } - } -} -``` - -- manifest `columns` 是 NDJSON 物理 schema 的权威来源,包含 `field_id`、`field_type`、`physical_type`、`stats` 以及可选的真实 example 或 hint;它不替代完整 Base field schema,选项配置、数字格式、Link 目标表或 formula/lookup 定义影响任务时读取 `+field-list`。全空列按 hint 跳过,任务必须使用时显式 cast。 -- `stats` 只统计本次导出的 records;`null_count` 只计 JSON `null`,字符串长度按 Unicode 字符计数,数字 `avg` 排除 null,多值 `avg_length` 按全部 records(含 `[]`)计算。 - -| 列类别 | `stats` | -| --- | --- | -| 普通字符串 | `null_count, max_length` | -| 数字 | `null_count, min, max, avg` | -| 日期 | `null_count, min, max` | -| checkbox | `true_count` | -| Location | `null_count` | -| 多值列 | `empty_count, max_length, avg_length` | -| 系统 `record_id` | `max_length` | - -- stdout 的 `records_count` 和 `has_more` 描述本次导出;确认后无需在分析代码中重读 manifest 或重新统计 NDJSON 行数。 -- `record_file_size_bytes` 是 NDJSON artifact 的实际字节数,用于选择一次读取、预览或分批方式;确定性计算由 jq/Python 直接读取文件。 -- `query_context` 保存导出查询范围;复用本轮 NDJSON 时结合原查询上下文确认谓词下推口径保持一致。 -- 仅在需要 `columns`、example、hint 或执行 artifact 复用判断时读取 `manifest_file`;满足复用条件后直接继续分析现有 NDJSON。 -- `ignored_fields` 和 `record_not_found` 仅在 stdout 返回时关注。 - -## 数据库专家快速心智模型 - -- Base table 是面向协作的反范式宽表;本地分析将每个导出表作为关系输入,不假设数据库级约束。 -- 每行是一条 record;系统 `record_id` 是表内真正的主键,由 Base 系统生成并维护,契约保证 `NOT NULL` 和 `UNIQUE`,分析代码无需再次检查空值或唯一性,也不可把它作为普通字段更新。Base 的“主字段”只是主要展示字段,不是主键。 -- NDJSON 业务列一律使用字段 `name` 作为 key,不使用 `field_id`;字段重命名会改变 key,对应的 `field_id` 仅记录在 manifest 列元数据中。 -- 除 `record_id` 外,不假设任何列满足 `NOT NULL`、`UNIQUE` 或业务键约束;仅当某列实际作为业务键参与关联或去重时处理空值和重复值。 -- checkbox 在 NDJSON 中始终为 `true` 或 `false`,上游空值会在导出时规范化为 `false`;其他标量列可空并使用 `null`。多值列始终非空,没有元素时用 `[]`;这些是序列化契约,不是业务约束。 -- NDJSON 的读取结构以 manifest `physical_type` 和下表为准,不等同于写记录时的 CellValue;`lark-base-cell-value.md` 在读写形态不一致的类型下提供对照说明。formula 和 lookup 在当前 NDJSON 中统一为字符串,不保留计算结果的原始类型。 -- 将 `physical_type` 和上述 CellValue 结构视为输入契约;一次性分析代码直接读取,不再逐格验证 `record_id`、数组或 struct 的运行时形状。 -- 未显式指定 sort 时不保证行顺序。 - -### Physical type 快速参考 - -| `field_type` | `physical_type` | 示例与语义 | -| --- | --- | --- | -| 系统 `record_id` | `string` | `"rec_xxx"`;系统主键 | -| `text`、`formula`、`lookup`、`auto_number`、`not_support` | `string|null` | `"进行中"`;formula、lookup 不保留结果的原始类型 | -| `datetime`、`created_at`、`updated_at` | `string|null` | `"2026-08-05T10:30:00.000+08:00"`;RFC3339,固定三位毫秒 | -| `number` | `number|null` | `12.5`;JSON 整数和小数均为 number | -| `checkbox` | `boolean` | `true`;上游空值已规范化为 `false` | -| `select` | `array` | `["进行中", "高优"]`;单选、多选读取均为名称数组 | -| `location` | `struct|null` | `{"lng":116.39,"lat":39.90,"full_address":"北京市"}`;非空 Location 的三个成员均非空 | -| `user`、`group_chat`、`created_by`、`updated_by` | `array>` | `[{"id":"ou_xxx","name":"张三"}]` | -| `link` | `array>` | `[{"id":"rec_xxx"}]`;schema 的 `table_id` 指定目标表,`id` 是目标 `record_id` | -| `attachment` | `array>` | `[{"file_token":"box_xxx","size":1024,"name":"report.pdf"}]` | +## 1. 先选数据路径 -### 日期字段读取 - -日期字段以带 offset 的 RFC3339 字符串序列化,并有两种分析语义: +| 任务条件 | 路径 | 完整性要求 | +| --- | --- | --- | +| 当前查询最多 2000 行且 `has_more=false` | NDJSON 本地分析 | 直接处理 artifact | +| 用户指定 View | 记录工具添加 `--view-id` 返回视图范围内的记录 | 结论只代表该 View;记录范围写入 `query_context` | +| 超过 2000 行且必须取得逐条原始记录 | 调整 `--offset` 后继续查询 | 直到 `has_more=false` 代表所有记录已读取 | +| 超过 2000 行,只需单表基础统计、分组或 Top-K | `+data-query` | 由 Base 云端在完整单表范围计算 | +| 多表 JOIN、窗口、递归、严格漏斗、语义分析或任意需要逐条明细的高级计算 | 完整 NDJSON 后由合适的本地分析引擎处理 | 每张参与表都必须完整;不能用 `data-query` 代替原始明细 | -- **instant semantics**:计算真实时长、先后顺序或跨时区比较时,解析完整 RFC3339 值,以其表示的绝对时刻计算。 -- **local-calendar semantics**:按来源 Base 的日、周、月等本地日历分组时,使用序列化值中的本地日期,不先转 UTC,也不按 manifest `timezone` 重复换算。 +局部预览、固定前 N 条或 `has_more=true` 的 artifact 不能支持全局结论。采样只在用户明确要求抽样时使用,并必须说明抽样范围和方法。 -例如,`2026-03-20T23:30:00.000-05:00` 与 `2026-03-21T12:30:00.000+08:00` 表示同一时刻;前者若是来源 Base 的值,本地日报归入 3 月 20 日,而时长或排序计算应把它解析为绝对时刻。只构造任务实际需要的日期表示,并在分析引擎中使用具备 datetime 功能的列。 +## 2. 范围、View、选择与投影 -## 读取与关系建模 +先明确分析总体,再导出数据: -仅在 SOP 已选择 Python 路径后,按实际实现方式只读一份示例: +- **整表范围:** 省略 `--view-id`;`query_context.record_scope` 应为 `all_records` 或 `filtered_records`。 +- **View 范围:** 传真实 `--view-id`。View 的 filter 决定记录范围,sort 决定顺序,`query_context.record_scope` 应为 `view_filtered_records`;结论必须表述为“该 View 内”。 +- **临时条件:** `--filter-json` 覆盖 View filter,`--sort-json` 覆盖 View sort;排序示例:`--sort-json '[{"field":"Updated","desc":true},{"field":"Title","desc":false}]'`,数组顺序是排序优先级,`desc=true` 为降序。两者只覆盖对应部分,不能把“指定 View”与“手工替换后的范围”混称为同一口径。tuple 条件的完整示例和协议见 [Filter 条件结构](lark-base-filter-condition.md)。 +- **关键词与结构化条件:** 展示文本关键词用 `+record-search`;数值、日期、选项、人员、群组、Link、空值等用 `--filter-json`。两者可以叠加。 +- **字段投影:** 重复 `--field-id`,只导出筛选、分组、排序、JOIN、解释、回查所需字段。系统 `record_id` 自动保留;跨表任务还必须投影 Link 或经过验证的业务 key。 -- [Python 标准库示例](lark-base-data-analysis-python-stdlib.md) -- [pandas 示例](lark-base-data-analysis-pandas.md) +manifest 的 `query_context` 是本次 artifact 范围的记录,不是完整查询语言的替代品。复用旧 artifact 前同时核对 `base_token`、`table_id`、View / filter / sort、投影字段和 `rev`。 -两份示例使用相同的五类场景:加载与日期解析、集合谓词、单数组展开、Link JOIN、多数组共现。场景语义和粒度规则以本 SOP 为准,示例只提供对应实现的最短代码。 +## 3. 大表完整读取 -标准库足以清晰表达任务时直接使用;DataFrame 能明显简化计算时再选 pandas。已选择 pandas 但环境未安装时,网络可用且存在 `uv` 或 `pip` 才按需安装,优先使用 `uv run --no-project --with pandas python analyze.py`。 +NDJSON 单次最多返回 2000 条。必须取得超过 2000 条逐行原始记录时: -将 Base 反范式宽表映射为关系模型时,可将标量列视为 record attributes,将多值列视为以 `record_id` 为关联键的 nested relation,将 Link 视为跨表 adjacency list。多值列通过 lateral `explode` / `UNNEST` 切换粒度;Link 规范化为 bridge relation 后再 `merge` / `join` / `JOIN`;同类来源表先投影到 conformed fact schema,再用 `concat` / `UNION ALL` 纵向合并。 +1. 固定 `base_token`、`table_id`、`view-id`、filter、sort 和字段投影;首块从 `offset=0` 开始,每块 `limit=2000`,输出到不同 artifact。 +2. 每块读取 manifest 的 `records_count`、`has_more`、`next_offset`、`rev` 和 `query_context`;`has_more=true` 时只使用返回的 `next_offset` 继续。 +3. 所有块的 `rev` 与 `query_context` 必须一致。读取期间 `rev` 改变表示数据快照已变化,可能产生遗漏或重复;需要严格完整时从头重读,否则明确披露非快照一致。 +4. 以最后一块 `has_more=false` 作为终止条件。分析引擎可逐块消费,不必为了分析先把所有文件拼成一个巨型文件。 +5. 多表任务分别完成每张表的完整性检查;任一输入不完整,JOIN、集合、窗口或统计结果都不完整。 -## 常见分析模式 +如果任务只需要单表基础统计,不应为了拿到所有原始行而分块下载,优先使用下方 `+data-query`。 -### 单表简单筛选与统计:jq +## 4. `data-query`:大规模单表基础统计逃生路径 -NDJSON 每行是一条 record。单表短筛选、计数和简单聚合可直接用 jq;下面筛选“状态”包含“进行中”的记录,并统计记录数和金额合计: +`+data-query` 的 datasource 是单个 Base Table,适合在超过 2000 行时由云端完成: -默认导出后使用本地 `jq -s`,同一 artifact 可反复查询而无需重新下载;本地 jq 不可用时,使用 Python 或其他数据分析引擎处理 records 文件。 +- `filters`:聚合前筛选,类似 WHERE;它使用 LiteQuery 特有的 DSL,不是 Record/View 的 tuple filter,注意不要混淆。 +- `dimensions`:分组字段。 +- `measures`:`sum`、`avg`、`min`、`max`、`count`、`count_all`、`distinct_count`。 +- `sort`:排序字段 -```bash -lark-cli base +record-list \ - --base-token \ - --table-id \ - --field-id 状态 \ - --field-id 金额 \ - --format ndjson \ - --output records.ndjson && -jq -s ' - map(select((.["状态"] | index("进行中")) != null)) as $records - | ($records | map(.["金额"] | select(. != null))) as $amounts - | { - records_count: ($records | length), - amount_sum: ( - if ($amounts | length) > 0 then ($amounts | add) else null end - ) - } -' records.ndjson -``` +SOP 选定这条路径后再读取 [data-query DSL](lark-base-data-query.md)。典型适用范围是**单表**总数、分组计数、数值汇总、去重计数、分组排序和 Top-K。 -### 多值列:nested relation 与目标粒度 +能力边界: -Base 的反范式宽表会把零到多个 Select、人员、群组、Link 或附件元素嵌入一条 source record。多值单元格默认按无重复、无序集合建模:元素顺序不承担稳定业务语义,同一 source record 内可将元素视为唯一,因此其元素数等于去重元素数;跨 source record 出现的同一元素仍是不同事实或关系边。分析时将数组视为以 `record_id` 为 correlation key 的 nested relation,并先确定 target grain: +- 只传 dimensions 时返回去重后的维度组合,不返回 `record_id`,不能视为逐条记录。 +- 不承担多表 JOIN、窗口函数、递归、原始明细导出或语义分析。 +- 没有独立 HAVING 语义;可先由 `data-query` 聚合,再对已收敛的聚合结果做本地条件过滤。 +- 条件聚合只有所有 measures 共用同一前置条件时才能直接下推到 `filters`;不同 measures 使用不同条件时,拆成可复核的查询或在完整明细上计算。 +- 聚合后需要展示原始记录时,用返回的真实业务 key / 维度值通过 `+record-list --filter-json` 或 `+record-get` 回查;不要从聚合行臆造 `record_id`。 -- **record grain**:包含、交集、子集和元素数量等问题直接使用集合谓词,不做 expansion。 -- **record-element grain**:通过 lateral `explode` / `UNNEST` 规范化为 `(source_record_id, element)` bridge relation。inner expansion 会丢弃空数组来源,outer expansion 会保留来源 record;回到 record 口径时按 `source_record_id` 聚合或去重。 -- **entity grain**:两侧分别规范化为 bridge relation,再按稳定 element key JOIN。人员和群组以 `id` 连接、以 `name` 展示;Select 以名称作为元素键,仅当字段共享同一业务值域时才可连接。 +## 5. Manifest 与 NDJSON 结构 -使用列 `stats` 中的 `empty_count`、`avg_length` 和 `max_length` 做 expansion cardinality 与数据倾斜预估:单数组 inner expansion 的估算行数为 `records_count × avg_length`,outer expansion 还需加上 `empty_count`;结合 `max_length` 识别极端 fan-out 或 hot record。任务确实需要元素粒度且估算规模可控时,可以直接展开。 +`--output ./records.ndjson` 生成记录文件和同名 `.manifest.json`。高频 manifest 字段: -#### 多数组、fan-out 与 row-local Cartesian product +| 字段 | 分析用途 | +| --- | --- | +| `records_count` / `has_more` / `next_offset` | 判断当前块大小、是否完整以及下一块起点 | +| `base_token` / `table_id` / `query_context` | 固定来源表和读取范围 | +| `rev` | 检查多块或复用 artifact 时的数据版本一致性 | +| `timezone` | 解释 Base 本地日历边界 | +| `columns.*.field_id/field_type/physical_type` | 确认 NDJSON 实际列类型与稳定字段标识 | +| `columns.*.stats/example/hint` | 估算空值、数组展开规模和文本体量;只描述本次导出 | +| `record_file_size_bytes` | 决定一次读取还是分块处理 artifact | -同一 source record 中的独立数组默认建立为彼此独立的 lateral pipeline,分别展开并聚合回 target grain 后再连接,避免 many-to-many fan-out 和重复计量。只有问题明确要求分析元素组合或共现时,才同时展开形成 row-local Cartesian product。 +NDJSON 每行是一条 Record,以字段 `name` 为 key,并额外包含系统 `record_id`;`field_id` 位于 manifest。字段改名会改变 NDJSON key,跨批次或长期脚本应通过 manifest 复核 `field_id → name`。 -两个数组同时展开的准确 cardinality 为 `Σᵢ(|Aᵢ| × |Bᵢ|)`;可用 `records_count × avg_length_a × avg_length_b` 估算执行规模,并结合两列的 `max_length` 判断极端 fan-out。平均长度乘积不反映列间相关性,只用于成本估算。Base schema 不提供不同多值列之间的 positional contract;仅当额外业务契约明确声明位置对应语义时,才按 ordinality ZIP。 +| `field_type` | NDJSON 结构 | Base 特有的分析语义 | +| --- | --- | --- | +| `record_id` | `string` | 表内唯一主键,用于定位和块间去重 | +| `text`、`formula`、`lookup`、`auto_number`、`not_support` | `string|null` | Formula / Lookup 不保留原始计算类型;需要数值运算时必须显式验证转换规则 | +| `datetime`、`created_at`、`updated_at` | RFC3339 `string|null` | 带 offset;区分绝对时刻与 Base 本地日历语义 | +| `number` | `number|null` | 空值不是零,是否纳入分母由任务口径决定 | +| `checkbox` | `boolean` | 上游空值在 NDJSON 中规范化为 `false` | +| `select` | `array` | 单选、多选都读取为选项名称数组;空值为 `[]` | +| `location` | `{lng,lat,full_address}|null` | 地理计算用坐标,文本范围分析用地址 | +| `user`、`group_chat`、`created_by`、`updated_by` | `array<{id,name}>` | 连接与去重使用 `id`,展示使用 `name` | +| `link` | `array<{id}>` | `id` 是 Field schema 指定目标表中的 `record_id` | +| `attachment` | `array<{file_token,size,name}>` | 文件 token 是稳定定位信息;数组展开会改变粒度 | -### Link:跨表 adjacency list +除 `record_id` 外,不假设任何列满足非空或唯一。标量空值通常是 `null`,多值列空值是 `[]`;未显式排序时不依赖 NDJSON 行顺序。 -- Link 字段的完整 schema 以 `+field-list` 为准,其中 `table_id` 声明唯一目标 table;NDJSON 的 `[{"id":"rec_xxx"}]` 表示指向该表目标 `record_id` 的零到多条有向边。以 `table_id` 确定目标表,缺少可信 schema 时先补充 `+field-list`。 -- 将 Link 规范化为 `(source_record_id, target_record_id)` edge/bridge relation,再按 `target_record_id = 目标表.record_id` 执行外键式 JOIN。需要反向遍历时复用同一 edge relation 反向分组或连接;NDJSON 不隐含自动反向关系。 -- 多跳 Link 通过逐跳组合 edge relation 完成 traversal,并始终在各自 record-id domain 内连接。最终展示目标表的用户可读 attributes;已有 Link 时使用 Link edge relation,其他关联使用经过验证的 business key。 +## 6. 专业分析场景中的 Base 映射 -### 跨表同类实体与指标 +下表不教授算法,只指出开始计算前必须解决的 Base 特有问题: -- 多表 users 等重复实体的事实分析,先把各表投影为 `(source_table, source_record_id, entity_id, metric...)` 的 conformed long fact schema,再 `UNION ALL` 并聚合到 entity grain。需要横向比较时,各表先聚合到相同 entity grain 再 JOIN,避免原始事实之间产生 many-to-many fan-out。 -- 没有 Link 时只能使用经过验证的 business key 关联。名称相似匹配属于 entity resolution,不属于普通 JOIN;应作为独立阶段输出匹配依据、置信度和未决项。 +| 场景 | Base 数据结构映射与正确性约束 | +| --- | --- | +| 复杂多表 JOIN | Link 先展开为 `(source_record_id, target_record_id)` 边,再按目标表 `record_id` 连接;目标 `table_id` 来自 Field schema。无 Link 时只能使用已验证唯一性和空值规则的业务 key,必须统计未匹配与重复 key。 | +| 集合运算 | Select 是名称数组,人员/群组按 `id`,Link 按目标 `record_id`;先明确是 record 级包含/交并差,还是 element 级集合,不能把数组字符串化比较。 | +| 多值展开与数据重塑 | Select、人员、群组、Link、附件都是 nested relation。一次展开把粒度从 record 变为 record-element;两个数组同时展开会产生行内笛卡尔积,除非任务明确分析共现,否则分别展开并聚合回目标粒度。 | +| 分组、条件聚合与 HAVING | 先确定 record / element / entity grain 和空值口径。单表基础聚合可走 `data-query`;HAVING 在聚合结果上本地过滤。不同条件的 measures 不要错误共用一个全局 filter。 | +| 排序与 Top-K | 原始记录 Top-K 用 Record sort;大表单表聚合 Top-K 用 `data-query`。并列值是否全部保留、如何稳定打破 ties 必须按任务口径明确。 | +| 窗口计算与严格时序漏斗 | NDJSON 不保证默认顺序;显式选择实体 key、事件时间、分区字段和同时间 tie-breaker。`data-query` 不提供窗口或逐事件漏斗语义。 | +| 时间边界与周期对齐 | 真实时长和跨时区排序按完整 RFC3339 instant;按来源 Base 的日/周/月分组使用值中的本地日期和 manifest `timezone`,不要先转 UTC 后再切日历周期。 | +| 层级与递归 | Link 是有向邻接边;逐跳保持各 Table 的 record-id domain,记录已访问节点以处理环,并明确深度或终止条件。 | +| 派生变量与指定规则的数据质量处理 | 保留原字段和 `record_id`,派生列另命名;只执行用户给定或业务已确认的缺失、异常、去重、标准化规则,不把通用清洗习惯当成业务事实。 | +| 临时语义转换 | LLM 产生的标签、主题或实体映射以 `record_id` 回连并保留判断依据;默认只作为本地临时派生结果,用户未要求时不写回 Base。 | +| 描述性统计、差异分解、关联分析与统计推断 | 先确认总体是整表还是 View、输入是否完整、分析粒度是否因多值展开改变,以及 Formula / Lookup 是否需要类型恢复;把选择偏差、缺失和重复实体视为 Base 数据口径问题,而不是静默用算法默认值处理。 | + +跨多个同类事实表时,先投影为一致的长表结构,例如 `(source_table, source_record_id, entity_id, metric...)` 再纵向合并;横向比较时,各表先聚合到相同 entity grain 再 JOIN,避免原始事实间 many-to-many fan-out。 + +## 7. 交付前检查 + +最终结果至少说明: + +- 数据来自哪些 Base / Table / View,应用了哪些 filter、时间范围和字段投影。 +- 每张输入表是否读到 `has_more=false`,或是否由 `data-query` 在云端完成完整单表聚合。 +- 分析粒度、空值口径、多值展开方式、JOIN key、重复 key 和未匹配数量。 +- 时间采用 instant 还是 Base local-calendar 语义。 +- 临时派生、清洗、语义标签或推断使用了哪些用户指定规则;哪些结果没有写回 Base。 + +只有范围完整且口径与问题一致时,才给出全局结论。 diff --git a/skills/lark-calendar/SKILL.md b/skills/lark-calendar/SKILL.md index bd00fe474a..1b81f7a7cf 100644 --- a/skills/lark-calendar/SKILL.md +++ b/skills/lark-calendar/SKILL.md @@ -41,6 +41,7 @@ lark-cli calendar +agenda --as bot | `+freebusy` | 查询用户主日历的忙闲信息和 RSVP 状态(纯查询场景;预约场景走 `+suggestion`) | | [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(无明确时间时禁止直接调用,需先走 +suggestion) | | [`+rsvp`](references/lark-calendar-rsvp.md) | 回复日程(接受/拒绝/待定) | +| [`+join-event`](references/lark-calendar-join-event.md) | 凭分享 token 加入日程(分享链接/二维码/分享卡片/RSVP 卡片) | | [`+suggestion`](references/lark-calendar-suggestion.md) | 根据非明确时间或一段时间范围,推荐多个可用时间块方案 | | [`+transfer`](references/lark-calendar-transfer.md) | 把日程组织者转让给另一个用户或机器人;不可逆,需 `--yes` | @@ -133,6 +134,7 @@ lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12 --user-id ou_xxx | 查询日历/日程或未来时间的会议 | 本 skill | | 按关键词搜索日程 | 本 skill(`+search-event`) | | 从日程获取关联的视频会议 ID 或用户绑定的会议纪要文档 | 本 skill(`+meeting`) | +| 把日程分享给某人 / 群 | 本 skill:先 `calendar events share_info` 取**日程分享链接**,再走 [lark-im](../lark-im/SKILL.md) 发送该链接;分享链接不是 applink,不要自己拼接或用 applink 代替 | | 从日程进一步拿 AI 智能纪要 / 逐字稿 / 妙记产物 | 先 `+meeting` 取 `meeting_id`,再进入 [`lark-meeting`](../lark-meeting/SKILL.md):[`vc +detail`](../lark-meeting/references/lark-vc-detail.md) → [`note +detail`](../lark-meeting/references/lark-note-detail.md) / [`minutes +detail`](../lark-meeting/references/lark-minutes-detail.md) | | 预约/改约日程、调整时间、添加/更换会议室、查会议室 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) | | 仅编辑日程字段(标题/描述)或增删参会人(不涉及时间和会议室) | 先定位 `event_id`,再读 [+update](references/lark-calendar-update.md) 执行变更 | @@ -171,6 +173,10 @@ lark-cli calendar calendars primary # 获取日程详情及 app_link lark-cli calendar events get --calendar-id --event-id +# 获取日程分享链接(分享给他人/群前必须先拿到) +# 返回形如 {{domain}}/calendar/share?token= 的分享链接,不是 applink;直接把该链接发给对方(对方可凭链接中的 token 走 +join-event 加入) +lark-cli calendar events share_info --calendar-id --event-id + # 删除日程 lark-cli calendar events delete --calendar-id --event-id ``` diff --git a/skills/lark-calendar/references/lark-calendar-join-event.md b/skills/lark-calendar/references/lark-calendar-join-event.md new file mode 100644 index 0000000000..e737c141f8 --- /dev/null +++ b/skills/lark-calendar/references/lark-calendar-join-event.md @@ -0,0 +1,43 @@ +# calendar +join-event + +凭**分享 token** 加入日程。 + +## 命令 + +```bash +# 用户以自身身份加入(默认场景) +lark-cli calendar +join-event --token --as user + +# 以应用身份加入 +lark-cli calendar +join-event --token --as bot +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--token ` | **是** | 分享 token,加入的唯一入参(别名 `--share-token`)。| + +## token 从哪来 + +| token 类型 | 承载来源 | 取值 | +|-----------|---------|------| +| 链接类 | 分享链接 / 二维码 | 链接 `{{domain}}/calendar/share?token=` 里的 `token` | +| 卡片类 | 分享卡片 / RSVP 卡片 | 从 IM 日程分享卡片或 RSVP 卡片消息解析出的日程分享 token | + +- **分享链接**:直接取 URL query 里的 `token` 值传入;无需解析日程字段。例如 `{{domain}}/calendar/share?token=29f762bdmsbd82ce9` → `--token 29f762bdmsbd82ce9`。 +- **二维码**:先用 OCR/扫码解析成分享链接,再取其中的 `token`——CLI 不承接二维码图像,只承接解析后的链接 token。 +- **卡片**:token 落在卡片消息 content(分享卡片 `SHARE_CALENDAR_EVENT`、RSVP 卡片 `GENERAL_CALENDER`);RSVP 卡片被转发后退化为分享卡片,同样可加入。 + +## 重复性日程 + +加入范围取决于 token 反解出的日程本体是「原重复性日程」还是「例外」(参见 [lark-calendar-recurring](lark-calendar-recurring.md) 的关键概念): + +- 分享的是**原重复性日程**(`{event_uid}_0`):加入的是**整个序列**(含例外)。 +- 分享的是某个**例外**(`originalTime > 0` 的单次实例):只加入这**一个例外日程**。 + +## 参考 + +- [lark-calendar](../SKILL.md) -- skill 入口与路由 +- [lark-calendar-rsvp](lark-calendar-rsvp.md) -- 已在日程中时回复接受/拒绝/待定(≠ 加入) +- [lark-calendar-recurring](lark-calendar-recurring.md) -- 重复性日程的序列 vs 实例操作规范 diff --git a/skills/lark-im/SKILL.md b/skills/lark-im/SKILL.md index 2024e086b0..203ff77dee 100644 --- a/skills/lark-im/SKILL.md +++ b/skills/lark-im/SKILL.md @@ -35,6 +35,10 @@ Chat (oc_xxx) ## Important Notes +### AppLink and Share Links + +Prefer CLI-returned links: use `chat_app_link` to open joined conversations, `message_app_link` to open messages, and `share_link` to invite others to groups. If manually building a joined-conversation AppLink, use `https:///client/chat/open?openChatId=`, never `chatId=` or `lark://...chat_id=`. + ### Identity and Token Mapping - `--as user` means **user identity** and uses `user_access_token`. Calls run as the authorized end user, so permissions depend on both the app scopes and that user's own access to the target chat/message/resource. diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index c275b192a4..47985767ca 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -1,6 +1,6 @@ --- name: lark-sheets -version: 3.1.6 +version: 3.1.7 description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)、金融/财务建模(DCF、三张表、预算、Sensitivity 等)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。" metadata: requires: @@ -216,6 +216,8 @@ lark-cli sheets +csv-get --url "https://.../sheets/shtXXX" --sheet-name "<真实 | `--print-schema` | bool | 否 | 本地打印复合 JSON flag 的 JSON Schema 并退出,不发起调用、不需要其它 required flag。搭配 `--flag-name` 指定查哪个 flag;省略时列出该 shortcut 可查询的 flag。仅对含复合 JSON flag 的 shortcut 有效。 | | `--flag-name` | string | 否 | 配合 `--print-schema`:flag 名不带 `--` 前缀(`cells` / `properties`)。**支持点分路径切片**:`--flag-name properties.snapshot.plotArea.axes` 只打印该子树,大 schema(chart 的 properties 约 1700 行)按需取,别整篇翻页。 | +> **bool flag 语法**:开启可用裸 `--flag`;显式值只用 `--flag=true` 或 `--flag=false`,不得用空格分隔。 + > ⚠️ **high-risk-write 命令清单(exit 10 强确认门禁)**:`+batch-update`、`+cells-clear`、`+cells-batch-clear`、`+sheet-delete`、`+dim-delete`、`+dropdown-delete`,以及各对象删除 `+chart-delete` / `+pivot-delete` / `+cond-format-delete` / `+filter-delete` / `+filter-view-delete` / `+sparkline-delete` / `+float-image-delete`。 > > **审批协议**:先 `--dry-run` 预览、向用户展示将执行的操作与影响范围,**获得用户明确同意后**再在原命令追加 `--yes` 执行。未经用户同意不得带 `--yes`,也不得在 exit 10 后静默补 `--yes` 重试——那等于禁用门禁。完整协议见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。 diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 144eb26032..5a079e3890 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -87,7 +87,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | `--colors` | string + File + Stdin(简单 JSON) | optional | 下拉胶囊背景色,RGB hex 数组(如 `["#1FB6C1","#F006C2"]`)。长度可短不可长——超长 Validate 拦截(`--colors length (N) must not exceed dropdown source size (M)`),未指定项按内置 10 色色板循环补色。**单独传即生效**;`--highlight=false` 时被忽略。 | | `--multiple` | bool | optional | 启用多选 | | `--highlight` | bool | optional | 下拉胶囊背景色高亮开关。**不传 = 开**(按内置 10 色色板循环上色);`--highlight=false` 关闭得到纯白下拉。配色用 `--colors` 覆盖。 | -| `--source-range` | string | xor | listFromRange 模式的下拉源 range,A1 表示法 + sheet 前缀(如 `'Sheet1'!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表(type=list),传本 flag 走 range 引用(type=listFromRange)。`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。当 `--highlight` 开启且 source 覆盖单元格数超过 2000 时,服务端会将该下拉判为 option-error(这是不支持的组合);CLI 会向 stderr 输出 warning。如需取消,传 `--highlight=false`。 | +| `--source-range` | string | xor | listFromRange 模式的下拉源 range,A1 表示法 + sheet 前缀(如 `'Sheet1'!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表(type=list),传本 flag 走 range 引用(type=listFromRange)。`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。当 `--highlight` 开启且 source 覆盖单元格数超过 2000 时,服务端会将该下拉判为 option-error(这是不支持的组合);CLI 会在返回结果的 `data.warnings` 中给出 warning。如需取消,传 `--highlight=false`。 | ### `+dropdown-delete` diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md index db7b974215..0befdf50ca 100644 --- a/skills/lark-sheets/references/lark-sheets-chart.md +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -32,7 +32,7 @@ 普通创建、数据源修正和常用配置更新不要构造原始 snapshot。 -典型工作流:先确认表头和精确数据范围,用 `+chart-create-basic` 一次创建并尽量在同次调用中带上已知标题/轴/标签内容要求;标签位置只有用户明确指定时才传。创建后用返回的完整 `snapshot` 检查范围、方向与系列,再按需用 `+chart-list` 验证。已有图表的数据范围或方向错误时用 `+chart-data-update`,常用配置修正用 `+chart-config-update`。只有用户要求单个系列、数据点或高级引擎字段时,才读取现有 snapshot 并调 `+chart-update --properties`。不要为了常用配置先输出整份 schema,也不要删除重建已经创建成功的图表。 +典型工作流:先确认表头、精确数据范围和图表配置,运行 `python scripts/lark_chart_size_advisor.py` 取得建议尺寸,再将返回的 `data.create_flags.width` / `height` 原样传给 `+chart-create-basic`;创建时尽量在同次调用中带上已知标题/轴/标签内容要求,标签位置只有用户明确指定时才传。创建后用返回的完整 `snapshot` 检查范围、方向与系列,再按需用 `+chart-list` 验证。已有图表的数据范围或方向错误时用 `+chart-data-update`,常用配置修正用 `+chart-config-update`。只有用户要求单个系列、数据点或高级引擎字段时,才读取现有 snapshot 并调 `+chart-update --properties`。不要为了常用配置先输出整份 schema,也不要删除重建已经创建成功的图表。 **多图表工作流**:先完成所有辅助数据和表头,列出每张目标图的类型、精确数据范围、标题和落点;确认清单后,用一次 `+batch-chart-create` 批量创建。它的每个 operation 直接填写 `+chart-create-basic` flags,CLI 内部固定按 `+chart-create-basic` 执行,不要再套 `shortcut` / `input`。图表之间独立时允许部分成功:按返回的逐项结果定位失败图表,只重试失败项。批量 create 的逐项结果不返回完整 snapshot;批次后每个受影响的 sheet 各调用一次 `+chart-list`。已经成功创建的图表有数据源或配置差异时,用 `+batch-chart-update` 批量执行对应的语义更新,不要删除重建。 @@ -62,11 +62,34 @@ **范围与系列前置校验(创建前必做)**:清单中同时记录每张图的表头范围、纳入维度、明确排除维度、数据方向和预期系列数。当前每张图**最多 50 个数值系列**;按列组织时通常为“所选数值列数”,按行组织时通常为“所选数值行数”。创建时就用 `+chart-create-basic --dim1-index ... --dim2-indexes ...` 显式选择类别与不超过 50 个数值系列;如果业务要求展示超过 50 个系列,应先建立紧凑汇总表或 Top-N,而不是反复删除重建。创建前根据实际表头确认索引和边界,不凭字母猜范围;创建后范围、方向或系列数不符时,使用 `+chart-data-update` 修正,CLI 会读取当前快照、重建 `refs` / `dim1` / `dim2.series` 并只提交 data patch,不要删除后重建。 -**坐标轴语义与范围**:所有带坐标轴的图表都要在清单中记录每条轴对应的字段语义、类别轴 / 连续轴类型、单位、边界、刻度间隔以及主副轴归属,不能只核对轴标题。多图对比时,先判断“范围 / 尺度一致”指绝对边界相同,还是跨度和刻度可比;用户未明确要求所有图共用相同最小值和最大值时,不要默认使用各数据子集的并集边界。按连续区间分图时,各图使用自己的区间边界并保持跨度和刻度可比;对比同一指标时保持值轴口径一致,不同单位或量级的指标不强行共用边界。 +**尺寸建议(创建前必做)**:确认 `--chart-type`、`--data-range`、数据方向、dim1/dim2、标题、图例和标签策略后,先运行尺寸建议器。有分离表头时同时传 `--header-range`。 + +硬下限如下;建议器不可用时也不得低于此值: + +| 图表类型 | 最小宽度 × 高度(px) | +|---|---:| +| 柱形图、折线图、面积图及其它默认类型 | `640 × 400` | +| 条形图、组合图 | `720 × 420` | +| 饼图、环形图 | `720 × 440` | + +```bash +python scripts/lark_chart_size_advisor.py "<表格 URL 或 spreadsheet token>" \ + --worksheet-id "" \ + --chart-type column --data-range "'Sheet1'!A1:C10" \ + --dim1-index 1 --dim2-indexes 2,3 \ + --data-labels value --legend-position bottom --title "销售额对比" +``` + +- 先运行建议器,再将 `data.create_flags.width` / `height` 原样用于创建命令(包括 `--dry-run`);不得凭经验填写尺寸。 +- `data.minimum_size` 只是硬下限,最终宽高不得低于建议器返回值。 +- `data.size_alone_is_insufficient=true` 时先执行 `data.layout_advice`:改横向条形图、Top-N、拆图或只保留关键点标签,禁止仅把宽度无限增大;配置变化后用新参数重跑建议器,再采用新的 `create_flags`。 +- 建议器根据类目数、系列数、中英文显示宽度、多行文本、标签密度、图例行数和饼图数值分布估算;饼图保持相对固定的饼区,主要按最长标签文本增加两侧留白,不随扇区数量线性增宽。它不模拟浏览器渲染,创建后仍须运行质量检查器。 + +**坐标轴语义与范围**:所有带坐标轴的图表都要在清单中记录每条轴对应的字段语义、类别轴 / 连续轴类型、单位以及主副轴归属,不能只核对轴标题。Y 轴显示范围默认交给图表引擎;用户未明确要求固定范围时,不传 `--y-axis-min` / `--y-axis-max`,重点只处理确有必要收紧的连续数值 X 轴。堆积图的峰值来自同一类别内系列累加,瀑布图来自逐项累计,组合图还要按左右轴分别计算;不得直接把数据源单列的最小值 / 最大值当成 Y 轴边界。只有用户明确要求或视觉验收证明自动范围不可读时,才按图表类型的实际绘制值计算并设置 Y 轴范围。多图对比时,先判断“范围 / 尺度一致”指绝对边界相同,还是跨度和刻度可比;对比同一指标时保持值轴口径一致,不同单位或量级的指标不强行共用边界。 **横向类别行配方**:当日期/月份等类别横向排列在一行、目标数值在另一行时,把“类别行 + 数值行”一起放进 `--data-range` 并传 `--data-direction row`,例如 `--data-range "'Sheet1'!A1:M1,'Sheet1'!A3:M3" --data-direction row`。此时类别行属于数据映射,**不要**传给 `--header-range`。`--header-range` 仅表示与纯数据分离的“维度/系列名称”:column 方向必须是一行,row 方向必须是一列。row 方向却传入多列表头,通常说明把类别行误当成了分离表头。 -**整图配色优先走语义参数**:只要求统一主题或一组系列颜色时,在创建时传 `--color-palette` 或 `--colors`,已有图表用 `+chart-config-update` 更新;二者互斥。`--colors` 接受逗号分隔且至少包含 2 个十六进制色值的字符串;批量 operation 的 `colors` 同时接受字符串或字符串数组,也必须至少包含 2 个颜色。`--colors` 是整图色板:引擎按颜色数组的顺序**循环**给每个系列上色(柱子、折线、扇区等各类系列元素都算一个上色单位),颜色数少于系列数时从头循环复用。若要**明确指定每个系列的颜色**,必须传入与系列数量相同的颜色(否则会因循环导致部分系列共用同一颜色)。只有指定某个系列或某个数据点的颜色时才使用原始 snapshot。 +**整图配色优先走语义参数**:统一主题或系列配色用 `--color-palette` / `--colors`,已有图用 `+chart-config-update`;优先继承原表主题,同一指标跨图保持同色,组合图用同色系柱形、高对比折线和中性辅助线。`--colors` 会循环复用,明确逐系列配色时颜色数须与系列数一致。颜色过多难以区分时优先 Top-N 或拆图;单系列/数据点配色才使用原始 snapshot。 ## 需求→图表类型映射(创建前必查) @@ -87,7 +110,9 @@ **常见配置错误(必须注意)**: - **图表类型选择错误**:用户说"堆积柱形图 / 百分比堆积"时,用 `+chart-create-basic --stack normal|percent` 或 `+chart-config-update --stack normal|percent`;用户说"占比 / 比例"时,优先考虑饼图或百分比堆积图。注意 `column` 是纵向柱形图、`bar` 是横向条形图,"对比 / 各 XX" 类纵向柱默认用 `column`;面积图原生支持 `snapshot.plotArea.plot.type="area"`,别因速查表没列就判"不支持"。 -- **数据标签开关**:创建时用 `--data-labels`,已有图用 `+chart-config-update --data-labels`;明确关闭时传 `none`,不要为常用标签配置构造原始 `labels` 对象。高级配置中 `plotArea.plot.labels` 对象的存在性即开关;关闭标签时应省略整个 `labels` 字段,不能用全部字段置为 `false` 代替。用常量或重复值系列表示基准、目标、阈值或上下限时,默认关闭该系列标签;不支持单点标签时,不得用全系列重复标签代替,改用包含名称和值的系列名、图例或标题。 +- **数据标签开关**:普通基础图默认开启数值标签,创建时传 `--data-labels value`;先采用尺寸建议器返回的宽高,若仍提示“数据标签过密”,不要关闭全部标签,改为只保留关键点、末值、异常值或用户明确要求的值。已有图用 `+chart-config-update --data-labels`;用户明确关闭时传 `none`,不要为常用标签配置构造原始 `labels` 对象。高级配置中 `plotArea.plot.labels` 对象的存在性即开关:创建时关闭标签应省略该字段,更新时删除已有全局标签传 `labels: null`,不能用全部字段置为 `false` 代替。多个系列的数据标签展示要求不同时,禁止传全局 `--data-labels`,应在创建后读取完整 `plotArea.plot.series`,仅给需要标签的系列设置 `labels`,再用 `+chart-update --properties` 整段回写该数组。 +- **辅助线与单点标签**:用户要求基准线、目标线、阈值线、平均线或上下限时,先在源数据旁新增一列重复目标值作为辅助线;如果只需要在线尾或某个关键位置显示一个标签,再新增一列稀疏标点数据,仅在目标行写入同一数值,其余单元格保持真正空白。数据准备完成后创建组合图:辅助值列用 `line`,稀疏标点列用 `scatter`,省略全局 `--data-labels`,并传 `--aggregate-categories=false` 关闭“汇总相同类别”;已有图用 `+chart-config-update --aggregate-categories=false`。随后读取完整系列数组,只给稀疏标点系列设置数值标签,辅助线系列必须省略 `labels`;原数据系列是否设置标签按用户要求决定。不得用重复值辅助线的全系列标签模拟单点标签,也不得用 0 代替空白标点,否则聚合会把空标点物化为每个类别的数据点,导致标签重复出现。 +- **常量系列标签**:目标线、阈值线和上下限等重复常量系列默认不显示逐点标签;名称和值放在系列名、图例、标题或单个稀疏标记中。创建后若质量检查器提示“常量系列重复标签”,移除该系列标签或改成只有一个非空点的稀疏标记。 - **数据标签位置**:只有用户明确要求且已有标签时才传 `--data-label-position`;它只调整已有标签的位置,不会单独开启标签。需要同时显示标签时一并传 `--data-labels`;未明确位置时省略,让图表按类型自动选择。标签位置只控制摆放方式,不能实现仅显示末点或关键点。 - **数据源范围与系列名来源要对齐**: - 默认让 `--data-range` 包含真正的表头行 / 列;表头上方的合并大标题必须跳过。 @@ -96,6 +121,8 @@ - **数据源必须是数值 / 日期型**:图表只渲染数值型单元格。用 `+cells-set` 构造数据源时,给数字 / 日期单元格设 `cell_styles.number_format`,不要留成纯文本,否则该系列渲染为空。 - **数值 / 日期显示异常**:坐标轴沿用源单元格格式。日期显示成序列号、大数值显示成科学计数法时,修正源数据的 `cell_styles.number_format`,不要给图表轴构造未定义的 format 字段。 - **轴口径错误**:用户要"占比 / 比例"时,用饼图或 `--stack percent`,并核对数据源与标签确实表达百分比,不要交付仍以原始计数为纵轴的图。 +- **组合图系列被压扁**:创建前比较各系列的单位和典型值 / 峰值量级;单位不同、相差约一个数量级以上,或折线贴近 X 轴时,不得把所有系列都放左轴。用 `--series-y-axes` 将会被压扁的系列(常见为百分比、比率或小量级折线)放到右轴,并用左右轴标题明确各自单位;`--series-types` / `--series-y-axes` 必须与 `--dim2-indexes` 逐项对齐。 +- **饼图标签截断**:饼图默认传 `--legend-position bottom`,并使用比普通单图更宽的画布;创建时同时传 `--width` / `--height`。宽度主要为左右两侧最长标签留白,不因类别数量线性增加;类别过多时改用 Top-N 或条形图,不能靠无限加宽或截断标签交付。 - **对象语义验证**:基础单图先核对返回的完整 `snapshot`;批量创建、响应不完整、后续又更新或结果存疑时,再按受影响的 sheet 调一次 `+chart-list`。这里只核对数量、数据源、方向、系列和配置,不能代替交付前的布局检查。 > **⚠️ 硬性规则:当用户通过列标题名称(而非列索引)指定横轴/纵轴系列时,必须先读取表格首行(表头)来确定列名与列索引的对应关系,再设置普通图表的 `--dim1-index` / `--dim2-indexes` 或气泡图的角色索引。** @@ -138,8 +165,8 @@ 完成本次所有图表创建或更新后,再逐图核对以下项;全部通过才算完成: 1. **数量**:图表数 = 用户明确要求的数量("每个 / 分别 / 逐一"等数量词已逐项展开为独立图,不用一张多系列图代替)。 -2. **文案与展示项**:回读图表标题、副标题和坐标轴标题,确认语义准确且无乱码、占位符或空括号;图例、数据标签按用户要求展示或隐藏(未要求时不擅自增删),辅助系列不得用全点重复标签模拟单点或末点。带坐标轴的图表还要回读每条轴的字段语义、类型、单位、最小值 / 最大值、刻度以及主副轴归属;多图对比时再核对边界、跨度和口径是否符合用户的可比性要求。 -3. **位置与布局**:图表创建、配置更新、数据更新或位置调整后,每个受影响子表运行一次 `python scripts/lark_chart_layout_check.py "<表格 URL 或 spreadsheet token>" --worksheet-id ""`,无需先用 `ls` 探测脚本。`data.passed=true` 且退出码为 `0` 才可交付;退出码 `2` 且 `data.passed=false` 表示检查成功发现问题,按返回位置用 `+chart-update --properties` 最小 patch 调整后重跑。退出码 `1`、网络超时或无有效 JSON 时只重试一次;仍失败则明确报告布局未完成验收,禁止用人工估算代替。 +2. **文案与展示项**:回读图表标题、副标题和坐标轴标题,确认语义准确且无乱码、占位符或空括号;图例按用户要求展示或隐藏,普通基础图的数据标签默认展示,仅在数据点较多、系列较多或标签容易重叠时根据可读性关闭;辅助系列不得用全点重复标签模拟单点或末点。带坐标轴的图表还要回读每条轴的字段语义、类型、单位、最小值 / 最大值、刻度以及主副轴归属;多图对比时再核对边界、跨度和口径是否符合用户的可比性要求。 +3. **图表质量**:图表创建、配置更新、数据更新或位置调整后,每个受影响子表运行一次 `python scripts/lark_chart_quality_check.py "<表格 URL 或 spreadsheet token>" --worksheet-id ""`,无需先用 `ls` 探测脚本。检查器覆盖几何重叠、遮挡内容、越界、最小尺寸、数值源格式、全零/空系列、常量系列重复标签和数据标签过密;`data.passed=true` 且退出码为 `0` 才可交付。`dense_data_labels` 只是启发式告警,不影响 `passed`;已采用尺寸建议时不得仅为消除该告警关闭全部标签,确需精简时只保留关键点标签。退出码 `2` 表示检查成功发现问题,按返回的修复建议调整后重跑;退出码 `1`、网络超时或无有效 JSON 时只重试一次,仍失败则明确报告质量检查未完成,禁止用人工估算代替。 ## Shortcuts @@ -173,15 +200,16 @@ _公共四件套 · 系统:`--dry-run`_ | `--data-range` | string | required | 数据范围;未传 --header-range 时须包含表头,传入时只传纯数据;支持逗号分隔及跨子表多范围 | | `--header-range` | string | optional | 可选的分离表头范围;column 方向须为一行、row 方向须为一列,表头数须等于数据维度数 | | `--data-direction` | string | optional | 数据系列方向;column 表示首列为类别,row 表示首行为类别(可选值:`column` / `row`)(默认 `column`) | +| `--aggregate-categories` | bool | optional | 是否汇总相同类别;稀疏标点或需要保留逐行数据点时使用 --aggregate-categories=false,省略时沿用图表默认行为 | | `--x-axis-numbers-as` | string | optional | 横轴数字的解释方式;text 将数字视为等间距文本类别,values 按连续数值及真实间距绘制(可选值:`text` / `values`)(默认 `text`) | | `--x-axis-min` | float64 | optional | 连续数值 X 轴的显示范围下界;需同时使用 --x-axis-numbers-as values | | `--x-axis-max` | float64 | optional | 连续数值 X 轴的显示范围上界;需同时使用 --x-axis-numbers-as values | -| `--y-axis-min` | float64 | optional | 左 Y 轴的显示范围下界;必须小于 --y-axis-max | -| `--y-axis-max` | float64 | optional | 左 Y 轴的显示范围上界;必须大于 --y-axis-min | +| `--y-axis-min` | float64 | optional | 左 Y 轴的显示范围下界;默认省略,仅在用户明确要求固定范围时传;不得直接使用数据源单列最小值,且必须小于 --y-axis-max | +| `--y-axis-max` | float64 | optional | 左 Y 轴的显示范围上界;默认省略,仅在用户明确要求固定范围时传;须按图表实际绘制值计算,且必须大于 --y-axis-min | | `--dim1-index` | int | optional | 类别/X 轴维度在数据范围中的 1-based 索引;默认 1 | | `--dim2-indexes` | string | optional | 值/Y 轴系列的 1-based 索引列表,逗号分隔;不能包含 dim1,最多 50 个。气泡图旧调用按 `x,y[,group][,size]` 顺序传 2–4 个,新调用优先使用角色索引;饼图和排列图只传 1 个 | -| `--series-types` | string | optional | 仅组合图;按 --dim2-indexes 顺序指定系列类型,逗号分隔,可选 column、line、area,数量必须与数值系列一致 | -| `--series-y-axes` | string | optional | 仅组合图;按 --dim2-indexes 顺序指定系列使用 left 或 right Y 轴,逗号分隔,数量必须与数值系列一致 | +| `--series-types` | string | optional | 仅组合图;按 --dim2-indexes 顺序指定系列类型,逗号分隔,可选 column、line、area、scatter,数量必须与数值系列一致 | +| `--series-y-axes` | string | optional | 仅组合图;先比较系列单位和量级,将会被压扁的系列放到 right 轴;按 --dim2-indexes 顺序传 left 或 right,数量必须与数值系列一致 | | `--key-index` | int | optional | 仅气泡图:标识/名称维度的 1-based 索引;与 dim1/dim2 索引互斥,默认 1 | | `--x-index` | int | optional | 仅气泡图:X 值维度的 1-based 索引;须与 --y-index 一起提供 | | `--y-index` | int | optional | 仅气泡图:Y 值维度的 1-based 索引;须与 --x-index 一起提供 | @@ -189,21 +217,21 @@ _公共四件套 · 系统:`--dry-run`_ | `--size-index` | int | optional | 仅气泡图:可选气泡大小维度的 1-based 索引 | | `--title` | string | optional | 图表标题 | | `--subtitle` | string | optional | 图表副标题 | -| `--legend-position` | string | optional | 图例位置;hidden 隐藏图例(可选值:`top` / `bottom` / `left` / `right` / `hidden`) | +| `--legend-position` | string | optional | 图例位置;饼图 / 环形图默认 bottom,hidden 隐藏图例(可选值:`top` / `bottom` / `left` / `right` / `hidden`) | | `--x-axis-title` | string | optional | X 轴标题 | | `--y-axis-title` | string | optional | 左 Y 轴标题 | | `--secondary-y-axis-title` | string | optional | 右 Y 轴标题 | | `--x-axis-label-angle` | int | optional | X 轴标签旋转角度(可选值:`-90` / `-45` / `0` / `45` / `90`) | | `--y-axis-label-angle` | int | optional | 左 Y 轴标签旋转角度(可选值:`-90` / `-45` / `0` / `45` / `90`) | -| `--data-labels` | string | optional | 数据标签内容;value、category、percentage 可按 value_category_percentage 顺序组成任意非空组合;series 显示系列名称,none 隐藏标签(可选值:`none` / `value` / `category` / `percentage` / `value_category` / `value_percentage` / `category_percentage` / `value_category_percentage` / `series`) | +| `--data-labels` | string | optional | 数据标签内容;普通基础图默认传 value,数据点较多、系列较多或标签容易重叠时可省略;value、category、percentage 可按 value_category_percentage 顺序组成任意非空组合;series 显示系列名称,none 隐藏标签(可选值:`none` / `value` / `category` / `percentage` / `value_category` / `value_percentage` / `category_percentage` / `value_category_percentage` / `series`) | | `--data-label-position` | string | optional | 仅当用户明确指定时传入;只调整已有数据标签的位置,不会单独开启标签;省略时按图表类型自动优化数据标签位置(可选值:`auto` / `top` / `bottom` / `left` / `right` / `center` / `inside` / `outside`) | | `--stack` | string | optional | 堆叠模式(可选值:`none` / `normal` / `percent`) | | `--stacked` | bool | optional | 兼容别名;等价于 --stack normal(隐藏 flag:不在 `--help` 列出,但可正常传入) | -| `--smooth` | bool | optional | 是否使用平滑曲线;支持 --smooth=false 和 --smooth false | +| `--smooth` | bool | optional | 是否使用平滑曲线;显式关闭使用 --smooth=false | | `--color-palette` | string | optional | 预设整图配色主题;与 --colors 互斥(可选值:`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`) | | `--colors` | string_slice | optional | 自定义整图系列颜色,逗号分隔且至少 2 个十六进制色值;与 --color-palette 互斥 | | `--anchor-cell` | string | optional | 可选图表锚点单元格,如 F2;省略时放到数据范围右侧 | -| `--width` | int | optional | 可选图表宽度;必须与 --height 同时传 | +| `--width` | int | optional | 可选图表宽度;必须与 --height 同时传;饼图 / 环形图及长类别标签场景应适量加宽以避免截断 | | `--height` | int | optional | 可选图表高度;必须与 --width 同时传 | ### `+chart-config-update` @@ -223,14 +251,14 @@ _公共四件套 · 系统:`--dry-run`_ | `--y-axis-label-angle` | int | optional | 左 Y 轴标签旋转角度(可选值:`-90` / `-45` / `0` / `45` / `90`) | | `--x-axis-min` | float64 | optional | 连续数值 X 轴的显示范围下界;必须小于 --x-axis-max | | `--x-axis-max` | float64 | optional | 连续数值 X 轴的显示范围上界;必须大于 --x-axis-min | -| `--y-axis-min` | float64 | optional | 左 Y 轴的显示范围下界;必须小于 --y-axis-max | -| `--y-axis-max` | float64 | optional | 左 Y 轴的显示范围上界;必须大于 --y-axis-min | +| `--y-axis-min` | float64 | optional | 左 Y 轴的显示范围下界;默认省略,仅在用户明确要求固定范围时传;不得直接使用数据源单列最小值,且必须小于 --y-axis-max | +| `--y-axis-max` | float64 | optional | 左 Y 轴的显示范围上界;默认省略,仅在用户明确要求固定范围时传;须按图表实际绘制值计算,且必须大于 --y-axis-min | | `--data-labels` | string | optional | 数据标签内容;value、category、percentage 可按 value_category_percentage 顺序组成任意非空组合;series 显示系列名称,none 隐藏标签(可选值:`none` / `value` / `category` / `percentage` / `value_category` / `value_percentage` / `category_percentage` / `value_category_percentage` / `series`) | | `--data-label-position` | string | optional | 仅当用户明确指定时传入;只调整已有数据标签的位置,不会单独开启标签;省略时按图表类型自动优化数据标签位置(可选值:`auto` / `top` / `bottom` / `left` / `right` / `center` / `inside` / `outside`) | -| `--last-point-label` | bool | optional | 仅折线图、面积图、雷达图及组合图中的线性系列;true 开启每个系列最后一个数据点的数值标签,false 关闭这些单点标签 | +| `--aggregate-categories` | bool | optional | 是否汇总相同类别;稀疏标点或需要保留逐行数据点时使用 --aggregate-categories=false,省略时保留当前设置 | | `--stack` | string | optional | 堆叠模式(可选值:`none` / `normal` / `percent`) | | `--stacked` | bool | optional | 兼容别名;等价于 --stack normal(隐藏 flag:不在 `--help` 列出,但可正常传入) | -| `--smooth` | bool | optional | 是否使用平滑曲线;支持 --smooth=false 和 --smooth false | +| `--smooth` | bool | optional | 是否使用平滑曲线;显式关闭使用 --smooth=false | | `--color-palette` | string | optional | 预设整图配色主题;与 --colors 互斥(可选值:`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`) | | `--colors` | string_slice | optional | 自定义整图系列颜色,逗号分隔且至少 2 个十六进制色值;与 --color-palette 互斥 | @@ -290,7 +318,6 @@ _创建/更新的图表属性_ - `position` (object?) — 必填 { row: number, col: string } - `offset` (object?) — 可选 { row_offset?: number, col_offset?: number } - `size` (object?) — 必填 { width: number, height: number } -- `last_point_label` (boolean?) — update 使用 - `snapshot` (oneOf?) — 图表快照配置 ## Examples @@ -303,7 +330,7 @@ _创建/更新的图表属性_ ### `+chart-create-basic` -默认使用第 1 个维度作为类别/X 轴,其余维度作为数值系列;普通图表可用 1-based 的 `--dim1-index` 和逗号分隔的 `--dim2-indexes` 精确选择。组合图默认首个数值系列为左轴柱、其余为右轴折线;需要其它组合时,用 `--series-types` 和 `--series-y-axes` 按 `--dim2-indexes` 的顺序逐项指定系列类型与左右轴,两组参数的数量都必须与最终数值系列数一致。横轴数字默认按等间距文本类别处理;只有数字之间的真实间距需要影响图形位置时,才传 `--x-axis-numbers-as values` 使用连续数轴。气泡图改用 `--key-index`、`--x-index`、`--y-index` 和可选的 `--group-index` / `--size-index`,其中 x/y 必须同时提供,key 默认 1;角色索引不能与 dim1/dim2 索引混用。旧气泡图的 dim1/dim2 位置调用仍兼容。饼图和排列图只允许一个数值系列;组合图至少需要两个数值系列;所有图表最多选择 50 个数值系列。默认让 `--data-range` 包含真实表头;只有“维度/系列名称”与纯数据分离时,才让 `--data-range` 只传纯数据,并用 `--header-range` 传对应的一行(column)或一列(row)表头。类别维度与数值维度不连续时,范围参数可传逗号分隔的多范围,也支持来自多个子表;沿数据点轴对齐的跨子表范围会保留独立引用,同一子表内错行、错列或重叠时合并为最小包围矩形,跨子表范围无法对齐时会报错。单独调用成功后返回完整 `snapshot`,可直接检查创建结果并继续修改。参数名使用 `--anchor-cell` 和 `--data-labels`。兼容调用中,`--type` / `--range` 会分别按 `--chart-type` / `--data-range` 处理,`--x-axis` / `--y-axis` 会按轴标题处理;新调用仍优先使用规范参数名。 +默认使用第 1 个维度作为类别/X 轴,其余维度作为数值系列;普通图表可用 1-based 的 `--dim1-index` 和逗号分隔的 `--dim2-indexes` 精确选择。组合图默认首个数值系列为左轴柱、其余为右轴折线;创建前仍要比较各系列单位和量级,避免折线或小量级系列因共用左轴而贴近 X 轴。需要其它组合时,用 `--series-types` 和 `--series-y-axes` 按 `--dim2-indexes` 的顺序逐项指定系列类型与左右轴;系列类型可选 `column`、`line`、`area`、`scatter`,两组参数的数量都必须与最终数值系列数一致。横轴数字默认按等间距文本类别处理;只有数字之间的真实间距需要影响图形位置时,才传 `--x-axis-numbers-as values` 使用连续数轴。气泡图改用 `--key-index`、`--x-index`、`--y-index` 和可选的 `--group-index` / `--size-index`,其中 x/y 必须同时提供,key 默认 1;角色索引不能与 dim1/dim2 索引混用。旧气泡图的 dim1/dim2 位置调用仍兼容。饼图和排列图只允许一个数值系列;组合图至少需要两个数值系列;所有图表最多选择 50 个数值系列。饼图默认将图例放在底部,并根据类别标签长度适量增加 `--width`(同时传 `--height`)。默认让 `--data-range` 包含真实表头;只有“维度/系列名称”与纯数据分离时,才让 `--data-range` 只传纯数据,并用 `--header-range` 传对应的一行(column)或一列(row)表头。类别维度与数值维度不连续时,范围参数可传逗号分隔的多范围,也支持来自多个子表;沿数据点轴对齐的跨子表范围会保留独立引用,同一子表内错行、错列或重叠时合并为最小包围矩形,跨子表范围无法对齐时会报错。单独调用成功后返回完整 `snapshot`,可直接检查创建结果并继续修改。参数名使用 `--anchor-cell` 和 `--data-labels`。兼容调用中,`--type` / `--range` 会分别按 `--chart-type` / `--data-range` 处理,`--x-axis` / `--y-axis` 会按轴标题处理;新调用仍优先使用规范参数名。 **连续数值 X 轴的可读性**:`--x-axis-numbers-as values` 会保留数字的真实间距,但未指定范围时可能自动包含 0。如果数据集中在远离 0 的窄区间,数据点会挤在图表一侧;此时应保留 `values`,创建时用 `--x-axis-min` / `--x-axis-max` 收紧范围,已有图表用 `+chart-config-update` 修正,不要改成 `text` 掩盖问题。两个边界可单独设置;同时设置时 min 必须小于 max。 @@ -322,6 +349,18 @@ lark-cli sheets +chart-create-basic --url "..." --sheet-name "Sheet1" \ --title "价格与效率" --y-axis-title "价格" --secondary-y-axis-title "效率" \ --anchor-cell F2 --width 700 --height 400 +# 辅助线只显示一个标签:C 列为重复目标值,D 列仅目标位置有值、其余单元格为空 +lark-cli sheets +chart-create-basic --url "..." --sheet-name "Sheet1" \ + --chart-type combo --data-range "'Sheet1'!A1:D7" \ + --dim1-index 1 --dim2-indexes 2,3,4 \ + --series-types line,line,scatter --series-y-axes left,left,left \ + --aggregate-categories=false \ + --title "趋势与目标线" --anchor-cell F2 --width 700 --height 400 + +# 先从创建结果或 +chart-list 取得完整 series 数组,再整段回写;辅助线系列不设置 labels +lark-cli sheets +chart-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \ + --properties '{"snapshot":{"plotArea":{"plot":{"series":[{"index":2,"comboType":"line","labels":{"value":true}},{"index":3,"comboType":"line"},{"index":4,"comboType":"scatter","labels":{"value":true}}]}}}}' + # 气泡图:x、y 必填,group、size 可选 lark-cli sheets +chart-create-basic --url "..." --sheet-name "Sheet1" \ --chart-type bubble --data-range "'Sheet1'!A1:E20" \ @@ -399,17 +438,15 @@ lark-cli sheets +chart-data-update --url "..." --sheet-id "$SID" --chart-id "chr ### `+chart-config-update` -只传需要改的字段,成功后返回更新后的 `viewModel`。`--data-labels` 支持 `value`、`category`、`percentage` 的任意非空组合,组合值按 `value_category_percentage` 顺序拼接;另可用 `series` 显示系列名称、用 `none` 删除数据标签。折线图、面积图、雷达图及组合图中的线性系列可用 `--last-point-label=true` 只开启每个系列最后一个数据点的数值标签,传 `false` 关闭这些单点标签。`--legend-position hidden` 隐藏图例;`--smooth=false` 和 `--smooth false` 都可显式关闭平滑曲线。为减少参数重试,`--stacked` 自动按 `--stack normal` 处理,`percentage,value` 或 `value,percentage` 自动按 `value_percentage` 处理,`--x-axis` / `--y-axis` 自动按 `--x-axis-title` / `--y-axis-title` 处理;新调用仍优先使用规范参数。 +只传需要改的字段,成功后返回更新后的 `viewModel`。`--data-labels` 支持 `value`、`category`、`percentage` 的任意非空组合,组合值按 `value_category_percentage` 顺序拼接;另可用 `series` 显示系列名称、用 `none` 删除数据标签。多个系列需要不同标签策略时不要使用这个全局参数,按上文的辅助列与高级系列配置流程处理。`--legend-position hidden` 隐藏图例;显式关闭平滑曲线时使用 `--smooth=false`。为减少参数重试,`--stacked` 自动按 `--stack normal` 处理,`percentage,value` 或 `value,percentage` 自动按 `value_percentage` 处理,`--x-axis` / `--y-axis` 自动按 `--x-axis-title` / `--y-axis-title` 处理;新调用仍优先使用规范参数。 ```bash lark-cli sheets +chart-config-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \ --title "新标题" --x-axis-label-angle -45 --legend-position right lark-cli sheets +chart-config-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \ - --data-labels value_percentage --stack percent + --data-labels value_percentage --stack percent --aggregate-categories=false -lark-cli sheets +chart-config-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \ - --last-point-label=true ``` ### `+chart-create` @@ -418,7 +455,7 @@ lark-cli sheets +chart-config-update --url "..." --sheet-id "$SID" --chart-id "c ### `+chart-update` -标题、轴、图例、标签、堆叠、平滑、配色优先使用 `+chart-config-update`,数据范围和方向使用 `+chart-data-update`。只有高级字段才使用 `+chart-update`;不要为常见修改构造 raw properties。 +标题、轴、图例、标签、堆叠、平滑、配色和相同类别汇总优先使用 `+chart-config-update`,数据范围和方向使用 `+chart-data-update`。只有高级字段才使用 `+chart-update`;不要为常见修改构造 raw properties。 `+chart-update` 支持真正的局部更新:只传实际变化的字段,未传字段保持不变,不要复制并回写完整 snapshot。 diff --git a/skills/lark-sheets/references/lark-sheets-read-data.md b/skills/lark-sheets/references/lark-sheets-read-data.md index a1f5334713..6881e651fb 100644 --- a/skills/lark-sheets/references/lark-sheets-read-data.md +++ b/skills/lark-sheets/references/lark-sheets-read-data.md @@ -165,7 +165,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--range` | string | required | A1 范围,如 `A1:F10`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet) | -| `--include` | string_slice | optional | 要返回的信息类别,逗号分隔多个。`truncation` 会额外按行高列宽 / 字号 / 自动换行估算每个单元格是否被截断显示,返回 `isRowTruncated` / `isColTruncated`(有额外计算开销,仅排版检查 / 调整行高列宽前才开)(可选值:`value` / `formula` / `style` / `comment` / `data_validation` / `truncation`) | +| `--include` | string_slice | optional | 要返回的信息类别,逗号分隔多个。`raw_value` 返回单元格原始值或公式计算结果原值,保留 number / string 类型,与 `formula` 互斥。`truncation` 会额外按行高列宽 / 字号 / 自动换行估算每个单元格是否被截断显示,返回 `isRowTruncated` / `isColTruncated`(有额外计算开销,仅排版检查 / 调整行高列宽前才开)(可选值:`value` / `formula` / `raw_value` / `style` / `comment` / `data_validation` / `truncation`) | | `--max-chars` | int | optional | 单次返回字符上限,默认 500000(兜底防爆)。要整表无截断直接用 --output-path 落盘(上限自动放宽到 2000 万字符——读取链路非流式,此上限是内存保护;更大就显式给 --max-chars);仅当要让结果直接进上下文、又不落盘时才调小(如 25000),按 has_more 分页。 传 0 表示「不自设上限」,等价于不传(仍是 500000 / 落盘时 2000 万),不会退回底层工具那个更小的默认截断。 | | `--output-path` | string | optional | 把完整读取结果写入本地路径(如 `./out.json`),文件内容为 data 载荷的 JSON;stdout 只回一个含 output_path/字节数的确认信息。**一旦设置,字符上限自动放宽到有界的 2000 万字符**(覆盖 --max-chars 默认),并非无限——读取链路非流式,该上限是内存保护;显式 --max-chars 优先。stdout 回执带 `complete` 字段(命中上限时另有 `truncated` 与提示),据此判断文件是否完整,不要默认整表已落全。省略时按常规把结果打到 stdout。 | | `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` | diff --git a/skills/lark-sheets/references/lark-sheets-visual-standards.md b/skills/lark-sheets/references/lark-sheets-visual-standards.md index 3b06e023a4..34f133ffca 100644 --- a/skills/lark-sheets/references/lark-sheets-visual-standards.md +++ b/skills/lark-sheets/references/lark-sheets-visual-standards.md @@ -81,8 +81,11 @@ ### 6. 图表展示 - 遵循用户指令选择图表类型,或匹配用户意图(饼图/环形图 → 占比,折线图 → 趋势)。 -- 包含必要元素:标题、坐标轴标题、多系列图例;数据标签按需(关键点 / 末值标注即可,不必每个数据点都加)。 -- 调整至合适大小,避免数据和标签过多堆叠。 +- 包含必要元素:标题、坐标轴标题、多系列图例;普通基础图仅在尺寸建议器未提示标签密集时默认开启数值标签,密集时只保留关键点、末值、异常或用户要求的值;目标线等常量系列不显示逐点重复标签。 +- Y 轴显示范围默认交给图表引擎,不按数据源单列的最小值 / 最大值主动设限;组合图先比较系列单位和量级,把会被压扁的系列放到右轴。 +- 饼图 / 环形图默认将图例放在底部;尺寸建议器保持相对固定的饼区,主要按最长标签增加两侧留白。类别过多或数值高度偏斜时优先 Top-N 或条形图,避免靠无限加宽解决。 +- 创建前运行 `scripts/lark_chart_size_advisor.py`,使用其 `create_flags`;若提示仅放大无法解决,则改用条形图、Top-N 或拆图。创建后运行 `scripts/lark_chart_quality_check.py`。 +- 优先继承原表配色,同一指标跨图保持同色;组合图使用同色系柱形和高对比折线,辅助系列使用中性色。分类色过多时优先精简数据,不依靠更多相近颜色区分。 - **图表放置防重叠**:新增图表前须计算放置区域,避免与已有图表重叠。具体步骤: 1. 调用 `+chart-list` 获取当前工作表所有已有图表的 `position`(锚点单元格:`col` 是列字母如 "A"/"B"、`row` 是 1-based 行号;以 `+chart-list` 实际返回字段为准)、`offset`(锚点内偏移:`row_offset`、`col_offset`,单位像素)以及 `size`(`width`、`height`,单位像素)。 2. 获取工作表的行高和列宽信息(像素)。 diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index a252a82943..c1f06b57da 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -321,7 +321,7 @@ _公共四件套 · 系统:`--dry-run`_ | `--colors` | string + File + Stdin(简单 JSON) | optional | 下拉胶囊背景色,RGB hex 数组(如 `["#1FB6C1","#F006C2"]`)。长度可短不可长——超长 Validate 拦截(`--colors length (N) must not exceed dropdown source size (M)`),未指定项按内置 10 色色板循环补色。**单独传即生效**;`--highlight=false` 时被忽略。 | | `--multiple` | bool | optional | 启用多选;默认 `false` | | `--highlight` | bool | optional | 下拉胶囊背景色高亮开关。**不传 = 开**(按内置 10 色色板循环上色);`--highlight=false` 关闭得到纯白下拉。配色用 `--colors` 覆盖。 | -| `--source-range` | string | xor | listFromRange 模式的下拉源 range,A1 表示法 + sheet 前缀(如 `'Sheet1'!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表(type=list),传本 flag 走 range 引用(type=listFromRange)。`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。当 `--highlight` 开启且 source 覆盖单元格数超过 2000 时,服务端会将该下拉判为 option-error(这是不支持的组合);CLI 会向 stderr 输出 warning。如需取消,传 `--highlight=false`。 | +| `--source-range` | string | xor | listFromRange 模式的下拉源 range,A1 表示法 + sheet 前缀(如 `'Sheet1'!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表(type=list),传本 flag 走 range 引用(type=listFromRange)。`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。当 `--highlight` 开启且 source 覆盖单元格数超过 2000 时,服务端会将该下拉判为 option-error(这是不支持的组合);CLI 会在返回结果的 `data.warnings` 中给出 warning。如需取消,传 `--highlight=false`。 | ### `+csv-put` diff --git a/skills/lark-sheets/scripts/lark_chart_layout_check.py b/skills/lark-sheets/scripts/lark_chart_layout_check.py deleted file mode 100644 index 526c9407aa..0000000000 --- a/skills/lark-sheets/scripts/lark_chart_layout_check.py +++ /dev/null @@ -1,472 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) 2026 Lark Technologies Pte. Ltd. -# SPDX-License-Identifier: MIT -"""Check whether Lark Sheet charts have obvious placement problems. - -The single required argument is a spreadsheet URL or spreadsheet token. By -default every worksheet is checked; pass --worksheet-id to restrict the check -to one worksheet reference_id. - -Exit codes: - 0: check completed and no layout issue was found - 1: the check could not be completed (CLI/read/response error) - 2: check completed and at least one layout issue was found -""" - -from __future__ import annotations - -import argparse -import json -from typing import Any - -from lark_sheet_read_cli import ( - LarkCliError, - emit_error, - envelope_data, - resolve_target_sheets, - run_sheets, - sheet_identifier, - sheet_title, -) - -ACTION = "chart_layout_check" -DEFAULT_COLUMN_WIDTH = 105.0 -DEFAULT_ROW_HEIGHT = 27.0 - - -def column_to_index(column: str) -> int: - value = 0 - text = str(column).strip().upper() - if not text or not text.isalpha(): - raise ValueError(f"Invalid column: {column!r}") - for char in text: - value = value * 26 + ord(char) - ord("A") + 1 - return value - 1 - - -def index_to_column(index: int) -> str: - if index < 0: - raise ValueError(f"Invalid column index: {index}") - chars: list[str] = [] - value = index + 1 - while value: - value, remainder = divmod(value - 1, 26) - chars.append(chr(ord("A") + remainder)) - return "".join(reversed(chars)) - - -def _span_bounds(span: str, *, columns: bool) -> tuple[int, int]: - start, separator, end = str(span).partition(":") - end = end if separator else start - if columns: - return column_to_index(start), column_to_index(end) - return int(start) - 1, int(end) - 1 - - -def _size_edges( - groups: Any, - *, - count: int, - span_key: str, - size_key: str, - columns: bool, - default_size: float, -) -> tuple[list[float], bool]: - sizes: list[float | None] = [None] * count - if isinstance(groups, list): - for group in groups: - if not isinstance(group, dict) or group.get(span_key) is None: - continue - start, end = _span_bounds(str(group[span_key]), columns=columns) - size = float(group.get(size_key, default_size)) - for index in range(max(0, start), min(count - 1, end) + 1): - sizes[index] = max(0.0, size) - - used_default = any(size is None for size in sizes) - resolved = [default_size if size is None else size for size in sizes] - edges = [0.0] - for size in resolved: - edges.append(edges[-1] + size) - return edges, used_default - - -def build_layout( - structure: dict[str, Any], row_count: int, column_count: int -) -> tuple[list[float], list[float], list[str]]: - row_groups = structure.get("row_heights") - column_groups = structure.get("col_widths", structure.get("column_widths")) - row_edges, row_defaulted = _size_edges( - row_groups, - count=row_count, - span_key="rows", - size_key="height", - columns=False, - default_size=DEFAULT_ROW_HEIGHT, - ) - column_edges, column_defaulted = _size_edges( - column_groups, - count=column_count, - span_key="cols", - size_key="width", - columns=True, - default_size=DEFAULT_COLUMN_WIDTH, - ) - warnings: list[str] = [] - if row_defaulted: - warnings.append("部分行缺少高度信息,按 27 px 估算") - if column_defaulted: - warnings.append("部分列缺少宽度信息,按 105 px 估算") - return row_edges, column_edges, warnings - - -def _first_dict(value: Any) -> dict[str, Any] | None: - if isinstance(value, dict): - return value - if isinstance(value, list): - return next((item for item in value if isinstance(item, dict)), None) - return None - - -def extract_sheet_structure(data: dict[str, Any]) -> dict[str, Any]: - sheet = _first_dict(data.get("sheets")) or _first_dict(data.get("sheet")) - return sheet or data - - -def extract_charts(data: dict[str, Any], sheet_id: str, title: str) -> list[dict[str, Any]]: - sheets = data.get("sheets") - if isinstance(sheets, list): - for sheet in sheets: - if not isinstance(sheet, dict): - continue - if sheet_identifier(sheet) == sheet_id or sheet_title(sheet) == title: - charts = sheet.get("charts") - return [chart for chart in charts if isinstance(chart, dict)] if isinstance(charts, list) else [] - charts = data.get("charts") - return [chart for chart in charts if isinstance(chart, dict)] if isinstance(charts, list) else [] - - -def chart_rectangle( - chart: dict[str, Any], row_edges: list[float], column_edges: list[float] -) -> dict[str, Any]: - details = chart.get("details") if isinstance(chart.get("details"), dict) else chart - position = details.get("position") if isinstance(details.get("position"), dict) else {} - offset = details.get("offset") if isinstance(details.get("offset"), dict) else {} - size = details.get("size") if isinstance(details.get("size"), dict) else {} - - row = int(position["row"]) - column = column_to_index(str(position["col"])) - if row < 0 or column < 0 or row >= len(row_edges) - 1 or column >= len(column_edges) - 1: - raise ValueError(f"anchor outside sheet: {position!r}") - - width = float(size["width"]) - height = float(size["height"]) - if width <= 0 or height <= 0: - raise ValueError(f"invalid chart size: {size!r}") - - left = column_edges[column] + float(offset.get("col_offset", 0) or 0) - top = row_edges[row] + float(offset.get("row_offset", 0) or 0) - return { - "chart_id": str(chart.get("chart_id") or chart.get("id") or ""), - "anchor_cell": f"{index_to_column(column)}{row + 1}", - "left": left, - "top": top, - "right": left + width, - "bottom": top + height, - "width": width, - "height": height, - } - - -def intersection(first: dict[str, Any], second: dict[str, Any]) -> dict[str, float] | None: - left = max(float(first["left"]), float(second["left"])) - top = max(float(first["top"]), float(second["top"])) - right = min(float(first["right"]), float(second["right"])) - bottom = min(float(first["bottom"]), float(second["bottom"])) - if right <= left or bottom <= top: - return None - return { - "width": round(right - left, 2), - "height": round(bottom - top, 2), - "area": round((right - left) * (bottom - top), 2), - } - - -def chart_context(rectangle: dict[str, Any]) -> dict[str, Any]: - return { - "chart_id": rectangle["chart_id"], - "anchor_cell": rectangle["anchor_cell"], - "rectangle_px": { - "left": round(rectangle["left"], 2), - "top": round(rectangle["top"], 2), - "right": round(rectangle["right"], 2), - "bottom": round(rectangle["bottom"], 2), - "width": round(rectangle["width"], 2), - "height": round(rectangle["height"], 2), - }, - } - - -def _covered_indexes(edges: list[float], start: float, end: float) -> list[int]: - return [ - index - for index in range(len(edges) - 1) - if edges[index + 1] > start and edges[index] < end - ] - - -def rectangle_cell_range( - rectangle: dict[str, Any], row_edges: list[float], column_edges: list[float] -) -> str | None: - rows = _covered_indexes(row_edges, max(0.0, rectangle["top"]), rectangle["bottom"]) - columns = _covered_indexes(column_edges, max(0.0, rectangle["left"]), rectangle["right"]) - if not rows or not columns: - return None - return f"{index_to_column(columns[0])}{rows[0] + 1}:{index_to_column(columns[-1])}{rows[-1] + 1}" - - -def _has_content(cell: Any) -> bool: - if not isinstance(cell, dict): - return False - for key in ("value", "formula", "note"): - value = cell.get(key) - if value not in (None, ""): - return True - return bool(cell.get("rich_text") or cell.get("multiple_values")) - - -def non_empty_cells(data: dict[str, Any], sample_limit: int) -> tuple[int, list[str], bool]: - count = 0 - samples: list[str] = [] - truncated = bool(data.get("has_more")) - ranges = data.get("ranges") - if not isinstance(ranges, list): - return 0, [], truncated - for result_range in ranges: - if not isinstance(result_range, dict): - continue - truncated = truncated or bool(result_range.get("truncated")) - cells = result_range.get("cells") - rows = result_range.get("row_indices") - columns = result_range.get("col_indices") - if not isinstance(cells, list): - continue - for row_offset, row in enumerate(cells): - if not isinstance(row, list): - continue - row_number = rows[row_offset] if isinstance(rows, list) and row_offset < len(rows) else row_offset + 1 - for column_offset, cell in enumerate(row): - if not _has_content(cell): - continue - count += 1 - if len(samples) < sample_limit: - column = columns[column_offset] if isinstance(columns, list) and column_offset < len(columns) else index_to_column(column_offset) - samples.append(f"{column}{row_number}") - return count, samples, truncated - - -def _locator(target: str) -> dict[str, str]: - return {"url": target} if target.startswith(("http://", "https://")) else {"spreadsheet_token": target} - - -def _sheet_counts(sheet: dict[str, Any]) -> tuple[int, int]: - row_count = int(sheet.get("row_count") or sheet.get("rowCount") or 0) - column_count = int(sheet.get("column_count") or sheet.get("columnCount") or 0) - if row_count <= 0 or column_count <= 0: - raise LarkCliError(f"Missing row_count/column_count for sheet {sheet_title(sheet)!r}") - return row_count, column_count - - -def check_sheet( - locator: dict[str, str], sheet: dict[str, Any], *, timeout: int, sample_limit: int -) -> dict[str, Any]: - sheet_id = sheet_identifier(sheet) - title = sheet_title(sheet) - row_count, column_count = _sheet_counts(sheet) - if not sheet_id: - raise LarkCliError(f"Missing sheet_id for sheet {title!r}") - - structure_data = envelope_data( - run_sheets( - "+sheet-info", - **locator, - sheet_id=sheet_id, - flags={"include": "row_heights,col_widths"}, - timeout=timeout, - ) - ) - row_edges, column_edges, warnings = build_layout( - extract_sheet_structure(structure_data), row_count, column_count - ) - chart_data = envelope_data( - run_sheets("+chart-list", **locator, sheet_id=sheet_id, timeout=timeout) - ) - charts = extract_charts(chart_data, sheet_id, title) - - rectangles: list[dict[str, Any]] = [] - unverifiable: list[dict[str, str]] = [] - expected_chart_count = sheet.get("chart_count") - if expected_chart_count is not None and int(expected_chart_count) != len(charts): - unverifiable.append( - { - "chart_id": "", - "reason": ( - f"chart-list returned {len(charts)} charts, " - f"but workbook-info reported {int(expected_chart_count)}" - ), - } - ) - for chart in charts: - chart_id = str(chart.get("chart_id") or chart.get("id") or "") - if not chart_id: - unverifiable.append({"chart_id": "", "reason": "chart is missing chart_id"}) - continue - try: - rectangles.append(chart_rectangle(chart, row_edges, column_edges)) - except (KeyError, TypeError, ValueError) as exc: - unverifiable.append({"chart_id": chart_id, "reason": str(exc)}) - - overlaps: list[dict[str, Any]] = [] - for index, first in enumerate(rectangles): - for second in rectangles[index + 1 :]: - overlap = intersection(first, second) - if overlap: - overlaps.append( - { - "chart_ids": [first["chart_id"], second["chart_id"]], - "charts": [chart_context(first), chart_context(second)], - "intersection": overlap, - } - ) - - sheet_width = column_edges[-1] - sheet_height = row_edges[-1] - out_of_bounds: list[dict[str, Any]] = [] - content_overlaps: list[dict[str, Any]] = [] - for rectangle in rectangles: - overflow = { - "left": round(max(0.0, -rectangle["left"]), 2), - "top": round(max(0.0, -rectangle["top"]), 2), - "right": round(max(0.0, rectangle["right"] - sheet_width), 2), - "bottom": round(max(0.0, rectangle["bottom"] - sheet_height), 2), - } - if any(overflow.values()): - out_of_bounds.append({**chart_context(rectangle), "overflow_px": overflow}) - - covered_range = rectangle_cell_range(rectangle, row_edges, column_edges) - if not covered_range: - continue - cells_data = envelope_data( - run_sheets( - "+cells-get", - **locator, - sheet_id=sheet_id, - flags={"range": covered_range, "include": "value,formula,comment"}, - timeout=timeout, - ) - ) - count, samples, truncated = non_empty_cells(cells_data, sample_limit) - if truncated: - unverifiable.append( - {"chart_id": rectangle["chart_id"], "reason": f"cells-get truncated for {covered_range}"} - ) - if count: - content_overlaps.append( - { - **chart_context(rectangle), - "covered_range": covered_range, - "non_empty_cell_count": count, - "sample_cells": samples, - } - ) - - issue_count = len(overlaps) + len(out_of_bounds) + len(content_overlaps) - return { - "sheet_id": sheet_id, - "sheet_name": title, - "chart_count": len(charts), - "sheet_size_px": {"width": round(sheet_width, 2), "height": round(sheet_height, 2)}, - "chart_overlaps": overlaps, - "cell_content_overlaps": content_overlaps, - "out_of_visible_range": out_of_bounds, - "unverifiable_charts": unverifiable, - "issue_count": issue_count, - "unverifiable_count": len(unverifiable), - "warnings": warnings, - } - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Check chart overlap, covered cell content, and worksheet boundary overflow." - ) - parser.add_argument("sheet_id", help="Spreadsheet URL or spreadsheet token") - parser.add_argument("--worksheet-id", help="Only check this worksheet reference_id") - parser.add_argument("--timeout", type=int, default=60) - parser.add_argument("--sample-limit", type=int, default=10) - return parser.parse_args() - - -def success_envelope(results: list[dict[str, Any]]) -> dict[str, Any]: - issue_count = sum(result["issue_count"] for result in results) - unverifiable_count = sum(result["unverifiable_count"] for result in results) - warnings = [ - f"{result['sheet_name'] or result['sheet_id']}: {warning}" - for result in results - for warning in result["warnings"] - ] - return { - "ok": True, - "engine": "lark", - "action": ACTION, - "data": { - "passed": issue_count == 0 and unverifiable_count == 0, - "scope_note": "out_of_visible_range checks worksheet drawable bounds, not a device-specific browser viewport", - "summary": { - "worksheet_count": len(results), - "chart_count": sum(result["chart_count"] for result in results), - "issue_count": issue_count, - "unverifiable_count": unverifiable_count, - }, - "sheets": results, - }, - "warnings": warnings, - } - - -def report_exit_code(report: dict[str, Any]) -> int: - if report["data"]["passed"]: - return 0 - if report["data"]["summary"]["issue_count"] > 0: - return 2 - return 1 - - -def main() -> None: - args = parse_args() - locator = _locator(args.sheet_id) - try: - workbook_data = envelope_data( - run_sheets("+workbook-info", **locator, timeout=args.timeout) - ) - sheets = resolve_target_sheets(workbook_data, sheet_id=args.worksheet_id) - if not args.worksheet_id: - sheets = [sheet for sheet in sheets if not bool(sheet.get("is_hidden"))] - if not sheets: - raise LarkCliError("No visible worksheet matched") - results = [ - check_sheet(locator, sheet, timeout=args.timeout, sample_limit=args.sample_limit) - for sheet in sheets - ] - except (LarkCliError, KeyError, TypeError, ValueError) as exc: - emit_error(ACTION, str(exc)) - raise SystemExit(1) from exc - - report = success_envelope(results) - print(json.dumps(report, ensure_ascii=False, indent=2)) - exit_code = report_exit_code(report) - if exit_code: - raise SystemExit(exit_code) - - -if __name__ == "__main__": - main() diff --git a/skills/lark-sheets/scripts/lark_chart_quality_check.py b/skills/lark-sheets/scripts/lark_chart_quality_check.py new file mode 100644 index 0000000000..639bba74a5 --- /dev/null +++ b/skills/lark-sheets/scripts/lark_chart_quality_check.py @@ -0,0 +1,1243 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +"""Check Lark Sheet chart quality, placement, and numeric source-data issues. + +The single required argument is a spreadsheet URL or spreadsheet token. By +default every worksheet is checked; pass --worksheet-id to restrict the check +to one worksheet reference_id. + +Exit codes: + 0: check completed and no issue was found + 1: the check could not be completed (CLI/read/response error) + 2: check completed and at least one chart-quality issue was found +""" + +from __future__ import annotations + +import argparse +import json +import re +from typing import Any + +from lark_sheet_read_cli import ( + LarkCliError, + emit_error, + envelope_data, + resolve_target_sheets, + run_sheets, + sheet_identifier, + sheet_title, +) +from lark_chart_size_rules import dense_data_labels, minimum_chart_size + +ACTION = "chart_quality_check" +DEFAULT_COLUMN_WIDTH = 105.0 +DEFAULT_ROW_HEIGHT = 27.0 +MAX_CELL_READ_SIZE = 2_000 +MAX_SOURCE_SAMPLE_POINTS = 50 +MAX_ZERO_SCAN_CELLS = 10_000 + + +CellBounds = tuple[int, int, int, int] +CellCache = dict[tuple[str, str, str], dict[str, Any]] +SeriesProfile = dict[str, Any] + + +def _parse_a1_bounds(cell_range: str) -> CellBounds: + value = str(cell_range).rsplit("!", 1)[-1].replace("$", "") + match = re.fullmatch(r"([A-Za-z]+)(\d+)(?::([A-Za-z]+)(\d+))?", value) + if not match: + raise ValueError(f"Invalid A1 range: {cell_range!r}") + start_column = column_to_index(match.group(1)) + start_row = int(match.group(2)) + end_column = column_to_index(match.group(3) or match.group(1)) + end_row = int(match.group(4) or match.group(2)) + if end_row < start_row or end_column < start_column: + raise ValueError(f"Invalid A1 range: {cell_range!r}") + return start_row, end_row, start_column, end_column + + +def _format_a1_bounds(bounds: CellBounds) -> str: + start_row, end_row, start_column, end_column = bounds + return ( + f"{index_to_column(start_column)}{start_row}:" + f"{index_to_column(end_column)}{end_row}" + ) + + +def _bounds_area(bounds: CellBounds) -> int: + start_row, end_row, start_column, end_column = bounds + return (end_row - start_row + 1) * (end_column - start_column + 1) + + +def _merge_bounds(first: CellBounds, second: CellBounds) -> CellBounds: + return ( + min(first[0], second[0]), + max(first[1], second[1]), + min(first[2], second[2]), + max(first[3], second[3]), + ) + + +def _cluster_cell_reads( + items: list[tuple[dict[str, Any], CellBounds]], +) -> list[dict[str, Any]]: + clusters: list[dict[str, Any]] = [] + for rectangle, bounds in sorted(items, key=lambda item: (item[1][0], item[1][2])): + if clusters: + merged = _merge_bounds(clusters[-1]["bounds"], bounds) + if _bounds_area(merged) <= MAX_CELL_READ_SIZE: + clusters[-1]["bounds"] = merged + clusters[-1]["members"].append((rectangle, bounds)) + continue + clusters.append({"bounds": bounds, "members": [(rectangle, bounds)]}) + return clusters + + +def column_to_index(column: str) -> int: + value = 0 + text = str(column).strip().upper() + if not text or not text.isalpha(): + raise ValueError(f"Invalid column: {column!r}") + for char in text: + value = value * 26 + ord(char) - ord("A") + 1 + return value - 1 + + +def index_to_column(index: int) -> str: + if index < 0: + raise ValueError(f"Invalid column index: {index}") + chars: list[str] = [] + value = index + 1 + while value: + value, remainder = divmod(value - 1, 26) + chars.append(chr(ord("A") + remainder)) + return "".join(reversed(chars)) + + +def _span_bounds(span: str, *, columns: bool) -> tuple[int, int]: + start, separator, end = str(span).partition(":") + end = end if separator else start + if columns: + return column_to_index(start), column_to_index(end) + return int(start) - 1, int(end) - 1 + + +def _size_edges( + groups: Any, + *, + count: int, + span_key: str, + size_key: str, + columns: bool, + default_size: float, +) -> tuple[list[float], bool]: + sizes: list[float | None] = [None] * count + if isinstance(groups, list): + for group in groups: + if not isinstance(group, dict) or group.get(span_key) is None: + continue + start, end = _span_bounds(str(group[span_key]), columns=columns) + size = float(group.get(size_key, default_size)) + for index in range(max(0, start), min(count - 1, end) + 1): + sizes[index] = max(0.0, size) + + used_default = any(size is None for size in sizes) + resolved = [default_size if size is None else size for size in sizes] + edges = [0.0] + for size in resolved: + edges.append(edges[-1] + size) + return edges, used_default + + +def build_layout( + structure: dict[str, Any], row_count: int, column_count: int +) -> tuple[list[float], list[float], list[str]]: + row_groups = structure.get("row_heights") + column_groups = structure.get("col_widths", structure.get("column_widths")) + row_edges, row_defaulted = _size_edges( + row_groups, + count=row_count, + span_key="rows", + size_key="height", + columns=False, + default_size=DEFAULT_ROW_HEIGHT, + ) + column_edges, column_defaulted = _size_edges( + column_groups, + count=column_count, + span_key="cols", + size_key="width", + columns=True, + default_size=DEFAULT_COLUMN_WIDTH, + ) + warnings: list[str] = [] + if row_defaulted: + warnings.append("部分行缺少高度信息,按 27 px 估算") + if column_defaulted: + warnings.append("部分列缺少宽度信息,按 105 px 估算") + return row_edges, column_edges, warnings + + +def _first_dict(value: Any) -> dict[str, Any] | None: + if isinstance(value, dict): + return value + if isinstance(value, list): + return next((item for item in value if isinstance(item, dict)), None) + return None + + +def extract_sheet_structure(data: dict[str, Any]) -> dict[str, Any]: + sheet = _first_dict(data.get("sheets")) or _first_dict(data.get("sheet")) + return sheet or data + + +def extract_charts(data: dict[str, Any], sheet_id: str, title: str) -> list[dict[str, Any]]: + sheets = data.get("sheets") + if isinstance(sheets, list): + for sheet in sheets: + if not isinstance(sheet, dict): + continue + if sheet_identifier(sheet) == sheet_id or sheet_title(sheet) == title: + charts = sheet.get("charts") + return [chart for chart in charts if isinstance(chart, dict)] if isinstance(charts, list) else [] + charts = data.get("charts") + return [chart for chart in charts if isinstance(chart, dict)] if isinstance(charts, list) else [] + + +def chart_rectangle( + chart: dict[str, Any], row_edges: list[float], column_edges: list[float] +) -> dict[str, Any]: + details = chart.get("details") if isinstance(chart.get("details"), dict) else chart + position = details.get("position") if isinstance(details.get("position"), dict) else {} + offset = details.get("offset") if isinstance(details.get("offset"), dict) else {} + size = details.get("size") if isinstance(details.get("size"), dict) else {} + + row = int(position["row"]) + column = column_to_index(str(position["col"])) + if row < 0 or column < 0 or row >= len(row_edges) - 1 or column >= len(column_edges) - 1: + raise ValueError(f"anchor outside sheet: {position!r}") + + width = float(size["width"]) + height = float(size["height"]) + if width <= 0 or height <= 0: + raise ValueError(f"invalid chart size: {size!r}") + + left = column_edges[column] + float(offset.get("col_offset", 0) or 0) + top = row_edges[row] + float(offset.get("row_offset", 0) or 0) + return { + "chart_id": str(chart.get("chart_id") or chart.get("id") or ""), + "anchor_cell": f"{index_to_column(column)}{row + 1}", + "left": left, + "top": top, + "right": left + width, + "bottom": top + height, + "width": width, + "height": height, + } + + +def intersection(first: dict[str, Any], second: dict[str, Any]) -> dict[str, float] | None: + left = max(float(first["left"]), float(second["left"])) + top = max(float(first["top"]), float(second["top"])) + right = min(float(first["right"]), float(second["right"])) + bottom = min(float(first["bottom"]), float(second["bottom"])) + if right <= left or bottom <= top: + return None + return { + "width": round(right - left, 2), + "height": round(bottom - top, 2), + "area": round((right - left) * (bottom - top), 2), + } + + +def chart_context(rectangle: dict[str, Any]) -> dict[str, Any]: + return { + "chart_id": rectangle["chart_id"], + "anchor_cell": rectangle["anchor_cell"], + "rectangle_px": { + "left": round(rectangle["left"], 2), + "top": round(rectangle["top"], 2), + "right": round(rectangle["right"], 2), + "bottom": round(rectangle["bottom"], 2), + "width": round(rectangle["width"], 2), + "height": round(rectangle["height"], 2), + }, + } + + +def _covered_indexes(edges: list[float], start: float, end: float) -> list[int]: + return [ + index + for index in range(len(edges) - 1) + if edges[index + 1] > start and edges[index] < end + ] + + +def rectangle_cell_range( + rectangle: dict[str, Any], row_edges: list[float], column_edges: list[float] +) -> str | None: + rows = _covered_indexes(row_edges, max(0.0, rectangle["top"]), rectangle["bottom"]) + columns = _covered_indexes(column_edges, max(0.0, rectangle["left"]), rectangle["right"]) + if not rows or not columns: + return None + return f"{index_to_column(columns[0])}{rows[0] + 1}:{index_to_column(columns[-1])}{rows[-1] + 1}" + + +def _has_content(cell: Any) -> bool: + if not isinstance(cell, dict): + return False + for key in ("value", "formula", "note"): + value = cell.get(key) + if value not in (None, ""): + return True + return bool(cell.get("rich_text") or cell.get("multiple_values")) + + +def _iter_cells(data: dict[str, Any]): + ranges = data.get("ranges") + if not isinstance(ranges, list): + return + for result_range in ranges: + if not isinstance(result_range, dict): + continue + cells = result_range.get("cells") + rows = result_range.get("row_indices") + columns = result_range.get("col_indices") + if not isinstance(cells, list): + continue + for row_offset, row in enumerate(cells): + if not isinstance(row, list): + continue + row_number = int(rows[row_offset]) if isinstance(rows, list) and row_offset < len(rows) else row_offset + 1 + for column_offset, cell in enumerate(row): + column = columns[column_offset] if isinstance(columns, list) and column_offset < len(columns) else index_to_column(column_offset) + yield row_number, column_to_index(str(column)), cell + + +def non_empty_cells( + data: dict[str, Any], sample_limit: int, bounds: CellBounds | None = None +) -> tuple[int, list[str], bool]: + count = 0 + samples: list[str] = [] + truncated = bool(data.get("has_more")) + ranges = data.get("ranges") + if not isinstance(ranges, list): + return 0, [], truncated + for result_range in ranges: + if not isinstance(result_range, dict): + continue + truncated = truncated or bool(result_range.get("truncated")) + for row_number, column_index, cell in _iter_cells(data): + if bounds and not ( + bounds[0] <= row_number <= bounds[1] + and bounds[2] <= column_index <= bounds[3] + ): + continue + if not _has_content(cell): + continue + count += 1 + if len(samples) < sample_limit: + samples.append(f"{index_to_column(column_index)}{row_number}") + return count, samples, truncated + + +def _read_cells( + cache: CellCache, + locator: dict[str, str], + *, + sheet_id: str | None, + sheet_name: str | None, + cell_range: str, + include: str, + timeout: int, +) -> dict[str, Any]: + selector = f"id:{sheet_id}" if sheet_id else f"name:{sheet_name}" + key = (selector, cell_range, include) + if key not in cache: + cache[key] = envelope_data( + run_sheets( + "+cells-get", + **locator, + **({"sheet_id": sheet_id} if sheet_id else {"sheet_name": sheet_name}), + flags={"range": cell_range, "include": key[2]}, + timeout=timeout, + ) + ) + return cache[key] + + +def _chart_snapshot(chart: dict[str, Any]) -> dict[str, Any]: + details = chart.get("details") if isinstance(chart.get("details"), dict) else chart + snapshot = details.get("snapshot") + return snapshot if isinstance(snapshot, dict) else {} + + +def _chart_type(snapshot: dict[str, Any]) -> str: + plot_area = snapshot.get("plotArea") + plot = plot_area.get("plot") if isinstance(plot_area, dict) else None + return str(plot.get("type") or "").lower() if isinstance(plot, dict) else "" + + +def _plot(snapshot: dict[str, Any]) -> dict[str, Any]: + plot_area = snapshot.get("plotArea") + plot = plot_area.get("plot") if isinstance(plot_area, dict) else None + return plot if isinstance(plot, dict) else {} + + +def _static_series_profiles(snapshot: dict[str, Any]) -> list[SeriesProfile]: + data = snapshot.get("data") + dim2 = data.get("dim2") if isinstance(data, dict) else None + fields = dim2.get("fields") if isinstance(dim2, dict) else None + if not isinstance(fields, list): + return [] + profiles: list[SeriesProfile] = [] + for offset, field in enumerate(fields, start=1): + if not isinstance(field, dict): + continue + values = [] + for value in field.get("parsedValues") or []: + numeric = ( + float(value) + if isinstance(value, (int, float)) and not isinstance(value, bool) + else _numeric_text_value(value) if isinstance(value, str) else None + ) + if numeric is not None: + values.append(numeric) + profiles.append( + { + "dimension_index": offset, + "series_name": str(field.get("name") or f"Series {offset}"), + "point_count": len(field.get("parsedValues") or []), + "numeric_value_count": len(values), + "unique_numeric_values": list(dict.fromkeys(values))[:2], + "source_sheet": "", + "source_range": "", + "series_range": "", + } + ) + return profiles + + +def _labeled_series_indexes( + snapshot: dict[str, Any], profiles: list[SeriesProfile] +) -> set[int]: + plot = _plot(snapshot) + available = { + int(profile["dimension_index"]) + for profile in profiles + if profile.get("dimension_index") is not None + } + labeled = set(available) if isinstance(plot.get("labels"), dict) else set() + series = plot.get("series") + if isinstance(series, list): + labeled.update( + int(item["index"]) + for item in series + if isinstance(item, dict) + and item.get("index") is not None + and isinstance(item.get("labels"), dict) + ) + return labeled & available + + +def _constant_labeled_series( + chart: dict[str, Any], profiles: list[SeriesProfile] +) -> list[dict[str, Any]]: + chart_id = str(chart.get("chart_id") or chart.get("id") or "") + snapshot = _chart_snapshot(chart) + labeled = _labeled_series_indexes(snapshot, profiles) + return [ + { + "chart_id": chart_id, + "dimension_index": profile["dimension_index"], + "series_name": profile["series_name"], + "source_sheet": profile["source_sheet"], + "source_range": profile["source_range"], + "series_range": profile["series_range"], + "reason": "constant_labeled_series", + "data_point_count": profile["point_count"], + "constant_value": profile["unique_numeric_values"][0], + "suggested_fix": "remove_series_labels_or_use_one_sparse_marker", + } + for profile in profiles + if int(profile.get("dimension_index", -1)) in labeled + and int(profile.get("numeric_value_count", 0)) >= 2 + and len(profile.get("unique_numeric_values") or []) == 1 + ] + + +def _category_count(snapshot: dict[str, Any], profiles: list[SeriesProfile]) -> int: + data = snapshot.get("data") + dim1 = data.get("dim1") if isinstance(data, dict) else None + field = dim1.get("field") if isinstance(dim1, dict) else None + values = field.get("parsedValues") if isinstance(field, dict) else None + if isinstance(values, list): + return len(values) + return max((int(profile.get("point_count", 0)) for profile in profiles), default=0) + + +def _dense_data_label_issue( + chart: dict[str, Any], profiles: list[SeriesProfile] +) -> dict[str, Any] | None: + details = chart.get("details") if isinstance(chart.get("details"), dict) else chart + snapshot = _chart_snapshot(chart) + size = details.get("size") if isinstance(details.get("size"), dict) else {} + labeled = _labeled_series_indexes(snapshot, profiles) + density = dense_data_labels( + chart_type=_chart_type(snapshot), + category_count=_category_count(snapshot, profiles), + labeled_series_count=len(labeled), + width=float(size.get("width") or 0), + height=float(size.get("height") or 0), + ) + if not density: + return None + return { + "chart_id": str(chart.get("chart_id") or chart.get("id") or ""), + "reason": "dense_data_labels", + "severity": "warning", + "chart_type": _chart_type(snapshot), + "category_count": _category_count(snapshot, profiles), + "labeled_series_count": len(labeled), + "size": { + "width": float(size.get("width") or 0), + "height": float(size.get("height") or 0), + }, + **density, + "suggested_fix": "increase_size_or_label_only_key_points", + } + + +def _undersized_chart(chart: dict[str, Any]) -> dict[str, Any] | None: + details = chart.get("details") if isinstance(chart.get("details"), dict) else chart + snapshot = _chart_snapshot(chart) + chart_type = _chart_type(snapshot) + size = details.get("size") if isinstance(details.get("size"), dict) else {} + actual = { + "width": float(size.get("width") or 0), + "height": float(size.get("height") or 0), + } + minimum = minimum_chart_size(chart_type) + if actual["width"] >= minimum["width"] and actual["height"] >= minimum["height"]: + return None + return { + "chart_id": str(chart.get("chart_id") or chart.get("id") or ""), + "reason": "chart_below_minimum_size", + "chart_type": chart_type, + "actual_size": actual, + "minimum_size": minimum, + "suggested_fix": "run_lark_chart_size_advisor_before_resizing", + } + + +def _numeric_dimensions(snapshot: dict[str, Any]) -> list[tuple[int, str]]: + data = snapshot.get("data") + if not isinstance(data, dict): + return [] + dim2 = data.get("dim2") + series = dim2.get("series") if isinstance(dim2, dict) else None + chart_type = _chart_type(snapshot) + dimensions: list[tuple[int, str]] = [] + if isinstance(series, list): + for offset, serie in enumerate(series): + if not isinstance(serie, dict) or serie.get("index") is None: + continue + if str(serie.get("aggregateType") or "").lower() == "counta": + continue + role = str(serie.get("role") or "").lower() + if chart_type == "bubble": + role = role or ("x", "y", "group", "size")[min(offset, 3)] + if role not in {"x", "y", "size"}: + continue + dimensions.append((int(serie["index"]), role or "value")) + + plot_area = snapshot.get("plotArea") + axes = plot_area.get("axes") if isinstance(plot_area, dict) else None + continuous_x = chart_type == "scatter" + if isinstance(axes, list): + continuous_x = continuous_x or any( + isinstance(axis, dict) + and str(axis.get("axisPosition") or axis.get("position") or "").lower() in {"bottom", "x"} + and str(axis.get("valueType") or "").lower() == "linear" + for axis in axes + ) + dim1 = data.get("dim1") + serie = dim1.get("serie") if isinstance(dim1, dict) else None + if continuous_x and chart_type != "bubble" and isinstance(serie, dict) and serie.get("index") is not None: + dimensions.append((int(serie["index"]), "x")) + + return list(dict.fromkeys(dimensions)) + + +def _parse_chart_ref(value: str, default_sheet: str) -> tuple[str, str, CellBounds]: + raw = str(value).strip() + sheet_name = default_sheet + cell_range = raw + if "!" in raw: + sheet_name, cell_range = raw.rsplit("!", 1) + sheet_name = sheet_name.strip() + if len(sheet_name) >= 2 and sheet_name[0] == sheet_name[-1] == "'": + sheet_name = sheet_name[1:-1].replace("''", "'") + return sheet_name, cell_range.replace("$", ""), _parse_a1_bounds(cell_range) + + +def _looks_numeric(value: str) -> bool: + return _numeric_text_value(value) is not None + + +def _numeric_text_value(value: str) -> float | None: + text = value.strip().replace(" ", "").replace(",", "") + if not text: + return None + text = re.sub(r"^([+-]?)[\$\u00a5\uffe5\u20ac\u00a3]", r"\1", text) + if text.endswith("%"): + text = text[:-1] + if not re.fullmatch(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?", text): + return None + return float(text) + + +def _zero_state(value: Any) -> str: + if value in (None, ""): + return "empty" + if isinstance(value, (int, float)) and not isinstance(value, bool): + return "zero" if value == 0 else "nonzero" + if isinstance(value, str): + numeric_value = _numeric_text_value(value) + if numeric_value is not None: + return "zero" if numeric_value == 0 else "nonzero" + return "nonzero" + + +def _update_series_state(state: dict[str, Any], value: Any) -> None: + state[_zero_state(value)] = True + numeric = ( + float(value) + if isinstance(value, (int, float)) and not isinstance(value, bool) + else _numeric_text_value(value) if isinstance(value, str) else None + ) + if numeric is None: + return + state["numeric_value_count"] += 1 + if len(state["unique_numeric_values"]) < 2: + state["unique_numeric_values"].add(numeric) + + +def _cells_truncated(data: dict[str, Any]) -> bool: + ranges = data.get("ranges") + return bool(data.get("has_more")) or any( + isinstance(item, dict) and item.get("truncated") + for item in (ranges if isinstance(ranges, list) else []) + ) + + +def _numeric_source_issues( + chart: dict[str, Any], + *, + owner_sheet_id: str, + owner_sheet_name: str, + cache: CellCache, + locator: dict[str, str], + timeout: int, + sample_limit: int, +) -> tuple[ + list[dict[str, Any]], + list[dict[str, Any]], + list[dict[str, str]], + list[SeriesProfile], +]: + chart_id = str(chart.get("chart_id") or chart.get("id") or "") + snapshot = _chart_snapshot(chart) + data = snapshot.get("data") + if not isinstance(data, dict): + return [], [], [{"chart_id": chart_id, "reason": "chart snapshot.data is missing"}], [] + if data.get("isStaticData") is True: + return [], [], [], _static_series_profiles(snapshot) + dimensions = _numeric_dimensions(snapshot) + if not dimensions: + return [], [], [], [] + refs = data.get("refs") + if not isinstance(refs, list) or not refs: + return [], [], [{"chart_id": chart_id, "reason": "chart data.refs is missing"}], [] + + parsed_refs: list[tuple[str, str, CellBounds]] = [] + unverifiable: list[dict[str, str]] = [] + for ref in refs: + raw_ref = ref.get("value") if isinstance(ref, dict) else ref + try: + parsed_refs.append(_parse_chart_ref(str(raw_ref), owner_sheet_name)) + except (TypeError, ValueError) as exc: + unverifiable.append({"chart_id": chart_id, "reason": str(exc)}) + return [], [], unverifiable, [] + + direction = str(data.get("direction") or "column").lower() + mapped: list[tuple[int, str, int, str, str, CellBounds]] = [] + for dimension_index, role in dimensions: + offset = 0 + for source_sheet, source_range, bounds in parsed_refs: + dimension_count = ( + bounds[3] - bounds[2] + 1 if direction == "column" else bounds[1] - bounds[0] + 1 + ) + if offset < dimension_index <= offset + dimension_count: + mapped.append( + ( + dimension_index, + role, + dimension_index - offset, + source_sheet, + source_range, + bounds, + ) + ) + break + offset += dimension_count + else: + unverifiable.append( + { + "chart_id": chart_id, + "reason": f"numeric dimension index {dimension_index} is outside data.refs", + } + ) + + detached = str(data.get("headerMode") or "").lower() == "detached" + mapped_by_ref: dict[ + tuple[str, str, CellBounds], list[tuple[int, str, int]] + ] = {} + for dimension_index, role, local_index, source_sheet, source_range, bounds in mapped: + mapped_by_ref.setdefault((source_sheet, source_range, bounds), []).append( + (dimension_index, role, local_index) + ) + + issue_groups: dict[tuple[int, str, str, str, str, str], list[str]] = {} + issue_counts: dict[tuple[int, str, str, str, str, str], int] = {} + degenerate_series: list[dict[str, Any]] = [] + series_profiles: list[SeriesProfile] = [] + for (source_sheet, source_range, bounds), ref_dimensions in mapped_by_ref.items(): + if direction == "column": + selected = { + bounds[2] + local_index - 1: (dimension_index, role) + for dimension_index, role, local_index in ref_dimensions + } + point_count = MAX_SOURCE_SAMPLE_POINTS + (0 if detached else 1) + checked_bounds = ( + bounds[0], + min(bounds[1], bounds[0] + point_count - 1), + min(selected), + max(selected), + ) + else: + selected = { + bounds[0] + local_index - 1: (dimension_index, role) + for dimension_index, role, local_index in ref_dimensions + } + point_count = MAX_SOURCE_SAMPLE_POINTS + (0 if detached else 1) + checked_bounds = ( + min(selected), + max(selected), + bounds[2], + min(bounds[3], bounds[2] + point_count - 1), + ) + checked_range = _format_a1_bounds(checked_bounds) + same_sheet = source_sheet == owner_sheet_name + cells_data = _read_cells( + cache, + locator, + sheet_id=owner_sheet_id if same_sheet else None, + sheet_name=None if same_sheet else source_sheet, + cell_range=checked_range, + include="value,style,raw_value", + timeout=timeout, + ) + truncated = _cells_truncated(cells_data) + if truncated: + unverifiable.append( + {"chart_id": chart_id, "reason": f"cells-get truncated for {source_sheet}!{checked_range}"} + ) + states = { + coordinate: { + "zero": False, + "empty": False, + "nonzero": False, + "numeric_value_count": 0, + "unique_numeric_values": set(), + } + for coordinate in selected + } + for row_number, column_index, cell in _iter_cells(cells_data): + coordinate = column_index if direction == "column" else row_number + dimension = selected.get(coordinate) + if dimension is None: + continue + if not detached and ( + (direction == "column" and row_number == bounds[0]) + or (direction != "column" and column_index == bounds[2]) + ): + continue + value = cell.get("value") if isinstance(cell, dict) else None + _update_series_state(states[coordinate], value) + number_format = ( + cell.get("cell_styles", {}).get("number_format") + if isinstance(cell, dict) and isinstance(cell.get("cell_styles"), dict) + else None + ) + reason = "" + uses_text_format = str(number_format or "").strip() == "@" + if isinstance(value, (int, float)) and not isinstance(value, bool) and uses_text_format: + reason = "numeric_value_uses_text_format" + elif isinstance(value, str) and _looks_numeric(value): + reason = "numeric_value_stored_as_text" + if not reason: + continue + dimension_index, role = dimension + key = (dimension_index, role, source_sheet, source_range, checked_range, reason) + issue_counts[key] = issue_counts.get(key, 0) + 1 + samples = issue_groups.setdefault(key, []) + if len(samples) < sample_limit: + samples.append(f"{index_to_column(column_index)}{row_number}") + + zero_candidates = { + coordinate for coordinate, state in states.items() if not state["nonzero"] + } + constant_candidates = { + coordinate + for coordinate, state in states.items() + if len(state["unique_numeric_values"]) <= 1 + } + candidates = zero_candidates | constant_candidates + if not truncated and candidates: + cursor = checked_bounds[1] + 1 if direction == "column" else checked_bounds[3] + 1 + end = bounds[1] if direction == "column" else bounds[3] + while candidates and cursor <= end: + fixed_span = max(candidates) - min(candidates) + 1 + points_per_window = max(1, MAX_ZERO_SCAN_CELLS // fixed_span) + window_end = min(end, cursor + points_per_window - 1) + scan_bounds = ( + (cursor, window_end, min(candidates), max(candidates)) + if direction == "column" + else (min(candidates), max(candidates), cursor, window_end) + ) + scan_range = _format_a1_bounds(scan_bounds) + scan_data = _read_cells( + cache, + locator, + sheet_id=owner_sheet_id if same_sheet else None, + sheet_name=None if same_sheet else source_sheet, + cell_range=scan_range, + include="value", + timeout=timeout, + ) + if _cells_truncated(scan_data): + truncated = True + unverifiable.append( + { + "chart_id": chart_id, + "reason": f"cells-get truncated for {source_sheet}!{scan_range}", + } + ) + break + for row_number, column_index, cell in _iter_cells(scan_data): + coordinate = column_index if direction == "column" else row_number + if coordinate not in candidates: + continue + value = cell.get("value") if isinstance(cell, dict) else None + _update_series_state(states[coordinate], value) + candidates = { + coordinate + for coordinate in candidates + if not states[coordinate]["nonzero"] + or len(states[coordinate]["unique_numeric_values"]) <= 1 + } + cursor = window_end + 1 + + if truncated: + continue + zero_candidates = { + coordinate for coordinate, state in states.items() if not state["nonzero"] + } + data_start = bounds[0] + (0 if detached else 1) + data_column = bounds[2] + (0 if detached else 1) + for coordinate, state in states.items(): + dimension_index, role = selected[coordinate] + if direction == "column": + point_total = max(0, bounds[1] - data_start + 1) + series_range = ( + f"{index_to_column(coordinate)}{data_start}:" + f"{index_to_column(coordinate)}{bounds[1]}" + if point_total + else "" + ) + else: + point_total = max(0, bounds[3] - data_column + 1) + series_range = ( + f"{index_to_column(data_column)}{coordinate}:" + f"{index_to_column(bounds[3])}{coordinate}" + if point_total + else "" + ) + source_series = next( + ( + item + for item in (data.get("dim2", {}).get("series") or []) + if isinstance(item, dict) + and item.get("index") is not None + and int(item["index"]) == dimension_index + ), + {}, + ) + series_profiles.append( + { + "dimension_index": dimension_index, + "series_name": str( + source_series.get("name") + or source_series.get("nameRef") + or f"Series {dimension_index}" + ), + "point_count": point_total, + "numeric_value_count": state["numeric_value_count"], + "unique_numeric_values": list(state["unique_numeric_values"]), + "source_sheet": source_sheet, + "source_range": source_range, + "series_range": series_range, + } + ) + if coordinate not in zero_candidates: + continue + degenerate_series.append( + { + "chart_id": chart_id, + "dimension_index": dimension_index, + "role": role, + "source_sheet": source_sheet, + "source_range": source_range, + "series_range": series_range, + "reason": ( + "numeric_series_all_zero_or_empty" + if states[coordinate]["zero"] + else "numeric_series_all_empty" + ), + "data_point_count": point_total, + } + ) + + issues = [ + { + "chart_id": chart_id, + "dimension_index": key[0], + "role": key[1], + "source_sheet": key[2], + "source_range": key[3], + "checked_range": key[4], + "reason": key[5], + "suggested_fix": ( + "set_numeric_number_format" + if key[5] == "numeric_value_uses_text_format" + else "rewrite_as_number_and_set_numeric_number_format" + ), + "affected_sample_cell_count": issue_counts[key], + "sample_cells": samples, + } + for key, samples in issue_groups.items() + ] + return issues, degenerate_series, unverifiable, series_profiles + + +def _locator(target: str) -> dict[str, str]: + return {"url": target} if target.startswith(("http://", "https://")) else {"spreadsheet_token": target} + + +def _sheet_counts(sheet: dict[str, Any]) -> tuple[int, int]: + row_count = int(sheet.get("row_count") or sheet.get("rowCount") or 0) + column_count = int(sheet.get("column_count") or sheet.get("columnCount") or 0) + if row_count <= 0 or column_count <= 0: + raise LarkCliError(f"Missing row_count/column_count for sheet {sheet_title(sheet)!r}") + return row_count, column_count + + +def check_sheet( + locator: dict[str, str], + sheet: dict[str, Any], + *, + timeout: int, + sample_limit: int, + cell_cache: CellCache | None = None, +) -> dict[str, Any]: + cell_cache = cell_cache if cell_cache is not None else {} + sheet_id = sheet_identifier(sheet) + title = sheet_title(sheet) + if not sheet_id: + raise LarkCliError(f"Missing sheet_id for sheet {title!r}") + + chart_data = envelope_data( + run_sheets("+chart-list", **locator, sheet_id=sheet_id, timeout=timeout) + ) + charts = extract_charts(chart_data, sheet_id, title) + unverifiable: list[dict[str, str]] = [] + expected_chart_count = sheet.get("chart_count") + if expected_chart_count is not None and int(expected_chart_count) != len(charts): + unverifiable.append( + { + "chart_id": "", + "reason": ( + f"chart-list returned {len(charts)} charts, " + f"but workbook-info reported {int(expected_chart_count)}" + ), + } + ) + if not charts: + return { + "sheet_id": sheet_id, + "sheet_name": title, + "chart_count": 0, + "sheet_size_px": None, + "chart_overlaps": [], + "cell_content_overlaps": [], + "numeric_source_format_issues": [], + "degenerate_numeric_series": [], + "constant_labeled_series": [], + "dense_data_labels": [], + "undersized_charts": [], + "out_of_visible_range": [], + "unverifiable_charts": unverifiable, + "issue_count": 0, + "unverifiable_count": len(unverifiable), + "warnings": [], + } + + row_count, column_count = _sheet_counts(sheet) + structure_data = envelope_data( + run_sheets( + "+sheet-info", + **locator, + sheet_id=sheet_id, + flags={"include": "row_heights,col_widths"}, + timeout=timeout, + ) + ) + row_edges, column_edges, warnings = build_layout( + extract_sheet_structure(structure_data), row_count, column_count + ) + + rectangles: list[dict[str, Any]] = [] + for chart in charts: + chart_id = str(chart.get("chart_id") or chart.get("id") or "") + if not chart_id: + unverifiable.append({"chart_id": "", "reason": "chart is missing chart_id"}) + continue + try: + rectangles.append(chart_rectangle(chart, row_edges, column_edges)) + except (KeyError, TypeError, ValueError) as exc: + unverifiable.append({"chart_id": chart_id, "reason": str(exc)}) + + overlaps: list[dict[str, Any]] = [] + for index, first in enumerate(rectangles): + for second in rectangles[index + 1 :]: + overlap = intersection(first, second) + if overlap: + overlaps.append( + { + "chart_ids": [first["chart_id"], second["chart_id"]], + "charts": [chart_context(first), chart_context(second)], + "intersection": overlap, + } + ) + + sheet_width = column_edges[-1] + sheet_height = row_edges[-1] + out_of_bounds: list[dict[str, Any]] = [] + content_overlaps: list[dict[str, Any]] = [] + covered_items: list[tuple[dict[str, Any], CellBounds]] = [] + for rectangle in rectangles: + overflow = { + "left": round(max(0.0, -rectangle["left"]), 2), + "top": round(max(0.0, -rectangle["top"]), 2), + "right": round(max(0.0, rectangle["right"] - sheet_width), 2), + "bottom": round(max(0.0, rectangle["bottom"] - sheet_height), 2), + } + if any(overflow.values()): + out_of_bounds.append({**chart_context(rectangle), "overflow_px": overflow}) + + covered_range = rectangle_cell_range(rectangle, row_edges, column_edges) + if not covered_range: + continue + covered_items.append((rectangle, _parse_a1_bounds(covered_range))) + + for cluster in _cluster_cell_reads(covered_items): + read_range = _format_a1_bounds(cluster["bounds"]) + cells_data = _read_cells( + cell_cache, + locator, + sheet_id=sheet_id, + sheet_name=None, + cell_range=read_range, + include="value,formula,comment", + timeout=timeout, + ) + for rectangle, bounds in cluster["members"]: + covered_range = _format_a1_bounds(bounds) + count, samples, truncated = non_empty_cells(cells_data, sample_limit, bounds) + if truncated: + unverifiable.append( + { + "chart_id": rectangle["chart_id"], + "reason": f"cells-get truncated for {read_range}", + } + ) + if count: + content_overlaps.append( + { + **chart_context(rectangle), + "covered_range": covered_range, + "non_empty_cell_count": count, + "sample_cells": samples, + } + ) + + numeric_source_issues: list[dict[str, Any]] = [] + degenerate_numeric_series: list[dict[str, Any]] = [] + constant_series_issues: list[dict[str, Any]] = [] + dense_label_issues: list[dict[str, Any]] = [] + undersized_charts: list[dict[str, Any]] = [] + for chart in charts: + issues, degenerate, source_unverifiable, profiles = _numeric_source_issues( + chart, + owner_sheet_id=sheet_id, + owner_sheet_name=title, + cache=cell_cache, + locator=locator, + timeout=timeout, + sample_limit=sample_limit, + ) + numeric_source_issues.extend(issues) + degenerate_numeric_series.extend(degenerate) + unverifiable.extend(source_unverifiable) + constant_series_issues.extend(_constant_labeled_series(chart, profiles)) + dense_issue = _dense_data_label_issue(chart, profiles) + if dense_issue: + dense_label_issues.append(dense_issue) + undersized = _undersized_chart(chart) + if undersized: + undersized_charts.append(undersized) + + issue_count = ( + len(overlaps) + + len(out_of_bounds) + + len(content_overlaps) + + len(numeric_source_issues) + + len(degenerate_numeric_series) + + len(constant_series_issues) + + len(undersized_charts) + ) + return { + "sheet_id": sheet_id, + "sheet_name": title, + "chart_count": len(charts), + "sheet_size_px": {"width": round(sheet_width, 2), "height": round(sheet_height, 2)}, + "chart_overlaps": overlaps, + "cell_content_overlaps": content_overlaps, + "numeric_source_format_issues": numeric_source_issues, + "degenerate_numeric_series": degenerate_numeric_series, + "constant_labeled_series": constant_series_issues, + "dense_data_labels": dense_label_issues, + "undersized_charts": undersized_charts, + "out_of_visible_range": out_of_bounds, + "unverifiable_charts": unverifiable, + "issue_count": issue_count, + "unverifiable_count": len(unverifiable), + "warnings": warnings, + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Check chart overlap, covered cell content, worksheet boundary overflow, " + "minimum size, label density, constant labeled series, numeric source-cell " + "formats, and all-zero/empty numeric series." + ) + ) + parser.add_argument("sheet_id", help="Spreadsheet URL or spreadsheet token") + parser.add_argument("--worksheet-id", help="Only check this worksheet reference_id") + parser.add_argument("--timeout", type=int, default=60) + parser.add_argument("--sample-limit", type=int, default=10) + return parser.parse_args() + + +def success_envelope(results: list[dict[str, Any]]) -> dict[str, Any]: + issue_count = sum(result["issue_count"] for result in results) + unverifiable_count = sum(result["unverifiable_count"] for result in results) + warnings = [ + f"{result['sheet_name'] or result['sheet_id']}: {warning}" + for result in results + for warning in result["warnings"] + ] + return { + "ok": True, + "engine": "lark", + "action": ACTION, + "data": { + "passed": issue_count == 0 and unverifiable_count == 0, + "scope_note": ( + "out_of_visible_range checks worksheet drawable bounds, not a device-specific browser viewport; " + "numeric source checks sample at most the first 50 data points of each chart value dimension " + "for formats and scan candidate series fully for all-zero/empty and constant-value checks; " + "label-density checks are advisory deterministic heuristics, not renderer collision detection, " + "and are excluded from issue_count" + ), + "summary": { + "worksheet_count": len(results), + "chart_count": sum(result["chart_count"] for result in results), + "issue_count": issue_count, + "unverifiable_count": unverifiable_count, + }, + "sheets": results, + }, + "warnings": warnings, + } + + +def report_exit_code(report: dict[str, Any]) -> int: + if report["data"]["passed"]: + return 0 + if report["data"]["summary"]["issue_count"] > 0: + return 2 + return 1 + + +def main() -> None: + args = parse_args() + locator = _locator(args.sheet_id) + cell_cache: CellCache = {} + try: + workbook_data = envelope_data( + run_sheets("+workbook-info", **locator, timeout=args.timeout) + ) + sheets = resolve_target_sheets(workbook_data, sheet_id=args.worksheet_id) + if not args.worksheet_id: + sheets = [sheet for sheet in sheets if not bool(sheet.get("is_hidden"))] + if not sheets: + raise LarkCliError("No visible worksheet matched") + results = [ + check_sheet( + locator, + sheet, + timeout=args.timeout, + sample_limit=args.sample_limit, + cell_cache=cell_cache, + ) + for sheet in sheets + ] + except (LarkCliError, KeyError, TypeError, ValueError) as exc: + emit_error(ACTION, str(exc)) + raise SystemExit(1) from exc + + report = success_envelope(results) + print(json.dumps(report, ensure_ascii=False, indent=2)) + exit_code = report_exit_code(report) + if exit_code: + raise SystemExit(exit_code) + + +if __name__ == "__main__": + main() diff --git a/skills/lark-sheets/scripts/lark_chart_size_advisor.py b/skills/lark-sheets/scripts/lark_chart_size_advisor.py new file mode 100644 index 0000000000..4730d20280 --- /dev/null +++ b/skills/lark-sheets/scripts/lark_chart_size_advisor.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +"""Recommend a Lark Sheet chart size before creating the chart object.""" + +from __future__ import annotations + +import argparse +import json +import re +from typing import Any + +from lark_chart_size_rules import recommend_chart_size +from lark_sheet_read_cli import ( + LarkCliError, + emit_error, + emit_success, + envelope_data, + extract_sheets, + run_sheets, + sheet_identifier, + sheet_title, +) + +ACTION = "chart_size_advisor" + + +def _locator(target: str) -> dict[str, str]: + return {"url": target} if target.startswith(("http://", "https://")) else {"spreadsheet_token": target} + + +def _split_range_refs(value: str) -> list[str]: + refs: list[str] = [] + start = 0 + quoted = False + index = 0 + while index < len(value): + char = value[index] + if char == "'": + if quoted and index + 1 < len(value) and value[index + 1] == "'": + index += 2 + continue + quoted = not quoted + elif char == "," and not quoted: + ref = value[start:index].strip() + if ref: + refs.append(ref) + start = index + 1 + index += 1 + tail = value[start:].strip() + if tail: + refs.append(tail) + if not refs or quoted: + raise ValueError(f"Invalid data range: {value!r}") + return refs + + +def _parse_ref(value: str) -> tuple[str | None, str]: + raw = value.strip() + sheet_name = None + cell_range = raw + if "!" in raw: + sheet_name, cell_range = raw.rsplit("!", 1) + sheet_name = sheet_name.strip() + if len(sheet_name) >= 2 and sheet_name[0] == sheet_name[-1] == "'": + sheet_name = sheet_name[1:-1].replace("''", "'") + cell_range = cell_range.replace("$", "") + if not re.fullmatch(r"[A-Za-z]+\d+:[A-Za-z]+\d+", cell_range): + raise ValueError(f"Invalid A1 range: {value!r}") + return sheet_name, cell_range + + +def _cell_value(cell: Any) -> Any: + if not isinstance(cell, dict): + return None + return cell.get("value", cell.get("raw_value")) + + +def _matrix(data: dict[str, Any]) -> list[list[Any]]: + ranges = data.get("ranges") + if not isinstance(ranges, list) or not ranges: + return [] + result = ranges[0] + cells = result.get("cells") if isinstance(result, dict) else None + if not isinstance(cells, list): + return [] + return [ + [_cell_value(cell) for cell in row] + for row in cells + if isinstance(row, list) + ] + + +def _is_network_timeout(exc: LarkCliError) -> bool: + text = str(exc).strip() + try: + payload = json.loads(text) + except json.JSONDecodeError: + payload = None + if isinstance(payload, dict): + error = payload.get("error") + if isinstance(error, dict) and str(error.get("subtype") or "").lower() == "timeout": + return True + lowered = text.lower() + return "server time out" in lowered or "timed out" in lowered + + +def _run_read( + shortcut: str, + *, + stage: str, + timeout: int, + **kwargs: Any, +) -> dict[str, Any]: + retried = False + while True: + try: + return run_sheets(shortcut, timeout=timeout, **kwargs) + except LarkCliError as exc: + if not retried and _is_network_timeout(exc): + retried = True + continue + suffix = " after one retry" if retried else "" + raise LarkCliError(f"{stage} failed{suffix}: {exc}", cmd=exc.cmd) from exc + + +def _sheet_selector( + sheets: list[dict[str, Any]], + *, + explicit_name: str | None, + worksheet_id: str | None, + worksheet_name: str | None, +) -> dict[str, str]: + if explicit_name: + return {"sheet_name": explicit_name} + if worksheet_id: + return {"sheet_id": worksheet_id} + if worksheet_name: + return {"sheet_name": worksheet_name} + if len(sheets) != 1: + raise LarkCliError("Unqualified data ranges require --worksheet-id or --worksheet-name") + sheet_id = sheet_identifier(sheets[0]) + if sheet_id: + return {"sheet_id": sheet_id} + return {"sheet_name": sheet_title(sheets[0])} + + +def _needs_workbook_metadata( + ranges: list[str | None], + *, + worksheet_id: str | None, + worksheet_name: str | None, +) -> bool: + if worksheet_id or worksheet_name: + return False + return any( + _parse_ref(ref)[0] is None + for value in ranges + if value + for ref in _split_range_refs(value) + ) + + +def _read_ranges( + locator: dict[str, str], + sheets: list[dict[str, Any]], + value: str, + *, + worksheet_id: str | None, + worksheet_name: str | None, + timeout: int, + stage_prefix: str = "data range", +) -> list[list[list[Any]]]: + matrices: list[list[list[Any]]] = [] + for ref in _split_range_refs(value): + explicit_name, cell_range = _parse_ref(ref) + selector = _sheet_selector( + sheets, + explicit_name=explicit_name, + worksheet_id=worksheet_id, + worksheet_name=worksheet_name, + ) + data = envelope_data( + _run_read( + "+cells-get", + stage=f"{stage_prefix} {ref}", + **locator, + **selector, + flags={"range": cell_range, "include": "value,raw_value"}, + timeout=timeout, + ) + ) + matrix = _matrix(data) + if not matrix: + raise LarkCliError(f"No cells returned for {ref}") + matrices.append(matrix) + return matrices + + +def _combine(matrices: list[list[list[Any]]], direction: str) -> list[list[Any]]: + if direction == "column": + row_count = len(matrices[0]) + if any(len(matrix) != row_count for matrix in matrices): + raise ValueError("Column-direction ranges must contain the same number of rows") + return [sum((matrix[row] for matrix in matrices), []) for row in range(row_count)] + column_count = max((len(row) for row in matrices[0]), default=0) + if any(max((len(row) for row in matrix), default=0) != column_count for matrix in matrices): + raise ValueError("Row-direction ranges must contain the same number of columns") + return sum(matrices, []) + + +def _parse_indexes(value: str | None, *, dimension_count: int, dim1_index: int) -> list[int]: + indexes = ( + [int(item.strip()) for item in value.split(",") if item.strip()] + if value + else [index for index in range(1, dimension_count + 1) if index != dim1_index] + ) + if not indexes or any(index < 1 or index > dimension_count for index in indexes): + raise ValueError("--dim2-indexes contains an out-of-range dimension index") + if dim1_index in indexes: + raise ValueError("--dim1-index cannot also appear in --dim2-indexes") + return indexes + + +def _numeric(value: Any) -> float | None: + if isinstance(value, (int, float)) and not isinstance(value, bool): + return float(value) + if isinstance(value, str): + text = value.strip().replace(",", "") + if text.endswith("%"): + text = text[:-1] + try: + return float(text) + except ValueError: + return None + return None + + +def profile_matrix( + matrix: list[list[Any]], + *, + direction: str, + dim1_index: int, + dim2_indexes: str | None, + detached_headers: list[Any] | None = None, +) -> dict[str, Any]: + if not matrix: + raise ValueError("Data range is empty") + dimension_count = max((len(row) for row in matrix), default=0) if direction == "column" else len(matrix) + if dim1_index < 1 or dim1_index > dimension_count: + raise ValueError("--dim1-index is outside the data range") + selected = _parse_indexes( + dim2_indexes, + dimension_count=dimension_count, + dim1_index=dim1_index, + ) + detached = detached_headers is not None + if direction == "column": + data_rows = matrix if detached else matrix[1:] + categories = [row[dim1_index - 1] if len(row) >= dim1_index else None for row in data_rows] + headers = detached_headers or matrix[0] + series_names = [str(headers[index - 1]) if len(headers) >= index else f"Series {index}" for index in selected] + first_values = [row[selected[0] - 1] if len(row) >= selected[0] else None for row in data_rows] + else: + category_row = matrix[dim1_index - 1] + categories = category_row if detached else category_row[1:] + headers = detached_headers or [row[0] if row else None for row in matrix] + series_names = [str(headers[index - 1]) if len(headers) >= index else f"Series {index}" for index in selected] + first_row = matrix[selected[0] - 1] + first_values = first_row if detached else first_row[1:] + nonempty_categories = [value for value in categories if value not in (None, "")] + return { + "categories": nonempty_categories, + "series_names": series_names, + "values": [number for value in first_values if (number := _numeric(value)) is not None], + "dim2_indexes": selected, + } + + +def _header_values(matrices: list[list[list[Any]]], direction: str) -> list[Any]: + matrix = _combine(matrices, direction) + if direction == "column": + if len(matrix) != 1: + raise ValueError("Column-direction --header-range must contain one row") + return matrix[0] + if any(len(row) != 1 for row in matrix): + raise ValueError("Row-direction --header-range must contain one column") + return [row[0] for row in matrix] + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Recommend chart width and height before +chart-create-basic") + parser.add_argument("target", help="Spreadsheet URL or spreadsheet token") + worksheet = parser.add_mutually_exclusive_group() + worksheet.add_argument("--worksheet-id") + worksheet.add_argument("--worksheet-name") + parser.add_argument("--chart-type", required=True) + parser.add_argument("--data-range", required=True) + parser.add_argument("--header-range") + parser.add_argument("--data-direction", choices=("column", "row"), default="column") + parser.add_argument("--dim1-index", type=int, default=1) + parser.add_argument("--dim2-indexes") + parser.add_argument("--data-labels", default="value") + parser.add_argument("--legend-position", default="bottom") + parser.add_argument("--title", default="") + parser.add_argument("--timeout", type=int, default=60) + args, _ = parser.parse_known_args(argv) + return args + + +def main() -> None: + args = parse_args() + locator = _locator(args.target) + try: + sheets: list[dict[str, Any]] = [] + if _needs_workbook_metadata( + [args.data_range, args.header_range], + worksheet_id=args.worksheet_id, + worksheet_name=args.worksheet_name, + ): + workbook = envelope_data( + _run_read( + "+workbook-info", + stage="workbook metadata", + **locator, + timeout=args.timeout, + ) + ) + sheets = extract_sheets(workbook) + matrices = _read_ranges( + locator, + sheets, + args.data_range, + worksheet_id=args.worksheet_id, + worksheet_name=args.worksheet_name, + timeout=args.timeout, + ) + headers = None + if args.header_range: + header_matrices = _read_ranges( + locator, + sheets, + args.header_range, + worksheet_id=args.worksheet_id, + worksheet_name=args.worksheet_name, + timeout=args.timeout, + stage_prefix="header range", + ) + headers = _header_values(header_matrices, args.data_direction) + profile = profile_matrix( + _combine(matrices, args.data_direction), + direction=args.data_direction, + dim1_index=args.dim1_index, + dim2_indexes=args.dim2_indexes, + detached_headers=headers, + ) + result = recommend_chart_size( + chart_type=args.chart_type, + categories=profile["categories"], + series_names=profile["series_names"], + data_labels=args.data_labels, + legend_position=args.legend_position, + title=args.title, + values=profile["values"], + ) + result["data_profile"] = { + "dim2_indexes": profile["dim2_indexes"], + **result.pop("evidence"), + } + except (LarkCliError, KeyError, TypeError, ValueError) as exc: + emit_error(ACTION, str(exc)) + raise SystemExit(1) from exc + emit_success(ACTION, result) + + +if __name__ == "__main__": + main() diff --git a/skills/lark-sheets/scripts/lark_chart_size_rules.py b/skills/lark-sheets/scripts/lark_chart_size_rules.py new file mode 100644 index 0000000000..7d2371f55a --- /dev/null +++ b/skills/lark-sheets/scripts/lark_chart_size_rules.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +"""Pure sizing heuristics shared by Lark chart helper scripts.""" + +from __future__ import annotations + +import math +import unicodedata +from typing import Any + + +MINIMUM_SIZES = { + "column": (640, 400), + "line": (640, 400), + "area": (640, 400), + "bar": (720, 420), + "combo": (720, 420), + "pie": (720, 440), + "doughnut": (720, 440), +} +DEFAULT_MINIMUM_SIZE = (640, 400) + + +def display_units(value: Any) -> int: + """Estimate visible text width; CJK/full-width characters count double.""" + lines = str(value if value is not None else "").splitlines() or [""] + return max( + sum(2 if unicodedata.east_asian_width(char) in {"W", "F", "A"} else 1 for char in line) + for line in lines + ) + + +def _round_up(value: float, step: int = 40) -> int: + return int(math.ceil(value / step) * step) + + +def _percentile(values: list[int], ratio: float) -> int: + if not values: + return 0 + ordered = sorted(values) + return ordered[max(0, math.ceil(len(ordered) * ratio) - 1)] + + +def minimum_chart_size(chart_type: str) -> dict[str, int]: + width, height = MINIMUM_SIZES.get(str(chart_type).lower(), DEFAULT_MINIMUM_SIZE) + return {"width": width, "height": height} + + +def estimate_legend_rows(items: list[str], width: int) -> int: + if not items: + return 0 + available = max(240, width - 80) + used = 0 + rows = 1 + for item in items: + item_width = min(320, 34 + display_units(item) * 7) + if used and used + item_width > available: + rows += 1 + used = 0 + used += item_width + return rows + + +def dense_data_labels( + *, + chart_type: str, + category_count: int, + labeled_series_count: int, + width: float, + height: float, +) -> dict[str, Any] | None: + if category_count <= 0 or labeled_series_count <= 0: + return None + chart_type = str(chart_type).lower() + label_count = category_count * labeled_series_count + if chart_type in {"pie", "doughnut"}: + labels_per_side = max(1, math.ceil(category_count / 2)) + slot = max(1.0, float(height) - 160) / labels_per_side + dense = slot < 24 + else: + horizontal_reserve = 230 if chart_type == "combo" else 170 + plot_width = max(1.0, float(width) - horizontal_reserve) + slot = plot_width / label_count + dense = ( + (category_count >= 8 and labeled_series_count >= 2 and slot < 42) + or (category_count >= 15 and slot < 36) + ) + if not dense: + return None + return { + "estimated_label_count": label_count, + "available_width_per_label_px": round(slot, 2), + } + + +def recommend_chart_size( + *, + chart_type: str, + categories: list[Any], + series_names: list[str], + data_labels: str = "value", + legend_position: str = "bottom", + title: str = "", + values: list[float] | None = None, +) -> dict[str, Any]: + chart_type = str(chart_type).lower() + category_text = [str(value if value is not None else "") for value in categories] + category_count = len(category_text) + series_count = max(1, len(series_names)) + label_units = [display_units(value) for value in category_text] + max_units = max(label_units, default=0) + p75_units = _percentile(label_units, 0.75) + max_lines = max((len(value.splitlines()) for value in category_text), default=1) + labels_enabled = str(data_labels or "").lower() not in {"", "none"} + minimum = minimum_chart_size(chart_type) + width = float(minimum["width"]) + height = float(minimum["height"]) + reasons: list[str] = [] + advice: list[str] = [] + + if chart_type in {"pie", "doughnut"}: + label_reserve = max(150, min(360, max_units * 7 + 60)) + width = max(width, 420 + 2 * label_reserve) + if labels_enabled: + reasons.append("outside_slice_labels") + if values: + positive = [value for value in values if value > 0] + total = sum(positive) + if total: + shares = [value / total for value in positive] + if max(shares, default=0) >= 0.75 and sum(share < 0.05 for share in shares) >= 3: + height += 40 + reasons.append("clustered_small_slices") + if category_count > 8: + advice.append("prefer_bar_or_top_n") + size_alone_is_insufficient = category_count > 12 + elif chart_type == "bar": + width = max(width, 420 + max_units * 7) + height = max(height, 190 + category_count * 36) + size_alone_is_insufficient = category_count > 24 and series_count > 2 + if size_alone_is_insufficient: + advice.extend(["use_top_n", "split_chart"]) + else: + reserve = 230 if chart_type == "combo" else 170 + base_slot = 44 if chart_type in {"line", "area"} else 52 + text_slot = 20 + p75_units * 7 * 0.72 + slot = max(base_slot, min(180, text_slot)) + if series_count == 1 and category_count >= 10: + # With many categories, Sheet rotates X-axis labels. Reserving each + # label's full horizontal text width makes single-series charts + # disproportionately wide; density checks below still expand when + # data labels would actually collide. + slot = min(slot, 68) + if chart_type in {"column", "combo"} and series_count > 1: + slot = max(slot, 20 + 22 * min(series_count, 5)) + if labels_enabled: + slot += min(24, 4 * series_count) + width = max(width, reserve + category_count * slot) + if p75_units > 12: + height += 40 + reasons.append("long_category_labels") + if max_lines > 1: + height += min(120, 40 * (max_lines - 1)) + reasons.append("multiline_category_labels") + size_alone_is_insufficient = ( + category_count > 20 + and (p75_units > 12 or series_count > 3 or labels_enabled) + ) + if size_alone_is_insufficient: + advice.extend(["prefer_bar_or_top_n", "split_chart"]) + + width = min(1600, _round_up(width)) + legend_items = category_text if chart_type in {"pie", "doughnut"} else series_names + legend_rows = 0 + if str(legend_position).lower() != "hidden": + legend_rows = estimate_legend_rows(legend_items, width) + if legend_rows > 1: + height += (legend_rows - 1) * 32 + reasons.append("multi_row_legend") + + label_density = dense_data_labels( + chart_type=chart_type, + category_count=category_count, + labeled_series_count=series_count if labels_enabled else 0, + width=width, + height=height, + ) + if label_density and chart_type not in {"pie", "doughnut"}: + target_slot = 42 if category_count >= 8 and series_count >= 2 else 36 + required_width = reserve + category_count * series_count * target_slot + width = min(1600, _round_up(max(width, required_width))) + label_density = dense_data_labels( + chart_type=chart_type, + category_count=category_count, + labeled_series_count=series_count if labels_enabled else 0, + width=width, + height=height, + ) + if not label_density: + reasons.append("expanded_for_data_labels") + if label_density: + reasons.append("dense_data_labels") + advice.append("label_only_key_points") + if label_density["estimated_label_count"] > 40: + size_alone_is_insufficient = True + advice.append("split_series_or_use_top_n") + if title: + reasons.append("chart_title") + + height = min(720, _round_up(height)) + if size_alone_is_insufficient: + if chart_type in {"pie", "doughnut"}: + height = max(height, 520) + else: + width = max(width, 1200) + height = max(height, 520) + + return { + "minimum_size": minimum, + "recommended_size": {"width": width, "height": height}, + "create_flags": {"width": width, "height": height}, + "evidence": { + "chart_type": chart_type, + "category_count": category_count, + "series_count": series_count, + "max_category_display_units": max_units, + "p75_category_display_units": p75_units, + "max_category_line_count": max_lines, + "legend_rows": legend_rows, + "data_labels": data_labels, + }, + "reasons": list(dict.fromkeys(reasons)), + "layout_advice": list(dict.fromkeys(advice)), + "size_alone_is_insufficient": size_alone_is_insufficient, + } diff --git a/tests/cli_e2e/base/base_record_batch_update_workflow_test.go b/tests/cli_e2e/base/base_record_batch_update_workflow_test.go index a08e13d026..2e7835eb1d 100644 --- a/tests/cli_e2e/base/base_record_batch_update_workflow_test.go +++ b/tests/cli_e2e/base/base_record_batch_update_workflow_test.go @@ -49,6 +49,22 @@ func TestBaseRecordBatchUpdatePerRecordWorkflow(t *testing.T) { require.NotEmpty(t, firstRecordID, "stdout:\n%s", createResult.Stdout) require.NotEmpty(t, secondRecordID, "stdout:\n%s", createResult.Stdout) + shareResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+record-share-link-create", + "--base-token", baseToken, + "--table-id", tableID, + "--record-id", firstRecordID, + "--record-ids", secondRecordID, + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + shareResult.AssertExitCode(t, 0) + shareResult.AssertStdoutStatus(t, true) + require.NotEmpty(t, gjson.Get(shareResult.Stdout, "data.record_share_links."+firstRecordID).String(), shareResult.Stdout) + require.NotEmpty(t, gjson.Get(shareResult.Stdout, "data.record_share_links."+secondRecordID).String(), shareResult.Stdout) + updateBody, err := json.Marshal(map[string]map[string]map[string]any{ "update_records": { firstRecordID: {"Status": []string{"Done"}}, diff --git a/tests/cli_e2e/base/base_record_share_link_dryrun_test.go b/tests/cli_e2e/base/base_record_share_link_dryrun_test.go new file mode 100644 index 0000000000..89c5012253 --- /dev/null +++ b/tests/cli_e2e/base/base_record_share_link_dryrun_test.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestBaseRecordShareLinkCreateDryRunAcceptsSingularAndPluralRecordIDFlags(t *testing.T) { + result := runBaseDryRun(t, 0, + "base", "+record-share-link-create", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--record-id", "rec_1", + "--record-ids", "rec_2,rec_3", + ) + + out := result.Stdout + require.Equal(t, "POST", gjson.Get(out, "data.api.0.method").String(), out) + require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/share_links/batch", gjson.Get(out, "data.api.0.url").String(), out) + require.Equal(t, []string{"rec_1", "rec_2", "rec_3"}, []string{ + gjson.Get(out, "data.api.0.body.record_ids.0").String(), + gjson.Get(out, "data.api.0.body.record_ids.1").String(), + gjson.Get(out, "data.api.0.body.record_ids.2").String(), + }, out) +} diff --git a/tests/cli_e2e/base/coverage.md b/tests/cli_e2e/base/coverage.md index 73976b8a5c..9968ef4f93 100644 --- a/tests/cli_e2e/base/coverage.md +++ b/tests/cli_e2e/base/coverage.md @@ -18,7 +18,8 @@ - TestBaseShareDryRun: proves dashboard/form share GET and PATCH routes, one-field update requests, explicit false preservation, and nested form settings without touching live data. - TestBaseShareWorkflow: deployment-gated by `LARK_CLI_E2E_BASE_SHARE_READY=1`; creates a Base, table, form, and dashboard, updates each share field in a separate request, verifies get round trips for both resources, disables sharing, and cleans up the Base. - TestBaseRecordBatchUpdatePerRecordDryRun: proves `+record-batch-update` preserves the per-record `update_records` request shape. -- TestBaseRecordBatchUpdatePerRecordWorkflow: creates two records, updates different field types in one request, asserts the minimal response contract, reads both records back, verifies a missing record ID is not prevalidated, and cleans up the temporary Base. +- TestBaseRecordBatchUpdatePerRecordWorkflow: creates two records, generates their share links using mixed `--record-id` / `--record-ids` input, updates different field types in one request, asserts the minimal response contract, reads both records back, verifies a missing record ID is not prevalidated, and cleans up the temporary Base. +- TestBaseRecordShareLinkCreateDryRunAcceptsSingularAndPluralRecordIDFlags: proves singular and plural record ID flags compose into one deduplicated share-link request. - TestBaseRecordHistoryListDryRunUsesExplicitRecordID / TestBaseRecordHistoryListDryRunRejectsNonPositiveMaxVersion: prove the history request keeps the explicit record ID and rejects explicitly non-positive cursors with a typed validation error. - TestBase_RoleWorkflow: proves `+advperm-enable`, `+role-create`, `+role-list`, `+role-get`, and `+role-update`; key `t.Run(...)` proof points are `list as bot`, `get as bot`, and `update as bot`. - TestBaseFormListDryRun_UsesBaseAndTableIdentifiers: proves `+form-list` dry-run request shape uses Base and table identifiers in the endpoint. @@ -87,7 +88,7 @@ | ✓ | base +record-history-list | shortcut | base_record_history_dryrun_test.go::TestBaseRecordHistoryListDryRunUsesExplicitRecordID; TestBaseRecordHistoryListDryRunRejectsNonPositiveMaxVersion | `--base-token`; `--table-id`; `--record-id`; `--page-size`; `--max-version`; dry-run | request shape and typed cursor validation | | ✕ | base +record-list | shortcut | | none | record workflows not covered | | ✕ | base +record-search | shortcut | | none | record workflows not covered | -| ✕ | base +record-share-link-create | shortcut | | none | record workflows not covered | +| ✓ | base +record-share-link-create | shortcut | base_record_share_link_dryrun_test.go::TestBaseRecordShareLinkCreateDryRunAcceptsSingularAndPluralRecordIDFlags; base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | canonical `--record-id`; hidden compatibility alias `--record-ids`; dry-run + live | singular and plural flags compose into one request | | ✓ | base +record-upload-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/upload | dry-run only | request shape only | | ✓ | base +record-download-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/download | dry-run only | request shape only | | ✓ | base +record-remove-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/remove | dry-run only | request shape only | diff --git a/tests/cli_e2e/docs/coverage.md b/tests/cli_e2e/docs/coverage.md index 3e56c9e345..08429fe1f5 100644 --- a/tests/cli_e2e/docs/coverage.md +++ b/tests/cli_e2e/docs/coverage.md @@ -16,7 +16,7 @@ - TestDocs_LocalResourcesDryRun: proves `docs +create` and `docs +update --command append|overwrite|block_replace` expose the complete no-network request plan for local images and files: placeholder content with intrinsic dimensions, media uploads, image binding with intrinsic `width`/`height` plus converted `scale`, file binding, conditional verification, and failure cleanup. - TestDocs_DryRunDefaultsToV2OpenAPI: proves `docs +create`, `docs +fetch`, and `docs +update` dry-run all emit `/open-apis/docs_ai/v1/...` requests without MCP or `--api-version` guidance; its fetch case asserts fetch sends the default `extra_param`, and its update case asserts `--reference-map` is sent as request body `reference_map`. - TestDocs_CreateTitleDryRunPrependsContent: proves `docs +create --title` dry-run prepends an escaped `...` tag to request body `content`. -- TestDocsScriptInitDraftCreatesUniqueWorkspacesWithoutXML, TestDocsScriptInitDraftDryRunDoesNotWrite, TestDocsScriptFileNameFlagIsRemoved, TestDocsScriptParseXMLFromFile, TestDocsScriptLocalParseWithAuthenticatedBot, TestDocsScriptRemoteImagePreflightDryRunDeclaresNetwork, TestDocsScriptInitializedDraftAutomaticallyValidatesPresentationDecision, TestDocsScriptParseRepairsMalformedXMLForProfile, TestDocsScriptStrictFlagIsRemoved, TestDocsScriptMarkdownToXMLIsRemoved, TestDocsScriptCreateTempXMLIsRemoved, TestDocsScriptParseAcceptsLocalImagePath, TestDocsScriptParseDoesNotSupportLegacyQAImage, TestDocsScriptParseAcceptsServerSDKAttributeAmpersand, TestDocsScriptParseRejectsMarkdownFromFile, TestDocsScriptDryRunIsLocal, TestDocsScriptOnlineDryRunFetchesXML, and TestDocs_CreateAndFetchWorkflowAsBot/script parse by token prove `docs +script` initializes distinct `draft__folder/draft.xml` workspaces under concurrency, reserves the XML path without creating the file, accepts the decision through `@file`, returns `ok:true` and exit code 0 for completed checks while separating pass/fail through `data.assessment.status`, reports word-count, required-block, and resource failures as structured `data.diagnostics[]`, groups same-cause remote image failures into one diagnostic with `image_indices[]`, keeps dry-run side-effect free, rejects the removed `--file-name`, `create-temp-xml`, and `markdown-to-xml` surfaces, exposes only tolerant parsing without `--strict`, accepts the service SDK's bare-ampersand URL attribute form and local `` syntax, does not recognize legacy `qa_image`, counts known block tags without enforcing business schema, profiles malformed XML through deterministic recovery, parses local XML with authenticated bot configuration, rejects Markdown input, reports external remote-image preflight network use accurately, fetches online document URL/token input as XML, and returns word/character/block profiles. +- TestDocsScriptInitDraftCreatesUniqueWorkspacesWithoutXML, TestDocsScriptInitDraftDryRunDoesNotWrite, TestDocsScriptInitDraftAcceptsWindowsCommandShimQuotes, TestDocsScriptMangledPresentationDecisionSuggestsFileInput, TestDocsScriptFileNameFlagIsRemoved, TestDocsScriptParseXMLFromFile, TestDocsScriptLocalParseWithAuthenticatedBot, TestDocsScriptRemoteImagePreflightDryRunDeclaresNetwork, TestDocsScriptInitializedDraftAutomaticallyValidatesPresentationDecision, TestDocsScriptParseRepairsMalformedXMLForProfile, TestDocsScriptStrictFlagIsRemoved, TestDocsScriptMarkdownToXMLIsRemoved, TestDocsScriptCreateTempXMLIsRemoved, TestDocsScriptParseAcceptsLocalImagePath, TestDocsScriptParseDoesNotSupportLegacyQAImage, TestDocsScriptParseAcceptsServerSDKAttributeAmpersand, TestDocsScriptParseRejectsMarkdownFromFile, TestDocsScriptDryRunIsLocal, TestDocsScriptOnlineDryRunFetchesXML, and TestDocs_CreateAndFetchWorkflowAsBot/script parse by token prove `docs +script` initializes distinct `draft__folder/draft.xml` workspaces under concurrency, reserves the XML path without creating the file, accepts the decision through inline JSON and `@file`, tolerates one outer single-quote pair preserved by Windows command shims, gives file-input recovery guidance when a shell removes JSON quotes, returns `ok:true` and exit code 0 for completed checks while separating pass/fail through `data.assessment.status`, reports word-count, required-block, and resource failures as structured `data.diagnostics[]`, groups same-cause remote image failures into one diagnostic with `image_indices[]`, keeps dry-run side-effect free, rejects the removed `--file-name`, `create-temp-xml`, and `markdown-to-xml` surfaces, exposes only tolerant parsing without `--strict`, accepts the service SDK's bare-ampersand URL attribute form and local `` syntax, does not recognize legacy `qa_image`, counts known block tags without enforcing business schema, profiles malformed XML through deterministic recovery, parses local XML with authenticated bot configuration, rejects Markdown input, reports external remote-image preflight network use accurately, fetches online document URL/token input as XML, and returns word/character/block profiles. - TestDocsScriptPresentationDecisionListCountsULAndOL proves the compatibility-only `list` requirement aggregates `
    ` and `
      ` block counts. - TestDocs_DryRunDefaultsToV2OpenAPI also proves `docs +history-list`, `docs +history-revert`, and `docs +history-revert-status` dry-run endpoint and query/body shapes. - TestDocs_HistoryWorkflow proves the guarded live history flow (`LARK_DOC_HISTORY_E2E=1`): create, update, list prior revisions, revert, poll status when needed, and fetch to verify reverted content. @@ -33,7 +33,7 @@ | ✓ | docs +history-list | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history list; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--page-size`; `--page-token` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` | | ✓ | docs +history-revert | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--history-version-id`; `--wait-timeout-ms` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` | | ✓ | docs +history-revert-status | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert status; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--task-id` | live workflow polls only when revert returns `running` | -| ✓ | docs +script | shortcut | docs_script_test.go::TestDocsScriptPresentationDecisionListCountsULAndOL; docs_script_test.go::TestDocsScriptInitDraftCreatesUniqueWorkspacesWithoutXML; docs_script_test.go::TestDocsScriptInitDraftDryRunDoesNotWrite; docs_script_test.go::TestDocsScriptFileNameFlagIsRemoved; docs_script_test.go::TestDocsScriptInitializedDraftAutomaticallyValidatesPresentationDecision; docs_script_test.go::TestDocsScriptInitializedDraftPreflightsBlockedRemoteImage; docs_script_test.go::TestDocsScriptParseRepairsMalformedXMLForProfile; docs_script_test.go::TestDocsScriptStrictFlagIsRemoved; docs_script_test.go::TestDocsScriptMarkdownToXMLIsRemoved; docs_script_test.go::TestDocsScriptCreateTempXMLIsRemoved; docs_script_test.go::TestDocsScriptParseRejectsMarkdownFromFile; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsBot/script parse by token | `--command init-draft` with `--presentation-decision `, `@file`, or `-`; `--command parse --content @file`; `--doc `; local/online dry-run | initializes `draft__folder/draft.xml` and reserves the uncreated XML path; validates word-count and planned block minimums; treats compatibility type `list` as the combined `
        ` and `
          ` count; preflights local/remote resources; returns `ok:true` with `assessment.status:failed` and structured diagnostics when checks do not pass; deduplicates same-cause image failures into `image_indices[]`; requires normal authentication for local parse, rejects Markdown input, and fetches online input as XML | +| ✓ | docs +script | shortcut | docs_script_test.go::TestDocsScriptPresentationDecisionListCountsULAndOL; docs_script_test.go::TestDocsScriptInitDraftCreatesUniqueWorkspacesWithoutXML; docs_script_test.go::TestDocsScriptInitDraftDryRunDoesNotWrite; docs_script_test.go::TestDocsScriptInitDraftAcceptsWindowsCommandShimQuotes; docs_script_test.go::TestDocsScriptMangledPresentationDecisionSuggestsFileInput; docs_script_test.go::TestDocsScriptFileNameFlagIsRemoved; docs_script_test.go::TestDocsScriptInitializedDraftAutomaticallyValidatesPresentationDecision; docs_script_test.go::TestDocsScriptInitializedDraftPreflightsBlockedRemoteImage; docs_script_test.go::TestDocsScriptParseRepairsMalformedXMLForProfile; docs_script_test.go::TestDocsScriptStrictFlagIsRemoved; docs_script_test.go::TestDocsScriptMarkdownToXMLIsRemoved; docs_script_test.go::TestDocsScriptCreateTempXMLIsRemoved; docs_script_test.go::TestDocsScriptParseRejectsMarkdownFromFile; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsBot/script parse by token | `--command init-draft` with `--presentation-decision `, one command-shim-preserved outer single-quote pair, `@file`, or `-`; `--command parse --content @file`; `--doc `; local/online dry-run | initializes `draft__folder/draft.xml` and reserves the uncreated XML path; validates word-count and planned block minimums; treats compatibility type `list` as the combined `
            ` and `
              ` count; preflights local/remote resources; returns `ok:true` with `assessment.status:failed` and structured diagnostics when checks do not pass; deduplicates same-cause image failures into `image_indices[]`; requires normal authentication for local parse, rejects Markdown input, and fetches online input as XML | | ✓ | docs +media-download | shortcut | docs_media_download_dryrun_test.go::TestDocsMediaDownloadDryRun_PlansExportAuthBeforeMediaDownload; docs_media_download_dryrun_test.go::TestDocsMediaDownloadDryRun_WhiteboardSkipsExportAuth; docs_media_download_workflow_test.go::TestDocs_MediaDownloadWorkflow | `--token`; `--output`; `--type whiteboard` dry-run | dry-run pins permission-check ordering and whiteboard bypass; live workflow creates a media fixture and verifies downloaded bytes | | ✓ | docs +media-insert | shortcut | docs_media_insert_dryrun_test.go::TestDocsMediaInsertDryRun_AppendsWithoutMCP; docs_media_insert_dryrun_test.go::TestDocsMediaInsertRemovedLocationFlagsRejected; docs_media_download_workflow_test.go::TestDocs_MediaDownloadWorkflow | `--doc`; `--file`; `--type image`; append-only `` plan without MCP; removed `--selection-with-ellipsis` / `--before` rejected | dry-run pins the append-only request chain and removed flags; live workflow asserts the returned media token and uses it to retrieve the inserted image | | ✕ | docs +media-preview | shortcut | | none | requires a deterministic comment-image fixture to verify whether `` works with the source preview endpoint | diff --git a/tests/cli_e2e/docs/docs_script_test.go b/tests/cli_e2e/docs/docs_script_test.go index 923c953897..43c8be8689 100644 --- a/tests/cli_e2e/docs/docs_script_test.go +++ b/tests/cli_e2e/docs/docs_script_test.go @@ -516,6 +516,80 @@ func TestDocsScriptInitDraftDryRunDoesNotWrite(t *testing.T) { require.Empty(t, entries) } +func TestDocsScriptInitDraftAcceptsWindowsCommandShimQuotes(t *testing.T) { + workDir := t.TempDir() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + decision := `{"audience":"reader","reader_task":"understand the topic","genre_contract":"none","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"plain text is sufficient","blocks":[]}}` + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", + "--command", "init-draft", + "--presentation-decision", "'" + decision + "'", + "--dry-run", + }, + DefaultAs: "bot", + WorkDir: workDir, + Env: docsScriptE2EEnv(t), + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + require.Equal(t, "init-draft", gjson.Get(result.Stdout, "data.command").String()) + require.True(t, gjson.Get(result.Stdout, "data.presentation_decision").Bool()) + entries, err := os.ReadDir(workDir) + require.NoError(t, err) + require.Empty(t, entries) +} + +func TestDocsScriptRecoversPowerShellDequotedPresentationDecision(t *testing.T) { + for _, decision := range []string{ + `{audience:a,reader_task:b,genre_contract:null,adapter:null,presentation_mode:normal,visual_plan:{reason:c,blocks:[]}}`, + `{"audience":a,"reader_task":b,"genre_contract":null,"adapter":null,"presentation_mode":normal,"visual_plan":{"reason":c,"blocks":[]}}`, + } { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", + "--command", "init-draft", + "--presentation-decision", decision, + "--dry-run", + }, + DefaultAs: "bot", + WorkDir: t.TempDir(), + Env: docsScriptE2EEnv(t), + }) + cancel() + require.NoError(t, err) + result.AssertExitCode(t, 0) + require.Equal(t, "init-draft", gjson.Get(result.Stdout, "data.command").String()) + require.True(t, gjson.Get(result.Stdout, "data.presentation_decision").Bool()) + } +} + +func TestDocsScriptAmbiguousMangledPresentationDecisionSuggestsFileInput(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+script", + "--command", "init-draft", + "--presentation-decision", `{audience:reader,reviewer,reader_task:understand}`, + "--dry-run", + }, + DefaultAs: "bot", + WorkDir: t.TempDir(), + Env: docsScriptE2EEnv(t), + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + require.Empty(t, result.Stdout) + require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String()) + require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String()) + require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), "--presentation-decision must be a valid Presentation Decision JSON object") + require.Equal(t, "--presentation-decision", gjson.Get(result.Stderr, "error.param").String()) + require.Equal(t, "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\"", gjson.Get(result.Stderr, "error.hint").String()) +} + func TestDocsScriptInitDraftRejectsNullWordCountWithOmitGuidance(t *testing.T) { workDir := t.TempDir() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)