diff --git a/internal/vfs/localfileio/path.go b/internal/vfs/localfileio/path.go index 1f343eeb6..c76dda011 100644 --- a/internal/vfs/localfileio/path.go +++ b/internal/vfs/localfileio/path.go @@ -65,7 +65,7 @@ func safePath(raw, flagName string) (string, error) { } if isAbsolutePath(raw) { - return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw) + return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: use a relative path like ./filename; flags that support stdin can read an out-of-tree file via '-' instead)", flagName, raw) } path := filepath.Clean(raw) diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 28ee04973..8b36d56b7 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -1070,7 +1070,7 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error { if rctx.stdinConsumed { return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name). WithParam("--"+fl.Name). - WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others as @file (e.g. --%s @/path/to/file)", fl.Name) + WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others inline or as @file with a relative path under the current directory (e.g. --%s @./payload.json)", fl.Name) } rctx.stdinConsumed = true data, err := io.ReadAll(rctx.IO().In) @@ -1104,9 +1104,16 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error { } data, err := cmdutil.ReadInputFile(rctx.FileIO(), path) if err != nil { - return ValidationErrorf("--%s: %v", fl.Name, err). + verr := ValidationErrorf("--%s: %v", fl.Name, err). WithParam("--" + fl.Name). WithCause(err) + if slices.Contains(fl.Input, Stdin) { + // Rejected @file paths are usually absolute (temp files under + // /tmp). Steer toward stdin rather than cd / copying the file + // into the project tree. + verr = verr.WithHint("this flag also reads stdin: pipe the file contents into this command and pass --%s -", fl.Name) + } + return verr } // strip a leading UTF-8 BOM so it // can't corrupt the first CSV cell or break JSON parsing downstream. diff --git a/shortcuts/common/runner_input_test.go b/shortcuts/common/runner_input_test.go index 3f0a42b84..3f9e873e8 100644 --- a/shortcuts/common/runner_input_test.go +++ b/shortcuts/common/runner_input_test.go @@ -227,6 +227,35 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) { } } +// TestResolveInputFlags_FileErrorSuggestsStdin pins the recovery hint when +// an @file path is rejected (typically an absolute /tmp path): flags that +// also accept stdin must explain the portable `--flag -` form — never cd'ing +// into the target directory or copying the file into the project tree. +func TestResolveInputFlags_FileErrorSuggestsStdin(t *testing.T) { + rctx := newTestRuntimeWithStdin(map[string]string{"csv": "@/tmp/does-not-exist.csv"}, "") + flags := []Flag{{Name: "csv", Input: []string{File, Stdin}}} + + err := resolveInputFlags(rctx, flags) + if err == nil { + t.Fatal("expected error for rejected @file path") + } + vErr := assertValidationParam(t, err, "--csv") + if !strings.Contains(vErr.Hint, "pipe the file contents") || !strings.Contains(vErr.Hint, "--csv -") { + t.Errorf("hint %q should explain the portable stdin form", vErr.Hint) + } + + // A flag without stdin support must not get the stdin hint. + rctx = newTestRuntimeWithStdin(map[string]string{"file": "@/tmp/does-not-exist.xlsx"}, "") + err = resolveInputFlags(rctx, []Flag{{Name: "file", Input: []string{File}}}) + if err == nil { + t.Fatal("expected error for rejected @file path") + } + vErr = assertValidationParam(t, err, "--file") + if strings.Contains(vErr.Hint, "stdin") { + t.Errorf("hint %q must not suggest stdin for a file-only flag", vErr.Hint) + } +} + func TestStripUTF8BOM(t *testing.T) { cases := []struct{ name, in, want string }{ {"leading BOM removed", "\uFEFFhello", "hello"}, diff --git a/shortcuts/drive/drive_import.go b/shortcuts/drive/drive_import.go index c4f7dafa8..c34a42414 100644 --- a/shortcuts/drive/drive_import.go +++ b/shortcuts/drive/drive_import.go @@ -54,15 +54,21 @@ type ImportParams struct { FolderToken string Name string TargetToken string + // FileExtension optionally overrides the extension inferred from File's + // name. Leave empty to infer from File (the default). Callers that have + // sniffed the file's real container use this to correct a mislabeled name + // so the backend receives the true format. + FileExtension string } func (p ImportParams) spec() driveImportSpec { return driveImportSpec{ - FilePath: p.File, - DocType: strings.ToLower(p.DocType), - FolderToken: p.FolderToken, - Name: p.Name, - TargetToken: p.TargetToken, + FilePath: p.File, + DocType: strings.ToLower(p.DocType), + FolderToken: p.FolderToken, + Name: p.Name, + TargetToken: p.TargetToken, + EffectiveExt: strings.TrimPrefix(strings.ToLower(p.FileExtension), "."), } } @@ -127,7 +133,7 @@ func RunImport(ctx context.Context, runtime *common.RuntimeContext, p ImportPara } // Step 1: Upload file as media - fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType) + fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec) if uploadErr != nil { return uploadErr } @@ -203,14 +209,14 @@ func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64, if !info.Mode().IsRegular() { return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", spec.FilePath).WithParam("--file") } - if err = validateDriveImportFileSize(spec.FilePath, spec.DocType, info.Size()); err != nil { + if err = validateDriveImportFileSize(spec.FileExtension(), spec.DocType, info.Size()); err != nil { return 0, err } return info.Size(), nil } func appendDriveImportUploadDryRun(dry *common.DryRunAPI, spec driveImportSpec, fileSize int64) { - extra, err := buildImportMediaExtra(spec.FilePath, spec.DocType) + extra, err := buildImportMediaExtra(spec.FileExtension(), spec.DocType) if err != nil { extra = fmt.Sprintf(`{"obj_type":"%s","file_extension":"%s"}`, spec.DocType, spec.FileExtension()) } diff --git a/shortcuts/drive/drive_import_common.go b/shortcuts/drive/drive_import_common.go index 71917a17a..1849e2570 100644 --- a/shortcuts/drive/drive_import_common.go +++ b/shortcuts/drive/drive_import_common.go @@ -59,14 +59,39 @@ type driveImportSpec struct { FolderToken string Name string TargetToken string // existing bitable token to import data into (only for type=bitable) + + // EffectiveExt is a caller-supplied override for the extension otherwise + // derived from FilePath (see ImportParams.FileExtension). It lets a caller + // that has detected the file's real container correct a mislabeled name + // (e.g. an OOXML workbook saved as .xls). Empty means "trust the filename". + EffectiveExt string } -func (s driveImportSpec) FileExtension() string { +// rawExtension is the lowercased extension taken verbatim from the file name. +func (s driveImportSpec) rawExtension() string { return strings.TrimPrefix(strings.ToLower(filepath.Ext(s.FilePath)), ".") } +// FileExtension is the extension the import pipeline treats as authoritative: +// the content-sniffed override when set, otherwise the file name's extension. +func (s driveImportSpec) FileExtension() string { + if s.EffectiveExt != "" { + return s.EffectiveExt + } + return s.rawExtension() +} + +// SourceFileName is the name used when staging the upload media. When content +// sniffing corrected the extension, the staged name must carry the corrected +// suffix too: the import backend cross-checks the media file name's extension +// against the file_extension in the import task and rejects a mismatch with +// "import file extension not match" (code 1069910). func (s driveImportSpec) SourceFileName() string { - return filepath.Base(s.FilePath) + base := filepath.Base(s.FilePath) + if s.EffectiveExt != "" && s.EffectiveExt != s.rawExtension() { + base = strings.TrimSuffix(base, filepath.Ext(base)) + "." + s.EffectiveExt + } + return base } func (s driveImportSpec) TargetFileName() string { @@ -97,18 +122,20 @@ func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{} // uploadMediaForImport uploads the source file to the temporary import media // endpoint and returns the file token consumed by import_tasks. -func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, docType string) (string, error) { +func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, spec driveImportSpec) (string, error) { + filePath := spec.FilePath + fileName := spec.SourceFileName() importInfo, err := runtime.FileIO().Stat(filePath) if err != nil { return "", driveInputStatError(err) } fileSize := importInfo.Size() - if err = validateDriveImportFileSize(filePath, docType, fileSize); err != nil { + if err = validateDriveImportFileSize(spec.FileExtension(), spec.DocType, fileSize); err != nil { return "", err } - extra, err := buildImportMediaExtra(filePath, docType) + extra, err := buildImportMediaExtra(spec.FileExtension(), spec.DocType) if err != nil { return "", err } @@ -139,12 +166,12 @@ func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, f }) } -func buildImportMediaExtra(filePath, docType string) (string, error) { +func buildImportMediaExtra(ext, docType string) (string, error) { // The import media endpoint uses extra to decide both the target native type // and how to interpret the uploaded source file. extraBytes, err := json.Marshal(map[string]string{ "obj_type": docType, - "file_extension": strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), "."), + "file_extension": ext, }) if err != nil { return "", errs.NewInternalError(errs.SubtypeUnknown, "build upload extra failed: %v", err).WithCause(err) @@ -152,10 +179,10 @@ func buildImportMediaExtra(filePath, docType string) (string, error) { return string(extraBytes), nil } -func driveImportFileSizeLimit(filePath, docType string) (int64, bool) { +func driveImportFileSizeLimit(ext, docType string) (int64, bool) { // Keep the limit mapping local to import flows so we do not widen behavior // changes beyond drive +import. - switch strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") { + switch ext { case "docx", "doc": return driveImport600MBFileSizeLimit, true case "pptx": @@ -174,13 +201,12 @@ func driveImportFileSizeLimit(filePath, docType string) (int64, bool) { } } -func validateDriveImportFileSize(filePath, docType string, fileSize int64) error { - limit, ok := driveImportFileSizeLimit(filePath, docType) +func validateDriveImportFileSize(ext, docType string, fileSize int64) error { + limit, ok := driveImportFileSizeLimit(ext, docType) if !ok || fileSize <= limit { return nil } - ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") if ext == "csv" { // CSV is the only source format whose limit depends on the target type. return errs.NewValidationError(errs.SubtypeInvalidArgument, diff --git a/shortcuts/drive/drive_import_common_test.go b/shortcuts/drive/drive_import_common_test.go index c44ab9b32..7b03e53e6 100644 --- a/shortcuts/drive/drive_import_common_test.go +++ b/shortcuts/drive/drive_import_common_test.go @@ -94,61 +94,61 @@ func TestValidateDriveImportFileSize(t *testing.T) { tests := []struct { name string - filePath string + ext string docType string fileSize int64 wantText string }{ { name: "docx exceeds 600mb limit", - filePath: "./report.docx", + ext: "docx", docType: "docx", fileSize: driveImport600MBFileSizeLimit + 1, wantText: "exceeds 600.0 MB import limit for .docx", }, { name: "csv sheet exceeds 20mb limit", - filePath: "./data.csv", + ext: "csv", docType: "sheet", fileSize: driveImport20MBFileSizeLimit + 1, wantText: "exceeds 20.0 MB import limit for .csv when importing as sheet", }, { name: "csv bitable exceeds 100mb limit", - filePath: "./data.csv", + ext: "csv", docType: "bitable", fileSize: driveImport100MBFileSizeLimit + 1, wantText: "exceeds 100.0 MB import limit for .csv when importing as bitable", }, { name: "xlsx within 800mb limit", - filePath: "./data.xlsx", + ext: "xlsx", docType: "sheet", fileSize: driveImport800MBFileSizeLimit, }, { name: "pptx exceeds 500mb limit", - filePath: "./deck.pptx", + ext: "pptx", docType: "slides", fileSize: driveImport500MBFileSizeLimit + 1, wantText: "exceeds 500.0 MB import limit for .pptx", }, { name: "pptx within 500mb limit", - filePath: "./deck.pptx", + ext: "pptx", docType: "slides", fileSize: driveImport500MBFileSizeLimit, }, { name: "base exceeds 20mb limit", - filePath: "./snapshot.base", + ext: "base", docType: "bitable", fileSize: driveImport20MBFileSizeLimit + 1, wantText: "exceeds 20.0 MB import limit for .base", }, { name: "base within 20mb limit", - filePath: "./snapshot.base", + ext: "base", docType: "bitable", fileSize: driveImport20MBFileSizeLimit, }, @@ -158,7 +158,7 @@ func TestValidateDriveImportFileSize(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - err := validateDriveImportFileSize(tt.filePath, tt.docType, tt.fileSize) + err := validateDriveImportFileSize(tt.ext, tt.docType, tt.fileSize) if tt.wantText == "" { if err != nil { t.Fatalf("expected no error, got %v", err) diff --git a/shortcuts/register.go b/shortcuts/register.go index 1ac23dee2..7d458ca18 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -207,16 +207,18 @@ func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand) } -// Sheets backward-compatibility help grouping. +// Sheets backward-compatibility grouping. // // shortcuts/sheets/backward keeps the pre-refactor command names alive so that // users whose lark-sheets skill predates the refactor keep working even after -// upgrading only the binary. In `sheets --help` those aliases would otherwise -// sort alphabetically into the same flat list as the current commands, -// indistinguishable from them. applySheetsCompatGroups splits them into a -// dedicated cobra group whose heading tells the user to update their skill, and -// appends a "(→ +new-command)" pointer to each alias so the migration target is -// obvious. Pure presentation — the aliases stay fully executable. +// upgrading only the binary. applySheetsCompatGroups tags each alias into a +// dedicated deprecated cobra group. The refactored commands have been the +// default for over a month, so `sheets --help` no longer lists these aliases: +// sheetsUsageTemplate renders every group except the deprecated one. The +// grouping is still applied for two reasons — the unknown-subcommand path +// (cmd/root.go) keys off it to classify a mistyped legacy alias, and each +// alias's own `sheets --help` still surfaces the "(→ +new-command)" +// migration pointer appended below. The aliases stay fully executable. const ( sheetsCurrentGroupID = "sheets-current" // sheetsDeprecatedGroupID aliases the shared deprecated-group id so both @@ -226,9 +228,10 @@ const ( ) // sheetsAliasReplacement maps each pre-refactor sheets alias to the current -// command(s) that replace it, shown as a "(→ ...)" suffix in --help. Aliases -// absent from this map still land in the deprecated group, just without a -// pointer, so a missing entry degrades gracefully rather than misgrouping. +// command(s) that replace it, shown as a "(→ ...)" suffix in the alias's own +// --help and reused by wrapSheetsBackwardDeprecation for the on-execution +// _notice. Aliases absent from this map still land in the deprecated group, +// just without a pointer, so a missing entry degrades gracefully. var sheetsAliasReplacement = map[string]string{ // spreadsheet / sheet management "+create": "+workbook-create", @@ -281,6 +284,43 @@ var sheetsAliasReplacement = map[string]string{ "+delete-float-image": "+float-image-delete", } +// sheetsUsageTemplate is cobra v1.10.2's stock usage template with a single +// change: the group loop is guarded by {{if ne $group.ID "deprecated"}} so the +// deprecated pre-refactor aliases are omitted from `sheets --help` altogether. +// Everything else — current commands, ungrouped metaapi subcommands under +// "Additional Commands", flags — renders exactly as cobra's default. Keep in +// sync with cobra's defaultUsageTemplate on upgrade. +var sheetsUsageTemplate = fmt.Sprintf(`Usage:{{if .Runnable}} + {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} + {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} + +Aliases: + {{.NameAndAliases}}{{end}}{{if .HasExample}} + +Examples: +{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}} + +Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}{{if ne $group.ID %q}} + +{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}} + +Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} + +Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} + {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} +`, sheetsDeprecatedGroupID) + func applySheetsCompatGroups(svc *cobra.Command) { svc.AddGroup( &cobra.Group{ID: sheetsCurrentGroupID, Title: "Available Commands:"}, @@ -312,6 +352,11 @@ func applySheetsCompatGroups(svc *cobra.Command) { c.GroupID = sheetsCurrentGroupID } } + + // Refactored commands have been the default for over a month: drop the + // deprecated group from `sheets --help` (see sheetsUsageTemplate). The + // aliases remain grouped and executable, just no longer advertised here. + svc.SetUsageTemplate(sheetsUsageTemplate) } // wrapSheetsBackwardDeprecation decorates each backward-compatibility sheets diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index c9514b0cb..a5688103e 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -532,10 +532,11 @@ func TestApplySheetsCompatGroups(t *testing.T) { } } -// End-to-end: the rendered `sheets --help` must surface the deprecated-group -// heading (telling users to update their skill) plus the per-alias migration -// pointers, while keeping the refactored shortcuts under Available Commands. -func TestRegisterShortcutsSheetsHelpGroupsDeprecatedAliases(t *testing.T) { +// End-to-end: `sheets --help` must list refactored shortcuts under Available +// Commands, but no longer advertise the deprecated pre-refactor aliases or the +// deprecated group heading (sheetsUsageTemplate skips that group). The aliases +// stay registered and executable — hidden from the parent listing, not removed. +func TestRegisterShortcutsSheetsHelpHidesDeprecatedAliases(t *testing.T) { program := &cobra.Command{Use: "root"} RegisterShortcuts(program, newRegisterTestFactory(t)) @@ -551,19 +552,25 @@ func TestRegisterShortcutsSheetsHelpGroupsDeprecatedAliases(t *testing.T) { } got := out.String() - for _, want := range []string{ - "Available Commands:", - "Deprecated pre-refactor commands", - "update your lark-sheets skill", - "+read", - "(→ +cells-get)", - "+write", - "(→ +cells-set)", - } { + for _, want := range []string{"Available Commands:", "+cells-get"} { if !strings.Contains(got, want) { t.Fatalf("sheets help missing %q:\n%s", want, got) } } + for _, unwanted := range []string{ + "Deprecated pre-refactor commands", + "update your lark-sheets skill", + "+read", + "+write", + } { + if strings.Contains(got, unwanted) { + t.Fatalf("sheets help still shows deprecated content %q:\n%s", unwanted, got) + } + } + + if alias, _, ferr := sheetsCmd.Find([]string{"+read"}); ferr != nil || alias == nil { + t.Fatalf("deprecated alias +read should stay registered, got err=%v cmd=%v", ferr, alias) + } } // wrapSheetsBackwardDeprecation must decorate each alias's Execute so that diff --git a/shortcuts/sheets/backward/lark_sheets_float_images.go b/shortcuts/sheets/backward/lark_sheets_float_images.go index a117bbc24..9efaa5dc1 100644 --- a/shortcuts/sheets/backward/lark_sheets_float_images.go +++ b/shortcuts/sheets/backward/lark_sheets_float_images.go @@ -5,6 +5,7 @@ package backward import ( "context" + "errors" "fmt" "path/filepath" "strings" @@ -17,20 +18,30 @@ import ( // Drive media parent_type values for uploading an image into a spreadsheet. // Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a -// synthetic token prefixed with "fake_office_" and the backend requires -// "office_sheet_file" instead. +// synthetic token prefixed with "fake_office_" (being renamed to +// "local_office_") and the backend requires "office_sheet_file" instead. const ( sheetImageParentType = "sheet_image" officeSheetFileParentType = "office_sheet_file" - fakeOfficeTokenPrefix = "fake_office_" + fakeOfficePrefix = "fake_office_" + localOfficePrefix = "local_office_" ) +// officePrefixes are the synthetic token prefixes an imported "office" +// spreadsheet may carry. The prefix is being renamed from "fake_office_" to +// "local_office_"; accept either so image uploads keep working across the +// rename. +var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix} + // sheetMediaParentType returns the drive media parent_type to use when -// uploading an image whose parent_node is spreadsheetToken, mapping the -// "fake_office_" imported-spreadsheet token prefix to "office_sheet_file". +// uploading an image whose parent_node is spreadsheetToken, mapping either the +// "fake_office_" or "local_office_" imported-spreadsheet token prefix to +// "office_sheet_file". func sheetMediaParentType(spreadsheetToken string) string { - if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) { - return officeSheetFileParentType + for _, prefix := range officePrefixes { + if strings.HasPrefix(spreadsheetToken, prefix) { + return officeSheetFileParentType + } } return sheetImageParentType } @@ -135,7 +146,8 @@ func validateSheetMediaUploadFile(runtime *common.RuntimeContext, filePath strin stat, err := runtime.FileIO().Stat(filePath) if err != nil { wrapped := common.WrapInputStatErrorTyped(err, "file not found") - if v, ok := wrapped.(*errs.ValidationError); ok { + var v *errs.ValidationError + if errors.As(wrapped, &v) { return "", nil, v.WithParam("--file") } return "", nil, wrapped diff --git a/shortcuts/sheets/batch_op_contract_test.go b/shortcuts/sheets/batch_op_contract_test.go index 455b4782c..f0ae57a4b 100644 --- a/shortcuts/sheets/batch_op_contract_test.go +++ b/shortcuts/sheets/batch_op_contract_test.go @@ -102,8 +102,8 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) { { shortcut: "+rows-resize", sc: RowsResize, - args: []string{"--sheet-id", "sh1", "--range", "1", "--type", "pixel", "--size", "30"}, - subInput: `{"sheet-id":"sh1","range":"1","type":"pixel","size":30}`, + args: []string{"--sheet-id", "sh1", "--range", "1", "--height", "30"}, + subInput: `{"sheet-id":"sh1","range":"1","height":30}`, }, { shortcut: "+cols-resize", @@ -409,12 +409,12 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) { wantContains: "--count must be > 0", }, { - name: "+rows-resize --type pixel without --size", + name: "+rows-resize --height with --type standard", shortcut: RowsResize, - args: []string{"--sheet-id", "sh1", "--range", "1:2", "--type", "pixel"}, + args: []string{"--sheet-id", "sh1", "--range", "1:2", "--height", "30", "--type", "standard"}, subShortcut: "+rows-resize", - subInput: `{"sheet-id":"sh1","range":"1:2","type":"pixel"}`, - wantContains: "--type pixel requires --size", + subInput: `{"sheet-id":"sh1","range":"1:2","height":30,"type":"standard"}`, + wantContains: "--height cannot be combined with --type standard", }, { name: "+sheet-delete missing sheet selector", @@ -469,6 +469,34 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) { } } +// TestBatchOp_RejectsResizeMapForm locks the nesting guard: the map form +// (--widths/--heights) expands into its own batch_update, and batch_update +// cannot nest, so a +batch-update sub-op carrying `widths`/`heights` must be +// rejected with a pointer to the standalone form — it is standalone-valid, +// so this case cannot live in the standalone-vs-batch equivalence table. +func TestBatchOp_RejectsResizeMapForm(t *testing.T) { + t.Parallel() + cases := []struct { + shortcut string + input string + }{ + {"+cols-resize", `{"sheet-id":"sh1","widths":{"A":100}}`}, + {"+rows-resize", `{"sheet-id":"sh1","heights":{"1":50}}`}, + } + for _, tc := range cases { + t.Run(tc.shortcut, func(t *testing.T) { + t.Parallel() + var subInput map[string]interface{} + if err := json.Unmarshal([]byte(tc.input), &subInput); err != nil { + t.Fatalf("bad input JSON: %v", err) + } + rawOp := map[string]interface{}{"shortcut": tc.shortcut, "input": subInput} + _, err := translateBatchOp(rawOp, testToken, 0) + requireValidation(t, err, "not supported inside +batch-update") + }) + } +} + // TestBatchOp_RejectsWrongScalarType locks the type-check that closes the // silent-coercion gap: `operations` skips parse-time schema validation, and // mapFlagView coerces a mismatched scalar to its zero value, so a sub-op field @@ -611,10 +639,10 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) { "--position is required", }, { - "+rows-resize missing --type", + "+rows-resize missing both --height and --type", "+rows-resize", `{"sheet-id":"sh1","range":"1:1"}`, - "--type is required", + "give --height for a pixel size, or --type standard / auto", }, { "+range-copy missing --target-range", @@ -802,7 +830,7 @@ func TestBatchOp_DispatchCoversReportedBugs(t *testing.T) { // bare single-element ranges. body = parseDryRunBody(t, BatchUpdate, []string{ "--url", testURL, - "--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","range":"23","type":"pixel","size":40}}]`, + "--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","range":"23","height":40}}]`, "--yes", }) ops = decodeToolInput(t, body, "batch_update")["operations"].([]interface{}) @@ -887,3 +915,99 @@ func TestBatchOp_RequiredFlagParity(t *testing.T) { }) } } + +func TestBatchOp_EnumParity(t *testing.T) { + t.Parallel() + + t.Run("canonical casing is normalized before translation", func(t *testing.T) { + t.Parallel() + got, err := translateBatchOp(map[string]interface{}{ + "shortcut": "+cells-clear", + "input": map[string]interface{}{ + "sheet-id": "sh1", + "range": "A1:B2", + "scope": "FORMATS", + }, + }, testToken, 0) + if err != nil { + t.Fatalf("translateBatchOp: %v", err) + } + input, _ := got["input"].(map[string]interface{}) + if input["clear_type"] != "formats" { + t.Fatalf("clear_type = %v, want formats", input["clear_type"]) + } + }) + + t.Run("cross-vocabulary alias is normalized", func(t *testing.T) { + t.Parallel() + got, err := translateBatchOp(map[string]interface{}{ + "shortcut": "+cells-set-style", + "input": map[string]interface{}{ + "sheet-id": "sh1", "range": "A1", "vertical-alignment": "center", + }, + }, testToken, 0) + if err != nil { + t.Fatalf("translateBatchOp: %v", err) + } + input := got["input"].(map[string]interface{}) + cells := input["cells"].([][]interface{}) + style := cells[0][0].(map[string]interface{})["cell_styles"].(map[string]interface{}) + if style["vertical_alignment"] != "middle" { + t.Fatalf("vertical_alignment = %v, want middle", style["vertical_alignment"]) + } + }) + + t.Run("underscore input keys are accepted", func(t *testing.T) { + t.Parallel() + got, err := translateBatchOp(map[string]interface{}{ + "shortcut": "+range-copy", + "input": map[string]interface{}{ + "sheet_id": "sh1", "source_range": "A1:B2", "target_range": "D1", "paste_type": "values", + }, + }, testToken, 0) + if err != nil { + t.Fatalf("translateBatchOp: %v", err) + } + input := got["input"].(map[string]interface{}) + if input["range"] != "A1:B2" || input["destination_range"] != "D1" || input["paste_type"] != "value_only" { + t.Fatalf("translated underscore-key input = %#v", input) + } + }) + + tests := []struct { + name string + shortcut string + input map[string]interface{} + want string + }{ + { + name: "invalid clear scope", + shortcut: "+cells-clear", + input: map[string]interface{}{ + "sheet-id": "sh1", "range": "A1:B2", "scope": "formtas", + }, + want: "invalid value \"formtas\" for --scope", + }, + { + name: "invalid copy paste type", + shortcut: "+range-copy", + input: map[string]interface{}{ + "sheet-id": "sh1", "source-range": "A1:B2", "target-range": "D1", "paste-type": "valuez", + }, + want: "invalid value \"valuez\" for --paste-type", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := translateBatchOp(map[string]interface{}{ + "shortcut": tt.shortcut, + "input": tt.input, + }, testToken, 0) + validationErr := requireValidation(t, err, tt.want) + if validationErr.Param != "--operations" { + t.Errorf("param = %q, want --operations", validationErr.Param) + } + }) + } +} diff --git a/shortcuts/sheets/batch_op_dispatch.go b/shortcuts/sheets/batch_op_dispatch.go index 0f8b74a6e..ec706fda5 100644 --- a/shortcuts/sheets/batch_op_dispatch.go +++ b/shortcuts/sheets/batch_op_dispatch.go @@ -4,6 +4,7 @@ package sheets import ( + "sort" "strings" ) @@ -118,10 +119,19 @@ var batchOpDispatch = map[string]batchOpMapping{ }}, // ─── 行高列宽 (resize_range, 无 operation 字段) ───────────────── + // The map form (--heights/--widths) fans out into its own batch_update + // and cannot nest inside +batch-update; sub-ops must use the uniform + // single-range form (range + height/width or type). "+rows-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + if err := rejectResizeMapInBatch(fv, "row"); err != nil { + return nil, err + } return resizeInput(fv, token, sid, sname, "row") }}, "+cols-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + if err := rejectResizeMapInBatch(fv, "column"); err != nil { + return nil, err + } return resizeInput(fv, token, sid, sname, "column") }}, @@ -197,6 +207,54 @@ var batchOpDispatch = map[string]batchOpMapping{ "+float-image-delete": {"manage_float_image_object", objDeleteTranslate(floatImageDeleteSpec)}, } +// allowedBatchShortcuts lists every shortcut accepted inside +batch-update, +// sorted, for the not-allowed error hint. +func allowedBatchShortcuts() []string { + out := make([]string, 0, len(batchOpDispatch)) + for sc := range batchOpDispatch { + out = append(out, sc) + } + sort.Strings(out) + return out +} + +// subOpInputContract renders one shortcut's complete sub-op key vocabulary +// (wire-style underscore names) for the translator-failure hint: required +// flags are marked, the sheet selector pair collapses to a choose-one, and +// spreadsheet locators are omitted (reserved for the batch top level). +// Returns "" for shortcuts without a flag-defs entry. +func subOpInputContract(sc string) string { + defs, _ := loadFlagDefs() + spec, ok := defs[sc] + if !ok { + return "" + } + idFlag, nameFlag := sheetSelectorFlagsForSubOp(sc) + var keys []string + sheetSelector := "" + for _, df := range spec.Flags { + if df.Kind == "system" || df.Hidden { + continue + } + switch df.Name { + case "url", "spreadsheet-token": + continue // reserved: supplied by +batch-update top level + case idFlag, nameFlag: + sheetSelector = strings.ReplaceAll(idFlag, "-", "_") + "|" + strings.ReplaceAll(nameFlag, "-", "_") + " (choose one)" + continue + } + key := strings.ReplaceAll(df.Name, "-", "_") + if df.Required == "required" { + key += " (required)" + } + keys = append(keys, key) + } + if sheetSelector != "" { + keys = append([]string{sheetSelector}, keys...) + } + return strings.Join(keys, ", ") +} + // rejectLocalImageInBatch blocks the local-file --image source inside // +batch-update: a batch sub-op has no upload phase, so the file could not be // turned into a file_token. Callers must pass --image-token / --image-uri. @@ -262,7 +320,8 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte } scRaw, present := op["shortcut"] if !present { - return nil, sheetsValidationForFlag("operations", "operations[%d]: 'shortcut' field is required", index) + return nil, sheetsValidationForFlag("operations", "operations[%d]: 'shortcut' field is required", index). + WithHint(`each entry must look like {"shortcut":"+cells-set","input":{"sheet_name":"…","range":"A1:B2","cells":[[…]]}} — input uses the shortcut's own flag names`) } sc, ok := scRaw.(string) if !ok || sc == "" { @@ -270,13 +329,15 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte } mapping, ok := batchOpDispatch[sc] if !ok { + // Inline the full allow-list: an agent that guessed a read op or a + // fan-out wrapper can pick the right shortcut immediately instead of + // spending a --print-schema round trip on the operations enum. return nil, sheetsValidationForFlag( "operations", "operations[%d]: shortcut %q not allowed in +batch-update "+ - "(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded; "+ - "run `lark-cli sheets +batch-update --print-schema --flag-name operations` to see the full enum)", + "(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded)", index, sc, - ) + ).WithHint("allowed shortcuts: %s", strings.Join(allowedBatchShortcuts(), ", ")) } inputRaw, hasInput := op["input"] var input map[string]interface{} @@ -319,12 +380,22 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte if err := fv.validateRawTypes(); err != nil { return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err) } + if err := fv.normalizeAndValidateEnums(); err != nil { + return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err) + } sheetIDFlag, sheetNameFlag := sheetSelectorFlagsForSubOp(sc) sheetID := strings.TrimSpace(fv.Str(sheetIDFlag)) sheetName := strings.TrimSpace(fv.Str(sheetNameFlag)) body, err := mapping.translate(fv, token, sheetID, sheetName) if err != nil { - return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err) + // The inner error names one problem at a time (first missing flag); + // the hint lists the sub-op's complete key contract so an agent fixes + // every gap in a single retry instead of iterating flag by flag. + verr := sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err) + if contract := subOpInputContract(sc); contract != "" { + verr = verr.WithHint("%s input keys: %s", sc, contract) + } + return nil, verr } return map[string]interface{}{ "tool_name": mapping.mcpToolName, @@ -332,18 +403,59 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte }, nil } +// maxBatchOperations caps how many sub-operations a single +batch-update may +// carry. Every translated op (with its own cells/properties payload) is held in +// the out slice at once before the whole batch is marshaled, so an unbounded +// operation count is the same unbounded-materialization hazard as the fan-out +// matrix, on the operations axis. +const maxBatchOperations = 100 + // translateBatchOperations 翻译整个 ops 数组;fail-fast,遇错立即返回。 func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) { if len(rawOps) == 0 { return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array") } + if len(rawOps) > maxBatchOperations { + batches := (len(rawOps) + maxBatchOperations - 1) / maxBatchOperations + return nil, sheetsValidationForFlag("operations", "--operations accepts at most %d entries; got %d", maxBatchOperations, len(rawOps)). + WithHint("split the operations into %d separate +batch-update calls of at most %d entries each", batches, maxBatchOperations) + } out := make([]interface{}, 0, len(rawOps)) + var totalCells int64 for i, raw := range rawOps { translated, err := translateBatchOp(raw, token, i) if err != nil { return nil, err } + totalCells += translatedCellCount(translated) + if totalCells > maxStampMatrixCells { + return nil, sheetsValidationForFlag("operations", + "--operations materialize %d cells total, over the %d-cell safety cap; reduce the number or size of cell operations", + totalCells, maxStampMatrixCells) + } out = append(out, translated) } return out, nil } + +func translatedCellCount(op map[string]interface{}) int64 { + input, _ := op["input"].(map[string]interface{}) + switch cells := input["cells"].(type) { + case [][]interface{}: + var total int64 + for _, row := range cells { + total += int64(len(row)) + } + return total + case []interface{}: + var total int64 + for _, rawRow := range cells { + if row, ok := rawRow.([]interface{}); ok { + total += int64(len(row)) + } + } + return total + default: + return 0 + } +} diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index acca33d7b..5f1cb4fbd 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -1,4 +1,59 @@ { + "+formula-verify": { + "risk": "read", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string_slice", + "required": "optional", + "desc": "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets." + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string_slice", + "required": "optional", + "desc": "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets." + }, + { + "name": "range", + "kind": "own", + "type": "string_slice", + "required": "optional", + "desc": "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region." + }, + { + "name": "max-locations", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Max locations / samples per error type; default 20.", + "default": "20" + }, + { + "name": "exit-on-error", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes." + } + ] + }, "+workbook-info": { "risk": "read", "flags": [ @@ -25,6 +80,32 @@ } ] }, + "+revision-get": { + "risk": "read", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet locator" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet locator" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, "+sheet-create": { "risk": "write", "flags": [ @@ -73,6 +154,17 @@ "desc": "Initial column count (default 20, max 200)", "default": "20" }, + { + "name": "type", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "New sub-sheet type: sheet (spreadsheet); default sheet.", + "default": "sheet", + "enum": [ + "sheet" + ] + }, { "name": "dry-run", "kind": "system", @@ -219,7 +311,7 @@ "kind": "own", "type": "int", "required": "optional", - "desc": "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`", + "desc": "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it", "default": "-1" }, { @@ -515,7 +607,7 @@ "kind": "own", "type": "string", "required": "optional", - "desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", + "desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected (dates / numbers land as text — use --sheets to preserve types), through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", "input": [ "file", "stdin" @@ -1069,7 +1161,7 @@ "kind": "own", "type": "int", "required": "optional", - "desc": "Group nesting level to ungroup; default 1 (outermost)", + "desc": "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)", "default": "1" }, { @@ -1711,6 +1803,13 @@ "required": "optional", "desc": "Font color (hex, e.g. `#000000`)" }, + { + "name": "font-family", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)" + }, { "name": "font-size", "kind": "own", @@ -2294,32 +2393,43 @@ "required": "xor", "desc": "Sheet name (XOR with `--sheet-id`)" }, + { + "name": "height", + "kind": "own", + "type": "int", + "required": "xor", + "desc": "Uniform row height in pixels (e.g. 30 / 40 / 60; NOT points), used with `--range`. Passing --height implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-row heights use `--heights`", + "default": "0" + }, + { + "name": "heights", + "kind": "own", + "type": "string", + "required": "xor", + "desc": "Per-row height map — set different heights for many rows in one atomic call. Keys: single row (`\"1\"`) or closed range (`\"2:20\"`); values: pixel height (e.g. 30 / 50), `\"auto\"` (fit content) or `\"standard\"` (reset to default). Units are pixels, NOT points. Mutually exclusive with `--range` / `--height` / `--type`", + "input": [ + "file", + "stdin" + ] + }, { "name": "type", "kind": "own", "type": "string", - "required": "required", - "desc": "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default row height) / `auto` (fit content)", + "required": "xor", + "desc": "Sizing mode: `pixel` (requires `--height`) / `standard` (reset to default row height) / `auto` (fit content). Passing --height alone is the common form; `--type standard` / `--type auto` cannot be combined with `--height`", "enum": [ "pixel", "standard", "auto" ] }, - { - "name": "size", - "kind": "own", - "type": "int", - "required": "optional", - "desc": "Row height in pixels (e.g. 30 / 40 / 60); required when `--type pixel`, ignored otherwise", - "default": "0" - }, { "name": "range", "kind": "own", "type": "string", - "required": "required", - "desc": "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row)" + "required": "xor", + "desc": "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row). Required for the uniform form (with `--height` or `--type`); omit with the map form (`--heights`)" }, { "name": "dry-run", @@ -2361,31 +2471,42 @@ "required": "xor", "desc": "Sheet name (XOR with `--sheet-id`)" }, + { + "name": "width", + "kind": "own", + "type": "int", + "required": "xor", + "desc": "Uniform column width in pixels (e.g. 80 / 120 / 200; NOT Excel character units), used with `--range`. Passing --width implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-column widths use `--widths`", + "default": "0" + }, + { + "name": "widths", + "kind": "own", + "type": "string", + "required": "xor", + "desc": "Per-column width map — set different widths for many columns in one atomic call. Keys: single column (`\"A\"`) or closed range (`\"C:E\"`); values: pixel width (e.g. 80 / 120 / 200) or `\"standard\"` (reset to default). Units are pixels, NOT Excel character units (px ≈ chars × 8 + 16). Mutually exclusive with `--range` / `--width` / `--type`", + "input": [ + "file", + "stdin" + ] + }, { "name": "type", "kind": "own", "type": "string", - "required": "required", - "desc": "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default column width)", + "required": "xor", + "desc": "Sizing mode: `pixel` (requires `--width`) / `standard` (reset to default column width). Passing --width alone is the common form; `--type standard` cannot be combined with `--width`", "enum": [ "pixel", "standard" ] }, - { - "name": "size", - "kind": "own", - "type": "int", - "required": "optional", - "desc": "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise", - "default": "0" - }, { "name": "range", "kind": "own", "type": "string", - "required": "required", - "desc": "Column closed range to resize; column letters like `A:E` or `C` (single column)" + "required": "xor", + "desc": "Column closed range to resize; column letters like `A:E` or `C` (single column). Required for the uniform form (with `--width` or `--type`); omit with the map form (`--widths`)" }, { "name": "dry-run", @@ -2739,7 +2860,7 @@ "kind": "own", "type": "string", "required": "required", - "desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", + "desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", "input": [ "file", "stdin" @@ -2759,6 +2880,13 @@ "required": "optional", "desc": "Font color (hex, e.g. `#000000`)" }, + { + "name": "font-family", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)" + }, { "name": "font-size", "kind": "own", @@ -2885,7 +3013,7 @@ "kind": "own", "type": "string", "required": "required", - "desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", + "desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", "input": [ "file", "stdin" @@ -2965,7 +3093,7 @@ "kind": "own", "type": "string", "required": "required", - "desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", + "desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!E2:E6\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", "input": [ "file", "stdin" @@ -3009,7 +3137,7 @@ "kind": "own", "type": "string", "required": "required", - "desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", + "desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", "input": [ "file", "stdin" @@ -3127,7 +3255,7 @@ "kind": "own", "type": "string", "required": "required", - "desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.", + "desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.", "input": [ "file", "stdin" @@ -4066,7 +4194,7 @@ "kind": "own", "type": "string", "required": "optional", - "desc": "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`" + "desc": "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`" }, { "name": "dry-run", @@ -4510,7 +4638,7 @@ "kind": "own", "type": "string", "required": "xor", - "desc": "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow" + "desc": "Image URI handle returned by the upload flow (not a sheet object reference_id; XOR with `--image-token`); converted to file_token automatically" }, { "name": "position-row", @@ -4626,15 +4754,15 @@ "name": "image-token", "kind": "own", "type": "string", - "required": "xor", - "desc": "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`" + "required": "optional", + "desc": "Optional image file_token; mutually exclusive with `--image-uri`; omit both to keep the current image. Common source: `image_token` returned by `+float-image-list`" }, { "name": "image-uri", "kind": "own", "type": "string", - "required": "xor", - "desc": "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow" + "required": "optional", + "desc": "Optional image URI handle returned by the upload flow (not a sheet object reference_id); mutually exclusive with `--image-token`; omit both to keep the current image; converted to file_token automatically" }, { "name": "position-row", @@ -4747,5 +4875,138 @@ "desc": "" } ] + }, + "+history-list": { + "risk": "read", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet locator" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet locator" + }, + { + "name": "end-version", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Max version to query (descending pagination). Omit on the first call; pass next_end_version from the previous response." + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+history-revert": { + "risk": "high-risk-write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet locator" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet locator" + }, + { + "name": "history-version-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "History version to revert to (from +history-list)." + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+history-revert-status": { + "risk": "read", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet locator" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet locator" + }, + { + "name": "transaction-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Async revert transaction id (from +history-revert)." + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+changeset-get": { + "risk": "read", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "start-revision", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Start version (CS revision); the before baseline for review (must be >= 1)" + }, + { + "name": "end-revision", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20", + "default": "-1" + } + ] } } diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json index 579d81a19..3d6180038 100644 --- a/shortcuts/sheets/data/flag-schemas.json +++ b/shortcuts/sheets/data/flag-schemas.json @@ -241,6 +241,10 @@ "description": "字体颜色(十六进制,例如 \"#000000\")", "type": "string" }, + "font_family": { + "description": "字体名称/字族(例如 \"Arial\"、\"微软雅黑\"、\"宋体\")", + "type": "string" + }, "font_size": { "description": "字体大小(单位:px/像素,例如 10、12、14)", "type": "number" @@ -1171,6 +1175,150 @@ "type": "number", "description": "数据点大小" }, + "fillGradient": { + "type": "object", + "description": "数据点填充渐变配置", + "properties": { + "type": { + "type": "string", + "enum": [ + "linear", + "radial" + ], + "description": "渐变类型" + }, + "x0": { + "type": "number", + "description": "起点 x,0-1 归一化坐标" + }, + "y0": { + "type": "number", + "description": "起点 y,0-1 归一化坐标" + }, + "x1": { + "type": "number", + "description": "终点 x,0-1 归一化坐标" + }, + "y1": { + "type": "number", + "description": "终点 y,0-1 归一化坐标" + }, + "r0": { + "type": "number", + "description": "径向渐变起点半径" + }, + "r1": { + "type": "number", + "description": "径向渐变终点半径" + }, + "gradientMethod": { + "type": "string", + "description": "渐变插值方法" + }, + "stops": { + "type": "array", + "description": "渐变色标数组,至少 2 个", + "minItems": 2, + "items": { + "type": "object", + "properties": { + "offset": { + "type": "number", + "description": "色标位置 0-1" + }, + "color": { + "type": "string", + "description": "色标颜色" + }, + "opacity": { + "type": "number", + "description": "色标透明度 0-1" + } + }, + "required": [ + "offset", + "color" + ] + } + } + }, + "required": [ + "type", + "stops" + ] + }, + "strokeGradient": { + "type": "object", + "description": "数据点描边渐变配置", + "properties": { + "type": { + "type": "string", + "enum": [ + "linear", + "radial" + ], + "description": "渐变类型" + }, + "x0": { + "type": "number", + "description": "起点 x,0-1 归一化坐标" + }, + "y0": { + "type": "number", + "description": "起点 y,0-1 归一化坐标" + }, + "x1": { + "type": "number", + "description": "终点 x,0-1 归一化坐标" + }, + "y1": { + "type": "number", + "description": "终点 y,0-1 归一化坐标" + }, + "r0": { + "type": "number", + "description": "径向渐变起点半径" + }, + "r1": { + "type": "number", + "description": "径向渐变终点半径" + }, + "gradientMethod": { + "type": "string", + "description": "渐变插值方法" + }, + "stops": { + "type": "array", + "description": "渐变色标数组,至少 2 个", + "minItems": 2, + "items": { + "type": "object", + "properties": { + "offset": { + "type": "number", + "description": "色标位置 0-1" + }, + "color": { + "type": "string", + "description": "色标颜色" + }, + "opacity": { + "type": "number", + "description": "色标透明度 0-1" + } + }, + "required": [ + "offset", + "color" + ] + } + } + }, + "required": [ + "type", + "stops" + ] + }, "point": { "type": "array", "description": "单个数据点配置数组", @@ -1192,6 +1340,78 @@ "size": { "type": "number", "description": "大小" + }, + "fillGradient": { + "type": "object", + "description": "单个数据点填充渐变配置", + "properties": { + "type": { + "type": "string", + "enum": [ + "linear", + "radial" + ], + "description": "渐变类型" + }, + "x0": { + "type": "number", + "description": "起点 x,0-1 归一化坐标" + }, + "y0": { + "type": "number", + "description": "起点 y,0-1 归一化坐标" + }, + "x1": { + "type": "number", + "description": "终点 x,0-1 归一化坐标" + }, + "y1": { + "type": "number", + "description": "终点 y,0-1 归一化坐标" + }, + "r0": { + "type": "number", + "description": "径向渐变起点半径" + }, + "r1": { + "type": "number", + "description": "径向渐变终点半径" + }, + "gradientMethod": { + "type": "string", + "description": "渐变插值方法" + }, + "stops": { + "type": "array", + "description": "渐变色标数组,至少 2 个", + "minItems": 2, + "items": { + "type": "object", + "properties": { + "offset": { + "type": "number", + "description": "色标位置 0-1" + }, + "color": { + "type": "string", + "description": "色标颜色" + }, + "opacity": { + "type": "number", + "description": "色标透明度 0-1" + } + }, + "required": [ + "offset", + "color" + ] + } + } + }, + "required": [ + "type", + "stops" + ] } }, "required": [ @@ -1230,6 +1450,78 @@ "zero", "link" ] + }, + "strokeGradient": { + "type": "object", + "description": "线条描边渐变配置", + "properties": { + "type": { + "type": "string", + "enum": [ + "linear", + "radial" + ], + "description": "渐变类型" + }, + "x0": { + "type": "number", + "description": "起点 x,0-1 归一化坐标" + }, + "y0": { + "type": "number", + "description": "起点 y,0-1 归一化坐标" + }, + "x1": { + "type": "number", + "description": "终点 x,0-1 归一化坐标" + }, + "y1": { + "type": "number", + "description": "终点 y,0-1 归一化坐标" + }, + "r0": { + "type": "number", + "description": "径向渐变起点半径" + }, + "r1": { + "type": "number", + "description": "径向渐变终点半径" + }, + "gradientMethod": { + "type": "string", + "description": "渐变插值方法" + }, + "stops": { + "type": "array", + "description": "渐变色标数组,至少 2 个", + "minItems": 2, + "items": { + "type": "object", + "properties": { + "offset": { + "type": "number", + "description": "色标位置 0-1" + }, + "color": { + "type": "string", + "description": "色标颜色" + }, + "opacity": { + "type": "number", + "description": "色标透明度 0-1" + } + }, + "required": [ + "offset", + "color" + ] + } + } + }, + "required": [ + "type", + "stops" + ] } } }, @@ -1240,6 +1532,78 @@ "color": { "type": "string", "description": "区域填充颜色" + }, + "fillGradient": { + "type": "object", + "description": "区域填充渐变", + "properties": { + "type": { + "type": "string", + "enum": [ + "linear", + "radial" + ], + "description": "渐变类型" + }, + "x0": { + "type": "number", + "description": "起点 x,0-1 归一化坐标" + }, + "y0": { + "type": "number", + "description": "起点 y,0-1 归一化坐标" + }, + "x1": { + "type": "number", + "description": "终点 x,0-1 归一化坐标" + }, + "y1": { + "type": "number", + "description": "终点 y,0-1 归一化坐标" + }, + "r0": { + "type": "number", + "description": "径向渐变起点半径" + }, + "r1": { + "type": "number", + "description": "径向渐变终点半径" + }, + "gradientMethod": { + "type": "string", + "description": "渐变插值方法" + }, + "stops": { + "type": "array", + "description": "渐变色标数组,至少 2 个", + "minItems": 2, + "items": { + "type": "object", + "properties": { + "offset": { + "type": "number", + "description": "色标位置 0-1" + }, + "color": { + "type": "string", + "description": "色标颜色" + }, + "opacity": { + "type": "number", + "description": "色标透明度 0-1" + } + }, + "required": [ + "offset", + "color" + ] + } + } + }, + "required": [ + "type", + "stops" + ] } } }, @@ -1280,6 +1644,150 @@ "type": "string", "description": "背景颜色" }, + "fillGradient": { + "type": "object", + "description": "柱子填充渐变", + "properties": { + "type": { + "type": "string", + "enum": [ + "linear", + "radial" + ], + "description": "渐变类型" + }, + "x0": { + "type": "number", + "description": "起点 x,0-1 归一化坐标" + }, + "y0": { + "type": "number", + "description": "起点 y,0-1 归一化坐标" + }, + "x1": { + "type": "number", + "description": "终点 x,0-1 归一化坐标" + }, + "y1": { + "type": "number", + "description": "终点 y,0-1 归一化坐标" + }, + "r0": { + "type": "number", + "description": "径向渐变起点半径" + }, + "r1": { + "type": "number", + "description": "径向渐变终点半径" + }, + "gradientMethod": { + "type": "string", + "description": "渐变插值方法" + }, + "stops": { + "type": "array", + "description": "渐变色标数组,至少 2 个", + "minItems": 2, + "items": { + "type": "object", + "properties": { + "offset": { + "type": "number", + "description": "色标位置 0-1" + }, + "color": { + "type": "string", + "description": "色标颜色" + }, + "opacity": { + "type": "number", + "description": "色标透明度 0-1" + } + }, + "required": [ + "offset", + "color" + ] + } + } + }, + "required": [ + "type", + "stops" + ] + }, + "strokeGradient": { + "type": "object", + "description": "柱子描边渐变", + "properties": { + "type": { + "type": "string", + "enum": [ + "linear", + "radial" + ], + "description": "渐变类型" + }, + "x0": { + "type": "number", + "description": "起点 x,0-1 归一化坐标" + }, + "y0": { + "type": "number", + "description": "起点 y,0-1 归一化坐标" + }, + "x1": { + "type": "number", + "description": "终点 x,0-1 归一化坐标" + }, + "y1": { + "type": "number", + "description": "终点 y,0-1 归一化坐标" + }, + "r0": { + "type": "number", + "description": "径向渐变起点半径" + }, + "r1": { + "type": "number", + "description": "径向渐变终点半径" + }, + "gradientMethod": { + "type": "string", + "description": "渐变插值方法" + }, + "stops": { + "type": "array", + "description": "渐变色标数组,至少 2 个", + "minItems": 2, + "items": { + "type": "object", + "properties": { + "offset": { + "type": "number", + "description": "色标位置 0-1" + }, + "color": { + "type": "string", + "description": "色标颜色" + }, + "opacity": { + "type": "number", + "description": "色标透明度 0-1" + } + }, + "required": [ + "offset", + "color" + ] + } + } + }, + "required": [ + "type", + "stops" + ] + }, "bar": { "type": "array", "description": "单个柱子配置数组", @@ -1305,6 +1813,78 @@ "borderStyle": { "type": "string", "description": "边框样式" + }, + "fillGradient": { + "type": "object", + "description": "单个柱子填充渐变", + "properties": { + "type": { + "type": "string", + "enum": [ + "linear", + "radial" + ], + "description": "渐变类型" + }, + "x0": { + "type": "number", + "description": "起点 x,0-1 归一化坐标" + }, + "y0": { + "type": "number", + "description": "起点 y,0-1 归一化坐标" + }, + "x1": { + "type": "number", + "description": "终点 x,0-1 归一化坐标" + }, + "y1": { + "type": "number", + "description": "终点 y,0-1 归一化坐标" + }, + "r0": { + "type": "number", + "description": "径向渐变起点半径" + }, + "r1": { + "type": "number", + "description": "径向渐变终点半径" + }, + "gradientMethod": { + "type": "string", + "description": "渐变插值方法" + }, + "stops": { + "type": "array", + "description": "渐变色标数组,至少 2 个", + "minItems": 2, + "items": { + "type": "object", + "properties": { + "offset": { + "type": "number", + "description": "色标位置 0-1" + }, + "color": { + "type": "string", + "description": "色标颜色" + }, + "opacity": { + "type": "number", + "description": "色标透明度 0-1" + } + }, + "required": [ + "offset", + "color" + ] + } + } + }, + "required": [ + "type", + "stops" + ] } }, "required": [ @@ -1316,7 +1896,7 @@ }, "labels": { "type": "object", - "description": "数据标签配置", + "description": "数据标签配置。labels 对象的存在性即开关:不显示数据标签时省略整个 labels 字段;一旦传入 labels(即便 value/category/series/percentage 全部置为 false),数据标签仍会显示,且默认兜底显示 value。", "properties": { "position": { "type": "string", @@ -1420,7 +2000,7 @@ }, "labels": { "type": "object", - "description": "数据标签配置" + "description": "数据标签配置(覆盖单系列,配置项同 plotArea.plot.labels)。不覆盖该系列数据标签时省略整个 labels 字段;一旦传入 labels(即便显示开关全部置为 false),该系列仍会显示数据标签。" }, "sectors": { "type": "object", @@ -1442,6 +2022,78 @@ "type": "number", "description": "起始角度,0-359" }, + "fillGradient": { + "type": "object", + "description": "扇区填充渐变", + "properties": { + "type": { + "type": "string", + "enum": [ + "linear", + "radial" + ], + "description": "渐变类型" + }, + "x0": { + "type": "number", + "description": "起点 x,0-1 归一化坐标" + }, + "y0": { + "type": "number", + "description": "起点 y,0-1 归一化坐标" + }, + "x1": { + "type": "number", + "description": "终点 x,0-1 归一化坐标" + }, + "y1": { + "type": "number", + "description": "终点 y,0-1 归一化坐标" + }, + "r0": { + "type": "number", + "description": "径向渐变起点半径" + }, + "r1": { + "type": "number", + "description": "径向渐变终点半径" + }, + "gradientMethod": { + "type": "string", + "description": "渐变插值方法" + }, + "stops": { + "type": "array", + "description": "渐变色标数组,至少 2 个", + "minItems": 2, + "items": { + "type": "object", + "properties": { + "offset": { + "type": "number", + "description": "色标位置 0-1" + }, + "color": { + "type": "string", + "description": "色标颜色" + }, + "opacity": { + "type": "number", + "description": "色标透明度 0-1" + } + }, + "required": [ + "offset", + "color" + ] + } + } + }, + "required": [ + "type", + "stops" + ] + }, "sector": { "type": "array", "description": "单个扇区配置数组", @@ -1463,6 +2115,78 @@ "color": { "type": "string", "description": "颜色" + }, + "fillGradient": { + "type": "object", + "description": "单个扇区填充渐变", + "properties": { + "type": { + "type": "string", + "enum": [ + "linear", + "radial" + ], + "description": "渐变类型" + }, + "x0": { + "type": "number", + "description": "起点 x,0-1 归一化坐标" + }, + "y0": { + "type": "number", + "description": "起点 y,0-1 归一化坐标" + }, + "x1": { + "type": "number", + "description": "终点 x,0-1 归一化坐标" + }, + "y1": { + "type": "number", + "description": "终点 y,0-1 归一化坐标" + }, + "r0": { + "type": "number", + "description": "径向渐变起点半径" + }, + "r1": { + "type": "number", + "description": "径向渐变终点半径" + }, + "gradientMethod": { + "type": "string", + "description": "渐变插值方法" + }, + "stops": { + "type": "array", + "description": "渐变色标数组,至少 2 个", + "minItems": 2, + "items": { + "type": "object", + "properties": { + "offset": { + "type": "number", + "description": "色标位置 0-1" + }, + "color": { + "type": "string", + "description": "色标颜色" + }, + "opacity": { + "type": "number", + "description": "色标透明度 0-1" + } + }, + "required": [ + "offset", + "color" + ] + } + } + }, + "required": [ + "type", + "stops" + ] } }, "required": [ @@ -2207,6 +2931,150 @@ "type": "number", "description": "数据点大小" }, + "fillGradient": { + "type": "object", + "description": "数据点填充渐变配置", + "properties": { + "type": { + "type": "string", + "enum": [ + "linear", + "radial" + ], + "description": "渐变类型" + }, + "x0": { + "type": "number", + "description": "起点 x,0-1 归一化坐标" + }, + "y0": { + "type": "number", + "description": "起点 y,0-1 归一化坐标" + }, + "x1": { + "type": "number", + "description": "终点 x,0-1 归一化坐标" + }, + "y1": { + "type": "number", + "description": "终点 y,0-1 归一化坐标" + }, + "r0": { + "type": "number", + "description": "径向渐变起点半径" + }, + "r1": { + "type": "number", + "description": "径向渐变终点半径" + }, + "gradientMethod": { + "type": "string", + "description": "渐变插值方法" + }, + "stops": { + "type": "array", + "description": "渐变色标数组,至少 2 个", + "minItems": 2, + "items": { + "type": "object", + "properties": { + "offset": { + "type": "number", + "description": "色标位置 0-1" + }, + "color": { + "type": "string", + "description": "色标颜色" + }, + "opacity": { + "type": "number", + "description": "色标透明度 0-1" + } + }, + "required": [ + "offset", + "color" + ] + } + } + }, + "required": [ + "type", + "stops" + ] + }, + "strokeGradient": { + "type": "object", + "description": "数据点描边渐变配置", + "properties": { + "type": { + "type": "string", + "enum": [ + "linear", + "radial" + ], + "description": "渐变类型" + }, + "x0": { + "type": "number", + "description": "起点 x,0-1 归一化坐标" + }, + "y0": { + "type": "number", + "description": "起点 y,0-1 归一化坐标" + }, + "x1": { + "type": "number", + "description": "终点 x,0-1 归一化坐标" + }, + "y1": { + "type": "number", + "description": "终点 y,0-1 归一化坐标" + }, + "r0": { + "type": "number", + "description": "径向渐变起点半径" + }, + "r1": { + "type": "number", + "description": "径向渐变终点半径" + }, + "gradientMethod": { + "type": "string", + "description": "渐变插值方法" + }, + "stops": { + "type": "array", + "description": "渐变色标数组,至少 2 个", + "minItems": 2, + "items": { + "type": "object", + "properties": { + "offset": { + "type": "number", + "description": "色标位置 0-1" + }, + "color": { + "type": "string", + "description": "色标颜色" + }, + "opacity": { + "type": "number", + "description": "色标透明度 0-1" + } + }, + "required": [ + "offset", + "color" + ] + } + } + }, + "required": [ + "type", + "stops" + ] + }, "point": { "type": "array", "description": "单个数据点配置数组", @@ -2228,6 +3096,78 @@ "size": { "type": "number", "description": "大小" + }, + "fillGradient": { + "type": "object", + "description": "单个数据点填充渐变配置", + "properties": { + "type": { + "type": "string", + "enum": [ + "linear", + "radial" + ], + "description": "渐变类型" + }, + "x0": { + "type": "number", + "description": "起点 x,0-1 归一化坐标" + }, + "y0": { + "type": "number", + "description": "起点 y,0-1 归一化坐标" + }, + "x1": { + "type": "number", + "description": "终点 x,0-1 归一化坐标" + }, + "y1": { + "type": "number", + "description": "终点 y,0-1 归一化坐标" + }, + "r0": { + "type": "number", + "description": "径向渐变起点半径" + }, + "r1": { + "type": "number", + "description": "径向渐变终点半径" + }, + "gradientMethod": { + "type": "string", + "description": "渐变插值方法" + }, + "stops": { + "type": "array", + "description": "渐变色标数组,至少 2 个", + "minItems": 2, + "items": { + "type": "object", + "properties": { + "offset": { + "type": "number", + "description": "色标位置 0-1" + }, + "color": { + "type": "string", + "description": "色标颜色" + }, + "opacity": { + "type": "number", + "description": "色标透明度 0-1" + } + }, + "required": [ + "offset", + "color" + ] + } + } + }, + "required": [ + "type", + "stops" + ] } }, "required": [ @@ -2266,6 +3206,78 @@ "zero", "link" ] + }, + "strokeGradient": { + "type": "object", + "description": "线条描边渐变配置", + "properties": { + "type": { + "type": "string", + "enum": [ + "linear", + "radial" + ], + "description": "渐变类型" + }, + "x0": { + "type": "number", + "description": "起点 x,0-1 归一化坐标" + }, + "y0": { + "type": "number", + "description": "起点 y,0-1 归一化坐标" + }, + "x1": { + "type": "number", + "description": "终点 x,0-1 归一化坐标" + }, + "y1": { + "type": "number", + "description": "终点 y,0-1 归一化坐标" + }, + "r0": { + "type": "number", + "description": "径向渐变起点半径" + }, + "r1": { + "type": "number", + "description": "径向渐变终点半径" + }, + "gradientMethod": { + "type": "string", + "description": "渐变插值方法" + }, + "stops": { + "type": "array", + "description": "渐变色标数组,至少 2 个", + "minItems": 2, + "items": { + "type": "object", + "properties": { + "offset": { + "type": "number", + "description": "色标位置 0-1" + }, + "color": { + "type": "string", + "description": "色标颜色" + }, + "opacity": { + "type": "number", + "description": "色标透明度 0-1" + } + }, + "required": [ + "offset", + "color" + ] + } + } + }, + "required": [ + "type", + "stops" + ] } } }, @@ -2276,6 +3288,78 @@ "color": { "type": "string", "description": "区域填充颜色" + }, + "fillGradient": { + "type": "object", + "description": "区域填充渐变", + "properties": { + "type": { + "type": "string", + "enum": [ + "linear", + "radial" + ], + "description": "渐变类型" + }, + "x0": { + "type": "number", + "description": "起点 x,0-1 归一化坐标" + }, + "y0": { + "type": "number", + "description": "起点 y,0-1 归一化坐标" + }, + "x1": { + "type": "number", + "description": "终点 x,0-1 归一化坐标" + }, + "y1": { + "type": "number", + "description": "终点 y,0-1 归一化坐标" + }, + "r0": { + "type": "number", + "description": "径向渐变起点半径" + }, + "r1": { + "type": "number", + "description": "径向渐变终点半径" + }, + "gradientMethod": { + "type": "string", + "description": "渐变插值方法" + }, + "stops": { + "type": "array", + "description": "渐变色标数组,至少 2 个", + "minItems": 2, + "items": { + "type": "object", + "properties": { + "offset": { + "type": "number", + "description": "色标位置 0-1" + }, + "color": { + "type": "string", + "description": "色标颜色" + }, + "opacity": { + "type": "number", + "description": "色标透明度 0-1" + } + }, + "required": [ + "offset", + "color" + ] + } + } + }, + "required": [ + "type", + "stops" + ] } } }, @@ -2316,6 +3400,150 @@ "type": "string", "description": "背景颜色" }, + "fillGradient": { + "type": "object", + "description": "柱子填充渐变", + "properties": { + "type": { + "type": "string", + "enum": [ + "linear", + "radial" + ], + "description": "渐变类型" + }, + "x0": { + "type": "number", + "description": "起点 x,0-1 归一化坐标" + }, + "y0": { + "type": "number", + "description": "起点 y,0-1 归一化坐标" + }, + "x1": { + "type": "number", + "description": "终点 x,0-1 归一化坐标" + }, + "y1": { + "type": "number", + "description": "终点 y,0-1 归一化坐标" + }, + "r0": { + "type": "number", + "description": "径向渐变起点半径" + }, + "r1": { + "type": "number", + "description": "径向渐变终点半径" + }, + "gradientMethod": { + "type": "string", + "description": "渐变插值方法" + }, + "stops": { + "type": "array", + "description": "渐变色标数组,至少 2 个", + "minItems": 2, + "items": { + "type": "object", + "properties": { + "offset": { + "type": "number", + "description": "色标位置 0-1" + }, + "color": { + "type": "string", + "description": "色标颜色" + }, + "opacity": { + "type": "number", + "description": "色标透明度 0-1" + } + }, + "required": [ + "offset", + "color" + ] + } + } + }, + "required": [ + "type", + "stops" + ] + }, + "strokeGradient": { + "type": "object", + "description": "柱子描边渐变", + "properties": { + "type": { + "type": "string", + "enum": [ + "linear", + "radial" + ], + "description": "渐变类型" + }, + "x0": { + "type": "number", + "description": "起点 x,0-1 归一化坐标" + }, + "y0": { + "type": "number", + "description": "起点 y,0-1 归一化坐标" + }, + "x1": { + "type": "number", + "description": "终点 x,0-1 归一化坐标" + }, + "y1": { + "type": "number", + "description": "终点 y,0-1 归一化坐标" + }, + "r0": { + "type": "number", + "description": "径向渐变起点半径" + }, + "r1": { + "type": "number", + "description": "径向渐变终点半径" + }, + "gradientMethod": { + "type": "string", + "description": "渐变插值方法" + }, + "stops": { + "type": "array", + "description": "渐变色标数组,至少 2 个", + "minItems": 2, + "items": { + "type": "object", + "properties": { + "offset": { + "type": "number", + "description": "色标位置 0-1" + }, + "color": { + "type": "string", + "description": "色标颜色" + }, + "opacity": { + "type": "number", + "description": "色标透明度 0-1" + } + }, + "required": [ + "offset", + "color" + ] + } + } + }, + "required": [ + "type", + "stops" + ] + }, "bar": { "type": "array", "description": "单个柱子配置数组", @@ -2341,6 +3569,78 @@ "borderStyle": { "type": "string", "description": "边框样式" + }, + "fillGradient": { + "type": "object", + "description": "单个柱子填充渐变", + "properties": { + "type": { + "type": "string", + "enum": [ + "linear", + "radial" + ], + "description": "渐变类型" + }, + "x0": { + "type": "number", + "description": "起点 x,0-1 归一化坐标" + }, + "y0": { + "type": "number", + "description": "起点 y,0-1 归一化坐标" + }, + "x1": { + "type": "number", + "description": "终点 x,0-1 归一化坐标" + }, + "y1": { + "type": "number", + "description": "终点 y,0-1 归一化坐标" + }, + "r0": { + "type": "number", + "description": "径向渐变起点半径" + }, + "r1": { + "type": "number", + "description": "径向渐变终点半径" + }, + "gradientMethod": { + "type": "string", + "description": "渐变插值方法" + }, + "stops": { + "type": "array", + "description": "渐变色标数组,至少 2 个", + "minItems": 2, + "items": { + "type": "object", + "properties": { + "offset": { + "type": "number", + "description": "色标位置 0-1" + }, + "color": { + "type": "string", + "description": "色标颜色" + }, + "opacity": { + "type": "number", + "description": "色标透明度 0-1" + } + }, + "required": [ + "offset", + "color" + ] + } + } + }, + "required": [ + "type", + "stops" + ] } }, "required": [ @@ -2352,7 +3652,7 @@ }, "labels": { "type": "object", - "description": "数据标签配置", + "description": "数据标签配置。labels 对象的存在性即开关:不显示数据标签时省略整个 labels 字段;一旦传入 labels(即便 value/category/series/percentage 全部置为 false),数据标签仍会显示,且默认兜底显示 value。", "properties": { "position": { "type": "string", @@ -2456,7 +3756,7 @@ }, "labels": { "type": "object", - "description": "数据标签配置" + "description": "数据标签配置(覆盖单系列,配置项同 plotArea.plot.labels)。不覆盖该系列数据标签时省略整个 labels 字段;一旦传入 labels(即便显示开关全部置为 false),该系列仍会显示数据标签。" }, "sectors": { "type": "object", @@ -2478,6 +3778,78 @@ "type": "number", "description": "起始角度,0-359" }, + "fillGradient": { + "type": "object", + "description": "扇区填充渐变", + "properties": { + "type": { + "type": "string", + "enum": [ + "linear", + "radial" + ], + "description": "渐变类型" + }, + "x0": { + "type": "number", + "description": "起点 x,0-1 归一化坐标" + }, + "y0": { + "type": "number", + "description": "起点 y,0-1 归一化坐标" + }, + "x1": { + "type": "number", + "description": "终点 x,0-1 归一化坐标" + }, + "y1": { + "type": "number", + "description": "终点 y,0-1 归一化坐标" + }, + "r0": { + "type": "number", + "description": "径向渐变起点半径" + }, + "r1": { + "type": "number", + "description": "径向渐变终点半径" + }, + "gradientMethod": { + "type": "string", + "description": "渐变插值方法" + }, + "stops": { + "type": "array", + "description": "渐变色标数组,至少 2 个", + "minItems": 2, + "items": { + "type": "object", + "properties": { + "offset": { + "type": "number", + "description": "色标位置 0-1" + }, + "color": { + "type": "string", + "description": "色标颜色" + }, + "opacity": { + "type": "number", + "description": "色标透明度 0-1" + } + }, + "required": [ + "offset", + "color" + ] + } + } + }, + "required": [ + "type", + "stops" + ] + }, "sector": { "type": "array", "description": "单个扇区配置数组", @@ -2499,6 +3871,78 @@ "color": { "type": "string", "description": "颜色" + }, + "fillGradient": { + "type": "object", + "description": "单个扇区填充渐变", + "properties": { + "type": { + "type": "string", + "enum": [ + "linear", + "radial" + ], + "description": "渐变类型" + }, + "x0": { + "type": "number", + "description": "起点 x,0-1 归一化坐标" + }, + "y0": { + "type": "number", + "description": "起点 y,0-1 归一化坐标" + }, + "x1": { + "type": "number", + "description": "终点 x,0-1 归一化坐标" + }, + "y1": { + "type": "number", + "description": "终点 y,0-1 归一化坐标" + }, + "r0": { + "type": "number", + "description": "径向渐变起点半径" + }, + "r1": { + "type": "number", + "description": "径向渐变终点半径" + }, + "gradientMethod": { + "type": "string", + "description": "渐变插值方法" + }, + "stops": { + "type": "array", + "description": "渐变色标数组,至少 2 个", + "minItems": 2, + "items": { + "type": "object", + "properties": { + "offset": { + "type": "number", + "description": "色标位置 0-1" + }, + "color": { + "type": "string", + "description": "色标颜色" + }, + "opacity": { + "type": "number", + "description": "色标透明度 0-1" + } + }, + "required": [ + "offset", + "color" + ] + } + } + }, + "required": [ + "type", + "stops" + ] } }, "required": [ @@ -2842,6 +4286,28 @@ } } }, + "+cols-resize": { + "widths": { + "type": "object", + "description": "列 → 宽度 map。键:单列(\"A\")或列闭区间(\"C:E\");值:正整数像素宽(如 80 / 120 / 200;⚠️ 单位是像素,不是 Excel 字符单位,像素 ≈ 字符数×8+16)或 \"standard\"(重置为默认列宽)。列宽不支持 \"auto\"。", + "additionalProperties": { + "oneOf": [ + { + "type": "integer", + "minimum": 1, + "description": "像素宽度(宽度 < 20px 会被 CLI 拒绝并提示疑似字符单位)" + }, + { + "type": "string", + "enum": [ + "standard" + ], + "description": "重置为默认列宽" + } + ] + } + } + }, "+cond-format-create": { "properties": { "description": "创建/更新的条件格式属性。", @@ -5699,6 +7165,29 @@ "description": "排序条件列表(仅 sort 操作)。支持多级排序,靠前的条件优先级更高。" } }, + "+rows-resize": { + "heights": { + "type": "object", + "description": "行 → 高度 map。键:单行(\"1\")或行闭区间(\"2:20\");值:正整数像素高(如 30 / 50;⚠️ 单位是像素,不是磅/points)、\"auto\"(自适应内容)或 \"standard\"(重置为默认行高)。", + "additionalProperties": { + "oneOf": [ + { + "type": "integer", + "minimum": 1, + "description": "像素高度" + }, + { + "type": "string", + "enum": [ + "standard", + "auto" + ], + "description": "standard=重置默认行高;auto=自适应内容" + } + ] + } + } + }, "+sparkline-create": { "properties": { "description": "创建/更新/部分删除的迷你图属性。delete 时不传 sparklines 即删整组,传则删指定项。", @@ -6498,6 +7987,9 @@ "font_color": { "type": "string" }, + "font_family": { + "type": "string" + }, "font_line": { "enum": [ "none", @@ -6867,6 +8359,9 @@ "font_color": { "type": "string" }, + "font_family": { + "type": "string" + }, "font_line": { "enum": [ "none", diff --git a/shortcuts/sheets/execute_paths_test.go b/shortcuts/sheets/execute_paths_test.go index c84d994c0..17b61f463 100644 --- a/shortcuts/sheets/execute_paths_test.go +++ b/shortcuts/sheets/execute_paths_test.go @@ -93,6 +93,36 @@ func TestExecute_WikiURLResolvesToSheet(t *testing.T) { } } +// TestExecute_RevisionGet_WikiURL guards RevisionGet's custom Execute hook: +// the wiki node token must be resolved before get_workbook_structure runs. +func TestExecute_RevisionGet_WikiURL(t *testing.T) { + t.Parallel() + getNode := &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/get_node", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "obj_type": "sheet", + "obj_token": testToken, + }, + }, + }, + } + tool := toolOutputStub(testToken, "read", `{"revision":60}`) + out, err := runShortcutWithStubs(t, RevisionGet, + []string{"--url", "https://example.feishu.cn/wiki/wikTestNODE"}, getNode, tool) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + data := decodeEnvelopeData(t, out) + if data["revision"] != float64(60) { + t.Fatalf("revision = %v, want 60; out=%s", data["revision"], out) + } +} + // TestExecute_WikiURLWrongObjType rejects a wiki node that resolves to a // non-spreadsheet obj_type before any tool invoke. func TestExecute_WikiURLWrongObjType(t *testing.T) { diff --git a/shortcuts/sheets/flag_defs_gen.go b/shortcuts/sheets/flag_defs_gen.go index bc69ffe8b..a1336e3ea 100644 --- a/shortcuts/sheets/flag_defs_gen.go +++ b/shortcuts/sheets/flag_defs_gen.go @@ -27,7 +27,7 @@ var flagDefs = map[string]commandDef{ Flags: []flagDef{ {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, - {Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}}, + {Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}}, {Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}}, {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); batch clear is irreversible"}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, @@ -38,9 +38,10 @@ var flagDefs = map[string]commandDef{ Flags: []flagDef{ {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, - {Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}}, + {Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}}, {Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"}, {Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"}, + {Name: "font-family", Kind: "own", Type: "string", Required: "optional", Desc: "Font family name (e.g. `Arial`, `Microsoft YaHei`)"}, {Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"}, {Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}}, {Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}}, @@ -165,6 +166,7 @@ var flagDefs = map[string]commandDef{ {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A1:B2`)"}, {Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"}, {Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"}, + {Name: "font-family", Kind: "own", Type: "string", Required: "optional", Desc: "Font family name (e.g. `Arial`, `Microsoft YaHei`)"}, {Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"}, {Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}}, {Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}}, @@ -188,6 +190,15 @@ var flagDefs = map[string]commandDef{ {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, }, }, + "+changeset-get": { + Risk: "read", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "start-revision", Kind: "own", Type: "int", Required: "required", Desc: "Start version (CS revision); the before baseline for review (must be >= 1)"}, + {Name: "end-revision", Kind: "own", Type: "int", Required: "optional", Desc: "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20", Default: "-1"}, + }, + }, "+chart-create": { Risk: "write", Flags: []flagDef{ @@ -195,7 +206,7 @@ var flagDefs = map[string]commandDef{ {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, - {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}}, + {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"}, }, }, @@ -241,9 +252,10 @@ var flagDefs = map[string]commandDef{ {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, - {Name: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default column width)", Enum: []string{"pixel", "standard"}}, - {Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise", Default: "0"}, - {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Column closed range to resize; column letters like `A:E` or `C` (single column)"}, + {Name: "width", Kind: "own", Type: "int", Required: "xor", Desc: "Uniform column width in pixels (e.g. 80 / 120 / 200; NOT Excel character units), used with `--range`. Passing --width implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-column widths use `--widths`", Default: "0"}, + {Name: "widths", Kind: "own", Type: "string", Required: "xor", Desc: "Per-column width map — set different widths for many columns in one atomic call. Keys: single column (`\"A\"`) or closed range (`\"C:E\"`); values: pixel width (e.g. 80 / 120 / 200) or `\"standard\"` (reset to default). Units are pixels, NOT Excel character units (px ≈ chars × 8 + 16). Mutually exclusive with `--range` / `--width` / `--type`", Input: []string{"file", "stdin"}}, + {Name: "type", Kind: "own", Type: "string", Required: "xor", Desc: "Sizing mode: `pixel` (requires `--width`) / `standard` (reset to default column width). Passing --width alone is the common form; `--type standard` cannot be combined with `--width`", Enum: []string{"pixel", "standard"}}, + {Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Column closed range to resize; column letters like `A:E` or `C` (single column). Required for the uniform form (with `--width` or `--type`); omit with the map form (`--widths`)"}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, }, }, @@ -405,7 +417,7 @@ var flagDefs = map[string]commandDef{ {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, - {Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (outermost)", Default: "1"}, + {Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)", Default: "1"}, {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, }, @@ -426,7 +438,7 @@ var flagDefs = map[string]commandDef{ Flags: []flagDef{ {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, - {Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}}, + {Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!E2:E6\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", Input: []string{"file", "stdin"}}, {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, }, @@ -463,7 +475,7 @@ var flagDefs = map[string]commandDef{ Flags: []flagDef{ {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, - {Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}}, + {Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", Input: []string{"file", "stdin"}}, {Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}}, {Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}}, {Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select"}, @@ -526,7 +538,7 @@ var flagDefs = map[string]commandDef{ {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}}, {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; required on create and must cover the header row"}, - {Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"}, + {Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`"}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, }, }, @@ -576,7 +588,7 @@ var flagDefs = map[string]commandDef{ {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, {Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"}, {Name: "image-token", Kind: "own", Type: "string", Required: "xor", Desc: "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"}, - {Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"}, + {Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image URI handle returned by the upload flow (not a sheet object reference_id; XOR with `--image-token`); converted to file_token automatically"}, {Name: "position-row", Kind: "own", Type: "int", Required: "required", Desc: "Row anchor of the image's top-left corner (0-based)"}, {Name: "position-col", Kind: "own", Type: "string", Required: "required", Desc: "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)"}, {Name: "size-width", Kind: "own", Type: "int", Required: "required", Desc: "Image width in pixels"}, @@ -620,8 +632,8 @@ var flagDefs = map[string]commandDef{ {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, {Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"}, {Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"}, - {Name: "image-token", Kind: "own", Type: "string", Required: "xor", Desc: "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"}, - {Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"}, + {Name: "image-token", Kind: "own", Type: "string", Required: "optional", Desc: "Optional image file_token; mutually exclusive with `--image-uri`; omit both to keep the current image. Common source: `image_token` returned by `+float-image-list`"}, + {Name: "image-uri", Kind: "own", Type: "string", Required: "optional", Desc: "Optional image URI handle returned by the upload flow (not a sheet object reference_id); mutually exclusive with `--image-token`; omit both to keep the current image; converted to file_token automatically"}, {Name: "position-row", Kind: "own", Type: "int", Required: "required", Desc: "Row anchor of the image's top-left corner (0-based)"}, {Name: "position-col", Kind: "own", Type: "string", Required: "required", Desc: "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)"}, {Name: "size-width", Kind: "own", Type: "int", Required: "required", Desc: "Image width in pixels"}, @@ -632,6 +644,45 @@ var flagDefs = map[string]commandDef{ {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, }, }, + "+formula-verify": { + Risk: "read", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string_slice", Required: "optional", Desc: "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."}, + {Name: "sheet-name", Kind: "public", Type: "string_slice", Required: "optional", Desc: "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."}, + {Name: "range", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region."}, + {Name: "max-locations", Kind: "own", Type: "int", Required: "optional", Desc: "Max locations / samples per error type; default 20.", Default: "20"}, + {Name: "exit-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes."}, + }, + }, + "+history-list": { + Risk: "read", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"}, + {Name: "end-version", Kind: "own", Type: "int", Required: "optional", Desc: "Max version to query (descending pagination). Omit on the first call; pass next_end_version from the previous response."}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+history-revert": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"}, + {Name: "history-version-id", Kind: "own", Type: "string", Required: "required", Desc: "History version to revert to (from +history-list)."}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+history-revert-status": { + Risk: "read", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"}, + {Name: "transaction-id", Kind: "own", Type: "string", Required: "required", Desc: "Async revert transaction id (from +history-revert)."}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, "+pivot-create": { Risk: "write", Flags: []flagDef{ @@ -734,6 +785,14 @@ var flagDefs = map[string]commandDef{ {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, }, }, + "+revision-get": { + Risk: "read", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, "+rows-resize": { Risk: "write", Flags: []flagDef{ @@ -741,9 +800,10 @@ var flagDefs = map[string]commandDef{ {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, - {Name: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default row height) / `auto` (fit content)", Enum: []string{"pixel", "standard", "auto"}}, - {Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Row height in pixels (e.g. 30 / 40 / 60); required when `--type pixel`, ignored otherwise", Default: "0"}, - {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row)"}, + {Name: "height", Kind: "own", Type: "int", Required: "xor", Desc: "Uniform row height in pixels (e.g. 30 / 40 / 60; NOT points), used with `--range`. Passing --height implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-row heights use `--heights`", Default: "0"}, + {Name: "heights", Kind: "own", Type: "string", Required: "xor", Desc: "Per-row height map — set different heights for many rows in one atomic call. Keys: single row (`\"1\"`) or closed range (`\"2:20\"`); values: pixel height (e.g. 30 / 50), `\"auto\"` (fit content) or `\"standard\"` (reset to default). Units are pixels, NOT points. Mutually exclusive with `--range` / `--height` / `--type`", Input: []string{"file", "stdin"}}, + {Name: "type", Kind: "own", Type: "string", Required: "xor", Desc: "Sizing mode: `pixel` (requires `--height`) / `standard` (reset to default row height) / `auto` (fit content). Passing --height alone is the common form; `--type standard` / `--type auto` cannot be combined with `--height`", Enum: []string{"pixel", "standard", "auto"}}, + {Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row). Required for the uniform form (with `--height` or `--type`); omit with the map form (`--heights`)"}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, }, }, @@ -768,6 +828,7 @@ var flagDefs = map[string]commandDef{ {Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position (0-based); appended to the end when omitted", Default: "-1"}, {Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"}, {Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"}, + {Name: "type", Kind: "own", Type: "string", Required: "optional", Desc: "New sub-sheet type: sheet (spreadsheet); default sheet.", Default: "sheet", Enum: []string{"sheet"}}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, }, }, @@ -822,7 +883,7 @@ var flagDefs = map[string]commandDef{ {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, {Name: "index", Kind: "own", Type: "int", Required: "required", Desc: "Target position (0-based)"}, - {Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`", Default: "-1"}, + {Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it", Default: "-1"}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, }, }, @@ -941,7 +1002,7 @@ var flagDefs = map[string]commandDef{ Flags: []flagDef{ {Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"}, {Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"}, - {Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}}, + {Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected (dates / numbers land as text — use --sheets to preserve types), through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}}, {Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}}, {Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).", Input: []string{"file", "stdin"}}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, diff --git a/shortcuts/sheets/flag_ergonomics.go b/shortcuts/sheets/flag_ergonomics.go new file mode 100644 index 000000000..0888e7493 --- /dev/null +++ b/shortcuts/sheets/flag_ergonomics.go @@ -0,0 +1,238 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "fmt" + "slices" + "sort" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/suggest" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// ─── sheets flag ergonomics ───────────────────────────────────────────── +// +// Eval traces show two recovery loops that burn agent round-trips on the +// sheets domain specifically: hallucinated flag names (--cols for --range, +// --file for --csv) whose unknown-flag error only points at --help, and +// enum values imported from CSS / Excel vocabulary ("center" for the +// vertical alignment Lark spells "middle"). Both fixes are wired through +// the existing PostMount hook — composed onto any prior PostMount in +// Shortcuts(), same pattern as withTokenAlias — so the common framework +// needs no change at all and no other domain's behavior shifts. + +// withFlagErgonomics wraps an optional PostMount so that, after it runs, +// the command gets the sheets-specific unknown-flag error (valid flags +// inlined) and enum-value normalization (canonical vocabulary auto-applied, +// typos suggested). +func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) { + return func(cmd *cobra.Command) { + if prev != nil { + prev(cmd) + } + cmd.SetFlagErrorFunc(sheetsFlagErrorFunc) + chainEnumNormalization(cmd) + } +} + +// sheetsFlagErrorFunc overrides the root FlagErrorFunc for sheets commands. +// It keeps the root behavior (typed error, did-you-mean suggestions, the +// offending flag on params) and additionally inlines the full valid-flag +// set: hallucinated sheets flags are usually semantic guesses (--cols for +// --range) that edit distance can't rank, and a --help round trip costs an +// agent a full extra call. One line here lets it re-issue the command +// immediately. +func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error { + name, isUnknown := unknownFlagFromParseError(ferr) + if !isUnknown { + return common.ValidationErrorf("%s", ferr.Error()). + WithHint("run `%s --help` for valid flags", c.CommandPath()) + } + valid := visibleFlagNames(c) + suggestions := suggest.Closest(name, valid, 3) + for i := range suggestions { + suggestions[i] = "--" + suggestions[i] + } + hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath()) + if list := inlineFlagList(valid); list != "" { + hint = "valid flags: " + list + if len(suggestions) > 0 { + hint = fmt.Sprintf("did you mean %s? valid flags: %s", + strings.Join(suggestions, ", "), list) + } + } + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "unknown flag %q for %q", "--"+name, c.CommandPath()). + WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}). + WithHint("%s", hint) +} + +// unknownFlagFromParseError extracts the offending long-flag name from +// cobra's flag-parse error text ("unknown flag: --query" → "query"). +// Returns ok=false for anything else (missing argument, invalid value, +// unknown shorthand) so those stay structured but generic. Mirrors the +// root-level parser in cmd; the prefix contract is cobra's English wording. +func unknownFlagFromParseError(err error) (string, bool) { + const p = "unknown flag: --" + msg := err.Error() + i := strings.Index(msg, p) + if i < 0 { + return "", false + } + rest := msg[i+len(p):] + if j := strings.IndexAny(rest, " \t"); j >= 0 { + rest = rest[:j] + } + return rest, true +} + +// visibleFlagNames lists the non-hidden flag names registered on c, sorted. +func visibleFlagNames(c *cobra.Command) []string { + var names []string + c.Flags().VisitAll(func(f *pflag.Flag) { + if !f.Hidden { + names = append(names, f.Name) + } + }) + sort.Strings(names) + return names +} + +// inlineFlagListLimit caps how many flag names ride inline on an +// unknown-flag hint. Sheets shortcuts stay well under it. +const inlineFlagListLimit = 25 + +// inlineFlagList renders valid flag names as one comma-separated line for +// the unknown-flag hint, truncating past inlineFlagListLimit. Empty when +// there is nothing to list. +func inlineFlagList(names []string) string { + if len(names) == 0 { + return "" + } + shown := names + var suffix string + if len(names) > inlineFlagListLimit { + shown = names[:inlineFlagListLimit] + suffix = fmt.Sprintf(", … (%d more; see --help)", len(names)-inlineFlagListLimit) + } + parts := make([]string, len(shown)) + for i, n := range shown { + parts[i] = "--" + n + } + return strings.Join(parts, ", ") + suffix +} + +// ─── enum vocabulary normalization ────────────────────────────────────── + +// enumAliases maps habitual values agents import from CSS / Excel / Google +// Sheets onto the value the Lark API actually uses, keyed by the wrong +// value. Applied only when the alias target is in the enum (and the wrong +// value is not), so e.g. "center" still stands for horizontal alignment +// (where it is valid) and only maps to "middle" for vertical alignment. +var enumAliases = map[string]string{ + "center": "middle", // CSS vertical-align: center → Lark "middle" + "centre": "center", + "middle": "center", // CSS-style middle → Lark horizontal "center" +} + +// canonicalEnumValue returns the enum entry an off-vocabulary value +// unambiguously means — exact case-insensitive match first, then the +// cross-vocabulary alias table. Unlike an edit-distance guess, the result +// is safe to apply on the caller's behalf. Returns "" when the value has +// no unambiguous canonical form in this enum. +func canonicalEnumValue(val string, enum []string) string { + lower := strings.ToLower(val) + for _, allowed := range enum { + if strings.ToLower(allowed) == lower { + return allowed + } + } + if target, ok := enumAliases[lower]; ok { + if slices.Contains(enum, target) { + return target + } + } + return "" +} + +// closestEnumValue picks the best "did you mean" candidate for an invalid +// enum value: the unambiguous canonical form first, then edit distance. +// For prose suggestions only — an edit-distance match must never be +// auto-applied. Returns "" when nothing is close. +func closestEnumValue(val string, enum []string) string { + if canon := canonicalEnumValue(val, enum); canon != "" { + return canon + } + if match := suggest.Closest(val, enum, 1); len(match) > 0 { + return match[0] + } + return "" +} + +// chainEnumNormalization installs a PreRunE stage (composed onto any +// framework-set PreRunE, which runs first so OnInvoke side effects and the +// --print-schema required-flag relaxation keep their contracts) that +// normalizes the command's flat enum flags before the common runner +// validates them: +// +// - an unambiguous vocabulary mismatch (casing, or a known alias like CSS +// "center" for Lark's vertical "middle") IS the value the caller meant — +// rewrite it in place and proceed instead of failing the call just to +// have the agent retype the canonical spelling; +// - anything else fails here with the allowed list plus a "did you mean" +// hint for edit-distance typos — a guess is never auto-applied. +// +// No-op for commands whose flag defs declare no enums. +func chainEnumNormalization(cmd *cobra.Command) { + defs, _ := loadFlagDefs() + spec, ok := defs[cmd.Name()] + if !ok { + return + } + var enumFlags []flagDef + for _, df := range spec.Flags { + if df.Kind != "system" && len(df.Enum) > 0 && df.Type == "string" { + enumFlags = append(enumFlags, df) + } + } + if len(enumFlags) == 0 { + return + } + prev := cmd.PreRunE + cmd.PreRunE = func(c *cobra.Command, args []string) error { + if prev != nil { + if err := prev(c, args); err != nil { + return err + } + } + // --print-schema is pure local introspection; the runner never enum- + // validates that path, so don't start here. + if want, err := c.Flags().GetBool("print-schema"); err == nil && want { + return nil + } + for _, df := range enumFlags { + val, err := c.Flags().GetString(df.Name) + if err != nil || val == "" || slices.Contains(df.Enum, val) { + continue + } + if canon := canonicalEnumValue(val, df.Enum); canon != "" { + c.Flags().Set(df.Name, canon) + continue + } + verr := common.ValidationErrorf("invalid value %q for --%s, allowed: %s", + val, df.Name, strings.Join(df.Enum, ", ")). + WithParam("--" + df.Name) + if match := suggest.Closest(val, df.Enum, 1); len(match) > 0 { + verr = verr.WithHint("did you mean %q?", match[0]) + } + return verr + } + return nil + } +} diff --git a/shortcuts/sheets/flag_ergonomics_test.go b/shortcuts/sheets/flag_ergonomics_test.go new file mode 100644 index 000000000..058412ac1 --- /dev/null +++ b/shortcuts/sheets/flag_ergonomics_test.go @@ -0,0 +1,296 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func TestUnknownFlagFromParseError(t *testing.T) { + t.Parallel() + cases := []struct { + in string + name string + ok bool + }{ + {"unknown flag: --cols", "cols", true}, + {"unknown flag: --with-styles", "with-styles", true}, + {"unknown shorthand flag: 'z' in -z", "", false}, + {"flag needs an argument: --find", "", false}, + {`invalid argument "x" for "--count"`, "", false}, + } + for _, c := range cases { + name, ok := unknownFlagFromParseError(errors.New(c.in)) + if name != c.name || ok != c.ok { + t.Errorf("unknownFlagFromParseError(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok) + } + } +} + +// TestSheetsFlagErrorFunc_SemanticGuessListsValidFlags pins the sheets +// override of the root unknown-flag error: --cols is a semantic guess for +// --range that edit distance can't rank, so the hint must inline the full +// valid-flag list instead of deferring to a --help round trip. +func TestSheetsFlagErrorFunc_SemanticGuessListsValidFlags(t *testing.T) { + t.Parallel() + c := &cobra.Command{Use: "demo"} + c.Flags().String("range", "", "") + c.Flags().Int("width", 0, "") + + err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --cols")) + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + if verr.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype = %q, want invalid_argument", verr.Subtype) + } + if len(verr.Params) != 1 || verr.Params[0].Name != "--cols" { + t.Errorf("Params = %v, want one entry named --cols", verr.Params) + } + if strings.Contains(verr.Hint, "--help") { + t.Errorf("hint should not defer to --help when flags fit inline, got %q", verr.Hint) + } + for _, want := range []string{"--range", "--width"} { + if !strings.Contains(verr.Hint, want) { + t.Errorf("hint should inline valid flag %s, got %q", want, verr.Hint) + } + } +} + +// TestSheetsFlagErrorFunc_TypoKeepsSuggestion pins that the root behavior +// (did-you-mean suggestion, machine-readable Suggestions) is preserved by +// the sheets override, with the valid-flag list appended. +func TestSheetsFlagErrorFunc_TypoKeepsSuggestion(t *testing.T) { + t.Parallel() + c := &cobra.Command{Use: "demo"} + c.Flags().String("range", "", "") + c.Flags().Bool("dry-run", false, "") + + err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --rang")) + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + found := false + for _, s := range verr.Params[0].Suggestions { + if s == "--range" { + found = true + } + } + if !found { + t.Errorf("Suggestions should include --range, got %v", verr.Params[0].Suggestions) + } + for _, want := range []string{"did you mean", "--range", "--dry-run"} { + if !strings.Contains(verr.Hint, want) { + t.Errorf("hint should contain %q, got %q", want, verr.Hint) + } + } +} + +func TestSheetsFlagErrorFunc_OtherErrorStaysGeneric(t *testing.T) { + t.Parallel() + c := &cobra.Command{Use: "demo"} + err := sheetsFlagErrorFunc(c, errors.New("flag needs an argument: --find")) + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + if verr.Param != "" || len(verr.Params) != 0 { + t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params) + } + if strings.Contains(verr.Hint, "did you mean") { + t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint) + } +} + +func TestInlineFlagList_TruncatesPastLimit(t *testing.T) { + t.Parallel() + if got := inlineFlagList(nil); got != "" { + t.Errorf("inlineFlagList(nil) = %q, want empty", got) + } + names := make([]string, inlineFlagListLimit+5) + for i := range names { + names[i] = fmt.Sprintf("flag-%02d", i) + } + got := inlineFlagList(names) + if !strings.Contains(got, "5 more") || !strings.Contains(got, "--help") { + t.Errorf("truncated list should count the overflow and defer to --help, got %q", got) + } + if strings.Contains(got, names[inlineFlagListLimit]) { + t.Errorf("list should stop at the limit, got %q", got) + } +} + +func TestCanonicalEnumValue(t *testing.T) { + t.Parallel() + cases := []struct { + val string + enum []string + want string + }{ + {"SUM", []string{"sum", "count"}, "sum"}, // casing + {"center", []string{"top", "middle", "bottom"}, "middle"}, // alias: CSS vertical center + {"middle", []string{"left", "center", "right"}, "center"}, // alias: horizontal middle + {"overwite", []string{"append", "overwrite"}, ""}, // typo is NOT canonical + {"delete", []string{"append", "overwrite"}, ""}, // nothing close + } + for _, c := range cases { + if got := canonicalEnumValue(c.val, c.enum); got != c.want { + t.Errorf("canonicalEnumValue(%q, %v) = %q, want %q", c.val, c.enum, got, c.want) + } + } +} + +func TestClosestEnumValue(t *testing.T) { + t.Parallel() + cases := []struct { + val string + enum []string + want string + }{ + {"SUM", []string{"sum", "count"}, "sum"}, // casing + {"center", []string{"top", "middle", "bottom"}, "middle"}, // alias + {"overwite", []string{"append", "overwrite"}, "overwrite"}, // edit distance + {"delete", []string{"append", "overwrite"}, ""}, // nothing close + } + for _, c := range cases { + if got := closestEnumValue(c.val, c.enum); got != c.want { + t.Errorf("closestEnumValue(%q, %v) = %q, want %q", c.val, c.enum, got, c.want) + } + } +} + +// TestChainEnumNormalization_UnitContract pins the PreRunE stage in +// isolation: canonical vocabulary is auto-applied, typos error with a +// suggestion (never applied), the framework PreRunE keeps running first, +// and --print-schema skips enum gating entirely. +func TestChainEnumNormalization_UnitContract(t *testing.T) { + t.Parallel() + newCmd := func() (*cobra.Command, *bool) { + cmd := &cobra.Command{Use: "+cells-set-style"} + cmd.Flags().String("vertical-alignment", "", "") + cmd.Flags().Bool("print-schema", false, "") + prevCalled := false + cmd.PreRunE = func(*cobra.Command, []string) error { + prevCalled = true + return nil + } + chainEnumNormalization(cmd) + return cmd, &prevCalled + } + + // Alias auto-applied, framework PreRunE preserved. + cmd, prevCalled := newCmd() + cmd.Flags().Set("vertical-alignment", "center") + if err := cmd.PreRunE(cmd, nil); err != nil { + t.Fatalf("center should normalize and pass, got: %v", err) + } + if got, _ := cmd.Flags().GetString("vertical-alignment"); got != "middle" { + t.Errorf("vertical-alignment = %q, want rewritten to %q", got, "middle") + } + if !*prevCalled { + t.Error("framework PreRunE must keep running first") + } + + // Typo: error with suggestion, value untouched. + cmd, _ = newCmd() + cmd.Flags().Set("vertical-alignment", "botom") + err := cmd.PreRunE(cmd, nil) + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("typo should fail with *errs.ValidationError, got %T: %v", err, err) + } + if !strings.Contains(verr.Hint, `"bottom"`) { + t.Errorf("hint should suggest bottom for the typo, got %q", verr.Hint) + } + if got, _ := cmd.Flags().GetString("vertical-alignment"); got != "botom" { + t.Errorf("typo must not be rewritten, got %q", got) + } + + // --print-schema skips enum gating (pure local introspection). + cmd, _ = newCmd() + cmd.Flags().Set("vertical-alignment", "not-a-value") + cmd.Flags().Set("print-schema", "true") + if err := cmd.PreRunE(cmd, nil); err != nil { + t.Errorf("--print-schema must skip enum gating, got: %v", err) + } +} + +// shortcutFromRegistry returns the fully wired shortcut (PostMount +// ergonomics included) as Shortcuts() exposes it to the framework. +func shortcutFromRegistry(t *testing.T, command string) common.Shortcut { + t.Helper() + for _, sc := range Shortcuts() { + if sc.Command == command { + return sc + } + } + t.Fatalf("shortcut %q not found in Shortcuts()", command) + return common.Shortcut{} +} + +// TestShortcuts_FlagErgonomicsMounted verifies the ergonomics ride every +// mounted sheets command end-to-end: enum vocabulary normalizes on a real +// invocation, and unknown flags answer with the inlined valid-flag list. +func TestShortcuts_FlagErgonomicsMounted(t *testing.T) { + t.Parallel() + + t.Run("enum alias normalizes through a real run", func(t *testing.T) { + t.Parallel() + sc := shortcutFromRegistry(t, "+cells-set-style") + stdout, _, err := runShortcutCapturingErr(t, sc, []string{ + "--url", testURL, + "--sheet-name", "s", + "--range", "A1:A1", + "--vertical-alignment", "center", + "--dry-run", + }) + if err != nil { + t.Fatalf("center should normalize to middle and pass, got: %v", err) + } + if !strings.Contains(stdout, "middle") || strings.Contains(stdout, "center") { + t.Errorf("dry-run body should carry the normalized value, got %q", stdout) + } + }) + + t.Run("enum typo errors with suggestion", func(t *testing.T) { + t.Parallel() + sc := shortcutFromRegistry(t, "+cells-set-style") + _, _, err := runShortcutCapturingErr(t, sc, []string{ + "--url", testURL, + "--sheet-name", "s", + "--range", "A1:A1", + "--vertical-alignment", "botom", + "--dry-run", + }) + ve := requireValidation(t, err, `invalid value "botom" for --vertical-alignment`) + if !strings.Contains(ve.Hint, `"bottom"`) { + t.Errorf("hint should suggest bottom, got %q", ve.Hint) + } + }) + + t.Run("unknown flag inlines valid flags", func(t *testing.T) { + t.Parallel() + sc := shortcutFromRegistry(t, "+cols-resize") + _, _, err := runShortcutCapturingErr(t, sc, []string{ + "--url", testURL, + "--sheet-name", "s", + "--cols", "A:D", + }) + ve := requireValidation(t, err, `unknown flag "--cols"`) + for _, want := range []string{"valid flags:", "--range", "--width", "--widths"} { + if !strings.Contains(ve.Hint, want) { + t.Errorf("hint should contain %q, got %q", want, ve.Hint) + } + } + }) +} diff --git a/shortcuts/sheets/flag_schema.go b/shortcuts/sheets/flag_schema.go index ccdbc7b08..b8b90de07 100644 --- a/shortcuts/sheets/flag_schema.go +++ b/shortcuts/sheets/flag_schema.go @@ -6,7 +6,6 @@ package sheets import ( _ "embed" "encoding/json" - "fmt" "sort" "sync" @@ -54,7 +53,7 @@ func loadFlagSchemas() (*flagSchemaIndex, error) { flagSchemasOnce.Do(func() { var idx flagSchemaIndex if err := json.Unmarshal(flagSchemasJSON, &idx); err != nil { - parseFlagErr = fmt.Errorf("flag-schemas.json: %w", err) + parseFlagErr = errs.NewInternalError(errs.SubtypeUnknown, "flag-schemas.json: %v", err).WithCause(err) return } if idx.Flags == nil { diff --git a/shortcuts/sheets/flag_schema_validate.go b/shortcuts/sheets/flag_schema_validate.go index 6e9af6681..e635167e1 100644 --- a/shortcuts/sheets/flag_schema_validate.go +++ b/shortcuts/sheets/flag_schema_validate.go @@ -5,6 +5,7 @@ package sheets import ( "encoding/json" + "errors" "fmt" "sort" "strings" @@ -97,11 +98,22 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err // Composite-JSON shape errors (e.g. +cells-set --cells, chart // --properties) are the highest-frequency usage-layer failure for // sheets, and agents often burn several retries guessing the shape. - // Point them straight at --print-schema, which dumps the exact JSON - // Schema for this (command, flag) pair. The hint is always actionable: - // reaching this branch means entry[name] resolved a schema from the - // embedded index, and --print-schema reads that same index, so the - // suggested command is guaranteed to print it. + // A shallow type mismatch means the caller misremembered the overall + // container shape (the classic {"cells": ...} wrapper around what + // must be a bare 2D array), so inline a skeleton of the expected + // shape — that fixes the retry without a --print-schema round trip. + // Deeper failures keep the --print-schema pointer, which dumps the + // exact JSON Schema for this (command, flag) pair; reaching this + // branch means entry[name] resolved a schema from the embedded + // index, so the suggested command is guaranteed to print it. + var tm *typeMismatchError + if errors.As(vErr, &tm) && pathDepth(tm.path) <= skeletonPathDepthLimit { + if sk := schemaSkeleton(&schema, skeletonMaxDepth); sk != "" { + return sheetsValidationForFlag(name, + "--%s: %s; expected shape: %s (run `lark-cli sheets %s --print-schema --flag-name %s` for the full JSON Schema)", + name, vErr.Error(), sk, command, name).WithCause(vErr) + } + } return sheetsValidationForFlag(name, "--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema", name, vErr.Error(), command, name).WithCause(vErr) @@ -243,7 +255,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin if schema.Type != "" { if !matchesJSONType(value, schema.Type) { - return fmt.Errorf("%sexpected type %q, got %q", pathPrefix(path), schema.Type, jsType(value)) + return &typeMismatchError{path: path, expected: schema.Type, got: jsType(value)} } } @@ -251,20 +263,20 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin // already reported above). Apply to both `number` and `integer` types. if num, ok := value.(float64); ok { if schema.Minimum != nil && num < *schema.Minimum { - return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum) + return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint } if schema.Maximum != nil && num > *schema.Maximum { - return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum) + return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint } } // Array length bounds — only checked when value is an array. if arr, ok := value.([]interface{}); ok { if schema.MinItems != nil && len(arr) < *schema.MinItems { - return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems) + return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint } if schema.MaxItems != nil && len(arr) > *schema.MaxItems { - return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems) + return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint } } @@ -279,10 +291,10 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin if !matched { msg := fmt.Sprintf("%svalue %s is not in enum %s", pathPrefix(path), formatJSONValue(value), formatEnum(schema.Enum)) - if hint := suggestEnumMatch(value, schema.Enum); hint != "" { + if hint := suggestEnumForError(value, schema.Enum); hint != "" { msg += fmt.Sprintf(` (did you mean %q?)`, hint) } - return fmt.Errorf("%s", msg) + return fmt.Errorf("%s", msg) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint } } @@ -295,7 +307,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin } } if !matched { - return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path)) + return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint } } @@ -305,7 +317,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin if obj, ok := value.(map[string]interface{}); ok { for _, key := range schema.Required { if _, present := obj[key]; !present { - return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path)) + return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint } } if schema.Properties != nil { @@ -357,7 +369,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin sort.Strings(extras) for _, key := range extras { if schema.AdditionalProperties.Strict { - return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key) + return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint } if schema.AdditionalProperties.Schema != nil { child := key @@ -388,6 +400,126 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin return nil } +// typeMismatchError is the type-check branch of validateAgainstSchema +// as a typed error, so validateValueAgainstSchema can recognize shape +// confusion (vs. deep value errors) and inline a skeleton of the +// expected shape. Error() keeps the exact legacy wording. +type typeMismatchError struct { + path string + expected string + got string +} + +func (e *typeMismatchError) Error() string { + return fmt.Sprintf("%sexpected type %q, got %q", pathPrefix(e.path), e.expected, e.got) +} + +// pathDepth counts how many levels below the flag root a JSON path +// points at: "" → 0, "[0]" → 1, "[0][3]" → 2, "[0][3].value" → 3, +// "legend" → 1, "snapshot.axes" → 2. Every "[" and "." starts a new +// segment; a leading bare key (no bracket) is one segment of its own. +func pathDepth(path string) int { + depth := strings.Count(path, "[") + strings.Count(path, ".") + if path != "" && path[0] != '[' { + depth++ + } + return depth +} + +// Skeleton rendering bounds: a mismatch at depth ≤ 2 is container-shape +// confusion worth a skeleton; deeper mismatches are value-level and the +// full schema pointer serves better. The skeleton itself stops after +// four levels and eight keys per object so it stays one line; a wide +// object (> skeletonWideObject keys) collapses its children to type +// placeholders so every key stays visible instead of the first branch +// eating the whole line. +const ( + skeletonPathDepthLimit = 2 + skeletonMaxDepth = 4 + skeletonMaxKeys = 8 + skeletonWideObject = 2 +) + +// schemaSkeleton renders a compact single-line sketch of the shape a +// schema expects, e.g. [[{"value": …, "formula": "…", …}]] for +// +cells-set --cells. Required keys come first, then alphabetical, +// capped at skeletonMaxKeys with a trailing … marker. Values render as +// their type placeholder; enum strings show the first allowed value. +func schemaSkeleton(s *schemaProperty, depth int) string { + if s == nil { + return "…" + } + if len(s.OneOf) > 0 && s.Type == "" { + return schemaSkeleton(s.OneOf[0], depth) + } + switch s.Type { + case "array": + if depth <= 0 { + return "[…]" + } + return "[" + schemaSkeleton(s.Items, depth-1) + "]" + case "object": + if depth <= 0 || len(s.Properties) == 0 { + return "{…}" + } + keys := skeletonKeys(s) + childDepth := depth - 1 + if len(s.Properties) > skeletonWideObject { + childDepth = 0 + } + parts := make([]string, 0, len(keys)+1) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%q: %s", k, schemaSkeleton(s.Properties[k], childDepth))) + } + if len(s.Properties) > len(keys) { + parts = append(parts, "…") + } + return "{" + strings.Join(parts, ", ") + "}" + case "string": + if len(s.Enum) > 0 { + return formatJSONValue(s.Enum[0]) + } + return `"…"` + case "number", "integer": + return "0" + case "boolean": + return "false" + } + return "…" +} + +// skeletonKeys picks which object keys a skeleton shows: required keys +// first (schema order), then remaining keys alphabetically, capped at +// skeletonMaxKeys. +func skeletonKeys(s *schemaProperty) []string { + keys := make([]string, 0, skeletonMaxKeys) + seen := make(map[string]struct{}, skeletonMaxKeys) + for _, k := range s.Required { + if _, ok := s.Properties[k]; !ok { + continue + } + if len(keys) == skeletonMaxKeys { + return keys + } + keys = append(keys, k) + seen[k] = struct{}{} + } + rest := make([]string, 0, len(s.Properties)) + for k := range s.Properties { + if _, dup := seen[k]; !dup { + rest = append(rest, k) + } + } + sort.Strings(rest) + for _, k := range rest { + if len(keys) == skeletonMaxKeys { + break + } + keys = append(keys, k) + } + return keys +} + func matchesJSONType(value interface{}, expected string) bool { switch expected { case "object": @@ -473,25 +605,48 @@ func joinFormatted(values []interface{}) string { return strings.Join(parts, ", ") } -// suggestEnumMatch returns a "did you mean" candidate when the user's -// value differs from an allowed enum entry only in casing — the most -// common real-world mistake ("SUM" vs "sum", "True" vs "true"). The -// match is restricted to strings; non-string enums (numbers, etc.) -// don't have a casing notion. Returns "" when no near-miss exists. +// suggestEnumMatch returns the canonical enum entry when the user's +// value unambiguously means one — casing ("SUM" vs "sum", "True" vs +// "true") or a cross-vocabulary alias (CSS "center" for Lark's vertical +// "middle"). Callers auto-apply the result, so it must stay restricted +// to unambiguous matches (edit-distance guesses belong in +// suggestEnumForError only). Non-string values have no vocabulary +// notion. Returns "" when no unambiguous match exists. func suggestEnumMatch(value interface{}, values []interface{}) string { s, ok := value.(string) if !ok { return "" } - lower := strings.ToLower(s) + canon := canonicalEnumValue(s, stringEnumEntries(values)) + if canon == "" || canon == s { // skip exact-equal (already would have matched). + return "" + } + return canon +} + +// stringEnumEntries extracts the string members of a JSON-schema enum +// list (mixed-type enums keep only their string entries). +func stringEnumEntries(values []interface{}) []string { + out := make([]string, 0, len(values)) for _, v := range values { - if vs, ok := v.(string); ok && strings.ToLower(vs) == lower { - if vs != s { // skip exact-equal (already would have matched). - return vs - } + if vs, ok := v.(string); ok { + out = append(out, vs) } } - return "" + return out +} + +// suggestEnumForError picks the "did you mean" candidate for an enum +// error message. Unlike suggestEnumMatch (whose result is auto-applied, +// so it must stay unambiguous), this one may also draw on edit distance +// — the suggestion is only prose, the user still has to re-issue the +// value explicitly. +func suggestEnumForError(value interface{}, values []interface{}) string { + s, ok := value.(string) + if !ok { + return "" + } + return closestEnumValue(s, stringEnumEntries(values)) } func pathPrefix(path string) string { diff --git a/shortcuts/sheets/flag_schema_validate_test.go b/shortcuts/sheets/flag_schema_validate_test.go index 388b949e1..90b37f40f 100644 --- a/shortcuts/sheets/flag_schema_validate_test.go +++ b/shortcuts/sheets/flag_schema_validate_test.go @@ -360,6 +360,142 @@ func TestValidateInputAgainstSchema_RealEnumCaseNormalized(t *testing.T) { } } +// TestValidateAgainstSchema_EnumAliasNormalized pins the cross-vocabulary +// auto-fix: CSS-habit "center" for a vertical alignment unambiguously means +// Lark's "middle", so the payload is normalized in place and the call +// proceeds — same treatment as the "SUM" vs "sum" casing class. +func TestValidateAgainstSchema_EnumAliasNormalized(t *testing.T) { + t.Parallel() + schema := parseSchema(t, `{ + "type":"object", + "properties":{"vertical_alignment":{"type":"string","enum":["top","middle","bottom"]}} + }`) + obj := map[string]interface{}{"vertical_alignment": "center"} + if err := validateAgainstSchema(obj, schema, ""); err != nil { + t.Fatalf("center should normalize to middle and pass, got: %v", err) + } + if got := obj["vertical_alignment"]; got != "middle" { + t.Errorf("vertical_alignment = %q, want normalized to %q", got, "middle") + } +} + +// TestValidateAgainstSchema_EnumTypoSuggestedNotApplied pins the auto-apply +// boundary on the error path: an edit-distance typo stays an error with a +// "did you mean" suggestion, never a silent rewrite. +func TestValidateAgainstSchema_EnumTypoSuggestedNotApplied(t *testing.T) { + t.Parallel() + schema := parseSchema(t, `{ + "type":"object", + "properties":{"order":{"type":"string","enum":["asc","desc"]}} + }`) + obj := map[string]interface{}{"order": "ascc"} + err := validateAgainstSchema(obj, schema, "") + if err == nil { + t.Fatal("typo must be rejected") + } + if !strings.Contains(err.Error(), `did you mean "asc"?`) { + t.Errorf("enum error should suggest asc for the typo, got %q", err.Error()) + } + if got := obj["order"]; got != "ascc" { + t.Errorf("typo must not be rewritten, got %q", got) + } +} + +// TestValidateValueAgainstSchema_ShapeSkeletonOnShallowTypeMismatch +// pins the highest-frequency eval failure: passing an object where +// --cells expects a 2D array must inline a skeleton of the expected +// shape (with the "value" key visible) so an agent fixes the retry +// without a --print-schema round trip. +func TestValidateValueAgainstSchema_ShapeSkeletonOnShallowTypeMismatch(t *testing.T) { + t.Parallel() + fv := mapFlagView{command: "+cells-set"} + err := validateValueAgainstSchema(fv, "cells", + map[string]interface{}{"cells": []interface{}{}}) + if err == nil { + t.Fatal("object where array expected must fail") + } + msg := err.Error() + for _, want := range []string{`expected type "array", got "object"`, "expected shape: [[{", `"value"`} { + if !strings.Contains(msg, want) { + t.Errorf("error should contain %q, got %q", want, msg) + } + } + + // Deep value-level mismatch keeps the plain --print-schema pointer + // (a whole-shape skeleton would not address the actual problem). + deep := []interface{}{[]interface{}{ + map[string]interface{}{"note": 12.5}, + }} + err = validateValueAgainstSchema(fv, "cells", deep) + if err == nil { + t.Fatal("wrong type for note must fail") + } + if strings.Contains(err.Error(), "expected shape:") { + t.Errorf("deep mismatch should not inline a skeleton, got %q", err.Error()) + } + if !strings.Contains(err.Error(), "--print-schema") { + t.Errorf("deep mismatch should keep the --print-schema pointer, got %q", err.Error()) + } +} + +func TestPathDepth(t *testing.T) { + t.Parallel() + cases := []struct { + path string + want int + }{ + {"", 0}, + {"[0]", 1}, + {"[0][3]", 2}, + {"[0][3].value", 3}, + {"legend", 1}, + {"snapshot.axes", 2}, + } + for _, c := range cases { + if got := pathDepth(c.path); got != c.want { + t.Errorf("pathDepth(%q) = %d, want %d", c.path, got, c.want) + } + } +} + +func TestSchemaSkeleton(t *testing.T) { + t.Parallel() + schema := parseSchema(t, `{ + "type":"array", + "items":{ + "type":"array", + "items":{ + "type":"object", + "properties":{ + "value":{}, + "formula":{"type":"string"}, + "align":{"type":"string","enum":["top","middle","bottom"]}, + "styles":{"type":"object","properties":{"bold":{"type":"boolean"}}} + } + } + } + }`) + got := schemaSkeleton(schema, skeletonMaxDepth) + // Wide object (>2 keys) collapses nested containers to placeholders; + // enum strings surface their first allowed value. + want := `[[{"align": "top", "formula": "…", "styles": {…}, "value": …}]]` + if got != want { + t.Errorf("skeleton = %s, want %s", got, want) + } + + narrow := parseSchema(t, `{ + "type":"object", + "required":["sheets"], + "properties":{"sheets":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"}}}}} + }`) + got = schemaSkeleton(narrow, skeletonMaxDepth) + // Narrow object (≤2 keys) keeps descending so the inner shape shows. + want = `{"sheets": [{"name": "…"}]}` + if got != want { + t.Errorf("narrow skeleton = %s, want %s", got, want) + } +} + // TestValidateAgainstSchema_NilSchemaSafe pins the defensive // `if schema == nil { return nil }` guard. Current production callers // always hand validator a real schema, but the guard means future diff --git a/shortcuts/sheets/flag_schemas_gen.go b/shortcuts/sheets/flag_schemas_gen.go index 95a7f4408..460465371 100644 --- a/shortcuts/sheets/flag_schemas_gen.go +++ b/shortcuts/sheets/flag_schemas_gen.go @@ -19,6 +19,7 @@ var commandsWithSchema = map[string]struct{}{ "+cells-set-style": {}, "+chart-create": {}, "+chart-update": {}, + "+cols-resize": {}, "+cond-format-create": {}, "+cond-format-update": {}, "+dropdown-set": {}, @@ -30,6 +31,7 @@ var commandsWithSchema = map[string]struct{}{ "+pivot-create": {}, "+pivot-update": {}, "+range-sort": {}, + "+rows-resize": {}, "+sparkline-create": {}, "+sparkline-update": {}, "+table-put": {}, diff --git a/shortcuts/sheets/flag_view.go b/shortcuts/sheets/flag_view.go index b31ed7b38..de1bfaa6d 100644 --- a/shortcuts/sheets/flag_view.go +++ b/shortcuts/sheets/flag_view.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "math" + "slices" "strconv" "strings" ) @@ -118,20 +119,21 @@ func (m mapFlagView) lookup(name string) (interface{}, bool) { // lookupRaw resolves a flag name against the user-supplied input only, trying // the exact key then the hyphen↔underscore variants. func (m mapFlagView) lookupRaw(name string) (interface{}, bool) { - if v, ok := m.raw[name]; ok { - return v, true - } - if alt := strings.ReplaceAll(name, "-", "_"); alt != name { - if v, ok := m.raw[alt]; ok { - return v, true + _, v, ok := m.lookupRawWithKey(name) + return v, ok +} + +func (m mapFlagView) lookupRawWithKey(name string) (string, interface{}, bool) { + for _, key := range []string{ + name, + strings.ReplaceAll(name, "-", "_"), + strings.ReplaceAll(name, "_", "-"), + } { + if v, ok := m.raw[key]; ok { + return key, v, true } } - if alt := strings.ReplaceAll(name, "_", "-"); alt != name { - if v, ok := m.raw[alt]; ok { - return v, true - } - } - return nil, false + return "", nil, false } func (m mapFlagView) Str(name string) string { @@ -281,24 +283,65 @@ func (m mapFlagView) validateRawTypes() error { // parse time; reject here too to keep batch/standalone parity. f, isNum := val.(float64) if !isNum { - return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) + return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error } if math.Trunc(f) != f { - return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64)) + return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error } case "float64": if _, isNum := val.(float64); !isNum { - return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) + return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error } case "bool": if _, isBool := val.(bool); !isBool { - return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val)) + return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error } } } return nil } +// normalizeAndValidateEnums applies the same flat string-enum contract as the +// standalone cobra path. Canonical casing and known aliases are rewritten in +// place; unknown values are rejected before a translator can silently fall +// back to a different operation. +func (m *mapFlagView) normalizeAndValidateEnums() error { + defs, err := loadFlagDefs() + if err != nil { + return nil //nolint:nilerr // match validateRawTypes: missing embedded metadata must not block the batch + } + spec, ok := defs[m.command] + if !ok { + return nil + } + for _, df := range spec.Flags { + if df.Kind == "system" || df.Type != "string" || len(df.Enum) == 0 { + continue + } + rawKey, raw, changed := m.lookupRawWithKey(df.Name) + if !changed { + continue + } + value, ok := raw.(string) + if !ok { + return fmt.Errorf("--%s must be a string, got %s", df.Name, jsonTypeName(raw)) //nolint:forbidigo // intermediate error; batch dispatcher adds typed operations context + } + if value == "" || slices.Contains(df.Enum, value) { + continue + } + if canonical := canonicalEnumValue(value, df.Enum); canonical != "" { + m.raw[rawKey] = canonical + continue + } + message := fmt.Sprintf("invalid value %q for --%s, allowed: %s", value, df.Name, strings.Join(df.Enum, ", ")) + if match := closestEnumValue(value, df.Enum); match != "" { + message += fmt.Sprintf("; did you mean %q?", match) + } + return fmt.Errorf("%s", message) //nolint:forbidigo // intermediate error; batch dispatcher adds typed operations context + } + return nil +} + // jsonTypeName names the JSON kind of a value decoded by encoding/json, for // type-mismatch error messages. func jsonTypeName(v interface{}) string { diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go index 23272e263..b72e7d7de 100644 --- a/shortcuts/sheets/helpers.go +++ b/shortcuts/sheets/helpers.go @@ -10,6 +10,7 @@ package sheets import ( "context" "encoding/json" + "errors" "fmt" neturl "net/url" "strings" @@ -44,7 +45,8 @@ func sheetsValidationCauseForFlag(name string, cause error) *errs.ValidationErro // classification and only adds the domain's flag param. func sheetsInputStatError(flag string, err error) error { wrapped := common.WrapInputStatErrorTyped(err) - if v, ok := wrapped.(*errs.ValidationError); ok { + var v *errs.ValidationError + if errors.As(wrapped, &v) { return v.WithParam(sheetsFlagParam(flag)) } return wrapped @@ -52,21 +54,30 @@ func sheetsInputStatError(flag string, err error) error { // Drive media parent_type values for uploading an image into a spreadsheet. // Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a -// synthetic token prefixed with "fake_office_" and the backend requires -// "office_sheet_file" instead. +// synthetic token prefixed with "fake_office_" (being renamed to +// "local_office_") and the backend requires "office_sheet_file" instead. const ( sheetImageParentType = "sheet_image" officeSheetFileParentType = "office_sheet_file" - fakeOfficeTokenPrefix = "fake_office_" + fakeOfficePrefix = "fake_office_" + localOfficePrefix = "local_office_" ) +// officePrefixes are the synthetic token prefixes an imported "office" +// spreadsheet may carry. The prefix is being renamed from "fake_office_" to +// "local_office_"; accept either so image uploads keep working across the +// rename. +var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix} + // sheetMediaParentType returns the drive media parent_type to use when // uploading an image whose parent_node is spreadsheetToken. It is the single // place that maps a spreadsheet token to its parent_type so every image-upload // entry point (and its dry-run preview) stays consistent. func sheetMediaParentType(spreadsheetToken string) string { - if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) { - return officeSheetFileParentType + for _, prefix := range officePrefixes { + if strings.HasPrefix(spreadsheetToken, prefix) { + return officeSheetFileParentType + } } return sheetImageParentType } @@ -440,7 +451,7 @@ func requireJSONArray(runtime flagView, name string) ([]interface{}, error) { // ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─ -// buildCellStyleFromFlags reads the 11 flat style flags and returns the +// buildCellStyleFromFlags reads the 12 flat style flags and returns the // cell_styles map expected by set_cell_range. Skips any flag the user // didn't set so partial styles work. func buildCellStyleFromFlags(runtime flagView) map[string]interface{} { @@ -451,6 +462,9 @@ func buildCellStyleFromFlags(runtime flagView) map[string]interface{} { if v := runtime.Str("font-color"); v != "" { style["font_color"] = v } + if v := runtime.Str("font-family"); v != "" { + style["font_family"] = v + } if runtime.Changed("font-size") && runtime.Float64("font-size") > 0 { style["font_size"] = runtime.Float64("font-size") } diff --git a/shortcuts/sheets/lark_sheet_batch_update.go b/shortcuts/sheets/lark_sheet_batch_update.go index 9739bb673..a6a5e0a5c 100644 --- a/shortcuts/sheets/lark_sheet_batch_update.go +++ b/shortcuts/sheets/lark_sheet_batch_update.go @@ -215,7 +215,8 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[ if borderStyles != nil { prototype["border_styles"] = borderStyles } - var ops []interface{} + ops := make([]interface{}, 0, len(ranges)) + var totalCells int64 for _, rng := range ranges { sheet, sub, err := splitSheetPrefixedRange(rng) if err != nil { @@ -225,6 +226,13 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[ if err != nil { return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err) } + if err := checkStampMatrixBudget("ranges", rng, rows, cols); err != nil { + return nil, err + } + totalCells += int64(rows) * int64(cols) + if err := checkBatchStampBudget(totalCells); err != nil { + return nil, err + } cells := fillCellsMatrix(rows, cols, prototype) ops = append(ops, map[string]interface{}{ "tool_name": "set_cell_range", @@ -299,7 +307,7 @@ func cellsBatchClearInput(runtime *common.RuntimeContext, token string) (map[str return nil, err } clearType := normalizeClearType(runtime.Str("scope")) - var ops []interface{} + ops := make([]interface{}, 0, len(ranges)) for _, rng := range ranges { sheet, sub, err := splitSheetPrefixedRange(rng) if err != nil { @@ -382,13 +390,10 @@ var DropdownDelete = common.Shortcut{ if _, err := resolveSpreadsheetToken(runtime); err != nil { return err } - ranges, err := validateDropdownRanges(runtime) - if err != nil { + // validateDropdownRanges enforces the shared maxBatchRanges cap. + if _, err := validateDropdownRanges(runtime); err != nil { return err } - if len(ranges) > 100 { - return sheetsValidationForFlag("ranges", "--ranges accepts at most 100 entries; got %d", len(ranges)) - } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -432,7 +437,8 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool } prototype = map[string]interface{}{"data_validation": validation} } - var ops []interface{} + ops := make([]interface{}, 0, len(ranges)) + var totalCells int64 for _, rng := range ranges { sheet, sub, err := splitSheetPrefixedRange(rng) if err != nil { @@ -442,6 +448,13 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool if err != nil { return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err) } + if err := checkStampMatrixBudget("ranges", rng, rows, cols); err != nil { + return nil, err + } + totalCells += int64(rows) * int64(cols) + if err := checkBatchStampBudget(totalCells); err != nil { + return nil, err + } cells := fillCellsMatrix(rows, cols, prototype) ops = append(ops, map[string]interface{}{ "tool_name": "set_cell_range", @@ -461,6 +474,25 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool // ─── helpers resurrected from B3 (used here + future skills) ────────── +// maxBatchRanges caps how many ranges a fan-out batch (+cells-batch-set-style / +// +cells-batch-clear / +dropdown-update / +dropdown-delete) may carry, bounding +// the number of ops materialized into one batch_update. +const maxBatchRanges = 100 + +// checkBatchStampBudget rejects a fan-out batch whose ranges materialize more +// than maxStampMatrixCells cells in aggregate. A batch builds every range's +// cells matrix up front, so the SUM across ranges is the real peak-memory bound +// — the per-range checkStampMatrixBudget alone can't stop many ranges from +// summing past it. totalCells is int64 to stay overflow-safe. +func checkBatchStampBudget(totalCells int64) error { + if totalCells > maxStampMatrixCells { + return sheetsValidationForFlag("ranges", + "ranges expand to %d cells total, over the %d-cell safety cap; reduce the number or size of ranges", + totalCells, maxStampMatrixCells) + } + return nil +} + // validateDropdownRanges parses --ranges, requires every entry to carry a // sheet prefix, and returns the parsed list. func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) { @@ -490,6 +522,9 @@ func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) { } out = append(out, s) } + if len(out) > maxBatchRanges { + return nil, sheetsValidationForFlag("ranges", "--ranges accepts at most %d entries; got %d", maxBatchRanges, len(out)) + } return out, nil } diff --git a/shortcuts/sheets/lark_sheet_batch_update_test.go b/shortcuts/sheets/lark_sheet_batch_update_test.go index 56e2c5e2b..7c7aeed43 100644 --- a/shortcuts/sheets/lark_sheet_batch_update_test.go +++ b/shortcuts/sheets/lark_sheet_batch_update_test.go @@ -5,6 +5,7 @@ package sheets import ( "encoding/json" + "strings" "testing" ) @@ -419,6 +420,116 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) { } } +// TestBatchUpdate_PrescriptiveHints pins the recovery hints that ride on the +// highest-frequency batch failures, so an agent can repair its payload in a +// single retry without --help / --print-schema round trips. +func TestBatchUpdate_PrescriptiveHints(t *testing.T) { + t.Parallel() + cases := []struct { + name string + opsJSON string + wantMatch string + wantInHint []string + }{ + { + name: "missing shortcut gets entry template", + opsJSON: `[{"input":{"range":"A1"}}]`, + wantMatch: "'shortcut' field is required", + wantInHint: []string{`{"shortcut":"+cells-set"`, `"input"`}, + }, + { + name: "disallowed shortcut lists the allow-list inline", + opsJSON: `[{"shortcut":"+cells-batch-set-style","input":{}}]`, + wantMatch: "not allowed in +batch-update", + wantInHint: []string{"allowed shortcuts:", "+cells-set-style", "+range-copy"}, + }, + { + name: "translator failure lists full key contract", + opsJSON: `[{"shortcut":"+dim-insert","input":{"sheet_name":"s"}}]`, + wantMatch: "--position is required", + wantInHint: []string{"+dim-insert input keys:", "sheet_id|sheet_name (choose one)", "position (required)", "count (required)", "inherit_style"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{ + "--url", testURL, + "--operations", tc.opsJSON, + "--yes", + "--dry-run", + }) + ve := requireValidation(t, err, tc.wantMatch) + for _, want := range tc.wantInHint { + if !strings.Contains(ve.Hint, want) { + t.Errorf("hint should contain %q, got %q", want, ve.Hint) + } + } + }) + } +} + +// TestTranslateBatchOperations_OverLimitSplitHint pins the split +// prescription on the 100-entry cap: the hint must say how many batches +// the caller should re-issue. +func TestTranslateBatchOperations_OverLimitSplitHint(t *testing.T) { + t.Parallel() + ops := make([]interface{}, 185) + for i := range ops { + ops[i] = map[string]interface{}{"shortcut": "+cells-set", "input": map[string]interface{}{}} + } + _, err := translateBatchOperations(ops, "shtcnX") + ve := requireValidation(t, err, "accepts at most 100 entries; got 185") + for _, want := range []string{"2 separate +batch-update calls", "at most 100 entries each"} { + if !strings.Contains(ve.Hint, want) { + t.Errorf("hint should contain %q, got %q", want, ve.Hint) + } + } +} + +func TestTranslateBatchOperations_AggregateCellCap(t *testing.T) { + ops := []interface{}{ + map[string]interface{}{ + "shortcut": "+cells-set-style", + "input": map[string]interface{}{ + "sheet-id": "sh1", "range": "A1:A100001", "font-weight": "bold", + }, + }, + map[string]interface{}{ + "shortcut": "+cells-set-style", + "input": map[string]interface{}{ + "sheet-id": "sh1", "range": "A1:A100000", "font-weight": "bold", + }, + }, + } + _, err := translateBatchOperations(ops, "shtcnX") + ve := requireValidation(t, err, "materialize 200001 cells total") + if ve.Param != "--operations" { + t.Fatalf("param = %q, want --operations", ve.Param) + } +} + +// TestSubOpInputContract pins the contract line derivation from flag-defs: +// reserved spreadsheet locators are omitted, the sheet selector collapses +// to a choose-one, and required flags are marked. +func TestSubOpInputContract(t *testing.T) { + t.Parallel() + got := subOpInputContract("+dim-insert") + for _, want := range []string{"sheet_id|sheet_name (choose one)", "position (required)", "count (required)", "inherit_style"} { + if !strings.Contains(got, want) { + t.Errorf("contract should contain %q, got %q", want, got) + } + } + for _, banned := range []string{"url", "spreadsheet_token", "dry_run"} { + if strings.Contains(got, banned) { + t.Errorf("contract must not expose %q, got %q", banned, got) + } + } + if got := subOpInputContract("+no-such-shortcut"); got != "" { + t.Errorf("unknown shortcut should yield empty contract, got %q", got) + } +} + // TestBatchUpdate_DimFreezeInjectsFreeze covers the static-freeze-only // path: +dim-freeze always injects operation=freeze (count==0 unfreeze // path of the single shortcut is intentionally not supported in batch). @@ -447,7 +558,7 @@ func TestBatchUpdate_ResizeNoOperationField(t *testing.T) { t.Parallel() body := parseDryRunBody(t, BatchUpdate, []string{ "--url", testURL, - "--operations", `[{"shortcut":"+rows-resize","input":{"sheet_id":"sh1","range":"1:3","type":"pixel","size":30}}]`, + "--operations", `[{"shortcut":"+rows-resize","input":{"sheet_id":"sh1","range":"1:3","height":30}}]`, "--yes", }) input := decodeToolInput(t, body, "batch_update") diff --git a/shortcuts/sheets/lark_sheet_changeset.go b/shortcuts/sheets/lark_sheet_changeset.go new file mode 100644 index 000000000..8a962f944 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_changeset.go @@ -0,0 +1,106 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── lark_sheet_changeset ───────────────────────────────────────────── +// +// +changeset-get wraps the get_changeset read tool: fetch the raw changeset +// (the list of edit actions) between two CS revisions of a spreadsheet, so a +// human or reviewing agent can verify whether an AI edit actually fulfilled +// the user's request. +// +// - --start-revision is the "before" baseline (required, >= 1). +// - --end-revision is optional; when omitted it defaults to the latest +// revision, returning every changeset from start up to now. +// - The version gap is capped at 20 (end - start + 1 <= 20); the same cap +// is enforced server-side (sheet-facade-agg maxChangesetRevGap). + +const changesetMaxRevGap = 20 + +// ChangesetGet fetches the raw changesets between two spreadsheet versions. +var ChangesetGet = common.Shortcut{ + Service: "sheets", + Command: "+changeset-get", + Description: "Fetch the raw changeset (edit actions) between two versions, to review whether an AI edit fulfilled the request.", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+changeset-get"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + _, _, err := changesetRevisions(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + input, _ := changesetInput(runtime, token) + return invokeToolDryRun(token, ToolKindRead, "get_changeset", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetTokenExec(runtime) + if err != nil { + return err + } + input, err := changesetInput(runtime, token) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindRead, "get_changeset", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + Tips: []string{ + "Pass only --start-revision to diff against the latest version; add --end-revision to bound the range.", + "The version gap is capped at 20 revisions (end - start + 1 <= 20).", + }, +} + +// changesetRevisions reads and validates the start / end revision flags. +// end <= 0 means "not provided" (default to latest, resolved server-side); a +// provided end must be >= start and within the 20-revision gap. +func changesetRevisions(runtime flagView) (start int, end int, err error) { + start = runtime.Int("start-revision") + end = runtime.Int("end-revision") + if start < 1 { + return 0, 0, sheetsValidationForFlag("start-revision", "--start-revision must be >= 1") + } + if end > 0 { + if end < start { + return 0, 0, sheetsValidationForFlag("end-revision", "--end-revision (%d) must be >= --start-revision (%d)", end, start) + } + if end-start+1 > changesetMaxRevGap { + return 0, 0, sheetsValidationForFlag("end-revision", "version gap exceeds limit %d (start=%d, end=%d)", changesetMaxRevGap, start, end) + } + } + return start, end, nil +} + +// changesetInput builds the get_changeset tool input. end_revision is only +// sent when explicitly provided; otherwise the server defaults to latest. +func changesetInput(runtime flagView, token string) (map[string]interface{}, error) { + start, end, err := changesetRevisions(runtime) + if err != nil { + return nil, err + } + input := map[string]interface{}{ + "excel_id": token, + "start_revision": start, + } + if end > 0 { + input["end_revision"] = end + } + return input, nil +} diff --git a/shortcuts/sheets/lark_sheet_changeset_test.go b/shortcuts/sheets/lark_sheet_changeset_test.go new file mode 100644 index 000000000..ec488245c --- /dev/null +++ b/shortcuts/sheets/lark_sheet_changeset_test.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import "testing" + +// TestChangesetGet_DryRun locks the get_changeset tool input: --end-revision +// is only sent when explicitly provided, otherwise the server defaults to the +// latest revision. +func TestChangesetGet_DryRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + wantInput map[string]interface{} + }{ + { + name: "start + end bounded range", + args: []string{"--url", testURL, "--start-revision", "120", "--end-revision", "135"}, + wantInput: map[string]interface{}{ + "excel_id": testToken, + "start_revision": float64(120), + "end_revision": float64(135), + }, + }, + { + name: "start only → end omitted (server defaults to latest)", + args: []string{"--url", testURL, "--start-revision", "120"}, + wantInput: map[string]interface{}{ + "excel_id": testToken, + "start_revision": float64(120), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, ChangesetGet, tt.args) + got := decodeToolInput(t, body, "get_changeset") + assertInputEquals(t, got, tt.wantInput) + }) + } +} + +// TestChangesetGet_Validation covers the client-side revision guards, which +// mirror the server cap (sheet-facade-agg maxChangesetRevGap = 20). +func TestChangesetGet_Validation(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + wantMsg string + wantParam string + }{ + { + name: "start-revision must be >= 1", + args: []string{"--url", testURL, "--start-revision", "0"}, + wantMsg: "start-revision must be >= 1", + wantParam: "--start-revision", + }, + { + name: "end before start rejected", + args: []string{"--url", testURL, "--start-revision", "100", "--end-revision", "50"}, + wantMsg: "end-revision", + wantParam: "--end-revision", + }, + { + name: "gap over 20 rejected", + args: []string{"--url", testURL, "--start-revision", "1", "--end-revision", "30"}, + wantMsg: "version gap exceeds limit", + wantParam: "--end-revision", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + _, _, err := runShortcutCapturingErr(t, ChangesetGet, append(c.args, "--dry-run")) + validationErr := requireValidation(t, err, c.wantMsg) + if validationErr.Param != c.wantParam { + t.Errorf("param = %q, want %q", validationErr.Param, c.wantParam) + } + }) + } +} diff --git a/shortcuts/sheets/lark_sheet_formula_verify.go b/shortcuts/sheets/lark_sheet_formula_verify.go new file mode 100644 index 000000000..62b6e1f42 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_formula_verify.go @@ -0,0 +1,167 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── lark_sheet_formula_verify ─────────────────────────────────────── +// +// Wraps verify_formula (read): scan formulas + cell error states across one +// or more sub-sheets and aggregate Excel errors (#REF! / #DIV/0! / #VALUE! / +// #NAME? / #NULL! / #NUM! / #N/A) plus compile failures (formula_errors) +// into a recalc.py-shaped JSON status report. The contract is the single +// AI self-check entry point for the R10 "write → verify zero-error" +// invariant — see canonical-spec/references/lark_sheet_formula_verify/. + +// FormulaVerify wraps verify_formula. Sheet selection is optional (both +// --sheet-id and --sheet-name are repeatable); when omitted, the tool scans +// every visible sub-sheet's current_region. +var FormulaVerify = common.Shortcut{ + Service: "sheets", + Command: "+formula-verify", + Description: "Scan formulas / cell errors and return a recalc.py-shaped status report (success / errors_found / partial).", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+formula-verify"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if err := validateFormulaVerifySheetSelector(runtime); err != nil { + return err + } + return validateFormulaVerifyLimits(runtime) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + return invokeToolDryRun(token, ToolKindRead, "verify_formula", formulaVerifyInput(runtime, token)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetTokenExec(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindRead, "verify_formula", formulaVerifyInput(runtime, token)) + if err != nil { + return err + } + runtime.Out(out, nil) + if runtime.Bool("exit-on-error") { + return formulaVerifyExitOnError(out) + } + return nil + }, +} + +// validateFormulaVerifySheetSelector enforces XOR-like guarantees on the +// two multi-value selectors: at most one of --sheet-id / --sheet-name may be +// non-empty (passing both is the high-frequency reflex confusion when the +// caller cargo-cults the single-sheet shortcut signature). Both empty is the +// documented "scan every visible sub-sheet" path. Control-char checks reuse +// requireSheetSelector's logic on each item. +func validateFormulaVerifySheetSelector(runtime *common.RuntimeContext) error { + ids := nonEmptySliceItems(runtime.StrSlice("sheet-id")) + names := nonEmptySliceItems(runtime.StrSlice("sheet-name")) + if len(ids) > 0 && len(names) > 0 { + return common.ValidationErrorf("--sheet-id and --sheet-name are mutually exclusive; pick one selector to identify sub-sheets"). + WithParams( + sheetsInvalidParam("sheet-id", "mutually exclusive"), + sheetsInvalidParam("sheet-name", "mutually exclusive"), + ) + } + for _, id := range ids { + if err := requireSheetSelector(id, ""); err != nil { + return err + } + } + for _, name := range names { + if err := requireSheetSelector("", name); err != nil { + return err + } + } + return nil +} + +// validateFormulaVerifyLimits rejects non-positive caps so a misplaced 0 or +// negative flag value can't silently degrade the scan (the server-side +// default would otherwise mask the typo). +func validateFormulaVerifyLimits(runtime *common.RuntimeContext) error { + if runtime.Changed("max-locations") && runtime.Int("max-locations") <= 0 { + return sheetsValidationForFlag("max-locations", "--max-locations must be > 0") + } + return nil +} + +// nonEmptySliceItems trims and drops blanks from a repeated-flag value so +// `--sheet-id ""` doesn't masquerade as a real entry. +func nonEmptySliceItems(in []string) []string { + out := make([]string, 0, len(in)) + for _, v := range in { + if trimmed := strings.TrimSpace(v); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +// formulaVerifyInput builds the verify_formula tool input map from CLI flags. +// excel_id is required; everything else is optional per the schema. +func formulaVerifyInput(runtime *common.RuntimeContext, token string) map[string]interface{} { + input := map[string]interface{}{ + "excel_id": token, + } + if ids := nonEmptySliceItems(runtime.StrSlice("sheet-id")); len(ids) > 0 { + input["sheet_ids"] = ids + } else if names := nonEmptySliceItems(runtime.StrSlice("sheet-name")); len(names) > 0 { + // The verify_formula schema only declares sheet_ids; the facade + // accepts sheet_names as a parallel optional field so name-based + // selection works without forcing the caller to pre-resolve. Mirrors + // how the other read shortcuts pack both fields via + // sheetSelectorForToolInput. + input["sheet_names"] = names + } + if ranges := nonEmptySliceItems(runtime.StrSlice("range")); len(ranges) > 0 { + input["ranges"] = ranges + } + if runtime.Changed("max-locations") { + input["max_locations_per_error"] = runtime.Int("max-locations") + } + return input +} + +// formulaVerifyExitOnError converts a verify_formula status into a non-zero +// CLI exit when the caller passed --exit-on-error. status="errors_found" +// is the only failure mode for this flag: "partial" means truncated but the +// scanned slice is clean, and "success" is obviously clean. A missing / +// unknown status is treated as a typed internal error because the tool's +// schema guarantees the field and we don't want a silent zero-exit. +func formulaVerifyExitOnError(out interface{}) error { + m, ok := out.(map[string]interface{}) + if !ok { + return errs.NewInternalError(errs.SubtypeInvalidResponse, + "verify_formula: missing status field in tool output") + } + status, _ := m["status"].(string) + switch status { + case "success", "partial": + return nil + case "errors_found": + total, _ := util.ToFloat64(m["total_errors"]) + return errs.NewValidationError(errs.SubtypeFailedPrecondition, + "verify_formula: %d formula error(s) detected; resolve and re-run", int(total)). + WithHint("inspect error_summary[*] / compile_errors[*] in the JSON output, fix or wrap with IFERROR, then re-run +formula-verify until status=success") + default: + return errs.NewInternalError(errs.SubtypeInvalidResponse, + "verify_formula: unexpected status %q", status) + } +} diff --git a/shortcuts/sheets/lark_sheet_formula_verify_test.go b/shortcuts/sheets/lark_sheet_formula_verify_test.go new file mode 100644 index 000000000..f0be248bc --- /dev/null +++ b/shortcuts/sheets/lark_sheet_formula_verify_test.go @@ -0,0 +1,213 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" +) + +// TestFormulaVerify_DryRun pins the wire shape verify_formula sends for the +// common input combinations: no selector (workbook-wide scan), explicit +// sheet_ids, explicit ranges, and the optional max_locations_per_error +// field. The test exercises the One-OpenAPI body +// directly so the schema field names stay locked to the canonical +// tool-schemas.json verify_formula node. +func TestFormulaVerify_DryRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + wantInput map[string]interface{} + }{ + { + name: "no selector — workbook-wide scan defaults", + args: []string{"--url", testURL}, + wantInput: map[string]interface{}{ + "excel_id": testToken, + }, + }, + { + name: "sheet_ids multi via repeat", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--sheet-id", testSheetID2}, + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_ids": []interface{}{testSheetID, testSheetID2}, + }, + }, + { + name: "sheet_names multi via comma", + args: []string{"--url", testURL, "--sheet-name", "Sheet1,Sheet2"}, + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_names": []interface{}{"Sheet1", "Sheet2"}, + }, + }, + { + name: "ranges + max_locations", + args: []string{ + "--url", testURL, + "--range", "A1:Z200", + "--range", "AA1:AZ100", + "--max-locations", "5", + }, + wantInput: map[string]interface{}{ + "excel_id": testToken, + "ranges": []interface{}{"A1:Z200", "AA1:AZ100"}, + "max_locations_per_error": float64(5), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, FormulaVerify, tt.args) + got := decodeToolInput(t, body, "verify_formula") + assertInputEquals(t, got, tt.wantInput) + }) + } +} + +// TestFormulaVerify_DryRunInvokeReadPath confirms the request hits +// invoke_read (read scope) and not invoke_write — a scope mismatch here would +// surface as a 403 from the gateway. +func TestFormulaVerify_DryRunInvokeReadPath(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, FormulaVerify, []string{"--url", testURL}) + if len(calls) == 0 { + t.Fatalf("dry-run produced no api calls") + } + call, _ := calls[0].(map[string]interface{}) + url, _ := call["url"].(string) + if !strings.HasSuffix(url, "/tools/invoke_read") { + t.Errorf("verify_formula must hit invoke_read; got url=%q", url) + } + if want := "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_read"; url != want { + t.Errorf("url = %q, want %q", url, want) + } +} + +// TestFormulaVerify_RejectsBothSelectors locks the "at most one selector" +// rule on the two multi-value flags. Both empty is the documented +// workbook-wide scan path, so we only reject the both-supplied case. +func TestFormulaVerify_RejectsBothSelectors(t *testing.T) { + t.Parallel() + _, _, err := runShortcutCapturingErr(t, FormulaVerify, []string{ + "--url", testURL, + "--sheet-id", testSheetID, + "--sheet-name", "Sheet1", + "--dry-run", + }) + ve := requireValidation(t, err, "mutually exclusive") + gotParams := map[string]bool{} + for _, p := range ve.Params { + gotParams[p.Name] = true + } + if !gotParams["--sheet-id"] || !gotParams["--sheet-name"] { + t.Errorf("params = %#v, want both --sheet-id and --sheet-name flagged", ve.Params) + } +} + +// TestFormulaVerify_RejectsNonPositiveLimits guards against typos like +// `--max-locations 0`, which would otherwise be silently swallowed by the +// "explicit value but unset" comparison in the input builder. +func TestFormulaVerify_RejectsNonPositiveLimits(t *testing.T) { + t.Parallel() + cases := []struct { + name string + args []string + want string + }{ + { + name: "max-locations=0", + args: []string{"--url", testURL, "--max-locations", "0"}, + want: "--max-locations must be > 0", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + _, _, err := runShortcutCapturingErr(t, FormulaVerify, append(c.args, "--dry-run")) + requireValidation(t, err, c.want) + }) + } +} + +// TestFormulaVerifyExitOnError_StatusMatrix locks the --exit-on-error +// contract: success/partial → no error; errors_found → typed validation +// error with SubtypeFailedPrecondition; missing or unknown status → +// typed internal error so a silent zero-exit can never happen. +func TestFormulaVerifyExitOnError_StatusMatrix(t *testing.T) { + t.Parallel() + + t.Run("success returns no error", func(t *testing.T) { + t.Parallel() + if err := formulaVerifyExitOnError(map[string]interface{}{"status": "success"}); err != nil { + t.Fatalf("success path returned err: %v", err) + } + }) + + t.Run("partial returns no error", func(t *testing.T) { + t.Parallel() + if err := formulaVerifyExitOnError(map[string]interface{}{"status": "partial", "has_more": true}); err != nil { + t.Fatalf("partial path returned err: %v", err) + } + }) + + t.Run("errors_found yields failed_precondition with count", func(t *testing.T) { + t.Parallel() + err := formulaVerifyExitOnError(map[string]interface{}{ + "status": "errors_found", + "total_errors": float64(7), + }) + if err == nil { + t.Fatal("expected error, got nil") + } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("error = %T %v, want *errs.ValidationError", err, err) + } + if ve.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition) + } + if !strings.Contains(ve.Message, "7 formula error") { + t.Errorf("message %q must surface the error count", ve.Message) + } + if ve.Hint == "" { + t.Errorf("hint must be set so AI agents know to re-run after fixes") + } + }) + + t.Run("unknown status maps to internal/invalid_response", func(t *testing.T) { + t.Parallel() + err := formulaVerifyExitOnError(map[string]interface{}{"status": "weird"}) + if err == nil { + t.Fatal("expected error, got nil") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed problem, got %T %v", err, err) + } + if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse { + t.Errorf("category/subtype = %q/%q, want internal/invalid_response", p.Category, p.Subtype) + } + }) + + t.Run("non-object output maps to internal/invalid_response", func(t *testing.T) { + t.Parallel() + err := formulaVerifyExitOnError("oops") + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed problem, got %T %v", err, err) + } + if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse { + t.Errorf("category/subtype = %q/%q, want internal/invalid_response", p.Category, p.Subtype) + } + }) +} diff --git a/shortcuts/sheets/lark_sheet_history_list.go b/shortcuts/sheets/lark_sheet_history_list.go new file mode 100644 index 000000000..22f006750 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_history_list.go @@ -0,0 +1,92 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── lark_sheet_history (BE-1: +history-list) ───────────────────────── +// +// Wraps the facade-agg `history_list` tool (read) behind the One-OpenAPI +// invoke_read endpoint. The tool returns a sheet's version history. The +// facade-agg tool already performs the response transform (minor_histories +// trim / id → history_version_id / 4-field projection / RFC3339 create_time), +// so the CLI passes the tool output straight through and does NOT re-implement +// the transform client-side. +// +// History is workbook-level (no sheet selector), mirroring +workbook-info: +// the only locator is --url / --spreadsheet-token (XOR), with --token accepted +// as a parse-time alias for --spreadsheet-token via the shared PostMount hook. + +// historyLocatorFlags is the --url / --spreadsheet-token XOR locator pair +// shared by the three history shortcuts. Mirrors +workbook-info's flag-defs +// entry; XOR is enforced in Validate via parseSpreadsheetRef, not by Required. +func historyLocatorFlags() []common.Flag { + return []common.Flag{ + {Name: "url", Type: "string", Desc: "Spreadsheet locator (a /sheets/ or /wiki/ URL)."}, + {Name: "spreadsheet-token", Type: "string", Desc: "Spreadsheet locator (raw spreadsheet token)."}, + } +} + +// HistoryList wraps the history_list tool: list a spreadsheet's history +// versions. Each item carries history_version_id / create_time / action / +// all_block_revision (projected server-side). An empty sheet yields an empty +// list and exit 0. +// +// Backward pagination: --end-version (optional int) maps to the tool's +// `end_version` parameter. Omit on the first call to fetch the latest page. +// On subsequent pages pass the previous response's next_end_version as +// --end-version. The tool returns next_end_version + has_more only when +// more history exists; both fields are absent at the earliest page. +var HistoryList = common.Shortcut{ + Service: "sheets", + Command: "+history-list", + Description: "List a spreadsheet's edit history versions (history_version_id, create_time, action, all_block_revision).", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(historyLocatorFlags(), + common.Flag{Name: "end-version", Type: "int", Desc: "Max version to query (descending pagination). Omit on the first call; pass the previous response's next_end_version on subsequent pages."}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := resolveSpreadsheetToken(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + return invokeToolDryRun(token, ToolKindRead, "history_list", historyListInput(runtime, token)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetTokenExec(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindRead, "history_list", historyListInput(runtime, token)) + if err != nil { + return err + } + // Pass the tool output through verbatim — facade-agg already shaped it. + runtime.Out(out, nil) + return nil + }, + Tips: []string{ + "Capture a history_version_id from the result to feed +history-revert.", + "For older history, capture next_end_version from the response and pass it as --end-version on the next call (omitted by the server when the earliest page is reached).", + }, +} + +// historyListInput composes the history_list tool input. --end-version is +// optional: include it only when explicitly set so the server treats absence +// as "first page (latest)". +func historyListInput(runtime *common.RuntimeContext, token string) map[string]interface{} { + in := map[string]interface{}{"excel_id": token} + if runtime.Changed("end-version") { + in["end_version"] = runtime.Int("end-version") + } + return in +} diff --git a/shortcuts/sheets/lark_sheet_history_revert.go b/shortcuts/sheets/lark_sheet_history_revert.go new file mode 100644 index 000000000..c5e275d7c --- /dev/null +++ b/shortcuts/sheets/lark_sheet_history_revert.go @@ -0,0 +1,171 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "strings" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── lark_sheet_history (BE-2: +history-revert / +history-revert-status) ── +// +// Two thin callTool wrappers over the facade-agg history tools: +// - +history-revert → history_revert (write) — async revert +// - +history-revert-status → history_revert_status (read) — poll outcome +// +// Both target a single history version via --history-version-id (the id +// surfaced by +history-list). Revert is asynchronous: it returns a receipt / +// transaction id that +history-revert-status then polls, distinguishing +// in-progress / success / failure from the tool output (passed through +// verbatim — no client-side shaping). +// +// ⚠️ Backend state: the facade-agg history_revert / history_revert_status +// tools are registered but their downstream RPC wiring is a DEFERRED +// follow-up; today they return a "not wired yet" guard error from the gateway, +// which surfaces here as a normal tool error. These CLI shortcuts are correct +// thin wrappers and will work end-to-end once the backend follow-up lands — +// this is NOT a CLI blocker. See self_check.md. + +func historyRevertFlags() []common.Flag { + return flagsFor("+history-revert") +} + +// validateHistoryVersionID enforces the required, control-char-clean +// --history-version-id. Returns the trimmed value so callers reuse it. +func validateHistoryVersionID(runtime *common.RuntimeContext) (string, error) { + id := strings.TrimSpace(runtime.Str("history-version-id")) + if id == "" { + return "", sheetsValidationForFlag("history-version-id", "--history-version-id is required") + } + if err := validate.RejectControlChars(id, "--history-version-id"); err != nil { + return "", err + } + return id, nil +} + +func historyRevertInput(token, versionID string) map[string]interface{} { + return map[string]interface{}{ + "excel_id": token, + "history_version_id": versionID, + } +} + +func historyRevertStatusFlags() []common.Flag { + return flagsFor("+history-revert-status") +} + +// validateTransactionID enforces the required, trimmed --transaction-id and +// returns it for reuse. +func validateTransactionID(runtime *common.RuntimeContext) (string, error) { + id := strings.TrimSpace(runtime.Str("transaction-id")) + if id == "" { + return "", sheetsValidationForFlag("transaction-id", "--transaction-id is required") + } + if err := validate.RejectControlChars(id, "--transaction-id"); err != nil { + return "", err + } + return id, nil +} + +func historyRevertStatusInput(token, transactionID string) map[string]interface{} { + return map[string]interface{}{ + "excel_id": token, + "transaction_id": transactionID, + } +} + +// HistoryRevert wraps the history_revert tool (write): asynchronously revert a +// spreadsheet to the given history version. --history-version-id is required +// at the cli surface (cobra MarkFlagRequired); a missing flag fails before +// Validate runs with cobra's standard "required flag(s)" error (which the +// dispatcher classifies as a typed *errs.ValidationError, exit 2). We still +// trim + reject empty / control-char values in Validate to catch the +// case where cobra accepts --history-version-id with an empty-string value. +var HistoryRevert = common.Shortcut{ + Service: "sheets", + Command: "+history-revert", + Description: "Revert a spreadsheet to a given history version (asynchronous; poll with +history-revert-status).", + Risk: "high-risk-write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: historyRevertFlags(), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + _, err := validateHistoryVersionID(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + versionID := strings.TrimSpace(runtime.Str("history-version-id")) + return invokeToolDryRun(token, ToolKindWrite, "history_revert", historyRevertInput(token, versionID)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetTokenExec(runtime) + if err != nil { + return err + } + versionID, err := validateHistoryVersionID(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "history_revert", historyRevertInput(token, versionID)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + Tips: []string{ + "Revert overwrites the current spreadsheet content. Always run with --dry-run first to verify the target spreadsheet and history_version_id.", + "Revert is asynchronous — pass the returned id to +history-revert-status to track in-progress / success / failure.", + }, +} + +// HistoryRevertStatus wraps the history_revert_status tool (read): poll the +// outcome of a prior +history-revert. The tool output distinguishes +// in-progress / success / failure and is passed through verbatim. +var HistoryRevertStatus = common.Shortcut{ + Service: "sheets", + Command: "+history-revert-status", + Description: "Poll the status of a history revert (in-progress / success / failure).", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: historyRevertStatusFlags(), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + _, err := validateTransactionID(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + txnID := strings.TrimSpace(runtime.Str("transaction-id")) + return invokeToolDryRun(token, ToolKindRead, "history_revert_status", historyRevertStatusInput(token, txnID)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetTokenExec(runtime) + if err != nil { + return err + } + txnID, err := validateTransactionID(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindRead, "history_revert_status", historyRevertStatusInput(token, txnID)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} diff --git a/shortcuts/sheets/lark_sheet_history_test.go b/shortcuts/sheets/lark_sheet_history_test.go new file mode 100644 index 000000000..00df24a63 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_history_test.go @@ -0,0 +1,177 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +// TestHistoryShortcuts_DryRun asserts each history shortcut targets the right +// facade-agg tool, routes through the correct read/write invoke endpoint, and +// builds the expected tool input (excel_id always; history_version_id for the +// revert pair). +func TestHistoryShortcuts_DryRun(t *testing.T) { + t.Parallel() + + const versionID = "histVER123" + const txnID = "txn-abc-123" + + tests := []struct { + name string + sc common.Shortcut + args []string + toolName string + wantPath string // invoke_read | invoke_write suffix + wantInput map[string]interface{} + }{ + { + name: "+history-list via --url", + sc: HistoryList, + args: []string{"--url", testURL}, + toolName: "history_list", + wantPath: "invoke_read", + wantInput: map[string]interface{}{ + "excel_id": testToken, + }, + }, + { + name: "+history-list via --spreadsheet-token", + sc: HistoryList, + args: []string{"--spreadsheet-token", testToken}, + toolName: "history_list", + wantPath: "invoke_read", + wantInput: map[string]interface{}{ + "excel_id": testToken, + }, + }, + { + name: "+history-list paginates with --end-version", + sc: HistoryList, + args: []string{"--url", testURL, "--end-version", "12345"}, + toolName: "history_list", + wantPath: "invoke_read", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "end_version": float64(12345), // post-JSON-unmarshal numeric type + }, + }, + { + name: "+history-revert routes to invoke_write with version id", + sc: HistoryRevert, + args: []string{"--url", testURL, "--history-version-id", versionID}, + toolName: "history_revert", + wantPath: "invoke_write", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "history_version_id": versionID, + }, + }, + { + name: "+history-revert-status routes to invoke_read with transaction id", + sc: HistoryRevertStatus, + args: []string{"--url", testURL, "--transaction-id", txnID}, + toolName: "history_revert_status", + wantPath: "invoke_read", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "transaction_id": txnID, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + callURL := dryRunFirstCallURL(t, tt.sc, tt.args) + if !containsSuffix(callURL, tt.wantPath) { + t.Errorf("invoke url = %q, want suffix %q", callURL, tt.wantPath) + } + body := parseDryRunBody(t, tt.sc, tt.args) + got := decodeToolInput(t, body, tt.toolName) + assertInputEquals(t, got, tt.wantInput) + }) + } +} + +// TestHistoryRevert_MissingRequiredFlag asserts each shortcut rejects a +// missing required selector before any request is sent, with two distinct +// gates by design: +// +// - +history-revert: --history-version-id is cobra-required (Required=true +// in the flag def → MarkFlagRequired). cobra refuses the call before +// Validate runs with a plain "required flag(s)" error; the cmd dispatcher +// classifies it as a typed *errs.ValidationError (invalid_argument, exit 2). +// The test rig invokes the shortcut via cmd.Execute and observes the raw +// cobra error directly (no dispatcher wrap), so we assert the cobra text +// contract instead of the typed envelope. +// +// - +history-revert-status: --transaction-id is cobra-optional; +// requiredness is enforced inside Validate so we still get a typed, +// flag-tagged *errs.ValidationError with Param="--transaction-id". +func TestHistoryRevert_MissingRequiredFlag(t *testing.T) { + t.Parallel() + + t.Run(HistoryRevert.Command, func(t *testing.T) { + t.Parallel() + _, _, err := runShortcutCapturingErr(t, HistoryRevert, []string{"--url", testURL}) + if err == nil { + t.Fatalf("%s: expected error for missing --history-version-id", HistoryRevert.Command) + } + msg := err.Error() + if !strings.Contains(msg, "required flag(s)") || !strings.Contains(msg, "history-version-id") { + t.Fatalf("%s: cobra error = %q, want substrings 'required flag(s)' and 'history-version-id'", HistoryRevert.Command, msg) + } + }) + + t.Run(HistoryRevertStatus.Command, func(t *testing.T) { + t.Parallel() + _, _, err := runShortcutCapturingErr(t, HistoryRevertStatus, []string{"--url", testURL}) + if err == nil { + t.Fatalf("%s: expected error for missing --transaction-id", HistoryRevertStatus.Command) + } + msg := err.Error() + if !strings.Contains(msg, "required flag(s)") || !strings.Contains(msg, "transaction-id") { + t.Fatalf("%s: cobra error = %q, want substrings 'required flag(s)' and 'transaction-id'", HistoryRevertStatus.Command, msg) + } + }) +} + +func TestHistoryRevert_HighRiskWriteRequiresYes(t *testing.T) { + t.Parallel() + _, _, err := runShortcutCapturingErr(t, HistoryRevert, []string{ + "--url", testURL, + "--history-version-id", "histVER123", + }) + requireProblem(t, err, errs.CategoryConfirmation, errs.SubtypeConfirmationRequired, "") +} + +// dryRunFirstCallURL runs the shortcut in --dry-run and returns the first +// api call's url, so tests can assert read vs. write endpoint routing. +func dryRunFirstCallURL(t *testing.T, sc common.Shortcut, args []string) string { + t.Helper() + out, err := runShortcut(t, sc, append(args, "--dry-run")) + if err != nil { + t.Fatalf("dry-run failed: %v\noutput=%s", err, out) + } + dryRun := decodeDryRunRaw(t, out) + calls, ok := dryRun["api"].([]interface{}) + if !ok || len(calls) == 0 { + t.Fatalf("dry-run api array empty or wrong shape: %#v", dryRun) + } + call, _ := calls[0].(map[string]interface{}) + url, _ := call["url"].(string) + return url +} + +func containsSuffix(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/shortcuts/sheets/lark_sheet_range_operations.go b/shortcuts/sheets/lark_sheet_range_operations.go index e2dc9eb6f..78acf6633 100644 --- a/shortcuts/sheets/lark_sheet_range_operations.go +++ b/shortcuts/sheets/lark_sheet_range_operations.go @@ -5,6 +5,8 @@ package sheets import ( "context" + "fmt" + "sort" "strings" "github.com/larksuite/cli/errs" @@ -208,77 +210,81 @@ func mergeInput(runtime flagView, token, sheetID, sheetName, op string, withMerg return input, nil } -// resize_range exposes two CLI shortcuts: +// resize_range exposes two CLI shortcuts, each with two input forms: // -// +rows-resize / +cols-resize — set row heights / column widths. --type -// enum (pixel / standard / [auto]) controls how: --type pixel needs --size, -// --type standard restores the sheet default, --type auto auto-fits row -// heights (rows only). --range is an A1 closed range ("2:10" / "5" rows or -// "A:E" / "C" columns); single-element form is expanded to "N:N" before -// send because resize_range rejects bare single-element ranges. +// +rows-resize / +cols-resize — set row heights / column widths. +// +// Uniform form: --range + --height/--width ; the pixel mode is implied +// so --type can be omitted (or set to `pixel` — equivalent). Non-pixel +// modes go through --type standard / --type auto (rows only) and cannot be +// combined with the pixel flag. --range is an A1 closed range ("2:10" / +// "5" rows or "A:E" / "C" columns); single-element form is expanded to +// "N:N" before send because resize_range rejects bare single-element +// ranges. +// +// Map form: --heights / --widths carries a JSON object of per-row/column +// sizes ({"A": 100, "C:E": 120, "G": "standard"}) and fans out into one +// atomic batch_update of resize_range ops — different sizes for many +// rows/columns in a single CLI call, no +batch-update needed. Mutually +// exclusive with --range/--height/--width/--type, and not accepted as a +// +batch-update sub-op (nested batch_update is unsupported upstream). // // Wire shape: resize_height / resize_width carries { type, value? }, e.g. // { "type": "pixel", "value": 30 } or { "type": "standard" }. +// +// Units are pixels. Column widths in Excel character units (openpyxl / +// xlsxwriter mental model, px ≈ chars × 8 + 16) are a real agent trap, so +// widths below minSaneColumnWidthPx are rejected with a conversion hint. -// RowsResize wraps resize_range for row heights. --type auto enables -// auto-fit (rows only); --type pixel requires --size. +// RowsResize wraps resize_range for row heights. Pass --range + --height +// for a uniform pixel height, --heights '{"1":50,"2:20":30}' for +// per-row heights, or --type standard/auto for non-pixel modes. var RowsResize = common.Shortcut{ Service: "sheets", Command: "+rows-resize", - Description: "Resize rows by pixel / standard / auto (--type pixel needs --size; --range is 1-based A1 like \"2:10\" or \"5\").", + Description: "Resize rows in pixels: --range + --height for one uniform height, --heights '{\"1\":50,\"2:20\":30,\"21\":\"auto\"}' for per-row heights in one atomic call, or --type standard/auto (--range is 1-based A1 like \"2:10\" or \"5\").", Risk: "write", Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+rows-resize"), Validate: validateViaResize("row"), - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := resolveSpreadsheetToken(runtime) - sheetID, sheetName, _ := resolveSheetSelector(runtime) - input, _ := resizeInput(runtime, token, sheetID, sheetName, "row") - return invokeToolDryRun(token, ToolKindWrite, "resize_range", input) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, err := resolveSpreadsheetTokenExec(runtime) - if err != nil { - return err - } - sheetID, sheetName, err := resolveSheetSelector(runtime) - if err != nil { - return err - } - input, err := resizeInput(runtime, token, sheetID, sheetName, "row") - if err != nil { - return err - } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "resize_range", input) - if err != nil { - return err - } - runtime.Out(out, nil) - return nil - }, + DryRun: resizeDryRun("row"), + Execute: resizeExecute("row"), } -// ColsResize wraps resize_range for column widths. Column widths do not -// support auto-fit — --type only accepts pixel / standard. +// ColsResize wraps resize_range for column widths. Pass --range + --width +// for a uniform pixel width, --widths '{"A":100,"C:E":120}' for +// per-column widths, or --type standard for the default width. Column +// widths do not support auto-fit — --type does not accept auto. var ColsResize = common.Shortcut{ Service: "sheets", Command: "+cols-resize", - Description: "Resize columns by pixel / standard (--type pixel needs --size; --range is column letters like \"A:E\" or \"C\"; no auto for cols).", + Description: "Resize columns in pixels (NOT Excel char units): --range + --width for one uniform width, --widths '{\"A\":100,\"C:E\":120}' for per-column widths in one atomic call, or --type standard to reset (--range is column letters like \"A:E\" or \"C\"; no auto for cols).", Risk: "write", Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+cols-resize"), Validate: validateViaResize("column"), - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + DryRun: resizeDryRun("column"), + Execute: resizeExecute("column"), +} + +// resizeDryRun / resizeExecute route a resize shortcut through resizeToolCall +// so the uniform form hits resize_range and the map form hits batch_update +// with identical inputs in preview and execution. +func resizeDryRun(dimension string) func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - input, _ := resizeInput(runtime, token, sheetID, sheetName, "column") - return invokeToolDryRun(token, ToolKindWrite, "resize_range", input) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + toolName, input, _ := resizeToolCall(runtime, token, sheetID, sheetName, dimension) + return invokeToolDryRun(token, ToolKindWrite, toolName, input) + } +} + +func resizeExecute(dimension string) func(ctx context.Context, runtime *common.RuntimeContext) error { + return func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetTokenExec(runtime) if err != nil { return err @@ -287,22 +293,21 @@ var ColsResize = common.Shortcut{ if err != nil { return err } - input, err := resizeInput(runtime, token, sheetID, sheetName, "column") + toolName, input, err := resizeToolCall(runtime, token, sheetID, sheetName, dimension) if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "resize_range", input) + out, err := callTool(ctx, runtime, token, ToolKindWrite, toolName, input) if err != nil { return err } runtime.Out(out, nil) return nil - }, + } } -// validateViaResize wires the standalone Validate to resizeInput so both -// paths (standalone + batch sub-op) emit the same error for missing --type, -// malformed --range, or --type auto on columns. +// validateViaResize wires the standalone Validate to resizeToolCall so both +// forms (uniform + map) are fully validated before execution. func validateViaResize(dimension string) func(ctx context.Context, runtime *common.RuntimeContext) error { return func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -311,17 +316,82 @@ func validateViaResize(dimension string) func(ctx context.Context, runtime *comm } sheetID := strings.TrimSpace(runtime.Str("sheet-id")) sheetName := strings.TrimSpace(runtime.Str("sheet-name")) - _, err = resizeInput(runtime, token, sheetID, sheetName, dimension) + _, _, err = resizeToolCall(runtime, token, sheetID, sheetName, dimension) return err } } -// autoSuffix appends " / auto" to the enum hint for rows. -func autoSuffix(dimension string) string { - if dimension == "row" { - return " / auto" +// resizeToolCall picks the input form: map form (--heights/--widths) builds a +// batch_update of resize_range ops; uniform form builds a single resize_range +// input. Returns the tool name to invoke alongside its input. +func resizeToolCall(runtime flagView, token, sheetID, sheetName, dimension string) (string, map[string]interface{}, error) { + if runtime.Changed(sizeMapFlag(dimension)) { + input, err := resizeMapInput(runtime, token, sheetID, sheetName, dimension) + return "batch_update", input, err } - return "" + input, err := resizeInput(runtime, token, sheetID, sheetName, dimension) + return "resize_range", input, err +} + +// nonPixelTypes lists the --type values a given dimension accepts (rows also +// accept auto; columns only accept standard). Used to shape the hint printed +// when --type is missing or invalid. +func nonPixelTypes(dimension string) string { + if dimension == "row" { + return "standard / auto" + } + return "standard" +} + +// pixelFlag maps a dimension to its pixel-value flag name (--height for rows, +// --width for cols). The wire block always emits "pixel" as the mode; the +// per-dimension flag name is just the surface knob. +func pixelFlag(dimension string) string { + if dimension == "row" { + return "height" + } + return "width" +} + +// sizeMapFlag maps a dimension to its map-form flag name (--heights for rows, +// --widths for cols). +func sizeMapFlag(dimension string) string { + return pixelFlag(dimension) + "s" +} + +// rejectResizeMapInBatch blocks the map form inside +batch-update sub-ops: +// it expands into its own batch_update and nesting batch_update is +// unsupported upstream. Called by the batch dispatch closures only — the +// standalone path routes the map form through resizeMapInput instead. +func rejectResizeMapInBatch(fv flagView, dimension string) error { + mapFlag := sizeMapFlag(dimension) + if !fv.Changed(mapFlag) { + return nil + } + return sheetsValidationForFlag(mapFlag, + "%q is not supported inside +batch-update (it expands into its own atomic batch); call %s --%s standalone, or give each sub-op the single-range form (range + %s/type)", + mapFlag, commandForDimension(dimension), mapFlag, pixelFlag(dimension)) +} + +// minSaneColumnWidthPx is the floor below which a column width almost +// certainly means the caller thought in Excel character units (openpyxl / +// xlsxwriter widths run 8-30 chars) instead of pixels. 10px columns are +// unusable; real pixel spacer columns start around 20px. +const minSaneColumnWidthPx = 20 + +// checkPixelSize validates a pixel value for one dimension. label names the +// offending input in the error ("--width" for the uniform flag, "--widths +// key \"A\"" for a map entry). +func checkPixelSize(dimension, flagName, label string, px int) error { + if px <= 0 { + return sheetsValidationForFlag(flagName, "%s must be > 0", label) + } + if dimension == "column" && px < minSaneColumnWidthPx { + return sheetsValidationForFlag(flagName, + "%s = %dpx is below %dpx and looks like an Excel character-unit width — column widths here are pixels (px ≈ chars × 8 + 16, so %d chars ≈ %dpx)", + label, px, minSaneColumnWidthPx, px, px*8+16) + } + return nil } // commandForDimension returns the shortcut command name a given dimension @@ -339,6 +409,11 @@ func commandForDimension(dimension string) string { // dimension (row → digits like "2:10" / "5"; column → letters like "A:E" / // "C"). Single-element form is expanded to "N:N" because resize_range // rejects bare single-element ranges. +// +// Surface: pixel size goes through --height / --width (dimension-specific). +// --type is optional when the pixel flag is present (defaults to "pixel"); +// explicit --type pixel is accepted and equivalent. --type standard / auto +// select non-pixel modes and cannot be combined with the pixel flag. func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string) (map[string]interface{}, error) { if err := requireSheetSelector(sheetID, sheetName); err != nil { return nil, err @@ -361,29 +436,42 @@ func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string) if !strings.Contains(rangeStr, ":") { rangeStr = rangeStr + ":" + rangeStr } + + sizeFlag := pixelFlag(dimension) + hasSize := runtime.Changed(sizeFlag) typ := strings.TrimSpace(runtime.Str("type")) - if typ == "" { - return nil, sheetsValidationForFlag("type", "--type is required (pixel / standard%s)", autoSuffix(dimension)) + hasType := typ != "" + + if !hasSize && !hasType { + return nil, common.ValidationErrorf("give --%s for a pixel size, or --type %s", sizeFlag, nonPixelTypes(dimension)).WithParams(sheetsInvalidParam(sizeFlag, "required"), sheetsInvalidParam("type", "required")) } - if dimension == "column" && typ == "auto" { + if hasSize && hasType && typ != "pixel" { + return nil, common.ValidationErrorf("--%s cannot be combined with --type %s", sizeFlag, typ).WithParams(sheetsInvalidParam(sizeFlag, "mutually exclusive"), sheetsInvalidParam("type", "mutually exclusive")) + } + if hasType && dimension == "column" && typ == "auto" { return nil, sheetsValidationForFlag("type", "--type auto is rows-only (column widths do not support auto-fit); use +rows-resize") } - hasSize := runtime.Changed("size") && runtime.Int("size") > 0 - if typ == "pixel" && !hasSize { - return nil, common.ValidationErrorf("--type pixel requires --size ").WithParams(sheetsInvalidParam("type", "required"), sheetsInvalidParam("size", "required")) + if hasType && typ == "pixel" && !hasSize { + return nil, common.ValidationErrorf("--type pixel requires --%s ", sizeFlag).WithParams(sheetsInvalidParam("type", "required"), sheetsInvalidParam(sizeFlag, "required")) } - if typ != "pixel" && hasSize { - return nil, common.ValidationErrorf("--size is only valid with --type pixel").WithParams(sheetsInvalidParam("size", "mutually exclusive"), sheetsInvalidParam("type", "mutually exclusive")) + + sizeBlock := map[string]interface{}{} + if hasSize { + px := runtime.Int(sizeFlag) + if err := checkPixelSize(dimension, sizeFlag, "--"+sizeFlag, px); err != nil { + return nil, err + } + sizeBlock["type"] = "pixel" + sizeBlock["value"] = px + } else { + sizeBlock["type"] = typ } + input := map[string]interface{}{ "excel_id": token, "range": rangeStr, } sheetSelectorForToolInput(input, sheetID, sheetName) - sizeBlock := map[string]interface{}{"type": typ} - if typ == "pixel" { - sizeBlock["value"] = runtime.Int("size") - } if dimension == "row" { input["resize_height"] = sizeBlock } else { @@ -392,6 +480,146 @@ func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string) return input, nil } +// resizeMapInput builds the batch_update input for the map form: every +// --heights/--widths entry becomes one resize_range op inside a single atomic +// batch. Keys are single rows/columns ("5" / "A") or closed ranges ("2:8" / +// "C:E") matching the command's dimension; values are positive pixel ints or +// the non-pixel mode strings ("standard", and "auto" for rows). Ops are +// sorted by start position so dry-run output and execution order are +// deterministic (JSON object order is not preserved by Go maps). +func resizeMapInput(runtime flagView, token, sheetID, sheetName, dimension string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + mapFlag := sizeMapFlag(dimension) + for _, other := range []string{"range", pixelFlag(dimension), "type"} { + if runtime.Changed(other) { + return nil, common.ValidationErrorf("--%s is a self-contained map; do not combine it with --%s", mapFlag, other).WithParams(sheetsInvalidParam(mapFlag, "mutually exclusive"), sheetsInvalidParam(other, "mutually exclusive")) + } + } + parsed, err := parseJSONFlag(runtime, mapFlag) + if err != nil { + return nil, err + } + entries, ok := parsed.(map[string]interface{}) + if !ok || parsed == nil { + return nil, sheetsValidationForFlag(mapFlag, "--%s must be a JSON object like {\"%s\": 100}", mapFlag, exampleMapKey(dimension)) + } + if len(entries) == 0 { + return nil, sheetsValidationForFlag(mapFlag, "--%s must contain at least one entry", mapFlag) + } + if len(entries) > maxBatchOperations { + return nil, sheetsValidationForFlag(mapFlag, "--%s accepts at most %d entries; got %d", mapFlag, maxBatchOperations, len(entries)) + } + + type resizeOp struct { + start int + end int + rangeKey string + input map[string]interface{} + } + ops := make([]resizeOp, 0, len(entries)) + seen := make(map[string]string, len(entries)) // normalized range → original key + for key, raw := range entries { + parsedDim, startIdx, endIdx, err := parseA1Range(key) + if err != nil { + return nil, sheetsValidationForFlag(mapFlag, "--%s key %q: %v", mapFlag, key, err) + } + if parsedDim != dimension { + want := "row numbers (e.g. \"2:10\")" + if dimension == "column" { + want = "column letters (e.g. \"A:E\")" + } + return nil, sheetsValidationForFlag(mapFlag, "--%s key %q is a %s range; %s expects %s", mapFlag, key, parsedDim, commandForDimension(dimension), want) + } + normalized := strings.TrimSpace(key) + if !strings.Contains(normalized, ":") { + normalized = normalized + ":" + normalized + } + if prev, dup := seen[normalized]; dup { + return nil, sheetsValidationForFlag(mapFlag, "--%s keys %q and %q target the same range %s; merge them into one entry", mapFlag, prev, key, normalized) + } + seen[normalized] = key + + sizeBlock := map[string]interface{}{} + switch v := raw.(type) { + case float64: + px := int(v) + if float64(px) != v { + return nil, sheetsValidationForFlag(mapFlag, "--%s[%q] must be an integer pixel value, got %v", mapFlag, key, v) + } + if err := checkPixelSize(dimension, mapFlag, fmt.Sprintf("--%s[%q]", mapFlag, key), px); err != nil { + return nil, err + } + sizeBlock["type"] = "pixel" + sizeBlock["value"] = px + case string: + mode := strings.TrimSpace(v) + if mode == "auto" && dimension == "column" { + return nil, sheetsValidationForFlag(mapFlag, "--%s[%q]: \"auto\" is rows-only (column widths do not support auto-fit); estimate a pixel width instead (px ≈ chars × 8 + 16)", mapFlag, key) + } + if mode != "standard" && !(mode == "auto" && dimension == "row") { + return nil, sheetsValidationForFlag(mapFlag, "--%s[%q] = %q is invalid; use a pixel integer or %s", mapFlag, key, v, nonPixelTypes(dimension)) + } + sizeBlock["type"] = mode + default: + return nil, sheetsValidationForFlag(mapFlag, "--%s[%q] must be a pixel integer or a mode string (%s), got %s", mapFlag, key, nonPixelTypes(dimension), jsonTypeName(raw)) + } + + opInput := map[string]interface{}{ + "excel_id": token, + "range": normalized, + } + sheetSelectorForToolInput(opInput, sheetID, sheetName) + if dimension == "row" { + opInput["resize_height"] = sizeBlock + } else { + opInput["resize_width"] = sizeBlock + } + ops = append(ops, resizeOp{ + start: startIdx, + end: endIdx, + rangeKey: normalized, + input: opInput, + }) + } + + sort.Slice(ops, func(i, j int) bool { + if ops[i].start != ops[j].start { + return ops[i].start < ops[j].start + } + return ops[i].end < ops[j].end + }) + for i := 1; i < len(ops); i++ { + if ops[i].start <= ops[i-1].end { + return nil, sheetsValidationForFlag( + mapFlag, + "--%s ranges %q and %q overlap; use non-overlapping ranges", + mapFlag, ops[i-1].rangeKey, ops[i].rangeKey, + ) + } + } + operations := make([]interface{}, 0, len(ops)) + for _, op := range ops { + operations = append(operations, map[string]interface{}{ + "tool_name": "resize_range", + "input": op.input, + }) + } + return map[string]interface{}{ + "excel_id": token, + "operations": operations, + }, nil +} + +// exampleMapKey renders a dimension-appropriate sample key for error hints. +func exampleMapKey(dimension string) string { + if dimension == "row" { + return "2:10" + } + return "A" +} + // ─── transform_range (4 shortcuts) ──────────────────────────────────── // // move / copy take --source-range + --target-range (+ optional cross-sheet diff --git a/shortcuts/sheets/lark_sheet_range_operations_test.go b/shortcuts/sheets/lark_sheet_range_operations_test.go index c2d573fb6..40e8e8920 100644 --- a/shortcuts/sheets/lark_sheet_range_operations_test.go +++ b/shortcuts/sheets/lark_sheet_range_operations_test.go @@ -113,9 +113,9 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) { }, }, { - name: "+rows-resize --range 1:5 pixel 200", + name: "+rows-resize --range 1:5 --height 200", sc: RowsResize, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "pixel", "--size", "200"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--height", "200"}, toolName: "resize_range", wantInput: map[string]interface{}{ "excel_id": testToken, @@ -138,7 +138,7 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) { }, }, { - name: "+cols-resize --range B:D standard", + name: "+cols-resize --range B:D --type standard", sc: ColsResize, args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "B:D", "--type", "standard"}, toolName: "resize_range", @@ -152,9 +152,22 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) { }, }, { - name: "+cols-resize --range A:C pixel 120", + name: "+cols-resize --range A:C --width 120", sc: ColsResize, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "pixel", "--size", "120"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--width", "120"}, + toolName: "resize_range", + wantInput: map[string]interface{}{ + "range": "A:C", + "resize_width": map[string]interface{}{ + "type": "pixel", + "value": float64(120), + }, + }, + }, + { + name: "+cols-resize --type pixel with --width 120 (explicit == implicit)", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "pixel", "--width", "120"}, toolName: "resize_range", wantInput: map[string]interface{}{ "range": "A:C", @@ -296,6 +309,194 @@ func TestRangeSort_RejectsMalformedKeys(t *testing.T) { } } +// TestResize_MapForm covers the --widths/--heights map form: entries fan out +// into one atomic batch_update of resize_range ops, sorted by start position +// regardless of JSON key order. +func TestResize_MapForm(t *testing.T) { + t.Parallel() + + t.Run("+cols-resize --widths mixes pixels, ranges and standard", func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, ColsResize, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--widths", `{"G": "standard", "A": 100, "C:E": 120}`, + }) + input := decodeToolInput(t, body, "batch_update") + wantOps := []interface{}{ + map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{ + "excel_id": testToken, "sheet_id": testSheetID, "range": "A:A", + "resize_width": map[string]interface{}{"type": "pixel", "value": float64(100)}, + }}, + map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{ + "excel_id": testToken, "sheet_id": testSheetID, "range": "C:E", + "resize_width": map[string]interface{}{"type": "pixel", "value": float64(120)}, + }}, + map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{ + "excel_id": testToken, "sheet_id": testSheetID, "range": "G:G", + "resize_width": map[string]interface{}{"type": "standard"}, + }}, + } + assertInputEquals(t, input, map[string]interface{}{ + "excel_id": testToken, + "operations": wantOps, + }) + }) + + t.Run("+rows-resize --heights mixes pixels, auto and standard", func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, RowsResize, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--heights", `{"21": "auto", "1": 50, "2:20": 30}`, + }) + input := decodeToolInput(t, body, "batch_update") + wantOps := []interface{}{ + map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{ + "excel_id": testToken, "sheet_id": testSheetID, "range": "1:1", + "resize_height": map[string]interface{}{"type": "pixel", "value": float64(50)}, + }}, + map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{ + "excel_id": testToken, "sheet_id": testSheetID, "range": "2:20", + "resize_height": map[string]interface{}{"type": "pixel", "value": float64(30)}, + }}, + map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{ + "excel_id": testToken, "sheet_id": testSheetID, "range": "21:21", + "resize_height": map[string]interface{}{"type": "auto"}, + }}, + } + assertInputEquals(t, input, map[string]interface{}{ + "excel_id": testToken, + "operations": wantOps, + }) + }) +} + +// TestResize_MapFormGuards covers map-form validation: exclusivity with the +// uniform flags, key/value shape errors, the char-unit width floor, and the +// +batch-update nesting rejection. +func TestResize_MapFormGuards(t *testing.T) { + t.Parallel() + cases := []struct { + name string + sc common.Shortcut + args []string + want string + }{ + { + name: "--widths rejects --range", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A": 100}`, "--range", "A:C"}, + want: "--widths is a self-contained map; do not combine it with --range", + }, + { + name: "--widths rejects --width", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A": 100}`, "--width", "120"}, + want: "--widths is a self-contained map; do not combine it with --width", + }, + { + name: "--heights rejects --type", + sc: RowsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--heights", `{"1": 50}`, "--type", "auto"}, + want: "--heights is a self-contained map; do not combine it with --type", + }, + { + name: "--widths empty object", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{}`}, + want: "must contain at least one entry", + }, + { + name: "--widths row key on cols command", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"2:8": 100}`}, + want: "+cols-resize expects column letters", + }, + { + name: "--heights column key on rows command", + sc: RowsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--heights", `{"A": 50}`}, + want: "+rows-resize expects row numbers", + }, + { + name: "--widths duplicate keys A and A:A", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A": 100, "A:A": 120}`}, + want: "target the same range A:A", + }, + { + name: "--widths rejects overlapping ranges with the same start", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A:C": 100, "A:F": 120}`}, + want: `ranges "A:C" and "A:F" overlap`, + }, + { + name: "--heights rejects overlapping ranges with different starts", + sc: RowsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--heights", `{"2:10": 30, "5:20": 40}`}, + want: `ranges "2:10" and "5:20" overlap`, + }, + { + name: "--widths char-unit width rejected with conversion hint", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A": 10}`}, + want: "looks like an Excel character-unit width", + }, + { + // The embedded schema (enum ["standard"]) rejects "auto" before the + // Go-level rows-only hint; the error steers to --print-schema whose + // description explains columns don't support auto. + name: "--widths rejects auto (rows-only) via schema", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A": "auto"}`}, + want: "does not match any of oneOf alternatives", + }, + { + name: "--heights rejects unknown mode string via schema", + sc: RowsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--heights", `{"1": "fit"}`}, + want: "does not match any of oneOf alternatives", + }, + { + name: "--heights rejects boolean value via schema", + sc: RowsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--heights", `{"1": true}`}, + want: "does not match any of oneOf alternatives", + }, + { + name: "--widths bad key syntax", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A1:B2": 100}`}, + want: "expected pure digits (row number) or letters", + }, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, _, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run")) + requireValidation(t, err, tt.want) + }) + } +} + +func TestResize_MapFormEntryCap(t *testing.T) { + var widths strings.Builder + widths.WriteByte('{') + for i := 0; i <= maxBatchOperations; i++ { + if i > 0 { + widths.WriteByte(',') + } + widths.WriteByte('"') + widths.WriteString(columnIndexToLetter(i)) + widths.WriteString(`":100`) + } + widths.WriteByte('}') + _, _, err := runShortcutCapturingErr(t, ColsResize, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--widths", widths.String(), "--dry-run", + }) + requireValidation(t, err, "accepts at most 100 entries; got 101") +} + func TestResize_TypeAndSizeGuards(t *testing.T) { t.Parallel() cases := []struct { @@ -305,22 +506,58 @@ func TestResize_TypeAndSizeGuards(t *testing.T) { want string }{ { - name: "+rows-resize --type pixel without --size", + name: "+rows-resize missing both --height and --type", sc: RowsResize, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "pixel"}, - want: "--type pixel requires --size", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5"}, + want: "give --height for a pixel size, or --type standard / auto", }, { - name: "+rows-resize --type standard with --size", + name: "+cols-resize missing both --width and --type", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C"}, + want: "give --width for a pixel size, or --type standard", + }, + { + name: "+rows-resize --height rejects --type standard", sc: RowsResize, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "standard", "--size", "30"}, - want: "--size is only valid with --type pixel", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--height", "30", "--type", "standard"}, + want: "--height cannot be combined with --type standard", + }, + { + name: "+cols-resize --width rejects --type standard", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--width", "120", "--type", "standard"}, + want: "--width cannot be combined with --type standard", + }, + { + name: "+rows-resize --type pixel without --height", + sc: RowsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "pixel"}, + want: "--type pixel requires --height", + }, + { + name: "+cols-resize --type pixel without --width", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "pixel"}, + want: "--type pixel requires --width", + }, + { + name: "+rows-resize --height must be positive", + sc: RowsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--height", "0"}, + want: "--height must be > 0", + }, + { + name: "+cols-resize --width below 20px rejected with char-unit hint", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--width", "12"}, + want: "looks like an Excel character-unit width", }, { name: "+cols-resize rejects --type auto", sc: ColsResize, args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "auto"}, - want: "auto", // cobra Enum gate kicks first with "valid values are: pixel, standard" + want: "auto", // cobra Enum gate kicks first with "valid values are: standard" }, { name: "+rows-resize given column range", diff --git a/shortcuts/sheets/lark_sheet_revision_get.go b/shortcuts/sheets/lark_sheet_revision_get.go new file mode 100644 index 000000000..102f0dd97 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_revision_get.go @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── lark_sheet_revision_get ─────────────────────────────────────────── +// +// RevisionGet is a read-only derivative over get_workbook_structure that +// projects out only the document revision (version number). The backend +// surfaces `revision` on every read/write tool response, so this shortcut +// needs no dedicated backend tool — it issues the lightest existing read +// (no range, just the workbook token) and narrows the payload to the single +// field callers want. +// +// The revision is the anchor for recover / undo. Callers that have just run a +// write already have it in that write's response; +revision-get is the +// explicit, zero-side-effect way to fetch the current value on its own. +var RevisionGet = common.Shortcut{ + Service: "sheets", + Command: "+revision-get", + Description: "Get the spreadsheet's current document revision (version number).", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+revision-get"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := resolveSpreadsheetToken(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + return invokeToolDryRun(token, ToolKindRead, "get_workbook_structure", map[string]interface{}{ + "excel_id": token, + }) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetTokenExec(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindRead, "get_workbook_structure", map[string]interface{}{ + "excel_id": token, + }) + if err != nil { + return err + } + rev, err := projectRevision(out) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"revision": rev}, nil) + return nil + }, + Tips: []string{ + "The revision is the version anchor for recover / undo; every read and write tool response already carries it.", + }, +} + +// projectRevision narrows a get_workbook_structure response to its `revision` +// field. An absent revision means the backend predates revision injection on +// read responses; surface that as an explicit error rather than emitting a +// silent null. +func projectRevision(out interface{}) (interface{}, error) { + obj, ok := out.(map[string]interface{}) + if !ok { + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, + "get_workbook_structure returned non-object output") + } + rev, ok := obj["revision"] + if !ok { + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, + "get_workbook_structure did not return a revision (backend may not support it yet)") + } + return rev, nil +} diff --git a/shortcuts/sheets/lark_sheet_revision_get_test.go b/shortcuts/sheets/lark_sheet_revision_get_test.go new file mode 100644 index 000000000..49152016a --- /dev/null +++ b/shortcuts/sheets/lark_sheet_revision_get_test.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "testing" + + "github.com/larksuite/cli/errs" +) + +func TestRevisionGetProjectRevision(t *testing.T) { + t.Parallel() + + t.Run("extracts revision from a workbook-structure object", func(t *testing.T) { + out := map[string]interface{}{ + "revision": float64(60), + "sheets": []interface{}{map[string]interface{}{"sheet_id": "Nh34WX"}}, + } + got, err := projectRevision(out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != float64(60) { + t.Errorf("revision = %v, want 60", got) + } + }) + + t.Run("errors when revision is absent", func(t *testing.T) { + out := map[string]interface{}{"sheets": []interface{}{}} + _, err := projectRevision(out) + requireProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse, "revision") + }) + + t.Run("errors on a non-object output", func(t *testing.T) { + _, err := projectRevision("not-an-object") + requireProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse, "non-object") + }) +} diff --git a/shortcuts/sheets/lark_sheet_sheet_structure.go b/shortcuts/sheets/lark_sheet_sheet_structure.go index d80355f85..e5c83e817 100644 --- a/shortcuts/sheets/lark_sheet_sheet_structure.go +++ b/shortcuts/sheets/lark_sheet_sheet_structure.go @@ -483,11 +483,11 @@ func dimGroupInput(runtime flagView, token, sheetID, sheetName, op string) (map[ func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error) { s = strings.TrimSpace(s) if s == "" { - return "", 0, 0, fmt.Errorf("range is empty") + return "", 0, 0, fmt.Errorf("range is empty") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error } parts := strings.Split(s, ":") if len(parts) > 2 { - return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element") + return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error } dim1, idx1, err := parseA1Position(parts[0]) if err != nil { @@ -501,10 +501,10 @@ func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error) return "", 0, 0, err } if dim1 != dim2 { - return "", 0, 0, fmt.Errorf("cannot mix row (digits) and column (letters) in one range") + return "", 0, 0, fmt.Errorf("cannot mix row (digits) and column (letters) in one range") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error } if idx2 < idx1 { - return "", 0, 0, fmt.Errorf("end position is before start") + return "", 0, 0, fmt.Errorf("end position is before start") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error } return dim1, idx1, idx2, nil } @@ -515,7 +515,7 @@ func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error) func parseA1Position(s string) (dimension string, idx int, err error) { s = strings.TrimSpace(s) if s == "" { - return "", 0, fmt.Errorf("position is empty") + return "", 0, fmt.Errorf("position is empty") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error } isDigits := true isLetters := true @@ -530,14 +530,14 @@ func parseA1Position(s string) (dimension string, idx int, err error) { if isDigits { n, _ := strconv.Atoi(s) if n <= 0 { - return "", 0, fmt.Errorf("row number must be >= 1 (got %q)", s) + return "", 0, fmt.Errorf("row number must be >= 1 (got %q)", s) //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error } return "row", n - 1, nil } if isLetters { return "column", letterToColumnIndex(s), nil } - return "", 0, fmt.Errorf("expected pure digits (row number) or letters (column letter), got %q", s) + return "", 0, fmt.Errorf("expected pure digits (row number) or letters (column letter), got %q", s) //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error } // columnIndexToLetter converts a 0-based column index to the spreadsheet diff --git a/shortcuts/sheets/lark_sheet_table_io.go b/shortcuts/sheets/lark_sheet_table_io.go index 46212bb9b..d789f5fef 100644 --- a/shortcuts/sheets/lark_sheet_table_io.go +++ b/shortcuts/sheets/lark_sheet_table_io.go @@ -63,8 +63,11 @@ var TablePut = common.Shortcut{ // --styles is parsed (and aligned against the payload's sheets) up front // so a malformed style item fails before any write lands — mirroring // +workbook-create's Validate. - _, err = parseWorkbookCreateSheetStyles(runtime, payload) - return err + styles, err := parseWorkbookCreateSheetStyles(runtime, payload) + if err != nil { + return err + } + return payload.checkCellBudgetWithStyles(styles) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return tablePutDryRun(runtime) @@ -317,17 +320,52 @@ func (in *tableSheetIn) normalize(idx int) (tableSheetSpec, error) { // compare against the canonical set. for k := range in.Dtypes { if !seenCol[k] { - return tableSheetSpec{}, common.ValidationErrorf("--sheets[%d] %q: dtypes references unknown column %q", idx, in.Name, k) + return tableSheetSpec{}, common.ValidationErrorf("--sheets[%d] %q: dtypes references unknown column %q", idx, in.Name, k). + WithHint("%s", columnKeyHint("dtypes", k, in.Columns)) } } for k := range in.Formats { if !seenCol[k] { - return tableSheetSpec{}, common.ValidationErrorf("--sheets[%d] %q: formats references unknown column %q", idx, in.Name, k) + return tableSheetSpec{}, common.ValidationErrorf("--sheets[%d] %q: formats references unknown column %q", idx, in.Name, k). + WithHint("%s", columnKeyHint("formats", k, in.Columns)) } } return spec, nil } +// columnKeyHint explains a dtypes/formats key that matched no column. The +// dominant failure is Excel habit — keying by column letter (A/B/AA) instead +// of the column name — so call that out explicitly; either way, inline the +// declared column names so the retry needs no second look at the payload. +func columnKeyHint(field, key string, columns []string) string { + shown := columns + const maxShown = 12 + suffix := "" + if len(shown) > maxShown { + shown = shown[:maxShown] + suffix = ", …" + } + list := `"` + strings.Join(shown, `", "`) + `"` + suffix + if isColumnLetterKey(key) { + return fmt.Sprintf("%s keys must be column names from `columns`, not A1-style column letters; this sheet's columns: %s", field, list) + } + return fmt.Sprintf("%s keys must exactly match a name in `columns`: %s", field, list) +} + +// isColumnLetterKey reports whether key looks like an A1-style column letter +// (A, B, AA, …) rather than a real column name. +func isColumnLetterKey(key string) bool { + if key == "" || len(key) > 3 { + return false + } + for _, r := range key { + if r < 'A' || r > 'Z' { + return false + } + } + return true +} + func (p *tablePayload) validate() error { if len(p.Sheets) == 0 { return common.ValidationErrorf("--sheets: must contain at least one sheet") @@ -382,6 +420,55 @@ func (p *tablePayload) validate() error { return common.ValidationErrorf("--sheets[%d] %q: mode %q is invalid (want \"overwrite\" or \"append\")", i, s.Name, s.Mode) } } + return p.checkCellBudget() +} + +// maxTablePutCells bounds how many cells a single +table-put / +workbook-create +// write may materialize. Unlike the fan-out stamp cap (maxStampMatrixCells), +// these cells come from the caller's own --sheets/--values payload rather than a +// range blow-up, so this is a generous OOM guardrail, not a usability limit: +// buildSheetMatrix builds the whole rows×cols matrix of per-cell maps in memory +// before slicing it into tablePutMaxCellsPerWrite-sized writes, so an unbounded +// payload (2.6M cells ≈ 900MB heap, doubled again by json.Marshal) OOMs the +// process before the first write leaves. +const maxTablePutCells = 1_000_000 + +// checkCellBudget rejects a payload whose total materialized cell count across +// all sheets exceeds maxTablePutCells. Counted in int64 to stay overflow-safe on +// pathological row/column counts. +func (p *tablePayload) checkCellBudget() error { + var total int64 + for i := range p.Sheets { + total += int64(len(p.Sheets[i].Rows)) * int64(len(p.Sheets[i].Columns)) + } + return checkTablePutCellBudget(total) +} + +// checkCellBudgetWithStyles includes the blank cells that cell_styles will add +// to each sheet's matrix. It must run before DryRun / Execute pads any matrix. +func (p *tablePayload) checkCellBudgetWithStyles(styles *workbookCreateSheetStyles) error { + var total int64 + for i := range p.Sheets { + s := &p.Sheets[i] + rows, cols := len(s.Rows), len(s.Columns) + _, baseCol, baseRow, _ := sheetAnchor(s) + if s.Mode == "append" { + // Append resolves its real row at Execute time. Zero is a safe upper + // bound for the style-driven extent and keeps validation allocation-free. + baseRow = 0 + } + rows, cols = matrixDimensionsForStyles(rows, cols, styles.styleFor(i), baseCol, baseRow) + total += int64(rows) * int64(cols) + } + return checkTablePutCellBudget(total) +} + +func checkTablePutCellBudget(total int64) error { + if total > maxTablePutCells { + return common.ValidationErrorf( + "--sheets/--values cover %d cells total, over the %d-cell safety cap; split the write across smaller payloads", + total, maxTablePutCells) + } return nil } @@ -558,6 +645,12 @@ var excelEpoch = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC) // parser still rejects it cleanly. func isoDateToSerial(s string) (int, error) { s = strings.TrimSpace(s) + if s == "" { + // Empty cells in a date-typed column are the classic header/total-row + // clash with the column-wide dtype declaration; name the three ways + // out so the caller does not have to guess what "bad format" means. + return 0, fmt.Errorf("date column has an empty cell — drop the empty rows, fill real yyyy-mm-dd dates, or declare the column dtype as object (text)") //nolint:forbidigo // intermediate error; callers wrap it into a typed --sheets/--values validation error with row/column context + } if i := strings.Index(s, "T"); i > 0 { s = s[:i] } @@ -643,7 +736,8 @@ func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token, if err != nil { return nil, err } - if err := applyWorkbookCreateStylesToMatrix(matrix, styles, col0, baseRow, fmt.Sprintf("--styles for sheet %q", s.Name)); err != nil { + matrix, err = applyWorkbookCreateStylesToMatrix(matrix, styles, col0, baseRow, fmt.Sprintf("--styles for sheet %q", s.Name)) + if err != nil { return nil, err } @@ -655,11 +749,15 @@ func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token, }, nil } + // styles can pad the matrix wider than the declared columns (cell_styles on + // blank cells past the data extent), so the written width comes from the + // padded matrix, not ncols. + writeCols := len(matrix[0]) startCol := columnIndexToLetter(col0) - endCol := columnIndexToLetter(col0 + ncols - 1) + endCol := columnIndexToLetter(col0 + writeCols - 1) allowOverwrite := s.AllowOverwrite == nil || *s.AllowOverwrite - rowsPerBatch := tablePutMaxCellsPerWrite / ncols + rowsPerBatch := tablePutMaxCellsPerWrite / writeCols if rowsPerBatch < 1 { rowsPerBatch = 1 } @@ -693,7 +791,7 @@ func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token, "sheet_id": sheetID, "range": fmt.Sprintf("%s%d:%s%d", startCol, baseRow+1, endCol, baseRow+len(matrix)), "data_rows": len(s.Rows), - "columns": ncols, + "columns": writeCols, "writes": writes, "mode": writeModeName(s), }, nil @@ -785,7 +883,7 @@ func writeTypedSheets(ctx context.Context, runtime *common.RuntimeContext, token s := &payload.Sheets[i] sheetID, ok := byName[s.Name] if !ok { - rows, cols := sheetCreateDims(s) + rows, cols := sheetCreateDims(s, styles.styleFor(i)) sheetID, err = createSheet(ctx, runtime, token, s.Name, rows, cols) if err != nil { return written, fmt.Errorf("creating sheet %q failed: %w", s.Name, err) //nolint:forbidigo // intermediate error; surfaced as a partial_success message string via tablePutPartial, not a typed final error @@ -864,10 +962,12 @@ func createSheet(ctx context.Context, runtime *common.RuntimeContext, token, nam // sheetCreateDims sizes a to-be-created sheet to the spec's write range so the // follow-up set_cell_range can't exceed sheet bounds. It accounts for the -// start_cell offset and the optional header row. The backend's 20×200 defaults -// are kept as floors (ordinary small tables are created exactly as before) and -// its hard limits (200 cols, 50000 rows) as ceilings. -func sheetCreateDims(s *tableSheetSpec) (rows, cols int) { +// start_cell offset, the optional header row, and any --styles extent (so a +// cell_styles / merge / resize op past the data still fits the grid). The +// backend's 20×200 defaults are kept as floors (ordinary small tables are +// created exactly as before) and its hard limits (200 cols, 50000 rows) as +// ceilings. +func sheetCreateDims(s *tableSheetSpec, styles *workbookCreateStylePayload) (rows, cols int) { _, col0, row0, _ := sheetAnchor(s) cols = col0 + len(s.Columns) rows = row0 + len(s.Rows) @@ -882,6 +982,19 @@ func sheetCreateDims(s *tableSheetSpec) (rows, cols int) { if headerOn(s) || (s.Mode == "append" && s.Header == nil) { rows++ } + // --styles can reach past the data (cell_styles on blank cells get padded + // into the matrix and written; merges / resizes run as separate ops). Size + // the grid to cover them too. workbookCreateStyleDimensions returns the + // extent relative to the anchor, so add the anchor offset back. + if styles != nil { + styleRows, styleCols := workbookCreateStyleDimensions(styles, col0, row0) + if col0+styleCols > cols { + cols = col0 + styleCols + } + if row0+styleRows > rows { + rows = row0 + styleRows + } + } if cols < 20 { cols = 20 } @@ -979,8 +1092,6 @@ func tablePutDryRun(runtime *common.RuntimeContext) *common.DryRunAPI { for i := range payload.Sheets { s := &payload.Sheets[i] matrix, _ := buildSheetMatrix(s, headerOn(s)) - desc := fmt.Sprintf("write sheet %q (%d data rows × %d cols, mode=%s) via set_cell_range", - s.Name, len(s.Rows), len(s.Columns), writeModeName(s)) rng := tablePutFullRange(s, len(matrix)) if s.Mode == "append" { rng = "" @@ -988,10 +1099,23 @@ func tablePutDryRun(runtime *common.RuntimeContext) *common.DryRunAPI { // cell_styles are merged into the matrix only for overwrite mode, // where the anchor row is known statically; append's base row is // resolved at execute time, so the preview leaves the matrix bare - // (the merges / sizes ops below still render). + // (the merges / sizes ops below still render). Padding can widen / + // lengthen the matrix past the data, so recompute the range from the + // padded dims to match what Execute writes. _, col0, row0, _ := sheetAnchor(s) - _ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name)) + matrix, _ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name)) + if len(matrix) > 0 { + rng = fmt.Sprintf("%s%d:%s%d", + columnIndexToLetter(col0), row0+1, + columnIndexToLetter(col0+len(matrix[0])-1), row0+len(matrix)) + } } + writeCols := len(s.Columns) + if len(matrix) > 0 { + writeCols = len(matrix[0]) + } + desc := fmt.Sprintf("write sheet %q (%d data rows × %d cols, mode=%s) via set_cell_range", + s.Name, len(s.Rows), writeCols, writeModeName(s)) input := map[string]interface{}{ "excel_id": token, "sheet_name": s.Name, diff --git a/shortcuts/sheets/lark_sheet_table_io_test.go b/shortcuts/sheets/lark_sheet_table_io_test.go index 1b32bf03d..a86a00e14 100644 --- a/shortcuts/sheets/lark_sheet_table_io_test.go +++ b/shortcuts/sheets/lark_sheet_table_io_test.go @@ -54,6 +54,67 @@ func TestTablePut_IsoDateToSerial(t *testing.T) { } } +// TestTablePut_EmptyDatePrescription pins the empty-cell branch: the error +// must name the three ways out (drop rows / fill dates / object dtype) +// instead of the generic "must be ISO" parse failure. +func TestTablePut_EmptyDatePrescription(t *testing.T) { + t.Parallel() + for _, in := range []string{"", " "} { + _, err := isoDateToSerial(in) + if err == nil { + t.Fatalf("isoDateToSerial(%q) should fail", in) + } + for _, want := range []string{"empty cell", "object (text)"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("isoDateToSerial(%q) error should contain %q, got %q", in, want, err.Error()) + } + } + } +} + +// TestTablePut_ColumnKeyHint pins the dtypes/formats unknown-column hint: +// A1-style letter keys get the Excel-habit callout, and the declared column +// names ride inline either way. +func TestTablePut_ColumnKeyHint(t *testing.T) { + t.Parallel() + cols := []string{"姓名", "出生日期"} + got := columnKeyHint("dtypes", "A", cols) + for _, want := range []string{"not A1-style column letters", `"姓名", "出生日期"`} { + if !strings.Contains(got, want) { + t.Errorf("letter-key hint should contain %q, got %q", want, got) + } + } + got = columnKeyHint("formats", "出生 日期", cols) + if strings.Contains(got, "A1-style") { + t.Errorf("non-letter key must not get the letter callout, got %q", got) + } + if !strings.Contains(got, `"姓名", "出生日期"`) { + t.Errorf("hint should inline column names, got %q", got) + } + + many := make([]string, 20) + for i := range many { + many[i] = fmt.Sprintf("col%02d", i) + } + got = columnKeyHint("dtypes", "X", many) + if !strings.Contains(got, ", …") || strings.Contains(got, "col19") { + t.Errorf("hint should truncate long column lists, got %q", got) + } +} + +func TestIsColumnLetterKey(t *testing.T) { + t.Parallel() + cases := map[string]bool{ + "A": true, "Z": true, "AA": true, "ABC": true, + "": false, "ABCD": false, "a": false, "A1": false, "姓名": false, + } + for in, want := range cases { + if got := isColumnLetterKey(in); got != want { + t.Errorf("isColumnLetterKey(%q) = %v, want %v", in, got, want) + } + } +} + func TestTablePut_BuildTypedCell(t *testing.T) { t.Parallel() @@ -399,6 +460,16 @@ func TestTablePut_StylesNameMismatchRejected(t *testing.T) { requireValidation(t, err, "must match") } +func TestTablePut_StylePaddingBudgetRejectedBeforeDryRun(t *testing.T) { + _, _, err := runShortcutCapturingErr(t, TablePut, []string{ + "--url", testURL, + "--sheets", `{"sheets":[{"name":"数据","columns":["a"],"data":[["x"]]}]}`, + "--styles", `{"styles":[{"name":"数据","cell_styles":[{"range":"A1:AX25000","font_weight":"bold"}]}]}`, + "--dry-run", + }) + requireValidation(t, err, "over the 1000000-cell safety cap") +} + // TestTablePut_ExecuteWithStyles drives the full write + visual-ops path: the // set_cell_range write carries the merged cell_styles, then merge_cells / // resize_range tool calls apply the structural styles in the same call. @@ -537,14 +608,15 @@ func TestTablePut_SheetCreateDims(t *testing.T) { cases := []struct { name string spec tableSheetSpec + styles *workbookCreateStylePayload wantRows, wantCols int }{ - {"small table keeps 20x200 floor", tableSheetSpec{Columns: cols(3), Rows: rows(5)}, 200, 20}, - {"wide table grows columns", tableSheetSpec{Columns: cols(37), Rows: rows(22)}, 200, 37}, - {"long table grows rows", tableSheetSpec{Columns: cols(3), Rows: rows(500)}, 501, 20}, - {"start_cell offset adds to both", tableSheetSpec{StartCell: "C5", Columns: cols(40), Rows: rows(5)}, 200, 42}, - {"header:false drops the header row", tableSheetSpec{Header: bp(false), Columns: cols(3), Rows: rows(500)}, 500, 20}, - {"columns clamp at backend max 200", tableSheetSpec{Columns: cols(250), Rows: rows(5)}, 200, 200}, + {"small table keeps 20x200 floor", tableSheetSpec{Columns: cols(3), Rows: rows(5)}, nil, 200, 20}, + {"wide table grows columns", tableSheetSpec{Columns: cols(37), Rows: rows(22)}, nil, 200, 37}, + {"long table grows rows", tableSheetSpec{Columns: cols(3), Rows: rows(500)}, nil, 501, 20}, + {"start_cell offset adds to both", tableSheetSpec{StartCell: "C5", Columns: cols(40), Rows: rows(5)}, nil, 200, 42}, + {"header:false drops the header row", tableSheetSpec{Header: bp(false), Columns: cols(3), Rows: rows(500)}, nil, 500, 20}, + {"columns clamp at backend max 200", tableSheetSpec{Columns: cols(250), Rows: rows(5)}, nil, 200, 200}, // Default headerOn() is false for append mode, but writeSheetData forces // a header when append hits an empty sheet with no explicit Header // choice (so column names aren't lost). sheetCreateDims runs only on @@ -552,14 +624,22 @@ func TestTablePut_SheetCreateDims(t *testing.T) { // match: append + Header=nil ⇒ +1 row. Otherwise an append-near-50000 // payload would be created one row short. {"append on new sheet sizes for the forced header row (49999 data rows + 1 header = 50000)", - tableSheetSpec{Mode: "append", Columns: cols(3), Rows: rows(49999)}, 50000, 20}, + tableSheetSpec{Mode: "append", Columns: cols(3), Rows: rows(49999)}, nil, 50000, 20}, {"append + Header=false (explicit) does NOT add the forced header row", - tableSheetSpec{Mode: "append", Header: bp(false), Columns: cols(3), Rows: rows(50)}, 200, 20}, + tableSheetSpec{Mode: "append", Header: bp(false), Columns: cols(3), Rows: rows(50)}, nil, 200, 20}, + // --styles reaching past the data grows the grid so a cell_styles op on a + // blank cell (or a merge / resize) still fits after the create. + {"styles past the data grow the grid", + tableSheetSpec{Columns: cols(3), Rows: rows(5)}, + &workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{{Range: "A1:Z400"}}}, 400, 26}, + {"styles inside the data don't shrink the grid", + tableSheetSpec{Columns: cols(3), Rows: rows(5)}, + &workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{{Range: "A1:B2"}}}, 200, 20}, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { t.Parallel() - gotRows, gotCols := sheetCreateDims(&tt.spec) + gotRows, gotCols := sheetCreateDims(&tt.spec, tt.styles) if gotRows != tt.wantRows || gotCols != tt.wantCols { t.Errorf("sheetCreateDims = (%d rows, %d cols), want (%d, %d)", gotRows, gotCols, tt.wantRows, tt.wantCols) } diff --git a/shortcuts/sheets/lark_sheet_workbook.go b/shortcuts/sheets/lark_sheet_workbook.go index b3c73c134..a0201baa7 100644 --- a/shortcuts/sheets/lark_sheet_workbook.go +++ b/shortcuts/sheets/lark_sheet_workbook.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "fmt" + "io" "path/filepath" "strings" @@ -123,6 +124,13 @@ func sheetCreateInput(runtime flagView, token string) (map[string]interface{}, e if strings.TrimSpace(runtime.Str("title")) == "" { return nil, common.ValidationErrorf("--title is required") } + sheetType := strings.TrimSpace(runtime.Str("type")) + if sheetType == "" { + sheetType = "sheet" + } + if sheetType != "sheet" { + return nil, common.ValidationErrorf("--type must be 'sheet'") + } if n := runtime.Int("row-count"); n < 0 || n > 50000 { return nil, common.ValidationErrorf("--row-count must be between 0 and 50000") } @@ -590,8 +598,11 @@ var WorkbookCreate = common.Shortcut{ if err != nil { return err } - _, err = parseWorkbookCreateSheetStyles(runtime, payload) - return err + styles, err := parseWorkbookCreateSheetStyles(runtime, payload) + if err != nil { + return err + } + return payload.checkCellBudgetWithStyles(styles) } // Untyped --values path: parse (and validate) --styles as a single sheet // style item, then synthesize --values into a type-less typed payload — @@ -634,16 +645,26 @@ var WorkbookCreate = common.Shortcut{ s := &payload.Sheets[i] matrix, _ := buildSheetMatrix(s, headerOn(s)) _, col0, row0, _ := sheetAnchor(s) - _ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name)) + matrix, _ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name)) + // Padding can widen / lengthen the matrix past the data, so build the + // range from the padded dims to match what Execute writes. + rng := tablePutFullRange(s, len(matrix)) + writeCols := len(s.Columns) + if len(matrix) > 0 { + writeCols = len(matrix[0]) + rng = fmt.Sprintf("%s%d:%s%d", + columnIndexToLetter(col0), row0+1, + columnIndexToLetter(col0+writeCols-1), row0+len(matrix)) + } input := map[string]interface{}{ "excel_id": "", "sheet_name": s.Name, - "range": tablePutFullRange(s, len(matrix)), + "range": rng, "cells": matrix, } wireBody, _ := buildToolBody("set_cell_range", input) dry.POST("/open-apis/sheet_ai/v2/spreadsheets//tools/invoke_write"). - Desc(fmt.Sprintf("write sheet %q (%d data rows × %d cols) via set_cell_range", s.Name, len(s.Rows), len(s.Columns))). + Desc(fmt.Sprintf("write sheet %q (%d data rows × %d cols) via set_cell_range", s.Name, len(s.Rows), writeCols)). Body(wireBody) appendWorkbookCreateVisualOpsDryRun(dry, "", "", s.Name, sheetStyles.styleFor(i)) } @@ -822,6 +843,9 @@ func buildValuesPayload(runtime flagView, sheetStyles *workbookCreateSheetStyles if maxCols == 0 || nrows == 0 { return nil, nil // nothing to write (e.g. --values '[]' with no styles) } + if err := checkTablePutCellBudget(int64(nrows) * int64(maxCols)); err != nil { + return nil, err + } // Pad to a rectangle; nil cells become empty cells in buildTypedCell. for len(rows) < nrows { rows = append(rows, nil) @@ -836,13 +860,19 @@ func buildValuesPayload(runtime flagView, sheetStyles *workbookCreateSheetStyles cols[i] = tableColumnSpec{Name: fmt.Sprintf("col%d", i+1)} // type-less } noHeader := false - return &tablePayload{Sheets: []tableSheetSpec{{ + payload := &tablePayload{Sheets: []tableSheetSpec{{ Name: valuesSheetName, Mode: "overwrite", Header: &noHeader, Columns: cols, Rows: rows, - }}}, nil + }}} + // --values bypasses tablePayload.validate(), so enforce the cell budget here + // too — otherwise a giant --values array materializes unbounded. + if err := payload.checkCellBudget(); err != nil { + return nil, err + } + return payload, nil } // parseValuesRows decodes --values (JSON 2D array, with @file/stdin already @@ -1139,10 +1169,14 @@ func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]work } return nil, common.ValidationErrorf("%s[%d].range %q must use %s", path, i, rangeStr, want) } + typeHint := "pixel/standard" + if dimension == "row" { + typeHint = "pixel/standard/auto" + } resizeType, _ := op["type"].(string) resizeType = strings.TrimSpace(resizeType) if resizeType == "" { - return nil, common.ValidationErrorf("%s[%d].type is required (pixel/standard%s)", path, i, autoSuffix(dimension)) + return nil, common.ValidationErrorf("%s[%d].type is required (%s)", path, i, typeHint) } if dimension == "column" && resizeType == "auto" { return nil, common.ValidationErrorf("%s[%d].type auto is rows-only", path, i) @@ -1150,7 +1184,7 @@ func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]work switch resizeType { case "pixel", "standard", "auto": default: - return nil, common.ValidationErrorf("%s[%d].type %q is invalid (want pixel/standard%s)", path, i, resizeType, autoSuffix(dimension)) + return nil, common.ValidationErrorf("%s[%d].type %q is invalid (want %s)", path, i, resizeType, typeHint) } size := 0 if raw, ok := op["size"]; ok { @@ -1246,7 +1280,7 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string) func workbookCreateCellStyleField(name string) bool { switch name { - case "font_color", "font_size", "font_weight", "font_style", "font_line", + case "font_color", "font_family", "font_size", "font_weight", "font_style", "font_line", "background_color", "horizontal_alignment", "vertical_alignment", "number_format", "word_wrap": return true @@ -1357,20 +1391,76 @@ func workbookCreateStyleDimensions(styles *workbookCreateStylePayload, baseCol, return rows, cols } -func applyWorkbookCreateStylesToMatrix(rows [][]interface{}, styles *workbookCreateStylePayload, baseCol, baseRow int, label string) error { +// matrixDimensionsForStyles projects the padded matrix size without allocating +// it. Only cell_styles contribute; merges and row/column sizes use separate API +// calls. Ranges up/left of the write anchor are left for the caller to reject. +func matrixDimensionsForStyles(rows, cols int, styles *workbookCreateStylePayload, baseCol, baseRow int) (int, int) { if styles == nil { - return nil + return rows, cols } + for _, op := range styles.CellStyles { + startCol, startRow, endCol, endRow, err := workbookCreateStyleRangeBounds(op.Range) + if err != nil || startCol < baseCol || startRow < baseRow { + continue // unparsable, or up/left of the anchor: not paddable + } + if endCol-baseCol+1 > cols { + cols = endCol - baseCol + 1 + } + if endRow-baseRow+1 > rows { + rows = endRow - baseRow + 1 + } + } + return rows, cols +} + +// padMatrixForStyles grows the matrix down and right to the projected style +// extent, appending empty cells that cell_styles can mutate in place. +func padMatrixForStyles(rows [][]interface{}, styles *workbookCreateStylePayload, baseCol, baseRow int) [][]interface{} { + needCols := 0 + if len(rows) > 0 { + needCols = len(rows[0]) + } + needRows, needCols := matrixDimensionsForStyles(len(rows), needCols, styles, baseCol, baseRow) + // Widen existing rows to needCols. + for r := range rows { + for len(rows[r]) < needCols { + rows[r] = append(rows[r], map[string]interface{}{}) + } + } + // Append full empty rows to reach needRows. + for len(rows) < needRows { + row := make([]interface{}, needCols) + for c := range row { + row[c] = map[string]interface{}{} + } + rows = append(rows, row) + } + return rows +} + +// applyWorkbookCreateStylesToMatrix pads the matrix to cover the cell_styles +// ranges (see padMatrixForStyles), merges each op's style into the covered +// cells, and returns the padded matrix. A range that starts left of / above the +// write anchor can't be padded to and is rejected. +func applyWorkbookCreateStylesToMatrix(rows [][]interface{}, styles *workbookCreateStylePayload, baseCol, baseRow int, label string) ([][]interface{}, error) { + if styles == nil { + return rows, nil + } + rows = padMatrixForStyles(rows, styles, baseCol, baseRow) for i, op := range styles.CellStyles { startCol, startRow, endCol, endRow, err := workbookCreateStyleRangeBounds(op.Range) if err != nil { - return common.ValidationErrorf("%s[%d].range %q: %v", label, i, op.Range, err) + return rows, common.ValidationErrorf("%s[%d].range %q: %v", label, i, op.Range, err) } - if startCol < baseCol || startRow < baseRow || endRow-baseRow >= len(rows) || len(rows) == 0 || endCol-baseCol >= len(rows[0]) { - return common.ValidationErrorf("%s[%d].range %q is outside the write range %s%d:%s%d", + // After padding, the matrix reaches every range that starts at or after + // the anchor; a start left of / above it can't be covered. The endRow / + // endCol checks stay as a defensive backstop (padding should have made + // them unreachable). + if startCol < baseCol || startRow < baseRow || len(rows) == 0 || + endRow-baseRow >= len(rows) || endCol-baseCol >= len(rows[0]) { + return rows, common.ValidationErrorf("%s[%d].range %q starts outside the write range (its top-left must be at or after %s%d)", label, i, op.Range, - columnIndexToLetter(baseCol), baseRow+1, - columnIndexToLetter(baseCol+len(rows[0])-1), baseRow+len(rows)) + columnIndexToLetter(baseCol), baseRow+1) } for r := startRow - baseRow; r <= endRow-baseRow; r++ { for c := startCol - baseCol; c <= endCol-baseCol; c++ { @@ -1378,7 +1468,7 @@ func applyWorkbookCreateStylesToMatrix(rows [][]interface{}, styles *workbookCre } } } - return nil + return rows, nil } func appendWorkbookCreateVisualOpsDryRun(dry *common.DryRunAPI, token, sheetID, sheetName string, styles *workbookCreateStylePayload) { @@ -1730,9 +1820,13 @@ func lookupFirstSheetID(ctx context.Context, runtime *common.RuntimeContext, tok // // Imports a local xlsx/xls/csv file as a brand-new spreadsheet. The full // upload → create-task → poll flow is the shared drive import core -// (drive.RunImport); this shortcut only pins the target type to "sheet" and -// omits the bitable-only --target-token. Symmetric with +workbook-export. -// Not exposed as an MCP tool. +// (drive.RunImport); this shortcut only pins the target type to "sheet", +// omits the bitable-only --target-token, and — because spreadsheet source +// files are routinely misnamed (an .xlsx exported/renamed to .xls, etc.) — +// sniffs the file's real container so the drive import backend receives the +// true file_extension instead of failing with a cryptic +// "xml_version_not_support". Symmetric with +workbook-export. Not exposed as +// an MCP tool. // WorkbookImport imports a local spreadsheet file as a new Feishu spreadsheet // by delegating to the shared drive import core with type fixed to "sheet". @@ -1746,24 +1840,119 @@ var WorkbookImport = common.Shortcut{ HasFormat: true, Flags: flagsFor("+workbook-import"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - return drive.ValidateImport(workbookImportParams(runtime)) + params, err := workbookImportParams(runtime) + if err != nil { + return err + } + return drive.ValidateImport(params) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - return drive.PlanImportDryRun(runtime, workbookImportParams(runtime)) + params, err := workbookImportParams(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + dry := drive.PlanImportDryRun(runtime, params) + if note := workbookImportMislabelNote(params); note != "" { + dry.Desc(note) + } + return dry }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - return drive.RunImport(ctx, runtime, workbookImportParams(runtime)) + params, err := workbookImportParams(runtime) + if err != nil { + return err + } + if note := workbookImportMislabelNote(params); note != "" { + fmt.Fprintln(runtime.IO().ErrOut, note) + } + return drive.RunImport(ctx, runtime, params) }, } // workbookImportParams builds the drive import request for +workbook-import, // pinning DocType to "sheet". The bitable-only --target-token is intentionally -// not exposed here — use drive +import for non-sheet import targets. -func workbookImportParams(runtime *common.RuntimeContext) drive.ImportParams { - return drive.ImportParams{ - File: runtime.Str("file"), +// not exposed here — use drive +import for non-sheet import targets. It also +// resolves a corrected file extension via content sniffing (see +// correctedWorkbookExtension) and surfaces it through ImportParams.FileExtension. +func workbookImportParams(runtime *common.RuntimeContext) (drive.ImportParams, error) { + file := runtime.Str("file") + params := drive.ImportParams{ + File: file, DocType: "sheet", FolderToken: runtime.Str("folder-token"), Name: runtime.Str("name"), } + ext, err := correctedWorkbookExtension(runtime.FileIO(), file) + if err != nil { + return params, err + } + params.FileExtension = ext + return params, nil +} + +// correctedWorkbookExtension returns an override extension when the file's +// declared .xls/.xlsx suffix disagrees with its real container, "" when the +// declared suffix is correct (or the extension is not in the Excel family, or +// the file cannot yet be read). A declared Excel file whose bytes match neither +// container yields a prescriptive validation error rather than deferring to the +// backend's opaque "xml_version_not_support". +func correctedWorkbookExtension(fio fileio.FileIO, filePath string) (string, error) { + declared := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") + if declared != "xls" && declared != "xlsx" { + return "", nil + } + + sniffed, ok := sniffWorkbookContainer(fio, filePath) + if !ok { + // Not readable here; let the drive core's stat/upload surface any error. + return "", nil + } + switch sniffed { + case declared: + return "", nil + case "xls", "xlsx": + return sniffed, nil + default: + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, + "file %s has a .%s extension but its content is neither an OOXML (.xlsx) nor a legacy Excel (.xls) workbook; re-save it as a real .xlsx/.xls (or export to .csv) before importing", + filePath, declared).WithParam("--file") + } +} + +// sniffWorkbookContainer inspects a file's leading magic bytes to tell an OOXML +// workbook (zip container -> .xlsx) apart from a legacy OLE2/BIFF workbook +// (compound document -> .xls). The second return value is false when the file +// cannot be read far enough to judge (open error or fewer than the four +// discriminating bytes). When true, the format is "xlsx", "xls", or "" (bytes +// matching neither container). +func sniffWorkbookContainer(fio fileio.FileIO, filePath string) (string, bool) { + f, err := fio.Open(filePath) + if err != nil { + return "", false + } + defer f.Close() + + var head [8]byte + n, _ := io.ReadFull(f, head[:]) + if n < 4 { + return "", false + } + switch { + case head[0] == 0x50 && head[1] == 0x4B: // "PK" -> ZIP, i.e. OOXML .xlsx + return "xlsx", true + case head[0] == 0xD0 && head[1] == 0xCF && head[2] == 0x11 && head[3] == 0xE0: // OLE2 compound doc -> legacy .xls + return "xls", true + } + return "", true +} + +// workbookImportMislabelNote returns a user-facing note when content sniffing +// overrode the declared extension, or "" when no correction was applied. +func workbookImportMislabelNote(params drive.ImportParams) string { + declared := strings.TrimPrefix(strings.ToLower(filepath.Ext(params.File)), ".") + if params.FileExtension == "" || params.FileExtension == declared { + return "" + } + return fmt.Sprintf("Note: %s has a mislabeled .%s extension but is actually a .%s workbook; importing it as .%s.", + filepath.Base(params.File), declared, params.FileExtension, params.FileExtension) } diff --git a/shortcuts/sheets/lark_sheet_workbook_import_test.go b/shortcuts/sheets/lark_sheet_workbook_import_test.go index bdfebada7..345cb34d4 100644 --- a/shortcuts/sheets/lark_sheet_workbook_import_test.go +++ b/shortcuts/sheets/lark_sheet_workbook_import_test.go @@ -10,7 +10,7 @@ import ( "testing" "github.com/larksuite/cli/internal/httpmock" - _ "github.com/larksuite/cli/internal/vfs/localfileio" + "github.com/larksuite/cli/internal/vfs/localfileio" ) // chdirTemp switches into a fresh temp dir for the duration of the test and @@ -32,7 +32,7 @@ func chdirTemp(t *testing.T) { // shared drive import core and hard-codes the import target type to "sheet". func TestWorkbookImport_DryRunPinsSheetType(t *testing.T) { chdirTemp(t) - if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0o644); err != nil { + if err := os.WriteFile("data.xlsx", []byte("PK\x03\x04fake-xlsx"), 0o644); err != nil { t.Fatalf("write file: %v", err) } @@ -70,6 +70,89 @@ func TestWorkbookImport_RejectsNonSheetFile(t *testing.T) { requireValidation(t, err, "can only be imported") } +// TestCorrectedWorkbookExtension covers the content-sniffing that corrects (or +// rejects) a mislabeled Excel file before its extension reaches the backend. +func TestCorrectedWorkbookExtension(t *testing.T) { + ooxml := []byte("PK\x03\x04rest") // zip -> .xlsx + ole2 := []byte("\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1rest") // compound doc -> .xls + + tests := []struct { + name string + fileName string + content []byte + wantExt string // expected override ("" == leave the declared extension) + wantErrSub string // non-empty == expect a validation error containing this + }{ + {name: "xlsx content mislabeled as xls corrects to xlsx", fileName: "book.xls", content: ooxml, wantExt: "xlsx"}, + {name: "xls content mislabeled as xlsx corrects to xls", fileName: "book.xlsx", content: ole2, wantExt: "xls"}, + {name: "genuine xlsx left untouched", fileName: "book.xlsx", content: ooxml, wantExt: ""}, + {name: "genuine xls left untouched", fileName: "book.xls", content: ole2, wantExt: ""}, + {name: "unrecognized content on .xls rejected", fileName: "book.xls", content: []byte("
"), wantErrSub: "neither an OOXML"}, + {name: "non-excel extension never sniffed", fileName: "notes.csv", content: []byte("a,b\n1,2\n"), wantExt: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chdirTemp(t) + if err := os.WriteFile(tt.fileName, tt.content, 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + ext, err := correctedWorkbookExtension(&localfileio.LocalFileIO{}, "./"+tt.fileName) + if tt.wantErrSub != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErrSub) { + t.Fatalf("error = %v, want substring %q", err, tt.wantErrSub) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ext != tt.wantExt { + t.Fatalf("override ext = %q, want %q", ext, tt.wantExt) + } + }) + } +} + +// TestWorkbookImport_DryRunCorrectsMislabeledXls verifies an .xls file whose +// bytes are actually OOXML is imported with file_extension=xlsx end to end. +func TestWorkbookImport_DryRunCorrectsMislabeledXls(t *testing.T) { + chdirTemp(t) + if err := os.WriteFile("book.xls", []byte("PK\x03\x04zip-body"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + calls := parseDryRunAPI(t, WorkbookImport, []string{"--file", "./book.xls"}) + + var createBody map[string]interface{} + for _, c := range calls { + cm, _ := c.(map[string]interface{}) + if u, _ := cm["url"].(string); u == "/open-apis/drive/v1/import_tasks" { + createBody, _ = cm["body"].(map[string]interface{}) + } + } + if createBody == nil { + t.Fatalf("no import_tasks create call in dry-run: %#v", calls) + } + if createBody["file_extension"] != "xlsx" { + t.Errorf("file_extension = %v, want xlsx (mislabeled .xls must be corrected)", createBody["file_extension"]) + } +} + +// TestWorkbookImport_RejectsUnrecognizedExcel ensures a file whose .xls/.xlsx +// name matches neither Excel container is rejected locally with a prescriptive +// error rather than deferring to the backend's opaque failure. +func TestWorkbookImport_RejectsUnrecognizedExcel(t *testing.T) { + chdirTemp(t) + if err := os.WriteFile("bogus.xls", []byte("not excel"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + _, _, err := runShortcutCapturingErr(t, WorkbookImport, []string{"--file", "./bogus.xls", "--dry-run"}) + requireValidation(t, err, "neither an OOXML") +} + // TestWorkbookImport_ExecuteCreatesSheet runs the full upload → create → poll // flow against stubs and asserts the resulting URL is a /sheets/ link. func TestWorkbookImport_ExecuteCreatesSheet(t *testing.T) { diff --git a/shortcuts/sheets/lark_sheet_workbook_test.go b/shortcuts/sheets/lark_sheet_workbook_test.go index 9445b7d1f..78c5dc3ef 100644 --- a/shortcuts/sheets/lark_sheet_workbook_test.go +++ b/shortcuts/sheets/lark_sheet_workbook_test.go @@ -281,6 +281,12 @@ func TestWorkbook_Validation(t *testing.T) { args: []string{"--url", testURL, "--title", "X", "--row-count", "999999"}, wantMsg: "--row-count must be between", }, + { + name: "+sheet-create rejects hidden bitable type", + sc: SheetCreate, + args: []string{"--url", testURL, "--title", "Tasks", "--type", "bitable"}, + wantMsg: `invalid value "bitable" for --type`, + }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { @@ -396,6 +402,37 @@ func TestWorkbookCreate_DryRun(t *testing.T) { t.Errorf("horizontal_alignment occurrences = %d, want 4 in 2x2 range; cells=%s", got, raw) } }) + + t.Run("cell style past the data pads empty cells so blank cells can be styled", func(t *testing.T) { + t.Parallel() + // Data is a single cell (A1), but the style targets A1:C3 — the matrix is + // padded to 3x3 with empty cells so the style range fits, letting blank + // cells carry styling. The written range must reflect the padded extent. + calls := parseDryRunAPI(t, WorkbookCreate, []string{ + "--title", "X", + "--values", `[["a"]]`, + "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:C3","background_color":"#FFEEAA"}]}]}`, + }) + body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{}) + input := decodeToolInput(t, body, "set_cell_range") + if input["range"] != "A1:C3" { + t.Errorf("range = %v, want A1:C3 (padded to the style extent)", input["range"]) + } + cells, _ := input["cells"].([]interface{}) + if len(cells) != 3 { + t.Fatalf("cells rows = %d, want 3 (padded); cells=%#v", len(cells), input["cells"]) + } + lastRow, _ := cells[2].([]interface{}) + if len(lastRow) != 3 { + t.Fatalf("last row width = %d, want 3 (padded)", len(lastRow)) + } + // A blank padded cell (C3) still carries the style. + c3, _ := lastRow[2].(map[string]interface{}) + c3s, _ := c3["cell_styles"].(map[string]interface{}) + if c3s["background_color"] != "#FFEEAA" { + t.Errorf("padded blank cell C3 style = %#v, want background_color", c3) + } + }) t.Run("style-only payload (cell_merges) still fills and emits merge_cells", func(t *testing.T) { t.Parallel() // Previously workbookCreateStyleDimensions only counted cell_styles, so a @@ -595,3 +632,60 @@ func deepEqualJSON(a, b interface{}) bool { } return a == b } + +// TestApplyWorkbookCreateStylesToMatrix covers the pad-then-style behavior +// directly: a style range past the data grows the matrix with empty cells (so +// blank cells can be styled), an in-range style leaves the matrix size alone, +// and a range up/left of the anchor — which padding can't reach — is rejected. +func TestApplyWorkbookCreateStylesToMatrix(t *testing.T) { + t.Parallel() + cell := func() interface{} { return map[string]interface{}{} } + + t.Run("pads down and right for a style past the data", func(t *testing.T) { + t.Parallel() + matrix := [][]interface{}{{cell()}} // 1x1 data + styles := &workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{ + {Range: "A1:C3", Style: map[string]interface{}{"cell_styles": map[string]interface{}{"background_color": "#FFEEAA"}}}, + }} + out, err := applyWorkbookCreateStylesToMatrix(matrix, styles, 0, 0, "--styles") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(out) != 3 || len(out[0]) != 3 || len(out[2]) != 3 { + t.Fatalf("padded matrix = %d rows x %v cols, want 3x3", len(out), out) + } + // A blank padded corner (C3) carries the style. + c3, _ := out[2][2].(map[string]interface{}) + c3s, _ := c3["cell_styles"].(map[string]interface{}) + if c3s["background_color"] != "#FFEEAA" { + t.Errorf("padded cell C3 = %#v, want background_color", c3) + } + }) + + t.Run("no pad when the style is within the data", func(t *testing.T) { + t.Parallel() + matrix := [][]interface{}{{cell(), cell()}, {cell(), cell()}} // 2x2 data + styles := &workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{ + {Range: "A1:B2", Style: map[string]interface{}{"cell_styles": map[string]interface{}{"font_weight": "bold"}}}, + }} + out, err := applyWorkbookCreateStylesToMatrix(matrix, styles, 0, 0, "--styles") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(out) != 2 || len(out[0]) != 2 { + t.Errorf("matrix = %dx%d, want 2x2 (no pad)", len(out), len(out[0])) + } + }) + + t.Run("rejects a range up/left of the anchor", func(t *testing.T) { + t.Parallel() + matrix := [][]interface{}{{cell()}} // anchored at C3 (col 2, row 2) + styles := &workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{ + {Range: "A1", Style: map[string]interface{}{"cell_styles": map[string]interface{}{"font_weight": "bold"}}}, + }} + _, err := applyWorkbookCreateStylesToMatrix(matrix, styles, 2, 2, "--styles") + if err == nil || !strings.Contains(err.Error(), "starts outside the write range") { + t.Errorf("err = %v, want 'starts outside the write range'", err) + } + }) +} diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index da7a07703..5415dcbad 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -111,10 +111,10 @@ func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[stri // CellsSetStyle stamps a single style block across every cell in --range. // Style is composed from a dozen flat flags (background-color, font-color, -// font-size, font-style, font-weight, font-line, horizontal-alignment, -// vertical-alignment, word-wrap, number-format) plus --border-styles for -// the only field that still needs a nested object. At least one flag must -// be set. +// font-family, font-size, font-style, font-weight, font-line, +// horizontal-alignment, vertical-alignment, word-wrap, number-format) plus +// --border-styles for the only field that still needs a nested object. At +// least one flag must be set. var CellsSetStyle = common.Shortcut{ Service: "sheets", Command: "+cells-set-style", @@ -165,6 +165,9 @@ func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map if err != nil { return nil, sheetsValidationForFlag("range", "--range %q: %v", rangeStr, err) } + if err := checkStampMatrixBudget("range", rangeStr, rows, cols); err != nil { + return nil, err + } if err := requireAnyStyleFlag(runtime); err != nil { return nil, err } @@ -450,6 +453,9 @@ func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[s if err != nil { return nil, sheetsValidationForFlag("range", "--range %q: %v", rangeStr, err) } + if err := checkStampMatrixBudget("range", rangeStr, rows, cols); err != nil { + return nil, err + } validation, err := buildDropdownValidation(runtime) if err != nil { return nil, err @@ -625,23 +631,23 @@ func rangeDimensions(rangeStr string) (rows, cols int, err error) { } rangeStr = strings.TrimSpace(rangeStr) if rangeStr == "" { - return 0, 0, fmt.Errorf("empty range") + return 0, 0, fmt.Errorf("empty range") //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error } parts := strings.SplitN(rangeStr, ":", 2) if len(parts) == 1 { // single cell, e.g. "A1" if _, _, ok := splitCellRef(parts[0]); !ok { - return 0, 0, fmt.Errorf("invalid cell ref %q", parts[0]) + return 0, 0, fmt.Errorf("invalid cell ref %q", parts[0]) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error } return 1, 1, nil } startCol, startRow, ok1 := splitCellRef(parts[0]) endCol, endRow, ok2 := splitCellRef(parts[1]) if !ok1 || !ok2 { - return 0, 0, fmt.Errorf("unsupported range form %q (need rectangular A1:B2)", rangeStr) + return 0, 0, fmt.Errorf("unsupported range form %q (need rectangular A1:B2)", rangeStr) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error } if endRow < startRow || endCol < startCol { - return 0, 0, fmt.Errorf("end %q must be at or after start %q", parts[1], parts[0]) + return 0, 0, fmt.Errorf("end %q must be at or after start %q", parts[1], parts[0]) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error } return endRow - startRow + 1, endCol - startCol + 1, nil } @@ -692,9 +698,31 @@ func letterToColumnIndex(letters string) int { return n - 1 } +// maxStampMatrixCells bounds how many per-cell maps a fan-out / stamp shortcut +// will materialize from a single A1 range. The backing tools take an explicit +// cells matrix, so the CLI must expand a range like "A1:Z100000" into rows×cols +// maps before sending it — an unbounded blow-up (2.6M cells ≈ 900MB heap, then +// doubled again by json.Marshal) that OOMs the process before the request even +// leaves. The 200000 ceiling is the selected fan-out guardrail; the separately +// documented --max-cells flag defaults to 50000. +const maxStampMatrixCells = 200000 + +// checkStampMatrixBudget rejects a range whose materialized cell count would +// exceed maxStampMatrixCells, before fillCellsMatrix allocates it. rows*cols is +// computed in int64 to stay safe against overflow on pathological ranges. +func checkStampMatrixBudget(flagName, rangeStr string, rows, cols int) error { + if total := int64(rows) * int64(cols); total > maxStampMatrixCells { + return sheetsValidationForFlag(flagName, + "range %q covers %d cells, over the %d-cell safety cap; narrow the range or split it across smaller ranges", + rangeStr, total, maxStampMatrixCells) + } + return nil +} + // fillCellsMatrix returns a rows×cols matrix where every cell is the same // (shallow-copied) prototype map. Use for fan-out shortcuts that stamp a // single attribute (style / data_validation) across an entire range. +// Callers MUST gate the dimensions through checkStampMatrixBudget first. func fillCellsMatrix(rows, cols int, prototype map[string]interface{}) [][]interface{} { cells := make([][]interface{}, rows) for r := range cells { diff --git a/shortcuts/sheets/lark_sheet_write_cells_test.go b/shortcuts/sheets/lark_sheet_write_cells_test.go index c36f59fd4..8c384d1d8 100644 --- a/shortcuts/sheets/lark_sheet_write_cells_test.go +++ b/shortcuts/sheets/lark_sheet_write_cells_test.go @@ -116,6 +116,18 @@ func TestWriteCellsShortcuts_DryRun(t *testing.T) { } } +func TestCsvPut_MissingCSVFailsRequiredGate(t *testing.T) { + t.Parallel() + _, _, err := runShortcutCapturingErr(t, CsvPut, []string{ + "--url", testURL, + "--sheet-id", testSheetID, + "--start-cell", "A1", + }) + if err == nil || !strings.Contains(err.Error(), "required flag(s) \"csv\" not set") { + t.Fatalf("missing --csv error = %v, want cobra required-flag error", err) + } +} + // TestDropdownSet_CellsShape inspects the 3×1 matrix produced from // --range A2:A4 to confirm the data_validation prototype is replicated. // Also covers --colors / --highlight emitting the canonical diff --git a/shortcuts/sheets/sheet_media_parent_type_test.go b/shortcuts/sheets/sheet_media_parent_type_test.go index ebf03deca..d37bd0737 100644 --- a/shortcuts/sheets/sheet_media_parent_type_test.go +++ b/shortcuts/sheets/sheet_media_parent_type_test.go @@ -25,8 +25,8 @@ import ( // TestSheetMediaParentType pins the token→parent_type mapping that every // sheets image-upload entry point funnels through. Native spreadsheet tokens -// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_" -// synthetic token and must upload with "office_sheet_file". +// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_" or +// "local_office_" synthetic token and must upload with "office_sheet_file". func TestSheetMediaParentType(t *testing.T) { t.Parallel() cases := []struct { @@ -36,9 +36,12 @@ func TestSheetMediaParentType(t *testing.T) { }{ {"native spreadsheet token", "shtcnABC123", sheetImageParentType}, {"empty token", "", sheetImageParentType}, - {"office imported token", "fake_office_abc123", officeSheetFileParentType}, - {"office token, only the prefix", fakeOfficeTokenPrefix, officeSheetFileParentType}, - {"prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType}, + {"fake_office imported token", "fake_office_abc123", officeSheetFileParentType}, + {"fake_office token, only the prefix", fakeOfficePrefix, officeSheetFileParentType}, + {"local_office imported token", "local_office_abc123", officeSheetFileParentType}, + {"local_office token, only the prefix", localOfficePrefix, officeSheetFileParentType}, + {"fake_office prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType}, + {"local_office prefix mid-string is not matched", "shtlocal_office_abc", sheetImageParentType}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -62,7 +65,8 @@ func TestUploadSheetImage_ParentType(t *testing.T) { wantParentType string }{ {"native spreadsheet", "shtcnTOK123", sheetImageParentType}, - {"office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType}, + {"fake_office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType}, + {"local_office imported spreadsheet", "local_office_abc123", officeSheetFileParentType}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/shortcuts/sheets/sheets_perf_bench_test.go b/shortcuts/sheets/sheets_perf_bench_test.go new file mode 100644 index 000000000..0a0c76a79 --- /dev/null +++ b/shortcuts/sheets/sheets_perf_bench_test.go @@ -0,0 +1,259 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "encoding/json" + "runtime" + "strings" + "testing" +) + +// These benchmarks back the memory review of the sheets fan-out paths. They +// measure the cell matrices materialized by range-based shortcuts and table IO. +// +// 1. fillCellsMatrix — fan-out shortcuts (+cells-set-style, +dropdown-set, +// +cells-batch-set-style, +dropdown-update) expand one A1 range into a +// rows×cols matrix of per-cell maps. A tiny input string ("A1:Z100000") +// explodes into millions of heap maps with no upper bound. +// Run: go test ./shortcuts/sheets -run XXX -bench 'FillCellsMatrix|BuildSheetMatrix' -benchmem + +var styleProto = map[string]interface{}{ + "cell_styles": map[string]interface{}{"bold": true, "fg_color": "#FF0000"}, + "border_styles": map[string]interface{}{"top": map[string]interface{}{"style": "solid"}}, +} + +func benchFillCellsMatrix(b *testing.B, rows, cols int) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + m := fillCellsMatrix(rows, cols, styleProto) + if len(m) != rows { + b.Fatalf("bad matrix") + } + } +} + +func BenchmarkFillCellsMatrix_100(b *testing.B) { benchFillCellsMatrix(b, 10, 10) } // A1:J10 +func BenchmarkFillCellsMatrix_10K(b *testing.B) { benchFillCellsMatrix(b, 1000, 10) } // A1:J1000 +func BenchmarkFillCellsMatrix_100K(b *testing.B) { benchFillCellsMatrix(b, 10000, 10) } // A1:J10000 +func BenchmarkFillCellsMatrix_2600K(b *testing.B) { benchFillCellsMatrix(b, 100000, 26) } // A1:Z100000 + +// TestFanoutMatrixPeakMemory reports the concrete resident-heap delta of +// materializing a large fan-out matrix, so the review doc can quote real MB. +// Not an assertion — it prints numbers under `go test -v -run PeakMemory`. +func TestFanoutMatrixPeakMemory(t *testing.T) { + if testing.Short() { + t.Skip("skipping memory probe in -short") + } + cases := []struct { + name string + rows, cols int + }{ + {"A1:Z10000 (260K cells)", 10000, 26}, + {"A1:Z100000 (2.6M cells)", 100000, 26}, + } + for _, c := range cases { + var before, after runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&before) + m := fillCellsMatrix(c.rows, c.cols, styleProto) + runtime.ReadMemStats(&after) + runtime.KeepAlive(m) + t.Logf("%-26s heap +%6.1f MB (%d total allocs)", + c.name, + float64(after.HeapAlloc-before.HeapAlloc)/(1024*1024), + after.Mallocs-before.Mallocs) + } +} + +// --- +table-put / +workbook-create matrix materialization (sibling #1 path) --- +// +// buildSheetMatrix turns the caller's --sheets/--values into a rows×cols matrix +// of per-cell maps, the same unbounded blow-up as fillCellsMatrix but on the +// table-put ingress (tablePutMaxCellsPerWrite only slices the *write*, not this +// in-memory build). checkCellBudget rejects oversized payloads before this runs. + +func makeTypelessSpec(rows, cols int) *tableSheetSpec { + c := make([]tableColumnSpec, cols) + r := make([][]interface{}, rows) + for i := range r { + row := make([]interface{}, cols) + for j := range row { + row[j] = "x" + } + r[i] = row + } + return &tableSheetSpec{Columns: c, Rows: r} +} + +func benchBuildSheetMatrix(b *testing.B, rows, cols int) { + spec := makeTypelessSpec(rows, cols) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + m, err := buildSheetMatrix(spec, true) + if err != nil || len(m) != rows+1 { + b.Fatalf("bad matrix") + } + } +} + +func BenchmarkBuildSheetMatrix_100K(b *testing.B) { benchBuildSheetMatrix(b, 10000, 10) } // 100K cells +func BenchmarkBuildSheetMatrix_2600K(b *testing.B) { benchBuildSheetMatrix(b, 100000, 26) } // 2.6M cells + +// TestTablePutMatrixPeakMemory reports the resident-heap delta of materializing +// a large table-put matrix (the cost checkCellBudget now prevents), so the +// review doc can quote real MB. Not an assertion — prints under -v -run PeakMemory. +func TestTablePutMatrixPeakMemory(t *testing.T) { + if testing.Short() { + t.Skip("skipping memory probe in -short") + } + for _, c := range []struct { + name string + rows, cols int + }{ + {"100000×26 (2.6M cells)", 100000, 26}, + } { + spec := makeTypelessSpec(c.rows, c.cols) + var before, after runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&before) + m, _ := buildSheetMatrix(spec, true) + runtime.ReadMemStats(&after) + runtime.KeepAlive(m) + t.Logf("%-24s buildSheetMatrix heap +%6.1f MB (%d total allocs)", + c.name, + float64(after.HeapAlloc-before.HeapAlloc)/(1024*1024), + after.Mallocs-before.Mallocs) + } +} + +// --- fan-out cell-budget cap (fix for the unbounded matrix blow-up) --- + +func TestStampMatrixBudgetCap(t *testing.T) { + // 199992 cells (7692×26) sits just under the 200000 cap → allowed. + if err := checkStampMatrixBudget("range", "A1:Z7692", 7692, 26); err != nil { + t.Fatalf("199992 cells should pass, got: %v", err) + } + // Exactly at the cap → allowed. + if err := checkStampMatrixBudget("range", "A1:A200000", 200000, 1); err != nil { + t.Fatalf("200000 cells (== cap) should pass, got: %v", err) + } + // Just over the cap → rejected. + if err := checkStampMatrixBudget("range", "A1:A200001", 200001, 1); err == nil { + t.Fatal("200001 cells should be rejected") + } + // The pathological case from the review (2.6M cells) → rejected. + if err := checkStampMatrixBudget("ranges", "Sheet1!A1:Z100000", 100000, 26); err == nil { + t.Fatal("2.6M-cell fan-out should be rejected") + } +} + +// --- sibling cap gaps: +table-put/+workbook-create payload, batch aggregate, +// batch-update operation count (follow-up to the single fan-out cap) --- + +// TestTablePutCellBudgetCap covers the --sheets/--values materialization cap: +// buildSheetMatrix builds the whole matrix in memory, so the total cell count is +// bounded before that allocation, summed across all sheets. +func TestTablePutCellBudgetCap(t *testing.T) { + // 1000×1000 = 1,000,000 == cap → allowed. + atCap := &tablePayload{Sheets: []tableSheetSpec{{ + Columns: make([]tableColumnSpec, 1000), + Rows: make([][]interface{}, 1000), + }}} + if err := atCap.checkCellBudget(); err != nil { + t.Fatalf("1,000,000 cells (== cap) should pass, got: %v", err) + } + // 1000×1001 = 1,001,000 > cap → rejected. + over := &tablePayload{Sheets: []tableSheetSpec{{ + Columns: make([]tableColumnSpec, 1000), + Rows: make([][]interface{}, 1001), + }}} + if err := over.checkCellBudget(); err == nil { + t.Fatal("1,001,000 cells should be rejected") + } + // Budget is summed across sheets, not per-sheet: 600k + 600k = 1.2M > cap. + twoSheets := &tablePayload{Sheets: []tableSheetSpec{ + {Columns: make([]tableColumnSpec, 1000), Rows: make([][]interface{}, 600)}, + {Columns: make([]tableColumnSpec, 1000), Rows: make([][]interface{}, 600)}, + }} + if err := twoSheets.checkCellBudget(); err == nil { + t.Fatal("1.2M cells across two sheets should be rejected") + } +} + +func TestTablePutCellBudgetIncludesStylePadding(t *testing.T) { + payload := &tablePayload{Sheets: []tableSheetSpec{{ + Columns: make([]tableColumnSpec, 1), + Rows: make([][]interface{}, 1), + }}} + styles := &workbookCreateSheetStyles{ByIndex: []*workbookCreateStylePayload{{ + CellStyles: []workbookCreateCellStyleOp{{Range: "A1:AX25000"}}, + }}} + if err := payload.checkCellBudgetWithStyles(styles); err == nil { + t.Fatal("1x1 data padded by styles to 1.25M cells should be rejected before allocation") + } + + twoSheets := &tablePayload{Sheets: []tableSheetSpec{ + {Columns: make([]tableColumnSpec, 1), Rows: make([][]interface{}, 1)}, + {Columns: make([]tableColumnSpec, 1), Rows: make([][]interface{}, 1)}, + }} + style := &workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{{Range: "A1:Z20000"}}} + if err := twoSheets.checkCellBudgetWithStyles(&workbookCreateSheetStyles{ByIndex: []*workbookCreateStylePayload{style, style}}); err == nil { + t.Fatal("style-padded cells should be summed across sheets") + } +} + +// TestBatchStampAggregateCap covers the batch fan-out aggregate budget — the +// per-range cap can't stop many ranges from summing past the matrix ceiling. +func TestBatchStampAggregateCap(t *testing.T) { + if err := checkBatchStampBudget(maxStampMatrixCells); err != nil { + t.Fatalf("aggregate == cap should pass, got: %v", err) + } + if err := checkBatchStampBudget(maxStampMatrixCells + 1); err == nil { + t.Fatal("aggregate over cap should be rejected") + } +} + +// TestBatchFanoutRangeCountCap drives a fan-out shortcut with > maxBatchRanges +// ranges and expects the shared validateDropdownRanges cap to reject it. +func TestBatchFanoutRangeCountCap(t *testing.T) { + ranges := make([]string, maxBatchRanges+1) + for i := range ranges { + ranges[i] = "sheet1!A1" + } + rangesJSON, _ := json.Marshal(ranges) + _, _, err := runShortcutCapturingErr(t, CellsBatchSetStyle, []string{ + "--url", testURL, + "--ranges", string(rangesJSON), + "--font-weight", "bold", + "--dry-run", + }) + requireValidation(t, err, "at most") +} + +// TestBatchOperationsCountCap covers the +batch-update sub-operation count cap. +func TestBatchOperationsCountCap(t *testing.T) { + ops := make([]interface{}, maxBatchOperations+1) + for i := range ops { + ops[i] = map[string]interface{}{"shortcut": "+cells-set", "input": map[string]interface{}{}} + } + _, err := translateBatchOperations(ops, testURL) + if err == nil || !strings.Contains(err.Error(), "at most") { + t.Fatalf("expected operations count cap error, got: %v", err) + } +} + +// BenchmarkStampBudget_RejectsOversized is the "after" side of the fix: the same +// A1:Z100000 input that BenchmarkFillCellsMatrix_2600K shows costing ~917MB / +// 5.3M allocs is now rejected up front, allocating only the error string. +func BenchmarkStampBudget_RejectsOversized(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if err := checkStampMatrixBudget("range", "A1:Z100000", 100000, 26); err == nil { + b.Fatal("expected rejection") + } + } +} diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index c0e4d9499..644817638 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -35,6 +35,10 @@ func Shortcuts() []common.Shortcut { if hasFlag(all[i].Flags, "spreadsheet-token") { all[i].PostMount = withTokenAlias(all[i].PostMount) } + // Sheets-scoped flag ergonomics (unknown-flag hints with the valid + // flags inlined, enum vocabulary normalization) ride the same + // PostMount composition, so no other domain's behavior shifts. + all[i].PostMount = withFlagErgonomics(all[i].PostMount) } return all } @@ -70,6 +74,7 @@ func shortcutList() []common.Shortcut { return []common.Shortcut{ // lark_sheet_workbook WorkbookInfo, + RevisionGet, SheetCreate, SheetDelete, SheetRename, @@ -95,6 +100,9 @@ func shortcutList() []common.Shortcut { DimUngroup, DimMove, + // lark_sheet_changeset + ChangesetGet, + // lark_sheet_read_data CellsGet, CsvGet, @@ -105,6 +113,9 @@ func shortcutList() []common.Shortcut { CellsSearch, CellsReplace, + // lark_sheet_formula_verify + FormulaVerify, + // lark_sheet_write_cells CellsSet, CellsSetStyle, @@ -148,5 +159,10 @@ func shortcutList() []common.Shortcut { CellsBatchClear, DropdownUpdate, DropdownDelete, + + // lark_sheet_history + HistoryList, + HistoryRevert, + HistoryRevertStatus, } } diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index 5aa947b1c..3ebaab16d 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -1,6 +1,6 @@ --- name: lark-sheets -version: 3.0.0 +version: 3.0.2 description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)、金融/财务建模(DCF、三张表、预算、Sensitivity 等)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。" metadata: requires: @@ -32,57 +32,124 @@ metadata: | 透视表 pivot | `--pivot-table-id` | 迷你图(按组) | `--group-id` | | 浮动图片 | `--float-image-id` | | | +## 飞书表格编辑准则(动手前必守,所有编辑类任务一律生效) + +下列准则横切所有飞书表格任务,**动手前先过一遍**——即使你是被索引直接路由进某个工具参考也一律生效。每条只给一句话纲要,展开与边界见括注的 reference。 + +1. **最小改动**:除任务要改的单元格 / 列外,原表其它单元格、行列结构、Sheet 名、合并区、格式 1:1 保持;中间结果放原数据右侧或新建空白 Sheet,**禁止删 / 改名 / 隐藏 / 移动已存在 Sheet**;改写类任务精确圈定行列,不该转的原值 1:1 保留。 +2. **真实写回 + 回读校验**:交付必须是对在线表格的真实写入,写完用 `+csv-get` / `+cells-get` / `+<对象>-list` 回读确认实际生效——**写操作返回 `ok` 只代表请求被接受、不代表结果符合预期**;写公式后查错误码、筛选 / 排序后核对前几行、删除 / 清空后确认已空。禁止只在文本里声称"已完成"。 +3. **读全再写**:批量填充 / 补齐 / 修正类任务先确认真实数据末行再写,只探前 N 行会漏写表尾(确定末行流程见 `lark-sheets-read-data`)。 +4. **公式优先于硬编码**:能用公式表达的计算(总计 / 占比 / 增长率 / 提取 / 查找)一律写公式而非静态值;**凡可由表内其它单元格推导的派生值默认就用公式,即使用户没说"联动 / 自动更新"**;写任何飞书公式前先读 `lark-sheets-formula-translation`,而且**只要公式真实写入表格,收尾默认就要继续跑 `lark-sheets-formula-verify` 的 `+formula-verify`,直到 `status='success'`**。 +5. **续写 / 扩展继承样式**:续写、补齐、复制区块、新增行列时禁止只读值只写值,必须连带 `cell_styles` + `border_styles` + 合并 + 行高一起继承(清单见 `lark-sheets-write-cells`,四边框最易漏)。 +6. **多步写入合并 `+batch-update`**:多个连续写入、或同一工具对多区域重复调用,合并为单次原子 `+batch-update`(语义见 `lark-sheets-batch-update`)。 +7. **分组汇总用透视表**:"按 X 统计 Y / 分组汇总 / 各类数量金额"用 `+pivot-{create|update|delete}`,禁止用 SUMIF / 本地脚本拼一张假透视表。 +8. **拆成可验证 checklist**:落地前把指令拆成所有"独立可验证子要点",逐点 `assert` 全过才交付(多维排序每维一点、多目标每目标一点、范围类核起 / 末 / 边界);只做第一个要点属违规。 +9. **全量处理前置断言条数**:翻译 / 打标 / 批量公式落地等逐条任务,先把预期条数硬编码再 `assert actual == expected`,禁止输出"已完成前 N 条,剩余继续"的半成品。 + +> 上述准则的实操展开——读取路径、原生工具优先级、脚本配合、易漏陷阱——见下方「执行要点」节;端到端工作流为:了解结构(`+workbook-info`)→ 读数据 → 理解语义 → 原生工具优先 → 写入 → 回读验证。 + ## 场景 → 命令速查(拿不准命令名先查这里,别按直觉拼) -把高频意图映射到**真实存在**的 shortcut / flag。agent 常从 Excel / Google Sheets / 飞书 OpenAPI 误迁移命令名或 flag,先对照本表,避免一次必然失败的试错。完整 shortcut 见各工具参考。 +把高频意图映射到**真实存在**的 shortcut / flag。agent 常从 Excel / Google Sheets / 飞书 OpenAPI 误迁移命令名或 flag,先对照本表,避免一次必然失败的试错。完整 shortcut 见各工具参考。**选定命令后别急着写——先读「动手前读」列指向的 reference 再动手**:命令名对得上不代表用法对,写入 / 清除 / 透视类尤其容易漏掉 reference 里的防错、类型与样式继承规则。 -| 你要做的事 | ✅ 正确写法 | ❌ 不存在(会被 cobra 拒) | -| --- | --- | --- | -| 读数据(纯值 / CSV) | `+csv-get`(范围用 `--range`) | `+get-range`、`+range-get`、`+cells-read` | -| 读值 + 公式 / 样式 / 批注 | `+cells-get --include value,formula,style,comment,data_validation` | `+get-cell`、`+cell-get`、`--with-styles`、`--with-merges`、`--include-merged-cells` | -| 写纯文本值(整块 CSV 平铺,列里没有需保留的数值 / 日期语义) | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格;也接受 `--range` 别名,区间自动取左上角) | — | -| 写带类型的数据到**已有**表(列里有数字 / 金额 / 百分比 / 日期 / 计数,要可排序 / 求和 / 入图表 / 透视) | `+table-put --sheets` 完整 payload `{"sheets":[{...}]}`(列名走 `columns`、二维数据走 `data`、列 pandas dtype 走 `dtypes`、列展示格式走 `formats`;来源不限 DataFrame——Counter / dict / list 同理,详见 write-cells) | 在本地把数字拼成 `"$1,234"` / `"30.5%"` 字符串再 `+csv-put`(会落成文本、丢失计算能力) | -| **新建**电子表格并写带类型的数据(类型保真需求同上,但目标表还不存在) | `+workbook-create --sheets`(协议与 `+table-put` 同构、一步建表 + typed 写入,无需先建空表再 `+table-put`;date / number 不丢,详见 workbook) | 用 `--values` 灌日期 / 数字(会落成文本、丢类型) | -| 写值 / 公式 / 样式 | `+cells-set`(定位用 `--range`) | — | -| 插图:图片**绑定到某条记录**、随行走(凭证 / 证件照 / 商品图 / 头像 / 二维码 / 每行配图) | `+cells-set-image`(单格 `--range`,嵌入单元格内) | — | -| 插图:**自由摆放、不绑数据**的装饰 / 标识(logo / 水印 / 封面大图 / banner) | `+float-image-create`(浮动图片,自由定位 + 尺寸 + 层级) | — | -| 查找单元格 | `+cells-search`(关键字用 `--find`) | `+cells-find`、`+find`、`--query` | -| 查找并替换 | `+cells-replace` | — | -| 看子表结构(合并 / 行高列宽 / 冻结 / 隐藏) | `+sheet-info` | `+sheet-get`、`+structure-get`、`+sheet-structure-get` | -| 看工作簿 / 子表清单 | `+workbook-info` | `+sheet-list`、`+workbook-get`、`+workbook-list` | -| 导出 xlsx / 单表 csv | `+workbook-export` | — | -| 导入本地 xlsx/xls/csv 文件为飞书电子表格 | `+workbook-import --file ./x.xlsx`(本地表格文件 → 飞书电子表格的正解;仅要导成多维表格 bitable 时才用 `drive +import --type bitable`) | `drive +import`(导电子表格时绕了 drive 通道、还要多给 `--type`,应直接用 `+workbook-import`)、把 .xlsx 在本地读成数据再 `+workbook-create` 重灌 | -| 清除内容 / 格式 | `+cells-clear`(范围维度用 `--scope`,取值 content / formats / all) | `--type` | -| 批量清除多区域 | `+cells-batch-clear`(`--scope`) | `--target` | -| 调整列宽 / 行高 | `+cols-resize` / `+rows-resize`(行、列是两个独立命令) | `--dimension`(无此 flag) | -| 分组汇总 / 透视 | `+pivot-create`(默认不传落点 flag → 自动新建子表,零覆盖) | 用 SUMIF / 本地脚本拼一张假透视表 | +| 你要做的事 | ✅ 正确写法 | 动手前读 | ❌ 不存在(会被 cobra 拒) | +| --- | --- | --- | --- | +| 读数据(纯值 / CSV) | `+csv-get`(范围用 `--range`) | `lark-sheets-read-data` | `+get-range`、`+range-get`、`+cells-read` | +| 读值 + 公式 / 样式 / 批注 | `+cells-get --include value,formula,style,comment,data_validation` | `lark-sheets-read-data` | `+get-cell`、`+cell-get`、`--with-styles`、`--with-merges`、`--include-merged-cells` | +| 写纯文本值(整块 CSV 平铺;列里**没有**需字面保真的数值 / 日期标签 / 编号——点分日期 `12.10`、编号 `001` 会被 csv-put 数值化,不算纯文本) | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格;也接受 `--range` 别名,区间自动取左上角) | `lark-sheets-write-cells` | 把含点分日期(`12.10`)/编号(`001`)的列裸灌 `+csv-put`——会被数值化(`12.10`→`12.1`、`001`→`1`,尾零/前导零丢失),改用 `+table-put` 声明 `dtypes:object` | +| 写带类型的数据到**已有**表(列里有数字 / 金额 / 百分比 / 日期 / 计数等**本质是量值**的数据——不看当下要不要排序 / 求和,量值一律走这里) | `+table-put --sheets` 完整 payload `{"sheets":[{...}]}`(列名走 `columns`、二维数据走 `data`、列 pandas dtype 走 `dtypes`、列展示格式走 `formats`;来源不限 DataFrame——Counter / dict / list 同理;要同时美化加 `--styles` 一步带样式(区域底色 / 边框 / 列宽 / 行高 / 合并),不必事后再刷;payload 里不存在的 sheet 名会自动建子表,详见 write-cells) | `lark-sheets-write-cells` | 在本地把数字拼成 `"$1,234"` / `"30.5%"` 字符串再 `+csv-put`(会落成文本、丢失计算能力;常见借口见下方 ⚠️) | +| **新建**电子表格并写带类型的数据(类型保真需求同上,但目标表还不存在) | `+workbook-create --sheets`(协议与 `+table-put` 同构、一步建表 + typed 写入,无需先建空表再 `+table-put`;date / number 不丢;`--styles` 同样可在建表同一步带全套样式,详见 workbook) | `lark-sheets-workbook` | 用 `--values` 灌日期 / 数字(会落成文本、丢类型) | +| 写公式 / 富写入(样式 · 批注 · 图片 · 富文本),或需精确矩形定位的值 | `+cells-set`(定位用 `--range`;批注 / 图片 / 富文本只能用它,公式也可;**公式落表后继续 `+formula-verify` 收尾**) | `lark-sheets-write-cells` | — | +| 插图:图片**绑定到某条记录**、随行走(凭证 / 证件照 / 商品图 / 头像 / 二维码 / 每行配图) | `+cells-set-image`(单格 `--range`,嵌入单元格内) | `lark-sheets-write-cells` | — | +| 插图:**自由摆放、不绑数据**的装饰 / 标识(logo / 水印 / 封面大图 / banner) | `+float-image-create`(浮动图片,自由定位 + 尺寸 + 层级) | `lark-sheets-float-image` | — | +| 查找 / 替换文本 | `+cells-search`(找,关键字用 `--find`)、`+cells-replace`(替换) | `lark-sheets-search-replace` | `+cells-find`、`+find`、`--query` | +| 看子表结构(合并 / 行高列宽 / 冻结 / 隐藏) | `+sheet-info` | `lark-sheets-sheet-structure` | `+sheet-get`、`+structure-get`、`+sheet-structure-get` | +| 看工作簿 / 子表清单 | `+workbook-info` | `lark-sheets-workbook` | `+sheet-list`、`+workbook-get`、`+workbook-list` | +| 复核某次(AI)编辑改了什么 / 取两个版本间的变更 | `+changeset-get --start-revision <编辑前版本>`(省略 `--end-revision` 取到最新;版本差 ≤ 20) | `lark-sheets-changeset` | — | +| 取当前文档 revision(版本号) | `+revision-get` | `lark-sheets-workbook` | — | +| 导出 xlsx / 单表 csv | `+workbook-export` | `lark-sheets-workbook` | — | +| 导入本地 xlsx/xls/csv 文件为飞书电子表格 | `+workbook-import --file ./x.xlsx`(本地表格文件 → 飞书电子表格的正解;仅要导成多维表格 bitable 时才用 `drive +import --type bitable`) | `lark-sheets-workbook` | `drive +import`(导电子表格时绕了 drive 通道、还要多给 `--type`,应直接用 `+workbook-import`)、把 .xlsx 在本地读成数据再 `+workbook-create` 重灌(多此一举,应直接 `+workbook-import`)、要把文件并入某个**已有在线工作簿**(给它加子表)却用它——import 只会新建独立表,加子表应走 `+sheet-copy` / `+sheet-create` | +| 参考某个**已有在线表**、把多个本地文件 / 数据各作为一张子表**追加**进去(不另起独立表) | 先 `+workbook-info` 拿模板子表 `sheet_id` → `+sheet-copy` 逐张复制模板子表(公式 / 合并 / 分组底色 / 列宽 / 条件格式全继承)再用 `+cells-*` 只改数据;无模板可继承时 `+sheet-create` 建空子表 + `+table-put --sheets/--styles` 写入 | `lark-sheets-workbook` | 把文件 `+workbook-import` / `+workbook-create` 另起一张**独立新表**(目标是并入已有工作簿时就跑偏了;这两条只产新表、不接受已有表定位) | +| 清除内容 / 格式 | `+cells-clear`(范围维度用 `--scope`,取值 content / formats / all) | `lark-sheets-range-operations` | `--type` | +| 批量清除多区域 | `+cells-batch-clear`(`--scope`) | `lark-sheets-batch-update` | `--target` | +| 调整列宽 / 行高 | `+cols-resize` / `+rows-resize`(行、列是两个独立命令) | `lark-sheets-range-operations` | `--dimension`(无此 flag) | +| 分组汇总 / 透视 | `+pivot-create`(默认不传落点 flag → 自动新建子表,零覆盖) | `lark-sheets-pivot-table` | 用 SUMIF / 本地脚本拼一张假透视表 | +| 画图表 / 可视化(柱 / 折线 / 饼 / 条 / 散点 / 组合…) | `+chart-create` | `lark-sheets-chart` | matplotlib / 本地画图再贴图(原生图表可交互、随数据更新) | +| 条件高亮 / 数据条 / 色阶 / 重复值标记 | `+cond-format-create` | `lark-sheets-conditional-format` | `+highlight`、`+conditional-format`、逐格 `+cells-set-style` 硬凑 | +| 筛选 / 只看符合条件的行 | `+filter-create` | `lark-sheets-filter` | pandas filter 后覆盖写回(会毁原数据;要保存多份筛选状态用 `+filter-view-create`) | +> ⚠️ **动手前的触发式必读(按动作判定,不看主场景)**:本次操作只要**涉及样式 / 美化**(底色 / 边框 / 字号 / 对齐 / 数字格式 / 汇总行 / 配色 / 列宽行高),动手前先读 `lark-sheets-visual-standards`;只要**要写飞书公式**,动手前先读 `lark-sheets-formula-translation`(飞书函数与 Excel 有差异,凭直觉迁移易错),**写完后再读 `lark-sheets-formula-verify` 并执行 `+formula-verify` 收尾**。哪怕主任务是"建表 / 展开数据 / 录入",只要动作里含美化或写公式就适用——别因"这不算专门的美化 / 公式任务"而跳过。 > ⚠️ **两种图片别选错**:图若**绑定某条记录、要随行排序 / 筛选 / 增删**(凭证 / 证件照 / 每行配图,话里带「对应 / 每行 / 这列」等绑定词)→ 单元格图片 `+cells-set-image`;只是自由摆放的装饰(logo / 水印 / 封面)→ 浮动图片 `+float-image-create`。别因「浮动图更好控制 / 更熟」默认选浮动图。 -> ⚠️ **纯文本还是数值语义**:要写的列里有数字 / 金额 / 百分比 / 日期 / 计数 → `+table-put`(写入已有表;外层 `{"sheets":[...]}` 包裹、列 pandas dtype 用 `dtypes`、展示格式用 `formats`,保留排序 / 求和 / 图表 / 透视能力;**目标表还不存在就用 `+workbook-create --sheets`**,同 typed 协议、一步建表 + 写入,别先建空表再 `+table-put`);只有纯文本才用 `+csv-put`。两者写完显示可以完全相同,但 `+csv-put` 落的是文本、不能参与计算——别把数值在本地拼成带 `$` / `%` 的字符串再走 `+csv-put`。 +> ⚠️ **纯文本还是数值语义(看数据本质,不看当下用途)**:金额 / 百分比 / 比率 / 计数 / 日期等**本质是量值**的数据 → 一律数值写入,常规二维表用 `+table-put`(`dtypes` 声明类型 + `formats` 设展示格式),版式装不下(多级 / 合并表头的宽表 leaderboard 等)改用 `+cells-set` 传数字(百分比传小数 `0.4`)+ `number_format`,照样显示 `40%` 且数值无损。只有编号 / 身份证 / 单据号这类**本质是标识符**、要字面保真的才用 `+csv-put` 平铺。**几个常见借口都不成立**——"只是 leaderboard / 报表展示不用算""版式复杂""样式以后再刷、先铺文本"都不是把百分比写成 `"40%"` 字符串灌 `+csv-put` 的理由(展示不改变它是数值;类型不能后补,落成文本就回不来)。判据与操作展开见 `lark-sheets-write-cells`「数字还是文本」。 +> ⚠️ **要新建子表 / 整表美化 → 别默认「`+csv-put` 写值再事后刷样式」**:`+table-put` / `+workbook-create` 的 `--styles` 能在写数据的**同一步**带全套样式(区域底色 / 边框 / 列宽 / 行高 / 合并),且 `+table-put` 的 payload 里若 sheet 名不在工作簿中会自动新建子表——**纯文本表要新建子表 + 美化时同样走这里**(`--styles` 与列是否 typed 无关),比「`+csv-put` 写值 + 多次 `+cells-batch-set-style` / `+*-resize` 刷样式」少好几次调用(冻结行列等 sheet 级属性仍需 `+dim-freeze` 单独一步)。 > ⚠️ **定位 flag**:`+cells-get` / `+cells-set` / `+csv-get` 用 `--range`;`+csv-put` 规范用 `--start-cell`(单个左上角锚点格),也接受 `--range` 别名(区间自动取左上角),二者择一即可。 > ⚠️ **读取附加信息**一律走 `+cells-get --include …`,**没有** `--with-styles` 这类 flag;**看合并单元格**用 `+sheet-info` 的 `merged_cells`,不要在 `+cells-get` 里找 merge flag。 +## 执行要点(读取 / 原生工具 / 陷阱) + +准则的实操展开。端到端工作流:了解结构 → 读数据 → 理解语义 → 原生工具优先 → 写入 → 回读验证。 + +### 读取:按需求选路径(细则见 `lark-sheets-read-data`) + +| 用户需求 | 读取路径 | +|---|---| +| "完善 / 补齐 / 填空 / 修正所有 XX"、分析 / 清洗 / 大数据 | 原生优先(公式 / `+pivot` / `+filter`);表达不了再分批 `+csv-get` 导出 + 脚本处理 + 分批回写(默认覆盖所有对应数据行,不以用户选区为准) | +| "查一下 / 看看 / 统计 / 汇总"等只读 | `+csv-get` 读到上下文 | +| 需要公式 / 样式 / 批注 | `+cells-get` | +| 续写 / 扩展已有内容 | `+csv-get` 看结构 + `+cells-get` 读源区样式 + `+sheet-info --include row_heights,merges`(见准则 5) | + +> "补齐 / 填空"类用只读路径探 10 行就写会漏写表尾——写入前先按 `lark-sheets-read-data` 确认真实数据末行(准则 3)。 + +### 计算:原生工具优先,代码兜底(强化准则 7) + +| 用户需求 | 用原生 | 禁止的替代 | +|---|---|---| +| 按 X 统计 Y、分组汇总 | `+pivot-{create\|update\|delete}` | pandas groupby → 写值 | +| 求和 / 计数 / 平均 / 占比 | 公式 | Python 算 → 写静态值 | +| 图表 / 可视化 | `+chart-*` | matplotlib | +| 条件高亮 / 色阶 | `+cond-format-*` | 逐格设样式 | +| 筛选 | `+filter-*` | pandas filter → 覆盖写入 | +| 文本提取 / 转换 / 查找 | 公式(REGEXEXTRACT / TEXT / VLOOKUP 等) | Python → 写静态值 | + +只有多步清洗、统计建模、公式试错 3 次仍失败时才用代码。 + +### 用脚本配合 CLI 时 + +- **只读 stdout**:CLI 数据走 stdout、诊断走 stderr;解析 JSON 别 `2>&1`(警告混入会解析失败),用管道或单独重定向 stdout。 +- **喂 CLI 的 CSV / JSON 用 UTF-8 无 BOM**;临时文件放系统临时目录、勿落项目目录。 +- **命令失败先读 stderr 再调整**,别原样重发。 +- **回写纯单元格值**:剥离 `值(V-Align: bottom)` 这类"值(样式)"串与残留引号再写;排序优先 `+range-sort` 原生工具,别"读出本地排完再整列写回"。 + +### 易漏陷阱 + +- **`+dim-insert` 不继承行高**:只继承值 / 公式 / 边框,新行回落默认高度截断长文本;插行填长文本前读相邻行 `row_height`,用 `+batch-update` 合 `+rows-resize` 补齐。 +- **公式容错**:日期 / 查找 / 数值转换公式用 `IFERROR` 包裹;写完读结果列首末各 5 行查 `#VALUE!` / `#REF!` / `#DIV/0!`,然后继续跑 `+formula-verify` 直到 `status='success'`;同一方案试错上限 3 次。 +- **循环引用**:聚合公式引用范围不能含目标 cell 自身或其传递依赖。 +- **隐藏行列**:`+csv-get` 默认含隐藏行列;设 `--skip-hidden=true` 只看可见,但返回行序号与实际行号不再对应。 +- **跨 sheet 对象**:图表 / 条件格式 / 透视表 / 浮动图片可能分布在多个子表,操作前先 `+workbook-info` 掌握全局。 +- **NLP 任务分批**:语义理解 / 翻译 / 改写 / 分类等用 NLP 处理(代码只做分批 / 行号映射 / 写回);数据量大必须分批(通常 30 行 / 批),每批处理完即时写回,单批生成通常 ≤ 300 行,多批用 `+batch-update`。 + ## References -本 skill 的 reference 分两组:先读**通用方法与规范**(横切所有任务的工作流、铁律、样式、公式规则,不含具体 shortcut),它们规定了"怎么做对";再按操作对象进入**工具参考**查具体 shortcut 与调用细节。编辑类任务务必先过一遍通用方法与规范,其中的铁律对所有工具参考一律生效。 +本 skill 的 reference 分两组:先读**通用方法与规范**(横切所有任务的样式、公式规则,不含具体 shortcut),它们规定了"怎么做对";再按操作对象进入**工具参考**查具体 shortcut 与调用细节。编辑类任务务必先过一遍通用方法与规范,连同上方「飞书表格编辑准则」对所有工具参考一律生效。 ### 通用方法与规范(先读,横切所有任务,不含具体 shortcut) | Reference | 描述 | | --- | --- | -| [飞书表格核心操作:分析、编辑与可视化](references/lark-sheets-core-operations.md) | 飞书表格核心操作工作流。当用户需要对已有的飞书表格进行查看、分析、编辑或可视化时使用。适用场景:数据查询与统计、公式计算、表格美化、创建图表/透视表、筛选排序、批量修改数据、调整表格结构等。即使用户没有明确说"飞书表格",只要操作对象是已有的在线表格,都应触发此工作流。 | -| [飞书表格样式与配色规范](references/lark-sheets-visual-standards.md) | 飞书表格样式与配色规范:表头/数据区/汇总行的颜色、字号、对齐、边框等取值标准,以及新增汇总行、追加行列继承原表风格、已有区域美化等典型场景的决策流程与样式要点。工具调用参数细节请参考对应的 lark-sheets-write-cells / lark-sheets-range-operations / lark-sheets-batch-update。条件格式(高亮、标红、数据条、色阶)请使用 lark-sheets-conditional-format。 | -| [飞书表格公式生成规则](references/lark-sheets-formula-translation.md) | Excel 公式到飞书表格公式的迁移与生成规则。核心目标不是保留 Excel 原语法,而是按飞书表格可执行规则重写公式,并在结果上尽量对齐 Excel。当用户要求把 Excel 公式改写成飞书表格公式,或需要生成飞书公式(尤其涉及 ARRAYFORMULA、原生数组函数、INDEX/OFFSET、MAP/LAMBDA、日期差、多层范围结果与二次展开)时使用。 | +| [飞书表格样式与配色规范](references/lark-sheets-visual-standards.md) | 飞书表格样式与配色规范:表头/数据区/汇总行的颜色、字号、对齐、边框、数字格式等取值标准,以及从零新建表格的版式美化、新增汇总行、追加行列继承原表风格、已有区域美化等典型场景的决策流程与样式要点。工具调用参数细节请参考对应的 lark-sheets-write-cells / lark-sheets-range-operations / lark-sheets-batch-update。条件格式(高亮、标红、数据条、色阶)请使用 lark-sheets-conditional-format。 | +| [飞书表格公式生成规则](references/lark-sheets-formula-translation.md) | Excel 公式到飞书表格公式的迁移与生成规则。核心目标不是保留 Excel 原语法,而是按飞书表格可执行规则重写公式,并在结果上尽量对齐 Excel。当用户要求把 Excel 公式改写成飞书表格公式,或需要生成飞书公式(尤其涉及 ARRAYFORMULA、原生数组函数、INDEX/OFFSET、MAP/LAMBDA、日期差、多层范围结果与二次展开)时使用。本文只负责把公式写对,落表后的强制收尾请接 `lark-sheets-formula-verify`。 | ### 按对象的工具参考(含 shortcut) | Reference | 描述 | | --- | --- | +| [Lark Sheet Formula Verify](references/lark-sheets-formula-verify.md) | 公式写入 / 批量填充 / `--copy-to-range` 扩展 / 导入含公式工作簿后的强制自检入口。对指定子表(或整本工作簿)扫描公式与单元格值,聚合所有 Excel 错误(#REF! / #DIV/0! / #VALUE! / #NAME? / #NULL! / #NUM! / #N/A),同时合并最近一次写入留下的编译失败(formula_errors),输出统一 JSON 让 AI 一次拿到完整健康度报告。只要任务涉及写公式,落表后就应调用 +formula-verify 收敛到 zero-error;`status='errors_found'` 或 `status='partial'` 时禁止把链路标为完成。 | | [Lark Sheet Workbook](references/lark-sheets-workbook.md) | 管理飞书表格的工作簿结构(子表列表及元数据)。当用户提到"看看这个表格有什么"、"表格结构"、"有哪些 sheet"、"新建一个 sheet"、"删除这个工作表"、"重命名"、"复制一份"、"移动到前面"时使用。 | | [Lark Sheet Sheet Structure](references/lark-sheets-sheet-structure.md) | 管理飞书表格的子表结构与布局。适用场景:查看行高、列宽、隐藏行列、合并单元格等布局信息,以及"插入一行"、"删除这列"、"隐藏行"、"冻结表头"、行列分组(大纲折叠/展开)等操作。行列大纲仅在用户明确提到"行分组"、"列分组"、"大纲"、"outline"时才触发,"按XXX分组"等数据分组场景请使用 lark-sheets-pivot-table。如需在表尾追加数据,应先通过此 skill 插入行,再通过 lark-sheets-write-cells 写入。 | | [Lark Sheet Read Data](references/lark-sheets-read-data.md) | 读取飞书表格中的单元格数据。当用户需要"看看数据"、"分析数据"、"统计/汇总"时使用;也适用于需要查看公式、样式、批注等详细信息的场景。 | | [Lark Sheet Search & Replace](references/lark-sheets-search-replace.md) | 在飞书表格中搜索和替换文本,支持限定范围、大小写匹配、精确匹配、正则表达式。当用户需要"查找"、"搜索"、"定位"某个值,或"替换"、"批量修改文本"、"把 A 改成 B"时使用。不要用于理解表格结构(应读取数据)、不要用于数据分析(应读取数据后计算)、不要把用户操作动作中的关键词(如"汇总金额""统计数量")当作搜索词。 | -| [Lark Sheet Write Cells](references/lark-sheets-write-cells.md) | 向飞书表格的指定区域批量写入值、公式、样式、批注或单元格图片。适用场景:填写数据、设置公式、修改格式、添加批注、嵌入单元格图片(如需操作浮动图片,请使用 lark-sheets-float-image);若只需把一块 CSV 批量铺到表格上(值或公式,不带样式/批注),直接使用 `+csv-put` 更短更快。追加数据需先通过 lark-sheets-sheet-structure 插入行列。 | +| [Lark Sheet Write Cells](references/lark-sheets-write-cells.md) | 向飞书表格的指定区域批量写入值、公式、样式、批注或单元格图片。适用场景:填写数据、设置公式、修改格式、添加批注、嵌入单元格图片(如需操作浮动图片,请使用 lark-sheets-float-image);若只需把一块 CSV 批量铺到表格上(值或公式,不带样式/批注),直接使用 `+csv-put` 更短更快。追加数据需先通过 lark-sheets-sheet-structure 插入行列。只要这次写入真实落了公式,收尾默认继续执行 `lark-sheets-formula-verify`。 | | [Lark Sheet Range Operations](references/lark-sheets-range-operations.md) | 对飞书表格中指定区域执行结构性操作(不涉及写入单元格数据值)。适用场景:清除内容或格式("清空"、"删除内容"、"去掉格式")、合并/取消合并单元格、调整行高列宽("加宽列"、"自适应列宽")、移动/复制/填充/排序数据("移动数据"、"复制到"、"自动填充"、"按某列排序")。写入单元格数据请使用 lark-sheets-write-cells。 | | [Lark Sheet Batch Update](references/lark-sheets-batch-update.md) | 将多个飞书表格写入操作合并为一次批量执行,按顺序依次完成。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。 | | [Lark Sheet Chart](references/lark-sheets-chart.md) | 管理飞书表格中的图表(柱形图、折线图、饼图、条形图、面积图、散点图、组合图、雷达图等)。当用户需要创建图表、修改图表样式或数据源、查看已有图表配置、删除图表时使用。也适用于用户提到"数据可视化"、"画个图"、"趋势分析"、"对比图"、"占比分析"、"做个图表"等数据可视化相关场景。 | @@ -92,6 +159,8 @@ metadata: | [Lark Sheet Filter View](references/lark-sheets-filter-view.md) | 管理飞书表格中的筛选视图(filter view)。当用户需要"建一个 XX 视图"、"保存这个筛选状态"、"切换不同筛选"、维护一个 sheet 上多份独立筛选配置时使用。视图与筛选器(filter)相互独立,可在同一 sheet 共存;视图的隐藏行仅在用户进入该视图时本地生效,不影响其他协作者。 | | [Lark Sheet Sparkline](references/lark-sheets-sparkline.md) | 管理飞书表格中的迷你图(折线迷你图、柱形迷你图、胜负迷你图)。当用户需要在单元格内嵌入小型图表来展示数据趋势时使用。也适用于"趋势线"、"单元格内图表"、"迷你图"等场景。注意:不等同于被禁用的 SPARKLINE() 公式函数。 | | [Lark Sheet Float Image](references/lark-sheets-float-image.md) | 管理飞书表格中的浮动图片。当用户需要在表格中插入浮动图片、调整图片位置和大小、查看已有浮动图片、删除图片时使用。也适用于"插入图片"、"添加 logo"、"放一张图"等场景。注意:如果用户需要将图片嵌入到某个单元格内部(单元格图片),请阅读 lark-sheets-write-cells。 | +| [Lark Sheet History](references/lark-sheets-history.md) | 查询飞书表格的历史版本并回滚到指定版本。当用户需要查看一张表的编辑历史版本列表、回滚到某个历史版本、或查询回滚的异步状态(进行中/成功/失败)时使用。回滚为异步操作,发起后通过状态查询轮询结果。仅针对飞书表格。 | +| [Lark Sheet Changeset](references/lark-sheets-changeset.md) | 读取两个版本(CS revision)之间的 changeset(原始变更操作清单),用于复核某次编辑——尤其是 AI 编辑——是否真实满足用户诉求。传入起始版本(编辑前基线),可选结束版本(省略取最新),版本差上限 20;返回里最外层带当前表格最新版本号。当用户需要"看看这次改了什么"、"核对 AI 改动"、"对比两个版本的变更"时使用。 | ## 公共 flag 速查 diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 21342ba75..1f300ad0a 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -8,6 +8,8 @@ 2. **批次完成后必须回读校验**:整个 `+batch-update` 执行成功后,用 `+csv-get` 或 `+cells-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末),与本地脚本预先计算的预期值对照。 3. **预期条数前置断言**:涉及"批量填充 N 行"或"对 M 个区域分别写入"时,先把 N、M 硬编码进代码,回读后断言实际等于预期;不一致就再发一轮 `+batch-update` 补齐,禁止交付半成品。 +若本次 `+batch-update` 的任一子操作写入了公式、复制了公式模板、或导入了含公式的数据块,**回读校验之后还必须继续执行 `+formula-verify`**。`+batch-update` 的原子提交只保证“写入动作都执行了”,不保证整批公式运行结果 zero-error。 + ## 使用场景 写入。批量执行多个写入工具操作。将多个工具调用合并为一次请求,按顺序依次执行。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。注意:不支持嵌套 `+batch-update`。 @@ -16,12 +18,18 @@ **⚠️ 何时必须使用 `+batch-update`(硬性要求)**: - 需要对**多个**不同区域执行 `+cells-{merge|unmerge}` 时(如按分组合并多列相同内容) -- 需要对**多个**不同区域执行 `+rows-resize / +cols-resize` 时(如统一调整多列列宽或多行行高) - 需要先插入行列再写入数据时(`+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` + `+cells-set`) - 需要对多个区域执行不同写入操作时(多次 `+cells-set` + `+cells-clear` 等组合) +**行高列宽批量不走这里**:多行 / 多列不同尺寸直接用 `+rows-resize --heights` / `+cols-resize --widths` 的 map 形态(如 `--widths '{"A":100,"C:E":120}'`,见 `lark-sheets-range-operations`),一次调用原子完成;map 形态不可作为 `--operations` 子操作嵌入(子操作里仍可用单区间形态 `range` + `height`/`width`)。 + 当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求——`+batch-update` 是原子提交(要么全成功要么整批回滚);逐个调用非原子,中途失败会留下半成品。 +**公式相关批处理的默认闭环**: +- 写前:先读 `lark-sheets-formula-translation`,把公式改写成飞书可执行语义。 +- 写时:用 `+batch-update` 一次性完成插行/写公式/复制模板等原子动作。 +- 写后:抽样回读之外,继续跑 `lark-sheets-formula-verify`,直到 `+formula-verify` 返回 `status='success'`。 + **`+dropdown-update` 的选项模式(`--options` / `--source-range` 二选一)+ 配色规则**(`--colors` 长度可短不能长、必须配 `--highlight=true` 才生效、不传按内置 10 色色板循环补色)见 [`lark-sheets-write-cells`](./lark-sheets-write-cells.md) 的「Dropdown 选项 + 配色」节,本文不重复。`+dropdown-delete` 不涉及这些 flag。 ## Shortcuts @@ -51,9 +59,10 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["'Sheet1'!A1:B2","'Sheet2'!D1:D10"]`);前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id;支持跨 sheet;所有 range 应用同一组 style | +| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(最多 100 个),每项必须带 sheet 前缀(如 `["Sheet1!A1:B2","Sheet2!D1:D10"]`,前缀裸写不加引号);前缀必须与 sheet 真实显示名完全一致(含大小写),不接受 sheet reference_id;支持跨 sheet;所有 range 应用同一组 style | | `--background-color` | string | optional | 背景颜色(十六进制,如 `#ffffff`) | | `--font-color` | string | optional | 字体颜色(十六进制,如 `#000000`) | +| `--font-family` | string | optional | 字体名称(如 `Arial`、`微软雅黑`) | | `--font-size` | float64 | optional | 字体大小(px,例:10、12、14) | | `--font-style` | string | optional | 字体样式(可选值:`normal` / `italic`) | | `--font-weight` | string | optional | 字重(可选值:`normal` / `bold`) | @@ -70,7 +79,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(如 `["'Sheet1'!A2:A100","'Sheet1'!C2:C100"]`),每项必须带 sheet 前缀;前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id | +| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(最多 100 个,如 `["Sheet1!A2:A100","Sheet1!C2:C100"]`,前缀裸写不加引号),每项必须带 sheet 前缀;前缀必须与 sheet 真实显示名完全一致(含大小写),不接受 sheet reference_id | | `--options` | string + File + Stdin(复合 JSON) | xor | 下拉选项 JSON 数组,例如 `["opt1","opt2"]`。服务端不限制选项数量,也不限制单个选项长度;含逗号的选项可以接受(写入时会自动转义)。大量选项建议改用 `--source-range`。 | | `--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 | 启用多选 | @@ -83,7 +92,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(最多 100 个,如 `["'Sheet1'!E2:E6"]`),每项必须带 sheet 前缀;前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id | +| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(最多 100 个,如 `["Sheet1!E2:E6"]`,前缀裸写不加引号),每项必须带 sheet 前缀;前缀必须与 sheet 真实显示名完全一致(含大小写),不接受 sheet reference_id | ### `+cells-batch-clear` @@ -91,7 +100,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["'Sheet1'!A2:Z1000","'Sheet2'!A2:Z1000"]`);前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id;支持跨 sheet;对所有 range 执行同一 scope 的清除 | +| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(最多 100 个),每项必须带 sheet 前缀(如 `["Sheet1!A2:Z1000","Sheet2!A2:Z1000"]`,前缀裸写不加引号);前缀必须与 sheet 真实显示名完全一致(含大小写),不接受 sheet reference_id;支持跨 sheet;对所有 range 执行同一 scope 的清除 | | `--scope` | string | optional | 清除范围 enum:`content`(默认,仅清内容)/ `formats`(仅清格式)/ `all`(清内容 + 格式)(可选值:`content` / `formats` / `all`) | ## Schemas @@ -137,7 +146,7 @@ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" -- # ops.json (array<{shortcut, input}>,shortcut 用 CLI 名): # [ -# {"shortcut": "+dim-insert", "input": {"sheet_id":"...","dimension":"row","start":10,"end":12}}, +# {"shortcut": "+dim-insert", "input": {"sheet_id":"...","position":10,"count":3}}, # {"shortcut": "+cells-set", "input": {"sheet_id":"...","range":"A11:B12","cells":[[{"value":"a"},{"value":"b"}],[{"value":"c"},{"value":"d"}]]}} # ] ``` @@ -145,7 +154,7 @@ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" -- > ⚠️ **子操作定位规则**: > - spreadsheet 定位(`--url` / `--spreadsheet-token`)**只在顶层给一次**;`+batch-update` 顶层**没有** `--sheet-id` / `--sheet-name`,在顶层传不生效。 > - **每个子操作的子表定位 `sheet_id`(或 `sheet_name`)写进它自己的 `input`**(见上方 ops.json 每个 item)。 -> - `input` 的键是该 shortcut 的 flag **展平**成 JSON(`"range":"A11:B12"`、`"dimension":"row"`),不要把整组 `--operations` 再套一层嵌套 JSON。 +> - `input` 的键是该 shortcut 的 flag **展平**成 JSON(`"range":"A11:B12"`、`"position":11`),不要把整组 `--operations` 再套一层嵌套 JSON。 > **常见组合:插列 + 写表头 + 整列回填**——一次原子提交,不要拆成 N 次独立调用。批量回填同一列 **只需一次** `+cells-set`(range 写整列范围、cells 写 N×1 矩阵),不需要逐行循环。 > @@ -153,9 +162,9 @@ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" -- > // 在 C 列前插入新列 → 写表头 C1 → 回填 C2:C100 共 99 行 > [ > {"shortcut": "+dim-insert", -> "input": {"sheet_id": "...", "dimension": "column", "start": 3, "end": 4}}, +> "input": {"sheet_name": "Sheet1", "position": "C", "count": 1}}, > {"shortcut": "+cells-set", -> "input": {"sheet_id": "...", "range": "C1:C100", +> "input": {"sheet_name": "Sheet1", "range": "C1:C100", > "cells": [[{"value":"score"}], [{"value":95}], [{"value":87}], /* ... 97 more rows ... */ ]}} > ] > ``` diff --git a/skills/lark-sheets/references/lark-sheets-changeset.md b/skills/lark-sheets/references/lark-sheets-changeset.md new file mode 100644 index 000000000..421fd9b51 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-changeset.md @@ -0,0 +1,105 @@ +# Lark Sheet Changeset + +## 使用场景 + +读取两个版本之间的 **changeset(变更操作清单)**,用于**复核某次编辑(尤其是 AI 编辑)是否真实满足用户诉求**。 + +典型场景:AI agent 对表格做了一批编辑后,想确认它"说做的"和"真正落到表格上的"是否一致——拉取编辑前版本到编辑后版本之间的 changeset,逐条核对 action 是否覆盖了用户要求的修改、有没有多改 / 漏改。 + +## 版本(revision)语义 + +- 这里的"版本"指表格的 **CS revision**(每次提交单调递增的修订号),不是文档历史里的命名版本。 +- `--start-revision` 是复核基线,即你认定的"编辑前"版本。 +- `--end-revision` 是"编辑后"版本;**省略时默认取最新 revision**,返回从 start 到最新的全部 changeset。 +- **版本差上限 20**:`end - start + 1 ≤ 20`,超出会被拒绝(服务端同样以 20 兜底)。复核大跨度变更时请分段拉取。 + +## Shortcuts + +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+changeset-get` | read | 变更记录 | + +## Flags + +### `+changeset-get` + +_公共:URL/token(无 sheet 定位)_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--start-revision` | int | required | 起始版本(编辑前基线,>= 1) | +| `--end-revision` | int | optional | 结束版本(省略取最新) | + +## 返回结构 + +返回一个 JSON 对象,`changesets` 数组按版本顺序排列,每个元素是一次提交的**原始 action 列表**与元信息: + +```json +{ + "spreadsheet_token": "shtcnXXXX", + "latest_revision": 142, + "start_revision": 120, + "end_revision": 135, + "changesets": [ + { + "revision": 121, + "create_time": "2026-06-12T10:00:00Z", + "actions": [ + { "action": "setCellRange", "sheetId": "...", "value": { /* ... */ } } + ], + "is_self_edit": false, + "is_ai_edit": true + } + ] +} +``` + +- 最外层 `latest_revision` 是**当前表格的最新版本号**(与查询区间无关),便于判断表格当前停在哪个版本、`--start-revision` 该取多少。 +- `actions` 是**未经语义渲染的原始操作对象**,按提交内的执行顺序排列。复核时逐条比对:每个 action 改了哪个 sheet、哪个区域、改成什么,是否对应用户的诉求。 +- `revision` / `create_time` 用于判断"这次改动属于哪个版本、什么时候做的"。 +- `is_self_edit` 表示该 changeset 是否由当前请求用户提交(committer 与请求用户相同),即"是不是我自己提交的编辑"。 +- `is_ai_edit` 表示该 changeset 是否由 AI 客户端提交(`member_id` 为 10 / 11)。复核时 `is_ai_edit=true` 即为 AI 写入的编辑(而非用户手动编辑),是核对 AI 是否完成诉求的主要对象。 + +## 复核工作流(判断 AI 是否真实完成诉求) + +1. 记下 AI 开始编辑前的 revision(编辑前 `+workbook-info` 或上一次工具返回的 revision 即可作为 `--start-revision`)。 +2. AI 编辑完成后,跑 `+changeset-get --url <表格> --start-revision <编辑前版本>`(不传 end → 取到最新)。 +3. 遍历 `changesets[].actions`,核对: + - 用户要求的每一处修改是否都有对应 action; + - 有没有越权 / 多余的修改(动了用户没让动的 sheet / 区域); + - action 的目标区域、值是否与诉求一致。 +4. 若版本跨度可能 > 20,分段拉取(如 `start..start+19`、`start+20..` …)。 + +## 注意 + +- `+changeset-get` 是**只读**操作,不改动表格。 +- 大跨度 / 大批量编辑的 changeset 可能体积较大;输出在传输层已 gzip。必要时缩小版本区间。 +- 该工具走只读 scope `sheets:spreadsheet:read`,需要对表格有查看权限。 + +## Examples + +### `+changeset-get` + +公共:`--url` / `--spreadsheet-token`(二选一,无 sheet 定位)。changeset 是工作簿级历史,不接受 sheet 定位 flag。 + +示例: + +```bash +# 只传起始版本 → 返回从该版本到最新的全部 changeset(最常用:复核 AI 编辑前后的差异) +lark-cli sheets +changeset-get --url "https://example.feishu.cn/sheets/shtXXX" --start-revision 120 + +# 传起始 + 结束版本(版本差 end-start+1 ≤ 20) +lark-cli sheets +changeset-get --spreadsheet-token shtXXX --start-revision 120 --end-revision 135 +``` + +输出契约(envelope.data): + +- `latest_revision` — 当前表格最新版本号(与查询区间无关) +- `start_revision` / `end_revision` — 实际查询区间(省略 `--end-revision` 时 `end_revision` = 最新版本) +- `changesets[]` — 按版本顺序排列;每项含 `revision` / `create_time` / `actions`(原始操作列表)/ `is_self_edit` / `is_ai_edit` + +### Validate / DryRun / Execute 约束 + +- `Validate` 阶段只做 XOR 检查(`--url` / `--spreadsheet-token` 二选一)与版本上限校验(`--start-revision ≥ 1`,传了 `--end-revision` 时 `end ≥ start` 且 `end - start + 1 ≤ 20`);**禁止**联网。 +- `DryRun` 输出请求模板,不实际拉取 changeset。 +- `Execute` 阶段才发起 changeset 查询;省略 `--end-revision` 时由服务端解析为最新 revision。 \ No newline at end of file diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md index 36b92b8d9..f41087b3e 100644 --- a/skills/lark-sheets/references/lark-sheets-chart.md +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -31,7 +31,9 @@ **常见配置错误(必须注意)**: - **图表类型选择错误**:用户说"堆积柱形图/百分比堆积"时,应在 `properties.snapshot.plotArea.plot.extra.stack` 中配置堆叠;百分比堆叠需在该 stack 下设置 `percentage: true`。用户说"占比/比例"时,优先考虑饼图或百分比堆积图。注意区分 `column`(柱形图,纵向)与 `bar`(条形图,横向)是两个不同的 type 取值,"对比/各 XX" 类纵向柱默认用 `column` -- **数据标签缺失**:用户需要看到具体数值时,需配置 `properties.snapshot.plotArea.plot.labels`(数据标签)相关字段 +- **数据标签开关**:`plotArea.plot.labels` 对象的**存在性即开关**—— + - 用户需要看到具体数值/类别时:传入 `labels` 并配置 `value` / `category` / `series` / `percentage` 等显示位。 + - 用户明确说"不要数据标签 / 关掉标签"时:**整个 `labels` 字段省略**。不要用 `labels: { value: false, category: false, series: false }` 这种"全部置 false"的写法关闭——只要传了 `labels`,系统就会显示数据标签(且默认兜底显示 value)。 - **数据源范围与系列名来源要对齐**: - **默认情况(inline 模式)**:`refs` 范围**应包含表头行**(首行/首列即系列名),且范围要精确覆盖目标数据,不要多选或少选。 - **合并标题行要跳过**:如果表格在表头上方存在合并的标题行(如"员工统计表"横跨多列的大标题),`refs` 必须跳过标题行、从真正的列标题行开始。例如表头在第 3 行、数据在第 4-20 行,则 `refs` 应为 `A3:G20` 而非 `A1:G20`。包含合并标题行会导致列名识别错误、表头被当作数据参与聚合计算。 @@ -122,7 +124,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--properties` | string + File + Stdin(复合 JSON) | required | 图表完整配置 JSON。顶层字段为 `position` / `offset` / `size` / `snapshot`(无顶层 `data`,也无再嵌一层 `properties`);图表数据配置在 `snapshot.data` 下(含 `refs` / `headerMode` / `dim1` / `dim2`)。结构嵌套深,完整结构跑 `--print-schema --flag-name properties` | +| `--properties` | string + File + Stdin(复合 JSON) | required | 图表完整配置 JSON。顶层字段为 `position` / `offset` / `size` / `snapshot`(无顶层 `data`,也无再嵌一层 `properties`);图表数据配置在 `snapshot.data` 下(含 `refs` / `headerMode` / `dim1` / `dim2`);必须至少含 `snapshot.data.dim1.serie.index` 或 `dim2.series[].index` 之一,否则 server 拒。结构嵌套深,完整结构跑 `--print-schema --flag-name properties` | ### `+chart-update` diff --git a/skills/lark-sheets/references/lark-sheets-conditional-format.md b/skills/lark-sheets/references/lark-sheets-conditional-format.md index 737ce2070..f6aa6292e 100644 --- a/skills/lark-sheets/references/lark-sheets-conditional-format.md +++ b/skills/lark-sheets/references/lark-sheets-conditional-format.md @@ -43,6 +43,8 @@ **正确做法(两步走)**: +Step 1 的 `+cells-set` 及 `--copy-to-range` 等 flag 以 `lark-sheets-write-cells` 为准。 + ``` Step 1: `+cells-set` 在新列写判断公式(形成"是/否"或布尔辅助列) range="H2", cells=[[{formula: "=IF(A2>B2, \"是\", \"否\")"}]], --copy-to-range="H2:H100" diff --git a/skills/lark-sheets/references/lark-sheets-core-operations.md b/skills/lark-sheets/references/lark-sheets-core-operations.md deleted file mode 100644 index e849da4d3..000000000 --- a/skills/lark-sheets/references/lark-sheets-core-operations.md +++ /dev/null @@ -1,103 +0,0 @@ -# 飞书表格核心操作:分析、编辑与可视化 - -## 概览 - -面向"已有飞书表格"的核心工作流,核心原则:**先了解,再分析或写入,最后验证**。本文是方法论总纲;具体工具的参数细节、边界陷阱在对应 reference,本文用指针引到那里,不重复展开。 - -**三份「通用方法与规范」如何分工**(都不含 shortcut,按主题单一归属): - -- **本文(core-operations)= 流程与铁律**:端到端工作流 + 全局铁律 + 横切陷阱,是读取入口与枢纽。 -- **`lark-sheets-visual-standards` = 样式知识**:配色 / 表头 / 数值格式 / 斑马纹 / 美化决策等"正确视觉输出"的全部标准。 -- **`lark-sheets-formula-translation` = 公式知识**:飞书公式书写与 Excel 迁移的全部正确性规则(绝对引用、范围语法、数组语义、不支持函数等)。 - -> **下面的铁律对所有任务一律生效**,即使你是被索引直接路由进 visual 或 formula 而没经过本文——编辑类任务务必先回到这里过一遍铁律。 - -## 铁律(所有编辑类任务必须满足,各 reference 不得放宽) - -1. **最小改动**:除用户明示要改的单元格 / 列外,原表其它单元格、行列结构、Sheet 名、合并区、格式必须 1:1 保持。中间结果优先放原数据**右侧**;会与原数据混淆或要承载透视表 / 图表时才**新建空白 Sheet**。**禁止**擅自删 / 改名 / 隐藏 / 移动**已存在**的 Sheet(新建允许,节制使用)。**改写 / 转换类任务要精确圈定适用行列**:只对任务真正要求的对象做变换,**不该转的行 / 列保持原值 1:1**(典型反例:要求"统一翻译"时把本就是中文、应原样保留的评论也重新翻译;要求"改写某列格式"时连原始测量值也一并改动 → 应保留的原文被篡改)。 -2. **真实写回 + 回读校验**:交付必须是对在线表格的真实写入,并 `+csv-get` / `+cells-get` / `+<对象>-list` 回读校验。**严禁**只在文本里描述"已完成"、用普通公式 / 文本假装结构化对象、或只给占位而无真实写入。**收尾前必须确认产物文件真实存在 / 可导出**——别在没真正生成产物时只凭文本"已完成"就结束(反例:文本称已完成,实际没生成产物文件,等于没交付)。 -3. **读全再写,禁止只探前 N 行**:批量填充 / 补齐 / 修正类任务必须先确认**真实数据末行**再写,否则会漏写表尾。完整的"按表格形态分流读取 + `current_region` / `has_more` 兜底 + 真实末行确认"流程见 `lark-sheets-read-data` 的「确定数据范围的正确流程」。 -4. **公式优先于硬编码**:能用飞书公式表达的计算(总计 / 占比 / 增长率 / 提取 / 查找等)一律写公式而非静态值,源数据变化才能自动重算。用户口头的"分列 / 排序 / 求和 / 提取"也要落地为公式或原生工具(SORT / `TEXTBEFORE` / `MID` / 透视表 等)。Excel 公式迁移、数组语义、不支持函数清单一律以 `lark-sheets-formula-translation` 为唯一权威。**即使用户没说"联动 / 自动更新",凡是可由表内其它单元格推导的派生值(年龄=当年-出生年、占比=本类数/总数、达标=阈值判断、排名、各类分组汇总)默认就必须用公式**——用户默认期望派生列能随源数据重算,**离线 Python / 脚本算完写静态值,即便当前数值正确,改了源数据也不会自动更新,等于没满足"派生"的本意**(反例:年龄、月度汇总、占比、分组求和等派生列写死值,源数据一改结果就过时)。 -5. **续写 / 扩展必须继承样式**:续写、补齐、复制区块、新增行列时,**禁止**只读值只写值。必须连带 `cell_styles` + `border_styles` + 合并 + 行高一起继承。完整继承清单与做法见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」(`border_styles` 四边最易漏)。 -6. **多步写入优先 `+batch-update`**:多个连续写入、或同一工具对多个区域重复调用(多次 merge / resize / cells-set),必须合并为单次原子 `+batch-update`。语义与不可嵌套的限制见 `lark-sheets-batch-update`。 -7. **分组汇总必须用透视表**:"按 X 统计 Y / 分组汇总 / 各部门数量金额"必须用 `+pivot-{create|update|delete}`(推荐省略 sheet_id 自动新建子表),**禁止**用 SUMIF / COUNTIF 或本地脚本覆盖原表替代。 -8. **任务拆成可验证 checklist**:落地前把指令拆成所有"独立可验证子要点",每点一个 `assert`,全部通过才交付:多维度操作(按部门一/二/三级排序)每维一个 assert;多目标(删 N 行)每目标一个;多格式兼容(多种日期格式)每种至少一个样本;范围类(A1:H11 加边框)起 / 末行 / 末列三边界都核。只完成第一个要点(只排一级、只删 1 行)属违规。**题面 / 表头里写明的格式规范也是子要点**:表头注明"需标注某字段"就必须给对应单元格加规定前缀并逐条 assert 前缀存在(反例:漏加规定前缀,该要点即不达标);"相同编号连续行合并"必须遍历所有相同编号组全部合并(反例:只合并了其中一部分组)。 -9. **全量处理要前置断言条数**:翻译 / 打标 / 批量公式落地等逐条任务,落地前把"预期处理条数"硬编码进代码,处理完 `assert actual == expected`。**严禁**输出"已完成前 N 条,剩余将继续"的半成品。 - -## 推荐工作流程 - -1. **规划 reference 清单**:开工前一次性列出本任务要读的 reference(避免读一个调一个),本轮已读过的不重复读。本文 + `lark-sheets-workbook` 几乎每次都要。 -2. **了解结构**:先 `+workbook-info` 拿子表列表 / 行列数 / 冻结位置(不可猜测,猜错会越界覆盖);涉及合并 / 隐藏 / 分组 / 行高列宽再用 `lark-sheets-sheet-structure` 的 `+sheet-info`。 -3. **读取数据(按任务类型选路径,细则见 `lark-sheets-read-data`)**: - - | 用户需求语义 | 路径 | - |---|---| - | "完善 / 补齐 / 填空 / 修正所有 XX" / 数据分析 / 清洗 / 大数据集 | **A:原生优先**(公式 / `+pivot` / `+filter`,见第 5 步);原生表达不了或更复杂时**分批 `+csv-get` 导出 + 本地脚本处理 + 分批回写**(默认覆盖所有对应数据行,不以用户选区为准;脚本与 CLI 配合见下方「CLI 配合要点」) | - | "查一下 / 看看 / 统计 / 汇总" 等只读 | B:`+csv-get` 读到上下文 | - | 需要公式 / 样式 / 批注 | C:`+cells-get` | - | 续写 / 扩展 / 完善已有内容 | D:`+csv-get` 看结构 + `+cells-get` 读源区样式 + `+sheet-info --include row_heights,merges`(见铁律 5) | - - **注意**:对"完善 / 补齐 / 填空"类任务用路径 B 探 10 行就写入,实测会漏写表尾多行。写入前必须按 `lark-sheets-read-data`「确定数据范围的正确流程」确认真实数据末行。按关键字定位区域用 `lark-sheets-search-replace` 的 `+cells-search`。 - -4. **理解数据语义(写入前必做)**:读表头 + 3-5 行样本确认各列含义与格式(文本 / 数字 / 日期 / 混合);写公式前先分析样本值格式模式再选提取策略;建透视表前先列清"行字段=分组维度、值字段=聚合指标"。需求模糊时(如"加入加减乘除"未说逻辑)基于表头与已有公式推断,不确定就问用户,禁止臆造业务逻辑。 - -5. **分析与计算(原生工具优先,代码兜底)**:飞书原生能力能随数据自动更新,**必须优先**: - - | 用户需求 | 必须用的原生工具 | 禁止用代码替代 | - |---|---|---| - | 按 X 统计 Y、分组汇总 | `+pivot-{create\|update\|delete}` | pandas groupby → `+cells-set` | - | 求和 / 计数 / 平均 / 占比 | 公式(SUM/COUNT/AVERAGE) | Python 算 → 写静态值 | - | 画图表 / 可视化 | `+chart-{create\|update\|delete}` | matplotlib 画图 | - | 条件高亮 / 色阶 | `+cond-format-{create\|update\|delete}` | 逐单元格设样式 | - | 数据筛选 | `+filter-{create\|update\|delete}` | pandas filter → 覆盖写入 | - | 文本提取 / 转换 | 公式(REGEXEXTRACT/TEXT/VALUE) | Python 正则 → 写静态值 | - | 查找匹配 | 公式(VLOOKUP/INDEX+MATCH) | pandas merge → 写静态值 | - - **只有以下才用代码**:多步清洗流水线、统计建模、公式试错 3 次仍失败的降级。代码结果回写:大块纯值用 `+csv-put`(+ `--start-cell`,必要时自动扩容);少量或需公式 / 样式用 `+cells-set`;能用飞书公式表达的写飞书公式。 - -6. **写入与修改(细节见 `lark-sheets-write-cells`)**:`+cells-set` 的 `range` 必须落在已有行列范围内、`cells` 二维数组与 `range` 严格同维;表尾追加先用 `+dim-insert` 插行列再写;整列 / 整行同结构的值 / 公式 / 格式用模板单元格 + `--copy-to-range`,禁止逐行 `+cells-set`;多步写入合并为 `+batch-update`;改尺寸先读相邻可见行列当前尺寸再决定 `pixel` / `standard` / `auto`,不要猜数值。 - -7. **验证**:重新读取受影响区域确认值 / 公式 / 样式 / 批注符合预期;对象类(图表 / 透视表 / 条件格式 / 筛选 / 迷你图 / 浮动图片)重新读对象配置确认;出错先定位错误类型 / 受影响区域 / 根因再修复重验。 - -## 用本地代码 / 脚本时的 CLI 配合要点 - -复杂处理——多步清洗、统计建模、批量转换、语义任务的分批编排等——用代码(`python` / `node` 等)解决是完全正当的。原生能力(公式 / `+pivot` / `+filter`)能表达就优先用(可随源数据自动重算);原生表达不了或逻辑更复杂时,放手用代码。下面几条让脚本与 CLI 顺畅配合: - -- **解析输出时只读 stdout**:CLI 把数据 JSON 写到 stdout、把诊断与警告写到 stderr。解析 JSON 时**不要合并这两条流**(即不要 `2>&1`),否则警告行混进 JSON 会让解析失败。用管道(`lark-cli … | jq …`)或先把 stdout 单独重定向到文件再读;需要诊断信息时把 stderr 另导到一个文件。 -- **喂给 CLI 的 CSV / JSON 用 UTF-8、不带 BOM**:BOM 会污染首格的值或触发 `invalid character` 解析错;脚本读写文件时显式指定 `encoding='utf-8'`。 -- **临时文件交给运行时的标准库**:用 `tempfile.gettempdir()` / `os.tmpdir()` 等取临时目录,不要硬编码固定路径;放在用户项目目录之外。 -- **命令失败先读错误再调整**:同一条命令失败后不要原样重发;先看 stderr 的报错(参数错误、缺依赖、解释器不可用等)定位原因,再决定换写法、补依赖或退回原生工具。 -- **写回的必须是纯单元格值,禁止把"值+样式标注"串当值写回**:本地脚本或某些 xlsx 解析库会把单元格渲染成 `甲方支行(V-Align: bottom)` 这种"值(样式)"字符串,CSV 字段还可能带包裹双引号。回写前必须**剥离括号样式标注、去掉残留引号**,只写原始值——否则样式描述会变成单元格的字面文本污染原数据(反例:排序后单元格值里被写进 `(V-Align: bottom)` 这类样式后缀文本,末尾还多一个双引号)。**排序本身优先用 `+range-sort` 原生工具**,不要"读出来本地排完再整列写回",从根上避免这类回写污染。 - -## 公式策略 - -- **公式优先于硬编码**(同铁律 4):能用公式表达的计算一律写公式,源数据变化才能自动重算。 -- **写任何公式前先读 `lark-sheets-formula-translation`**:它是公式正确性的唯一权威,覆盖绝对引用(`$`)、飞书范围语法(`H:H` 与工具 A1 表示法的区别)、ARRAYFORMULA / 数组语义、Excel 迁移、不支持函数清单等全部规则。本文不再单列这些细则。 - -## 常见陷阱(铁律已覆盖的不再重复,仅列易漏点) - -- **合并单元格**:合并区只有左上角存数据,其余读为空是正常行为;写入只能写左上角,写其它位置会报 `cell ... is inside a merged region`。改合并区先取消再操作。安全操作 5 条与"批量取消用大 range 一次调用"见 `lark-sheets-range-operations`。 -- **`+dim-insert` 不继承行高**:`--inherit-style before/after` 只继承值 / 公式 / 边框,不继承 `row_height`,新行会回落默认高度截断长文本;中间插行填文本前先读相邻行 `row_height`,用 `+batch-update` 合 `+rows-resize` 补齐。 -- **公式容错**:日期 / 查找 / 数值转换公式用 `IFERROR` 包裹;写完读结果列首 5 + 末 5 行查 `#VALUE!` / `#NAME?` / `#REF!` / `#DIV/0!`;同一方案试错上限 3 次,超了改代码以值写入。 -- **循环引用**:聚合公式(SUM/AVERAGE)引用范围不能含目标 cell 自身或其传递依赖。 -- **NaN / 空值 / 除零**:空值不直接参与运算;除法用 `IF` / `IFERROR` 防零。 -- **排序 / 筛选混合文本列**:带货币符 / 单位 / 表达式的文本列直接排序 / 筛选会按字典序出错,先抽数值到辅助列再处理(细则见 `lark-sheets-range-operations` / `lark-sheets-filter`)。 -- **隐藏行列**:`+csv-get` 默认 `--skip-hidden=false`(含隐藏行列);设 `true` 只看可见数据,但返回行序号与实际行号不再对应。 -- **行号一律取 `[row=N]` 前缀**:`+csv-get` 的 CSV 中双引号内换行是单元格内换行不是新行;禁止数 `\n`、禁止用"序号列"当行号(细则见 `lark-sheets-read-data`)。 -- **列字母取 `col_indices[j]`**:禁止手数表头逗号定位列(>10 列极易 off-by-one)。 -- **跨 sheet 对象**:图表 / 条件格式 / 透视表 / 浮动图片可能分布在多个子表,操作前先 `+workbook-info` 掌握全局。 -- **`+cells-search` 不是万能**:用户说"汇总金额"是操作动作(求和),不是搜索该文本;只在确需定位某文本位置时才用。 - -## 特殊场景 - -### 续写 / 复制已有区块格式 - -核心要求见铁律 5。机制(带齐哪些样式字段、怎么采样写入)见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」;样式标准(斑马纹奇偶 / 配色 / 边框层级)见 `lark-sheets-visual-standards` 场景二。本文不再展开。 - -### NLP 任务处理 - -任务涉及语义理解、翻译、改写、摘要、分类、抽取、多行聚合时,以 NLP 方式处理,不要用纯规则代码替代语义理解(但可用代码做分批、行号映射、结果拼装与写回)。数据量大时**必须**分批(通常 30 行一批),每批处理完立即写回,不要全处理完再一次写入;单批生成通常不超 300 行,超出时按性质抽样或分批并向用户说明范围;多批写入优先用 `+batch-update` 合并为原子提交。 - -### 格式处理优先公式 - -"去除多余零 / 提取数字 / 文本格式转换 / 日期格式化"等清洗,**必须优先用公式**(`SUBSTITUTE` / `TEXT` / `VALUE` / `LEFT` / `RIGHT` / `MID` 等):写一个模板 + `--copy-to-range` 即可整列处理,远比逐行修改高效。 diff --git a/skills/lark-sheets/references/lark-sheets-filter-view.md b/skills/lark-sheets/references/lark-sheets-filter-view.md index 1c9595609..919cc8045 100644 --- a/skills/lark-sheets/references/lark-sheets-filter-view.md +++ b/skills/lark-sheets/references/lark-sheets-filter-view.md @@ -50,7 +50,7 @@ _公共四件套 · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--properties` | string + File + Stdin(复合 JSON) | required | 筛选视图规则 JSON,含 `rules?`(列级筛选规则数组)和 `filtered_columns?`。`range` 和 `view_name` 是独立 flag | | `--range` | string | required | 筛选视图作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段;create 必填,必须覆盖表头行 | -| `--view-name` | string | optional | 筛选视图名称;create 不传时系统自动分配,update 不传时保留原名;优先级高于 `--properties` 中同名字段 | +| `--view-name` | string | optional | 筛选视图名称;不传时系统自动分配;优先级高于 `--properties` 中同名字段 | ### `+filter-view-update` diff --git a/skills/lark-sheets/references/lark-sheets-float-image.md b/skills/lark-sheets/references/lark-sheets-float-image.md index 0d63679d5..7c8716251 100644 --- a/skills/lark-sheets/references/lark-sheets-float-image.md +++ b/skills/lark-sheets/references/lark-sheets-float-image.md @@ -29,9 +29,9 @@ - **`--image <本地路径>`(首选,最省事)**:直接给本地图片文件路径(PNG/JPEG/GIF/BMP/HEIC 等)。CLI 会自动把它以 `parent_type=sheet_image` 上传,拿到 file_token 后创建浮动图,**不用你手动上传 / 取 token**。路径规则同其它本地文件 flag:必须是当前工作目录内的相对路径(绝对路径会被 Validate 拒,`--dry-run` 也会拦)。 - `--image-token`:复用**已存在**的图片 file_token。常见来源:① `+float-image-list` 返回的 `image_token`(适合"换皮不换位置"复用同一张图);② `+cells-set-image` 成功返回里的 `file_token`(它也是 `sheet_image` 上传句柄)。适合"同一张图复用到多处",省去重复上传。 -- `--image-uri`:图片 reference_id(image URI),由系统自动转 file_token。 +- `--image-uri`:图片 URI(上传链路返回的句柄),**非**表内对象 reference_id;由系统自动转 file_token。 -> ⚠️ **`--image` 仅 `+float-image-create` 支持**。`+float-image-update` 换图仍只接受 `--image-token` / `--image-uri`,而且**图片源是 update 唯一可省的部分**——三者全不传则保留原图。但 `--image-name` / `--position-{row,col}` / `--size-{width,height}` 在 update 时和 create 一样**必填**(`+float-image-update` 强制要求这套核心字段,且 `+float-image-list` 不回传 `image_name` 供 CLI 回填)。要在 update 里换一张本地新图,先用 `+cells-set-image` 上传到任意临时单元格、从返回取 `file_token`,再把它传给 update 的 `--image-token`。 +> ⚠️ **`--image` 仅 `+float-image-create` 支持**。`+float-image-update` 换图仍只接受 `--image-token` / `--image-uri`,而且**图片源是 update 唯一可省的部分**——三者全不传则保留原图。但 `--image-name` / `--position-{row,col}` / `--size-{width,height}` 在 update 时和 create 一样**必填**(`+float-image-update` 强制要求这套核心字段,且 `+float-image-list` 不回传 `image_name` 供 CLI 回填)。要在 update 里换一张本地新图,先用 `+cells-set-image` 上传到任意临时单元格、从返回取 `file_token`,再把它传给 update 的 `--image-token`;用完清除该临时单元格,避免残留多余图片。 ## Shortcuts @@ -60,7 +60,7 @@ _公共四件套 · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--image-name` | string | required | 图片名称,含扩展名(如 `logo.png`) | | `--image-token` | string | xor | 图片 file_token(与 `--image-uri` 二选一)。常见来源:`+float-image-list` 返回的 `image_token` | -| `--image-uri` | string | xor | 图片 reference_id(与 `--image-token` 二选一);图片上传链路返回的 reference_id | +| `--image-uri` | string | xor | 图片 URI(上传链路返回的句柄,非表内对象 reference_id;与 `--image-token` 二选一);系统自动转换为 file_token | | `--position-row` | int | required | 图片左上角所在行(0-based) | | `--position-col` | string | required | 图片左上角所在列(列字母,如 `A` / `B`) | | `--size-width` | int | required | 图片宽度(像素) | @@ -78,8 +78,8 @@ _公共四件套 · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--float-image-id` | string | required | 目标图片 id | | `--image-name` | string | required | 图片名称,含扩展名(如 `logo.png`) | -| `--image-token` | string | xor | 图片 file_token(与 `--image-uri` 二选一)。常见来源:`+float-image-list` 返回的 `image_token` | -| `--image-uri` | string | xor | 图片 reference_id(与 `--image-token` 二选一);图片上传链路返回的 reference_id | +| `--image-token` | string | optional | 可选图片 file_token;与 `--image-uri` 互斥,二者均省略时保留原图。常见来源:`+float-image-list` 返回的 `image_token` | +| `--image-uri` | string | optional | 可选图片 URI(上传链路返回的句柄,非表内对象 reference_id);与 `--image-token` 互斥,二者均省略时保留原图;系统自动转换为 file_token | | `--position-row` | int | required | 图片左上角所在行(0-based) | | `--position-col` | string | required | 图片左上角所在列(列字母,如 `A` / `B`) | | `--size-width` | int | required | 图片宽度(像素) | @@ -122,7 +122,7 @@ lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \ --image-name "logo.png" --image-token "$TOKEN" \ --position-row 0 --position-col A --size-width 200 --size-height 150 -# 用 reference_id(图片上传链路返回的 image reference_id;与 --image-token 二选一) +# 用 image URI(上传链路返回的句柄,非表内对象 reference_id;与 --image-token 二选一) lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \ --image-name "logo.png" --image-uri "$IMAGE_URI" \ --position-row 2 --position-col B --size-width 300 --size-height 200 --z-index 1 diff --git a/skills/lark-sheets/references/lark-sheets-formula-translation.md b/skills/lark-sheets/references/lark-sheets-formula-translation.md index ddbbd186f..1b1ec8cc9 100644 --- a/skills/lark-sheets/references/lark-sheets-formula-translation.md +++ b/skills/lark-sheets/references/lark-sheets-formula-translation.md @@ -1,14 +1,14 @@ # 飞书表格公式生成规则 > **本文定位**:飞书公式正确性的**唯一权威**——书写任何飞书公式、或把 Excel 公式迁移到飞书前,先读本文。涵盖公式书写约定(绝对引用、范围语法)、投影 vs spill、ARRAYFORMULA / 数组语义、高风险引用函数、日期差、不支持函数清单。 -> **边界**:本文只讲"公式怎么写对";公式**怎么写入表格**(`+cells-set` / 模板单元格 + `--copy-to-range` / 容错回读)见 `lark-sheets-write-cells` 与 `lark-sheets-core-operations`。本文不含 shortcut,铁律见 `lark-sheets-core-operations`。 +> **边界**:本文只讲"公式怎么写对";公式**怎么写入表格**(`+cells-set` / 模板单元格 + `--copy-to-range` / 容错回读)见 `lark-sheets-write-cells`。**公式写入完成后的强制收尾**见 `lark-sheets-formula-verify`:不要把"翻译对了"误当成"已经交付完成"。本文不含 shortcut,通用编辑准则见主 SKILL.md「飞书表格编辑准则」。 **核心原则:飞书不像 Excel 365 那样默认 spill(溢出展开)。飞书普通公式遇到区域时默认"投影"(只取当前行/列对应的单个值),必须显式使用 `ARRAYFORMULA` 或原生数组函数才能逐项展开。** ## 公式书写约定(写任何公式都先满足) - **绝对引用 `$`**:向下 / 向右填充前判断哪些引用要锁定——用户指定的固定 cell(`$C$3`)、要固定的数据范围(`$A$2:$B$5`)、锁列不锁行(`$A2`)、锁行不锁列(`B$1`)。填充前检查是否需固定汇率 / 税率 / 查找表 / 权重表,以及同列 / 同行公式结构是否一致。 -- **公式字符串用飞书范围语法**:写 `H:H`、`A2:B5`,**禁止** `H2:H` / `2:2`。这与 CLI 工具参数(如 `--range`)的 A1 表示法(`A1:D3`、`1:1`)写法不同,两者混淆会导致调用失败或公式报错。 +- **公式字符串用飞书范围语法**:写 `H:H`、`A2:B5`,**禁止** `H2:H` / `2:2`。要在公式里引用整行,用显式范围(如 `$A2:$Z2`)替代禁用的 `2:2`。这与 CLI 工具参数(如 `--range` / `--copy-to-range`)的 A1 表示法写法不同:参数侧合法的 `D3:D`、`1:1`、`3:6` 在公式串里反而非法。**公式串 ≠ CLI 参数**,两套规则别互相照搬,混用会导致调用失败或公式报错。 ## 翻译后必做:代码复现校验 @@ -21,6 +21,14 @@ **理由**:Excel→飞书的语法翻译很容易在 spill / 数组 / 日期差 / 范围引用上出现等价性偏差,仅靠语法转换通过不足以保证业务结果正确。 +## 落表后的默认交接 + +本文解决的是"公式怎么写对",不是"写进表里后一定能零错误运行"。因此: + +1. 按本文完成公式改写后,用 `lark-sheets-write-cells` / `lark-sheets-batch-update` 把公式真实写入表格。 +2. 公式一旦落表,就默认进入 `lark-sheets-formula-verify` 的收尾阶段。 +3. 最终必须跑 `+formula-verify` 收敛到 `status='success'`;`errors_found` / `partial` 都不算完成。 + ## 决策流程 1. 最终结果是**标量**(单值)→ 通常不需要 `ARRAYFORMULA` @@ -224,7 +232,7 @@ Excel:`{=A1:A10*B1:B10}`(Ctrl+Shift+Enter 输入) ## 飞书不支持的函数 -> 本段是"飞书不支持函数"的**唯一权威清单**(`lark-sheets-core-operations` 不再单列,统一指向这里)。以下函数在飞书里不存在或被禁用,禁止主动使用;用户明确要求时应拒绝并提供替代方案: +> 本段是"飞书不支持函数"的**唯一权威清单**。以下函数在飞书里不存在或被禁用,禁止主动使用;用户明确要求时应拒绝并提供替代方案: - `STOCKHISTORY` — 实时股票数据,飞书无等价函数,需手动导入数据 - `WEBSERVICE` — 外部 HTTP 请求,飞书无等价函数 @@ -234,6 +242,7 @@ Excel:`{=A1:A10*B1:B10}`(Ctrl+Shift+Enter 输入) - `INFO`、`RTD` — 系统信息 / 实时数据函数,飞书不支持 - `PIVOT` — 用 `+pivot-{create|update|delete}` 透视表对象替代 - `AMORDEGRC`、`PHONETIC`、`DETECTLANGUAGE` — 飞书不支持 +- `LET`、命名自定义函数(名称管理器里定义的 LAMBDA)、独立调用的 `LAMBDA`(如 `=LAMBDA(x,x+1)(5)`)— 会报 `#NAME?`;改用嵌套 IF / 辅助列。**例外**:`LAMBDA` 作为 `MAP` / `REDUCE` / `BYROW` / `BYCOL` / `SCAN` / `MAKEARRAY` 的内联参数时**支持**(见上方「飞书原生数组函数清单」) ## 代表性改写示例 diff --git a/skills/lark-sheets/references/lark-sheets-formula-verify.md b/skills/lark-sheets/references/lark-sheets-formula-verify.md new file mode 100644 index 000000000..cac72fd34 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-formula-verify.md @@ -0,0 +1,77 @@ +# Lark Sheet Formula Verify(+formula-verify) + +> **本文定位**:飞书表格"公式写入后是否真的零错误"的自检入口,也是所有写公式任务的**强制收尾步骤**。公式的书写规则与 Excel→飞书迁移的语义规则一律以 `lark-sheets-formula-translation` 为唯一权威,本文不重复;本文聚焦"写完了之后怎么用一次调用确认 zero-error"。 +> +> **边界**:本文不讲公式怎么写(去 `lark-sheets-formula-translation`),也不讲公式怎么写入表格(去 `lark-sheets-write-cells` / `lark-sheets-batch-update`)。本文只讲一件事:**只要任务里发生了公式落表、批量填充公式、`--copy-to-range` 扩展公式、导入含公式 workbook,收尾就必须用 `+formula-verify` 自检到 zero-error 才能交付**。 + +## 为什么需要自检 + +飞书在线表格已经实时算好结果,但"算出来"和"算对了"是两件事。常见缺口: + +- 公式编译失败 → 单元格落成文本(写入类 shortcut 返回的 `formula_errors[]` 是**编译失败**信号)。 +- 公式编译成功但**运行时错误**:`#REF!` / `#DIV/0!` / `#VALUE!` / `#NAME?` / `#NULL!` / `#NUM!` / `#N/A`——这一类只看 `formula_errors[]` 看不到,必须扫单元格值。 + +`+formula-verify` 把两路信号合并成一份统一 JSON:一次调用聚合全表错误清单 + 编译失败清单 + 每类错误的定位与样本,AI 一眼就能定位修复,链路也能据 `status` 强制收敛到 `success`。 + +## 调用契约 + +最小调用形态: + +| 入参 | 含义 | +|---|---| +| `--url` / `--spreadsheet-token` | 表格定位(XOR 二选一,必填) | +| `--sheet-id` / `--sheet-name` | 限定子表(mutually exclusive;省略则扫全部可见子表) | +| `--range` | 限定 A1 范围;省略则用各 sheet 的 `current_region` | +| `--max-locations` | 每类错误样本上限,默认 20 | +| `--exit-on-error` | `status='errors_found'` 时返回非 0 退出码(CI 网关用) | + +返回核心字段: + +- `status` ∈ `success` / `errors_found` / `partial`——**唯一可机读的健康度判据**。 +- `total_errors` / `total_formulas` / `scanned_cells`——本次扫描规模指标。 +- `has_more`——为 true 表示扫描被内部上限截断(详见后文「截断与续读」),未覆盖完整范围。 +- `error_summary[<错误类型>]`——每类错误的 `count` / `locations[]` / `samples[].{address,formula,depends_on}`。 +- `compile_errors[]`——合并最近一次写入留下的编译失败清单,与运行时错误并存时同时出现。 +- `warning_message`——仅在 `has_more=true` 时出现,告知调用方需要缩小 `--range` / 拆 `--sheet-id` 续读。 + +## 写入收尾收敛规则 + +任何批量公式 / 含公式列写入完成后调用 `+formula-verify` 直到 `status='success'` 才能交付。不要等用户显式说"校验一下公式"才想到这里;**只要任务动作包含写公式,这一步默认就该做**。触发场景: + +- `+cells-set` / `+csv-put` +- `+cells-set --copy-to-range` / 模板单元格向整列或整块扩展公式 +- `+workbook-import` +- `+batch-update` 中含写入子操作 +- `+table-put`(任意列含公式时) +- `+workbook-import`(导入的 xlsx 含公式时) + +收敛规则: + +1. `status='success'` → 通过;可以把链路标完成。 +2. `status='partial'` → 扫描被内部上限截断。先缩小 `--range` 或拆 `--sheet-id` 续扫,**不允许**把 `partial` 当作 `success`。 +3. `status='errors_found'` 且 `compile_errors[]` 非空 → **先解决编译失败**:根据 `compile_errors[].reason` 修正公式语法(飞书函数名 / 范围语法 / 引用样式),用 `+cells-set` 重写后再调一次 `+formula-verify`。 +4. `status='errors_found'` 且只剩运行时错误 → 按 `error_summary` 的 `samples[].formula` + `depends_on` 排查根因(零除?空值参与运算?引用越界?日期差写法?数组语义?),修复后重新自检。 +5. 同一处错误连续修复 3 次仍未通过 → 改用 `IFERROR` 包裹兜底,或退回纯值写入;不要在 `errors_found` 状态下扩展 `+cells-set --copy-to-range`、追加批量写入。 + +注意: + +- 在 `status='errors_found'` 的状态下调用 `+cells-set --copy-to-range` 继续扩展会把错误复制放大。 +- "编译失败但运行时无报错"不是 zero-error(编译失败的单元格此刻是文本不是公式,源数据一变就再也算不出值)。 +- 跳过自检直接交付、靠肉眼读首末 5 行确认是不可靠的——表中段、隐藏行、合并区里的错误这样根本看不到。 + +## 截断与续读 + +后端有一个内部硬上限对总扫描单元格数做截断(不暴露给调用方),超过后立即返回 `has_more=true` + `warning_message`,`error_summary` / `compile_errors` 仅覆盖已扫描部分。处理路径: + +- 把工作簿按 `--sheet-id` / `--sheet-name` 拆成多次调用。 +- 同 sheet 内按 `--range` 切片(如先 `A1:Z200` 再 `AA1:AZ200`),逐块自检。 +- 每块都跑到 `has_more=false` 且 `status='success'` 才算通过。 + +## 常见陷阱 + +| 坑 | 应对 | +|---|---| +| 错误字符串本地化 | 后端按内部 `error_kind` / `compute_status` 字段识别错误类别,不走字符串匹配;调用方拿到的 7 类英文错误代码由后端统一规范输出,与 locale 无关。 | +| `formatted_value` 可能隐藏错误 | 某些条件格式 / 自定义数字格式会把 `#DIV/0!` 显示成空白。后端直接读 cell `error_kind`,不依赖 `formatted_value`,绕开此类被遮蔽。 | +| 把 `partial` 当 `success` | `partial` 仅表示**已扫描部分**无错误,剩余区域未知。必须续扫直到 `has_more=false` 且 `status='success'` 才能算通过。 | +| 编译失败 vs 运行时错误 | 同一份报告里 `compile_errors[]` 与 `error_summary` 并存。语义层先解决 `compile_errors[]`、再做运行时自检。 | diff --git a/skills/lark-sheets/references/lark-sheets-history.md b/skills/lark-sheets/references/lark-sheets-history.md new file mode 100644 index 000000000..598712a71 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-history.md @@ -0,0 +1,93 @@ +# Lark Sheet History + +## 概念回顾 + +每张飞书电子表格保留一串历史版本(`minor_histories`)。每个版本由 `history_version_id` 标识,并附带创建时间(`create_time`)、动作(`action`)与块修订信息(`all_block_revision`)。历史是**工作簿级**的(针对整张电子表格,不针对单个子表)。 + +回滚(revert)把电子表格的当前内容覆盖回某个历史版本——这是一个**高风险写入**操作,且为**异步**:发起后立即返回受理标识,真正的回滚在后台进行,需通过状态查询轮询最终结果(进行中 / 成功 / 失败)。 + +`+history-list` 读取版本列表以挑选目标;`+history-revert` 发起回滚;`+history-revert-status` 轮询回滚结果。若只是想拿**当前文档版本号(revision)**当作 recover / undo / `+changeset-get` 的起点锚点,直接用 `+revision-get` 更轻量。 + +## 使用场景 + +读取历史版本、发起回滚、查询回滚状态。本 reference 覆盖 3 个 shortcut: + +| 操作需求 | 使用工具 | 说明 | +|---------|---------|------| +| 查看历史版本列表 | `+history-list` | 返回 `minor_histories`,每条含 `history_version_id` / `create_time` / `action` / `all_block_revision` 四个字段;支持向前分页(可选 `--end-version`) | +| 回滚到指定历史版本 | `+history-revert` | 传入 `--history-version-id`;异步受理,返回可查询标识 | +| 查询回滚状态 | `+history-revert-status` | 传入 `--transaction-id`(取自 `+history-revert` 的异步受理标识);轮询某次回滚的进行中 / 成功 / 失败状态 | + +典型工作流:`+history-list` 拿到目标版本的 `history_version_id`(必要时翻页拉取更早历史)→ `+history-revert` 发起回滚并取回 `transaction_id` → `+history-revert-status --transaction-id ` 轮询直到成功或失败。 + +**注意事项(必须了解)**: +- **回滚是高风险写入操作**:会用历史版本内容覆盖当前表格,执行前应明确告知用户影响。 +- **回滚是异步的**:`+history-revert` 返回的是 `transaction_id`(受理标识),不代表回滚已完成;必须用 `+history-revert-status --transaction-id ` 确认最终结果。 +- **`history_version_id` 与 `transaction_id` 不是同一个**:`history_version_id` 用于 `+history-revert`(取自 `+history-list`);`transaction_id` 用于 `+history-revert-status`(取自 `+history-revert` 的输出)。 +- **历史是工作簿级**:定位只需 `--url` / `--spreadsheet-token`(XOR),不需要子表选择器。 +- **`+history-list` 倒序分页**:首次查省略 `--end-version`,返回最新一页;若响应里附带 `next_end_version` 与 `has_more=true`,把 `next_end_version` 作为下一次的 `--end-version` 即可继续向更早翻页;当响应**不包含**这两个字段时表示已到最早一页,不必再翻。 + +## Shortcuts + +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+history-list` | read | 历史版本 | +| `+history-revert` | high-risk-write | 历史版本 | +| `+history-revert-status` | read | 历史版本 | + +## Flags + +### `+history-list` + +_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--end-version` | int | optional | 分页查询的最大版本(倒序);首次查询省略,下一页传上一页返回的 next_end_version。 | + +### `+history-revert` + +_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--history-version-id` | string | required | 要回滚到的历史版本(取自 +history-list) | + +### `+history-revert-status` + +_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--transaction-id` | string | required | 异步回滚的受理标识(取自 +history-revert) | + +## Examples + +公共定位:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token`(XOR,二选一)。`+history-revert` 用 `--history-version-id`(取自 `+history-list`);`+history-revert-status` 用 `--transaction-id`(取自 `+history-revert` 的异步受理标识)。 + +### `+history-list` + +```bash +# 列出某张电子表格的最新一页历史版本 +lark-cli sheets +history-list --url "https://sample.feishu.cn/sheets/SHTxxxxxx" + +# 用原始 spreadsheet token 定位 +lark-cli sheets +history-list --spreadsheet-token "SHTxxxxxx" + +# 翻到下一页:把上次响应里的 next_end_version 作为 --end-version 传入 +lark-cli sheets +history-list --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --end-version 12345 +``` + +### `+history-revert` + +```bash +# 回滚到指定历史版本(异步受理) +lark-cli sheets +history-revert --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --history-version-id "" +``` + +### `+history-revert-status` + +```bash +# 查询某次回滚的当前状态(进行中 / 成功 / 失败) +lark-cli sheets +history-revert-status --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --transaction-id "" +``` diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md index dad7a1bb5..3e5d1051a 100644 --- a/skills/lark-sheets/references/lark-sheets-pivot-table.md +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -32,9 +32,10 @@ **常见配置错误(必须注意)**: - **数据源范围必须精确**:透视表的数据源范围必须包含表头行,且精确覆盖全部数据行列。范围过大(包含空行/空列)或过小(遗漏数据列)都会导致透视表结果错误 - **行列字段选择要匹配用户意图**:用户说"按商品统计金额"→ 行字段=商品,值字段=金额(`summarize_by: "sum"`)。不要把行列字段搞反 -- **聚合类型要匹配**:用户说"统计数量"→ `summarize_by: "count"`;"统计总额"→ `"sum"`;"统计平均"→ `"average"`。完整合法值:`sum` / `count` / `average` / `max` / `min` / `product` / `countNums` / `stdDev` / `stdDevp` / `var` / `varp` / `distinct` / `median`。默认不要用 `count` 替代 `sum` +- **聚合类型要匹配**:用户说"统计数量"→ `summarize_by: "count"`;"统计总额"→ `"sum"`;"统计平均"→ `"average"`。完整合法值:`sum` / `count` / `average` / `max` / `min` / `product` / `countNums` / `stdDev` / `stdDevp` / `var` / `varp` / `distinct` / `median`。按用户意图选聚合方式,不要拿 `count` 顶替 `sum` - **参数长度限制**:如果透视表配置 JSON 过长(数据源范围跨越大量行列),可能导致工具调用失败。此时应先确认数据范围的精确边界,避免传入过大的 range -- **创建后必须验证**:调用 `+pivot-list` 确认透视表结构正确 +- **落点不能覆盖任何已有数据(不只是 `--source` 范围)**:透视表创建后会向右下**展开**,展开区域哪怕只盖到一个已有单元格(即便已避开源数据),也会报「目标位置不能与数据源重叠」并产生 `#REF!`。创建前无法精确预知展开尺寸,故**强烈优先默认策略**(不传 `--target-sheet-id/-name` 与 `--target-position`/`--range`,后端自动新建空白子表),零覆盖风险;非要落到已有子表,必须挑一片足够大的纯空白区 +- **创建后必须校验(用 `info` 读取展开后的真实占用区域)**:创建后调用 `+pivot-list` 读 `info.error_state` 与 `info.content_range`/`page_range`——`error_state` 非 `None`(如 `Cover` 盖到其它内容 / `Shrink` 展不开)说明落点冲突,应删除后重建到空白区;`content_range`/`page_range` 是展开后**实际占用区域**,可用 `+csv-get` 抽查其边缘外有没有盖掉原有数据,确认结构正确 ## Shortcuts @@ -120,6 +121,10 @@ _创建/更新的透视表属性_ lark-cli sheets +pivot-list --url "..." --sheet-id "$SID" ``` +> **返回值含 `info`(展开后的占用区域与状态)**:每个透视表对象除 `position` / `snapshot` 外,还返回 `info`,标明它在 sheet 上的平铺区域与状态——`info.page_range`(筛选/分页区 A1)、`info.content_range`(主体数据区 A1)、`info.span_range`(空表合并区 A1)、`info.error_state`(错误状态,如 `None`/`Cover`/`Shrink`/`Loading`)、`info.is_empty` / `info.is_hidden`、`info.row`/`info.col`(锚点)等。 +> **用途 1(判断改值还是改配置)**:当用户描述某个单元格要改动时,先 `+pivot-list` 拿到 `info`,判断该单元格是否落在 `page_range` / `content_range` 内——**落在区域内 = 属于透视表,应走 `+pivot-update` 改配置**(透视表单元格不能直接 `+cells-set` 改值);**落在区域外 = 普通单元格,正常 `+cells-set` 改值**。 +> **用途 2(创建后校验覆盖)**:建完透视表用 `info.error_state` 判断有没有冲突(非 `None` 即落点/展开区与已有数据重叠或展不开),用 `info.content_range`/`page_range` 拿到展开后真实占用区域再核对是否盖到原有数据。 + ### `+pivot-create` > 数据源 `--source` 必须从表头行开始;空行 / 汇总行会被当作数据参与聚合,需提前用 `+csv-get` 确认起止边界。`--source` 和 `--range` 是独立 flag(不要再放 `--properties`);`rows` / `columns` / `values` 等数组字段走 `--properties`。 diff --git a/skills/lark-sheets/references/lark-sheets-range-operations.md b/skills/lark-sheets/references/lark-sheets-range-operations.md index e24e7f9d1..0d29c59dd 100644 --- a/skills/lark-sheets/references/lark-sheets-range-operations.md +++ b/skills/lark-sheets/references/lark-sheets-range-operations.md @@ -22,6 +22,7 @@ 注意: +- **`--range` 两种语法别混**:`+cells-clear` / `+cells-{merge|unmerge}` / `+range-*` 用单元格 A1 矩形(如 `A2:A10`);`+rows-resize` / `+cols-resize` 用纯行 / 列区间(行 `2:10`、列 `A:C`),不要给 resize 传 `A2:A10` - 用户说"这行 / 整行 / 首行"时,优先使用整行范围如 `1:1`;"这列 / 整列"时使用 `J:J`。不要截断为局部矩形 - 合并后只保留左上角单元格的内容,其余清除。写入合并区域用 `+cells-set` 对左上角单元格操作 - 调整行高列宽时,先读取相邻行列尺寸再决定像素值,不要随意猜测 @@ -35,7 +36,7 @@ 2. **判定阈值**:当前列宽(用 `+sheet-info --include row_heights,col_widths` 拿)≥ 最长字符数 × 字体宽度系数 + buffer 才算适配。默认列宽 11 通常只够 11 个半角字符或 5-6 个汉字,写长文本前必扩宽。 3. **修复二选一**: - **扩列宽**:用 `+rows-resize / +cols-resize` 把目标列宽设为 `max(表头字符数, 内容采样最长字符数) × 8 + 16` 像素(经验值) - - **自动换行**:在 `+cells-set` 时给单元格设置 `cell_styles.word_wrap="auto-wrap"`(可选值:`overflow` / `auto-wrap` / `word-clip`),并用 `+rows-resize / +cols-resize` 调高对应行的行高 + - **自动换行**:在 `+cells-set` 时给单元格设置 `cell_styles.word_wrap="auto-wrap"`(可选值:`overflow` / `auto-wrap` / `word-clip`;`cell_styles` 字段见 `lark-sheets-write-cells`),并用 `+rows-resize / +cols-resize` 调高对应行的行高 4. **新增列默认列宽规则**:新增列宽度 ≥ `max(表头字符数, 内容采样最长字符数) × 8 + 16` 像素,**禁止**用默认 11 直接交付。 **典型反例**:默认列宽 11 但内容含 12+ 字符的中文 / 含单位的数值(如 `109.10μmol/L`)/ 长数字未设 `number_format` 显示为科学计数法 —— 用户在结果表里看不到完整原值。 @@ -53,7 +54,7 @@ 5. **新增合并时数据保护**:合并前确认目标区域只有左上角有数据,其余单元格为空,否则合并会导致非左上角的数据丢失。 6. **批量取消合并一次调用即可**:当一个范围(整列 `A:A`、整行 `3:3`、矩形 `A1:D100`)内存在多个合并区域,直接调一次 `+cells-unmerge` 传入这个大范围,会一次性取消该范围内所有合并区域;**不要**为每个合并区域单独调用 unmerge,也不要用 `+batch-update` 拆成多次 unmerge。 -**⚠️ 批量操作必须用 `+batch-update`**:对**多个**不同区域执行 `+cells-merge` 或 `+rows-resize / +cols-resize` 时,禁止逐个调用,合并为单次原子 `+batch-update`(语义与 `--operations` 入参格式见 `lark-sheets-batch-update`)。 +**⚠️ 批量操作必须用 `+batch-update`**:对**多个**不同区域执行 `+cells-merge` 时,禁止逐个调用,合并为单次原子 `+batch-update`(语义与 `--operations` 入参格式见 `lark-sheets-batch-update`)。行高列宽**不需要** `+batch-update`:多行 / 多列不同尺寸直接用 `+rows-resize --heights` / `+cols-resize --widths` 的 map 形态,一次调用原子完成。 **唯一例外**:`+cells-unmerge` 原生支持传一个大 range 一次性取消其中所有合并区域,应直接单次调用,**不要**拆进 `+batch-update`。 @@ -127,9 +128,10 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--type` | string | required | 尺寸方式 enum:`pixel`(指定 px 像素值,需配 `--size`)/ `standard`(重置为默认标准行高)/ `auto`(自动适应内容)(可选值:`pixel` / `standard` / `auto`) | -| `--size` | int | optional | 行高(像素,例:30 / 40 / 60);`--type pixel` 时必填,其它 type 忽略 | -| `--range` | string | required | 要调整行高的行闭区间;1-based 行号如 `2:10` 或单行 `5` | +| `--height` | int | xor | 统一行高(像素,例:30 / 40 / 60;不是磅/points),配 `--range` 使用。传了 `--height` 就是像素模式,可以省略 `--type`;显式 `--type pixel` 也行(等价)。多行不同高用 `--heights` | +| `--heights` | string + File + Stdin(复合 JSON) | xor | 差异化行高 map,一次原子调用给多行设置不同高度:键为单行(`"1"`)或行闭区间(`"2:20"`),值为像素高(如 30 / 50)、`"auto"`(自适应内容)或 `"standard"`(重置默认)。⚠️ 单位是像素,不是磅/points。与 `--range` / `--height` / `--type` 互斥 | +| `--type` | string | xor | 尺寸方式 enum:`pixel`(需配 `--height`)/ `standard`(重置为默认行高)/ `auto`(自动适应内容)。常规写法直接给 `--height` 即可省略本 flag;`--type standard` / `--type auto` 不能与 `--height` 同时给(可选值:`pixel` / `standard` / `auto`) | +| `--range` | string | xor | 要调整行高的行闭区间;1-based 行号如 `2:10` 或单行 `5`。统一尺寸形态必填(配 `--height` 或 `--type`);map 形态(`--heights`)不传 | ### `+cols-resize` @@ -137,9 +139,10 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--type` | string | required | 尺寸方式 enum:`pixel`(指定 px 像素值,需配 `--size`)/ `standard`(重置为默认标准列宽)(可选值:`pixel` / `standard`) | -| `--size` | int | optional | 列宽(像素,例:80 / 120 / 200);`--type pixel` 时必填,其它 type 忽略 | -| `--range` | string | required | 要调整列宽的列闭区间;列字母如 `A:E` 或单列 `C` | +| `--width` | int | xor | 统一列宽(像素,例:80 / 120 / 200;不是 Excel 字符单位),配 `--range` 使用。传了 `--width` 就是像素模式,可以省略 `--type`;显式 `--type pixel` 也行(等价)。多列不同宽用 `--widths` | +| `--widths` | string + File + Stdin(复合 JSON) | xor | 差异化列宽 map,一次原子调用给多列设置不同宽度:键为单列(`"A"`)或列闭区间(`"C:E"`),值为像素宽(如 80 / 120 / 200)或 `"standard"`(重置默认)。⚠️ 单位是像素,不是 Excel 字符单位(像素 ≈ 字符数×8+16)。与 `--range` / `--width` / `--type` 互斥 | +| `--type` | string | xor | 尺寸方式 enum:`pixel`(需配 `--width`)/ `standard`(重置为默认列宽)。常规写法直接给 `--width` 即可省略本 flag;`--type standard` 不能与 `--width` 同时给(可选值:`pixel` / `standard`) | +| `--range` | string | xor | 要调整列宽的列闭区间;列字母如 `A:E` 或单列 `C`。统一尺寸形态必填(配 `--width` 或 `--type`);map 形态(`--widths`)不传 | ### `+range-move` @@ -186,6 +189,16 @@ _公共四件套 · 系统:`--dry-run`_ > 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。 +### `+rows-resize` `--heights` + +_行 → 高度 map_ +- type: object + +### `+cols-resize` `--widths` + +_列 → 宽度 map_ +- type: object + ### `+range-sort` `--sort-keys` _排序条件列表(仅 sort 操作)_ @@ -202,6 +215,8 @@ _排序条件列表(仅 sort 操作)_ ### `+cells-clear` +> ⚠️ **`--scope all` 清整表是不可逆的大范围破坏**:会一并抹掉该区域的合并单元格、原公式,以及图表 / 透视表引用的数据源列(这类列常在主数据区右侧,视觉上"看着没用"却被图例 / 系列引用)。**"美化 / 规范化一张已有表"永远不需要 clear 原表再重写**——若你打算"清空原表 → 写入重排后的版本",说明走错了路径,应改为原地只刷样式(见 `lark-sheets-visual-standards` 场景三)。 + > **删不掉嵌入对象**:`+cells-clear`(任何 `--scope`,含 `all`)只清单元格的值 / 格式,**删不掉**压在范围内的透视表 / 图表等嵌入对象——后端会报 `can not find embedded block`。删透视表用 `+pivot-delete`、删图表用 `+chart-delete`(先用 `+pivot-list` / `+chart-list` 拿对象 id)。 > 需要一次清除**多个不连续 range**(如把内容搬走后批量去掉散落各处的边框/底色)时,改用 `lark-sheets-batch-update` 的 `+cells-batch-clear`,避免对 `+cells-clear` 逐个 range 调用。 @@ -224,14 +239,25 @@ lark-cli sheets +cells-unmerge --url "..." --sheet-id "$SID" --range "A1:C100" ### `+rows-resize` / `+cols-resize` -行高列宽分两条 shortcut,避免行 / 列在底层 schema 的差异(行支持 `auto`,列不支持)混在一起。每条 `--type` 必填: +行高列宽分两条 shortcut,避免行 / 列在底层 schema 的差异(行支持 `auto`,列不支持)混在一起。两种形态: + +- **统一尺寸**:`--range` + `--height`/`--width `(省略 `--type`,等价于 `--type pixel`)。非像素模式走 `--type standard` / `--type auto`,此时不能再带像素值。 +- **差异化尺寸**:`--heights`/`--widths` 一个 JSON map,键为单行/列或闭区间、值为像素或模式字符串,**一次调用原子完成多行 / 多列不同尺寸**——不要拆多次调用,也不要用 `+batch-update`。 ```bash -# 把第 2-10 行设为固定 30 px -lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --range "2:10" --type pixel --size 30 +# 统一尺寸:把第 2-10 行设为固定 30 px +lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --range "2:10" --height 30 -# 把 A-C 列设为固定 120 px -lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --range "A:C" --type pixel --size 120 +# 统一尺寸:把 A-C 列设为固定 120 px +lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --range "A:C" --width 120 + +# 差异化尺寸:多列不同宽,一次调用(值可混用 "standard" 重置某列) +lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" \ + --widths '{"A": 100, "B": 358, "C:E": 120, "G": "standard"}' + +# 差异化尺寸:多行不同高,值可混用 "auto" / "standard" +lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" \ + --heights '{"1": 50, "2:20": 30, "21": "auto"}' # 第 1 行行高自动适应内容(列宽不支持 auto) lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --range "1" --type auto @@ -240,6 +266,10 @@ lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --range "1" --type au lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --range "A:E" --type standard ``` +**⚠️ 单位是像素,不是 Excel 字符单位 / 磅**:列宽常见 60~400px;如果你按 Excel 字符单位(openpyxl / xlsxwriter 的 `width`)心算,先换算 `px ≈ 字符数 × 8 + 16`——写 `{"A": 10}` 得到的是 10px 的不可用窄列(CLI 会拒绝 < 20px 的列宽并提示换算)。行高是像素不是磅(points),默认行高约 24px。 + +**列宽没有 auto-fit**:需要"列宽自适应内容"时,按"写入后列宽自适应"一节的公式估算像素值(`max(表头字符数, 内容最长字符数) × 8 + 16`)后用 `--widths` 显式设置。 + > 同时出现在 `lark-sheets-sheet-structure.md` —— 行高 / 列宽调整也算行列结构层动作。 ### `+range-move` / `+range-copy` @@ -262,6 +292,6 @@ lark-cli sheets +range-sort --url "..." --sheet-id "$SID" --range "A1:E100" --ha ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`+cells-clear` 强制 `--yes` 或 `--dry-run`;`+range-*` 校验源 / 目标 range 在同一 spreadsheet;`+range-sort` 的 `--sort-keys` 必须合法 JSON 数组且 col 都在 `--range` 内;`+rows-resize` / `+cols-resize` 的 `--type` 必填,`--type pixel` 时 `--size` 必填、其它 type 时 `--size` 会被忽略(传了无害);`+cols-resize.--type` 不接受 `auto`(只行高支持自适应)。 +- `Validate`:XOR 公共四件套;`+cells-clear` 强制 `--yes` 或 `--dry-run`;`+range-*` 校验源 / 目标 range 在同一 spreadsheet;`+range-sort` 的 `--sort-keys` 必须合法 JSON 数组且 col 都在 `--range` 内;`+rows-resize` / `+cols-resize` 两种形态二选一——统一形态必须给 `--range` 且至少给 `--height`/`--width` 或 `--type` 之一(`--type standard`/`auto` 不能与像素 flag 同给,`--type pixel` 共存 OK),map 形态(`--heights`/`--widths`)不能与 `--range`/`--height`/`--width`/`--type` 混用,map 键必须与命令维度一致(行数字 / 列字母)、不得重复,值为正整数像素或模式字符串;列宽 < 20px 拒绝(疑似 Excel 字符单位);`+cols-resize` 不接受 `auto`(列宽不支持自适应)。map 形态在 `+batch-update` 子操作里不可用(它本身就是原子批量)。 - `DryRun`:所有写操作输出"将要 PATCH 的 range + 受影响 cell 数估算"。 - `Execute`:写后不自动回读;如需确认,自行调用 `+cells-get --range <影响范围>` 抽样比对。 diff --git a/skills/lark-sheets/references/lark-sheets-read-data.md b/skills/lark-sheets/references/lark-sheets-read-data.md index 74e8e43e7..8316c0106 100644 --- a/skills/lark-sheets/references/lark-sheets-read-data.md +++ b/skills/lark-sheets/references/lark-sheets-read-data.md @@ -2,7 +2,7 @@ ## 列格式多样性预探(写公式 / 排序 / 筛选前必做) -> 对应 `lark-sheets-core-operations` 的 **R3 计算复现**——本节是 R3 在 read_data 工具层的具体落地。 +> 本节给出"写公式 / 排序 / 筛选前先探清列格式多样性"的正确流程,是主 SKILL.md「飞书表格编辑准则」准则 3(读全再写)在 read_data 工具层的落地。 对参与后续**计算 / 排序 / 筛选 / 公式提取**的列,**必须**先 sample **至少 50 行**(小表则全量),识别该列所有值类型变体后再设计公式 / 条件。只看前 10 行不够,因为下列差异通常潜伏在表尾或中段: @@ -22,7 +22,7 @@ | 读取目的 | 用这个 shortcut | 数据去向 | 说明 | |---------|----------------|---------|------| | 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本(每行带 `[row=N]` 前缀);大表请按 `--range` 行窗口分批读(截断时看 `has_more`) | -| 按列类型结构化读出(喂 DataFrame / round-trip 回 `+table-put`) | `+table-get` | 对话上下文 | 返回 typed 协议(`columns:[列名]` + `data` + `dtypes`/`formats` + `range`),输出形状对齐 pandas split;可一行 `pd.DataFrame(sheet["data"], columns=sheet["columns"]).astype(sheet["dtypes"])` 还原 DataFrame,或直接 round-trip 回 `+table-put`。不带 `--range` 时读**完整 used range**(跨过表中部空行 / 空列),每个子表回传实际读取范围 `range` 供完整性校验 | +| 按列类型结构化读出(喂 DataFrame / round-trip 回 `+table-put`) | `+table-get` | 对话上下文 | 返回 typed 协议(`columns:[列名]` + `data` + `dtypes`/`formats` + `range`),输出形状对齐 pandas split;可一行 `pd.DataFrame(sheet["data"], columns=sheet["columns"]).astype(sheet["dtypes"])` 还原 DataFrame,或直接 round-trip 回 `+table-put`。不带 `--range` 时读**完整 used range**(跨过表中部空行 / 空列),每个子表回传实际读取范围 `range` 供完整性校验。注意这与下文 `current_region` "遇表中部空行截断"不矛盾:`+table-get` 读的是子表物理 used range(飞书记录的已用矩形,含中间空行),`current_region` 是从锚点连通扩展、遇整行空行就断 | | 查看公式、样式、批注、数据验证 | `+cells-get` | 对话上下文 | 返回单元格完整信息,token 开销较大 | | 查看某区域的下拉框(数据验证)选项 | `+dropdown-get` | 对话上下文 | 返回该 A1 范围已配置的下拉列表选项 | @@ -42,7 +42,7 @@ 注意: -- `+csv-get` 和 `+cells-get` 支持分页/截断,注意检查 `has_more` / `truncated` 标志;使用 `+cells-get` 时,在读取 `cells` 之前还必须先看 `warning_message`,并用每个 range 的 `actual_range` / `row_indices` / `col_indices` 判断真实位置 +- `+csv-get` 和 `+cells-get` 支持分页/截断,注意检查 `has_more` / `truncated` 标志;两者在处理返回数据之前都必须先读 `warning_message`(上游 schema 要求先读它再用其它字段,内含定位与截断续读提示),`+cells-get` 还要用每个 range 的 `actual_range` / `row_indices` / `col_indices` 判断真实位置 - 隐藏行列默认包含在返回结果中(`--skip-hidden=false`),如需只看可见数据设为 `true`。读取原语本身不标注哪些行列被隐藏:若要识别隐藏区间(以决定是否过滤、或如何解读混入的隐藏数据),用 `+sheet-info --include hidden_rows,hidden_cols` 取隐藏行列集合,再结合 `+csv-get` / `+cells-get` 返回的 `row_indices` / `col_indices` 判断每行 / 每列是否隐藏 **常见配置错误(必须注意)**: diff --git a/skills/lark-sheets/references/lark-sheets-sheet-structure.md b/skills/lark-sheets/references/lark-sheets-sheet-structure.md index 16473e3b7..ed26904f0 100644 --- a/skills/lark-sheets/references/lark-sheets-sheet-structure.md +++ b/skills/lark-sheets/references/lark-sheets-sheet-structure.md @@ -39,7 +39,7 @@ **常见配置错误(必须注意)**: - **插入列直接用字母**:`+dim-insert` 的 `--position` 在列场景直接传字母(如 `C`),不要把列字母换算成 0-based 索引 - **插入后引用偏移**:插入行/列后,原有数据的行号 / 列字母会发生偏移。如果插入后还需要对原有区域执行写入操作,必须重新计算偏移后的位置 -- **删除行列前先确认范围**:删除操作不可逆,执行前应确认 `--range` 精确无误。可先用 `+csv-get` 读取目标区域验证内容 +- **删除行列前先确认范围**:删除操作不可逆,执行前应确认 `--range` 精确无误。可先用 `+csv-get` 读取目标区域验证内容(`+csv-get` / `+cells-get` 见 `lark-sheets-read-data`) - **"在 D 列左侧新增一列"的正确写法**:`--position D --count 1`(新列插在 D 列之前);要继承左侧列样式加 `--inherit-style before` - **`+dim-move` 同维度约束**:`--source-range` 是行区间时 `--target` 必须是行号(数字),是列区间时 `--target` 必须是列字母——不可一行一列混用 - **插入列后必须检查多行表头合并区域**:很多表格有 2-3 行的合并表头。插入列后,原有的合并区域不会自动扩展到新列。必须先用 `+sheet-info --include merges` 读取合并区域,插入后将跨越插入位置的合并区域重新设置(用 `+cells-{merge|unmerge}`),否则新列的表头会是空的、格式不连续 @@ -129,7 +129,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--depth` | int | optional | 要取消的分组层级,默认 1(最外层) | +| `--depth` | int | optional | 要取消的分组层级,默认 1(1=最外层,数字越大越内层) | | `--range` | string | required | 要取消分组的行/列闭区间;行如 `3:7`,列如 `C:F` | ### `+dim-move` @@ -192,7 +192,7 @@ lark-cli sheets +dim-move --url "..." --sheet-id "$SID" --source-range "C:F" --t > ⚠️ 这两条 shortcut 来自 `lark-sheets-range-operations` 的 `+rows-resize / +cols-resize` tool(分组在"工作表"是为了发现性)。详细参数和示例在 `lark-sheets-range-operations.md`。 > -> 行 vs 列底层 schema 有差异:`+rows-resize.--type` 支持 `pixel` / `standard` / `auto`,`+cols-resize.--type` 只支持 `pixel` / `standard`(列宽不支持自动适应)。 +> 常规写法:行高走 `--range` + `--height `、列宽走 `--range` + `--width `,无需再传 `--type`(等价于 `--type pixel`);多行 / 多列不同尺寸用 map 形态 `--heights` / `--widths`(如 `--widths '{"A":100,"C:E":120}'`)一次原子完成,不要拆多次调用或走 `+batch-update`。`--type standard` / `--type auto` 用于非像素模式,不能与像素 flag 同给。`+cols-resize.--type` 不接受 `auto`(列宽不支持自动适应)。⚠️ 单位是像素(不是 Excel 字符单位 / 磅)。 ### `+dim-freeze` @@ -207,6 +207,6 @@ lark-cli sheets +dim-freeze --url "..." --sheet-id "$SID" --dimension row --coun ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`--range` / `--source-range` 必须是合法 A1 闭区间(行用数字、列用字母,不可混用);`+dim-insert` 的 `--count` > 0;`+dim-move` 的 `--target` 必须与 `--source-range` 同维度(行 vs 列);`+dim-delete` 强制 `--yes` 或 `--dry-run`;`+rows-resize` / `+cols-resize` 的 `--type` 必填,`--type pixel` 时 `--size` 必填、其它 type 时 `--size` 会被忽略(传了无害);`+rows-resize` / `+cols-resize` 的行 vs 列 `--type` 差异详见 `lark-sheets-range-operations.md`。 +- `Validate`:XOR 公共四件套;`--range` / `--source-range` 必须是合法 A1 闭区间(行用数字、列用字母,不可混用);`+dim-insert` 的 `--count` > 0;`+dim-move` 的 `--target` 必须与 `--source-range` 同维度(行 vs 列);`+dim-delete` 强制 `--yes` 或 `--dry-run`;`+rows-resize` / `+cols-resize` 的统一形态(`--range` + `--height`/`--width` 或 `--type`)与 map 形态(`--heights`/`--widths`)二选一、不可混用;详见 `lark-sheets-range-operations.md`。 - `DryRun`:写操作输出"将要 PATCH 的目标范围 + 目标参数"。 - `Execute`:写后不自动回读;如需确认,自行调用 `+sheet-info --include row_heights,col_widths,hidden_rows,hidden_cols,groups,frozen` 查看受影响的范围。 diff --git a/skills/lark-sheets/references/lark-sheets-visual-standards.md b/skills/lark-sheets/references/lark-sheets-visual-standards.md index 4e698a1a9..547ca73f3 100644 --- a/skills/lark-sheets/references/lark-sheets-visual-standards.md +++ b/skills/lark-sheets/references/lark-sheets-visual-standards.md @@ -1,7 +1,7 @@ # 飞书表格样式与配色规范 > **本文定位**:飞书表格"正确视觉输出"的取值标准与美化决策流——配色、表头、对齐、数值格式、斑马纹、列宽行高、图表展示,以及新增 / 继承 / 美化已有区域三类场景的做法。 -> **边界**:本文只讲"样式长什么样、怎么决策";**怎么调用工具写入样式**(`cell_styles` / `border_styles` 字段、合并、resize 等参数)见 `lark-sheets-write-cells` / `lark-sheets-range-operations` / `lark-sheets-batch-update`。**条件格式**(高亮 / 标红 / 数据条 / 色阶)见 `lark-sheets-conditional-format`。本文不含 shortcut,铁律见 `lark-sheets-core-operations`。 +> **边界**:本文只讲"样式长什么样、怎么决策";**怎么调用工具写入样式**(`cell_styles` / `border_styles` 字段、合并、resize 等参数)见 `lark-sheets-write-cells` / `lark-sheets-range-operations` / `lark-sheets-batch-update`。**条件格式**(高亮 / 标红 / 数据条 / 色阶)见 `lark-sheets-conditional-format`。本文不含 shortcut,通用编辑准则见主 SKILL.md「飞书表格编辑准则」。 ## 最高优先级原则 @@ -64,7 +64,7 @@ - 若追加位置紧邻汇总行、说明区或空白分隔区,先判断真实数据区域边界再操作,避免破坏原有结构。 - **Zebra Stripes 维护**:插入或删除行后若影响后续行奇偶性,须从受影响行往后重建条纹(先清理再重设)。少量增删用局部重建,大量变动用全局清理+统一重建。 - 具体采样与复制流程见下方「场景二:从已有区域继承美化」。 -- **列宽调整**(飞书 `+rows-resize / +cols-resize` 按 pixel 传值): +- **列宽 / 行高调整**(飞书 `+cols-resize` / `+rows-resize` 直接给像素值:统一尺寸用 `--range` + `--width`/`--height `,多列 / 多行不同尺寸用 `--widths`/`--heights` map 一次原子完成,如 `--widths '{"A":100,"C:E":120}'`): - 禁止硬编码固定列宽,须根据该列实际内容长度估算像素。 - 经验估算:中文每字约 15-18px,英文/数字每字约 7-9px,外加 10-16px padding。 - 上下限建议 80~400px;超上限启用自动换行(`word_wrap: auto-wrap`)+ 调整行高,而非无限加宽。 @@ -82,7 +82,7 @@ - 包含必要元素:标题、图例、数据标签、坐标轴标题。 - 调整至合适大小,避免数据和标签过多堆叠。 - **图表放置防重叠**:新增图表前须计算放置区域,避免与已有图表重叠。具体步骤: - 1. 调用 `+chart-list` 获取当前工作表所有已有图表的 `position`(锚点单元格:`row` 行索引、`col` 列索引如 "A"/"B")、`offset`(锚点内偏移:`row_offset`、`col_offset`,单位像素)以及 `size`(`width`、`height`,单位像素)。 + 1. 调用 `+chart-list` 获取当前工作表所有已有图表的 `position`(锚点单元格:`col` 是列字母如 "A"/"B"、`row` 是 1-based 行号;以 `+chart-list` 实际返回字段为准)、`offset`(锚点内偏移:`row_offset`、`col_offset`,单位像素)以及 `size`(`width`、`height`,单位像素)。 2. 获取工作表的行高和列宽信息(像素)。 3. 根据每个图表的锚点 `position.row`/`position.col` + 偏移 `offset.row_offset`/`offset.col_offset` + 尺寸 `size.width`/`size.height`,结合行高列宽,计算出每个已有图表覆盖的像素矩形区域 `(x_min, y_min, x_max, y_max)`。 4. 为新图表选定大小后,候选放置位置应避开所有已有矩形区域;若存在重叠则向下或向右偏移,直至找到无冲突位置。 @@ -155,7 +155,7 @@ Step 1 — 格式铺开:`+batch-update` + `+range-copy`(或 `+range-fill`) Step 2 — 内容覆写:`+batch-update` + `+cells-set`(仅传 value/formula,不传任何样式) └── 将每行的实际数据写入,cell_styles 全部省略,因为格式已在 Step 1 中就位 -Step 3 — 微调收尾:`+batch-update` + `+rows-resize / +cols-resize` / `+cells-{merge|unmerge}` 等 +Step 3 — 微调收尾:`+rows-resize --heights` / `+cols-resize --widths`(行高列宽 map 一次原子完成)、`+batch-update` + `+cells-{merge|unmerge}` 等 └── 调整行高列宽、处理合并单元格、扩展条件格式范围等边缘情况 ``` diff --git a/skills/lark-sheets/references/lark-sheets-workbook.md b/skills/lark-sheets/references/lark-sheets-workbook.md index e8eafe854..a1e58b45c 100644 --- a/skills/lark-sheets/references/lark-sheets-workbook.md +++ b/skills/lark-sheets/references/lark-sheets-workbook.md @@ -15,7 +15,12 @@ | 操作需求 | 使用工具 | 说明 | |---------|---------|------| | 查看工作簿结构 | `+workbook-info` | 获取子表列表、名称、行列数、冻结位置等元数据 | +| 获取当前 revision | `+revision-get` | 获取当前文档 revision(版本号),可作为 recover / undo / changeset 复核的版本锚点 | +| 新建工作簿(可预填数据) | `+workbook-create` | 从内存数据建一张新表(`--values` / `--sheets` typed) | +| 导入本地文件为新表 | `+workbook-import` | 把本地 `.xlsx` / `.xls` / `.csv` 导入为新的飞书电子表格 | +| 导出工作簿到本地 | `+workbook-export` | 导出为本地 `.xlsx`(整簿)或单子表 `.csv` | | 变更工作簿结构 | `+sheet-{create|delete|rename|move|copy|hide|unhide|set-tab-color}` | 新建/删除/移动/重命名/复制/隐藏子表、修改标签颜色 | +| 切换子表网格线显隐 | `+sheet-show-gridline` / `+sheet-hide-gridline` | 显示 / 隐藏单个子表的网格线 | 注意: @@ -33,6 +38,7 @@ | Shortcut | Risk | 分组 | | --- | --- | --- | | `+workbook-info` | read | 工作簿 | +| `+revision-get` | read | 工作簿 | | `+sheet-create` | write | 工作簿 | | `+sheet-delete` | high-risk-write | 工作簿 | | `+sheet-rename` | write | 工作簿 | @@ -55,6 +61,12 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ _仅含公共 / 系统 flag。_ +### `+revision-get` + +_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ + +_仅含公共 / 系统 flag。_ + ### `+sheet-create` _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ @@ -65,6 +77,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | `--index` | int | optional | 插入位置(0-based);省略时附加到末尾 | | `--row-count` | int | optional | 初始行数(默认 200,上限 50000) | | `--col-count` | int | optional | 初始列数(默认 20,上限 200) | +| `--type` | string | optional | 新子表类型:sheet(电子表格);默认 sheet(可选值:`sheet`) | ### `+sheet-delete` @@ -87,7 +100,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--index` | int | required | 目标位置(0-based) | -| `--source-index` | int | optional | 源位置(0-based);可选,未传时由 CLI runtime 根据 `--sheet-id` / `--sheet-name` 当前在工作簿中的 index 自动派生 | +| `--source-index` | int | optional | 源位置(0-based);standalone 调用时可选,未传时由 CLI runtime 根据 `--sheet-id` / `--sheet-name` 当前在工作簿中的 index 自动派生。但在 `+batch-update` 内不可省(须显式传)——batch 中途无法发起结构查询自动派生 | ### `+sheet-copy` @@ -138,7 +151,7 @@ _系统:`--dry-run`_ | --- | --- | --- | --- | | `--title` | string | required | 新 spreadsheet 标题 | | `--folder-token` | string | optional | 目标文件夹 token;省略时放在云空间根目录 | -| `--values` | string + File + Stdin(简单 JSON) | optional | untyped 初始数据,一个 JSON 二维数组(表头并入第一行):`[["列A","列B"],["alice",95]]`;值原样写入、类型由飞书自动识别,走与 --sheets 相同的分批 `+cells-set`;配 --styles 控制格式/颜色/合并/行列尺寸 | +| `--values` | string + File + Stdin(简单 JSON) | optional | untyped 初始数据,一个 JSON 二维数组(表头并入第一行):`[["列A","列B"],["alice",95]]`;值原样写入、类型由飞书自动识别(日期 / 数字会落成文本,需类型保真改用 --sheets),走与 --sheets 相同的分批 `+cells-set`;配 --styles 控制格式/颜色/合并/行列尺寸 | | `--sheets` | string + File + Stdin(复合 JSON) | optional | 建表后写入的 typed 表格协议 JSON(同 +table-put):顶层 `{"sheets":[...]}`,每个数组项是一张子表 `{name, start_cell?, mode?, header?, allow_overwrite?, columns:["colA","colB",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` —— `name` 与外层 `sheets` 数组都不可省。Agents 用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 把 DataFrame 转成一项再包 `{"sheets":[...]}`。与 --values 互斥;新表默认子表复用为第一个子表,日期/数字类型保真。 | | `--styles` | string + File + Stdin(复合 JSON) | optional | 建表时同时写入的视觉处理操作 JSON:顶层 `{styles:[...]}`,每项对应一个目标子表、含 `name`,并至少给 `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges` 之一。`cell_styles` 用 A1 单元格 range + 扁平样式字段(字段同 +cells-set-style,含 number_format / 颜色 / 对齐 / border_styles);row/col sizes 用行/列范围 + type/size;merges 用单元格 range + 可选 merge_type。与 --sheets 搭配时 styles 数组长度/顺序/name 必须与 --sheets.sheets 对应;与 --values 搭配时只给一个 styles 项(其 name 忽略)。完整 cell_styles 字段结构跑 `+workbook-create --print-schema --flag-name styles`。 | @@ -184,7 +197,7 @@ _一个或多个子表的 typed 数据,每个数组元素写入一张子表; **数组项**(类型 object): - `cell_merges` (array?) — 单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all each: { merge_type?: enum, range: string } -- `cell_styles` (array?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border_styles?: object, font_color?: string, font_line?: enum, font_size?: number, …共 12 项 } +- `cell_styles` (array?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border_styles?: object, font_color?: string, font_family?: string, font_line?: enum, …共 13 项 } - `col_sizes` (array?) — 列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size each: { range: string, size?: number, type: enum } - `name` (string) — 子表名 - `row_sizes` (array?) — 行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size each: { range: string, size?: number, type: enum } @@ -195,7 +208,17 @@ _一个或多个子表的 typed 数据,每个数组元素写入一张子表; ### `+workbook-info` -输出契约:返回 `sheets[]`,每个含 `sheet_id` / `title`(工作表显示名;旧 payload 用 `sheet_name`,读取时优先取 `title`、缺失再回退 `sheet_name`)/ `row_count` / `column_count` / `index` / `is_hidden`,以及计数字段 `merged_cells_count` / `chart_count` / `pivot_table_count` / `float_image_count`(无 `frozen_*` 字段,冻结信息请用 `+sheet-info` 读取)。是操作飞书表格的第一步——任何后续 sheet 级动作都需要先拿这里的 sheet_id。 +输出契约:返回 `sheets[]`,每个含 `sheet_id` / `title`(工作表显示名;旧 payload 用 `sheet_name`,读取时优先取 `title`、缺失再回退 `sheet_name`)/ `index` / `resource_type` / `row_count` / `column_count` / `is_hidden`,以及计数字段 `merged_cells_count` / `chart_count` / `pivot_table_count` / `float_image_count`(无 `frozen_*` 字段,冻结信息请用 `+sheet-info` 读取)。是操作飞书表格的第一步——任何后续 sheet 级动作都需要先拿这里的 sheet_id。 + +> **子表类型 `resource_type`**:`sheet`(普通网格子表)/ `bitable`(内嵌的多维表格子表)/ `#UNSUPPORTED_TYPE`(其它暂不支持的嵌入子表)。 +> - 网格类操作(读写单元格 / 区域 / 样式 / CSV / 筛选 / 透视 / 图表等)**仅适用于 `sheet`**。对 `bitable` / `#UNSUPPORTED_TYPE` 子表执行网格操作会被直接拒绝并返回明确报错,不再静默出错。 +> - 要操作 `bitable` 子表里的数据:该子表条目会附带 `bitable_app_token` + `bitable_table_id` 两个字段,直接用多维表格命令操作,例如 `lark-cli base +record-list --base-token --table-id `(记录增删改查、字段、视图等整套 `lark-cli base` 命令均可用)。不要走 sheets 网格命令。 +> - `bitable` / `#UNSUPPORTED_TYPE` 子表条目**只含** `sheet_id` / `sheet_name` / `index` / `resource_type`(bitable 另加上述两个 token)以及 `is_hidden` / `tab_color`;**不输出** `row_count` / `column_count` / `merged_cells_count` / `chart_count` / `pivot_table_count` / `float_image_count` / `frozen_*` 等网格指标(对非网格子表无意义)。 +> - tab 管理类操作(`+sheet-rename` / `+sheet-move` / `+sheet-delete` / `+sheet-hide` 等)对任意 `resource_type` 的子表都合法,不受此限制。 + +### `+revision-get` + +输出契约:返回单个 `revision` 字段,即当前文档版本号。它是 recover / undo / `+changeset-get` 的版本锚点:如果刚执行过一次读写操作,也可以直接复用那次响应里的 `revision`;当只想单独取当前版本号、且不需要其它结构信息时,用 `+revision-get` 最直接。 ### `+workbook-create` @@ -366,6 +389,8 @@ standalone 路径在缺 `--source-index` / 只给 `--sheet-name` 时会自动发 lark-cli sheets +sheet-copy --url "..." --sheet-id "$SID" --title "副本" ``` +> 💡 `+sheet-copy` 连**公式 / 合并 / 分组底色 / 列宽 / 条件格式**一起整表复制。"照一张现成子表批量造结构相同的新子表"(如参考模板给每份数据各建一张同构子表)时,先 `+sheet-copy` 复制模板再用 `+cells-*` 只改数据,比从零 `+sheet-create` + 重建公式 / 样式省一大截,也天然满足"公式 / 分组 / 颜色照搬"。要把本地文件 / 数据并入**已有工作簿**当子表时走它(或 `+sheet-create`),别用 `+workbook-import` / `+workbook-create`——那两条只会新建独立表。 + ### `+sheet-hide` / `+sheet-unhide` ```bash diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 96c3626c9..3c19599b6 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -5,7 +5,7 @@ 1. **明确写入边界**:写入前必须能回答"目标 range 的起止行列号是多少?是否落在用户授权范围内?"。除用户明示要修改的区域外,禁止扩张到原数据列以外或新建 Sheet。 2. **完整性断言**:批量写入前先把"预期写入条数"硬编码到代码里(如要填 106 条翻译 → `expected = 106`),写完后回读断言 `actual == expected`。少于预期就继续写,禁止交付半成品。 3. **回读抽样校验**:写完关键值 / 公式后,用 `+csv-get` 或 `+cells-get` 重新读取写入区域,至少抽样 3-5 个代表性单元格(首 / 中 / 末),核对值与预期一致(与本地脚本计算的预期值对照)。公式特定的"先验证模板再 --copy-to-range / 修完再读回"细则见下方相关章节。 -4. **护原表 · 派生产物落点(写排名 / 标记 / 汇总 / 改写列时易丢数据)**:派生结果一律写到**真实末列 +1 的全新空列**或新建子表,**禁止复用任何已有原数据列**——哪怕该列看起来"空",也要先 `+csv-get` 回读确认整列无原始数据再写。三条铁律:① 不把新公式 / 新值写进原数据列(典型反例:把新算的排名公式写进了原本存放另一份原始数据的列,整列原始数据被覆盖丢失);② 不改写、不合并原表头字段名(典型反例:把几个独立表头字段合并成一列,原字段名丢失);③ 慎用 `--allow-overwrite`:它一旦让写入区盖到相邻原始列 / 行就是不可逆数据丢失,加它之前必须用 `+sheet-info` / `+csv-get` 核清目标 range 不含任何原始数据。 +4. **护原表 · 派生产物落点(写排名 / 标记 / 汇总 / 改写列时易丢数据)**:派生结果一律写到**真实末列 +1 的全新空列**或新建子表,**禁止复用任何已有原数据列**——哪怕该列看起来"空",也要先 `+csv-get` 回读确认整列无原始数据再写。三条准则:① 不把新公式 / 新值写进原数据列(典型反例:把新算的排名公式写进了原本存放另一份原始数据的列,整列原始数据被覆盖丢失);② 不改写、不合并原表头字段名(典型反例:把几个独立表头字段合并成一列,原字段名丢失);③ 慎用 `--allow-overwrite`:它一旦让写入区盖到相邻原始列 / 行就是不可逆数据丢失,加它之前必须用 `+sheet-info` / `+csv-get` 核清目标 range 不含任何原始数据。 ## 新增列 / 新增行的样式继承(防止视觉风格不一致) @@ -13,7 +13,7 @@ **完整继承清单**(写新列 / 新行时 cells 数组必须同时携带): -1. `cell_styles.font_size` / `cell_styles.font_weight` / `cell_styles.font_color` / `cell_styles.font_style`(字号 / 粗细 / 颜色 / 斜体等) +1. `cell_styles.font_family` / `cell_styles.font_size` / `cell_styles.font_weight` / `cell_styles.font_color` / `cell_styles.font_style`(字体名称 / 字号 / 粗细 / 颜色 / 斜体等) 2. `cell_styles.horizontal_alignment` / `cell_styles.vertical_alignment`(H-Align / V-Align)—— 漏继承会导致新列对齐与原列不一致(常见) 3. `cell_styles.number_format`(小数位 / 千分位 / 百分比 / 日期格式)—— 漏继承会导致同列数值格式混乱 4. `cell_styles.background_color`(背景色) @@ -43,6 +43,8 @@ **典型反例**:长数字列(如审批单号、流水号)未设 `number_format`,飞书显示为 `1.23E+15`,用户复制出来已经丢失精度。 +> **数字还是文本,按"数据本质是量值还是标识符"二选一 —— 不看当下要不要计算**:金额 / 百分比 / 比率 / 计数 / 度量这类**本质是量值**的数据,一律以**数字类型**写入(百分比存小数 `0.54` 配 `number_format:"0%"`),**不要**设 `@` 文本格式。**这与"用户当下是否要排序 / 求和"无关**——数据类型由数据本质决定、不由当下用途决定:表格数据几乎总会被后续排序 / 图表 / 二次计算复用,`"54%"` 文本与数值列混排本就破坏一致性,且数字 + `number_format` 显示效果与文本**完全相同**,没有任何理由选文本。**最常见的误判就是"这只是 leaderboard / 报表 / 看板展示,又不用算,写成 `54%` 字符串就行"——这是错的,展示用途不改变"百分比是数值"的事实。**(`+table-put` 用 `dtypes` 声明 `int64` / `float64`;版式 `+table-put` 装不下时用 `+cells-set` 传数字 + `number_format`;都别在本地拼成带 `$` / `%` 的字符串走 `+csv-put`。)反过来,编号 `001`、规格 `3-1`、身份证 / 电话 / 单据号等**本质是标识符 / 标签**、要原样保留不被飞书自动解释的内容(否则 `001`→`1`、`3-1`→日期、点分日期 `12.10`→`12.1`(尾零丢失)、长号→科学计数),才以**字符串类型**写入(`dtypes` 设 `object`)并把 `number_format` 设为 `"@"`(文本格式),字面保真。 + ## 使用场景 写入。向飞书表格的单元格区域写入值、公式、样式、批注、图片或下拉,也可批量写入 CSV / DataFrame。本 reference 覆盖 6 个 shortcut,按数据来源 + 内容形态选: @@ -50,23 +52,26 @@ | 场景 | 用这个 shortcut | 原因 | |------|----------------|------| | 模型手里已经有 CSV 文本(小规模手动构造、从 `+csv-get` 取到后简单加工) | `+csv-put` | 直接传 CSV 文本 + `--start-cell`,不用自己拼二维 cells 数组;必要时自动扩容行列 | -| 列里有数值语义的数据(数字 / 金额 / 百分比 / 日期 / 计数)→ 飞书,要类型保真(来源不限:DataFrame、Counter、dict、list 都算) | `+table-put` | typed 协议(外层 `{"sheets":[{"name":"…","columns":[...],"data":[[...]],"dtypes":{...},"formats":{...}}]}`,**只有这四件套字段**):`dtypes` 用 pandas dtype 串声明列类型(`int64` / `float64` / `datetime64[ns]` / `bool` / `object`),`formats` 给每列展示格式(千分位 / 百分比 / 日期)。**date 落真日期、金额 / 百分比 / 计数等数值列保精度且带 `number_format`(可排序 / 求和 / 入图表)**、string 保前导零,多 sheet 一次写。**只要列有数值语义就走这里**,不要在本地把数字拼成带 `$` / `%` 的字符串再走 `+csv-put` | +| 列里有数值语义的数据(数字 / 金额 / 百分比 / 日期 / 计数)→ 飞书,要类型保真(来源不限:DataFrame、Counter、dict、list 都算) | `+table-put` | typed 协议(外层 `{"sheets":[{"name":"…","columns":[...],"data":[[...]],"dtypes":{...},"formats":{...}}]}`,**只有这四件套字段**):`dtypes` 用 pandas dtype 串声明列类型(`int64` / `float64` / `datetime64[ns]` / `bool` / `object`),`formats` 给每列展示格式(千分位 / 百分比 / 日期)。**date 落真日期、金额 / 百分比 / 计数等数值列保精度且带 `number_format`(可排序 / 求和 / 入图表)**、string 保前导零,多 sheet 一次写 | | 写入含样式、批注、图片、数据校验等任意富写入 | `+cells-set` | 唯一支持完整富字段的 shortcut(公式 `+csv-put` 也能写) | | 只改已有 cell 的样式,不动 value/formula | `+cells-set-style` | 拍平 10 个样式字段为独立 flag;不触发不必要的值写入 | | 单 cell 嵌入图片 | `+cells-set-image` | 比 `+cells-set` 参数更简短 | -| 大量纯值 + 需要表头样式/边框 | 先用 `+csv-put` 写值,再用 `+cells-set-style` 补样式 | 分工配合,入参最短 | +| 在**已有区域**局部补表头样式/边框 | 先用 `+csv-put` 写值,再用 `+cells-set-style` 补样式 | 分工配合,入参最短 | +| **新建子表 / 整表成套美化**(哪怕全是纯文本) | `+table-put --sheets … --styles …` 一步带值 + 全套样式(区域底色 / 边框 / 列宽 / 行高 / 合并;payload 里不存在的 sheet 名自动建子表) | `--styles` 与列是否 typed 无关,纯文本同样适用;比「写值 + 多次刷样式」少好几次调用 | -**优先级**:常规批量写入(纯值或公式)优先 `+csv-put`(最短入参,直接传 CSV 文本);含样式/批注/图片才用 `+cells-set`。⚠️ 这里"纯值"特指**已是文本、无需保留数值语义**的内容;只要列里是金额 / 百分比 / 日期 / 计数等有数值语义的数据,应优先 `+table-put`(用 typed 协议的 `dtypes` 声明列类型 + `formats` 设展示格式),而不是 `+csv-put`。 +**选命令按内容形态分流(不设"默认首选")**:① 列有数值语义(金额 / 百分比 / 日期 / 计数)→ `+table-put`(`dtypes` 声明类型 + `formats` 设展示格式),版式装不下时 → `+cells-set` 传数字 + `number_format`;② 要样式 / 批注 / 图片 / 富文本 → `+cells-set`;③ **仅**全文本、无数值语义的内容平铺 → `+csv-put`(入参最短)。判据详见上方「数字还是文本」。 ⚠️ `+csv-put` 可写值或公式:以 `=` 开头的单元格会被当作公式计算(读回时 `formula` 字段保留、`value` 为计算结果)。**公式内部含逗号 / 引号 / 换行时必须按 RFC 4180 转义**——含逗号的字段整格用双引号包裹、字段内部的引号再翻倍:如 `=COUNTIF(D5:D22,"及格")` 必须写成 `"=COUNTIF(D5:D22,""及格"")"`(外层双引号包裹整格,内部 `"及格"` 的引号翻倍成 `""及格""`)。漏转义会被 CSV 解析器按逗号拆列、整块写入区域错位(如本该 `G4:H6` 错成 `G4:K4`),详见下方 `+csv-put` 示例。**因此含逗号 / 引号 / 换行的公式优先改用 `+cells-set`(JSON 二维数组)写入——`cells[r][c].formula` 字段直接放公式串,零 CSV 转义负担,从根上避免拆列错位**(`+table-put` 的 typed 协议只接受 `columns / data / dtypes / formats` 四件套、没有 `formula` 字段,公式写入只能走 `+cells-set` / `+csv-put`)。此外 `+csv-put` **不会**携带样式/批注/图片,也无法把 `=` 开头的内容当字面量文本写入;需要样式/批注/图片用 `+cells-set`(或"写值 + 补样式"两步法)。 -⚠️ **别把本该是数值的列格式化成字符串用 `+csv-put` 写入**:金额 / 百分比 / 市值 / 计数等列,若在本地拼成带 `$` / `%` / 千分位的字符串(如 `"$1,234.50"` / `"+30.5%"`)再 `+csv-put` 灌进去,单元格会变成**文本**——丢失排序 / 求和 / 图表 / 透视能力,且与 `number` 列混排时无法参与计算。正解是 `+table-put --sheets` 完整 payload(外层一定要带 `{"sheets":[...]}`、列名走 `columns`、二维数据走 `data`、列 pandas dtype 走 `dtypes`、列展示格式走 `formats`),数值列用 pandas dtype 串如 `dtypes:{"价格":"float64"}`(百分比同样存小数 `0.305`),并配 `formats:{"价格":"$#,##0.00","完成率":"0.0%"}` 做展示格式,**显示效果完全相同、数值无损**。判断信号:**当你准备把一个数字 format 成字符串再写时,几乎总该用 `+table-put` 而非 `+csv-put`**。 +⚠️ **`+csv-put` 会把数值落成文本**:把金额 / 百分比 / 计数等在本地拼成带 `$` / `%` / 千分位的字符串(如 `"$1,234.50"` / `"+30.5%"`)再 `+csv-put` 灌进去,单元格就是**文本**——丢失排序 / 求和 / 图表能力,且与数值列混排无法参与计算。数值该怎么写、何时 `+table-put`、版式装不下时何时退 `+cells-set` 传数字 + `number_format`,判据与分流见上方「数字还是文本」;核心一句:**准备把数字 format 成字符串再写时就是走错了路,数值一律以数字写入 + `number_format` 控制显示。** + +⚠️ **`+csv-put` 也会把「看着像数字」的字段静默数值化**(与上一条相反的另一半坑):CSV 里语义是**日期标签 / 编号 / 标识符**、内容却全是数字字符的列,会被按数值解析——`12.10`→`12.1`(点分日期尾零丢失)、`3.0`→`3`、`001`→`1`、长号→科学计数。**这类列即使已攒好 CSV 文本也不能裸走 `+csv-put`**:优先 `+table-put` 把该列 `dtypes` 声明为 `object`(无年份的点分标签如 `12.10` / `3-1` 字面保真)或 `datetime64[ns]`(完整真日期),版式装不下再退 `+cells-set` + `number_format:"@"`。此类失真在「抽样首 / 中 / 末」回读时易被掩盖(`12.10` / `12.20` 等尾零行常不落在抽样窗口),日期 / 编号列回读要专挑带尾零 / 前导零的代表值核对。 ⚠️ 大数据回写走"`+csv-get` 按 `--range` 行窗口分批读到本地 + 本地脚本处理 + `+csv-put` 分批回写"。 ## `+cells-set` 写入要点(常用模式 / 公式 / 样式) -> 以下是用 `+cells-set`(及 `+cells-set-style`)做富写入时的常用模式与铁律;选哪个 shortcut 见上方「使用场景」。 +> 以下是用 `+cells-set`(及 `+cells-set-style`)做富写入时的常用模式与准则;选哪个 shortcut 见上方「使用场景」。 `+cells-set` 为一块区域设置值 / 公式 / 批注 / 样式,也支持 `rich_text` 的 `type: "embed-image"` 嵌入单元格图片。**关键:`cells` 二维数组的行列维度必须与 `range`(闭区间)严格一致,否则触发 `InvalidCellRangeError`**——维度计算示例见文末 `## Schemas` 的 `--cells`。 @@ -80,12 +85,14 @@ - 用户说”这列 / 整列 / 这行 / 首行 / 向下复制”时,**必须**使用模板单元格 + `--copy-to-range` - 多区域写入相同格式/公式结构时,优先写一个模板,再用 `--copy-to-range` 复制到所有目标区域 +⚠️ **模板 `--range` 从数据行起算、别把表头圈进去**:`--copy-to-range` 会把 `--range` 模板按目标区尺寸周期性平铺,模板里若含了表头行,表头会每隔几行重复铺进数据区。整列填充时模板只取一格数据样式(如 `H2`),不要取成 `H1:H2`。 + ⚠️ **逐行写入公式是常见低效写法**:对每一行单独调用 `+cells-set` 写公式(如 26 次)既慢又易错,且不会自动平移公式引用。正确做法是 1 次模板写入 + 1 次 `--copy-to-range`(公式引用自动平移)。 💡 **写入公式前先按迁移规则改写**:如果公式来自 Excel 或包含数组场景,先读取并遵循 `lark-sheets-formula-translation` 的规则完成改写,再把最终公式写入 `formula` 字段。 💡 **内容与样式分离写入(推荐)**:当需要同时写入内容和样式时,`cells` 中每个单元格都带上 `cell_styles` / `border_styles` 会导致入参非常冗长。由于同一区域的样式通常高度重复(如整列统一背景色、统一边框),推荐拆成两步: -1. **先写内容**:`+cells-set` 只传 `value` / `formula`,不带样式,`cells` 入参精简 +1. **先写内容**:`+cells-set` 只传 `value` / `formula`,不带样式,`cells` 入参精简。⚠️ 这里"不带样式"指暂不带 `cell_styles`,**不是**降级用 `+csv-put` 铺文本——数值列(百分比 / 金额 / 计数)仍必须以数字写入(百分比传 `0.44`):样式能后补,数据类型不能后补(见上方「数字还是文本」)。 2. **再批量刷样式**:对区域中的一个单元格写入目标样式作为模板,再用 `--copy-to-range` 将样式扩展到整列 / 整行 / 整个区域(`--copy-to-range` 会复制值、公式和样式,所以模板单元格应已包含正确的值) 示例:要对 A2:A100 写入数据并统一设置蓝色背景 + 边框: @@ -120,6 +127,8 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl 7. **公式范围与用户指令字面对齐**:用户说"对 F 至 L 列求和"就必须写 `SUM(F2:L2)` 或 `F2+G2+H2+I2+J2+K2+L2`,**不能漏列、多列、错列**。写完用 `+cells-get` 拿回 `formula` 字符串,与用户原话逐字对照(参与求和的列名一致 / 起止列号一致 / 运算符一致),不一致就是违规 8. **量纲 / 单位换算 / 数量乘项预检(公式不报错但结果整体偏倍数)**:从文本提取数字做计算前,先核对**单位是否统一、是否漏乘数量、口径是否一致**——这类错误公式能跑通、无 `#` 报错,回读也看不出(值"像对的")。必须用本地脚本对 3–5 个代表行**离线手算一遍预期值**,与公式结果逐格比对量级:① 单位不一致先统一再算(典型反例:尺寸 `320CM*337CM` 直接取数相乘除以 1e6 得 0.11,正确是 CM→MM 换算后得 10.78,**差 100 倍**);② 按"单件×数量"的量必须乘数量列(典型反例:侧面板面积漏乘 F 列数量,F=2 的行只算了一半);③ 标准值口径对齐(典型反例:营养成分 mg/kg 与 g/100g 口径混用,整列放大 100 倍)。**口径 / 单位 / 数量任一项错,整列计算结果就是错的;这类错误公式不报错、回读也不易看出,必须靠离线手算对照。** +⚠️ **公式写入的默认收尾不是停在回读,而是继续跑 `+formula-verify`**:`+csv-get` / `+cells-get` 的抽样回读只能帮你快速发现明显错误,但它覆盖不到整列中段、隐藏行、被条件格式遮蔽的错误,也看不到 `partial` 截断。**只要这次 `+cells-set` / `--copy-to-range` / `+csv-put` 实际写入了公式,收尾默认就是转到 `lark-sheets-formula-verify` 跑 `+formula-verify`,直到 `status='success'`。** 不要等用户补一句“再验证下公式”才做。 + ⚠️ **收到 `formula_errors` 反馈后不要只打补丁**:`+cells-set` 返回值里若出现 `formula_errors: [{cell, formula, error_type, detail}]`,说明某些 cell 公式编译失败(`error_type=compile_failed` 通常是函数语法错如 `SPLIT(x)[1]` 的下标取值飞书不支持(SPLIT 本身支持,取第 N 项用 `INDEX(SPLIT(...),N)`);`non_formula` 是 `=` 开头但解析不通过)。此时**禁止只聚焦修报错点的局部语法**(如仅把 `[1]` 换成 `INDEX(..,1)`),必须: 1. **重新审视整条公式的完整性**:被 formula_errors 标出的那一行,公式除了下标语法错,还可能有其他先天缺陷(字符清洗不全、IFERROR 兜底漏条件、引用列写错),修完语法错后立即整体复核 @@ -227,7 +236,7 @@ lark-cli sheets +dropdown-set \ > ⚠️ **`--source-range` 必须带 sheet 前缀**(即使跟 `--range` 同 sheet)。注意一个坑:回读这种 listFromRange 下拉单元格时,`data_validation.range` 看起来不带 sheet 前缀(形如 `$T$1:$T$3`),如果要把读出来的 range 反过来写回 `--source-range`,**必须自己重新补上 sheet 前缀**,否则会被拒。 > -> ⚠️ **sheet 前缀里的表名一律「裸写」,不要加引号**——这条对所有带 sheet 前缀的 range 入参通用(`--source-range`、`+cells-batch-set-style` / `+cells-batch-clear` / `+dropdown-update` 的 `--ranges` 等)。即使表名含点或空格(如 `2025.9`、`一月份 `),也直接写 `2025.9!A1`;**不要**按电子表格习惯写成 `'2025.9'!A1`——引号会被当成表名的一部分,导致 `sheet "'2025.9'" not found`。 +> ⚠️ **`--ranges` 类批量 flag 的 sheet 前缀必须「裸写」**——`+cells-batch-set-style` / `+cells-batch-clear` / `+dropdown-update` / `+dropdown-delete` 的 `--ranges` 解析器不接受引号:表名含点或空格(如 `2025.9`、`一月份`)也直接写 `2025.9!A1`,写成 `'2025.9'!A1` 会被当成表名一部分、报 `sheet not found`。**但 `--source-range`、透视表 `--source`、`--range` 走 A1 标准**:sheet 名带单引号(如 `'Sheet1'!A1:B2`)是标准写法、裸写也接受,回读统一返回带引号形式——别把 `--ranges` 的裸写要求套到这些 flag 上。 `+dropdown-update`(多 range 批量更新)的所有 flag 语义与 `+dropdown-set` 完全一致;只是目标 `--ranges` 由单值变成 JSON 数组(每项带 sheet 前缀),同一份选项 + 配色应用到所有 range。 @@ -265,6 +274,7 @@ _公共四件套 · 系统:`--dry-run`_ | `--range` | string | required | 目标范围(A1 格式,如 `A1:B2`) | | `--background-color` | string | optional | 背景颜色(十六进制,如 `#ffffff`) | | `--font-color` | string | optional | 字体颜色(十六进制,如 `#000000`) | +| `--font-family` | string | optional | 字体名称(如 `Arial`、`微软雅黑`) | | `--font-size` | float64 | optional | 字体大小(px,例:10、12、14) | | `--font-style` | string | optional | 字体样式(可选值:`normal` / `italic`) | | `--font-weight` | string | optional | 字重(可选值:`normal` / `bold`) | @@ -330,7 +340,7 @@ _【维度】行列数必须与 range 完全一致:'A1:C2'→[[_,_,_],[_,_,_]] - `value` (oneOf?) — 静态单元格值(文本、数字、布尔) - `formula` (string?) — 以 '=' 开头的单元格公式(例如:'=SUM(A1:A10)') - `note` (string?) — 单元格批注/备注 -- `cell_styles` (object?) — 单元格样式属性,包括字体、颜色、对齐方式和数字格式 { font_color?: string, font_size?: number, font_weight?: enum, font_style?: enum, font_line?: enum, …共 10 项 } +- `cell_styles` (object?) — 单元格样式属性,包括字体、颜色、对齐方式和数字格式 { font_color?: string, font_family?: string, font_size?: number, font_weight?: enum, font_style?: enum, …共 11 项 } - `border_styles` (object?) — 单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top) { top?: object, bottom?: object, left?: object, right?: object } - `rich_text` (array?) — 富文本内容 each: { type: enum, text: string, style?: object, link?: string, mention_token?: string, …共 17 项 } - `multiple_values` (array?) — 多值内容,用于支持多选的列表验证单元格 each: { value: oneOf, format?: string } @@ -373,7 +383,7 @@ _一个或多个子表的 typed 数据,每个数组元素写入一张子表; **数组项**(类型 object): - `cell_merges` (array?) — 单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all each: { merge_type?: enum, range: string } -- `cell_styles` (array?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border_styles?: object, font_color?: string, font_line?: enum, font_size?: number, …共 12 项 } +- `cell_styles` (array?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border_styles?: object, font_color?: string, font_family?: string, font_line?: enum, …共 13 项 } - `col_sizes` (array?) — 列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size each: { range: string, size?: number, type: enum } - `name` (string) — 子表名 - `row_sizes` (array?) — 行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size each: { range: string, size?: number, type: enum }