From 17a5f29306fe1430f2e175c91102324f7f99210b Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Fri, 15 May 2026 20:22:07 +0800 Subject: [PATCH 001/114] refactor(sheets): rebuild lark-sheets on sheet-skill-spec canonical + One-OpenAPI Restart lark-sheets as a spec-driven downstream. Skill content (SKILL.md and 16 references covering 13 operations skills + 3 workflow skills, including the standalone filter-view skill) is mirrored from the sheet-skill-spec canonical-spec; do not hand-edit, change upstream and rerun npm run sync:consumers. Drop the 11 legacy shortcut sources (spreadsheet / sheet management, cell ops, dropdown, filter-view, float image, etc.) and 10 associated tests. Wire up the new sheet_ai/v2 One-OpenAPI single entry that dispatches by tool_name with JSON-string input/output, and land the first canonical shortcut +workbook-info as a template that exercises the public token XOR pair, Risk tiering, and zero-side-effect DryRun. sheet_ai_api.go provides callTool / invokeToolDryRun and bypasses runtime.CallAPI's silent swallowing of non-envelope responses so gateway and business errors from the new endpoint surface precisely. The remaining 55 shortcuts will be designed and landed separately, canonical skill by canonical skill. --- shortcuts/sheets/helpers.go | 240 +---- shortcuts/sheets/lark_sheet_workbook.go | 52 + shortcuts/sheets/lark_sheets_cell_data.go | 421 -------- shortcuts/sheets/lark_sheets_cell_images.go | 129 --- .../lark_sheets_cell_style_and_merge.go | 350 ------- shortcuts/sheets/lark_sheets_dropdown.go | 333 ------ shortcuts/sheets/lark_sheets_filter_views.go | 488 --------- shortcuts/sheets/lark_sheets_float_images.go | 464 --------- .../lark_sheets_row_column_management.go | 369 ------- .../sheets/lark_sheets_sheet_cell_ops_test.go | 781 -------------- .../sheets/lark_sheets_sheet_create_test.go | 306 ------ .../lark_sheets_sheet_dimension_test.go | 979 ------------------ .../sheets/lark_sheets_sheet_dropdown_test.go | 552 ---------- .../sheets/lark_sheets_sheet_export_test.go | 140 --- .../lark_sheets_sheet_filter_view_test.go | 673 ------------ .../lark_sheets_sheet_float_image_test.go | 524 ---------- .../sheets/lark_sheets_sheet_manage_test.go | 702 ------------- .../sheets/lark_sheets_sheet_management.go | 721 ------------- .../lark_sheets_sheet_media_upload_test.go | 272 ----- .../sheets/lark_sheets_sheet_ranges_test.go | 268 ----- .../lark_sheets_sheet_write_image_test.go | 590 ----------- .../lark_sheets_spreadsheet_management.go | 323 ------ shortcuts/sheets/sheet_ai_api.go | 119 +++ shortcuts/sheets/shortcuts.go | 65 +- skills/lark-sheets/SKILL.md | 367 +------ .../references/lark-sheets-batch-update.md | 84 ++ .../references/lark-sheets-cell-data.md | 197 ---- .../references/lark-sheets-cell-images.md | 59 -- .../lark-sheets-cell-style-and-merge.md | 141 --- .../references/lark-sheets-chart.md | 225 ++++ .../lark-sheets-conditional-format.md | 171 +++ .../references/lark-sheets-core-operations.md | 362 +++++++ .../references/lark-sheets-dropdown.md | 133 --- .../references/lark-sheets-filter-view.md | 146 +++ .../references/lark-sheets-filter-views.md | 193 ---- .../references/lark-sheets-filter.md | 128 +++ .../references/lark-sheets-float-image.md | 139 +++ .../references/lark-sheets-float-images.md | 125 --- .../lark-sheets-formula-translation.md | 245 +++++ .../references/lark-sheets-formula.md | 88 -- .../references/lark-sheets-pivot-table.md | 151 +++ .../lark-sheets-range-operations.md | 279 +++++ .../references/lark-sheets-read-data.md | 165 +++ .../lark-sheets-row-column-management.md | 151 --- .../references/lark-sheets-search-replace.md | 117 +++ .../lark-sheets-sheet-management.md | 164 --- .../references/lark-sheets-sheet-structure.md | 215 ++++ .../references/lark-sheets-sparkline.md | 120 +++ .../lark-sheets-spreadsheet-management.md | 140 --- .../lark-sheets-visual-standards.md | 201 ++++ .../references/lark-sheets-workbook.md | 210 ++++ .../references/lark-sheets-write-cells.md | 387 +++++++ 52 files changed, 3587 insertions(+), 11377 deletions(-) create mode 100644 shortcuts/sheets/lark_sheet_workbook.go delete mode 100644 shortcuts/sheets/lark_sheets_cell_data.go delete mode 100644 shortcuts/sheets/lark_sheets_cell_images.go delete mode 100644 shortcuts/sheets/lark_sheets_cell_style_and_merge.go delete mode 100644 shortcuts/sheets/lark_sheets_dropdown.go delete mode 100644 shortcuts/sheets/lark_sheets_filter_views.go delete mode 100644 shortcuts/sheets/lark_sheets_float_images.go delete mode 100644 shortcuts/sheets/lark_sheets_row_column_management.go delete mode 100644 shortcuts/sheets/lark_sheets_sheet_cell_ops_test.go delete mode 100644 shortcuts/sheets/lark_sheets_sheet_create_test.go delete mode 100644 shortcuts/sheets/lark_sheets_sheet_dimension_test.go delete mode 100644 shortcuts/sheets/lark_sheets_sheet_dropdown_test.go delete mode 100644 shortcuts/sheets/lark_sheets_sheet_export_test.go delete mode 100644 shortcuts/sheets/lark_sheets_sheet_filter_view_test.go delete mode 100644 shortcuts/sheets/lark_sheets_sheet_float_image_test.go delete mode 100644 shortcuts/sheets/lark_sheets_sheet_manage_test.go delete mode 100644 shortcuts/sheets/lark_sheets_sheet_management.go delete mode 100644 shortcuts/sheets/lark_sheets_sheet_media_upload_test.go delete mode 100644 shortcuts/sheets/lark_sheets_sheet_ranges_test.go delete mode 100644 shortcuts/sheets/lark_sheets_sheet_write_image_test.go delete mode 100644 shortcuts/sheets/lark_sheets_spreadsheet_management.go create mode 100644 shortcuts/sheets/sheet_ai_api.go create mode 100644 skills/lark-sheets/references/lark-sheets-batch-update.md delete mode 100644 skills/lark-sheets/references/lark-sheets-cell-data.md delete mode 100644 skills/lark-sheets/references/lark-sheets-cell-images.md delete mode 100644 skills/lark-sheets/references/lark-sheets-cell-style-and-merge.md create mode 100644 skills/lark-sheets/references/lark-sheets-chart.md create mode 100644 skills/lark-sheets/references/lark-sheets-conditional-format.md create mode 100644 skills/lark-sheets/references/lark-sheets-core-operations.md delete mode 100644 skills/lark-sheets/references/lark-sheets-dropdown.md create mode 100644 skills/lark-sheets/references/lark-sheets-filter-view.md delete mode 100644 skills/lark-sheets/references/lark-sheets-filter-views.md create mode 100644 skills/lark-sheets/references/lark-sheets-filter.md create mode 100644 skills/lark-sheets/references/lark-sheets-float-image.md delete mode 100644 skills/lark-sheets/references/lark-sheets-float-images.md create mode 100644 skills/lark-sheets/references/lark-sheets-formula-translation.md delete mode 100644 skills/lark-sheets/references/lark-sheets-formula.md create mode 100644 skills/lark-sheets/references/lark-sheets-pivot-table.md create mode 100644 skills/lark-sheets/references/lark-sheets-range-operations.md create mode 100644 skills/lark-sheets/references/lark-sheets-read-data.md delete mode 100644 skills/lark-sheets/references/lark-sheets-row-column-management.md create mode 100644 skills/lark-sheets/references/lark-sheets-search-replace.md delete mode 100644 skills/lark-sheets/references/lark-sheets-sheet-management.md create mode 100644 skills/lark-sheets/references/lark-sheets-sheet-structure.md create mode 100644 skills/lark-sheets/references/lark-sheets-sparkline.md delete mode 100644 skills/lark-sheets/references/lark-sheets-spreadsheet-management.md create mode 100644 skills/lark-sheets/references/lark-sheets-visual-standards.md create mode 100644 skills/lark-sheets/references/lark-sheets-workbook.md create mode 100644 skills/lark-sheets/references/lark-sheets-write-cells.md diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go index 6e8ba91df..2fb2e64ef 100644 --- a/shortcuts/sheets/helpers.go +++ b/shortcuts/sheets/helpers.go @@ -1,51 +1,50 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT +// Package sheets contains lark-sheets shortcuts aligned with the +// sheet-skill-spec canonical layout. Each shortcut wraps a single +// sheet-ai-skills tool behind the One-OpenAPI endpoint +// (sheet_ai/v2/.../tools/invoke_{read,write}). package sheets import ( - "fmt" - "regexp" - "strconv" "strings" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) -var ( - singleCellRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*$`) - cellSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+[1-9][0-9]*$`) - cellToColRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+$`) - colSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+:[A-Za-z]+$`) - rowSpanRangePattern = regexp.MustCompile(`^[1-9][0-9]*:[1-9][0-9]*$`) - cellRefPattern = regexp.MustCompile(`^([A-Za-z]+)([1-9][0-9]*)$`) -) - -var sheetRangeSeparatorReplacer = strings.NewReplacer(`\!`, "!", `\!`, "!", "!", "!") - -// getFirstSheetID queries the spreadsheet and returns the first sheet's ID. -func getFirstSheetID(runtime *common.RuntimeContext, spreadsheetToken string) (string, error) { - data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil) - if err != nil { +// resolveSpreadsheetToken applies the public --url / --spreadsheet-token XOR +// pair shared by every sheets canonical shortcut and returns the resolved +// token. Network-free, safe to call from Validate and DryRun. +func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) { + if err := common.ExactlyOne(runtime, "url", "spreadsheet-token"); err != nil { return "", err } - sheets, _ := data["sheets"].([]interface{}) - if len(sheets) > 0 { - sheet, _ := sheets[0].(map[string]interface{}) - if id, ok := sheet["sheet_id"].(string); ok && id != "" { - return id, nil + if token := strings.TrimSpace(runtime.Str("spreadsheet-token")); token != "" { + if err := validate.RejectControlChars(token, "spreadsheet-token"); err != nil { + return "", common.FlagErrorf("%v", err) } + return token, nil + } + + url := strings.TrimSpace(runtime.Str("url")) + token := extractSpreadsheetToken(url) + if token == "" || token == url { + return "", common.FlagErrorf("--url must be a spreadsheet URL like https://.../sheets/") } - return "", output.Errorf(output.ExitAPI, "not_found", "no sheets found in this spreadsheet") + if err := validate.RejectControlChars(token, "url"); err != nil { + return "", common.FlagErrorf("%v", err) + } + return token, nil } -// extractSpreadsheetToken extracts spreadsheet token from URL. +// extractSpreadsheetToken pulls the token segment out of a /sheets/ +// or /spreadsheets/ URL. Returns the input unchanged when no known +// prefix is present (callers must check token != originalInput). func extractSpreadsheetToken(input string) string { input = strings.TrimSpace(input) - prefixes := []string{"/sheets/", "/spreadsheets/"} - for _, prefix := range prefixes { + for _, prefix := range []string{"/sheets/", "/spreadsheets/"} { if idx := strings.Index(input, prefix); idx >= 0 { token := input[idx+len(prefix):] if idx2 := strings.IndexAny(token, "/?#"); idx2 >= 0 { @@ -57,183 +56,12 @@ func extractSpreadsheetToken(input string) string { return input } -func normalizeSheetRange(sheetID, input string) string { - input = normalizeSheetRangeSeparators(input) - if input == "" || strings.Contains(input, "!") || sheetID == "" { - return input - } - if looksLikeRelativeRange(input) { - return sheetID + "!" + input - } - return input -} - -func normalizePointRange(sheetID, input string) string { - input = normalizeSheetRange(sheetID, input) - if input == "" { - return input - } - rangeSheetID, subRange, ok := splitSheetRange(input) - if !ok || !singleCellRangePattern.MatchString(subRange) { - return input - } - return rangeSheetID + "!" + subRange + ":" + subRange -} - -func normalizeWriteRange(sheetID, input string, values interface{}) string { - rows, cols := matrixDimensions(values) - input = normalizeSheetRangeSeparators(input) - if input == "" { - return buildRectRange(sheetID, "A1", rows, cols) - } - - input = normalizeSheetRange(sheetID, input) - rangeSheetID, subRange, ok := splitSheetRange(input) - if !ok { - return buildRectRange(input, "A1", rows, cols) - } - if singleCellRangePattern.MatchString(subRange) { - return buildRectRange(rangeSheetID, subRange, rows, cols) - } - return input -} - -func validateSheetRangeInput(sheetID, input string) error { - input = normalizeSheetRangeSeparators(input) - if input == "" || strings.Contains(input, "!") || sheetID != "" { - return nil - } - if looksLikeRelativeRange(input) { - return common.FlagErrorf("--range %q requires --sheet-id or a ! prefix", input) - } - return nil -} - -// validateSingleCellRange rejects multi-cell spans (e.g. "A1:B2") that are -// invalid for single-cell operations like write-image. Empty and single-cell -// values pass through. -func validateSingleCellRange(input string) error { - input = normalizeSheetRangeSeparators(input) - if input == "" { - return nil - } - // Extract the sub-range after the sheet ID prefix, if present. - subRange := input - if _, sr, ok := splitSheetRange(input); ok { - subRange = sr - } - if cellSpanRangePattern.MatchString(subRange) { - parts := strings.SplitN(subRange, ":", 2) - if strings.EqualFold(parts[0], parts[1]) { - return nil - } - return common.FlagErrorf("--range %q must be a single cell (e.g. A1 or A1:A1), got a multi-cell span", input) - } - return nil -} - -func looksLikeRelativeRange(input string) bool { - input = normalizeSheetRangeSeparators(input) - if input == "" { - return false - } - return singleCellRangePattern.MatchString(input) || - cellSpanRangePattern.MatchString(input) || - cellToColRangePattern.MatchString(input) || - colSpanRangePattern.MatchString(input) || - rowSpanRangePattern.MatchString(input) -} - -func splitSheetRange(input string) (sheetID, subRange string, ok bool) { - parts := strings.SplitN(normalizeSheetRangeSeparators(input), "!", 2) - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return "", "", false - } - return parts[0], parts[1], true -} - -func normalizeSheetRangeSeparators(input string) string { - input = strings.TrimSpace(input) - if input == "" { - return input - } - return sheetRangeSeparatorReplacer.Replace(input) -} - -func buildRectRange(sheetID, anchor string, rows, cols int) string { - if sheetID == "" { - return "" - } - if rows < 1 { - rows = 1 - } - if cols < 1 { - cols = 1 - } - endCell, err := offsetCell(anchor, rows-1, cols-1) - if err != nil { - return sheetID - } - return sheetID + "!" + anchor + ":" + endCell -} - -func matrixDimensions(values interface{}) (rows, cols int) { - rowList, ok := values.([]interface{}) - if !ok || len(rowList) == 0 { - return 1, 1 - } - rows = len(rowList) - for _, row := range rowList { - if cells, ok := row.([]interface{}); ok && len(cells) > cols { - cols = len(cells) - } - } - if cols == 0 { - cols = 1 - } - return rows, cols -} - -func offsetCell(cell string, rowOffset, colOffset int) (string, error) { - matches := cellRefPattern.FindStringSubmatch(strings.TrimSpace(cell)) - if len(matches) != 3 { - return "", fmt.Errorf("invalid cell reference: %s", cell) - } - colIndex := columnNameToIndex(matches[1]) - if colIndex < 1 { - return "", fmt.Errorf("invalid column: %s", matches[1]) - } - rowIndex, err := strconv.Atoi(matches[2]) - if err != nil { - return "", err - } - return fmt.Sprintf("%s%d", columnIndexToName(colIndex+colOffset), rowIndex+rowOffset), nil -} - -func columnNameToIndex(name string) int { - name = strings.ToUpper(strings.TrimSpace(name)) - if name == "" { - return 0 - } - index := 0 - for _, r := range name { - if r < 'A' || r > 'Z' { - return 0 - } - index = index*26 + int(r-'A'+1) - } - return index -} - -func columnIndexToName(index int) string { - if index < 1 { - return "" - } - var out []byte - for index > 0 { - index-- - out = append([]byte{byte('A' + index%26)}, out...) - index /= 26 +// publicTokenFlags is the leading pair of every canonical sheets shortcut. +// Shortcuts targeting a single sheet append the public sheet-id / sheet-name +// XOR pair on top of this; workbook-level shortcuts use this pair only. +func publicTokenFlags() []common.Flag { + return []common.Flag{ + {Name: "url", Desc: "spreadsheet URL (XOR --spreadsheet-token)"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token (XOR --url)"}, } - return string(out) } diff --git a/shortcuts/sheets/lark_sheet_workbook.go b/shortcuts/sheets/lark_sheet_workbook.go new file mode 100644 index 000000000..8ba1f42a6 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_workbook.go @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +// WorkbookInfo wraps the get_workbook_structure tool: list a workbook's +// sub-sheets with their metadata (sheet_id, title, dimensions, freeze rows +// and cols, index, hidden). This is the first step for every sheets task — +// downstream sheet-level operations all depend on the sheet_id returned here. +var WorkbookInfo = common.Shortcut{ + Service: "sheets", + Command: "+workbook-info", + Description: "List sub-sheets of a spreadsheet with metadata (sheet_id, title, dimensions, freeze, hidden).", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: publicTokenFlags(), + 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 := resolveSpreadsheetToken(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 + } + runtime.Out(out, nil) + return nil + }, + Tips: []string{ + "First step for every sheets task — capture sheet_id from the result before doing any sheet-level operation.", + }, +} diff --git a/shortcuts/sheets/lark_sheets_cell_data.go b/shortcuts/sheets/lark_sheets_cell_data.go deleted file mode 100644 index 8c654716f..000000000 --- a/shortcuts/sheets/lark_sheets_cell_data.go +++ /dev/null @@ -1,421 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/shortcuts/common" -) - -func parseValues2DJSON(raw string) ([][]interface{}, error) { - var rows [][]interface{} - if err := json.Unmarshal([]byte(raw), &rows); err != nil { - return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array") - } - if rows == nil { - return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array") - } - return rows, nil -} - -var SheetRead = common.Shortcut{ - Service: "sheets", - Command: "+read", - Description: "Read spreadsheet cell values", - Risk: "read", - Scopes: []string{"sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "range", Desc: "read range (!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"}, - {Name: "sheet-id", Desc: "sheet ID"}, - {Name: "value-render-option", Desc: "render option: ToString|FormattedValue|Formula|UnformattedValue"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateSheetManageToken(runtime); err != nil { - return err - } - if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { - return err - } - if r := runtime.Str("range"); r != "" { - if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") { - return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")) - } - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateSheetManageToken(runtime) - readRange := runtime.Str("range") - if readRange == "" && runtime.Str("sheet-id") != "" { - readRange = runtime.Str("sheet-id") - } - readRange = normalizePointRange(runtime.Str("sheet-id"), readRange) - return common.NewDryRunAPI(). - GET("/open-apis/sheets/v2/spreadsheets/:token/values/:range"). - Set("token", token).Set("range", readRange) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateSheetManageToken(runtime) - - readRange := runtime.Str("range") - if readRange == "" && runtime.Str("sheet-id") != "" { - readRange = runtime.Str("sheet-id") - } - - if readRange == "" { - var err error - readRange, err = getFirstSheetID(runtime, token) - if err != nil { - return err - } - } - readRange = normalizePointRange(runtime.Str("sheet-id"), readRange) - - params := map[string]interface{}{} - renderOption := runtime.Str("value-render-option") - if renderOption != "" { - params["valueRenderOption"] = renderOption - } - - data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values/%s", validate.EncodePathSegment(token), validate.EncodePathSegment(readRange)), params, nil) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetWrite = common.Shortcut{ - Service: "sheets", - Command: "+write", - Description: "Write to spreadsheet cells (overwrite mode)", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "range", Desc: "write range (!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"}, - {Name: "sheet-id", Desc: "sheet ID"}, - {Name: "values", Desc: "2D array JSON", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateSheetManageToken(runtime); err != nil { - return err - } - - if _, err := parseValues2DJSON(runtime.Str("values")); err != nil { - return err - } - if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { - return err - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateSheetManageToken(runtime) - writeRange := runtime.Str("range") - if writeRange == "" && runtime.Str("sheet-id") != "" { - writeRange = runtime.Str("sheet-id") - } - values, _ := parseValues2DJSON(runtime.Str("values")) - writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values) - return common.NewDryRunAPI(). - PUT("/open-apis/sheets/v2/spreadsheets/:token/values"). - Body(map[string]interface{}{"valueRange": map[string]interface{}{"range": writeRange, "values": values}}). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateSheetManageToken(runtime) - - values, err := parseValues2DJSON(runtime.Str("values")) - if err != nil { - return err - } - - writeRange := runtime.Str("range") - if writeRange == "" && runtime.Str("sheet-id") != "" { - writeRange = runtime.Str("sheet-id") - } - - if writeRange == "" { - var err error - writeRange, err = getFirstSheetID(runtime, token) - if err != nil { - return err - } - } - writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values) - - data, err := runtime.CallAPI("PUT", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values", validate.EncodePathSegment(token)), nil, map[string]interface{}{ - "valueRange": map[string]interface{}{ - "range": writeRange, - "values": values, - }, - }) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetAppend = common.Shortcut{ - Service: "sheets", - Command: "+append", - Description: "Append rows to a spreadsheet", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "range", Desc: "append range (!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"}, - {Name: "sheet-id", Desc: "sheet ID"}, - {Name: "values", Desc: "2D array JSON", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateSheetManageToken(runtime); err != nil { - return err - } - - if _, err := parseValues2DJSON(runtime.Str("values")); err != nil { - return err - } - if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { - return err - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateSheetManageToken(runtime) - appendRange := runtime.Str("range") - if appendRange == "" && runtime.Str("sheet-id") != "" { - appendRange = runtime.Str("sheet-id") - } - values, _ := parseValues2DJSON(runtime.Str("values")) - appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange) - return common.NewDryRunAPI(). - POST("/open-apis/sheets/v2/spreadsheets/:token/values_append"). - Body(map[string]interface{}{"valueRange": map[string]interface{}{"range": appendRange, "values": values}}). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateSheetManageToken(runtime) - - values, err := parseValues2DJSON(runtime.Str("values")) - if err != nil { - return err - } - - appendRange := runtime.Str("range") - if appendRange == "" && runtime.Str("sheet-id") != "" { - appendRange = runtime.Str("sheet-id") - } - - if appendRange == "" { - var err error - appendRange, err = getFirstSheetID(runtime, token) - if err != nil { - return err - } - } - appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange) - - data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{ - "valueRange": map[string]interface{}{ - "range": appendRange, - "values": values, - }, - }) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetFind = common.Shortcut{ - Service: "sheets", - Command: "+find", - Description: "Find cells in a spreadsheet", - Risk: "read", - Scopes: []string{"sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - {Name: "find", Desc: "search text", Required: true}, - {Name: "range", Desc: "search range (!A1:D10, or A1:D10 / C2 with --sheet-id)"}, - {Name: "ignore-case", Type: "bool", Desc: "case-insensitive search"}, - {Name: "match-entire-cell", Type: "bool", Desc: "match entire cell"}, - {Name: "search-by-regex", Type: "bool", Desc: "regex search"}, - {Name: "include-formulas", Type: "bool", Desc: "search formulas"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateSheetManageToken(runtime); err != nil { - return err - } - if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { - return err - } - if r := runtime.Str("range"); r != "" { - if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") { - return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")) - } - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateSheetManageToken(runtime) - sheetID := runtime.Str("sheet-id") - findCondition := map[string]interface{}{ - "range": sheetID, - "match_case": !runtime.Bool("ignore-case"), - "match_entire_cell": runtime.Bool("match-entire-cell"), - "search_by_regex": runtime.Bool("search-by-regex"), - "include_formulas": runtime.Bool("include-formulas"), - } - if runtime.Str("range") != "" { - findCondition["range"] = normalizePointRange(sheetID, runtime.Str("range")) - } - return common.NewDryRunAPI(). - POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/find"). - Body(map[string]interface{}{ - "find": runtime.Str("find"), - "find_condition": findCondition, - }). - Set("token", token).Set("sheet_id", sheetID).Set("find", runtime.Str("find")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateSheetManageToken(runtime) - - sheetID := runtime.Str("sheet-id") - findText := runtime.Str("find") - - findCondition := map[string]interface{}{ - "range": sheetID, - "match_case": !runtime.Bool("ignore-case"), - "match_entire_cell": runtime.Bool("match-entire-cell"), - "search_by_regex": runtime.Bool("search-by-regex"), - "include_formulas": runtime.Bool("include-formulas"), - } - if runtime.Str("range") != "" { - findCondition["range"] = normalizePointRange(sheetID, runtime.Str("range")) - } - - reqData := map[string]interface{}{ - "find_condition": findCondition, - "find": findText, - } - - data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/find", validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)), nil, reqData) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetReplace = common.Shortcut{ - Service: "sheets", - Command: "+replace", - Description: "Find and replace cell values in a spreadsheet", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - {Name: "find", Desc: "search text or regex pattern", Required: true}, - {Name: "replacement", Desc: "replacement text", Required: true}, - {Name: "range", Desc: "search range (!A1:D10, or A1:D10 with --sheet-id)"}, - {Name: "match-case", Type: "bool", Desc: "case-sensitive search"}, - {Name: "match-entire-cell", Type: "bool", Desc: "match entire cell content"}, - {Name: "search-by-regex", Type: "bool", Desc: "use regex search"}, - {Name: "include-formulas", Type: "bool", Desc: "search in formulas"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateSheetManageToken(runtime); err != nil { - return err - } - if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { - return err - } - if r := runtime.Str("range"); r != "" { - if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") { - return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")) - } - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateSheetManageToken(runtime) - sheetID := runtime.Str("sheet-id") - findCondition := map[string]interface{}{ - "range": sheetID, - "match_case": runtime.Bool("match-case"), - "match_entire_cell": runtime.Bool("match-entire-cell"), - "search_by_regex": runtime.Bool("search-by-regex"), - "include_formulas": runtime.Bool("include-formulas"), - } - if runtime.Str("range") != "" { - findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range")) - } - return common.NewDryRunAPI(). - POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/replace"). - Body(map[string]interface{}{ - "find_condition": findCondition, - "find": runtime.Str("find"), - "replacement": runtime.Str("replacement"), - }). - Set("token", token).Set("sheet_id", sheetID) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateSheetManageToken(runtime) - - sheetID := runtime.Str("sheet-id") - findCondition := map[string]interface{}{ - "range": sheetID, - "match_case": runtime.Bool("match-case"), - "match_entire_cell": runtime.Bool("match-entire-cell"), - "search_by_regex": runtime.Bool("search-by-regex"), - "include_formulas": runtime.Bool("include-formulas"), - } - if runtime.Str("range") != "" { - findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range")) - } - - data, err := runtime.CallAPI("POST", - fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/replace", - validate.EncodePathSegment(token), - validate.EncodePathSegment(sheetID), - ), - nil, - map[string]interface{}{ - "find_condition": findCondition, - "find": runtime.Str("find"), - "replacement": runtime.Str("replacement"), - }, - ) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} diff --git a/shortcuts/sheets/lark_sheets_cell_images.go b/shortcuts/sheets/lark_sheets_cell_images.go deleted file mode 100644 index c84d32496..000000000 --- a/shortcuts/sheets/lark_sheets_cell_images.go +++ /dev/null @@ -1,129 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "context" - "fmt" - "io/fs" - "path/filepath" - - "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" - "github.com/larksuite/cli/shortcuts/common" -) - -var SheetWriteImage = common.Shortcut{ - Service: "sheets", - Command: "+write-image", - Description: "Write an image into a spreadsheet cell", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "sheet-id", Desc: "sheet ID"}, - {Name: "range", Desc: "target cell (e.g. A1 or !A1). Start and end cell must be the same", Required: true}, - {Name: "image", Desc: "local image file path (supported formats: PNG, JPEG, JPG, GIF, BMP, JFIF, EXIF, TIFF, BPG, HEIC)", Required: true}, - {Name: "name", Desc: "image file name with extension (defaults to the basename of --image)"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") - } - if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { - return err - } - if err := validateSingleCellRange(runtime.Str("range")); err != nil { - return err - } - _, _, err := validateSheetWriteImageFile(runtime.Str("image")) - if err != nil { - return err - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - pointRange := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range")) - imageName := runtime.Str("name") - if imageName == "" { - imageName = filepath.Base(runtime.Str("image")) - } - return common.NewDryRunAPI(). - Desc("JSON upload with inline image bytes"). - POST("/open-apis/sheets/v2/spreadsheets/:token/values_image"). - Body(map[string]interface{}{ - "range": pointRange, - "image": fmt.Sprintf("", runtime.Str("image")), - "name": imageName, - }). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - - pointRange := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range")) - - imagePath := runtime.Str("image") - safePath, stat, err := validateSheetWriteImageFile(imagePath) - if err != nil { - return err - } - - imageBytes, err := vfs.ReadFile(safePath) - if err != nil { - return output.ErrValidation("cannot read image file: %s", err) - } - - imageName := runtime.Str("name") - if imageName == "" { - imageName = filepath.Base(imagePath) - } - - fmt.Fprintf(runtime.IO().ErrOut, "Writing image: %s (%d bytes) → %s\n", imageName, stat.Size(), pointRange) - - data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_image", validate.EncodePathSegment(token)), nil, map[string]interface{}{ - "range": pointRange, - "image": imageBytes, - "name": imageName, - }) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -func validateSheetWriteImageFile(imagePath string) (string, fs.FileInfo, error) { - safePath, err := validate.SafeInputPath(imagePath) - if err != nil { - return "", nil, output.ErrValidation("unsafe image path: %s", err) - } - stat, err := vfs.Stat(safePath) - if err != nil { - return "", nil, output.ErrValidation("image file not found: %s", imagePath) - } - if !stat.Mode().IsRegular() { - return "", nil, output.ErrValidation("image must be a regular file: %s", imagePath) - } - const maxImageSize int64 = 20 * 1024 * 1024 - if stat.Size() > maxImageSize { - return "", nil, output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024) - } - return safePath, stat, nil -} diff --git a/shortcuts/sheets/lark_sheets_cell_style_and_merge.go b/shortcuts/sheets/lark_sheets_cell_style_and_merge.go deleted file mode 100644 index 3553fd945..000000000 --- a/shortcuts/sheets/lark_sheets_cell_style_and_merge.go +++ /dev/null @@ -1,350 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/shortcuts/common" -) - -func validateBatchStyleData(raw string) error { - var data interface{} - if err := json.Unmarshal([]byte(raw), &data); err != nil { - return common.FlagErrorf("--data must be valid JSON: %v", err) - } - arr, ok := data.([]interface{}) - if !ok || len(arr) == 0 { - return common.FlagErrorf("--data must be a non-empty JSON array") - } - for i, item := range arr { - entry, ok := item.(map[string]interface{}) - if !ok { - return common.FlagErrorf("--data[%d] must be an object with ranges and style", i) - } - rangesRaw, ok := entry["ranges"] - if !ok { - return common.FlagErrorf("--data[%d].ranges is required", i) - } - ranges, ok := rangesRaw.([]interface{}) - if !ok || len(ranges) == 0 { - return common.FlagErrorf("--data[%d].ranges must be a non-empty array of strings", i) - } - for j, r := range ranges { - s, ok := r.(string) - if !ok || s == "" { - return common.FlagErrorf("--data[%d].ranges[%d] must be a non-empty string", i, j) - } - if _, _, ok := splitSheetRange(s); !ok { - return common.FlagErrorf("--data[%d].ranges[%d] %q must include a sheetId! prefix", i, j, s) - } - } - styleRaw, ok := entry["style"] - if !ok { - return common.FlagErrorf("--data[%d].style is required", i) - } - if _, ok := styleRaw.(map[string]interface{}); !ok { - return common.FlagErrorf("--data[%d].style must be a JSON object", i) - } - } - return nil -} - -var SheetSetStyle = common.Shortcut{ - Service: "sheets", - Command: "+set-style", - Description: "Set cell style for a range", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "range", Desc: "cell range (!A1:B2, or A1:B2 with --sheet-id)", Required: true}, - {Name: "sheet-id", Desc: "sheet ID (for relative range)"}, - {Name: "style", Desc: "style JSON object (e.g. {\"font\":{\"bold\":true},\"backColor\":\"#ff0000\"})", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") - } - var style interface{} - if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil { - return common.FlagErrorf("--style must be valid JSON: %v", err) - } - if _, ok := style.(map[string]interface{}); !ok { - return common.FlagErrorf("--style must be a JSON object, got %T", style) - } - if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { - return err - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range")) - var style interface{} - _ = json.Unmarshal([]byte(runtime.Str("style")), &style) // Validate already parses and validates this JSON. - return common.NewDryRunAPI(). - PUT("/open-apis/sheets/v2/spreadsheets/:token/style"). - Body(map[string]interface{}{ - "appendStyle": map[string]interface{}{ - "range": r, - "style": style, - }, - }). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - - r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range")) - var style interface{} - if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil { - return common.FlagErrorf("--style must be valid JSON: %v", err) - } - - data, err := runtime.CallAPI("PUT", - fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/style", validate.EncodePathSegment(token)), - nil, - map[string]interface{}{ - "appendStyle": map[string]interface{}{ - "range": r, - "style": style, - }, - }, - ) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetBatchSetStyle = common.Shortcut{ - Service: "sheets", - Command: "+batch-set-style", - Description: "Batch set cell styles for multiple ranges", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "data", Desc: "JSON array of {ranges, style} objects; each range must carry a sheetId! prefix (e.g. sheet1!A1)", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") - } - return validateBatchStyleData(runtime.Str("data")) - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - var data interface{} - _ = json.Unmarshal([]byte(runtime.Str("data")), &data) // Validate already parses and validates this JSON via validateBatchStyleData(). - normalizeBatchStyleRanges(data) - return common.NewDryRunAPI(). - PUT("/open-apis/sheets/v2/spreadsheets/:token/styles_batch_update"). - Body(map[string]interface{}{ - "data": data, - }). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - - var data interface{} - if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil { - return common.FlagErrorf("--data must be valid JSON: %v", err) - } - normalizeBatchStyleRanges(data) - - result, err := runtime.CallAPI("PUT", - fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/styles_batch_update", validate.EncodePathSegment(token)), - nil, - map[string]interface{}{ - "data": data, - }, - ) - if err != nil { - return err - } - runtime.Out(result, nil) - return nil - }, -} - -func normalizeBatchStyleRanges(data interface{}) { - items, ok := data.([]interface{}) - if !ok { - return - } - for _, item := range items { - entry, ok := item.(map[string]interface{}) - if !ok { - continue - } - ranges, ok := entry["ranges"].([]interface{}) - if !ok { - continue - } - for i, r := range ranges { - if s, ok := r.(string); ok { - ranges[i] = normalizePointRange("", s) - } - } - } -} - -var SheetMergeCells = common.Shortcut{ - Service: "sheets", - Command: "+merge-cells", - Description: "Merge cells in a spreadsheet", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "range", Desc: "cell range (!A1:B2, or A1:B2 with --sheet-id)", Required: true}, - {Name: "sheet-id", Desc: "sheet ID (for relative range)"}, - {Name: "merge-type", Desc: "merge method", Required: true, Enum: []string{"MERGE_ALL", "MERGE_ROWS", "MERGE_COLUMNS"}}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") - } - if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { - return err - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range")) - return common.NewDryRunAPI(). - POST("/open-apis/sheets/v2/spreadsheets/:token/merge_cells"). - Body(map[string]interface{}{ - "range": r, - "mergeType": runtime.Str("merge-type"), - }). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - - r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range")) - - data, err := runtime.CallAPI("POST", - fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/merge_cells", validate.EncodePathSegment(token)), - nil, - map[string]interface{}{ - "range": r, - "mergeType": runtime.Str("merge-type"), - }, - ) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetUnmergeCells = common.Shortcut{ - Service: "sheets", - Command: "+unmerge-cells", - Description: "Unmerge (split) cells in a spreadsheet", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "range", Desc: "cell range (!A1:B2, or A1:B2 with --sheet-id)", Required: true}, - {Name: "sheet-id", Desc: "sheet ID (for relative range)"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") - } - if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { - return err - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range")) - return common.NewDryRunAPI(). - POST("/open-apis/sheets/v2/spreadsheets/:token/unmerge_cells"). - Body(map[string]interface{}{ - "range": r, - }). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - - r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range")) - - data, err := runtime.CallAPI("POST", - fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/unmerge_cells", validate.EncodePathSegment(token)), - nil, - map[string]interface{}{ - "range": r, - }, - ) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} diff --git a/shortcuts/sheets/lark_sheets_dropdown.go b/shortcuts/sheets/lark_sheets_dropdown.go deleted file mode 100644 index fe092bbac..000000000 --- a/shortcuts/sheets/lark_sheets_dropdown.go +++ /dev/null @@ -1,333 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/shortcuts/common" -) - -func dataValidationBasePath(token string) string { - return fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dataValidation", - validate.EncodePathSegment(token)) -} - -func dataValidationSheetPath(token, sheetID string) string { - return fmt.Sprintf("%s/%s", dataValidationBasePath(token), validate.EncodePathSegment(sheetID)) -} - -func validateDropdownToken(runtime *common.RuntimeContext) (string, error) { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return "", common.FlagErrorf("specify --url or --spreadsheet-token") - } - return token, nil -} - -func parseJSONStringArray(flagName, value string) ([]interface{}, error) { - var typed []string - if err := json.Unmarshal([]byte(value), &typed); err != nil { - return nil, common.FlagErrorf("--%s must be a JSON array of strings: %v", flagName, err) - } - if typed == nil { - return nil, common.FlagErrorf("--%s must be a JSON array, got null", flagName) - } - arr := make([]interface{}, len(typed)) - for i, s := range typed { - arr[i] = s - } - return arr, nil -} - -func validateRangesFlag(runtime *common.RuntimeContext) ([]interface{}, error) { - ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges")) - if err != nil { - return nil, err - } - if len(ranges) == 0 { - return nil, common.FlagErrorf("--ranges must not be empty") - } - for i, r := range ranges { - s, _ := r.(string) - if _, _, ok := splitSheetRange(s); !ok { - return nil, common.FlagErrorf("--ranges[%d] %q must be a fully qualified range with sheet ID prefix (e.g. !A2:A100)", i, s) - } - } - return ranges, nil -} - -func buildDropdownBody(runtime *common.RuntimeContext) (map[string]interface{}, error) { - condValues, err := parseJSONStringArray("condition-values", runtime.Str("condition-values")) - if err != nil { - return nil, err - } - if len(condValues) == 0 { - return nil, common.FlagErrorf("--condition-values must not be empty") - } - - dv := map[string]interface{}{ - "conditionValues": condValues, - } - - opts := map[string]interface{}{} - if runtime.Cmd.Flags().Changed("multiple") { - opts["multipleValues"] = runtime.Bool("multiple") - } - if runtime.Cmd.Flags().Changed("highlight") { - opts["highlightValidData"] = runtime.Bool("highlight") - } - if runtime.Str("colors") != "" { - colors, err := parseJSONStringArray("colors", runtime.Str("colors")) - if err != nil { - return nil, err - } - if len(colors) != len(condValues) { - return nil, common.FlagErrorf("--colors length (%d) must match --condition-values length (%d)", len(colors), len(condValues)) - } - opts["colors"] = colors - } - if len(opts) > 0 { - dv["options"] = opts - } - - return dv, nil -} - -// SheetSetDropdown sets dropdown list validation on a range. -var SheetSetDropdown = common.Shortcut{ - Service: "sheets", - Command: "+set-dropdown", - Description: "Set dropdown list on a cell range", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "range", Desc: "cell range (!A2:A100)", Required: true}, - {Name: "condition-values", Desc: `dropdown options as JSON array (e.g. '["opt1","opt2"]'), max 500, each <=100 chars, no commas`, Required: true}, - {Name: "multiple", Desc: "enable multi-select (default false)", Type: "bool"}, - {Name: "highlight", Desc: "color-code options (default false)", Type: "bool"}, - {Name: "colors", Desc: `RGB hex color array (e.g. '["#1FB6C1","#F006C2"]'), must match condition-values length`}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateDropdownToken(runtime); err != nil { - return err - } - if _, _, ok := splitSheetRange(runtime.Str("range")); !ok { - return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. !A2:A100)") - } - _, err := buildDropdownBody(runtime) - return err - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateDropdownToken(runtime) - dv, _ := buildDropdownBody(runtime) - return common.NewDryRunAPI(). - POST("/open-apis/sheets/v2/spreadsheets/:token/dataValidation"). - Body(map[string]interface{}{ - "range": runtime.Str("range"), - "dataValidationType": "list", - "dataValidation": dv, - }). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateDropdownToken(runtime) - dv, err := buildDropdownBody(runtime) - if err != nil { - return err - } - - data, err := runtime.CallAPI("POST", dataValidationBasePath(token), nil, - map[string]interface{}{ - "range": runtime.Str("range"), - "dataValidationType": "list", - "dataValidation": dv, - }, - ) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -// SheetUpdateDropdown updates dropdown list settings for given ranges. -var SheetUpdateDropdown = common.Shortcut{ - Service: "sheets", - Command: "+update-dropdown", - Description: "Update dropdown list settings", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - {Name: "ranges", Desc: `ranges as JSON array (e.g. '["sheetId!A1:A100"]')`, Required: true}, - {Name: "condition-values", Desc: `dropdown options as JSON array (e.g. '["opt1","opt2"]')`, Required: true}, - {Name: "multiple", Desc: "enable multi-select (default false)", Type: "bool"}, - {Name: "highlight", Desc: "color-code options (default false)", Type: "bool"}, - {Name: "colors", Desc: `RGB hex color array, must match condition-values length`}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateDropdownToken(runtime); err != nil { - return err - } - if _, err := validateRangesFlag(runtime); err != nil { - return err - } - _, err := buildDropdownBody(runtime) - return err - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateDropdownToken(runtime) - ranges, _ := parseJSONStringArray("ranges", runtime.Str("ranges")) - dv, _ := buildDropdownBody(runtime) - return common.NewDryRunAPI(). - PUT("/open-apis/sheets/v2/spreadsheets/:token/dataValidation/:sheet_id"). - Body(map[string]interface{}{ - "ranges": ranges, - "dataValidationType": "list", - "dataValidation": dv, - }). - Set("token", token).Set("sheet_id", runtime.Str("sheet-id")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateDropdownToken(runtime) - ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges")) - if err != nil { - return err - } - dv, err := buildDropdownBody(runtime) - if err != nil { - return err - } - - data, err := runtime.CallAPI("PUT", dataValidationSheetPath(token, runtime.Str("sheet-id")), nil, - map[string]interface{}{ - "ranges": ranges, - "dataValidationType": "list", - "dataValidation": dv, - }, - ) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -// SheetGetDropdown queries dropdown list settings for a range. -var SheetGetDropdown = common.Shortcut{ - Service: "sheets", - Command: "+get-dropdown", - Description: "Get dropdown list settings for a range", - Risk: "read", - Scopes: []string{"sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "range", Desc: "cell range (!A2:A100)", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateDropdownToken(runtime); err != nil { - return err - } - if _, _, ok := splitSheetRange(runtime.Str("range")); !ok { - return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. !A2:A100)") - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateDropdownToken(runtime) - return common.NewDryRunAPI(). - GET("/open-apis/sheets/v2/spreadsheets/:token/dataValidation?range=:range&dataValidationType=list"). - Set("token", token).Set("range", runtime.Str("range")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateDropdownToken(runtime) - data, err := runtime.CallAPI("GET", dataValidationBasePath(token), - map[string]interface{}{ - "range": runtime.Str("range"), - "dataValidationType": "list", - }, nil, - ) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -// SheetDeleteDropdown deletes dropdown list settings from given ranges. -var SheetDeleteDropdown = common.Shortcut{ - Service: "sheets", - Command: "+delete-dropdown", - Description: "Delete dropdown list from cell ranges", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "ranges", Desc: `ranges as JSON array (e.g. '["sheetId!A2:A100"]'), max 100 ranges`, Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateDropdownToken(runtime); err != nil { - return err - } - _, err := validateRangesFlag(runtime) - return err - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateDropdownToken(runtime) - ranges, _ := parseJSONStringArray("ranges", runtime.Str("ranges")) - dvRanges := make([]interface{}, 0, len(ranges)) - for _, r := range ranges { - dvRanges = append(dvRanges, map[string]interface{}{"range": r}) - } - return common.NewDryRunAPI(). - DELETE("/open-apis/sheets/v2/spreadsheets/:token/dataValidation"). - Body(map[string]interface{}{ - "dataValidationRanges": dvRanges, - }). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateDropdownToken(runtime) - ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges")) - if err != nil { - return err - } - - dvRanges := make([]interface{}, 0, len(ranges)) - for _, r := range ranges { - dvRanges = append(dvRanges, map[string]interface{}{"range": r}) - } - - data, err := runtime.CallAPI("DELETE", dataValidationBasePath(token), nil, - map[string]interface{}{ - "dataValidationRanges": dvRanges, - }, - ) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} diff --git a/shortcuts/sheets/lark_sheets_filter_views.go b/shortcuts/sheets/lark_sheets_filter_views.go deleted file mode 100644 index 072d3d0ca..000000000 --- a/shortcuts/sheets/lark_sheets_filter_views.go +++ /dev/null @@ -1,488 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/shortcuts/common" -) - -func filterViewBasePath(token, sheetID string) string { - return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/filter_views", - validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)) -} - -func filterViewItemPath(token, sheetID, filterViewID string) string { - return fmt.Sprintf("%s/%s", filterViewBasePath(token, sheetID), validate.EncodePathSegment(filterViewID)) -} - -func filterViewConditionBasePath(token, sheetID, filterViewID string) string { - return fmt.Sprintf("%s/conditions", filterViewItemPath(token, sheetID, filterViewID)) -} - -func filterViewConditionItemPath(token, sheetID, filterViewID, conditionID string) string { - return fmt.Sprintf("%s/%s", filterViewConditionBasePath(token, sheetID, filterViewID), validate.EncodePathSegment(conditionID)) -} - -func validateFilterViewToken(runtime *common.RuntimeContext) (string, error) { - return validateSheetManageToken(runtime) -} - -func hasNonEmptyStringFlag(runtime *common.RuntimeContext, name string) bool { - return runtime.Cmd.Flags().Changed(name) && strings.TrimSpace(runtime.Str(name)) != "" -} - -var SheetCreateFilterView = common.Shortcut{ - Service: "sheets", - Command: "+create-filter-view", - Description: "Create a filter view", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - {Name: "range", Desc: "filter range (e.g. sheetId!A1:H14)", Required: true}, - {Name: "filter-view-name", Desc: "display name (max 100 chars)"}, - {Name: "filter-view-id", Desc: "custom 10-char alphanumeric ID (auto-generated if omitted)"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateFilterViewToken(runtime); err != nil { - return err - } - if strings.TrimSpace(runtime.Str("range")) == "" { - return common.FlagErrorf("--range must not be empty") - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateFilterViewToken(runtime) - body := map[string]interface{}{"range": runtime.Str("range")} - if s := runtime.Str("filter-view-name"); s != "" { - body["filter_view_name"] = s - } - if s := runtime.Str("filter-view-id"); s != "" { - body["filter_view_id"] = s - } - return common.NewDryRunAPI(). - POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views"). - Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateFilterViewToken(runtime) - body := map[string]interface{}{"range": runtime.Str("range")} - if s := runtime.Str("filter-view-name"); s != "" { - body["filter_view_name"] = s - } - if s := runtime.Str("filter-view-id"); s != "" { - body["filter_view_id"] = s - } - data, err := runtime.CallAPI("POST", filterViewBasePath(token, runtime.Str("sheet-id")), nil, body) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetUpdateFilterView = common.Shortcut{ - Service: "sheets", - Command: "+update-filter-view", - Description: "Update a filter view", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - {Name: "filter-view-id", Desc: "filter view ID", Required: true}, - {Name: "range", Desc: "new filter range"}, - {Name: "filter-view-name", Desc: "new display name (max 100 chars)"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateFilterViewToken(runtime); err != nil { - return err - } - if !hasNonEmptyStringFlag(runtime, "range") && - !hasNonEmptyStringFlag(runtime, "filter-view-name") { - return common.FlagErrorf("specify at least one of --range or --filter-view-name") - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateFilterViewToken(runtime) - body := map[string]interface{}{} - if s := runtime.Str("range"); s != "" { - body["range"] = s - } - if s := runtime.Str("filter-view-name"); s != "" { - body["filter_view_name"] = s - } - return common.NewDryRunAPI(). - PATCH("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id"). - Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateFilterViewToken(runtime) - body := map[string]interface{}{} - if s := runtime.Str("range"); s != "" { - body["range"] = s - } - if s := runtime.Str("filter-view-name"); s != "" { - body["filter_view_name"] = s - } - data, err := runtime.CallAPI("PATCH", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetListFilterViews = common.Shortcut{ - Service: "sheets", - Command: "+list-filter-views", - Description: "List all filter views in a sheet", - Risk: "read", - Scopes: []string{"sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - _, err := validateFilterViewToken(runtime) - return err - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateFilterViewToken(runtime) - return common.NewDryRunAPI(). - GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/query"). - Set("token", token).Set("sheet_id", runtime.Str("sheet-id")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateFilterViewToken(runtime) - data, err := runtime.CallAPI("GET", filterViewBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetGetFilterView = common.Shortcut{ - Service: "sheets", - Command: "+get-filter-view", - Description: "Get a filter view by ID", - Risk: "read", - Scopes: []string{"sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - {Name: "filter-view-id", Desc: "filter view ID", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - _, err := validateFilterViewToken(runtime) - return err - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateFilterViewToken(runtime) - return common.NewDryRunAPI(). - GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id"). - Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateFilterViewToken(runtime) - data, err := runtime.CallAPI("GET", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetDeleteFilterView = common.Shortcut{ - Service: "sheets", - Command: "+delete-filter-view", - Description: "Delete a filter view", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - {Name: "filter-view-id", Desc: "filter view ID", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - _, err := validateFilterViewToken(runtime) - return err - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateFilterViewToken(runtime) - return common.NewDryRunAPI(). - DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id"). - Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateFilterViewToken(runtime) - data, err := runtime.CallAPI("DELETE", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetCreateFilterViewCondition = common.Shortcut{ - Service: "sheets", - Command: "+create-filter-view-condition", - Description: "Create a filter condition on a filter view", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - {Name: "filter-view-id", Desc: "filter view ID", Required: true}, - {Name: "condition-id", Desc: "column letter (e.g. E)", Required: true}, - {Name: "filter-type", Desc: "filter type: hiddenValue, number, text, color", Required: true}, - {Name: "compare-type", Desc: "comparison operator (e.g. less, beginsWith, between)"}, - {Name: "expected", Desc: "filter values JSON array (e.g. [\"6\"])", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateFilterViewToken(runtime); err != nil { - return err - } - return validateExpectedFlag(runtime.Str("expected")) - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateFilterViewToken(runtime) - body := buildConditionBody(runtime, true) - return common.NewDryRunAPI(). - POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions"). - Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateFilterViewToken(runtime) - body := buildConditionBody(runtime, true) - data, err := runtime.CallAPI("POST", filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetUpdateFilterViewCondition = common.Shortcut{ - Service: "sheets", - Command: "+update-filter-view-condition", - Description: "Update a filter condition on a filter view", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - {Name: "filter-view-id", Desc: "filter view ID", Required: true}, - {Name: "condition-id", Desc: "column letter (e.g. E)", Required: true}, - {Name: "filter-type", Desc: "filter type: hiddenValue, number, text, color"}, - {Name: "compare-type", Desc: "comparison operator"}, - {Name: "expected", Desc: "filter values JSON array"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateFilterViewToken(runtime); err != nil { - return err - } - if !hasNonEmptyStringFlag(runtime, "filter-type") && - !hasNonEmptyStringFlag(runtime, "compare-type") && - !hasNonEmptyStringFlag(runtime, "expected") { - return common.FlagErrorf("specify at least one of --filter-type, --compare-type, or --expected") - } - if s := runtime.Str("expected"); s != "" { - return validateExpectedFlag(s) - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateFilterViewToken(runtime) - body := buildConditionBody(runtime, false) - return common.NewDryRunAPI(). - PUT("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/:condition_id"). - Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")). - Set("filter_view_id", runtime.Str("filter-view-id")).Set("condition_id", runtime.Str("condition-id")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateFilterViewToken(runtime) - body := buildConditionBody(runtime, false) - data, err := runtime.CallAPI("PUT", - filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")), - nil, body) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetListFilterViewConditions = common.Shortcut{ - Service: "sheets", - Command: "+list-filter-view-conditions", - Description: "List all filter conditions of a filter view", - Risk: "read", - Scopes: []string{"sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - {Name: "filter-view-id", Desc: "filter view ID", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - _, err := validateFilterViewToken(runtime) - return err - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateFilterViewToken(runtime) - return common.NewDryRunAPI(). - GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/query"). - Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateFilterViewToken(runtime) - data, err := runtime.CallAPI("GET", - filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"))+"/query", - nil, nil) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetGetFilterViewCondition = common.Shortcut{ - Service: "sheets", - Command: "+get-filter-view-condition", - Description: "Get a filter condition by column", - Risk: "read", - Scopes: []string{"sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - {Name: "filter-view-id", Desc: "filter view ID", Required: true}, - {Name: "condition-id", Desc: "column letter (e.g. E)", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - _, err := validateFilterViewToken(runtime) - return err - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateFilterViewToken(runtime) - return common.NewDryRunAPI(). - GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/:condition_id"). - Set("token", token).Set("sheet_id", runtime.Str("sheet-id")). - Set("filter_view_id", runtime.Str("filter-view-id")).Set("condition_id", runtime.Str("condition-id")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateFilterViewToken(runtime) - data, err := runtime.CallAPI("GET", - filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")), - nil, nil) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetDeleteFilterViewCondition = common.Shortcut{ - Service: "sheets", - Command: "+delete-filter-view-condition", - Description: "Delete a filter condition from a filter view", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - {Name: "filter-view-id", Desc: "filter view ID", Required: true}, - {Name: "condition-id", Desc: "column letter (e.g. E)", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - _, err := validateFilterViewToken(runtime) - return err - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateFilterViewToken(runtime) - return common.NewDryRunAPI(). - DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/:condition_id"). - Set("token", token).Set("sheet_id", runtime.Str("sheet-id")). - Set("filter_view_id", runtime.Str("filter-view-id")).Set("condition_id", runtime.Str("condition-id")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateFilterViewToken(runtime) - data, err := runtime.CallAPI("DELETE", - filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")), - nil, nil) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -func validateExpectedFlag(s string) error { - if s == "" { - return nil - } - var arr []interface{} - if err := json.Unmarshal([]byte(s), &arr); err != nil { - return fmt.Errorf("--expected must be a JSON array (e.g. [\"6\"]), got: %s", s) - } - return nil -} - -func buildConditionBody(runtime *common.RuntimeContext, includeConditionID bool) map[string]interface{} { - body := map[string]interface{}{} - if includeConditionID { - body["condition_id"] = runtime.Str("condition-id") - } - if s := runtime.Str("filter-type"); s != "" { - body["filter_type"] = s - } - if s := runtime.Str("compare-type"); s != "" { - body["compare_type"] = s - } - if s := runtime.Str("expected"); s != "" { - var arr []interface{} - _ = json.Unmarshal([]byte(s), &arr) - body["expected"] = arr - } - return body -} diff --git a/shortcuts/sheets/lark_sheets_float_images.go b/shortcuts/sheets/lark_sheets_float_images.go deleted file mode 100644 index cb70d6ca0..000000000 --- a/shortcuts/sheets/lark_sheets_float_images.go +++ /dev/null @@ -1,464 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "context" - "fmt" - "path/filepath" - - "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/shortcuts/common" -) - -const sheetImageParentType = "sheet_image" - -var SheetMediaUpload = common.Shortcut{ - Service: "sheets", - Command: "+media-upload", - Description: "Upload a local image for use as a floating image and return the file_token", - Risk: "write", - Scopes: []string{"docs:document.media:upload"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "file", Desc: "local image path (files > 20MB use multipart upload automatically)", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSheetMediaUploadParent(runtime); err != nil { - return err - } - _, _, err := validateSheetMediaUploadFile(runtime, runtime.Str("file")) - return err - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - parentNode, err := resolveSheetMediaUploadParent(runtime) - if err != nil { - return common.NewDryRunAPI().Set("error", err.Error()) - } - filePath := runtime.Str("file") - fileName := filepath.Base(filePath) - - dry := common.NewDryRunAPI() - if sheetMediaShouldUseMultipart(runtime.FileIO(), filePath) { - dry.Desc("chunked media upload (files > 20MB)"). - POST("/open-apis/drive/v1/medias/upload_prepare"). - Body(map[string]interface{}{ - "file_name": fileName, - "parent_type": sheetImageParentType, - "parent_node": parentNode, - "size": "", - }). - POST("/open-apis/drive/v1/medias/upload_part"). - Body(map[string]interface{}{ - "upload_id": "", - "seq": "", - "size": "", - "file": "", - }). - POST("/open-apis/drive/v1/medias/upload_finish"). - Body(map[string]interface{}{ - "upload_id": "", - "block_num": "", - }) - return dry.Set("spreadsheet_token", parentNode) - } - return dry.Desc("multipart/form-data upload"). - POST("/open-apis/drive/v1/medias/upload_all"). - Body(map[string]interface{}{ - "file_name": fileName, - "parent_type": sheetImageParentType, - "parent_node": parentNode, - "size": "", - "file": "@" + filePath, - }). - Set("spreadsheet_token", parentNode) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - parentNode, err := resolveSheetMediaUploadParent(runtime) - if err != nil { - return err - } - filePath := runtime.Str("file") - - safePath, stat, err := validateSheetMediaUploadFile(runtime, filePath) - if err != nil { - return err - } - - fileName := filepath.Base(safePath) - fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s) -> spreadsheet %s\n", - fileName, common.FormatSize(stat.Size()), common.MaskToken(parentNode)) - if stat.Size() > common.MaxDriveMediaUploadSinglePartSize { - fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n") - } - - fileToken, err := uploadSheetMediaFile(runtime, safePath, fileName, stat.Size(), parentNode) - if err != nil { - return err - } - - runtime.Out(map[string]interface{}{ - "file_token": fileToken, - "file_name": fileName, - "size": stat.Size(), - "spreadsheet_token": parentNode, - }, nil) - return nil - }, -} - -func validateSheetMediaUploadFile(runtime *common.RuntimeContext, filePath string) (string, fileio.FileInfo, error) { - stat, err := runtime.FileIO().Stat(filePath) - if err != nil { - return "", nil, common.WrapInputStatError(err, "file not found") - } - if !stat.Mode().IsRegular() { - return "", nil, output.ErrValidation("file must be a regular file: %s", filePath) - } - return filePath, stat, nil -} - -func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, error) { - token := runtime.Str("spreadsheet-token") - if u := runtime.Str("url"); u != "" { - if parsed := extractSpreadsheetToken(u); parsed != "" { - token = parsed - } - } - if token == "" { - return "", common.FlagErrorf("specify --url or --spreadsheet-token") - } - return token, nil -} - -func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, parentNode string) (string, error) { - if fileSize <= common.MaxDriveMediaUploadSinglePartSize { - pn := parentNode - return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ - FilePath: filePath, - FileName: fileName, - FileSize: fileSize, - ParentType: sheetImageParentType, - ParentNode: &pn, - }) - } - return common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{ - FilePath: filePath, - FileName: fileName, - FileSize: fileSize, - ParentType: sheetImageParentType, - ParentNode: parentNode, - }) -} - -func sheetMediaShouldUseMultipart(fio fileio.FileIO, filePath string) bool { - info, err := fio.Stat(filePath) - if err != nil { - return false - } - return info.Mode().IsRegular() && info.Size() > common.MaxDriveMediaUploadSinglePartSize -} - -func floatImageBasePath(token, sheetID string) string { - return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/float_images", - validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)) -} - -func floatImageItemPath(token, sheetID, floatImageID string) string { - return fmt.Sprintf("%s/%s", floatImageBasePath(token, sheetID), validate.EncodePathSegment(floatImageID)) -} - -func validateFloatImageToken(runtime *common.RuntimeContext) (string, error) { - token := runtime.Str("spreadsheet-token") - if u := runtime.Str("url"); u != "" { - if parsed := extractSpreadsheetToken(u); parsed != u { - token = parsed - } - } - if token == "" { - return "", common.FlagErrorf("specify --url or --spreadsheet-token") - } - return token, nil -} - -func validateFloatImageRange(sheetID, rangeVal string) error { - if rangeVal == "" { - return nil - } - if err := validateSingleCellRange(rangeVal); err != nil { - return err - } - if prefix, _, ok := splitSheetRange(rangeVal); ok && sheetID != "" && prefix != sheetID { - return common.FlagErrorf("--range prefix %q does not match --sheet-id %q", prefix, sheetID) - } - return nil -} - -func validateFloatImageUpdatePayload(runtime *common.RuntimeContext) error { - hasField := runtime.Str("range") != "" || - runtime.Cmd.Flags().Changed("width") || - runtime.Cmd.Flags().Changed("height") || - runtime.Cmd.Flags().Changed("offset-x") || - runtime.Cmd.Flags().Changed("offset-y") - if !hasField { - return common.FlagErrorf("specify at least one of --range, --width, --height, --offset-x, --offset-y to update") - } - return nil -} - -func validateFloatImageDims(runtime *common.RuntimeContext) error { - if runtime.Cmd.Flags().Changed("width") { - if v := runtime.Int("width"); v < 20 { - return common.FlagErrorf("--width must be >= 20 pixels, got %d", v) - } - } - if runtime.Cmd.Flags().Changed("height") { - if v := runtime.Int("height"); v < 20 { - return common.FlagErrorf("--height must be >= 20 pixels, got %d", v) - } - } - if runtime.Cmd.Flags().Changed("offset-x") { - if v := runtime.Int("offset-x"); v < 0 { - return common.FlagErrorf("--offset-x must be >= 0, got %d", v) - } - } - if runtime.Cmd.Flags().Changed("offset-y") { - if v := runtime.Int("offset-y"); v < 0 { - return common.FlagErrorf("--offset-y must be >= 0, got %d", v) - } - } - return nil -} - -func buildFloatImageBody(runtime *common.RuntimeContext, includeToken bool) map[string]interface{} { - body := map[string]interface{}{} - if includeToken { - if s := runtime.Str("float-image-token"); s != "" { - body["float_image_token"] = s - } - } - if s := runtime.Str("range"); s != "" { - body["range"] = s - } - if runtime.Cmd.Flags().Changed("width") { - body["width"] = runtime.Int("width") - } - if runtime.Cmd.Flags().Changed("height") { - body["height"] = runtime.Int("height") - } - if runtime.Cmd.Flags().Changed("offset-x") { - body["offset_x"] = runtime.Int("offset-x") - } - if runtime.Cmd.Flags().Changed("offset-y") { - body["offset_y"] = runtime.Int("offset-y") - } - return body -} - -var SheetCreateFloatImage = common.Shortcut{ - Service: "sheets", - Command: "+create-float-image", - Description: "Create a floating image on a sheet", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - {Name: "float-image-token", Desc: "image file token (from upload API)", Required: true}, - {Name: "range", Desc: "anchor cell, must be a single cell (e.g. sheetId!A1:A1)", Required: true}, - {Name: "width", Type: "int", Desc: "width in pixels (>=20)"}, - {Name: "height", Type: "int", Desc: "height in pixels (>=20)"}, - {Name: "offset-x", Type: "int", Desc: "horizontal offset from anchor cell's top-left (pixels, >=0)"}, - {Name: "offset-y", Type: "int", Desc: "vertical offset from anchor cell's top-left (pixels, >=0)"}, - {Name: "float-image-id", Desc: "custom 10-char alphanumeric ID (auto-generated if omitted)"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateFloatImageToken(runtime); err != nil { - return err - } - if err := validateFloatImageRange(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { - return err - } - return validateFloatImageDims(runtime) - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateFloatImageToken(runtime) - body := buildFloatImageBody(runtime, true) - if s := runtime.Str("float-image-id"); s != "" { - body["float_image_id"] = s - } - return common.NewDryRunAPI(). - POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images"). - Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateFloatImageToken(runtime) - body := buildFloatImageBody(runtime, true) - if s := runtime.Str("float-image-id"); s != "" { - body["float_image_id"] = s - } - data, err := runtime.CallAPI("POST", floatImageBasePath(token, runtime.Str("sheet-id")), nil, body) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetUpdateFloatImage = common.Shortcut{ - Service: "sheets", - Command: "+update-float-image", - Description: "Update a floating image", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - {Name: "float-image-id", Desc: "float image ID", Required: true}, - {Name: "range", Desc: "new anchor cell, must be a single cell (e.g. sheetId!B2:B2)"}, - {Name: "width", Type: "int", Desc: "width in pixels (>=20)"}, - {Name: "height", Type: "int", Desc: "height in pixels (>=20)"}, - {Name: "offset-x", Type: "int", Desc: "horizontal offset from anchor cell's top-left (pixels, >=0)"}, - {Name: "offset-y", Type: "int", Desc: "vertical offset from anchor cell's top-left (pixels, >=0)"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateFloatImageToken(runtime); err != nil { - return err - } - if err := validateFloatImageUpdatePayload(runtime); err != nil { - return err - } - if err := validateFloatImageRange(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { - return err - } - return validateFloatImageDims(runtime) - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateFloatImageToken(runtime) - body := buildFloatImageBody(runtime, false) - return common.NewDryRunAPI(). - PATCH("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id"). - Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateFloatImageToken(runtime) - body := buildFloatImageBody(runtime, false) - data, err := runtime.CallAPI("PATCH", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, body) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetGetFloatImage = common.Shortcut{ - Service: "sheets", - Command: "+get-float-image", - Description: "Get a floating image by ID", - Risk: "read", - Scopes: []string{"sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - {Name: "float-image-id", Desc: "float image ID", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - _, err := validateFloatImageToken(runtime) - return err - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateFloatImageToken(runtime) - return common.NewDryRunAPI(). - GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id"). - Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateFloatImageToken(runtime) - data, err := runtime.CallAPI("GET", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetListFloatImages = common.Shortcut{ - Service: "sheets", - Command: "+list-float-images", - Description: "List all floating images in a sheet", - Risk: "read", - Scopes: []string{"sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - _, err := validateFloatImageToken(runtime) - return err - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateFloatImageToken(runtime) - return common.NewDryRunAPI(). - GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/query"). - Set("token", token).Set("sheet_id", runtime.Str("sheet-id")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateFloatImageToken(runtime) - data, err := runtime.CallAPI("GET", floatImageBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetDeleteFloatImage = common.Shortcut{ - Service: "sheets", - Command: "+delete-float-image", - Description: "Delete a floating image", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - {Name: "float-image-id", Desc: "float image ID", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - _, err := validateFloatImageToken(runtime) - return err - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateFloatImageToken(runtime) - return common.NewDryRunAPI(). - DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id"). - Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateFloatImageToken(runtime) - data, err := runtime.CallAPI("DELETE", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} diff --git a/shortcuts/sheets/lark_sheets_row_column_management.go b/shortcuts/sheets/lark_sheets_row_column_management.go deleted file mode 100644 index 5d5f9dec5..000000000 --- a/shortcuts/sheets/lark_sheets_row_column_management.go +++ /dev/null @@ -1,369 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "context" - "fmt" - - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/shortcuts/common" -) - -var SheetAddDimension = common.Shortcut{ - Service: "sheets", - Command: "+add-dimension", - Description: "Add rows or columns at the end of a sheet", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "sheet-id", Desc: "worksheet ID", Required: true}, - {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, - {Name: "length", Type: "int", Desc: "number of rows/columns to add (1-5000)", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateSheetManageToken(runtime); err != nil { - return err - } - length := runtime.Int("length") - if length < 1 || length > 5000 { - return common.FlagErrorf("--length must be between 1 and 5000, got %d", length) - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateSheetManageToken(runtime) - return common.NewDryRunAPI(). - POST("/open-apis/sheets/v2/spreadsheets/:token/dimension_range"). - Body(map[string]interface{}{ - "dimension": map[string]interface{}{ - "sheetId": runtime.Str("sheet-id"), - "majorDimension": runtime.Str("dimension"), - "length": runtime.Int("length"), - }, - }). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateSheetManageToken(runtime) - - data, err := runtime.CallAPI("POST", - fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)), - nil, - map[string]interface{}{ - "dimension": map[string]interface{}{ - "sheetId": runtime.Str("sheet-id"), - "majorDimension": runtime.Str("dimension"), - "length": runtime.Int("length"), - }, - }, - ) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetInsertDimension = common.Shortcut{ - Service: "sheets", - Command: "+insert-dimension", - Description: "Insert rows or columns at a specified position", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "sheet-id", Desc: "worksheet ID", Required: true}, - {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, - {Name: "start-index", Type: "int", Desc: "start position (0-indexed)", Required: true}, - {Name: "end-index", Type: "int", Desc: "end position (0-indexed, exclusive)", Required: true}, - {Name: "inherit-style", Desc: "style inheritance: BEFORE or AFTER", Enum: []string{"BEFORE", "AFTER"}}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateSheetManageToken(runtime); err != nil { - return err - } - if runtime.Int("start-index") < 0 { - return common.FlagErrorf("--start-index must be >= 0") - } - if runtime.Int("end-index") <= runtime.Int("start-index") { - return common.FlagErrorf("--end-index must be greater than --start-index") - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateSheetManageToken(runtime) - body := map[string]interface{}{ - "dimension": map[string]interface{}{ - "sheetId": runtime.Str("sheet-id"), - "majorDimension": runtime.Str("dimension"), - "startIndex": runtime.Int("start-index"), - "endIndex": runtime.Int("end-index"), - }, - } - if s := runtime.Str("inherit-style"); s != "" { - body["inheritStyle"] = s - } - return common.NewDryRunAPI(). - POST("/open-apis/sheets/v2/spreadsheets/:token/insert_dimension_range"). - Body(body). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateSheetManageToken(runtime) - - body := map[string]interface{}{ - "dimension": map[string]interface{}{ - "sheetId": runtime.Str("sheet-id"), - "majorDimension": runtime.Str("dimension"), - "startIndex": runtime.Int("start-index"), - "endIndex": runtime.Int("end-index"), - }, - } - if s := runtime.Str("inherit-style"); s != "" { - body["inheritStyle"] = s - } - - data, err := runtime.CallAPI("POST", - fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/insert_dimension_range", validate.EncodePathSegment(token)), - nil, body, - ) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetUpdateDimension = common.Shortcut{ - Service: "sheets", - Command: "+update-dimension", - Description: "Update row or column properties (visibility, size)", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "sheet-id", Desc: "worksheet ID", Required: true}, - {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, - {Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true}, - {Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true}, - {Name: "visible", Type: "bool", Desc: "true to show, false to hide"}, - {Name: "fixed-size", Type: "int", Desc: "row height or column width in pixels"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateSheetManageToken(runtime); err != nil { - return err - } - if runtime.Int("start-index") < 1 { - return common.FlagErrorf("--start-index must be >= 1") - } - if runtime.Int("end-index") < runtime.Int("start-index") { - return common.FlagErrorf("--end-index must be >= --start-index") - } - if !runtime.Cmd.Flags().Changed("visible") && !runtime.Cmd.Flags().Changed("fixed-size") { - return common.FlagErrorf("specify at least one of --visible or --fixed-size") - } - if runtime.Cmd.Flags().Changed("fixed-size") && runtime.Int("fixed-size") < 1 { - return common.FlagErrorf("--fixed-size must be >= 1") - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateSheetManageToken(runtime) - props := map[string]interface{}{} - if runtime.Cmd.Flags().Changed("visible") { - props["visible"] = runtime.Bool("visible") - } - if runtime.Cmd.Flags().Changed("fixed-size") { - props["fixedSize"] = runtime.Int("fixed-size") - } - return common.NewDryRunAPI(). - PUT("/open-apis/sheets/v2/spreadsheets/:token/dimension_range"). - Body(map[string]interface{}{ - "dimension": map[string]interface{}{ - "sheetId": runtime.Str("sheet-id"), - "majorDimension": runtime.Str("dimension"), - "startIndex": runtime.Int("start-index"), - "endIndex": runtime.Int("end-index"), - }, - "dimensionProperties": props, - }). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateSheetManageToken(runtime) - - props := map[string]interface{}{} - if runtime.Cmd.Flags().Changed("visible") { - props["visible"] = runtime.Bool("visible") - } - if runtime.Cmd.Flags().Changed("fixed-size") { - props["fixedSize"] = runtime.Int("fixed-size") - } - - data, err := runtime.CallAPI("PUT", - fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)), - nil, - map[string]interface{}{ - "dimension": map[string]interface{}{ - "sheetId": runtime.Str("sheet-id"), - "majorDimension": runtime.Str("dimension"), - "startIndex": runtime.Int("start-index"), - "endIndex": runtime.Int("end-index"), - }, - "dimensionProperties": props, - }, - ) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetMoveDimension = common.Shortcut{ - Service: "sheets", - Command: "+move-dimension", - Description: "Move rows or columns to a new position", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "sheet-id", Desc: "worksheet ID", Required: true}, - {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, - {Name: "start-index", Type: "int", Desc: "source start position (0-indexed)", Required: true}, - {Name: "end-index", Type: "int", Desc: "source end position (0-indexed, inclusive)", Required: true}, - {Name: "destination-index", Type: "int", Desc: "target position to move to (0-indexed)", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateSheetManageToken(runtime); err != nil { - return err - } - if runtime.Int("start-index") < 0 { - return common.FlagErrorf("--start-index must be >= 0") - } - if runtime.Int("end-index") < runtime.Int("start-index") { - return common.FlagErrorf("--end-index must be >= --start-index") - } - if runtime.Int("destination-index") < 0 { - return common.FlagErrorf("--destination-index must be >= 0") - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateSheetManageToken(runtime) - return common.NewDryRunAPI(). - POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/move_dimension"). - Body(map[string]interface{}{ - "source": map[string]interface{}{ - "major_dimension": runtime.Str("dimension"), - "start_index": runtime.Int("start-index"), - "end_index": runtime.Int("end-index"), - }, - "destination_index": runtime.Int("destination-index"), - }). - Set("token", token). - Set("sheet_id", runtime.Str("sheet-id")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateSheetManageToken(runtime) - - data, err := runtime.CallAPI("POST", - fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/move_dimension", - validate.EncodePathSegment(token), - validate.EncodePathSegment(runtime.Str("sheet-id")), - ), - nil, - map[string]interface{}{ - "source": map[string]interface{}{ - "major_dimension": runtime.Str("dimension"), - "start_index": runtime.Int("start-index"), - "end_index": runtime.Int("end-index"), - }, - "destination_index": runtime.Int("destination-index"), - }, - ) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetDeleteDimension = common.Shortcut{ - Service: "sheets", - Command: "+delete-dimension", - Description: "Delete rows or columns", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "sheet-id", Desc: "worksheet ID", Required: true}, - {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, - {Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true}, - {Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateSheetManageToken(runtime); err != nil { - return err - } - if runtime.Int("start-index") < 1 { - return common.FlagErrorf("--start-index must be >= 1") - } - if runtime.Int("end-index") < runtime.Int("start-index") { - return common.FlagErrorf("--end-index must be >= --start-index") - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateSheetManageToken(runtime) - return common.NewDryRunAPI(). - DELETE("/open-apis/sheets/v2/spreadsheets/:token/dimension_range"). - Body(map[string]interface{}{ - "dimension": map[string]interface{}{ - "sheetId": runtime.Str("sheet-id"), - "majorDimension": runtime.Str("dimension"), - "startIndex": runtime.Int("start-index"), - "endIndex": runtime.Int("end-index"), - }, - }). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateSheetManageToken(runtime) - - data, err := runtime.CallAPI("DELETE", - fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)), - nil, - map[string]interface{}{ - "dimension": map[string]interface{}{ - "sheetId": runtime.Str("sheet-id"), - "majorDimension": runtime.Str("dimension"), - "startIndex": runtime.Int("start-index"), - "endIndex": runtime.Int("end-index"), - }, - }, - ) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} diff --git a/shortcuts/sheets/lark_sheets_sheet_cell_ops_test.go b/shortcuts/sheets/lark_sheets_sheet_cell_ops_test.go deleted file mode 100644 index 708fc9f1a..000000000 --- a/shortcuts/sheets/lark_sheets_sheet_cell_ops_test.go +++ /dev/null @@ -1,781 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "context" - "encoding/json" - "strings" - "testing" - - "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/httpmock" -) - -// ── MergeCells ─────────────────────────────────────────────────────────────── - -func TestSheetMergeCellsValidateMissingToken(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "", "merge-type": "MERGE_ALL", - }, nil) - err := SheetMergeCells.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { - t.Fatalf("expected token error, got: %v", err) - } -} - -func TestSheetMergeCellsValidateRelativeRangeWithoutSheetID(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "range": "A1:B2", "sheet-id": "", "merge-type": "MERGE_ALL", - }, nil) - err := SheetMergeCells.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--sheet-id") { - t.Fatalf("expected sheet-id error, got: %v", err) - } -} - -func TestSheetMergeCellsValidateSuccess(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "", "merge-type": "MERGE_ROWS", - }, nil) - if err := SheetMergeCells.Validate(context.Background(), rt); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetMergeCellsDryRun(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "range": "A1:B2", "sheet-id": "sheet1", "merge-type": "MERGE_ALL", - }, nil) - got := mustMarshalSheetsDryRun(t, SheetMergeCells.DryRun(context.Background(), rt)) - if !strings.Contains(got, `merge_cells`) { - t.Fatalf("DryRun URL missing merge_cells: %s", got) - } - if !strings.Contains(got, `"range":"sheet1!A1:B2"`) { - t.Fatalf("DryRun range not normalized: %s", got) - } - if !strings.Contains(got, `"mergeType":"MERGE_ALL"`) { - t.Fatalf("DryRun missing mergeType: %s", got) - } -} - -func TestSheetMergeCellsExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/merge_cells", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"spreadsheetToken": "shtTOKEN"}}, - }) - err := mountAndRunSheets(t, SheetMergeCells, []string{ - "+merge-cells", "--spreadsheet-token", "shtTOKEN", - "--range", "sheet1!A1:B2", "--merge-type", "MERGE_ALL", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(stdout.String(), "spreadsheetToken") { - t.Fatalf("stdout missing spreadsheetToken: %s", stdout.String()) - } -} - -func TestSheetMergeCellsExecuteAPIError(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/merge_cells", - Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"}, - }) - err := mountAndRunSheets(t, SheetMergeCells, []string{ - "+merge-cells", "--spreadsheet-token", "shtTOKEN", - "--range", "sheet1!A1:B2", "--merge-type", "MERGE_ALL", "--as", "user", - }, f, nil) - if err == nil { - t.Fatal("expected error") - } -} - -// ── UnmergeCells ───────────────────────────────────────────────────────────── - -func TestSheetUnmergeCellsValidateMissingToken(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "", - }, nil) - err := SheetUnmergeCells.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { - t.Fatalf("expected token error, got: %v", err) - } -} - -func TestSheetUnmergeCellsValidateSuccess(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "", - }, nil) - if err := SheetUnmergeCells.Validate(context.Background(), rt); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetUnmergeCellsDryRun(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "range": "sheet1!A1:B2", "sheet-id": "", - }, nil) - got := mustMarshalSheetsDryRun(t, SheetUnmergeCells.DryRun(context.Background(), rt)) - if !strings.Contains(got, `unmerge_cells`) { - t.Fatalf("DryRun URL missing unmerge_cells: %s", got) - } - if !strings.Contains(got, `"range":"sheet1!A1:B2"`) { - t.Fatalf("DryRun missing range: %s", got) - } -} - -func TestSheetUnmergeCellsExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/unmerge_cells", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"spreadsheetToken": "shtTOKEN"}}, - }) - err := mountAndRunSheets(t, SheetUnmergeCells, []string{ - "+unmerge-cells", "--spreadsheet-token", "shtTOKEN", - "--range", "sheet1!A1:B2", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetUnmergeCellsExecuteAPIError(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/unmerge_cells", - Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"}, - }) - err := mountAndRunSheets(t, SheetUnmergeCells, []string{ - "+unmerge-cells", "--spreadsheet-token", "shtTOKEN", - "--range", "sheet1!A1:B2", "--as", "user", - }, f, nil) - if err == nil { - t.Fatal("expected error") - } -} - -// ── Replace ────────────────────────────────────────────────────────────────── - -func TestSheetReplaceValidateMissingToken(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "", "sheet-id": "s1", "find": "a", "replacement": "b", "range": "", - }, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false}) - err := SheetReplace.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { - t.Fatalf("expected token error, got: %v", err) - } -} - -func TestSheetReplaceValidateSuccess(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "find": "hello", "replacement": "world", "range": "", - }, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false}) - if err := SheetReplace.Validate(context.Background(), rt); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetReplaceValidateMismatchedRangeSheetID(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", "find": "a", "replacement": "b", - "range": "sheet2!A1:B2", - }, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false}) - err := SheetReplace.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "does not match") { - t.Fatalf("expected mismatch error, got: %v", err) - } -} - -func TestSheetReplaceValidateMatchingRangeSheetID(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", "find": "a", "replacement": "b", - "range": "sheet1!A1:B2", - }, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false}) - if err := SheetReplace.Validate(context.Background(), rt); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetReplaceDryRun(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "find": "old", "replacement": "new", "range": "A1:C5", - }, map[string]bool{"match-case": true, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false}) - got := mustMarshalSheetsDryRun(t, SheetReplace.DryRun(context.Background(), rt)) - if !strings.Contains(got, `replace`) { - t.Fatalf("DryRun URL missing replace: %s", got) - } - if !strings.Contains(got, `"find":"old"`) { - t.Fatalf("DryRun missing find: %s", got) - } - if !strings.Contains(got, `"replacement":"new"`) { - t.Fatalf("DryRun missing replacement: %s", got) - } - if !strings.Contains(got, `"match_case":true`) { - t.Fatalf("DryRun missing match_case: %s", got) - } -} - -func TestSheetReplaceDryRunNoRange(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "find": "a", "replacement": "b", "range": "", - }, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false}) - got := mustMarshalSheetsDryRun(t, SheetReplace.DryRun(context.Background(), rt)) - // When no range specified, range defaults to sheet-id - if !strings.Contains(got, `"range":"sheet1"`) { - t.Fatalf("DryRun range should default to sheet-id: %s", got) - } -} - -func TestSheetReplaceExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - stub := &httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/replace", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "replace_result": map[string]interface{}{ - "matched_cells": []interface{}{"A1"}, "rows_count": float64(1), - }, - }}, - } - reg.Register(stub) - err := mountAndRunSheets(t, SheetReplace, []string{ - "+replace", "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", "--find", "hello", "--replacement", "world", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(stdout.String(), "matched_cells") { - t.Fatalf("stdout missing matched_cells: %s", stdout.String()) - } - var body map[string]interface{} - if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { - t.Fatalf("parse body: %v", err) - } - if body["find"] != "hello" || body["replacement"] != "world" { - t.Fatalf("unexpected body: %#v", body) - } -} - -func TestSheetReplaceExecuteAPIError(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/replace", - Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"}, - }) - err := mountAndRunSheets(t, SheetReplace, []string{ - "+replace", "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", "--find", "a", "--replacement", "b", "--as", "user", - }, f, nil) - if err == nil { - t.Fatal("expected error") - } -} - -// ── SetStyle ───────────────────────────────────────────────────────────────── - -func TestSheetSetStyleValidateMissingToken(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "", - "style": `{"font":{"bold":true}}`, - }, nil) - err := SheetSetStyle.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { - t.Fatalf("expected token error, got: %v", err) - } -} - -func TestSheetSetStyleValidateInvalidJSON(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "", - "style": `{invalid}`, - }, nil) - err := SheetSetStyle.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--style must be valid JSON") { - t.Fatalf("expected JSON error, got: %v", err) - } -} - -func TestSheetSetStyleValidateRejectsArray(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "", - "style": `[{"bold":true}]`, - }, nil) - err := SheetSetStyle.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "JSON object") { - t.Fatalf("expected object error, got: %v", err) - } -} - -func TestSheetSetStyleValidateRejectsString(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "", - "style": `"bold"`, - }, nil) - err := SheetSetStyle.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "JSON object") { - t.Fatalf("expected object error, got: %v", err) - } -} - -func TestSheetSetStyleValidateRejectsNull(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "", - "style": `null`, - }, nil) - err := SheetSetStyle.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "JSON object") { - t.Fatalf("expected object error, got: %v", err) - } -} - -func TestSheetSetStyleValidateSuccess(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "", - "style": `{"font":{"bold":true},"backColor":"#ff0000"}`, - }, nil) - if err := SheetSetStyle.Validate(context.Background(), rt); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetSetStyleDryRun(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "range": "A1:B2", "sheet-id": "sheet1", - "style": `{"font":{"bold":true}}`, - }, nil) - got := mustMarshalSheetsDryRun(t, SheetSetStyle.DryRun(context.Background(), rt)) - if !strings.Contains(got, `"method":"PUT"`) { - t.Fatalf("DryRun should use PUT: %s", got) - } - if !strings.Contains(got, `/style`) { - t.Fatalf("DryRun URL missing /style: %s", got) - } - if !strings.Contains(got, `"range":"sheet1!A1:B2"`) { - t.Fatalf("DryRun range not normalized: %s", got) - } - if !strings.Contains(got, `"bold":true`) { - t.Fatalf("DryRun missing style: %s", got) - } -} - -func TestSheetSetStyleExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - stub := &httpmock.Stub{ - Method: "PUT", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "updates": map[string]interface{}{"updatedCells": float64(4), "updatedRange": "sheet1!A1:B2"}, - }}, - } - reg.Register(stub) - err := mountAndRunSheets(t, SheetSetStyle, []string{ - "+set-style", "--spreadsheet-token", "shtTOKEN", - "--range", "sheet1!A1:B2", "--style", `{"font":{"bold":true}}`, "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(stdout.String(), "updatedCells") { - t.Fatalf("stdout missing updatedCells: %s", stdout.String()) - } - var body map[string]interface{} - if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { - t.Fatalf("parse body: %v", err) - } - appendStyle, _ := body["appendStyle"].(map[string]interface{}) - if appendStyle["range"] != "sheet1!A1:B2" { - t.Fatalf("unexpected range: %v", appendStyle["range"]) - } -} - -func TestSheetSetStyleDryRunExpandsSingleCell(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "range": "A1", "sheet-id": "sheet1", - "style": `{"font":{"bold":true}}`, - }, nil) - got := mustMarshalSheetsDryRun(t, SheetSetStyle.DryRun(context.Background(), rt)) - if !strings.Contains(got, `"range":"sheet1!A1:A1"`) { - t.Fatalf("DryRun should expand single cell to A1:A1: %s", got) - } -} - -func TestSheetSetStyleExecuteExpandsSingleCell(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - stub := &httpmock.Stub{ - Method: "PUT", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "updates": map[string]interface{}{"updatedCells": float64(1), "updatedRange": "sheet1!A1:A1"}, - }}, - } - reg.Register(stub) - err := mountAndRunSheets(t, SheetSetStyle, []string{ - "+set-style", "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", "--range", "A1", - "--style", `{"font":{"bold":true}}`, "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - var body map[string]interface{} - if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { - t.Fatalf("parse body: %v", err) - } - appendStyle, _ := body["appendStyle"].(map[string]interface{}) - if appendStyle["range"] != "sheet1!A1:A1" { - t.Fatalf("single cell should be expanded to sheet1!A1:A1, got: %v", appendStyle["range"]) - } -} - -func TestSheetSetStyleExecuteAPIError(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style", - Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"}, - }) - err := mountAndRunSheets(t, SheetSetStyle, []string{ - "+set-style", "--spreadsheet-token", "shtTOKEN", - "--range", "sheet1!A1:B2", "--style", `{"font":{"bold":true}}`, "--as", "user", - }, f, nil) - if err == nil { - t.Fatal("expected error") - } -} - -// ── BatchSetStyle ──────────────────────────────────────────────────────────── - -func TestSheetBatchSetStyleValidateMissingToken(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "", - "data": `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`, - }, nil) - err := SheetBatchSetStyle.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { - t.Fatalf("expected token error, got: %v", err) - } -} - -func TestSheetBatchSetStyleValidateInvalidJSON(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "data": `not-json`, - }, nil) - err := SheetBatchSetStyle.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--data must be valid JSON") { - t.Fatalf("expected JSON error, got: %v", err) - } -} - -func TestSheetBatchSetStyleValidateNotArray(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "data": `{"not":"array"}`, - }, nil) - err := SheetBatchSetStyle.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "non-empty JSON array") { - t.Fatalf("expected array error, got: %v", err) - } -} - -func TestSheetBatchSetStyleValidateEmptyArray(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "data": `[]`, - }, nil) - err := SheetBatchSetStyle.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "non-empty JSON array") { - t.Fatalf("expected empty array error, got: %v", err) - } -} - -func TestSheetBatchSetStyleValidateSuccess(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", - "data": `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`, - }, nil) - if err := SheetBatchSetStyle.Validate(context.Background(), rt); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetBatchSetStyleValidateRejectsMalformedEntries(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - data string - wantSubst string - }{ - { - name: "entry must be object", - data: `["bad"]`, - wantSubst: "must be an object with ranges and style", - }, - { - name: "ranges required", - data: `[{"style":{}}]`, - wantSubst: ".ranges is required", - }, - { - name: "ranges must be array", - data: `[{"ranges":"sheet1!A1","style":{}}]`, - wantSubst: ".ranges must be a non-empty array of strings", - }, - { - name: "ranges must not be empty", - data: `[{"ranges":[],"style":{}}]`, - wantSubst: ".ranges must be a non-empty array of strings", - }, - { - name: "range must include sheet prefix", - data: `[{"ranges":["A1"],"style":{}}]`, - wantSubst: "must include a sheetId! prefix", - }, - { - name: "style required", - data: `[{"ranges":["sheet1!A1:B2"]}]`, - wantSubst: ".style is required", - }, - { - name: "style must be object", - data: `[{"ranges":["sheet1!A1:B2"],"style":"bad"}]`, - wantSubst: ".style must be a JSON object", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "data": tt.data, - }, nil) - err := SheetBatchSetStyle.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), tt.wantSubst) { - t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err) - } - }) - } -} - -func TestSheetBatchSetStyleDryRun(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", - "data": `[{"ranges":["sheet1!A1:B2"],"style":{"backColor":"#ff0000"}}]`, - }, nil) - got := mustMarshalSheetsDryRun(t, SheetBatchSetStyle.DryRun(context.Background(), rt)) - if !strings.Contains(got, `styles_batch_update`) { - t.Fatalf("DryRun URL missing styles_batch_update: %s", got) - } - if !strings.Contains(got, `"method":"PUT"`) { - t.Fatalf("DryRun should use PUT: %s", got) - } -} - -func TestSheetBatchSetStyleExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "PUT", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "totalUpdatedCells": float64(4), "revision": float64(90), - }}, - }) - err := mountAndRunSheets(t, SheetBatchSetStyle, []string{ - "+batch-set-style", "--spreadsheet-token", "shtTOKEN", - "--data", `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`, "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(stdout.String(), "totalUpdatedCells") { - t.Fatalf("stdout missing totalUpdatedCells: %s", stdout.String()) - } -} - -func TestSheetBatchSetStyleDryRunExpandsSingleCells(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", - "data": `[{"ranges":["sheet1!A2","sheet1!B2"],"style":{"font":{"bold":true}}}]`, - }, nil) - got := mustMarshalSheetsDryRun(t, SheetBatchSetStyle.DryRun(context.Background(), rt)) - if !strings.Contains(got, `"sheet1!A2:A2"`) || !strings.Contains(got, `"sheet1!B2:B2"`) { - t.Fatalf("DryRun should expand single cells to A2:A2 and B2:B2: %s", got) - } -} - -func TestSheetBatchSetStyleExecuteNormalizesMixedRanges(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - stub := &httpmock.Stub{ - Method: "PUT", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "totalUpdatedCells": float64(5), - }}, - } - reg.Register(stub) - err := mountAndRunSheets(t, SheetBatchSetStyle, []string{ - "+batch-set-style", "--spreadsheet-token", "shtTOKEN", - "--data", `[{"ranges":["sheet1!C1:D2","sheet1!E3"],"style":{"font":{"italic":true}}}]`, - "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - var body map[string]interface{} - if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { - t.Fatalf("parse body: %v", err) - } - data, _ := body["data"].([]interface{}) - if len(data) != 1 { - t.Fatalf("expected 1 data entry, got %d", len(data)) - } - entry, _ := data[0].(map[string]interface{}) - ranges, _ := entry["ranges"].([]interface{}) - if len(ranges) != 2 || ranges[0] != "sheet1!C1:D2" || ranges[1] != "sheet1!E3:E3" { - t.Fatalf("ranges should preserve span and expand single cell, got: %v", ranges) - } -} - -func TestSheetBatchSetStyleExecuteAPIError(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update", - Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"}, - }) - err := mountAndRunSheets(t, SheetBatchSetStyle, []string{ - "+batch-set-style", "--spreadsheet-token", "shtTOKEN", - "--data", `[{"ranges":["sheet1!A1:B2"],"style":{}}]`, "--as", "user", - }, f, nil) - if err == nil { - t.Fatal("expected error") - } -} - -func TestNormalizeBatchStyleRanges(t *testing.T) { - t.Parallel() - - t.Run("single cell with sheet prefix is expanded in place", func(t *testing.T) { - t.Parallel() - data := []interface{}{ - map[string]interface{}{ - "ranges": []interface{}{"sheet1!A1", "sheet1!B2"}, - "style": map[string]interface{}{"font": map[string]interface{}{"bold": true}}, - }, - } - normalizeBatchStyleRanges(data) - got := data[0].(map[string]interface{})["ranges"].([]interface{}) - if got[0] != "sheet1!A1:A1" || got[1] != "sheet1!B2:B2" { - t.Fatalf("want [sheet1!A1:A1 sheet1!B2:B2], got %v", got) - } - }) - - t.Run("multi-cell span passes through unchanged", func(t *testing.T) { - t.Parallel() - data := []interface{}{ - map[string]interface{}{ - "ranges": []interface{}{"sheet1!A1:B2"}, - }, - } - normalizeBatchStyleRanges(data) - got := data[0].(map[string]interface{})["ranges"].([]interface{}) - if got[0] != "sheet1!A1:B2" { - t.Fatalf("multi-cell span should be unchanged, got %v", got[0]) - } - }) - - t.Run("bare single cell without sheet prefix passes through", func(t *testing.T) { - t.Parallel() - // Without a sheetId! prefix there's no sheet context; entry is left - // alone and the backend will reject it. Documented in the helper. - data := []interface{}{ - map[string]interface{}{ - "ranges": []interface{}{"A1"}, - }, - } - normalizeBatchStyleRanges(data) - got := data[0].(map[string]interface{})["ranges"].([]interface{}) - if got[0] != "A1" { - t.Fatalf("bare single cell should pass through, got %v", got[0]) - } - }) - - t.Run("non-string entries are preserved", func(t *testing.T) { - t.Parallel() - data := []interface{}{ - map[string]interface{}{ - "ranges": []interface{}{"sheet1!A1", 42, nil, "sheet1!B2"}, - }, - } - normalizeBatchStyleRanges(data) - got := data[0].(map[string]interface{})["ranges"].([]interface{}) - if got[0] != "sheet1!A1:A1" { - t.Fatalf("first entry should be expanded, got %v", got[0]) - } - if got[1] != 42 { - t.Fatalf("int entry should be preserved, got %v", got[1]) - } - if got[2] != nil { - t.Fatalf("nil entry should be preserved, got %v", got[2]) - } - if got[3] != "sheet1!B2:B2" { - t.Fatalf("last entry should be expanded, got %v", got[3]) - } - }) - - t.Run("missing or non-array ranges key is skipped", func(t *testing.T) { - t.Parallel() - data := []interface{}{ - map[string]interface{}{ - "style": map[string]interface{}{"font": map[string]interface{}{"bold": true}}, - }, - map[string]interface{}{ - "ranges": "not-an-array", - }, - "not-a-map", - } - normalizeBatchStyleRanges(data) - if data[1].(map[string]interface{})["ranges"] != "not-an-array" { - t.Fatal("non-array ranges should be left alone") - } - }) - - t.Run("top-level non-array inputs do not panic", func(t *testing.T) { - t.Parallel() - // Any of these would panic if the helper didn't guard its type assertions. - normalizeBatchStyleRanges(nil) - normalizeBatchStyleRanges(map[string]interface{}{"foo": "bar"}) - normalizeBatchStyleRanges("string") - normalizeBatchStyleRanges(42) - }) -} diff --git a/shortcuts/sheets/lark_sheets_sheet_create_test.go b/shortcuts/sheets/lark_sheets_sheet_create_test.go deleted file mode 100644 index 9863abf21..000000000 --- a/shortcuts/sheets/lark_sheets_sheet_create_test.go +++ /dev/null @@ -1,306 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "bytes" - "context" - "encoding/json" - "strings" - "testing" - - "github.com/spf13/cobra" - - "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/httpmock" - "github.com/larksuite/cli/shortcuts/common" -) - -func TestSheetCreateBotAutoGrantSuccess(t *testing.T) { - t.Parallel() - - f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "ou_current_user")) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v3/spreadsheets", - Body: map[string]interface{}{ - "code": 0, - "msg": "ok", - "data": map[string]interface{}{ - "spreadsheet": map[string]interface{}{ - "spreadsheet_token": "shtcn_new_sheet", - "url": "https://example.feishu.cn/sheets/shtcn_new_sheet", - }, - }, - }, - }) - - permStub := &httpmock.Stub{ - Method: "POST", - URL: "/open-apis/drive/v1/permissions/shtcn_new_sheet/members", - Body: map[string]interface{}{ - "code": 0, - "msg": "ok", - }, - } - reg.Register(permStub) - - err := runSheetCreateShortcut(t, f, stdout, []string{ - "+create", - "--title", "项目排期", - "--as", "bot", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data := decodeSheetCreateEnvelope(t, stdout) - grant, _ := data["permission_grant"].(map[string]interface{}) - if grant["status"] != common.PermissionGrantGranted { - t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted) - } - if grant["user_open_id"] != "ou_current_user" { - t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user") - } - if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new spreadsheet." { - t.Fatalf("permission_grant.message = %#v", grant["message"]) - } - - var body map[string]interface{} - if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil { - t.Fatalf("failed to parse permission request body: %v", err) - } - if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" { - t.Fatalf("unexpected permission request body: %#v", body) - } -} - -func TestSheetCreateUserSkipsPermissionGrantAugmentation(t *testing.T) { - t.Parallel() - - f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "ou_current_user")) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v3/spreadsheets", - Body: map[string]interface{}{ - "code": 0, - "msg": "ok", - "data": map[string]interface{}{ - "spreadsheet": map[string]interface{}{ - "spreadsheet_token": "shtcn_new_sheet", - "url": "https://example.feishu.cn/sheets/shtcn_new_sheet", - }, - }, - }, - }) - - err := runSheetCreateShortcut(t, f, stdout, []string{ - "+create", - "--title", "项目排期", - "--as", "user", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data := decodeSheetCreateEnvelope(t, stdout) - if _, ok := data["permission_grant"]; ok { - t.Fatalf("did not expect permission_grant in user mode output: %#v", data) - } -} - -func TestSheetCreateFallbackURLWhenBackendOmitsIt(t *testing.T) { - t.Parallel() - - f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "")) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v3/spreadsheets", - Body: map[string]interface{}{ - "code": 0, - "msg": "ok", - "data": map[string]interface{}{ - "spreadsheet": map[string]interface{}{ - "spreadsheet_token": "shtcn_new_sheet", - // "url" deliberately omitted to exercise the fallback. - }, - }, - }, - }) - - err := runSheetCreateShortcut(t, f, stdout, []string{ - "+create", - "--title", "项目排期", - "--as", "user", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data := decodeSheetCreateEnvelope(t, stdout) - if got, want := data["url"], "https://www.feishu.cn/sheets/shtcn_new_sheet"; got != want { - t.Fatalf("url = %#v, want %q (brand-standard fallback)", got, want) - } -} - -func TestSheetCreateDryRunIncludesFolderToken(t *testing.T) { - t.Parallel() - - rt := newDimTestRuntime(t, - map[string]string{ - "title": "项目排期", - "folder-token": "fldcn123", - "headers": "", - "data": "", - }, - nil, nil) - got := mustMarshalSheetsDryRun(t, SheetCreate.DryRun(context.Background(), rt)) - if !strings.Contains(got, `"folder_token":"fldcn123"`) { - t.Fatalf("DryRun should include folder_token, got: %s", got) - } -} - -func TestSheetCreatePreservesBackendURL(t *testing.T) { - t.Parallel() - - f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "")) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v3/spreadsheets", - Body: map[string]interface{}{ - "code": 0, - "msg": "ok", - "data": map[string]interface{}{ - "spreadsheet": map[string]interface{}{ - "spreadsheet_token": "shtcn_new_sheet", - "url": "https://tenant.larkoffice.com/sheets/shtcn_new_sheet", - }, - }, - }, - }) - - err := runSheetCreateShortcut(t, f, stdout, []string{ - "+create", - "--title", "项目排期", - "--as", "user", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data := decodeSheetCreateEnvelope(t, stdout) - if got, want := data["url"], "https://tenant.larkoffice.com/sheets/shtcn_new_sheet"; got != want { - t.Fatalf("url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want) - } -} - -func TestSheetCreateFallbackURLWhenBackendURLIsWhitespace(t *testing.T) { - t.Parallel() - - f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "")) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v3/spreadsheets", - Body: map[string]interface{}{ - "code": 0, - "msg": "ok", - "data": map[string]interface{}{ - "spreadsheet": map[string]interface{}{ - "spreadsheet_token": "shtcn_new_sheet", - "url": " ", // whitespace-only must trigger fallback, not pass through. - }, - }, - }, - }) - - err := runSheetCreateShortcut(t, f, stdout, []string{ - "+create", - "--title", "项目排期", - "--as", "user", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data := decodeSheetCreateEnvelope(t, stdout) - if got, want := data["url"], "https://www.feishu.cn/sheets/shtcn_new_sheet"; got != want { - t.Fatalf("url = %#v, want %q (whitespace-only backend URL must yield fallback)", got, want) - } -} - -func TestSheetCreateTrimsPaddedBackendURL(t *testing.T) { - t.Parallel() - - f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "")) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v3/spreadsheets", - Body: map[string]interface{}{ - "code": 0, - "msg": "ok", - "data": map[string]interface{}{ - "spreadsheet": map[string]interface{}{ - "spreadsheet_token": "shtcn_new_sheet", - "url": " https://tenant.larkoffice.com/sheets/shtcn_new_sheet ", - }, - }, - }, - }) - - err := runSheetCreateShortcut(t, f, stdout, []string{ - "+create", - "--title", "项目排期", - "--as", "user", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data := decodeSheetCreateEnvelope(t, stdout) - if got, want := data["url"], "https://tenant.larkoffice.com/sheets/shtcn_new_sheet"; got != want { - t.Fatalf("url = %#v, want trimmed backend URL %q (whitespace must not leak into output)", got, want) - } -} - -func sheetCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig { - t.Helper() - - replacer := strings.NewReplacer("/", "-", " ", "-") - suffix := replacer.Replace(strings.ToLower(t.Name())) - return &core.CliConfig{ - AppID: "test-sheet-create-" + suffix, - AppSecret: "secret-sheet-create-" + suffix, - Brand: core.BrandFeishu, - UserOpenId: userOpenID, - } -} - -func runSheetCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error { - t.Helper() - - parent := &cobra.Command{Use: "sheets"} - SheetCreate.Mount(parent, f) - parent.SetArgs(args) - parent.SilenceErrors = true - parent.SilenceUsage = true - if stdout != nil { - stdout.Reset() - } - return parent.Execute() -} - -func decodeSheetCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} { - t.Helper() - - var envelope map[string]interface{} - if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { - t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String()) - } - data, _ := envelope["data"].(map[string]interface{}) - if data == nil { - t.Fatalf("missing data in output envelope: %#v", envelope) - } - return data -} diff --git a/shortcuts/sheets/lark_sheets_sheet_dimension_test.go b/shortcuts/sheets/lark_sheets_sheet_dimension_test.go deleted file mode 100644 index ee165412a..000000000 --- a/shortcuts/sheets/lark_sheets_sheet_dimension_test.go +++ /dev/null @@ -1,979 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "context" - "encoding/json" - "strconv" - "strings" - "testing" - - "github.com/spf13/cobra" - - "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/httpmock" - "github.com/larksuite/cli/shortcuts/common" -) - -// newDimTestRuntime creates a RuntimeContext with string, int, and bool flags. -func newDimTestRuntime(t *testing.T, strFlags map[string]string, intFlags map[string]int, boolFlags map[string]bool) *common.RuntimeContext { - t.Helper() - cmd := &cobra.Command{Use: "test"} - for name := range strFlags { - cmd.Flags().String(name, "", "") - } - for name := range intFlags { - cmd.Flags().Int(name, 0, "") - } - for name := range boolFlags { - cmd.Flags().Bool(name, false, "") - } - if err := cmd.ParseFlags(nil); err != nil { - t.Fatalf("ParseFlags() error = %v", err) - } - for name, value := range strFlags { - if err := cmd.Flags().Set(name, value); err != nil { - t.Fatalf("Flags().Set(%q) error = %v", name, err) - } - } - for name, value := range intFlags { - if err := cmd.Flags().Set(name, strconv.Itoa(value)); err != nil { - t.Fatalf("Flags().Set(%q) error = %v", name, err) - } - } - for name, value := range boolFlags { - if err := cmd.Flags().Set(name, strconv.FormatBool(value)); err != nil { - t.Fatalf("Flags().Set(%q) error = %v", name, err) - } - } - return &common.RuntimeContext{Cmd: cmd} -} - -func marshalDryRun(t *testing.T, v interface{}) string { - t.Helper() - b, err := json.Marshal(v) - if err != nil { - t.Fatalf("json.Marshal() error = %v", err) - } - return string(b) -} - -// ── AddDimension ───────────────────────────────────────────────────────────── - -func TestSheetAddDimensionValidateMissingToken(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"}, - map[string]int{"length": 10}, nil) - err := SheetAddDimension.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { - t.Fatalf("expected token error, got: %v", err) - } -} - -func TestSheetAddDimensionValidateLengthOutOfRange(t *testing.T) { - t.Parallel() - for _, length := range []int{0, -1, 5001} { - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, - map[string]int{"length": length}, nil) - err := SheetAddDimension.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--length") { - t.Fatalf("length=%d: expected length error, got: %v", length, err) - } - } -} - -func TestSheetAddDimensionValidateSuccess(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, - map[string]int{"length": 100}, nil) - if err := SheetAddDimension.Validate(context.Background(), rt); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetAddDimensionValidateWithURL(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"}, - map[string]int{"length": 5}, nil) - if err := SheetAddDimension.Validate(context.Background(), rt); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestDimensionShortcutsValidateRejectURLAndTokenTogether(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - shortcut common.Shortcut - strFlags map[string]string - intFlags map[string]int - boolFlags map[string]bool - }{ - { - name: "add", - shortcut: SheetAddDimension, - strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS"}, - intFlags: map[string]int{"length": 1}, - }, - { - name: "insert", - shortcut: SheetInsertDimension, - strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS", "inherit-style": ""}, - intFlags: map[string]int{"start-index": 0, "end-index": 1}, - }, - { - name: "update", - shortcut: SheetUpdateDimension, - strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS"}, - intFlags: map[string]int{"start-index": 1, "end-index": 1}, - boolFlags: map[string]bool{"visible": true}, - }, - { - name: "move", - shortcut: SheetMoveDimension, - strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS"}, - intFlags: map[string]int{"start-index": 0, "end-index": 0, "destination-index": 1}, - }, - { - name: "delete", - shortcut: SheetDeleteDimension, - strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS"}, - intFlags: map[string]int{"start-index": 1, "end-index": 1}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - rt := newDimTestRuntime(t, tt.strFlags, tt.intFlags, tt.boolFlags) - err := tt.shortcut.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { - t.Fatalf("expected mutual exclusivity error, got: %v", err) - } - }) - } -} - -func TestSheetAddDimensionDryRun(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"}, - map[string]int{"length": 8}, nil) - got := marshalDryRun(t, SheetAddDimension.DryRun(context.Background(), rt)) - - if !strings.Contains(got, `dimension_range`) { - t.Fatalf("DryRun URL missing dimension_range: %s", got) - } - if !strings.Contains(got, `"sheetId":"sheet1"`) { - t.Fatalf("DryRun missing sheetId: %s", got) - } - if !strings.Contains(got, `"majorDimension":"ROWS"`) { - t.Fatalf("DryRun missing majorDimension: %s", got) - } - if !strings.Contains(got, `"length":8`) { - t.Fatalf("DryRun missing length: %s", got) - } -} - -func TestSheetAddDimensionDryRunWithURL(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"}, - map[string]int{"length": 3}, nil) - got := marshalDryRun(t, SheetAddDimension.DryRun(context.Background(), rt)) - if !strings.Contains(got, "shtFromURL") { - t.Fatalf("DryRun should extract token from URL: %s", got) - } -} - -func TestSheetAddDimensionExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - stub := &httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range", - Body: map[string]interface{}{ - "code": 0, "msg": "Success", - "data": map[string]interface{}{"addCount": float64(8), "majorDimension": "ROWS"}, - }, - } - reg.Register(stub) - - err := mountAndRunSheets(t, SheetAddDimension, []string{ - "+add-dimension", - "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", - "--dimension", "ROWS", - "--length", "8", - "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(stdout.String(), `"addCount"`) { - t.Fatalf("stdout missing addCount: %s", stdout.String()) - } - - var body map[string]interface{} - if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { - t.Fatalf("parse request body: %v", err) - } - dim, _ := body["dimension"].(map[string]interface{}) - if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" { - t.Fatalf("unexpected request body: %#v", body) - } -} - -func TestSheetAddDimensionExecuteAPIError(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range", - Status: 400, - Body: map[string]interface{}{"code": 90001, "msg": "invalid request"}, - }) - - err := mountAndRunSheets(t, SheetAddDimension, []string{ - "+add-dimension", - "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", - "--dimension", "ROWS", - "--length", "8", - "--as", "user", - }, f, nil) - if err == nil { - t.Fatal("expected API error, got nil") - } -} - -// ── InsertDimension ────────────────────────────────────────────────────────── - -func TestSheetInsertDimensionValidateMissingToken(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""}, - map[string]int{"start-index": 0, "end-index": 3}, nil) - err := SheetInsertDimension.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { - t.Fatalf("expected token error, got: %v", err) - } -} - -func TestSheetInsertDimensionValidateNegativeStartIndex(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""}, - map[string]int{"start-index": -1, "end-index": 3}, nil) - err := SheetInsertDimension.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--start-index") { - t.Fatalf("expected start-index error, got: %v", err) - } -} - -func TestSheetInsertDimensionValidateEndNotGreaterThanStart(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""}, - map[string]int{"start-index": 5, "end-index": 5}, nil) - err := SheetInsertDimension.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--end-index") { - t.Fatalf("expected end-index error, got: %v", err) - } -} - -func TestSheetInsertDimensionValidateSuccess(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS", "inherit-style": ""}, - map[string]int{"start-index": 0, "end-index": 4}, nil) - if err := SheetInsertDimension.Validate(context.Background(), rt); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetInsertDimensionDryRun(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS", "inherit-style": "BEFORE"}, - map[string]int{"start-index": 3, "end-index": 7}, nil) - got := marshalDryRun(t, SheetInsertDimension.DryRun(context.Background(), rt)) - - if !strings.Contains(got, `insert_dimension_range`) { - t.Fatalf("DryRun URL missing insert_dimension_range: %s", got) - } - if !strings.Contains(got, `"startIndex":3`) { - t.Fatalf("DryRun missing startIndex: %s", got) - } - if !strings.Contains(got, `"endIndex":7`) { - t.Fatalf("DryRun missing endIndex: %s", got) - } - if !strings.Contains(got, `"inheritStyle":"BEFORE"`) { - t.Fatalf("DryRun missing inheritStyle: %s", got) - } -} - -func TestSheetInsertDimensionDryRunNoInheritStyle(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "COLUMNS", "inherit-style": ""}, - map[string]int{"start-index": 0, "end-index": 2}, nil) - got := marshalDryRun(t, SheetInsertDimension.DryRun(context.Background(), rt)) - - if strings.Contains(got, `inheritStyle`) { - t.Fatalf("DryRun should omit inheritStyle when empty: %s", got) - } -} - -func TestSheetInsertDimensionExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - stub := &httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range", - Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}}, - } - reg.Register(stub) - - err := mountAndRunSheets(t, SheetInsertDimension, []string{ - "+insert-dimension", - "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", - "--dimension", "ROWS", - "--start-index", "3", - "--end-index", "7", - "--inherit-style", "AFTER", - "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var body map[string]interface{} - if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { - t.Fatalf("parse request body: %v", err) - } - dim, _ := body["dimension"].(map[string]interface{}) - if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" { - t.Fatalf("unexpected dimension: %#v", dim) - } - if body["inheritStyle"] != "AFTER" { - t.Fatalf("unexpected inheritStyle: %v", body["inheritStyle"]) - } -} - -func TestSheetInsertDimensionExecuteWithoutInheritStyle(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - stub := &httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range", - Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}}, - } - reg.Register(stub) - - err := mountAndRunSheets(t, SheetInsertDimension, []string{ - "+insert-dimension", - "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", - "--dimension", "COLUMNS", - "--start-index", "0", - "--end-index", "2", - "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var body map[string]interface{} - if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { - t.Fatalf("parse request body: %v", err) - } - if _, ok := body["inheritStyle"]; ok { - t.Fatalf("inheritStyle should be absent when not specified: %#v", body) - } -} - -func TestSheetInsertDimensionExecuteAPIError(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range", - Status: 400, - Body: map[string]interface{}{"code": 90001, "msg": "invalid request"}, - }) - - err := mountAndRunSheets(t, SheetInsertDimension, []string{ - "+insert-dimension", - "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", - "--dimension", "ROWS", - "--start-index", "0", - "--end-index", "3", - "--as", "user", - }, f, nil) - if err == nil { - t.Fatal("expected API error, got nil") - } -} - -// ── UpdateDimension ────────────────────────────────────────────────────────── - -func TestSheetUpdateDimensionValidateMissingToken(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"}, - map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 50}, - map[string]bool{"visible": true}) - err := SheetUpdateDimension.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { - t.Fatalf("expected token error, got: %v", err) - } -} - -func TestSheetUpdateDimensionValidateStartIndexLessThan1(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, - map[string]int{"start-index": 0, "end-index": 3, "fixed-size": 50}, - map[string]bool{"visible": true}) - err := SheetUpdateDimension.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--start-index") { - t.Fatalf("expected start-index error, got: %v", err) - } -} - -func TestSheetUpdateDimensionValidateEndLessThanStart(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, - map[string]int{"start-index": 5, "end-index": 3, "fixed-size": 50}, - map[string]bool{"visible": true}) - err := SheetUpdateDimension.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--end-index") { - t.Fatalf("expected end-index error, got: %v", err) - } -} - -func TestSheetUpdateDimensionValidateNoProperties(t *testing.T) { - t.Parallel() - // Neither --visible nor --fixed-size is set (Changed returns false) - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, - map[string]int{"start-index": 1, "end-index": 3}, nil) - // Register the flags but don't set them so Changed() returns false - rt.Cmd.Flags().Bool("visible", false, "") - rt.Cmd.Flags().Int("fixed-size", 0, "") - err := SheetUpdateDimension.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--visible or --fixed-size") { - t.Fatalf("expected properties error, got: %v", err) - } -} - -func TestSheetUpdateDimensionValidateSuccessWithVisible(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, - map[string]int{"start-index": 1, "end-index": 3}, - map[string]bool{"visible": true}) - // Ensure fixed-size flag exists but is not set - rt.Cmd.Flags().Int("fixed-size", 0, "") - if err := SheetUpdateDimension.Validate(context.Background(), rt); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetUpdateDimensionValidateFixedSizeZero(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, - map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 0}, nil) - rt.Cmd.Flags().Bool("visible", false, "") - err := SheetUpdateDimension.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--fixed-size must be >= 1") { - t.Fatalf("expected fixed-size error, got: %v", err) - } -} - -func TestSheetUpdateDimensionValidateFixedSizeNegative(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, - map[string]int{"start-index": 1, "end-index": 3, "fixed-size": -10}, nil) - rt.Cmd.Flags().Bool("visible", false, "") - err := SheetUpdateDimension.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--fixed-size must be >= 1") { - t.Fatalf("expected fixed-size error, got: %v", err) - } -} - -func TestSheetUpdateDimensionValidateSuccessWithFixedSize(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"}, - map[string]int{"start-index": 1, "end-index": 5, "fixed-size": 120}, nil) - // Ensure visible flag exists but is not set - rt.Cmd.Flags().Bool("visible", false, "") - if err := SheetUpdateDimension.Validate(context.Background(), rt); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetUpdateDimensionDryRun(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"}, - map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 50}, - map[string]bool{"visible": true}) - got := marshalDryRun(t, SheetUpdateDimension.DryRun(context.Background(), rt)) - - if !strings.Contains(got, `"method":"PUT"`) { - t.Fatalf("DryRun should use PUT: %s", got) - } - if !strings.Contains(got, `dimension_range`) { - t.Fatalf("DryRun URL missing dimension_range: %s", got) - } - if !strings.Contains(got, `"visible":true`) { - t.Fatalf("DryRun missing visible: %s", got) - } - if !strings.Contains(got, `"fixedSize":50`) { - t.Fatalf("DryRun missing fixedSize: %s", got) - } -} - -func TestSheetUpdateDimensionDryRunOnlyVisible(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"}, - map[string]int{"start-index": 1, "end-index": 3}, - map[string]bool{"visible": false}) - // Add fixed-size flag but don't set it - rt.Cmd.Flags().Int("fixed-size", 0, "") - got := marshalDryRun(t, SheetUpdateDimension.DryRun(context.Background(), rt)) - - if !strings.Contains(got, `"visible":false`) { - t.Fatalf("DryRun missing visible: %s", got) - } - if strings.Contains(got, `fixedSize`) { - t.Fatalf("DryRun should omit fixedSize when not set: %s", got) - } -} - -func TestSheetUpdateDimensionExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - stub := &httpmock.Stub{ - Method: "PUT", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range", - Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}}, - } - reg.Register(stub) - - err := mountAndRunSheets(t, SheetUpdateDimension, []string{ - "+update-dimension", - "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", - "--dimension", "ROWS", - "--start-index", "1", - "--end-index", "3", - "--visible=true", - "--fixed-size", "50", - "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var body map[string]interface{} - if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { - t.Fatalf("parse request body: %v", err) - } - props, _ := body["dimensionProperties"].(map[string]interface{}) - if props["visible"] != true { - t.Fatalf("expected visible=true, got: %#v", props) - } - if props["fixedSize"] != float64(50) { - t.Fatalf("expected fixedSize=50, got: %#v", props) - } -} - -func TestSheetUpdateDimensionExecuteAPIError(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "PUT", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range", - Status: 400, - Body: map[string]interface{}{"code": 90001, "msg": "invalid request"}, - }) - - err := mountAndRunSheets(t, SheetUpdateDimension, []string{ - "+update-dimension", - "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", - "--dimension", "ROWS", - "--start-index", "1", - "--end-index", "3", - "--visible=true", - "--as", "user", - }, f, nil) - if err == nil { - t.Fatal("expected API error, got nil") - } -} - -// ── MoveDimension ──────────────────────────────────────────────────────────── - -func TestSheetMoveDimensionValidateMissingToken(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"}, - map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil) - err := SheetMoveDimension.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { - t.Fatalf("expected token error, got: %v", err) - } -} - -func TestSheetMoveDimensionValidateNegativeStartIndex(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, - map[string]int{"start-index": -1, "end-index": 1, "destination-index": 4}, nil) - err := SheetMoveDimension.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--start-index") { - t.Fatalf("expected start-index error, got: %v", err) - } -} - -func TestSheetMoveDimensionValidateEndLessThanStart(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, - map[string]int{"start-index": 5, "end-index": 3, "destination-index": 0}, nil) - err := SheetMoveDimension.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--end-index") { - t.Fatalf("expected end-index error, got: %v", err) - } -} - -func TestSheetMoveDimensionValidateNegativeDestinationIndex(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, - map[string]int{"start-index": 0, "end-index": 1, "destination-index": -1}, nil) - err := SheetMoveDimension.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--destination-index") { - t.Fatalf("expected destination-index error, got: %v", err) - } -} - -func TestSheetMoveDimensionValidateSuccess(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"}, - map[string]int{"start-index": 0, "end-index": 2, "destination-index": 5}, nil) - if err := SheetMoveDimension.Validate(context.Background(), rt); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetMoveDimensionValidateWithURL(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"}, - map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil) - if err := SheetMoveDimension.Validate(context.Background(), rt); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetMoveDimensionDryRun(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"}, - map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil) - got := marshalDryRun(t, SheetMoveDimension.DryRun(context.Background(), rt)) - - if !strings.Contains(got, `move_dimension`) { - t.Fatalf("DryRun URL missing move_dimension: %s", got) - } - if !strings.Contains(got, `"major_dimension":"ROWS"`) { - t.Fatalf("DryRun missing major_dimension: %s", got) - } - if !strings.Contains(got, `"start_index":0`) { - t.Fatalf("DryRun missing start_index: %s", got) - } - if !strings.Contains(got, `"destination_index":4`) { - t.Fatalf("DryRun missing destination_index: %s", got) - } -} - -func TestSheetMoveDimensionDryRunWithURL(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "sheet1", "dimension": "COLUMNS"}, - map[string]int{"start-index": 1, "end-index": 3, "destination-index": 0}, nil) - got := marshalDryRun(t, SheetMoveDimension.DryRun(context.Background(), rt)) - if !strings.Contains(got, "shtFromURL") { - t.Fatalf("DryRun should extract token from URL: %s", got) - } -} - -func TestSheetMoveDimensionExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - stub := &httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/move_dimension", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, - } - reg.Register(stub) - - err := mountAndRunSheets(t, SheetMoveDimension, []string{ - "+move-dimension", - "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", - "--dimension", "ROWS", - "--start-index", "0", - "--end-index", "1", - "--destination-index", "4", - "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var body map[string]interface{} - if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { - t.Fatalf("parse request body: %v", err) - } - source, _ := body["source"].(map[string]interface{}) - if source["major_dimension"] != "ROWS" { - t.Fatalf("unexpected major_dimension: %v", source["major_dimension"]) - } - if body["destination_index"] != float64(4) { - t.Fatalf("unexpected destination_index: %v", body["destination_index"]) - } -} - -func TestSheetMoveDimensionExecuteWithURL(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/move_dimension", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, - }) - - err := mountAndRunSheets(t, SheetMoveDimension, []string{ - "+move-dimension", - "--url", "https://example.feishu.cn/sheets/shtFromURL", - "--sheet-id", "sheet1", - "--dimension", "COLUMNS", - "--start-index", "1", - "--end-index", "2", - "--destination-index", "0", - "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetMoveDimensionExecuteAPIError(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/move_dimension", - Status: 400, - Body: map[string]interface{}{"code": 1310211, "msg": "wrong sheet id"}, - }) - - err := mountAndRunSheets(t, SheetMoveDimension, []string{ - "+move-dimension", - "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", - "--dimension", "ROWS", - "--start-index", "0", - "--end-index", "1", - "--destination-index", "4", - "--as", "user", - }, f, nil) - if err == nil { - t.Fatal("expected API error, got nil") - } -} - -// ── DeleteDimension ────────────────────────────────────────────────────────── - -func TestSheetDeleteDimensionValidateMissingToken(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"}, - map[string]int{"start-index": 1, "end-index": 3}, nil) - err := SheetDeleteDimension.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { - t.Fatalf("expected token error, got: %v", err) - } -} - -func TestSheetDeleteDimensionValidateStartIndexLessThan1(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, - map[string]int{"start-index": 0, "end-index": 3}, nil) - err := SheetDeleteDimension.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--start-index") { - t.Fatalf("expected start-index error, got: %v", err) - } -} - -func TestSheetDeleteDimensionValidateEndLessThanStart(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"}, - map[string]int{"start-index": 5, "end-index": 3}, nil) - err := SheetDeleteDimension.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--end-index") { - t.Fatalf("expected end-index error, got: %v", err) - } -} - -func TestSheetDeleteDimensionValidateSuccess(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, - map[string]int{"start-index": 3, "end-index": 7}, nil) - if err := SheetDeleteDimension.Validate(context.Background(), rt); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetDeleteDimensionValidateWithURL(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"}, - map[string]int{"start-index": 1, "end-index": 2}, nil) - if err := SheetDeleteDimension.Validate(context.Background(), rt); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetDeleteDimensionDryRun(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"}, - map[string]int{"start-index": 3, "end-index": 7}, nil) - got := marshalDryRun(t, SheetDeleteDimension.DryRun(context.Background(), rt)) - - if !strings.Contains(got, `"method":"DELETE"`) { - t.Fatalf("DryRun should use DELETE: %s", got) - } - if !strings.Contains(got, `dimension_range`) { - t.Fatalf("DryRun URL missing dimension_range: %s", got) - } - if !strings.Contains(got, `"startIndex":3`) { - t.Fatalf("DryRun missing startIndex: %s", got) - } - if !strings.Contains(got, `"endIndex":7`) { - t.Fatalf("DryRun missing endIndex: %s", got) - } -} - -func TestSheetDeleteDimensionDryRunWithURL(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "sheet1", "dimension": "COLUMNS"}, - map[string]int{"start-index": 1, "end-index": 5}, nil) - got := marshalDryRun(t, SheetDeleteDimension.DryRun(context.Background(), rt)) - if !strings.Contains(got, "shtFromURL") { - t.Fatalf("DryRun should extract token from URL: %s", got) - } -} - -func TestSheetDeleteDimensionExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - stub := &httpmock.Stub{ - Method: "DELETE", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range", - Body: map[string]interface{}{ - "code": 0, "msg": "success", - "data": map[string]interface{}{"delCount": float64(5), "majorDimension": "ROWS"}, - }, - } - reg.Register(stub) - - err := mountAndRunSheets(t, SheetDeleteDimension, []string{ - "+delete-dimension", - "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", - "--dimension", "ROWS", - "--start-index", "3", - "--end-index", "7", - "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(stdout.String(), `"delCount"`) { - t.Fatalf("stdout missing delCount: %s", stdout.String()) - } - - var body map[string]interface{} - if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { - t.Fatalf("parse request body: %v", err) - } - dim, _ := body["dimension"].(map[string]interface{}) - if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" { - t.Fatalf("unexpected dimension: %#v", dim) - } -} - -func TestSheetDeleteDimensionExecuteWithURL(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "DELETE", - URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dimension_range", - Body: map[string]interface{}{ - "code": 0, "msg": "success", - "data": map[string]interface{}{"delCount": float64(2), "majorDimension": "COLUMNS"}, - }, - }) - - err := mountAndRunSheets(t, SheetDeleteDimension, []string{ - "+delete-dimension", - "--url", "https://example.feishu.cn/sheets/shtFromURL", - "--sheet-id", "sheet1", - "--dimension", "COLUMNS", - "--start-index", "1", - "--end-index", "2", - "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetDeleteDimensionExecuteAPIError(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "DELETE", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range", - Status: 400, - Body: map[string]interface{}{"code": 90001, "msg": "invalid request"}, - }) - - err := mountAndRunSheets(t, SheetDeleteDimension, []string{ - "+delete-dimension", - "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", - "--dimension", "ROWS", - "--start-index", "3", - "--end-index", "7", - "--as", "user", - }, f, nil) - if err == nil { - t.Fatal("expected API error, got nil") - } -} diff --git a/shortcuts/sheets/lark_sheets_sheet_dropdown_test.go b/shortcuts/sheets/lark_sheets_sheet_dropdown_test.go deleted file mode 100644 index 135d91ce3..000000000 --- a/shortcuts/sheets/lark_sheets_sheet_dropdown_test.go +++ /dev/null @@ -1,552 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "bytes" - "context" - "encoding/json" - "strings" - "testing" - - "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/httpmock" -) - -// ── SetDropdown ───────────────────────────────────────────────────────────── - -func TestSetDropdownValidateMissingToken(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "", - "range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`, - "colors": "", - }, map[string]bool{"multiple": false, "highlight": false}) - err := SheetSetDropdown.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { - t.Fatalf("expected token error, got: %v", err) - } -} - -func TestSetDropdownValidateInvalidConditionValues(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", - "range": "s1!A2:A100", "condition-values": "not-json", - "colors": "", - }, map[string]bool{"multiple": false, "highlight": false}) - err := SheetSetDropdown.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--condition-values must be a JSON array") { - t.Fatalf("expected JSON array error, got: %v", err) - } -} - -func TestSetDropdownValidateNonStringConditionValues(t *testing.T) { - t.Parallel() - cases := []struct { - name string - input string - }{ - {"mixed types", `["ok", 1, null]`}, - {"all numbers", `[1, 2, 3]`}, - {"null literal", `null`}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", - "range": "s1!A2:A100", "condition-values": tc.input, - "colors": "", - }, map[string]bool{"multiple": false, "highlight": false}) - err := SheetSetDropdown.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--condition-values must be") { - t.Fatalf("expected validation error for %q, got: %v", tc.input, err) - } - }) - } -} - -func TestSetDropdownValidateInvalidColors(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", - "range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`, - "colors": "bad-json", - }, map[string]bool{"multiple": false, "highlight": true}) - err := SheetSetDropdown.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--colors must be a JSON array") { - t.Fatalf("expected colors JSON error, got: %v", err) - } -} - -func TestSetDropdownValidateRangeMissingSheetID(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", - "range": "A2:A100", "condition-values": `["opt1"]`, - "colors": "", - }, map[string]bool{"multiple": false, "highlight": false}) - err := SheetSetDropdown.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "fully qualified range") { - t.Fatalf("expected range validation error, got: %v", err) - } -} - -func TestSetDropdownValidateEmptyConditionValues(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", - "range": "s1!A2:A100", "condition-values": `[]`, - "colors": "", - }, map[string]bool{"multiple": false, "highlight": false}) - err := SheetSetDropdown.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--condition-values must not be empty") { - t.Fatalf("expected empty error, got: %v", err) - } -} - -func TestSetDropdownValidateColorsMismatchLength(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", - "range": "s1!A2:A100", "condition-values": `["a","b","c"]`, - "colors": `["#FF0000"]`, - }, map[string]bool{"multiple": false, "highlight": true}) - err := SheetSetDropdown.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--colors length") { - t.Fatalf("expected length mismatch error, got: %v", err) - } -} - -func TestSetDropdownValidateSuccess(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", - "range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`, - "colors": "", - }, map[string]bool{"multiple": false, "highlight": false}) - if err := SheetSetDropdown.Validate(context.Background(), rt); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSetDropdownDryRun(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", - "range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`, - "colors": "", - }, map[string]bool{"multiple": true, "highlight": false}) - got := mustMarshalSheetsDryRun(t, SheetSetDropdown.DryRun(context.Background(), rt)) - if !strings.Contains(got, `"method":"POST"`) { - t.Fatalf("DryRun should use POST: %s", got) - } - if !strings.Contains(got, `dataValidation`) { - t.Fatalf("DryRun missing dataValidation: %s", got) - } - if !strings.Contains(got, `"dataValidationType":"list"`) { - t.Fatalf("DryRun missing dataValidationType: %s", got) - } -} - -func TestSetDropdownExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, - }) - err := mountAndRunSheets(t, SheetSetDropdown, []string{ - "+set-dropdown", "--spreadsheet-token", "shtTOKEN", - "--range", "s1!A2:A100", "--condition-values", `["opt1","opt2","opt3"]`, - "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSetDropdownExecuteWithMultipleAndColors(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - stub := &httpmock.Stub{ - Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, - } - reg.Register(stub) - err := mountAndRunSheets(t, SheetSetDropdown, []string{ - "+set-dropdown", "--spreadsheet-token", "shtTOKEN", - "--range", "s1!A2:A100", "--condition-values", `["a","b"]`, - "--multiple", "--highlight", "--colors", `["#1FB6C1","#F006C2"]`, - "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - var body map[string]interface{} - if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { - t.Fatalf("parse body: %v", err) - } - dv, _ := body["dataValidation"].(map[string]interface{}) - opts, _ := dv["options"].(map[string]interface{}) - if opts["multipleValues"] != true { - t.Fatalf("expected multipleValues=true, got: %v", opts["multipleValues"]) - } - if opts["highlightValidData"] != true { - t.Fatalf("expected highlightValidData=true, got: %v", opts["highlightValidData"]) - } -} - -func TestSetDropdownExecuteAPIError(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation", - Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"}, - }) - err := mountAndRunSheets(t, SheetSetDropdown, []string{ - "+set-dropdown", "--spreadsheet-token", "shtTOKEN", - "--range", "s1!A2:A100", "--condition-values", `["opt1"]`, - "--as", "user", - }, f, nil) - if err == nil { - t.Fatal("expected error") - } -} - -func TestSetDropdownWithURL(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, - }) - err := mountAndRunSheets(t, SheetSetDropdown, []string{ - "+set-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL", - "--range", "s1!A2:A100", "--condition-values", `["opt1"]`, - "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -// ── UpdateDropdown ────────────────────────────────────────────────────────── - -func TestUpdateDropdownValidateMissingToken(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "", "sheet-id": "s1", - "ranges": `["s1!A1:A100"]`, "condition-values": `["opt1"]`, - "colors": "", - }, map[string]bool{"multiple": false, "highlight": false}) - err := SheetUpdateDropdown.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { - t.Fatalf("expected token error, got: %v", err) - } -} - -func TestUpdateDropdownValidateInvalidRanges(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", - "ranges": "not-json", "condition-values": `["opt1"]`, - "colors": "", - }, map[string]bool{"multiple": false, "highlight": false}) - err := SheetUpdateDropdown.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--ranges must be a JSON array") { - t.Fatalf("expected JSON array error, got: %v", err) - } -} - -func TestUpdateDropdownValidateRangesMissingSheetID(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", - "ranges": `["A1:A100"]`, "condition-values": `["opt1"]`, - "colors": "", - }, map[string]bool{"multiple": false, "highlight": false}) - err := SheetUpdateDropdown.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "fully qualified range") { - t.Fatalf("expected range validation error, got: %v", err) - } -} - -func TestUpdateDropdownValidateEmptyRanges(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", - "ranges": `[]`, "condition-values": `["opt1"]`, - "colors": "", - }, map[string]bool{"multiple": false, "highlight": false}) - err := SheetUpdateDropdown.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--ranges must not be empty") { - t.Fatalf("expected empty error, got: %v", err) - } -} - -func TestUpdateDropdownValidateInvalidColors(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", - "ranges": `["s1!A1:A100"]`, "condition-values": `["opt1"]`, - "colors": "{not-array}", - }, map[string]bool{"multiple": false, "highlight": true}) - err := SheetUpdateDropdown.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--colors must be a JSON array") { - t.Fatalf("expected colors JSON error, got: %v", err) - } -} - -func TestUpdateDropdownDryRun(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", - "ranges": `["sheet1!A1:A100"]`, "condition-values": `["new1","new2"]`, - "colors": "", - }, map[string]bool{"multiple": false, "highlight": false}) - got := mustMarshalSheetsDryRun(t, SheetUpdateDropdown.DryRun(context.Background(), rt)) - if !strings.Contains(got, `"method":"PUT"`) { - t.Fatalf("DryRun should use PUT: %s", got) - } - if !strings.Contains(got, `sheet1`) { - t.Fatalf("DryRun missing sheet_id: %s", got) - } -} - -func TestUpdateDropdownExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation/sheet1", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "spreadsheetToken": "shtTOKEN", "sheetId": "sheet1", - }}, - }) - err := mountAndRunSheets(t, SheetUpdateDropdown, []string{ - "+update-dropdown", "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", "--ranges", `["sheet1!A1:A100"]`, - "--condition-values", `["new1","new2"]`, "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestUpdateDropdownWithURL(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation/sheet1", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, - }) - err := mountAndRunSheets(t, SheetUpdateDropdown, []string{ - "+update-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL", - "--sheet-id", "sheet1", "--ranges", `["sheet1!A1:A100"]`, - "--condition-values", `["opt1"]`, "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -// ── GetDropdown ───────────────────────────────────────────────────────────── - -func TestGetDropdownValidateRangeMissingSheetID(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "range": "A2:A100", - }, nil) - err := SheetGetDropdown.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "fully qualified range") { - t.Fatalf("expected range validation error, got: %v", err) - } -} - -func TestGetDropdownValidateMissingToken(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "", "range": "s1!A2:A100", - }, nil) - err := SheetGetDropdown.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { - t.Fatalf("expected token error, got: %v", err) - } -} - -func TestGetDropdownDryRun(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "range": "s1!A2:A100", - }, nil) - got := mustMarshalSheetsDryRun(t, SheetGetDropdown.DryRun(context.Background(), rt)) - if !strings.Contains(got, `"method":"GET"`) { - t.Fatalf("DryRun should use GET: %s", got) - } - if !strings.Contains(got, `dataValidation`) { - t.Fatalf("DryRun missing dataValidation path: %s", got) - } -} - -func TestGetDropdownExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "GET", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation", - Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{ - "dataValidations": []interface{}{ - map[string]interface{}{ - "dataValidationType": "list", - "conditionValues": []interface{}{"opt1", "opt2"}, - "ranges": []interface{}{"s1!A2:A100"}, - }, - }, - }}, - }) - err := mountAndRunSheets(t, SheetGetDropdown, []string{ - "+get-dropdown", "--spreadsheet-token", "shtTOKEN", - "--range", "s1!A2:A100", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(stdout.String(), "dataValidations") { - t.Fatalf("stdout missing dataValidations: %s", stdout.String()) - } -} - -func TestGetDropdownWithURL(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "GET", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation", - Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{ - "dataValidations": []interface{}{}, - }}, - }) - err := mountAndRunSheets(t, SheetGetDropdown, []string{ - "+get-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL", - "--range", "s1!A2:A100", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -// ── DeleteDropdown ────────────────────────────────────────────────────────── - -func TestDeleteDropdownValidateMissingToken(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "", "ranges": `["s1!A2:A100"]`, - }, nil) - err := SheetDeleteDropdown.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { - t.Fatalf("expected token error, got: %v", err) - } -} - -func TestDeleteDropdownValidateRangesMissingSheetID(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "ranges": `["B1:B50"]`, - }, nil) - err := SheetDeleteDropdown.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "fully qualified range") { - t.Fatalf("expected range validation error, got: %v", err) - } -} - -func TestDeleteDropdownValidateEmptyRanges(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "ranges": `[]`, - }, nil) - err := SheetDeleteDropdown.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--ranges must not be empty") { - t.Fatalf("expected empty error, got: %v", err) - } -} - -func TestDeleteDropdownValidateInvalidRanges(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "ranges": "bad", - }, nil) - err := SheetDeleteDropdown.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--ranges must be a JSON array") { - t.Fatalf("expected JSON array error, got: %v", err) - } -} - -func TestDeleteDropdownDryRun(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "ranges": `["s1!A2:A100","s1!C1:C50"]`, - }, nil) - got := mustMarshalSheetsDryRun(t, SheetDeleteDropdown.DryRun(context.Background(), rt)) - if !strings.Contains(got, `"method":"DELETE"`) { - t.Fatalf("DryRun should use DELETE: %s", got) - } - if !strings.Contains(got, `dataValidationRanges`) { - t.Fatalf("DryRun missing dataValidationRanges: %s", got) - } -} - -func TestDeleteDropdownExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "rangeResults": []interface{}{ - map[string]interface{}{"range": "s1!A2:A100", "success": true, "updatedCells": 99}, - }, - }}, - }) - err := mountAndRunSheets(t, SheetDeleteDropdown, []string{ - "+delete-dropdown", "--spreadsheet-token", "shtTOKEN", - "--ranges", `["s1!A2:A100"]`, "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(stdout.String(), "rangeResults") { - t.Fatalf("stdout missing rangeResults: %s", stdout.String()) - } -} - -func TestDeleteDropdownExecuteMultipleRanges(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - stub := &httpmock.Stub{ - Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, - } - reg.Register(stub) - err := mountAndRunSheets(t, SheetDeleteDropdown, []string{ - "+delete-dropdown", "--spreadsheet-token", "shtTOKEN", - "--ranges", `["s1!A2:A100","s1!C1:C50"]`, "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - var body map[string]interface{} - if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { - t.Fatalf("parse body: %v", err) - } - dvRanges, _ := body["dataValidationRanges"].([]interface{}) - if len(dvRanges) != 2 { - t.Fatalf("expected 2 ranges, got: %d", len(dvRanges)) - } -} - -func TestDeleteDropdownWithURL(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, - }) - err := mountAndRunSheets(t, SheetDeleteDropdown, []string{ - "+delete-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL", - "--ranges", `["s1!A2:A100"]`, "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -// suppress unused import for bytes in case the test helpers already import it -var _ = (*bytes.Buffer)(nil) diff --git a/shortcuts/sheets/lark_sheets_sheet_export_test.go b/shortcuts/sheets/lark_sheets_sheet_export_test.go deleted file mode 100644 index 83bef63de..000000000 --- a/shortcuts/sheets/lark_sheets_sheet_export_test.go +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "context" - "strings" - "testing" - - "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/httpmock" - "github.com/tidwall/gjson" -) - -func TestSheetExportValidateRejectsURLAndTokenTogether(t *testing.T) { - t.Parallel() - - rt := newDimTestRuntime(t, map[string]string{ - "url": "https://example.feishu.cn/sheets/shtFromURL", - "spreadsheet-token": "shtTOKEN", - "file-extension": "xlsx", - "output-path": "", - "sheet-id": "", - }, nil, nil) - err := SheetExport.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { - t.Fatalf("expected mutual exclusivity error, got: %v", err) - } -} - -func TestSheetExportValidateRequiresSheetIDForCSV(t *testing.T) { - t.Parallel() - - rt := newDimTestRuntime(t, map[string]string{ - "url": "", - "spreadsheet-token": "shtTOKEN", - "file-extension": "csv", - "output-path": "", - "sheet-id": "", - }, nil, nil) - err := SheetExport.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--sheet-id is required when --file-extension is csv") { - t.Fatalf("expected csv sheet-id validation error, got: %v", err) - } -} - -func TestSheetExportValidateAllowsCSVWithSheetID(t *testing.T) { - t.Parallel() - - rt := newDimTestRuntime(t, map[string]string{ - "url": "", - "spreadsheet-token": "shtTOKEN", - "file-extension": "csv", - "output-path": "", - "sheet-id": "sheet1", - }, nil, nil) - if err := SheetExport.Validate(context.Background(), rt); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetExportDryRunIncludesSubIDForCSV(t *testing.T) { - t.Parallel() - - rt := newDimTestRuntime(t, map[string]string{ - "url": "", - "spreadsheet-token": "shtTOKEN", - "file-extension": "csv", - "output-path": "", - "sheet-id": "sheet1", - }, nil, nil) - got := mustMarshalSheetsDryRun(t, SheetExport.DryRun(context.Background(), rt)) - if !strings.Contains(got, `"sub_id":"sheet1"`) { - t.Fatalf("DryRun should include sub_id for csv export, got: %s", got) - } -} - -func TestSheetExportCommandRejectsInvalidFileExtension(t *testing.T) { - t.Parallel() - - f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) - err := mountAndRunSheets(t, SheetExport, []string{ - "+export", - "--spreadsheet-token", "shtTOKEN", - "--file-extension", "pdf", - "--as", "user", - }, f, nil) - if err == nil || !strings.Contains(err.Error(), `allowed: xlsx, csv`) { - t.Fatalf("expected invalid file-extension error, got: %v", err) - } -} - -func TestSheetExportExecuteWithoutOutputPathReturnsMetadataOnly(t *testing.T) { - t.Parallel() - - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/drive/v1/export_tasks", - Body: map[string]interface{}{ - "code": 0, - "msg": "success", - "data": map[string]interface{}{ - "ticket": "tk_123", - }, - }, - }) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/drive/v1/export_tasks/tk_123", - Body: map[string]interface{}{ - "code": 0, - "msg": "success", - "data": map[string]interface{}{ - "result": map[string]interface{}{ - "file_token": "box_123", - }, - }, - }, - }) - - err := mountAndRunSheets(t, SheetExport, []string{ - "+export", - "--spreadsheet-token", "shtTOKEN", - "--file-extension", "xlsx", - "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - got := stdout.String() - if gjson.Get(got, "data.file_token").String() != "box_123" || gjson.Get(got, "data.ticket").String() != "tk_123" { - t.Fatalf("stdout should return export metadata, got: %s", got) - } - if strings.Contains(got, `"saved_path"`) { - t.Fatalf("stdout should not include saved_path when --output-path is omitted: %s", got) - } -} diff --git a/shortcuts/sheets/lark_sheets_sheet_filter_view_test.go b/shortcuts/sheets/lark_sheets_sheet_filter_view_test.go deleted file mode 100644 index a28aec242..000000000 --- a/shortcuts/sheets/lark_sheets_sheet_filter_view_test.go +++ /dev/null @@ -1,673 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "context" - "encoding/json" - "strings" - "testing" - - "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/httpmock" -) - -// ── CreateFilterView ───────────────────────────────────────────────────────── - -func TestCreateFilterViewValidateMissingToken(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "", "sheet-id": "s1", "range": "s1!A1:H14", - "filter-view-name": "", "filter-view-id": "", - }, nil) - err := SheetCreateFilterView.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { - t.Fatalf("expected token error, got: %v", err) - } -} - -func TestValidateFilterViewTokenRejectsURLAndTokenTogether(t *testing.T) { - t.Parallel() - - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "https://example.feishu.cn/sheets/shtFromURL", - "spreadsheet-token": "shtTOKEN", - "sheet-id": "s1", - "range": "s1!A1:H14", - "filter-view-name": "", - "filter-view-id": "", - }, nil) - _, err := validateFilterViewToken(rt) - if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { - t.Fatalf("expected mutual exclusivity error, got: %v", err) - } -} - -func TestCreateFilterViewValidateRejectsEmptyRange(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "range": "", - "filter-view-name": "", "filter-view-id": "", - }, nil) - err := SheetCreateFilterView.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--range must not be empty") { - t.Fatalf("expected empty range error, got: %v", err) - } -} - -func TestCreateFilterViewValidateSuccess(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "range": "s1!A1:H14", - "filter-view-name": "", "filter-view-id": "", - }, nil) - if err := SheetCreateFilterView.Validate(context.Background(), rt); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestCreateFilterViewDryRun(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "range": "sheet1!A1:H14", - "filter-view-name": "my view", "filter-view-id": "", - }, nil) - got := mustMarshalSheetsDryRun(t, SheetCreateFilterView.DryRun(context.Background(), rt)) - if !strings.Contains(got, `filter_views`) { - t.Fatalf("DryRun URL missing filter_views: %s", got) - } - if !strings.Contains(got, `"filter_view_name":"my view"`) { - t.Fatalf("DryRun missing name: %s", got) - } -} - -func TestCreateFilterViewExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "filter_view": map[string]interface{}{"filter_view_id": "pH9hbVcCXA", "range": "sheet1!A1:H14"}, - }}, - }) - err := mountAndRunSheets(t, SheetCreateFilterView, []string{ - "+create-filter-view", "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", "--range", "sheet1!A1:H14", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(stdout.String(), "filter_view_id") { - t.Fatalf("stdout missing filter_view_id: %s", stdout.String()) - } -} - -func TestCreateFilterViewExecuteAPIError(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views", - Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"}, - }) - err := mountAndRunSheets(t, SheetCreateFilterView, []string{ - "+create-filter-view", "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", "--range", "sheet1!A1:H14", "--as", "user", - }, f, nil) - if err == nil { - t.Fatal("expected error") - } -} - -// ── UpdateFilterView ───────────────────────────────────────────────────────── - -func TestUpdateFilterViewDryRun(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", - "filter-view-id": "pH9hbVcCXA", "range": "sheet1!A1:J20", "filter-view-name": "", - }, nil) - got := mustMarshalSheetsDryRun(t, SheetUpdateFilterView.DryRun(context.Background(), rt)) - if !strings.Contains(got, `"method":"PATCH"`) { - t.Fatalf("DryRun should use PATCH: %s", got) - } - if !strings.Contains(got, `pH9hbVcCXA`) { - t.Fatalf("DryRun missing filter_view_id: %s", got) - } -} - -func TestUpdateFilterViewExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv123", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "filter_view": map[string]interface{}{"filter_view_id": "fv123", "range": "sheet1!A1:J20"}, - }}, - }) - err := mountAndRunSheets(t, SheetUpdateFilterView, []string{ - "+update-filter-view", "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", "--filter-view-id", "fv123", "--range", "sheet1!A1:J20", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestUpdateFilterViewRejectsNoFields(t *testing.T) { - f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) - err := mountAndRunSheets(t, SheetUpdateFilterView, []string{ - "+update-filter-view", "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", "--filter-view-id", "fv1", - "--as", "user", - }, f, stdout) - if err == nil { - t.Fatal("expected validation error when no update fields provided, got nil") - } - if !strings.Contains(err.Error(), "at least one") { - t.Fatalf("unexpected error message: %v", err) - } -} - -func TestUpdateFilterViewRejectsBlankFieldsOnly(t *testing.T) { - f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) - err := mountAndRunSheets(t, SheetUpdateFilterView, []string{ - "+update-filter-view", "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", "--filter-view-id", "fv1", - "--range", "", "--filter-view-name", "", - "--as", "user", - }, f, stdout) - if err == nil { - t.Fatal("expected validation error when only blank update fields are provided, got nil") - } - if !strings.Contains(err.Error(), "at least one") { - t.Fatalf("unexpected error message: %v", err) - } -} - -// ── ListFilterViews ────────────────────────────────────────────────────────── - -func TestListFilterViewsDryRun(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", - }, nil) - got := mustMarshalSheetsDryRun(t, SheetListFilterViews.DryRun(context.Background(), rt)) - if !strings.Contains(got, `filter_views/query`) { - t.Fatalf("DryRun URL missing query: %s", got) - } -} - -func TestListFilterViewsExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/query", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "items": []interface{}{map[string]interface{}{"filter_view_id": "fv1"}}, - }}, - }) - err := mountAndRunSheets(t, SheetListFilterViews, []string{ - "+list-filter-views", "--spreadsheet-token", "shtTOKEN", "--sheet-id", "sheet1", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(stdout.String(), "fv1") { - t.Fatalf("stdout missing fv1: %s", stdout.String()) - } -} - -// ── GetFilterView ──────────────────────────────────────────────────────────── - -func TestGetFilterViewDryRun(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv123", - }, nil) - got := mustMarshalSheetsDryRun(t, SheetGetFilterView.DryRun(context.Background(), rt)) - if !strings.Contains(got, `"method":"GET"`) { - t.Fatalf("DryRun should use GET: %s", got) - } - if !strings.Contains(got, `fv123`) { - t.Fatalf("DryRun missing filter_view_id: %s", got) - } -} - -func TestGetFilterViewExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv123", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "filter_view": map[string]interface{}{"filter_view_id": "fv123"}, - }}, - }) - err := mountAndRunSheets(t, SheetGetFilterView, []string{ - "+get-filter-view", "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", "--filter-view-id", "fv123", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -// ── DeleteFilterView ───────────────────────────────────────────────────────── - -func TestDeleteFilterViewDryRun(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv123", - }, nil) - got := mustMarshalSheetsDryRun(t, SheetDeleteFilterView.DryRun(context.Background(), rt)) - if !strings.Contains(got, `"method":"DELETE"`) { - t.Fatalf("DryRun should use DELETE: %s", got) - } -} - -func TestDeleteFilterViewExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv123", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, - }) - err := mountAndRunSheets(t, SheetDeleteFilterView, []string{ - "+delete-filter-view", "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", "--filter-view-id", "fv123", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -// ── CreateFilterViewCondition ──────────────────────────────────────────────── - -func TestCreateFilterViewConditionValidateMissingToken(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "", "sheet-id": "s1", "filter-view-id": "fv1", - "condition-id": "E", "filter-type": "number", "compare-type": "less", "expected": `["6"]`, - }, nil) - err := SheetCreateFilterViewCondition.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { - t.Fatalf("expected token error, got: %v", err) - } -} - -func TestCreateFilterViewConditionDryRun(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv1", - "condition-id": "E", "filter-type": "number", "compare-type": "less", "expected": `["6"]`, - }, nil) - got := mustMarshalSheetsDryRun(t, SheetCreateFilterViewCondition.DryRun(context.Background(), rt)) - if !strings.Contains(got, `conditions`) { - t.Fatalf("DryRun URL missing conditions: %s", got) - } - if !strings.Contains(got, `"condition_id":"E"`) { - t.Fatalf("DryRun missing condition_id: %s", got) - } - if !strings.Contains(got, `"filter_type":"number"`) { - t.Fatalf("DryRun missing filter_type: %s", got) - } -} - -func TestCreateFilterViewConditionExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - stub := &httpmock.Stub{ - Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "condition": map[string]interface{}{"condition_id": "E", "filter_type": "number"}, - }}, - } - reg.Register(stub) - err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{ - "+create-filter-view-condition", "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", "--filter-view-id", "fv1", - "--condition-id", "E", "--filter-type", "number", "--compare-type", "less", - "--expected", `["6"]`, "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - var body map[string]interface{} - if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { - t.Fatalf("parse body: %v", err) - } - if body["condition_id"] != "E" { - t.Fatalf("unexpected condition_id: %v", body["condition_id"]) - } -} - -// ── UpdateFilterViewCondition ──────────────────────────────────────────────── - -func TestUpdateFilterViewConditionDryRun(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv1", - "condition-id": "E", "filter-type": "number", "compare-type": "between", "expected": `["2","10"]`, - }, nil) - got := mustMarshalSheetsDryRun(t, SheetUpdateFilterViewCondition.DryRun(context.Background(), rt)) - if !strings.Contains(got, `"method":"PUT"`) { - t.Fatalf("DryRun should use PUT: %s", got) - } - if !strings.Contains(got, `"compare_type":"between"`) { - t.Fatalf("DryRun missing compare_type: %s", got) - } -} - -func TestUpdateFilterViewConditionExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "PUT", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/E", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "condition": map[string]interface{}{"condition_id": "E"}, - }}, - }) - err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{ - "+update-filter-view-condition", "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", - "--filter-type", "number", "--compare-type", "between", "--expected", `["2","10"]`, "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestUpdateFilterViewConditionRejectsNoFields(t *testing.T) { - f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) - err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{ - "+update-filter-view-condition", "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", - "--as", "user", - }, f, stdout) - if err == nil { - t.Fatal("expected validation error when no update fields provided, got nil") - } - if !strings.Contains(err.Error(), "at least one") { - t.Fatalf("unexpected error message: %v", err) - } -} - -// ── ListFilterViewConditions ───────────────────────────────────────────────── - -func TestListFilterViewConditionsDryRun(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv1", - }, nil) - got := mustMarshalSheetsDryRun(t, SheetListFilterViewConditions.DryRun(context.Background(), rt)) - if !strings.Contains(got, `conditions/query`) { - t.Fatalf("DryRun URL missing conditions/query: %s", got) - } -} - -func TestListFilterViewConditionsExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/query", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "items": []interface{}{map[string]interface{}{"condition_id": "E"}}, - }}, - }) - err := mountAndRunSheets(t, SheetListFilterViewConditions, []string{ - "+list-filter-view-conditions", "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -// ── GetFilterViewCondition ─────────────────────────────────────────────────── - -func TestGetFilterViewConditionDryRun(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", - "filter-view-id": "fv1", "condition-id": "E", - }, nil) - got := mustMarshalSheetsDryRun(t, SheetGetFilterViewCondition.DryRun(context.Background(), rt)) - if !strings.Contains(got, `"method":"GET"`) { - t.Fatalf("DryRun should use GET: %s", got) - } -} - -func TestGetFilterViewConditionExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/E", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "condition": map[string]interface{}{"condition_id": "E", "filter_type": "number"}, - }}, - }) - err := mountAndRunSheets(t, SheetGetFilterViewCondition, []string{ - "+get-filter-view-condition", "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -// ── DeleteFilterViewCondition ──────────────────────────────────────────────── - -func TestDeleteFilterViewConditionDryRun(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", - "filter-view-id": "fv1", "condition-id": "E", - }, nil) - got := mustMarshalSheetsDryRun(t, SheetDeleteFilterViewCondition.DryRun(context.Background(), rt)) - if !strings.Contains(got, `"method":"DELETE"`) { - t.Fatalf("DryRun should use DELETE: %s", got) - } -} - -func TestDeleteFilterViewConditionExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/E", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, - }) - err := mountAndRunSheets(t, SheetDeleteFilterViewCondition, []string{ - "+delete-filter-view-condition", "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -// ── URL flag coverage ──────────────────────────────────────────────────────── - -func TestCreateFilterViewWithURL(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "filter_view": map[string]interface{}{"filter_view_id": "fv1"}, - }}, - }) - err := mountAndRunSheets(t, SheetCreateFilterView, []string{ - "+create-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL", - "--sheet-id", "sheet1", "--range", "sheet1!A1:H14", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestListFilterViewsWithURL(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/query", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"items": []interface{}{}}}, - }) - err := mountAndRunSheets(t, SheetListFilterViews, []string{ - "+list-filter-views", "--url", "https://example.feishu.cn/sheets/shtFromURL", - "--sheet-id", "sheet1", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestGetFilterViewWithURL(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "filter_view": map[string]interface{}{"filter_view_id": "fv1"}, - }}, - }) - err := mountAndRunSheets(t, SheetGetFilterView, []string{ - "+get-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL", - "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestUpdateFilterViewWithURL(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "filter_view": map[string]interface{}{"filter_view_id": "fv1"}, - }}, - }) - err := mountAndRunSheets(t, SheetUpdateFilterView, []string{ - "+update-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL", - "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--range", "sheet1!A1:J20", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestDeleteFilterViewWithURL(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, - }) - err := mountAndRunSheets(t, SheetDeleteFilterView, []string{ - "+delete-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL", - "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestCreateFilterViewConditionWithURL(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "condition": map[string]interface{}{"condition_id": "E"}, - }}, - }) - err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{ - "+create-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL", - "--sheet-id", "sheet1", "--filter-view-id", "fv1", - "--condition-id", "E", "--filter-type", "number", "--compare-type", "less", - "--expected", `["6"]`, "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestUpdateFilterViewConditionWithURL(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "PUT", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/E", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "condition": map[string]interface{}{"condition_id": "E"}, - }}, - }) - err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{ - "+update-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL", - "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", - "--filter-type", "number", "--expected", `["5"]`, "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestListFilterViewConditionsWithURL(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/query", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"items": []interface{}{}}}, - }) - err := mountAndRunSheets(t, SheetListFilterViewConditions, []string{ - "+list-filter-view-conditions", "--url", "https://example.feishu.cn/sheets/shtFromURL", - "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestGetFilterViewConditionWithURL(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/E", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "condition": map[string]interface{}{"condition_id": "E"}, - }}, - }) - err := mountAndRunSheets(t, SheetGetFilterViewCondition, []string{ - "+get-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL", - "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestDeleteFilterViewConditionWithURL(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/E", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, - }) - err := mountAndRunSheets(t, SheetDeleteFilterViewCondition, []string{ - "+delete-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL", - "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -// ── --expected validation rejects non-array input ──────────────────────────── - -func TestCreateFilterViewConditionRejectsNonArrayExpected(t *testing.T) { - cases := []struct { - name string - expected string - }{ - {"plain string", "hello"}, - {"JSON object", `{"key":"val"}`}, - {"JSON number", "42"}, - {"JSON string", `"hello"`}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) - err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{ - "+create-filter-view-condition", "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", "--filter-view-id", "fv1", - "--condition-id", "A", "--filter-type", "text", "--compare-type", "contains", - "--expected", tc.expected, "--as", "user", - }, f, stdout) - if err == nil { - t.Fatalf("expected validation error for --expected=%q, got nil", tc.expected) - } - if !strings.Contains(err.Error(), "--expected must be a JSON array") { - t.Fatalf("unexpected error message: %v", err) - } - }) - } -} diff --git a/shortcuts/sheets/lark_sheets_sheet_float_image_test.go b/shortcuts/sheets/lark_sheets_sheet_float_image_test.go deleted file mode 100644 index e8658d199..000000000 --- a/shortcuts/sheets/lark_sheets_sheet_float_image_test.go +++ /dev/null @@ -1,524 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "context" - "encoding/json" - "strings" - "testing" - - "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/httpmock" -) - -// ── CreateFloatImage ──────────────────────────────────────────────────────── - -func TestCreateFloatImageValidateMissingToken(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "", "sheet-id": "s1", - "float-image-token": "boxToken", "range": "s1!A1:A1", - "width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "", - }, nil) - err := SheetCreateFloatImage.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { - t.Fatalf("expected token error, got: %v", err) - } -} - -func TestCreateFloatImageValidateSuccess(t *testing.T) { - t.Parallel() - // Pixel flags are int-typed by the shortcut; leave them unset (empty - // intFlags map) so Cmd.Flags().Changed(...) returns false and - // validateFloatImageDims doesn't try to read non-existent ints. - rt := newDimTestRuntime(t, - map[string]string{ - "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", - "float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "", - }, nil, nil) - if err := SheetCreateFloatImage.Validate(context.Background(), rt); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestCreateFloatImageValidateRejectsMultiCellRange(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", - "float-image-token": "boxToken", "range": "s1!A1:B2", - "width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "", - }, nil) - err := SheetCreateFloatImage.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "single cell") { - t.Fatalf("expected single-cell error, got: %v", err) - } -} - -func TestCreateFloatImageValidateRejectsSheetIDMismatch(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", - "float-image-token": "boxToken", "range": "other!A1:A1", - "width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "", - }, nil) - err := SheetCreateFloatImage.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "does not match --sheet-id") { - t.Fatalf("expected sheet-id mismatch error, got: %v", err) - } -} - -func TestCreateFloatImageValidateRejectsOutOfBoundsDims(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - intFlags map[string]int - wantSubst string - }{ - {"width below 20", map[string]int{"width": 5}, "--width must be >= 20"}, - {"height below 20", map[string]int{"height": 10}, "--height must be >= 20"}, - {"negative offset-x", map[string]int{"offset-x": -1}, "--offset-x must be >= 0"}, - {"negative offset-y", map[string]int{"offset-y": -5}, "--offset-y must be >= 0"}, - } - - baseStr := map[string]string{ - "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", - "float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "", - } - - for _, temp := range tests { - tt := temp - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, baseStr, tt.intFlags, nil) - err := SheetCreateFloatImage.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), tt.wantSubst) { - t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err) - } - }) - } -} - -func TestCreateFloatImageValidateAcceptsBoundaryDims(t *testing.T) { - t.Parallel() - // Boundary values exactly at the lower bound should pass. - rt := newDimTestRuntime(t, - map[string]string{ - "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", - "float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "", - }, - map[string]int{"width": 20, "height": 20, "offset-x": 0, "offset-y": 0}, nil) - if err := SheetCreateFloatImage.Validate(context.Background(), rt); err != nil { - t.Fatalf("boundary values should pass, got: %v", err) - } -} - -func TestCreateFloatImageDryRun(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", - "float-image-token": "boxToken", "range": "sheet1!A1:A1", "float-image-id": "", - }, - map[string]int{"width": 200, "height": 150}, nil) - got := mustMarshalSheetsDryRun(t, SheetCreateFloatImage.DryRun(context.Background(), rt)) - if !strings.Contains(got, `"method":"POST"`) { - t.Fatalf("DryRun should use POST: %s", got) - } - if !strings.Contains(got, `float_images`) { - t.Fatalf("DryRun URL missing float_images: %s", got) - } - if !strings.Contains(got, `"float_image_token":"boxToken"`) { - t.Fatalf("DryRun missing float_image_token: %s", got) - } - if !strings.Contains(got, `"width":200`) || !strings.Contains(got, `"height":150`) { - t.Fatalf("DryRun should emit numeric width/height, got: %s", got) - } -} - -func TestCreateFloatImageExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - stub := &httpmock.Stub{ - Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "float_image": map[string]interface{}{ - "float_image_id": "fi12345678", "float_image_token": "boxToken", - "range": "sheet1!A1:A1", "width": 200, "height": 150, - }, - }}, - } - reg.Register(stub) - err := mountAndRunSheets(t, SheetCreateFloatImage, []string{ - "+create-float-image", "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", "--float-image-token", "boxToken", - "--range", "sheet1!A1:A1", "--width", "200", "--height", "150", - "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(stdout.String(), "float_image_id") { - t.Fatalf("stdout missing float_image_id: %s", stdout.String()) - } - var body map[string]interface{} - if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { - t.Fatalf("parse body: %v", err) - } - if body["float_image_token"] != "boxToken" { - t.Fatalf("unexpected float_image_token: %v", body["float_image_token"]) - } - if w, ok := body["width"].(float64); !ok || w != 200 { - t.Fatalf("width should be numeric 200, got %T=%v", body["width"], body["width"]) - } - if h, ok := body["height"].(float64); !ok || h != 150 { - t.Fatalf("height should be numeric 150, got %T=%v", body["height"], body["height"]) - } -} - -func TestCreateFloatImageWithURL(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "float_image": map[string]interface{}{"float_image_id": "fi12345678"}, - }}, - }) - err := mountAndRunSheets(t, SheetCreateFloatImage, []string{ - "+create-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL", - "--sheet-id", "sheet1", "--float-image-token", "boxToken", - "--range", "sheet1!A1:A1", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestCreateFloatImageExecuteAPIError(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images", - Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"}, - }) - err := mountAndRunSheets(t, SheetCreateFloatImage, []string{ - "+create-float-image", "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", "--float-image-token", "boxToken", - "--range", "sheet1!A1:A1", "--as", "user", - }, f, nil) - if err == nil { - t.Fatal("expected error") - } -} - -// ── UpdateFloatImage ──────────────────────────────────────────────────────── - -func TestUpdateFloatImageValidateRejectsEmptyPayload(t *testing.T) { - t.Parallel() - // Only IDs set, no mutable field: PATCH would be an empty {} body. - rt := newDimTestRuntime(t, - map[string]string{ - "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", - "float-image-id": "fi123", "range": "", - }, nil, nil) - err := SheetUpdateFloatImage.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "specify at least one of --range") { - t.Fatalf("expected empty-payload error, got: %v", err) - } -} - -func TestUpdateFloatImageValidateAcceptsSingleField(t *testing.T) { - t.Parallel() - // Any single mutable field should satisfy the payload check. - tests := []struct { - name string - strFlags map[string]string - intFlags map[string]int - }{ - { - name: "range only", - strFlags: map[string]string{ - "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", - "float-image-id": "fi123", "range": "sheet1!B2:B2", - }, - }, - { - name: "offset-x only (zero value)", - strFlags: map[string]string{ - "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", - "float-image-id": "fi123", "range": "", - }, - intFlags: map[string]int{"offset-x": 0}, - }, - } - for _, temp := range tests { - tt := temp - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, tt.strFlags, tt.intFlags, nil) - if err := SheetUpdateFloatImage.Validate(context.Background(), rt); err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - } -} - -func TestUpdateFloatImageValidateRejectsSheetIDMismatch(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", - "float-image-id": "fi123", "range": "other!A1:A1", - "width": "", "height": "", "offset-x": "", "offset-y": "", - }, nil) - err := SheetUpdateFloatImage.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "does not match --sheet-id") { - t.Fatalf("expected sheet-id mismatch error, got: %v", err) - } -} - -func TestUpdateFloatImageValidateRejectsOutOfBoundsDims(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - intFlags map[string]int - wantSubst string - }{ - {"width below 20", map[string]int{"width": 19}, "--width must be >= 20"}, - {"height below 20", map[string]int{"height": 0}, "--height must be >= 20"}, - {"negative offset-x", map[string]int{"offset-x": -10}, "--offset-x must be >= 0"}, - {"negative offset-y", map[string]int{"offset-y": -1}, "--offset-y must be >= 0"}, - } - - baseStr := map[string]string{ - "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", - "float-image-id": "fi123", "range": "", - } - - for _, temp := range tests { - tt := temp - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, baseStr, tt.intFlags, nil) - err := SheetUpdateFloatImage.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), tt.wantSubst) { - t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err) - } - }) - } -} - -func TestUpdateFloatImageDryRun(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", - "float-image-id": "fi12345678", "range": "sheet1!B2:B2", - }, - map[string]int{"width": 300, "offset-y": 10}, nil) - got := mustMarshalSheetsDryRun(t, SheetUpdateFloatImage.DryRun(context.Background(), rt)) - if !strings.Contains(got, `"method":"PATCH"`) { - t.Fatalf("DryRun should use PATCH: %s", got) - } - if !strings.Contains(got, `fi12345678`) { - t.Fatalf("DryRun missing float_image_id: %s", got) - } - if !strings.Contains(got, `"width":300`) || !strings.Contains(got, `"offset_y":10`) { - t.Fatalf("DryRun should emit numeric width/offset_y, got: %s", got) - } -} - -func TestUpdateFloatImageExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "float_image": map[string]interface{}{"float_image_id": "fi123", "width": 300}, - }}, - }) - err := mountAndRunSheets(t, SheetUpdateFloatImage, []string{ - "+update-float-image", "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", "--float-image-id", "fi123", - "--width", "300", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestUpdateFloatImageWithURL(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "float_image": map[string]interface{}{"float_image_id": "fi123"}, - }}, - }) - err := mountAndRunSheets(t, SheetUpdateFloatImage, []string{ - "+update-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL", - "--sheet-id", "sheet1", "--float-image-id", "fi123", - "--range", "sheet1!C3:C3", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -// ── GetFloatImage ─────────────────────────────────────────────────────────── - -func TestGetFloatImageValidateMissingToken(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "", "sheet-id": "s1", "float-image-id": "fi1", - }, nil) - err := SheetGetFloatImage.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { - t.Fatalf("expected token error, got: %v", err) - } -} - -func TestGetFloatImageDryRun(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "float-image-id": "fi123", - }, nil) - got := mustMarshalSheetsDryRun(t, SheetGetFloatImage.DryRun(context.Background(), rt)) - if !strings.Contains(got, `"method":"GET"`) { - t.Fatalf("DryRun should use GET: %s", got) - } - if !strings.Contains(got, `fi123`) { - t.Fatalf("DryRun missing float_image_id: %s", got) - } -} - -func TestGetFloatImageExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "float_image": map[string]interface{}{ - "float_image_id": "fi123", "range": "sheet1!A1:A1", "width": 100, "height": 100, - }, - }}, - }) - err := mountAndRunSheets(t, SheetGetFloatImage, []string{ - "+get-float-image", "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(stdout.String(), "fi123") { - t.Fatalf("stdout missing fi123: %s", stdout.String()) - } -} - -func TestGetFloatImageWithURL(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "float_image": map[string]interface{}{"float_image_id": "fi123"}, - }}, - }) - err := mountAndRunSheets(t, SheetGetFloatImage, []string{ - "+get-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL", - "--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -// ── ListFloatImages ───────────────────────────────────────────────────────── - -func TestListFloatImagesDryRun(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", - }, nil) - got := mustMarshalSheetsDryRun(t, SheetListFloatImages.DryRun(context.Background(), rt)) - if !strings.Contains(got, `float_images/query`) { - t.Fatalf("DryRun URL missing query: %s", got) - } -} - -func TestListFloatImagesExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/query", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ - "items": []interface{}{ - map[string]interface{}{"float_image_id": "fi1"}, - map[string]interface{}{"float_image_id": "fi2"}, - }, - }}, - }) - err := mountAndRunSheets(t, SheetListFloatImages, []string{ - "+list-float-images", "--spreadsheet-token", "shtTOKEN", "--sheet-id", "sheet1", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(stdout.String(), "fi1") { - t.Fatalf("stdout missing fi1: %s", stdout.String()) - } -} - -func TestListFloatImagesWithURL(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/query", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"items": []interface{}{}}}, - }) - err := mountAndRunSheets(t, SheetListFloatImages, []string{ - "+list-float-images", "--url", "https://example.feishu.cn/sheets/shtFromURL", - "--sheet-id", "sheet1", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -// ── DeleteFloatImage ──────────────────────────────────────────────────────── - -func TestDeleteFloatImageDryRun(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "float-image-id": "fi123", - }, nil) - got := mustMarshalSheetsDryRun(t, SheetDeleteFloatImage.DryRun(context.Background(), rt)) - if !strings.Contains(got, `"method":"DELETE"`) { - t.Fatalf("DryRun should use DELETE: %s", got) - } -} - -func TestDeleteFloatImageExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, - }) - err := mountAndRunSheets(t, SheetDeleteFloatImage, []string{ - "+delete-float-image", "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestDeleteFloatImageWithURL(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123", - Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, - }) - err := mountAndRunSheets(t, SheetDeleteFloatImage, []string{ - "+delete-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL", - "--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} diff --git a/shortcuts/sheets/lark_sheets_sheet_manage_test.go b/shortcuts/sheets/lark_sheets_sheet_manage_test.go deleted file mode 100644 index 97156ca5d..000000000 --- a/shortcuts/sheets/lark_sheets_sheet_manage_test.go +++ /dev/null @@ -1,702 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "context" - "encoding/json" - "errors" - "reflect" - "strings" - "testing" - - "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/httpmock" - "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/shortcuts/common" - "github.com/tidwall/gjson" -) - -func TestSheetCreateSheetValidateMissingToken(t *testing.T) { - t.Parallel() - - rt := newDimTestRuntime(t, - map[string]string{"url": "", "spreadsheet-token": "", "title": "Sheet 2"}, - nil, nil) - err := SheetCreateSheet.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { - t.Fatalf("expected token error, got: %v", err) - } -} - -func TestSheetInfoRequiresSpreadsheetMetaAndReadScopes(t *testing.T) { - t.Parallel() - - want := []string{"sheets:spreadsheet.meta:read", "sheets:spreadsheet:read"} - if !reflect.DeepEqual(SheetInfo.Scopes, want) { - t.Fatalf("SheetInfo scopes = %v, want %v", SheetInfo.Scopes, want) - } -} - -func TestSheetManageValidateRejectsURLAndTokenTogether(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - shortcut common.Shortcut - args map[string]string - }{ - { - name: "create-sheet", - shortcut: SheetCreateSheet, - args: map[string]string{ - "url": "https://example.feishu.cn/sheets/shtFromURL", - "spreadsheet-token": "shtTOKEN", - "title": "Data", - }, - }, - { - name: "copy-sheet", - shortcut: SheetCopySheet, - args: map[string]string{ - "url": "https://example.feishu.cn/sheets/shtFromURL", - "spreadsheet-token": "shtTOKEN", - "sheet-id": "sheet1", - "title": "Copy", - }, - }, - { - name: "delete-sheet", - shortcut: SheetDeleteSheet, - args: map[string]string{ - "url": "https://example.feishu.cn/sheets/shtFromURL", - "spreadsheet-token": "shtTOKEN", - "sheet-id": "sheet1", - }, - }, - { - name: "update-sheet", - shortcut: SheetUpdateSheet, - args: map[string]string{ - "url": "https://example.feishu.cn/sheets/shtFromURL", - "spreadsheet-token": "shtTOKEN", - "sheet-id": "sheet1", - "title": "Renamed", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, tt.args, nil, nil) - err := tt.shortcut.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { - t.Fatalf("expected mutual exclusivity error, got: %v", err) - } - }) - } -} - -func TestSheetCreateSheetValidateRejectsInvalidTitle(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - title string - wantSubst string - }{ - {name: "special chars", title: "bad/title", wantSubst: "must not contain"}, - {name: "empty", title: "", wantSubst: "must not be empty"}, - {name: "tab", title: "bad\ttitle", wantSubst: "tabs or line breaks"}, - {name: "newline", title: "bad\ntitle", wantSubst: "tabs or line breaks"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, - map[string]string{"spreadsheet-token": "sht1", "title": tt.title}, - nil, nil) - err := SheetCreateSheet.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), tt.wantSubst) { - t.Fatalf("expected title error containing %q, got: %v", tt.wantSubst, err) - } - }) - } -} - -func TestSheetCreateSheetValidateRejectsNegativeIndexWhenTitleProvided(t *testing.T) { - t.Parallel() - - rt := newDimTestRuntime(t, - map[string]string{"spreadsheet-token": "sht1", "title": "Data"}, - map[string]int{"index": -1}, nil) - err := SheetCreateSheet.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--index must be >= 0") { - t.Fatalf("expected index validation error, got: %v", err) - } -} - -func TestSheetCopySheetValidateRejectsInvalidTitle(t *testing.T) { - t.Parallel() - - rt := newDimTestRuntime(t, - map[string]string{"spreadsheet-token": "sht1", "sheet-id": "sheet1", "title": "bad\ttitle"}, - nil, nil) - err := SheetCopySheet.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "tabs or line breaks") { - t.Fatalf("expected title error, got: %v", err) - } -} - -func TestSheetCopySheetValidateRejectsNegativeIndexWhenTitleProvided(t *testing.T) { - t.Parallel() - - rt := newDimTestRuntime(t, - map[string]string{"spreadsheet-token": "sht1", "sheet-id": "sheet1", "title": "Copy"}, - map[string]int{"index": -1}, nil) - err := SheetCopySheet.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--index must be >= 0") { - t.Fatalf("expected index validation error, got: %v", err) - } -} - -func TestSheetUpdateSheetValidateRejectsEmptyTitle(t *testing.T) { - t.Parallel() - - rt := newDimTestRuntime(t, - map[string]string{"spreadsheet-token": "sht1", "sheet-id": "sheet1", "title": ""}, - nil, nil) - err := SheetUpdateSheet.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "must not be empty") { - t.Fatalf("expected empty-title error, got: %v", err) - } -} - -func TestSheetCreateSheetDryRun(t *testing.T) { - t.Parallel() - - rt := newDimTestRuntime(t, - map[string]string{"spreadsheet-token": "shtTOKEN", "title": "Data"}, - map[string]int{"index": 0}, nil) - got := mustMarshalSheetsDryRun(t, SheetCreateSheet.DryRun(context.Background(), rt)) - if !strings.Contains(got, `"/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update"`) { - t.Fatalf("DryRun URL mismatch: %s", got) - } - if !strings.Contains(got, `"addSheet"`) || !strings.Contains(got, `"title":"Data"`) || !strings.Contains(got, `"index":0`) { - t.Fatalf("DryRun body mismatch: %s", got) - } -} - -func TestSheetCreateSheetExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - stub := &httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update", - Body: map[string]interface{}{ - "code": 0, - "msg": "success", - "data": map[string]interface{}{ - "replies": []interface{}{ - map[string]interface{}{ - "addSheet": map[string]interface{}{ - "properties": map[string]interface{}{ - "sheetId": "sheet_new", - "title": "Data", - "index": 0, - }, - }, - }, - }, - }, - }, - } - reg.Register(stub) - - err := mountAndRunSheets(t, SheetCreateSheet, []string{ - "+create-sheet", - "--spreadsheet-token", "shtTOKEN", - "--title", "Data", - "--index", "0", - "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if gjson.Get(stdout.String(), "data.sheet_id").String() != "sheet_new" { - t.Fatalf("stdout missing sheet_id: %s", stdout.String()) - } - - var body map[string]interface{} - if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { - t.Fatalf("parse body: %v", err) - } - requests, _ := body["requests"].([]interface{}) - if len(requests) != 1 { - t.Fatalf("unexpected body: %#v", body) - } - req0, _ := requests[0].(map[string]interface{}) - addSheet, _ := req0["addSheet"].(map[string]interface{}) - props, _ := addSheet["properties"].(map[string]interface{}) - if props["title"] != "Data" { - t.Fatalf("request title = %#v", props["title"]) - } - if idx, ok := props["index"].(float64); !ok || idx != 0 { - t.Fatalf("request index = %#v", props["index"]) - } -} - -func TestSheetCopySheetValidateMissingSheetID(t *testing.T) { - t.Parallel() - - rt := newDimTestRuntime(t, - map[string]string{"spreadsheet-token": "sht1", "sheet-id": ""}, - nil, nil) - err := SheetCopySheet.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "--sheet-id") { - t.Fatalf("expected sheet-id error, got: %v", err) - } -} - -func TestSheetCopySheetDryRun(t *testing.T) { - t.Parallel() - - rt := newDimTestRuntime(t, - map[string]string{"spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "title": "Copy"}, - map[string]int{"index": 2}, nil) - got := mustMarshalSheetsDryRun(t, SheetCopySheet.DryRun(context.Background(), rt)) - if !strings.Contains(got, `"/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update"`) { - t.Fatalf("DryRun URL mismatch: %s", got) - } - if !strings.Contains(got, `"copySheet"`) || !strings.Contains(got, `"sheetId":"sheet1"`) || !strings.Contains(got, `"title":"Copy"`) { - t.Fatalf("DryRun body mismatch: %s", got) - } - if !strings.Contains(got, `"[2] Move copied sheet to requested index"`) || !strings.Contains(got, `\u003ccopied_sheet_id\u003e`) || !strings.Contains(got, `"index":2`) { - t.Fatalf("DryRun should describe follow-up move: %s", got) - } -} - -func TestSheetCopySheetExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - copyStub := &httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update", - Body: map[string]interface{}{ - "code": 0, - "msg": "success", - "data": map[string]interface{}{ - "replies": []interface{}{ - map[string]interface{}{ - "copySheet": map[string]interface{}{ - "properties": map[string]interface{}{ - "sheetId": "sheet_copy", - "title": "Copy", - "index": 1, - }, - }, - }, - }, - }, - }, - } - moveStub := &httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update", - Body: map[string]interface{}{ - "code": 0, - "msg": "success", - "data": map[string]interface{}{ - "replies": []interface{}{ - map[string]interface{}{ - "updateSheet": map[string]interface{}{ - "properties": map[string]interface{}{ - "sheetId": "sheet_copy", - "index": 2, - }, - }, - }, - }, - }, - }, - } - reg.Register(copyStub) - reg.Register(moveStub) - - err := mountAndRunSheets(t, SheetCopySheet, []string{ - "+copy-sheet", - "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", - "--title", "Copy", - "--index", "2", - "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if gjson.Get(stdout.String(), "data.sheet_id").String() != "sheet_copy" { - t.Fatalf("stdout missing copied sheet id: %s", stdout.String()) - } - if gjson.Get(stdout.String(), "data.sheet.index").Int() != 2 { - t.Fatalf("stdout missing moved index: %s", stdout.String()) - } - - var copyBody map[string]interface{} - if err := json.Unmarshal(copyStub.CapturedBody, ©Body); err != nil { - t.Fatalf("parse copy body: %v", err) - } - if !strings.Contains(string(copyStub.CapturedBody), `"copySheet"`) { - t.Fatalf("copy request missing copySheet: %s", string(copyStub.CapturedBody)) - } - if !strings.Contains(string(moveStub.CapturedBody), `"updateSheet"`) || !strings.Contains(string(moveStub.CapturedBody), `"index":2`) { - t.Fatalf("move request mismatch: %s", string(moveStub.CapturedBody)) - } -} - -func TestSheetCopySheetExecuteMoveFailureIncludesCopiedSheetRecovery(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update", - Body: map[string]interface{}{ - "code": 0, - "msg": "success", - "data": map[string]interface{}{ - "replies": []interface{}{ - map[string]interface{}{ - "copySheet": map[string]interface{}{ - "properties": map[string]interface{}{ - "sheetId": "sheet_copy", - "title": "Copy", - "index": 1, - }, - }, - }, - }, - }, - }, - }) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update", - Status: 400, - Body: map[string]interface{}{ - "code": 1310211, - "msg": "wrong sheet id", - "error": map[string]interface{}{ - "log_id": "log-move-failed", - }, - }, - }) - - err := mountAndRunSheets(t, SheetCopySheet, []string{ - "+copy-sheet", - "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", - "--title", "Copy", - "--index", "2", - "--as", "user", - }, f, nil) - if err == nil { - t.Fatal("expected move failure, got nil") - } - - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected *output.ExitError with detail, got %T: %v", err, err) - } - if exitErr.Detail.Code != 1310211 { - t.Fatalf("error code = %d, want 1310211", exitErr.Detail.Code) - } - if !strings.Contains(exitErr.Detail.Message, `sheet copied successfully as "sheet_copy"`) { - t.Fatalf("message missing copied sheet id: %q", exitErr.Detail.Message) - } - if !strings.Contains(exitErr.Detail.Hint, "do not retry +copy-sheet") { - t.Fatalf("hint missing retry guard: %q", exitErr.Detail.Hint) - } - if !strings.Contains(exitErr.Detail.Hint, "+update-sheet --spreadsheet-token shtTOKEN --sheet-id sheet_copy --index 2") { - t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint) - } - - detail, _ := exitErr.Detail.Detail.(map[string]interface{}) - if detail["partial_success"] != true { - t.Fatalf("partial_success = %#v, want true", detail["partial_success"]) - } - if detail["sheet_id"] != "sheet_copy" { - t.Fatalf("sheet_id = %#v, want %q", detail["sheet_id"], "sheet_copy") - } - if detail["requested_index"] != 2 { - t.Fatalf("requested_index = %#v, want 2", detail["requested_index"]) - } - if detail["retry_command"] != "lark-cli sheets +update-sheet --spreadsheet-token shtTOKEN --sheet-id sheet_copy --index 2" { - t.Fatalf("retry_command = %#v", detail["retry_command"]) - } - if detail["log_id"] != "log-move-failed" { - t.Fatalf("log_id = %#v, want %q", detail["log_id"], "log-move-failed") - } -} - -func TestSheetDeleteSheetDryRun(t *testing.T) { - t.Parallel() - - rt := newDimTestRuntime(t, - map[string]string{"spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1"}, - nil, nil) - got := mustMarshalSheetsDryRun(t, SheetDeleteSheet.DryRun(context.Background(), rt)) - if !strings.Contains(got, `"method":"POST"`) { - t.Fatalf("DryRun should use POST: %s", got) - } - if !strings.Contains(got, `"/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update"`) { - t.Fatalf("DryRun URL mismatch: %s", got) - } - if !strings.Contains(got, `"deleteSheet"`) || !strings.Contains(got, `"sheetId":"sheet1"`) { - t.Fatalf("DryRun body mismatch: %s", got) - } -} - -func TestSheetDeleteSheetExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update", - Body: map[string]interface{}{ - "code": 0, - "msg": "success", - "data": map[string]interface{}{ - "replies": []interface{}{ - map[string]interface{}{ - "deleteSheet": map[string]interface{}{ - "result": true, - "sheetId": "sheet1", - }, - }, - }, - }, - }, - }) - - err := mountAndRunSheets(t, SheetDeleteSheet, []string{ - "+delete-sheet", - "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", - "--yes", - "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !gjson.Get(stdout.String(), "data.deleted").Bool() { - t.Fatalf("stdout missing deleted=true: %s", stdout.String()) - } - if gjson.Get(stdout.String(), "data.sheet_id").String() != "sheet1" { - t.Fatalf("stdout missing sheet_id: %s", stdout.String()) - } -} - -func TestSheetUpdateSheetValidateRequiresMutation(t *testing.T) { - t.Parallel() - - rt := newDimTestRuntime(t, - map[string]string{"spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1"}, - nil, nil) - err := SheetUpdateSheet.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "specify at least one") { - t.Fatalf("expected mutation error, got: %v", err) - } -} - -func TestSheetUpdateSheetValidateRejectsBadProtectionConfig(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - strFlags map[string]string - intFlags map[string]int - wantSubst string - }{ - { - name: "lock-info requires lock", - strFlags: map[string]string{ - "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "lock-info": "private", - }, - wantSubst: "--lock when updating protection settings", - }, - { - name: "user-ids requires user-id-type", - strFlags: map[string]string{ - "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "lock": "LOCK", - "user-ids": `["ou_1"]`, - }, - wantSubst: "--user-ids requires --user-id-type", - }, - { - name: "negative frozen rows rejected", - strFlags: map[string]string{ - "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", - }, - intFlags: map[string]int{"frozen-row-count": -1}, - wantSubst: "--frozen-row-count must be >= 0", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - rt := newDimTestRuntime(t, tt.strFlags, tt.intFlags, nil) - err := SheetUpdateSheet.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), tt.wantSubst) { - t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err) - } - }) - } -} - -func TestSheetUpdateSheetDryRun(t *testing.T) { - t.Parallel() - - rt := newDimTestRuntime(t, - map[string]string{ - "spreadsheet-token": "shtTOKEN", - "sheet-id": "sheet1", - "title": "Hidden Sheet", - "lock": "LOCK", - "lock-info": "private", - "user-ids": `["ou_1"]`, - "user-id-type": "open_id", - }, - map[string]int{ - "index": 3, - "frozen-row-count": 2, - "frozen-col-count": 1, - }, - map[string]bool{"hidden": false}, - ) - got := mustMarshalSheetsDryRun(t, SheetUpdateSheet.DryRun(context.Background(), rt)) - for _, want := range []string{ - `"/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update"`, - `"user_id_type":"open_id"`, - `"sheetId":"sheet1"`, - `"title":"Hidden Sheet"`, - `"index":3`, - `"hidden":false`, - `"frozenRowCount":2`, - `"frozenColCount":1`, - `"lock":"LOCK"`, - `"lockInfo":"private"`, - `"userIDs":["ou_1"]`, - } { - if !strings.Contains(got, want) { - t.Fatalf("DryRun missing %s: %s", want, got) - } - } -} - -func TestSheetUpdateSheetExecuteSuccess(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - stub := &httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update?user_id_type=open_id", - Body: map[string]interface{}{ - "code": 0, - "msg": "success", - "data": map[string]interface{}{ - "replies": []interface{}{ - map[string]interface{}{ - "updateSheet": map[string]interface{}{ - "properties": map[string]interface{}{ - "sheetId": "sheet1", - "title": "Renamed", - "index": 1, - "hidden": true, - "frozenRowCount": 2, - "frozenColCount": 1, - "protect": map[string]interface{}{ - "lock": "LOCK", - "lockInfo": "private", - "userIDs": []interface{}{"ou_1"}, - }, - }, - }, - }, - }, - }, - }, - } - reg.Register(stub) - - err := mountAndRunSheets(t, SheetUpdateSheet, []string{ - "+update-sheet", - "--spreadsheet-token", "shtTOKEN", - "--sheet-id", "sheet1", - "--title", "Renamed", - "--index", "1", - "--hidden=true", - "--frozen-row-count", "2", - "--frozen-col-count", "1", - "--lock", "LOCK", - "--lock-info", "private", - "--user-ids", `["ou_1"]`, - "--user-id-type", "open_id", - "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if gjson.Get(stdout.String(), "data.sheet_id").String() != "sheet1" { - t.Fatalf("stdout missing sheet_id: %s", stdout.String()) - } - if gjson.Get(stdout.String(), "data.sheet.title").String() != "Renamed" { - t.Fatalf("stdout missing title: %s", stdout.String()) - } - if gjson.Get(stdout.String(), "data.sheet.grid_properties.frozen_row_count").Int() != 2 { - t.Fatalf("stdout missing frozen_row_count: %s", stdout.String()) - } - if gjson.Get(stdout.String(), "data.sheet.protect.lock_info").String() != "private" { - t.Fatalf("stdout missing lock_info: %s", stdout.String()) - } - - var body map[string]interface{} - if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { - t.Fatalf("parse body: %v", err) - } - requests, ok := body["requests"].([]interface{}) - if !ok || len(requests) != 1 { - t.Fatalf("unexpected requests body: %#v", body) - } - req0, _ := requests[0].(map[string]interface{}) - updateSheet, _ := req0["updateSheet"].(map[string]interface{}) - props, _ := updateSheet["properties"].(map[string]interface{}) - if props["sheetId"] != "sheet1" || props["title"] != "Renamed" { - t.Fatalf("unexpected properties: %#v", props) - } -} - -func TestBuildUpdateSheetOutputOmitsBlankTitleWhenTitleNotChanged(t *testing.T) { - t.Parallel() - - out, ok := buildUpdateSheetOutput("shtTOKEN", map[string]interface{}{ - "replies": []interface{}{ - map[string]interface{}{ - "updateSheet": map[string]interface{}{ - "properties": map[string]interface{}{ - "sheetId": "sheet1", - "title": "", - "hidden": false, - "frozenRowCount": 0, - }, - }, - }, - }, - }, false) - if !ok { - t.Fatal("expected output") - } - sheet, _ := out["sheet"].(map[string]interface{}) - if _, exists := sheet["title"]; exists { - t.Fatalf("blank title should be omitted when title is unchanged: %#v", sheet) - } - if sheet["sheet_id"] != "sheet1" { - t.Fatalf("unexpected sheet output: %#v", sheet) - } -} diff --git a/shortcuts/sheets/lark_sheets_sheet_management.go b/shortcuts/sheets/lark_sheets_sheet_management.go deleted file mode 100644 index 67ab6f9d7..000000000 --- a/shortcuts/sheets/lark_sheets_sheet_management.go +++ /dev/null @@ -1,721 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "context" - "errors" - "fmt" - "strings" - - "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/shortcuts/common" -) - -var sheetProtectLockValues = []string{"LOCK", "UNLOCK"} - -func sheetBatchUpdatePath(token string) string { - return fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/sheets_batch_update", validate.EncodePathSegment(token)) -} - -func validateSheetManageToken(runtime *common.RuntimeContext) (string, error) { - if err := common.ExactlyOne(runtime, "url", "spreadsheet-token"); err != nil { - return "", err - } - if token := strings.TrimSpace(runtime.Str("spreadsheet-token")); token != "" { - if err := validate.RejectControlChars(token, "spreadsheet-token"); err != nil { - return "", common.FlagErrorf("%v", err) - } - return token, nil - } - - url := strings.TrimSpace(runtime.Str("url")) - if url == "" { - return "", common.FlagErrorf("specify --url or --spreadsheet-token") - } - - token := extractSpreadsheetToken(url) - if token == "" || token == url { - return "", common.FlagErrorf("--url must be a spreadsheet URL like https://.../sheets/") - } - if err := validate.RejectControlChars(token, "url"); err != nil { - return "", common.FlagErrorf("%v", err) - } - return token, nil -} - -func validateSheetID(flagName, sheetID string) error { - if strings.TrimSpace(sheetID) == "" { - return common.FlagErrorf("specify --%s", flagName) - } - if err := validate.RejectControlChars(sheetID, flagName); err != nil { - return common.FlagErrorf("%v", err) - } - return nil -} - -func validateSheetTitle(flagName, title string) error { - if title == "" { - return common.FlagErrorf("--%s must not be empty", flagName) - } - if strings.ContainsAny(title, "\t\r\n") { - return common.FlagErrorf("--%s must not contain tabs or line breaks", flagName) - } - if err := validate.RejectControlChars(title, flagName); err != nil { - return common.FlagErrorf("%v", err) - } - if len([]rune(title)) > 100 { - return common.FlagErrorf("--%s must be <= 100 characters", flagName) - } - if strings.ContainsAny(title, `/\?*[]:`) || strings.Contains(title, `\`) { - return common.FlagErrorf("--%s must not contain any of / \\ ? * [ ] :", flagName) - } - return nil -} - -func validateNonNegativeInt(flagName string, value int) error { - if value < 0 { - return common.FlagErrorf("--%s must be >= 0, got %d", flagName, value) - } - return nil -} - -func buildSheetCreateProperties(runtime *common.RuntimeContext) map[string]interface{} { - properties := map[string]interface{}{} - if runtime.Changed("title") { - properties["title"] = runtime.Str("title") - } - if runtime.Changed("index") { - properties["index"] = runtime.Int("index") - } - return properties -} - -func buildCreateSheetBody(runtime *common.RuntimeContext) map[string]interface{} { - return map[string]interface{}{ - "requests": []interface{}{ - map[string]interface{}{ - "addSheet": map[string]interface{}{ - "properties": buildSheetCreateProperties(runtime), - }, - }, - }, - } -} - -func buildCopySheetBody(runtime *common.RuntimeContext) map[string]interface{} { - copySheet := map[string]interface{}{ - "source": map[string]interface{}{ - "sheetId": runtime.Str("sheet-id"), - }, - } - if runtime.Changed("title") { - copySheet["destination"] = map[string]interface{}{ - "title": runtime.Str("title"), - } - } - return map[string]interface{}{ - "requests": []interface{}{ - map[string]interface{}{ - "copySheet": copySheet, - }, - }, - } -} - -func buildDeleteSheetBody(sheetID string) map[string]interface{} { - return map[string]interface{}{ - "requests": []interface{}{ - map[string]interface{}{ - "deleteSheet": map[string]interface{}{ - "sheetId": sheetID, - }, - }, - }, - } -} - -func buildMoveCopiedSheetBody(sheetID string, index int) map[string]interface{} { - return map[string]interface{}{ - "requests": []interface{}{ - map[string]interface{}{ - "updateSheet": map[string]interface{}{ - "properties": map[string]interface{}{ - "sheetId": sheetID, - "index": index, - }, - }, - }, - }, - } -} - -func normalizeSheetProperties(properties map[string]interface{}, titleChanged bool) map[string]interface{} { - sheet := map[string]interface{}{} - if v, ok := properties["sheetId"]; ok { - sheet["sheet_id"] = v - } - if v, ok := properties["title"]; ok { - if title, ok := v.(string); !ok || title != "" || titleChanged { - sheet["title"] = v - } - } - if v, ok := properties["index"]; ok { - sheet["index"] = v - } - if v, ok := properties["hidden"]; ok { - sheet["hidden"] = v - } - - grid := map[string]interface{}{} - if v, ok := properties["frozenRowCount"]; ok { - grid["frozen_row_count"] = v - } - if v, ok := properties["frozenColCount"]; ok { - grid["frozen_column_count"] = v - } - if len(grid) > 0 { - sheet["grid_properties"] = grid - } - - if protect, ok := properties["protect"].(map[string]interface{}); ok { - outProtect := map[string]interface{}{} - if v, ok := protect["lock"]; ok { - outProtect["lock"] = v - } - if v, ok := protect["lockInfo"]; ok { - outProtect["lock_info"] = v - } - if v, ok := protect["userIDs"]; ok { - outProtect["user_ids"] = v - } - if len(outProtect) > 0 { - sheet["protect"] = outProtect - } - } - return sheet -} - -func firstReply(data map[string]interface{}) (map[string]interface{}, bool) { - replies, ok := data["replies"].([]interface{}) - if !ok || len(replies) == 0 { - return nil, false - } - reply, ok := replies[0].(map[string]interface{}) - if !ok { - return nil, false - } - return reply, true -} - -func buildOperateSheetOutput(token string, data map[string]interface{}, opKey string, titleChanged bool) (map[string]interface{}, bool) { - reply, ok := firstReply(data) - if !ok { - return nil, false - } - op, ok := reply[opKey].(map[string]interface{}) - if !ok { - return nil, false - } - properties, ok := op["properties"].(map[string]interface{}) - if !ok { - return nil, false - } - sheet := normalizeSheetProperties(properties, titleChanged) - out := map[string]interface{}{ - "spreadsheet_token": token, - "sheet": sheet, - } - if sheetID, ok := sheet["sheet_id"].(string); ok && sheetID != "" { - out["sheet_id"] = sheetID - } - return out, true -} - -func buildDeleteSheetOutput(token string, sheetID string, data map[string]interface{}) (map[string]interface{}, bool) { - reply, ok := firstReply(data) - if !ok { - return nil, false - } - del, ok := reply["deleteSheet"].(map[string]interface{}) - if !ok { - return nil, false - } - out := map[string]interface{}{ - "spreadsheet_token": token, - "sheet_id": sheetID, - "deleted": true, - } - if v, ok := del["sheetId"].(string); ok && v != "" { - out["sheet_id"] = v - } - if v, ok := del["result"].(bool); ok { - out["deleted"] = v - } - return out, true -} - -func mergeSheetOutputs(base, overlay map[string]interface{}) map[string]interface{} { - if base == nil { - return overlay - } - if overlay == nil { - return base - } - out := map[string]interface{}{} - for k, v := range base { - out[k] = v - } - for k, v := range overlay { - if k == "sheet" { - baseSheet, _ := out["sheet"].(map[string]interface{}) - overlaySheet, _ := v.(map[string]interface{}) - mergedSheet := map[string]interface{}{} - for sk, sv := range baseSheet { - mergedSheet[sk] = sv - } - for sk, sv := range overlaySheet { - mergedSheet[sk] = sv - } - out["sheet"] = mergedSheet - continue - } - out[k] = v - } - return out -} - -func mergeSheetErrorDetail(detail interface{}, overlay map[string]interface{}) interface{} { - if len(overlay) == 0 { - return detail - } - if detail == nil { - return overlay - } - if existing, ok := detail.(map[string]interface{}); ok { - merged := map[string]interface{}{} - for k, v := range existing { - merged[k] = v - } - for k, v := range overlay { - merged[k] = v - } - return merged - } - - merged := map[string]interface{}{} - for k, v := range overlay { - merged[k] = v - } - merged["cause_detail"] = detail - return merged -} - -func copySheetMoveRetryCommand(token, sheetID string, index int) string { - return fmt.Sprintf("lark-cli sheets +update-sheet --spreadsheet-token %s --sheet-id %s --index %d", token, sheetID, index) -} - -func wrapCopySheetMoveError(err error, token, sheetID string, index int) error { - if strings.TrimSpace(sheetID) == "" { - return err - } - - retryCommand := copySheetMoveRetryCommand(token, sheetID, index) - msg := fmt.Sprintf("sheet copied successfully as %q, but moving it to index %d failed", sheetID, index) - hint := fmt.Sprintf( - "do not retry +copy-sheet: the new sheet already exists as %s\nretry only the move with: %s", - sheetID, - retryCommand, - ) - detail := map[string]interface{}{ - "partial_success": true, - "failed_step": "move_copied_sheet", - "spreadsheet_token": token, - "sheet_id": sheetID, - "requested_index": index, - "retry_command": retryCommand, - } - - var exitErr *output.ExitError - if errors.As(err, &exitErr) && exitErr.Detail != nil { - if upstreamHint := strings.TrimSpace(exitErr.Detail.Hint); upstreamHint != "" { - hint = upstreamHint + "\n" + hint - } - return &output.ExitError{ - Code: exitErr.Code, - Detail: &output.ErrDetail{ - Type: exitErr.Detail.Type, - Code: exitErr.Detail.Code, - Message: fmt.Sprintf("%s: %s", msg, exitErr.Detail.Message), - Hint: hint, - ConsoleURL: exitErr.Detail.ConsoleURL, - Risk: exitErr.Detail.Risk, - Detail: mergeSheetErrorDetail(exitErr.Detail.Detail, detail), - }, - Err: err, - Raw: exitErr.Raw, - } - } - - return &output.ExitError{ - Code: output.ExitAPI, - Detail: &output.ErrDetail{ - Type: "api_error", - Message: fmt.Sprintf("%s: %v", msg, err), - Hint: hint, - Detail: detail, - }, - Err: err, - } -} - -func validateUpdateSheetFlags(runtime *common.RuntimeContext) error { - if err := validateSheetID("sheet-id", runtime.Str("sheet-id")); err != nil { - return err - } - if runtime.Changed("title") { - if err := validateSheetTitle("title", runtime.Str("title")); err != nil { - return err - } - } - if runtime.Changed("index") { - if err := validateNonNegativeInt("index", runtime.Int("index")); err != nil { - return err - } - } - if runtime.Changed("frozen-row-count") { - if err := validateNonNegativeInt("frozen-row-count", runtime.Int("frozen-row-count")); err != nil { - return err - } - } - if runtime.Changed("frozen-col-count") { - if err := validateNonNegativeInt("frozen-col-count", runtime.Int("frozen-col-count")); err != nil { - return err - } - } - if runtime.Changed("lock-info") { - if err := validate.RejectControlChars(runtime.Str("lock-info"), "lock-info"); err != nil { - return common.FlagErrorf("%v", err) - } - } - - hasProtectConfig := runtime.Changed("lock") || runtime.Changed("lock-info") || runtime.Changed("user-ids") - if hasProtectConfig { - lock := runtime.Str("lock") - if !runtime.Changed("lock") { - return common.FlagErrorf("specify --lock when updating protection settings") - } - if runtime.Changed("lock-info") && lock != "LOCK" { - return common.FlagErrorf("--lock-info requires --lock LOCK") - } - if runtime.Changed("user-ids") { - if lock != "LOCK" { - return common.FlagErrorf("--user-ids requires --lock LOCK") - } - if runtime.Str("user-id-type") == "" { - return common.FlagErrorf("--user-ids requires --user-id-type") - } - userIDs, err := parseJSONStringArray("user-ids", runtime.Str("user-ids")) - if err != nil { - return err - } - if len(userIDs) == 0 { - return common.FlagErrorf("--user-ids must not be empty") - } - } - } - - hasUpdate := runtime.Changed("title") || - runtime.Changed("index") || - runtime.Changed("hidden") || - runtime.Changed("frozen-row-count") || - runtime.Changed("frozen-col-count") || - hasProtectConfig - if !hasUpdate { - return common.FlagErrorf("specify at least one of --title, --index, --hidden, --frozen-row-count, --frozen-col-count, --lock, --lock-info, or --user-ids") - } - - return nil -} - -func buildUpdateSheetBody(runtime *common.RuntimeContext) (map[string]interface{}, error) { - properties := map[string]interface{}{ - "sheetId": runtime.Str("sheet-id"), - } - - if runtime.Changed("title") { - properties["title"] = runtime.Str("title") - } - if runtime.Changed("index") { - properties["index"] = runtime.Int("index") - } - if runtime.Changed("hidden") { - properties["hidden"] = runtime.Bool("hidden") - } - if runtime.Changed("frozen-row-count") { - properties["frozenRowCount"] = runtime.Int("frozen-row-count") - } - if runtime.Changed("frozen-col-count") { - properties["frozenColCount"] = runtime.Int("frozen-col-count") - } - if runtime.Changed("lock") || runtime.Changed("lock-info") || runtime.Changed("user-ids") { - protect := map[string]interface{}{ - "lock": runtime.Str("lock"), - } - if runtime.Changed("lock-info") { - protect["lockInfo"] = runtime.Str("lock-info") - } - if runtime.Changed("user-ids") { - userIDs, err := parseJSONStringArray("user-ids", runtime.Str("user-ids")) - if err != nil { - return nil, err - } - protect["userIDs"] = userIDs - } - properties["protect"] = protect - } - - return map[string]interface{}{ - "requests": []interface{}{ - map[string]interface{}{ - "updateSheet": map[string]interface{}{ - "properties": properties, - }, - }, - }, - }, nil -} - -func buildUpdateSheetOutput(token string, data map[string]interface{}, titleChanged bool) (map[string]interface{}, bool) { - return buildOperateSheetOutput(token, data, "updateSheet", titleChanged) -} - -var SheetCreateSheet = common.Shortcut{ - Service: "sheets", - Command: "+create-sheet", - Description: "Create a sheet in an existing spreadsheet", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "title", Desc: "sheet title"}, - {Name: "index", Type: "int", Desc: "sheet index (0-based)"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateSheetManageToken(runtime); err != nil { - return err - } - if runtime.Changed("title") { - if err := validateSheetTitle("title", runtime.Str("title")); err != nil { - return err - } - } - if runtime.Changed("index") { - if err := validateNonNegativeInt("index", runtime.Int("index")); err != nil { - return err - } - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateSheetManageToken(runtime) - return common.NewDryRunAPI(). - POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update"). - Body(buildCreateSheetBody(runtime)). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateSheetManageToken(runtime) - data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildCreateSheetBody(runtime)) - if err != nil { - return err - } - if out, ok := buildOperateSheetOutput(token, data, "addSheet", runtime.Changed("title")); ok { - runtime.Out(out, nil) - return nil - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetCopySheet = common.Shortcut{ - Service: "sheets", - Command: "+copy-sheet", - Description: "Copy a sheet within a spreadsheet", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "sheet-id", Desc: "source sheet ID", Required: true}, - {Name: "title", Desc: "new sheet title"}, - {Name: "index", Type: "int", Desc: "new sheet index (0-based)"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateSheetManageToken(runtime); err != nil { - return err - } - if err := validateSheetID("sheet-id", runtime.Str("sheet-id")); err != nil { - return err - } - if runtime.Changed("title") { - if err := validateSheetTitle("title", runtime.Str("title")); err != nil { - return err - } - } - if runtime.Changed("index") { - if err := validateNonNegativeInt("index", runtime.Int("index")); err != nil { - return err - } - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateSheetManageToken(runtime) - dry := common.NewDryRunAPI(). - POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update"). - Desc("[1] Copy sheet"). - Body(buildCopySheetBody(runtime)). - Set("token", token) - if runtime.Changed("index") { - dry.POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update"). - Desc("[2] Move copied sheet to requested index"). - Body(buildMoveCopiedSheetBody("", runtime.Int("index"))). - Set("token", token) - } - return dry - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateSheetManageToken(runtime) - data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildCopySheetBody(runtime)) - if err != nil { - return err - } - out, ok := buildOperateSheetOutput(token, data, "copySheet", runtime.Changed("title")) - if !ok { - runtime.Out(data, nil) - return nil - } - if runtime.Changed("index") { - copiedSheetID, _ := out["sheet_id"].(string) - moveResp, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildMoveCopiedSheetBody(copiedSheetID, runtime.Int("index"))) - if err != nil { - return wrapCopySheetMoveError(err, token, copiedSheetID, runtime.Int("index")) - } - if moveOut, ok := buildUpdateSheetOutput(token, moveResp, false); ok { - out = mergeSheetOutputs(out, moveOut) - } - } - runtime.Out(out, nil) - return nil - }, -} - -var SheetDeleteSheet = common.Shortcut{ - Service: "sheets", - Command: "+delete-sheet", - Description: "Delete a sheet from a spreadsheet", - Risk: "high-risk-write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "sheet-id", Desc: "sheet ID to delete", Required: true}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateSheetManageToken(runtime); err != nil { - return err - } - return validateSheetID("sheet-id", runtime.Str("sheet-id")) - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateSheetManageToken(runtime) - return common.NewDryRunAPI(). - POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update"). - Body(buildDeleteSheetBody(runtime.Str("sheet-id"))). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateSheetManageToken(runtime) - data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildDeleteSheetBody(runtime.Str("sheet-id"))) - if err != nil { - return err - } - if out, ok := buildDeleteSheetOutput(token, runtime.Str("sheet-id"), data); ok { - runtime.Out(out, nil) - return nil - } - runtime.Out(data, nil) - return nil - }, -} - -var SheetUpdateSheet = common.Shortcut{ - Service: "sheets", - Command: "+update-sheet", - Description: "Update sheet properties", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "sheet-id", Desc: "sheet ID", Required: true}, - {Name: "title", Desc: "sheet title"}, - {Name: "index", Type: "int", Desc: "sheet index (0-based)"}, - {Name: "hidden", Type: "bool", Desc: "set true to hide or false to unhide"}, - {Name: "frozen-row-count", Type: "int", Desc: "freeze rows through this count (0 unfreezes)"}, - {Name: "frozen-col-count", Type: "int", Desc: "freeze columns through this count (0 unfreezes)"}, - {Name: "lock", Desc: "sheet protection mode", Enum: sheetProtectLockValues}, - {Name: "lock-info", Desc: "protection remark"}, - {Name: "user-ids", Desc: `extra editor IDs for protected sheet as JSON array (e.g. '["ou_xxx"]')`}, - {Name: "user-id-type", Desc: "user ID type for --user-ids", Enum: []string{"open_id", "union_id", "lark_id", "user_id"}}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateSheetManageToken(runtime); err != nil { - return err - } - return validateUpdateSheetFlags(runtime) - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateSheetManageToken(runtime) - body, _ := buildUpdateSheetBody(runtime) - dry := common.NewDryRunAPI(). - POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update"). - Body(body). - Set("token", token) - if userIDType := runtime.Str("user-id-type"); userIDType != "" { - dry.Params(map[string]interface{}{"user_id_type": userIDType}) - } - return dry - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateSheetManageToken(runtime) - body, err := buildUpdateSheetBody(runtime) - if err != nil { - return err - } - var params map[string]interface{} - if userIDType := runtime.Str("user-id-type"); userIDType != "" { - params = map[string]interface{}{"user_id_type": userIDType} - } - - data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), params, body) - if err != nil { - return err - } - if out, ok := buildUpdateSheetOutput(token, data, runtime.Changed("title")); ok { - runtime.Out(out, nil) - return nil - } - runtime.Out(data, nil) - return nil - }, -} diff --git a/shortcuts/sheets/lark_sheets_sheet_media_upload_test.go b/shortcuts/sheets/lark_sheets_sheet_media_upload_test.go deleted file mode 100644 index 181ebcdf7..000000000 --- a/shortcuts/sheets/lark_sheets_sheet_media_upload_test.go +++ /dev/null @@ -1,272 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "bytes" - "encoding/json" - "mime" - "mime/multipart" - "os" - "strings" - "testing" - - "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/httpmock" -) - -func TestSheetMediaUploadValidateMissingToken(t *testing.T) { - t.Parallel() - f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) - err := mountAndRunSheets(t, SheetMediaUpload, []string{ - "+media-upload", "--file", "img.png", "--as", "user", - }, f, stdout) - if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { - t.Fatalf("expected token error, got: %v", err) - } -} - -func TestSheetMediaUploadValidateMissingFileBeforeDryRun(t *testing.T) { - dir := t.TempDir() - withSheetsTestWorkingDir(t, dir) - - f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) - err := mountAndRunSheets(t, SheetMediaUpload, []string{ - "+media-upload", - "--spreadsheet-token", "shtSTUB", - "--file", "missing.png", - "--dry-run", "--as", "user", - }, f, stdout) - if err == nil || !strings.Contains(err.Error(), "file not found") { - t.Fatalf("expected file-not-found error before dry-run planning, got: %v", err) - } -} - -func TestSheetMediaUploadValidateRejectsDirectoryBeforeDryRun(t *testing.T) { - dir := t.TempDir() - withSheetsTestWorkingDir(t, dir) - if err := os.Mkdir("imgdir", 0o755); err != nil { - t.Fatal(err) - } - - f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) - err := mountAndRunSheets(t, SheetMediaUpload, []string{ - "+media-upload", - "--spreadsheet-token", "shtSTUB", - "--file", "imgdir", - "--dry-run", "--as", "user", - }, f, stdout) - if err == nil || !strings.Contains(err.Error(), "regular file") { - t.Fatalf("expected regular-file error before dry-run planning, got: %v", err) - } -} - -func TestSheetMediaUploadDryRunSmallFile(t *testing.T) { - dir := t.TempDir() - withSheetsTestWorkingDir(t, dir) - if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil { - t.Fatal(err) - } - - f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) - err := mountAndRunSheets(t, SheetMediaUpload, []string{ - "+media-upload", - "--spreadsheet-token", "shtSTUB", - "--file", "img.png", - "--dry-run", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - out := stdout.String() - if !strings.Contains(out, "/open-apis/drive/v1/medias/upload_all") { - t.Fatalf("dry-run should use upload_all for small file, got: %s", out) - } - if !strings.Contains(out, `"sheet_image"`) { - t.Fatalf("dry-run should include parent_type=sheet_image, got: %s", out) - } - if strings.Contains(out, "upload_prepare") { - t.Fatalf("dry-run should not use multipart for small file, got: %s", out) - } -} - -func TestSheetMediaUploadDryRunURLExtractsToken(t *testing.T) { - dir := t.TempDir() - withSheetsTestWorkingDir(t, dir) - if err := os.WriteFile("img.png", []byte("x"), 0o600); err != nil { - t.Fatal(err) - } - f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) - err := mountAndRunSheets(t, SheetMediaUpload, []string{ - "+media-upload", - "--url", "https://example.feishu.cn/sheets/shtFromURL?sheet=abc", - "--file", "img.png", - "--dry-run", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(stdout.String(), "shtFromURL") { - t.Fatalf("dry-run should extract token from URL, got: %s", stdout.String()) - } -} - -func TestSheetMediaUploadDryRunLargeFileUsesMultipart(t *testing.T) { - dir := t.TempDir() - withSheetsTestWorkingDir(t, dir) - // Sparse file: 20MB + 1 byte, triggers multipart path without allocating disk. - largeFile, err := os.Create("big.png") - if err != nil { - t.Fatal(err) - } - if err := largeFile.Truncate(20*1024*1024 + 1); err != nil { - t.Fatal(err) - } - _ = largeFile.Close() - - f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) - err = mountAndRunSheets(t, SheetMediaUpload, []string{ - "+media-upload", - "--spreadsheet-token", "shtSTUB", - "--file", "big.png", - "--dry-run", "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - out := stdout.String() - for _, want := range []string{ - "/open-apis/drive/v1/medias/upload_prepare", - "/open-apis/drive/v1/medias/upload_part", - "/open-apis/drive/v1/medias/upload_finish", - } { - if !strings.Contains(out, want) { - t.Fatalf("dry-run should include %q for large file, got: %s", want, out) - } - } - if strings.Contains(out, "upload_all") { - t.Fatalf("dry-run should not use upload_all for large file, got: %s", out) - } -} - -func TestSheetMediaUploadExecuteSuccess(t *testing.T) { - dir := t.TempDir() - withSheetsTestWorkingDir(t, dir) - if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil { - t.Fatal(err) - } - - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - stub := &httpmock.Stub{ - Method: "POST", - URL: "/open-apis/drive/v1/medias/upload_all", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{"file_token": "boxTOK123"}, - }, - } - reg.Register(stub) - - err := mountAndRunSheets(t, SheetMediaUpload, []string{ - "+media-upload", - "--spreadsheet-token", "shtSTUB", - "--file", "img.png", - "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var envelope map[string]interface{} - if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { - t.Fatalf("parse output: %v", err) - } - data, _ := envelope["data"].(map[string]interface{}) - if data["file_token"] != "boxTOK123" { - t.Fatalf("file_token = %v, want boxTOK123", data["file_token"]) - } - if data["spreadsheet_token"] != "shtSTUB" { - t.Fatalf("spreadsheet_token = %v, want shtSTUB", data["spreadsheet_token"]) - } - - body := decodeSheetsMultipartBody(t, stub) - if got := body.Fields["parent_type"]; got != sheetImageParentType { - t.Fatalf("parent_type = %q, want %q", got, sheetImageParentType) - } - if got := body.Fields["parent_node"]; got != "shtSTUB" { - t.Fatalf("parent_node = %q, want shtSTUB", got) - } - if got := body.Fields["file_name"]; got != "img.png" { - t.Fatalf("file_name = %q, want img.png", got) - } - if got := body.Fields["size"]; got != "9" { - t.Fatalf("size = %q, want 9 (len of png-bytes)", got) - } -} - -func TestSheetMediaUploadFileNotFound(t *testing.T) { - dir := t.TempDir() - withSheetsTestWorkingDir(t, dir) - - f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) - err := mountAndRunSheets(t, SheetMediaUpload, []string{ - "+media-upload", - "--spreadsheet-token", "shtSTUB", - "--file", "missing.png", - "--as", "user", - }, f, stdout) - if err == nil { - t.Fatal("expected error for missing file") - } - if !strings.Contains(err.Error(), "file not found") && !strings.Contains(err.Error(), "no such file") { - t.Fatalf("err = %v, want file-not-found error", err) - } -} - -// withSheetsTestWorkingDir chdirs to dir for this test. Not compatible with -// t.Parallel — chdir is process-wide. -func withSheetsTestWorkingDir(t *testing.T, dir string) { - t.Helper() - cwd, err := os.Getwd() - if err != nil { - t.Fatalf("getwd: %v", err) - } - if err := os.Chdir(dir); err != nil { - t.Fatalf("chdir: %v", err) - } - t.Cleanup(func() { _ = os.Chdir(cwd) }) -} - -type capturedSheetsMultipart struct { - Fields map[string]string - Files map[string][]byte -} - -func decodeSheetsMultipartBody(t *testing.T, stub *httpmock.Stub) capturedSheetsMultipart { - t.Helper() - contentType := stub.CapturedHeaders.Get("Content-Type") - mediaType, params, err := mime.ParseMediaType(contentType) - if err != nil { - t.Fatalf("parse content-type %q: %v", contentType, err) - } - if mediaType != "multipart/form-data" { - t.Fatalf("content type = %q, want multipart/form-data", mediaType) - } - reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"]) - body := capturedSheetsMultipart{Fields: map[string]string{}, Files: map[string][]byte{}} - for { - part, err := reader.NextPart() - if err != nil { - break - } - buf := new(bytes.Buffer) - _, _ = buf.ReadFrom(part) - if part.FileName() != "" { - body.Files[part.FormName()] = buf.Bytes() - continue - } - body.Fields[part.FormName()] = buf.String() - } - return body -} diff --git a/shortcuts/sheets/lark_sheets_sheet_ranges_test.go b/shortcuts/sheets/lark_sheets_sheet_ranges_test.go deleted file mode 100644 index a4b2a4eb4..000000000 --- a/shortcuts/sheets/lark_sheets_sheet_ranges_test.go +++ /dev/null @@ -1,268 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "context" - "encoding/json" - "strings" - "testing" - - "github.com/larksuite/cli/shortcuts/common" - "github.com/spf13/cobra" -) - -func mustMarshalSheetsDryRun(t *testing.T, v interface{}) string { - t.Helper() - - b, err := json.Marshal(v) - if err != nil { - t.Fatalf("json.Marshal() error = %v", err) - } - return string(b) -} - -func newSheetsTestRuntime(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext { - t.Helper() - - cmd := &cobra.Command{Use: "test"} - for name := range stringFlags { - cmd.Flags().String(name, "", "") - } - for name := range boolFlags { - cmd.Flags().Bool(name, false, "") - } - if err := cmd.ParseFlags(nil); err != nil { - t.Fatalf("ParseFlags() error = %v", err) - } - for name, value := range stringFlags { - if err := cmd.Flags().Set(name, value); err != nil { - t.Fatalf("Flags().Set(%q) error = %v", name, err) - } - } - for name, value := range boolFlags { - if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[value]); err != nil { - t.Fatalf("Flags().Set(%q) error = %v", name, err) - } - } - return &common.RuntimeContext{Cmd: cmd} -} - -func TestNormalizeSheetRangeSeparators(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - want string - }{ - {name: "standard", input: "sheet_123!A1:B2", want: "sheet_123!A1:B2"}, - {name: "escaped ascii", input: `sheet_123\!A1:B2`, want: "sheet_123!A1:B2"}, - {name: "fullwidth", input: "sheet_123!A1:B2", want: "sheet_123!A1:B2"}, - {name: "escaped fullwidth", input: `sheet_123\!A1:B2`, want: "sheet_123!A1:B2"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - if got := normalizeSheetRangeSeparators(tt.input); got != tt.want { - t.Fatalf("normalizeSheetRangeSeparators(%q) = %q, want %q", tt.input, got, tt.want) - } - }) - } -} - -func TestValidateSheetRangeInputAcceptsEscapedSeparator(t *testing.T) { - t.Parallel() - - if err := validateSheetRangeInput("", `sheet_123\!A1:B2`); err != nil { - t.Fatalf("validateSheetRangeInput() error = %v, want nil", err) - } -} - -func TestSheetReadDryRunNormalizesEscapedSeparator(t *testing.T) { - t.Parallel() - - runtime := newSheetsTestRuntime(t, map[string]string{ - "spreadsheet-token": "sht_test", - "range": `sheet_123\!A1`, - "sheet-id": "", - }, nil) - - got := mustMarshalSheetsDryRun(t, SheetRead.DryRun(context.Background(), runtime)) - if !strings.Contains(got, `"range":"sheet_123!A1:A1"`) { - t.Fatalf("SheetRead.DryRun() = %s, want normalized escaped separator", got) - } -} - -func TestSheetWriteDryRunNormalizesEscapedSeparator(t *testing.T) { - t.Parallel() - - runtime := newSheetsTestRuntime(t, map[string]string{ - "spreadsheet-token": "sht_test", - "range": `sheet_123\!A1:B2`, - "values": `[[1,2],[3,4]]`, - }, nil) - - got := mustMarshalSheetsDryRun(t, SheetWrite.DryRun(context.Background(), runtime)) - if !strings.Contains(got, `"range":"sheet_123!A1:B2"`) { - t.Fatalf("SheetWrite.DryRun() = %s, want normalized escaped separator", got) - } -} - -func TestSheetAppendDryRunNormalizesEscapedSeparator(t *testing.T) { - t.Parallel() - - runtime := newSheetsTestRuntime(t, map[string]string{ - "spreadsheet-token": "sht_test", - "range": `sheet_123\!A1:B2`, - "values": `[["foo","bar"]]`, - }, nil) - - got := mustMarshalSheetsDryRun(t, SheetAppend.DryRun(context.Background(), runtime)) - if !strings.Contains(got, `"range":"sheet_123!A1:B2"`) { - t.Fatalf("SheetAppend.DryRun() = %s, want normalized escaped separator", got) - } -} - -func TestSheetFindDryRunNormalizesEscapedSeparator(t *testing.T) { - t.Parallel() - - runtime := newSheetsTestRuntime(t, map[string]string{ - "spreadsheet-token": "sht_test", - "sheet-id": "sheet_123", - "find": "target", - "range": `sheet_123\!A1:B2`, - }, map[string]bool{ - "ignore-case": false, - "match-entire-cell": false, - "search-by-regex": false, - "include-formulas": false, - }) - - got := mustMarshalSheetsDryRun(t, SheetFind.DryRun(context.Background(), runtime)) - if !strings.Contains(got, `"range":"sheet_123!A1:B2"`) { - t.Fatalf("SheetFind.DryRun() = %s, want normalized escaped separator", got) - } -} - -func TestSheetFindValidateMismatchedRangeSheetID(t *testing.T) { - t.Parallel() - - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", "find": "target", - "range": "sheet2!A1:B2", - }, map[string]bool{ - "ignore-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false, - }) - err := SheetFind.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "does not match --sheet-id") { - t.Fatalf("expected mismatch error, got: %v", err) - } -} - -func TestCellDataValidateRejectsURLAndTokenTogether(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - shortcut common.Shortcut - strFlags map[string]string - boolFlags map[string]bool - }{ - { - name: "read", - shortcut: SheetRead, - strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN"}, - }, - { - name: "write", - shortcut: SheetWrite, - strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "values": `[[1]]`}, - }, - { - name: "append", - shortcut: SheetAppend, - strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "values": `[[1]]`}, - }, - { - name: "find", - shortcut: SheetFind, - strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "find": "x"}, - boolFlags: map[string]bool{"ignore-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false}, - }, - { - name: "replace", - shortcut: SheetReplace, - strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "find": "a", "replacement": "b"}, - boolFlags: map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, tt.strFlags, tt.boolFlags) - err := tt.shortcut.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { - t.Fatalf("expected mutual exclusivity error, got: %v", err) - } - }) - } -} - -func TestCellDataValidateRejectsInvalidSpreadsheetURL(t *testing.T) { - t.Parallel() - - rt := newSheetsTestRuntime(t, map[string]string{ - "url": "https://example.feishu.cn/docx/doxcnNotSheet", - "spreadsheet-token": "", - }, nil) - err := SheetRead.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "spreadsheet URL") { - t.Fatalf("expected invalid spreadsheet URL error, got: %v", err) - } -} - -func TestCellDataValidateRejectsNon2DValues(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - shortcut common.Shortcut - strFlags map[string]string - }{ - { - name: "write 1d array", - shortcut: SheetWrite, - strFlags: map[string]string{"spreadsheet-token": "sht1", "values": `[1,2]`}, - }, - { - name: "write object", - shortcut: SheetWrite, - strFlags: map[string]string{"spreadsheet-token": "sht1", "values": `{"a":1}`}, - }, - { - name: "append string", - shortcut: SheetAppend, - strFlags: map[string]string{"spreadsheet-token": "sht1", "values": `"x"`}, - }, - { - name: "append null", - shortcut: SheetAppend, - strFlags: map[string]string{"spreadsheet-token": "sht1", "values": `null`}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - rt := newSheetsTestRuntime(t, tt.strFlags, nil) - err := tt.shortcut.Validate(context.Background(), rt) - if err == nil || !strings.Contains(err.Error(), "must be a 2D array") { - t.Fatalf("expected 2D-array validation error, got: %v", err) - } - }) - } -} diff --git a/shortcuts/sheets/lark_sheets_sheet_write_image_test.go b/shortcuts/sheets/lark_sheets_sheet_write_image_test.go deleted file mode 100644 index 9ab5fa388..000000000 --- a/shortcuts/sheets/lark_sheets_sheet_write_image_test.go +++ /dev/null @@ -1,590 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "bytes" - "context" - "encoding/json" - "os" - "strings" - "testing" - - "github.com/spf13/cobra" - - "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/httpmock" - "github.com/larksuite/cli/shortcuts/common" -) - -func sheetsTestConfig() *core.CliConfig { - return &core.CliConfig{ - AppID: "sheets-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, - } -} - -func mountAndRunSheets(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error { - t.Helper() - parent := &cobra.Command{Use: "sheets"} - s.Mount(parent, f) - parent.SetArgs(args) - parent.SilenceErrors = true - parent.SilenceUsage = true - if stdout != nil { - stdout.Reset() - } - return parent.Execute() -} - -const existingWriteImageTestFile = "./lark_sheets_cell_images.go" - -// ── Validate ───────────────────────────────────────────────────────────────── - -func TestSheetWriteImageValidateRequiresToken(t *testing.T) { - t.Parallel() - runtime := newSheetsTestRuntime(t, map[string]string{ - "image": "./logo.png", - "range": "A1", - }, nil) - err := SheetWriteImage.Validate(context.Background(), runtime) - if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { - t.Fatalf("expected token error, got: %v", err) - } -} - -func TestSheetWriteImageValidateAcceptsURL(t *testing.T) { - t.Parallel() - runtime := newSheetsTestRuntime(t, map[string]string{ - "url": "https://example.larksuite.com/sheets/shtABC123", - "image": existingWriteImageTestFile, - "range": "sheetId!A1:A1", - "sheet-id": "", - }, nil) - err := SheetWriteImage.Validate(context.Background(), runtime) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetWriteImageValidateAcceptsSpreadsheetToken(t *testing.T) { - t.Parallel() - runtime := newSheetsTestRuntime(t, map[string]string{ - "spreadsheet-token": "shtABC123", - "image": existingWriteImageTestFile, - "range": "sheetId!A1:A1", - "sheet-id": "", - }, nil) - err := SheetWriteImage.Validate(context.Background(), runtime) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetWriteImageValidateRejectsRelativeRangeWithoutSheetID(t *testing.T) { - t.Parallel() - runtime := newSheetsTestRuntime(t, map[string]string{ - "spreadsheet-token": "shtABC123", - "image": "./logo.png", - "range": "A1", - "sheet-id": "", - }, nil) - err := SheetWriteImage.Validate(context.Background(), runtime) - if err == nil || !strings.Contains(err.Error(), "--sheet-id") { - t.Fatalf("expected sheet-id error, got: %v", err) - } -} - -func TestSheetWriteImageValidateAcceptsRelativeRangeWithSheetID(t *testing.T) { - t.Parallel() - runtime := newSheetsTestRuntime(t, map[string]string{ - "spreadsheet-token": "shtABC123", - "image": existingWriteImageTestFile, - "range": "A1", - "sheet-id": "sheet1", - }, nil) - err := SheetWriteImage.Validate(context.Background(), runtime) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetWriteImageValidateRejectsMultiCellRange(t *testing.T) { - t.Parallel() - runtime := newSheetsTestRuntime(t, map[string]string{ - "spreadsheet-token": "shtABC123", - "image": "./logo.png", - "range": "sheet1!A1:B2", - "sheet-id": "", - }, nil) - err := SheetWriteImage.Validate(context.Background(), runtime) - if err == nil || !strings.Contains(err.Error(), "single cell") { - t.Fatalf("expected single cell error, got: %v", err) - } -} - -func TestSheetWriteImageValidateAcceptsSameCellSpan(t *testing.T) { - t.Parallel() - runtime := newSheetsTestRuntime(t, map[string]string{ - "spreadsheet-token": "shtABC123", - "image": existingWriteImageTestFile, - "range": "sheet1!A1:A1", - "sheet-id": "", - }, nil) - err := SheetWriteImage.Validate(context.Background(), runtime) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -// ── DryRun ─────────────────────────────────────────────────────────────────── - -func TestSheetWriteImageDryRun(t *testing.T) { - t.Parallel() - runtime := newSheetsTestRuntime(t, map[string]string{ - "spreadsheet-token": "sht_test", - "range": "sheet1!B2", - "sheet-id": "", - "image": "./chart.png", - "name": "", - "url": "", - }, nil) - got := mustMarshalSheetsDryRun(t, SheetWriteImage.DryRun(context.Background(), runtime)) - - if !strings.Contains(got, `"range":"sheet1!B2:B2"`) { - t.Fatalf("DryRun range not normalized: %s", got) - } - if !strings.Contains(got, `"name":"chart.png"`) { - t.Fatalf("DryRun name not derived from image path: %s", got) - } - // JSON escapes < and > to \u003c and \u003e. - if !strings.Contains(got, `binary: ./chart.png`) { - t.Fatalf("DryRun image field not showing binary placeholder: %s", got) - } - if !strings.Contains(got, `"description":"JSON upload with inline image bytes"`) { - t.Fatalf("DryRun description incorrect: %s", got) - } -} - -func TestSheetWriteImageDryRunCustomName(t *testing.T) { - t.Parallel() - runtime := newSheetsTestRuntime(t, map[string]string{ - "spreadsheet-token": "sht_test", - "range": "sheet1!A1:A1", - "sheet-id": "", - "image": "./output.png", - "name": "revenue_chart.png", - "url": "", - }, nil) - got := mustMarshalSheetsDryRun(t, SheetWriteImage.DryRun(context.Background(), runtime)) - - if !strings.Contains(got, `"name":"revenue_chart.png"`) { - t.Fatalf("DryRun should use custom name: %s", got) - } -} - -func TestSheetWriteImageDryRunUsesURL(t *testing.T) { - t.Parallel() - runtime := newSheetsTestRuntime(t, map[string]string{ - "spreadsheet-token": "", - "range": "sheet1!C3", - "sheet-id": "", - "image": "./logo.png", - "name": "", - "url": "https://example.larksuite.com/sheets/shtFromURL", - }, nil) - got := mustMarshalSheetsDryRun(t, SheetWriteImage.DryRun(context.Background(), runtime)) - - if !strings.Contains(got, `shtFromURL`) { - t.Fatalf("DryRun should extract token from URL: %s", got) - } - if !strings.Contains(got, `"range":"sheet1!C3:C3"`) { - t.Fatalf("DryRun range not normalized: %s", got) - } -} - -func TestSheetWriteImageDryRunWithSheetID(t *testing.T) { - t.Parallel() - runtime := newSheetsTestRuntime(t, map[string]string{ - "spreadsheet-token": "sht_test", - "range": "A1", - "sheet-id": "mySheet", - "image": "./img.png", - "name": "", - "url": "", - }, nil) - got := mustMarshalSheetsDryRun(t, SheetWriteImage.DryRun(context.Background(), runtime)) - - if !strings.Contains(got, `"range":"mySheet!A1:A1"`) { - t.Fatalf("DryRun should normalize relative range with sheet-id: %s", got) - } -} - -func TestSheetWriteImageDryRunRejectsMissingFile(t *testing.T) { - f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) - err := mountAndRunSheets(t, SheetWriteImage, []string{ - "+write-image", - "--spreadsheet-token", "shtTOKEN", - "--range", "sheet1!A1:A1", - "--image", "./missing.png", - "--dry-run", "--as", "user", - }, f, stdout) - if err == nil || !strings.Contains(err.Error(), "image file not found") { - t.Fatalf("expected file-not-found error before dry-run planning, got: %v", err) - } -} - -func TestSheetWriteImageDryRunRejectsDirectory(t *testing.T) { - tmpDir := t.TempDir() - cmdutil.TestChdir(t, tmpDir) - if err := os.Mkdir("imgdir", 0o755); err != nil { - t.Fatalf("Mkdir() error: %v", err) - } - - f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) - err := mountAndRunSheets(t, SheetWriteImage, []string{ - "+write-image", - "--spreadsheet-token", "shtTOKEN", - "--range", "sheet1!A1:A1", - "--image", "./imgdir", - "--dry-run", "--as", "user", - }, f, stdout) - if err == nil || !strings.Contains(err.Error(), "regular file") { - t.Fatalf("expected regular-file error before dry-run planning, got: %v", err) - } -} - -func TestSheetWriteImageDryRunRejectsAbsolutePath(t *testing.T) { - f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) - err := mountAndRunSheets(t, SheetWriteImage, []string{ - "+write-image", - "--spreadsheet-token", "shtTOKEN", - "--range", "sheet1!A1:A1", - "--image", "/etc/passwd", - "--dry-run", "--as", "user", - }, f, stdout) - if err == nil || !strings.Contains(err.Error(), "unsafe image path") { - t.Fatalf("expected unsafe-path error before dry-run planning, got: %v", err) - } -} - -func TestSheetWriteImageDryRunRejectsOversizedFile(t *testing.T) { - tmpDir := t.TempDir() - cmdutil.TestChdir(t, tmpDir) - - fh, err := os.Create("huge.png") - if err != nil { - t.Fatalf("Create() error: %v", err) - } - if err := fh.Truncate(20*1024*1024 + 1); err != nil { - fh.Close() - t.Fatalf("Truncate() error: %v", err) - } - if err := fh.Close(); err != nil { - t.Fatalf("Close() error: %v", err) - } - - f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) - err = mountAndRunSheets(t, SheetWriteImage, []string{ - "+write-image", - "--spreadsheet-token", "shtTOKEN", - "--range", "sheet1!A1:A1", - "--image", "./huge.png", - "--dry-run", "--as", "user", - }, f, stdout) - if err == nil || !strings.Contains(err.Error(), "exceeds 20MB limit") { - t.Fatalf("expected size error before dry-run planning, got: %v", err) - } -} - -// ── Execute ────────────────────────────────────────────────────────────────── - -func TestSheetWriteImageExecuteSendsJSON(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - - stub := &httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/values_image", - Body: map[string]interface{}{ - "code": 0, - "msg": "success", - "data": map[string]interface{}{ - "spreadsheetToken": "shtTOKEN", - "revision": float64(5), - "updateRange": "sheet1!A1:A1", - }, - }, - } - reg.Register(stub) - - tmpDir := t.TempDir() - cmdutil.TestChdir(t, tmpDir) - - // Create a small test image file. - imgData := []byte{0x89, 0x50, 0x4E, 0x47} // PNG magic bytes - if err := os.WriteFile("test.png", imgData, 0644); err != nil { - t.Fatalf("WriteFile() error: %v", err) - } - - err := mountAndRunSheets(t, SheetWriteImage, []string{ - "+write-image", - "--spreadsheet-token", "shtTOKEN", - "--range", "sheet1!A1:A1", - "--image", "./test.png", - "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Verify the request was sent as JSON (not multipart/form-data). - if stub.CapturedHeaders == nil { - t.Fatal("request headers not captured") - } - ct := stub.CapturedHeaders.Get("Content-Type") - if !strings.Contains(ct, "application/json") { - t.Fatalf("Content-Type = %q, want application/json", ct) - } - - // Verify the captured body contains the image as base64 in JSON. - var body map[string]interface{} - if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { - t.Fatalf("request body is not valid JSON: %v", err) - } - if body["range"] != "sheet1!A1:A1" { - t.Fatalf("body range = %v, want sheet1!A1:A1", body["range"]) - } - if body["name"] != "test.png" { - t.Fatalf("body name = %v, want test.png", body["name"]) - } - if body["image"] == nil { - t.Fatal("body image field is nil") - } - - // Verify output contains expected fields. - if !strings.Contains(stdout.String(), "spreadsheetToken") { - t.Fatalf("stdout missing spreadsheetToken: %s", stdout.String()) - } -} - -func TestSheetWriteImageExecuteRejectsNonexistentFile(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) - - tmpDir := t.TempDir() - cmdutil.TestChdir(t, tmpDir) - - err := mountAndRunSheets(t, SheetWriteImage, []string{ - "+write-image", - "--spreadsheet-token", "shtTOKEN", - "--range", "sheet1!A1:A1", - "--image", "./nonexistent.png", - "--as", "user", - }, f, nil) - if err == nil { - t.Fatal("expected error for nonexistent file, got nil") - } - if !strings.Contains(err.Error(), "not found") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetWriteImageExecuteRejectsDirectory(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) - - tmpDir := t.TempDir() - cmdutil.TestChdir(t, tmpDir) - - // Create a directory where the image path points. - if err := os.Mkdir("not_a_file", 0755); err != nil { - t.Fatalf("Mkdir() error: %v", err) - } - - err := mountAndRunSheets(t, SheetWriteImage, []string{ - "+write-image", - "--spreadsheet-token", "shtTOKEN", - "--range", "sheet1!A1:A1", - "--image", "./not_a_file", - "--as", "user", - }, f, nil) - if err == nil { - t.Fatal("expected error for directory, got nil") - } - if !strings.Contains(err.Error(), "regular file") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetWriteImageExecuteWithURL(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - - stub := &httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/values_image", - Body: map[string]interface{}{ - "code": 0, - "msg": "success", - "data": map[string]interface{}{ - "spreadsheetToken": "shtFromURL", - "revision": float64(1), - "updateRange": "sheet1!B2:B2", - }, - }, - } - reg.Register(stub) - - tmpDir := t.TempDir() - cmdutil.TestChdir(t, tmpDir) - - if err := os.WriteFile("pic.png", []byte{0x89, 0x50, 0x4E, 0x47}, 0644); err != nil { - t.Fatalf("WriteFile() error: %v", err) - } - - err := mountAndRunSheets(t, SheetWriteImage, []string{ - "+write-image", - "--url", "https://example.larksuite.com/sheets/shtFromURL", - "--range", "sheet1!B2:B2", - "--image", "./pic.png", - "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(stdout.String(), "shtFromURL") { - t.Fatalf("stdout missing token: %s", stdout.String()) - } -} - -func TestSheetWriteImageExecuteCustomName(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - - stub := &httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/values_image", - Body: map[string]interface{}{ - "code": 0, - "msg": "success", - "data": map[string]interface{}{ - "spreadsheetToken": "shtTOKEN", - "revision": float64(2), - "updateRange": "sheet1!A1:A1", - }, - }, - } - reg.Register(stub) - - tmpDir := t.TempDir() - cmdutil.TestChdir(t, tmpDir) - - if err := os.WriteFile("raw.png", []byte{0x89, 0x50, 0x4E, 0x47}, 0644); err != nil { - t.Fatalf("WriteFile() error: %v", err) - } - - err := mountAndRunSheets(t, SheetWriteImage, []string{ - "+write-image", - "--spreadsheet-token", "shtTOKEN", - "--range", "sheet1!A1:A1", - "--image", "./raw.png", - "--name", "custom_chart.png", - "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var body map[string]interface{} - if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { - t.Fatalf("request body is not valid JSON: %v", err) - } - if body["name"] != "custom_chart.png" { - t.Fatalf("body name = %v, want custom_chart.png", body["name"]) - } -} - -func TestSheetWriteImageExecuteAPIError(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) - - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/values_image", - Status: 400, - Body: map[string]interface{}{ - "code": 90001, - "msg": "invalid range", - }, - }) - - tmpDir := t.TempDir() - cmdutil.TestChdir(t, tmpDir) - - if err := os.WriteFile("bad.png", []byte{0x89, 0x50}, 0644); err != nil { - t.Fatalf("WriteFile() error: %v", err) - } - - err := mountAndRunSheets(t, SheetWriteImage, []string{ - "+write-image", - "--spreadsheet-token", "shtTOKEN", - "--range", "sheet1!A1:A1", - "--image", "./bad.png", - "--as", "user", - }, f, nil) - if err == nil { - t.Fatal("expected API error, got nil") - } -} - -func TestSheetWriteImageExecuteRejectsOversizedFile(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) - - tmpDir := t.TempDir() - cmdutil.TestChdir(t, tmpDir) - - // Create a sparse file that reports > 20MB without writing actual data. - fh, err := os.Create("huge.png") - if err != nil { - t.Fatalf("Create() error: %v", err) - } - if err := fh.Truncate(21 * 1024 * 1024); err != nil { - t.Fatalf("Truncate() error: %v", err) - } - fh.Close() - - err = mountAndRunSheets(t, SheetWriteImage, []string{ - "+write-image", - "--spreadsheet-token", "shtTOKEN", - "--range", "sheet1!A1:A1", - "--image", "./huge.png", - "--as", "user", - }, f, nil) - if err == nil { - t.Fatal("expected error for oversized file, got nil") - } - if !strings.Contains(err.Error(), "exceeds 20MB limit") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSheetWriteImageExecuteRejectsAbsolutePath(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) - - tmpDir := t.TempDir() - cmdutil.TestChdir(t, tmpDir) - - if err := os.WriteFile("abs.png", []byte{0x89, 0x50}, 0644); err != nil { - t.Fatalf("WriteFile() error: %v", err) - } - - err := mountAndRunSheets(t, SheetWriteImage, []string{ - "+write-image", - "--spreadsheet-token", "shtTOKEN", - "--range", "sheet1!A1:A1", - "--image", "/etc/passwd", - "--as", "user", - }, f, nil) - if err == nil { - t.Fatal("expected error for absolute path, got nil") - } - if !strings.Contains(err.Error(), "unsafe image path") { - t.Fatalf("unexpected error: %v", err) - } -} diff --git a/shortcuts/sheets/lark_sheets_spreadsheet_management.go b/shortcuts/sheets/lark_sheets_spreadsheet_management.go deleted file mode 100644 index d60b5df27..000000000 --- a/shortcuts/sheets/lark_sheets_spreadsheet_management.go +++ /dev/null @@ -1,323 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strings" - "time" - - larkcore "github.com/larksuite/oapi-sdk-go/v3/core" - - "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/shortcuts/common" -) - -var SheetInfo = common.Shortcut{ - Service: "sheets", - Command: "+info", - Description: "View spreadsheet metadata and sheet information", - Risk: "read", - Scopes: []string{"sheets:spreadsheet.meta:read", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - return common.NewDryRunAPI(). - GET("/open-apis/sheets/v3/spreadsheets/:token"). - Set("token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token := runtime.Str("spreadsheet-token") - if runtime.Str("url") != "" { - token = extractSpreadsheetToken(runtime.Str("url")) - } - - spreadsheetData, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s", validate.EncodePathSegment(token)), nil, nil) - if err != nil { - return err - } - - var sheetsData interface{} - sheetsResult, sheetsErr := runtime.RawAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(token)), nil, nil) - if sheetsErr == nil { - if sheetsMap, ok := sheetsResult.(map[string]interface{}); ok { - if d, ok := sheetsMap["data"].(map[string]interface{}); ok { - sheetsData = d - } - } - } - - runtime.Out(map[string]interface{}{ - "spreadsheet": spreadsheetData, - "sheets": sheetsData, - }, nil) - return nil - }, -} - -var SheetCreate = common.Shortcut{ - Service: "sheets", - Command: "+create", - Description: "Create a spreadsheet (optional header row and initial data)", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:create", "sheets:spreadsheet:write_only"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "title", Desc: "spreadsheet title", Required: true}, - {Name: "folder-token", Desc: "target folder token"}, - {Name: "headers", Desc: "header row JSON array"}, - {Name: "data", Desc: "initial data JSON 2D array"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if headersStr := runtime.Str("headers"); headersStr != "" { - var headers []interface{} - if err := json.Unmarshal([]byte(headersStr), &headers); err != nil { - return common.FlagErrorf("--headers invalid JSON, must be a 1D array") - } - } - if dataStr := runtime.Str("data"); dataStr != "" { - var rows [][]interface{} - if err := json.Unmarshal([]byte(dataStr), &rows); err != nil { - return common.FlagErrorf("--data invalid JSON, must be a 2D array") - } - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - body := map[string]interface{}{"title": runtime.Str("title")} - if folderToken := runtime.Str("folder-token"); folderToken != "" { - body["folder_token"] = folderToken - } - d := common.NewDryRunAPI(). - POST("/open-apis/sheets/v3/spreadsheets"). - Body(body) - if runtime.IsBot() { - d.Desc("After spreadsheet creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new spreadsheet.") - } - return d - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - title := runtime.Str("title") - folderToken := runtime.Str("folder-token") - headersStr := runtime.Str("headers") - dataStr := runtime.Str("data") - var allRows []interface{} - - if headersStr != "" { - var headers []interface{} - if err := json.Unmarshal([]byte(headersStr), &headers); err != nil { - return common.FlagErrorf("--headers invalid JSON, must be a 1D array") - } - if len(headers) > 0 { - allRows = append(allRows, any(headers)) - } - } - - if dataStr != "" { - var rows []interface{} - if err := json.Unmarshal([]byte(dataStr), &rows); err != nil { - return common.FlagErrorf("--data invalid JSON, must be a 2D array") - } - if len(rows) > 0 { - allRows = append(allRows, rows...) - } - } - - createData := map[string]interface{}{"title": title} - if folderToken != "" { - createData["folder_token"] = folderToken - } - - data, err := runtime.CallAPI("POST", "/open-apis/sheets/v3/spreadsheets", nil, createData) - if err != nil { - return err - } - - spreadsheet, _ := data["spreadsheet"].(map[string]interface{}) - token, _ := spreadsheet["spreadsheet_token"].(string) - - if len(allRows) > 0 && token != "" { - appendRange, err := getFirstSheetID(runtime, token) - if err != nil { - return err - } - if _, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{ - "valueRange": map[string]interface{}{ - "range": appendRange, - "values": allRows, - }, - }); err != nil { - return err - } - } - - out := map[string]interface{}{ - "spreadsheet_token": token, - "title": title, - } - url, _ := spreadsheet["url"].(string) - if url = strings.TrimSpace(url); url != "" { - out["url"] = url - } else if u := common.BuildResourceURL(runtime.Config.Brand, "sheet", token); u != "" { - out["url"] = u - } - if grant := common.AutoGrantCurrentUserDrivePermission(runtime, token, "sheet"); grant != nil { - out["permission_grant"] = grant - } - - runtime.Out(out, nil) - return nil - }, -} - -var SheetExport = common.Shortcut{ - Service: "sheets", - Command: "+export", - Description: "Export a spreadsheet (async task polling + optional download)", - Risk: "read", - Scopes: []string{"docs:document:export", "drive:file:download"}, - AuthTypes: []string{"user", "bot"}, - Flags: []common.Flag{ - {Name: "url", Desc: "spreadsheet URL"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "file-extension", Desc: "export format: xlsx | csv", Required: true, Enum: []string{"xlsx", "csv"}}, - {Name: "output-path", Desc: "local save path"}, - {Name: "sheet-id", Desc: "sheet ID (required for CSV)"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := validateSheetManageToken(runtime); err != nil { - return err - } - if runtime.Str("file-extension") == "csv" && strings.TrimSpace(runtime.Str("sheet-id")) == "" { - return common.FlagErrorf("--sheet-id is required when --file-extension is csv") - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := validateSheetManageToken(runtime) - body := map[string]interface{}{ - "token": token, - "type": "sheet", - "file_extension": runtime.Str("file-extension"), - } - if sheetID := strings.TrimSpace(runtime.Str("sheet-id")); sheetID != "" { - body["sub_id"] = sheetID - } - return common.NewDryRunAPI(). - POST("/open-apis/drive/v1/export_tasks"). - Body(body). - Set("token", token).Set("ext", runtime.Str("file-extension")) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, _ := validateSheetManageToken(runtime) - - fileExt := runtime.Str("file-extension") - outputPath := runtime.Str("output-path") - sheetID := runtime.Str("sheet-id") - - if outputPath != "" { - if _, err := runtime.ResolveSavePath(outputPath); err != nil { - return output.ErrValidation("unsafe output path: %s", err) - } - } - - exportData := map[string]interface{}{ - "token": token, - "type": "sheet", - "file_extension": fileExt, - } - if sheetID != "" { - exportData["sub_id"] = sheetID - } - - data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, exportData) - if err != nil { - return err - } - ticket, _ := data["ticket"].(string) - - fmt.Fprintf(runtime.IO().ErrOut, "Waiting for export task to complete...\n") - var fileToken string - for i := 0; i < 50; i++ { - time.Sleep(600 * time.Millisecond) - pollResult, err := runtime.RawAPI("GET", "/open-apis/drive/v1/export_tasks/"+ticket, map[string]interface{}{"token": token}, nil) - if err != nil { - continue - } - pollMap, _ := pollResult.(map[string]interface{}) - pollData, _ := pollMap["data"].(map[string]interface{}) - pollResult2, _ := pollData["result"].(map[string]interface{}) - if pollResult2 != nil { - ft, _ := pollResult2["file_token"].(string) - if ft != "" { - fileToken = ft - break - } - } - } - - if fileToken == "" { - return output.Errorf(output.ExitAPI, "api_error", "export task timed out") - } - - fmt.Fprintf(runtime.IO().ErrOut, "Export complete: file_token=%s\n", fileToken) - - if outputPath == "" { - runtime.Out(map[string]interface{}{ - "file_token": fileToken, - "ticket": ticket, - }, nil) - return nil - } - - resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{ - HttpMethod: http.MethodGet, - ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)), - }) - if err != nil { - return output.ErrNetwork("download failed: %s", err) - } - defer resp.Body.Close() - - result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{ - ContentType: resp.Header.Get("Content-Type"), - ContentLength: resp.ContentLength, - }, resp.Body) - if err != nil { - return common.WrapSaveErrorByCategory(err, "io") - } - - savedPath, _ := runtime.ResolveSavePath(outputPath) - if savedPath == "" { - savedPath = outputPath - } - runtime.Out(map[string]interface{}{ - "saved_path": savedPath, - "size_bytes": result.Size(), - }, nil) - return nil - }, -} diff --git a/shortcuts/sheets/sheet_ai_api.go b/shortcuts/sheets/sheet_ai_api.go new file mode 100644 index 000000000..eb4368476 --- /dev/null +++ b/shortcuts/sheets/sheet_ai_api.go @@ -0,0 +1,119 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// ToolKind selects the One-OpenAPI endpoint and its rate-limit bucket. +// +// - ToolKindRead → POST .../tools/invoke_read (scope sheets:spreadsheet:read, 10 qps) +// - ToolKindWrite → POST .../tools/invoke_write (scope sheets:spreadsheet:write_only, 5 qps) +type ToolKind string + +const ( + ToolKindRead ToolKind = "read" + ToolKindWrite ToolKind = "write" +) + +// toolInvokePath returns the full One-OpenAPI invoke path for the given +// spreadsheet token + tool kind. Network-free, safe in DryRun. +func toolInvokePath(token string, kind ToolKind) string { + suffix := "invoke_read" + if kind == ToolKindWrite { + suffix = "invoke_write" + } + return fmt.Sprintf("/open-apis/sheet_ai/v2/spreadsheets/%s/tools/%s", + validate.EncodePathSegment(token), suffix) +} + +// buildToolBody constructs the One-OpenAPI request body for a tool invocation. +// `input` is serialized to a JSON string per the API contract; callers pass +// a typed Go map and never need to handle JSON encoding themselves. +func buildToolBody(toolName string, input map[string]interface{}) (map[string]interface{}, error) { + inputJSON, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("encode tool input: %w", err) + } + return map[string]interface{}{ + "tool_name": toolName, + "input": string(inputJSON), + }, nil +} + +// callTool invokes a sheet-ai tool via the One-OpenAPI endpoint and decodes +// the JSON-string `output` field into a generic Go value (typically +// map[string]interface{}). When the tool returns an empty `output`, callTool +// returns nil with no error. +// +// kind must match the tool's read/write classification — passing a read tool +// to invoke_write (or vice versa) results in a 403 from the gateway. +func callTool( + ctx context.Context, + runtime *common.RuntimeContext, + token string, + kind ToolKind, + toolName string, + input map[string]interface{}, +) (interface{}, error) { + body, err := buildToolBody(toolName, input) + if err != nil { + return nil, err + } + + raw, err := runtime.RawAPI("POST", toolInvokePath(token, kind), nil, body) + if err != nil { + return nil, err + } + + envelope, ok := raw.(map[string]interface{}) + if !ok { + return nil, output.Errorf(output.ExitAPI, "tool_response", + "tool %q: unexpected non-JSON-object response: %v", toolName, raw) + } + code, _ := util.ToFloat64(envelope["code"]) + if code != 0 { + msg, _ := envelope["msg"].(string) + return nil, output.ErrAPI(int(code), fmt.Sprintf("tool %q failed: [%d] %s", toolName, int(code), msg), envelope["error"]) + } + data, _ := envelope["data"].(map[string]interface{}) + rawOutput, _ := data["output"].(string) + if rawOutput == "" { + return nil, nil + } + + var out interface{} + if err := json.Unmarshal([]byte(rawOutput), &out); err != nil { + return nil, output.Errorf(output.ExitAPI, "tool_output", + "tool %q returned invalid JSON output: %v", toolName, err) + } + return out, nil +} + +// invokeToolDryRun renders the One-OpenAPI request the shortcut would send. +// The wire-format body (with input serialized to a JSON string) is preserved +// for fidelity, and a decoded tool_input map is surfaced alongside so humans +// don't have to mentally unmarshal the string field. +func invokeToolDryRun( + token string, + kind ToolKind, + toolName string, + input map[string]interface{}, +) *common.DryRunAPI { + wireBody, _ := buildToolBody(toolName, input) + return common.NewDryRunAPI(). + POST(toolInvokePath(token, kind)). + Body(wireBody). + Set("spreadsheet_token", token). + Set("tool_name", toolName). + Set("tool_input", input) +} diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index ac5e94487..47e6b5106 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -5,67 +5,12 @@ package sheets import "github.com/larksuite/cli/shortcuts/common" -// Shortcuts returns all sheets shortcuts. +// Shortcuts returns all lark-sheets shortcuts. The list is grouped by +// canonical skill to mirror the sheet-skill-spec layout +// (lark_sheet_workbook → lark_sheet_float_image). func Shortcuts() []common.Shortcut { return []common.Shortcut{ - // Spreadsheet management - SheetCreate, - SheetInfo, - SheetExport, - - // Sheet management - SheetCreateSheet, - SheetCopySheet, - SheetDeleteSheet, - SheetUpdateSheet, - - // Cell data - SheetRead, - SheetWrite, - SheetAppend, - SheetFind, - SheetReplace, - - // Cell style and merge - SheetSetStyle, - SheetBatchSetStyle, - SheetMergeCells, - SheetUnmergeCells, - - // Cell images - SheetWriteImage, - - // Row/column management - SheetAddDimension, - SheetInsertDimension, - SheetUpdateDimension, - SheetMoveDimension, - SheetDeleteDimension, - - // Filter views - SheetCreateFilterView, - SheetUpdateFilterView, - SheetListFilterViews, - SheetGetFilterView, - SheetDeleteFilterView, - SheetCreateFilterViewCondition, - SheetUpdateFilterViewCondition, - SheetListFilterViewConditions, - SheetGetFilterViewCondition, - SheetDeleteFilterViewCondition, - - // Dropdown - SheetSetDropdown, - SheetUpdateDropdown, - SheetGetDropdown, - SheetDeleteDropdown, - - // Float images - SheetMediaUpload, - SheetCreateFloatImage, - SheetUpdateFloatImage, - SheetGetFloatImage, - SheetListFloatImages, - SheetDeleteFloatImage, + // lark_sheet_workbook + WorkbookInfo, } } diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index c9098fdea..bcddab005 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -1,343 +1,40 @@ --- name: lark-sheets -version: 1.2.0 -description: "飞书电子表格:创建和操作电子表格。支持创建表格、创建/复制/删除/更新工作表、读写单元格、追加行数据、查找内容、导出文件。当用户需要创建电子表格、管理工作表、批量读写数据、在已知表格中查找内容、导出或下载表格时使用。若用户是想按名称或关键词搜索云空间里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。" +version: 2.0.0-draft +description: "飞书电子表格:分析、编辑与可视化飞书在线表格。每个能力子域(read / write / chart / pivot / filter ...)有独立 reference 文档,内容与 sheet-ai-skills 对应 skill 完全一致;CLI 实现按子域提供对应 shortcut,详见各 reference。" metadata: requires: bins: ["lark-cli"] + siblings: ["lark-shared"] cliHelp: "lark-cli sheets --help" --- -# sheets (v3) - -**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理** - -## 快速决策 -- 已知 spreadsheet URL / token 后,再进入 `sheets +info`、`sheets +read`、`sheets +find` 等对象内部操作。 - -## 核心概念 - -### 文档类型与 Token - -飞书开放平台中,不同类型的文档有不同的 URL 格式和 Token 处理方式。在进行文档操作(如添加评论、下载文件等)时,必须先获取正确的 `file_token`。 - -### 文档 URL 格式与 Token 处理 - -| URL 格式 | 示例 | Token 类型 | 处理方式 | -|----------|---------------------------------------------------------|-----------|----------| -| `/docx/` | `https://example.larksuite.com/docx/doxcnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 | -| `/doc/` | `https://example.larksuite.com/doc/doccnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 | -| `/wiki/` | `https://example.larksuite.com/wiki/wikcnxxxxxxxxx` | `wiki_token` | ⚠️ **不能直接使用**,需要先查询获取真实的 `obj_token` | -| `/sheets/` | `https://example.larksuite.com/sheets/shtcnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 | -| `/drive/folder/` | `https://example.larksuite.com/drive/folder/fldcnxxxx` | `folder_token` | URL 路径中的 token 作为文件夹 token 使用 | - -### Wiki 链接特殊处理(关键!) - -知识库链接(`/wiki/TOKEN`)背后可能是云文档、电子表格、多维表格等不同类型的文档。**不能直接假设 URL 中的 token 就是 file_token**,必须先查询实际类型和真实 token。 - -#### 处理流程 - -1. **使用 `wiki.spaces.get_node` 查询节点信息** - ```bash - lark-cli wiki spaces get_node --params '{"token":"wiki_token"}' - ``` - -2. **从返回结果中提取关键信息** - - `node.obj_type`:文档类型(docx/doc/sheet/bitable/slides/file/mindnote) - - `node.obj_token`:**真实的文档 token**(用于后续操作) - - `node.title`:文档标题 - -3. **根据 `obj_type` 使用对应的 API** - - | obj_type | 说明 | 使用的 API | - |----------|------|-----------| - | `docx` | 新版云文档 | `drive file.comments.*`、`docx.*` | - | `doc` | 旧版云文档 | `drive file.comments.*` | - | `sheet` | 电子表格 | `sheets.*` | - | `bitable` | 多维表格 | `bitable.*` | - | `slides` | 幻灯片 | `drive.*` | - | `file` | 文件 | `drive.*` | - | `mindnote` | 思维导图 | `drive.*` | - -#### 查询示例 - -```bash -# 查询 wiki 节点 -lark-cli wiki spaces get_node --params '{"token":"wiki_token"}' -``` - -返回结果示例: -```json -{ - "node": { - "obj_type": "docx", - "obj_token": "xxxx", - "title": "标题", - "node_type": "origin", - "space_id": "12345678910" - } -} -``` - -### 资源关系 - -``` -Wiki Space (知识空间) -└── Wiki Node (知识库节点) - ├── obj_type: docx (新版文档) - │ └── obj_token (真实文档 token) - ├── obj_type: doc (旧版文档) - │ └── obj_token (真实文档 token) - ├── obj_type: sheet (电子表格) - │ └── obj_token (真实文档 token) - ├── obj_type: bitable (多维表格) - │ └── obj_token (真实文档 token) - └── obj_type: file/slides/mindnote - └── obj_token (真实文档 token) - -Drive Folder (云空间文件夹) -└── File (文件/文档) - └── file_token (直接使用) -``` - -**操作流程(重要):** - -1. **create** — 创建筛选 - - 用于首次创建筛选 - - ⚠️ range 必须覆盖所有需要筛选的列(如 B1:E200) - - 如果已有筛选存在,再用 create 会覆盖整个筛选 - -2. **update** — 更新筛选 - - 用于在已有筛选上添加/更新指定列的条件 - - 只需指定 col 和 condition,不需要 range - -3. **delete** — 删除筛选 - -4. **get** — 获取筛选状态 - -**多列筛选示例:** - -创建媒体名称(B列)和情感分析(E列)的双重筛选: - -```bash -# 1. 删除现有筛选(如有) -lark-cli sheets spreadsheet.sheet.filters delete \ - --params '{"spreadsheet_token":"","sheet_id":""}' - -# 2. 创建第一个筛选,range 覆盖所有要筛选的列 -lark-cli sheets spreadsheet.sheet.filters create \ - --params '{"spreadsheet_token":"","sheet_id":""}' \ - --data '{"col":"B","condition":{"expected":["xx"],"filter_type":"multiValue"},"range":"!B1:E200"}' - -# 3. 添加第二个筛选条件 -lark-cli sheets spreadsheet.sheet.filters update \ - --params '{"spreadsheet_token":"","sheet_id":""}' \ - --data '{"col":"E","condition":{"expected":["xx"],"filter_type":"multiValue"}}' -``` - -**常见错误:** -- `Wrong Filter Value`:筛选已存在,需要先 delete 再 create -- `Excess Limit`:update 时重复添加同一列条件 - -### 单元格数据类型 - -接受二维数组的 shortcut(`+write`/`+append` 的 `--values`、`+create` 的 `--data`)中,每个单元格值支持以下类型。**公式、带文本链接、@人、@文档、下拉列表必须使用对象格式**,直接传字符串会被当作纯文本存储。 - -| 类型 | 写入格式 | 示例 | -|------|---------|------| -| 字符串 | `"文本"` | `"hello"` | -| 数字 | `数字` | `123`、`3.14` | -| 日期 | `数字`(自 1899-12-30 起的天数,需先设单元格日期格式) | `42101` | -| 链接(纯 URL) | `"URL 字符串"` | `"https://example.com"` | -| 链接(带文本) | `{"type":"url","text":"显示文本","link":"URL"}` | `{"type":"url","text":"飞书","link":"https://www.feishu.cn"}` | -| 邮箱 | `"邮箱字符串"` | `"user@example.com"` | -| **公式** | `{"type":"formula","text":"=公式"}` | `{"type":"formula","text":"=SUM(A1:A10)"}` | -| @人 | `{"type":"mention","text":"标识","textType":"email\|openId\|unionId","notify":false}` | `{"type":"mention","text":"user@example.com","textType":"email","notify":false}`(notify 可选,默认 false;仅在用户明确要求通知时设为 true) | -| @文档 | `{"type":"mention","textType":"fileToken","text":"token","objType":"类型"}` | `{"type":"mention","textType":"fileToken","text":"shtXXX","objType":"sheet"}` | -| 下拉列表 | `{"type":"multipleValue","values":[值1,值2]}` | `{"type":"multipleValue","values":["选项A","选项B"]}` | - -**写入公式示例**: - -```bash -# ✅ 正确:使用对象格式 -lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \ - --values '[[{"type":"formula","text":"=SUM(C2:C5)"}]]' - -# ❌ 错误:直接传字符串,会被存为纯文本 -lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \ - --values '[["=SUM(C2:C5)"]]' -``` - -> **公式语法参考**:涉及 ARRAYFORMULA、原生数组函数、MAP/LAMBDA、日期差、Excel 公式改写等飞书特有规则时,先阅读 [`references/lark-sheets-formula.md`](references/lark-sheets-formula.md)。 - -**限制**: -- 公式支持 IMPORTRANGE 跨表引用(最多 5 层嵌套、每个工作表最多 100 个引用) -- @人仅支持同租户用户,单次最多 50 人 -- 下拉列表需**先配置下拉选项**,否则 `multipleValue` 写入会变成纯文本。配置方法见 [`references/lark-sheets-dropdown.md#set-dropdown`](references/lark-sheets-dropdown.md#set-dropdown)。值中的字符串不能包含逗号 - -## Shortcuts(推荐优先使用) - -Shortcut 是对常用操作的高级封装(`lark-cli sheets + [flags]`)。有 Shortcut 的操作优先使用。 - -### Spreadsheet Management - -对应参考文档:[spreadsheet-management](references/lark-sheets-spreadsheet-management.md) - -| Shortcut | 说明 | -|----------|------| -| [`+create`](references/lark-sheets-spreadsheet-management.md#create) | Create a spreadsheet (optional header row and initial data) | -| [`+info`](references/lark-sheets-spreadsheet-management.md#info) | View spreadsheet metadata and sheet information | -| [`+export`](references/lark-sheets-spreadsheet-management.md#export) | Export a spreadsheet (async task polling + optional download) | - -### Sheet Management - -对应参考文档:[sheet-management](references/lark-sheets-sheet-management.md) - -| Shortcut | 说明 | -|----------|------| -| [`+create-sheet`](references/lark-sheets-sheet-management.md#create-sheet) | Create a sheet in an existing spreadsheet | -| [`+copy-sheet`](references/lark-sheets-sheet-management.md#copy-sheet) | Copy a sheet within a spreadsheet | -| [`+delete-sheet`](references/lark-sheets-sheet-management.md#delete-sheet) | Delete a sheet from a spreadsheet | -| [`+update-sheet`](references/lark-sheets-sheet-management.md#update-sheet) | Update sheet title, position, visibility, freeze, or protection | - -### Cell Data - -对应参考文档:[cell-data](references/lark-sheets-cell-data.md) - -| Shortcut | 说明 | -|----------|------| -| [`+read`](references/lark-sheets-cell-data.md#read) | Read spreadsheet cell values | -| [`+write`](references/lark-sheets-cell-data.md#write) | Write to spreadsheet cells (overwrite mode) | -| [`+append`](references/lark-sheets-cell-data.md#append) | Append rows to a spreadsheet | -| [`+find`](references/lark-sheets-cell-data.md#find) | Find cells in a spreadsheet | -| [`+replace`](references/lark-sheets-cell-data.md#replace) | Find and replace cell values | - -### Cell Style And Merge - -对应参考文档:[cell-style-and-merge](references/lark-sheets-cell-style-and-merge.md) - -| Shortcut | 说明 | -|----------|------| -| [`+set-style`](references/lark-sheets-cell-style-and-merge.md#set-style) | Set cell style for a range | -| [`+batch-set-style`](references/lark-sheets-cell-style-and-merge.md#batch-set-style) | Batch set cell styles for multiple ranges | -| [`+merge-cells`](references/lark-sheets-cell-style-and-merge.md#merge-cells) | Merge cells in a spreadsheet | -| [`+unmerge-cells`](references/lark-sheets-cell-style-and-merge.md#unmerge-cells) | Unmerge (split) cells in a spreadsheet | - -### Cell Images - -对应参考文档:[cell-images](references/lark-sheets-cell-images.md) - -| Shortcut | 说明 | -|----------|------| -| [`+write-image`](references/lark-sheets-cell-images.md#write-image) | Write an image into a spreadsheet cell | - -### Row Column Management - -对应参考文档:[row-column-management](references/lark-sheets-row-column-management.md) - -| Shortcut | 说明 | -|----------|------| -| [`+add-dimension`](references/lark-sheets-row-column-management.md#add-dimension) | Add rows or columns at the end of a sheet | -| [`+insert-dimension`](references/lark-sheets-row-column-management.md#insert-dimension) | Insert rows or columns at a specified position | -| [`+update-dimension`](references/lark-sheets-row-column-management.md#update-dimension) | Update row or column properties (visibility, size) | -| [`+move-dimension`](references/lark-sheets-row-column-management.md#move-dimension) | Move rows or columns to a new position | -| [`+delete-dimension`](references/lark-sheets-row-column-management.md#delete-dimension) | Delete rows or columns | - -### Filter Views - -对应参考文档:[filter-views](references/lark-sheets-filter-views.md) - -| Shortcut | 说明 | -|----------|------| -| [`+create-filter-view`](references/lark-sheets-filter-views.md#create-filter-view) | Create a filter view | -| [`+update-filter-view`](references/lark-sheets-filter-views.md#update-filter-view) | Update a filter view | -| [`+list-filter-views`](references/lark-sheets-filter-views.md#list-filter-views) | List all filter views in a sheet | -| [`+get-filter-view`](references/lark-sheets-filter-views.md#get-filter-view) | Get a filter view by ID | -| [`+delete-filter-view`](references/lark-sheets-filter-views.md#delete-filter-view) | Delete a filter view | -| [`+create-filter-view-condition`](references/lark-sheets-filter-views.md#create-filter-view-condition) | Create a filter condition on a filter view | -| [`+update-filter-view-condition`](references/lark-sheets-filter-views.md#update-filter-view-condition) | Update a filter condition | -| [`+list-filter-view-conditions`](references/lark-sheets-filter-views.md#list-filter-view-conditions) | List all filter conditions of a filter view | -| [`+get-filter-view-condition`](references/lark-sheets-filter-views.md#get-filter-view-condition) | Get a filter condition by column | -| [`+delete-filter-view-condition`](references/lark-sheets-filter-views.md#delete-filter-view-condition) | Delete a filter condition | - -### Dropdown - -对应参考文档:[dropdown](references/lark-sheets-dropdown.md) - -| Shortcut | 说明 | -|----------|------| -| [`+set-dropdown`](references/lark-sheets-dropdown.md#set-dropdown) | 设置下拉列表(`multipleValue` 写入的前置步骤) | -| [`+update-dropdown`](references/lark-sheets-dropdown.md#update-dropdown) | 更新下拉列表选项 | -| [`+get-dropdown`](references/lark-sheets-dropdown.md#get-dropdown) | 查询下拉列表配置 | -| [`+delete-dropdown`](references/lark-sheets-dropdown.md#delete-dropdown) | 删除下拉列表 | - -### Float Images - -对应参考文档:[float-images](references/lark-sheets-float-images.md) - -| Shortcut | 说明 | -|----------|------| -| [`+media-upload`](references/lark-sheets-float-images.md#media-upload) | 上传本地图片素材,返回 `file_token`(供 `+create-float-image` 使用;>20MB 自动分片) | -| [`+create-float-image`](references/lark-sheets-float-images.md#create-float-image) | 创建浮动图片 | -| [`+update-float-image`](references/lark-sheets-float-images.md#update-float-image) | 更新浮动图片属性 | -| [`+get-float-image`](references/lark-sheets-float-images.md#get-float-image) | 获取浮动图片 | -| [`+list-float-images`](references/lark-sheets-float-images.md#list-float-images) | 查询所有浮动图片 | -| [`+delete-float-image`](references/lark-sheets-float-images.md#delete-float-image) | 删除浮动图片 | - -### Formula - -对应参考文档:[formula](references/lark-sheets-formula.md) - -> 浮动图片相关的读接口只返回元数据(含 `float_image_token`),**不包含图片字节**。要读取图片内容,用 token 调 `lark-cli docs +media-preview --token "" --output ./image.png`。 - -## API Resources - -```bash -lark-cli schema sheets.. # 调用 API 前必须先查看参数结构 -lark-cli sheets [flags] # 调用 API -``` - -> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。 - -### spreadsheets - - - `create` — 创建电子表格 - - `get` — 获取电子表格信息 - - `patch` — 修改电子表格属性 - -### spreadsheet.sheet.filters - - - `create` — 创建筛选 - - `delete` — 删除筛选 - - `get` — 获取筛选 - - `update` — 更新筛选 - -### spreadsheet.sheets - - - `find` — 查找单元格 - -### spreadsheet.sheet.float_images - - - `create` — 创建浮动图片 - - `patch` — 更新浮动图片 - - `get` — 获取浮动图片 - - `query` — 查询所有浮动图片 - - `delete` — 删除浮动图片 - -## 权限表 - -| 方法 | 所需 scope | -|------|-----------| -| `spreadsheets.create` | `sheets:spreadsheet:create` | -| `spreadsheets.get` | `sheets:spreadsheet.meta:read` | -| `spreadsheets.patch` | `sheets:spreadsheet.meta:write_only` | -| `spreadsheet.sheet.filters.create` | `sheets:spreadsheet:write_only` | -| `spreadsheet.sheet.filters.delete` | `sheets:spreadsheet:write_only` | -| `spreadsheet.sheet.filters.get` | `sheets:spreadsheet:read` | -| `spreadsheet.sheet.filters.update` | `sheets:spreadsheet:write_only` | -| `spreadsheet.sheets.find` | `sheets:spreadsheet:read` | -| `spreadsheet.sheet.float_images.create` | `sheets:spreadsheet:write_only` | -| `spreadsheet.sheet.float_images.patch` | `sheets:spreadsheet:write_only` | -| `spreadsheet.sheet.float_images.get` | `sheets:spreadsheet:read` | -| `spreadsheet.sheet.float_images.query` | `sheets:spreadsheet:read` | -| `spreadsheet.sheet.float_images.delete` | `sheets:spreadsheet:write_only` | +# sheets + +**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理。** + +飞书电子表格:分析、编辑与可视化飞书在线表格。每个能力子域(read / write / chart / pivot / filter ...)有独立 reference 文档,内容与 sheet-ai-skills 对应 skill 完全一致;CLI 实现按子域提供对应 shortcut,详见各 reference。 + +## References + +每个 reference 内容与 `sheet-ai-skills` 中对应 skill 完全一致,按能力子域组织。CLI shortcut / API 路由的实现按这些子域提供,并在对应 reference 中描述。 + +| Reference | 描述 | +| --- | --- | +| [飞书表格核心操作:分析、编辑与可视化](references/lark-sheets-core-operations.md) | 飞书表格核心操作工作流。当用户需要对已有的飞书表格进行查看、分析、编辑或可视化时使用。适用场景:数据查询与统计、公式计算、表格美化、创建图表/透视表、筛选排序、批量修改数据、调整表格结构等。即使用户没有明确说"飞书表格",只要操作对象是已有的在线表格,都应触发此工作流。不适用于本地 Excel 文件操作。 | +| [飞书表格样式与配色规范](references/lark-sheets-visual-standards.md) | 飞书表格样式与配色规范:表头/数据区/汇总行的颜色、字号、对齐、边框等取值标准,以及新增汇总行、追加行列继承原表风格、已有区域美化等典型场景的决策流程与样式要点。工具调用参数细节请参考对应的 lark_sheet_write_cells / lark_sheet_range_operations / lark_sheet_batch_update。条件格式(高亮、标红、数据条、色阶)请使用 lark_sheet_conditional_format。仅针对飞书表格;Excel 请参考 excel_general_visual_standards。 | +| [飞书表格公式生成规则](references/lark-sheets-formula-translation.md) | Excel 公式到飞书表格公式的迁移与生成规则。核心目标不是保留 Excel 原语法,而是按飞书表格可执行规则重写公式,并在结果上尽量对齐 Excel。当用户要求把 Excel 公式改写成飞书表格公式,或需要生成飞书公式(尤其涉及 ARRAYFORMULA、原生数组函数、INDEX/OFFSET、MAP/LAMBDA、日期差、多层范围结果与二次展开)时使用。仅针对飞书在线表格,不适用于本地 Excel 文件执行。 | +| [Lark Sheet Workbook](references/lark-sheets-workbook.md) | 管理飞书表格的工作簿结构(子表列表及元数据)。当用户提到"看看这个表格有什么"、"表格结构"、"有哪些 sheet"、"新建一个 sheet"、"删除这个工作表"、"重命名"、"复制一份"、"移动到前面"时使用。仅针对飞书表格。 | +| [Lark Sheet Sheet Structure](references/lark-sheets-sheet-structure.md) | 管理飞书表格的子表结构与布局。适用场景:查看行高、列宽、隐藏行列、合并单元格等布局信息,以及"插入一行"、"删除这列"、"隐藏行"、"冻结表头"、行列分组(大纲折叠/展开)等操作。行列大纲仅在用户明确提到"行分组"、"列分组"、"大纲"、"outline"时才触发,"按XXX分组"等数据分组场景请使用 lark_sheet_pivot_table。如需在表尾追加数据,应先通过此 skill 插入行,再通过 lark_sheet_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_sheet_float_image);若只需把一块 CSV 纯值批量铺到表格上(不带公式/样式),直接使用 set_range_from_csv 更短更快。追加数据需先通过 lark_sheet_sheet_structure 插入行列。仅针对飞书表格。 | +| [Lark Sheet Range Operations](references/lark-sheets-range-operations.md) | 对飞书表格中指定区域执行结构性操作(不涉及写入单元格数据值)。适用场景:清除内容或格式("清空"、"删除内容"、"去掉格式")、合并/取消合并单元格、调整行高列宽("加宽列"、"自适应列宽")、移动/复制/填充/排序数据("移动数据"、"复制到"、"自动填充"、"按某列排序")。写入单元格数据请使用 lark_sheet_write_cells。仅针对飞书表格。 | +| [Lark Sheet Batch Update](references/lark-sheets-batch-update.md) | 将多个飞书表格写入操作合并为一次批量执行,按顺序依次完成。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。仅针对飞书表格。 | +| [Lark Sheet Chart](references/lark-sheets-chart.md) | 管理飞书表格中的图表(柱形图、折线图、饼图、条形图、面积图、散点图、组合图、雷达图等)。当用户需要创建图表、修改图表样式或数据源、查看已有图表配置、删除图表时使用。也适用于用户提到"数据可视化"、"画个图"、"趋势分析"、"对比图"、"占比分析"、"做个图表"等数据可视化相关场景。仅针对飞书表格。 | +| [Lark Sheet Pivot Table](references/lark-sheets-pivot-table.md) | 管理飞书表格中的数据透视表。当用户需要创建透视表、修改透视表的行列字段/聚合方式/筛选条件、查看已有透视表配置、删除透视表时使用。也适用于用户提到"分组汇总"、"交叉分析"、"按XXX统计"、"按字段分组"、"再分下组"、"多维分析"、"数据透视"等场景。仅针对飞书表格。 | +| [Lark Sheet Conditional Format](references/lark-sheets-conditional-format.md) | 管理飞书表格中的条件格式规则(重复值高亮、单元格值比较、数据条、色阶、排名、自定义公式等)。当用户需要创建条件格式、修改已有规则的范围或样式、查看当前条件格式配置、删除规则时使用。也适用于用户提到"高亮"、"标红"、"颜色标记"、"数据条"、"色阶"、"条件样式"等场景。仅针对飞书表格。 | +| [Lark Sheet Filter](references/lark-sheets-filter.md) | 管理飞书表格中的筛选器(filter)。当用户需要筛选数据(按文本/数值/颜色/日期条件过滤行)、查看已有筛选配置、修改或删除筛选器时使用。也适用于"只看"、"筛选出"、"仅保留符合条件的"等场景。仅针对飞书表格。 | +| [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_sheet_write_cells Skill。仅针对飞书表格。 | diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md new file mode 100644 index 000000000..853c8b6d5 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -0,0 +1,84 @@ +# Lark Sheet Batch Update + +## 写入边界 + 回读校验 + +`+batch-update` 把多次写入打包成单次请求,但每个子操作仍受编辑类任务硬性默认规则约束: + +1. **目标 range 必须落在用户授权范围内**:除用户明示要修改的区域外,子操作禁止扩张到无关单元格 / 列 / Sheet。规划 range 时先确认每个子操作的边界。 +2. **批次完成后必须回读校验**:整个 `+batch-update` 执行成功后,用 `+csv-get` 或 `+cells-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末),与 `doubao_code_interpreter` 预先计算的预期值对照。 +3. **预期条数前置断言**:涉及"批量填充 N 行"或"对 M 个区域分别写入"时,先把 N、M 硬编码进代码,回读后断言实际等于预期;不一致就再发一轮 `+batch-update` 补齐,禁止交付半成品。 + +## 使用场景 + +写入。批量执行多个写入工具操作。将多个工具调用合并为一次请求,按顺序依次执行。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。注意:不支持嵌套 batch_update。 + +**⚠️ 何时必须使用 `+batch-update`(硬性要求)**: +- 需要对**多个**不同区域执行 `+cells-{merge|unmerge}` 时(如按分组合并多列相同内容) +- 需要对**多个**不同区域执行 `+dim-resize` 时(如统一调整多列列宽或多行行高) +- 需要先插入行列再写入数据时(`+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` + `+cells-set`) +- 需要对多个区域执行不同写入操作时(多次 `+cells-set` + `+cells-clear` 等组合) + +当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求。逐个调用会快速耗尽工具调用轮次上限(60R),导致任务无法完成。 + +## Shortcuts + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 + +| MCP tool | CLI shortcut | Risk | 分组 | +| --- | --- | --- | --- | +| `batch_update` | `+batch-update` | high-risk-write | 批量 | + +## Flags + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 + +### `+batch-update` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet 定位(与子操作的 sheet 定位独立) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet 定位(与子操作的 sheet 定位独立) | +| `--data` | 专有 | string + File + Stdin | 是 | JSON:`{"operations":[{"tool":"set_cell_range","params":{...}}, ...]}`;按数组顺序串行执行 | +| `--yes` | 系统 | bool | 是 | `high-risk-write`,必须二次确认(不带时退出码 10) | +| `--dry-run` | 系统 | bool | 否 | 输出每个子操作的请求模板,零网络副作用 | + +## Schemas + +> 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 + +### `+batch-update` `--data` + +_要批量执行的操作列表,按顺序依次执行_ + +**数组项**(类型 object): +- `input` (object) — 对应工具的入参,结构与单独调用该工具时完全一致 +- `tool_name` (string) — 要执行的工具名称,如 "set_cell_range"、"clear_cell_range"、"modify_sheet_structure" 等 + +## Examples + +> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 + +公共四件套:`--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(前两者 XOR;`+batch-update` 本身不强制 sheet-id,子操作各自携带)。 + +### `+batch-update` + +示例: + +```bash +lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" --yes \ + --data @ops.json + +# ops.json: +# { +# "operations": [ +# {"tool": "modify_sheet_structure", "params": {"sheet_id":"...","operation":"insert","dimension":"row","start":10,"end":12}}, +# {"tool": "set_cell_range", "params": {"sheet_id":"...","range":"A11:B12","values":[["a","b"],["c","d"]]}} +# ] +# } +``` + +### Validate / DryRun / Execute 约束 + +- `Validate`:`--data` 必须合法 JSON,且 `operations` 是非空数组;逐个子操作 `tool` / `params.sheet_id` 字段必填校验;**禁止嵌套 batch_update**。 +- `DryRun`:按顺序输出每个子操作的目标 API + 请求 body 模板;首个失败则整批 fail-fast(不实际执行任何后续)。 +- `Execute`:按声明顺序串行执行;任一子操作失败立即中断并回滚到该子操作前状态(具体回滚能力取决于子操作类型,沿用 MCP `batch_update` 的语义)。 diff --git a/skills/lark-sheets/references/lark-sheets-cell-data.md b/skills/lark-sheets/references/lark-sheets-cell-data.md deleted file mode 100644 index dca3d20c8..000000000 --- a/skills/lark-sheets/references/lark-sheets-cell-data.md +++ /dev/null @@ -1,197 +0,0 @@ -# Sheets Cell Data - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -这份 reference 汇总单元格数据操作: - -- `+read` -- `+write` -- `+append` -- `+find` -- `+replace` - - -## `+read` - -对应命令:`lark-cli sheets +read` - -内置能力: - -- 支持 `--url` / `--spreadsheet-token` 二选一(URL 支持 wiki) -- 若已传 `--sheet-id`,`--range` 可写 `A1:D10` 或 `C2` -- 默认最多返回 200 行 - -```bash -lark-cli sheets +read --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --range "!A1:H20" - -lark-cli sheets +read --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --range "C2" -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--range` | 否 | `!A1:D10`、`A1:D10` / `C2` 或 `` | -| `--sheet-id` | 否 | 工作表 ID | -| `--value-render-option` | 否 | `ToString` / `FormattedValue` / `Formula` / `UnformattedValue` | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `range` -- `values` -- `truncated` -- `total_rows` - - -## `+write` - -对应命令:`lark-cli sheets +write` - -用于覆盖写入一个矩形区域。 - -```bash -lark-cli sheets +write --spreadsheet-token "shtxxxxxxxx" \ - --range "!A1:B2" \ - --values '[["name","age"],["alice",18]]' - -lark-cli sheets +write --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "" --range "C2" \ - --values '[["hello"]]' -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--range` | 否 | 写入范围;可用相对范围或 `` | -| `--sheet-id` | 否 | 工作表 ID | -| `--values` | 是 | 二维数组 JSON | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `updated_range` -- `updated_rows` -- `updated_columns` -- `updated_cells` -- `revision` - - -## `+append` - -对应命令:`lark-cli sheets +append` - -用于向工作表末尾追加行。 - -```bash -lark-cli sheets +append --spreadsheet-token "shtxxxxxxxx" \ - --range "!A1" \ - --values '[["华东一仓","2026-03",125000,98000,168000,"41.7%"]]' -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--range` | 否 | 追加范围:支持 ``、完整范围、相对范围 | -| `--sheet-id` | 否 | 工作表 ID | -| `--values` | 是 | 二维数组 JSON | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `table_range` -- `updated_range` -- `updated_rows` -- `updated_columns` -- `updated_cells` -- `revision` - - -## `+find` - -对应命令:`lark-cli sheets +find` - -只在一个已知 spreadsheet 内查找单元格内容,不是云空间搜索。 - -```bash -lark-cli sheets +find --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "" --find "张三" --range "A1:H200" - -lark-cli sheets +find --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --find "仓库管理营收报表" --ignore-case -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--find` | 是 | 查找内容 | -| `--range` | 否 | 范围;不填则搜索整个工作表 | -| `--ignore-case` | 否 | 不区分大小写 | -| `--match-entire-cell` | 否 | 完全匹配单元格 | -| `--search-by-regex` | 否 | 使用正则 | -| `--include-formulas` | 否 | 搜索公式 | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `matched_cells` -- `matched_formula_cells` -- `rows_count` - - -## `+replace` - -对应命令:`lark-cli sheets +replace` - -在指定范围内查找并替换单元格内容。 - -```bash -lark-cli sheets +replace --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --find "hello" --replacement "world" - -lark-cli sheets +replace --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --find "\\d{4}-\\d{2}-\\d{2}" \ - --replacement "DATE" --search-by-regex -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--find` | 是 | 搜索文本 | -| `--replacement` | 是 | 替换文本 | -| `--range` | 否 | 搜索范围,不传则搜索整个工作表 | -| `--match-case` | 否 | 区分大小写 | -| `--match-entire-cell` | 否 | 匹配整个单元格 | -| `--search-by-regex` | 否 | 使用正则 | -| `--include-formulas` | 否 | 在公式中搜索 | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `replace_result.matched_cells` -- `replace_result.matched_formula_cells` -- `replace_result.rows_count` - -## 参考 - -- [spreadsheet-management](lark-sheets-spreadsheet-management.md#info) — 先获取 `sheet_id` -- [dropdown](lark-sheets-dropdown.md#set-dropdown) — 写入 `multipleValue` 前先设置下拉列表 -- [formula](lark-sheets-formula.md) — 公式写入规则 diff --git a/skills/lark-sheets/references/lark-sheets-cell-images.md b/skills/lark-sheets/references/lark-sheets-cell-images.md deleted file mode 100644 index b06c1b553..000000000 --- a/skills/lark-sheets/references/lark-sheets-cell-images.md +++ /dev/null @@ -1,59 +0,0 @@ -# Sheets Cell Images - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -这份 reference 汇总单元格图片写入能力: - -- `+write-image` - - -## `+write-image` - -对应命令:`lark-cli sheets +write-image` - -特性: - -- 将本地图片文件写入到指定单元格 -- 支持格式:PNG、JPEG、JPG、GIF、BMP、JFIF、EXIF、TIFF、BPG、HEIC -- `--range` 必须表示单个单元格,如 `A1` 或 `!B2:B2` -- `--name` 默认取 `--image` 的文件名 - -```bash -# 写入图片到指定单元格 -lark-cli sheets +write-image --spreadsheet-token "shtxxxxxxxx" \ - --range "!B2:B2" \ - --image "./logo.png" - -# 使用 URL + sheet-id,指定单个单元格 -lark-cli sheets +write-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "" --range "C3" \ - --image "./chart.jpg" - -# 自定义图片名称 -lark-cli sheets +write-image --spreadsheet-token "shtxxxxxxxx" \ - --range "!A1:A1" \ - --image "./output.png" --name "revenue_chart.png" -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--range` | 是 | 目标单元格:`!A1:A1` 或相对单元格 | -| `--sheet-id` | 否 | 工作表 ID | -| `--image` | 是 | 本地图片文件的相对路径 | -| `--name` | 否 | 图片文件名(默认取 `--image` 的文件名) | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `spreadsheetToken` -- `updateRange` -- `revision` - -## 参考 - -- [cell-data](lark-sheets-cell-data.md#write) — 写入普通单元格数据 -- [float-images](lark-sheets-float-images.md) — 管理浮动图片 diff --git a/skills/lark-sheets/references/lark-sheets-cell-style-and-merge.md b/skills/lark-sheets/references/lark-sheets-cell-style-and-merge.md deleted file mode 100644 index 8ca135da5..000000000 --- a/skills/lark-sheets/references/lark-sheets-cell-style-and-merge.md +++ /dev/null @@ -1,141 +0,0 @@ -# Sheets Cell Style and Merge - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -这份 reference 汇总单元格样式和合并相关操作: - -- `+set-style` -- `+batch-set-style` -- `+merge-cells` -- `+unmerge-cells` - - -## `+set-style` - -对应命令:`lark-cli sheets +set-style` - -对指定范围设置字体、颜色、对齐、边框等样式。 - -```bash -lark-cli sheets +set-style --spreadsheet-token "shtxxxxxxxx" \ - --range "!A1:C3" \ - --style '{"font":{"bold":true},"backColor":"#ff0000"}' - -lark-cli sheets +set-style --spreadsheet-token "shtxxxxxxxx" \ - --range "!A1:Z100" --style '{"clean":true}' -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--range` | 是 | 单元格范围 | -| `--sheet-id` | 否 | 工作表 ID(用于相对范围) | -| `--style` | 是 | 样式 JSON 对象 | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -常用 `style` 字段: - -- `font.bold` -- `font.italic` -- `font.font_size` -- `textDecoration` -- `formatter` -- `hAlign` -- `vAlign` -- `foreColor` -- `backColor` -- `borderType` -- `borderColor` -- `clean` - -输出:`updates`(updatedRange / updatedRows / updatedColumns / updatedCells / revision) - - -## `+batch-set-style` - -对应命令:`lark-cli sheets +batch-set-style` - -对多个范围批量设置不同样式。 - -```bash -lark-cli sheets +batch-set-style --spreadsheet-token "shtxxxxxxxx" \ - --data '[{"ranges":["!A1:C3"],"style":{"font":{"bold":true},"backColor":"#21d11f"}},{"ranges":["!D1:F3"],"style":{"foreColor":"#ff0000"}}]' -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--data` | 是 | JSON 数组,每项包含 `ranges` 和 `style` | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `totalUpdatedRows` -- `totalUpdatedColumns` -- `totalUpdatedCells` -- `revision` -- `responses[]` - - -## `+merge-cells` - -对应命令:`lark-cli sheets +merge-cells` - -支持三种模式: - -- `MERGE_ALL` -- `MERGE_ROWS` -- `MERGE_COLUMNS` - -```bash -lark-cli sheets +merge-cells --spreadsheet-token "shtxxxxxxxx" \ - --range "!A1:B2" --merge-type MERGE_ALL -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--range` | 是 | 单元格范围 | -| `--sheet-id` | 否 | 工作表 ID(用于相对范围) | -| `--merge-type` | 是 | `MERGE_ALL` / `MERGE_ROWS` / `MERGE_COLUMNS` | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出:`spreadsheetToken` - - -## `+unmerge-cells` - -对应命令:`lark-cli sheets +unmerge-cells` - -用于拆分合并单元格。 - -```bash -lark-cli sheets +unmerge-cells --spreadsheet-token "shtxxxxxxxx" \ - --range "!A1:B2" -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--range` | 是 | 单元格范围 | -| `--sheet-id` | 否 | 工作表 ID(用于相对范围) | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出:`spreadsheetToken` - -## 参考 - -- [cell-data](lark-sheets-cell-data.md) — 数据读写 -- [cell-images](lark-sheets-cell-images.md) — 写入单元格图片 diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md new file mode 100644 index 000000000..fc68c49b0 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -0,0 +1,225 @@ +# Lark Sheet Chart + +## 真对象硬约束 + +当用户要求"画个图 / 数据可视化 / 趋势图 / 对比图 / 占比图"时,**必须**通过 `+chart-{create|update|delete}` 创建真实的图表对象。**禁止**用 doubao_code_interpreter 调 matplotlib / seaborn 生成图片再插入到表格代替——静态图片无法随源数据更新,且失去交互能力。判断标准:交付后 `+chart-list` 必须能返回该对象。 + +## 使用场景 + +读写图表对象。本 Skill 包含两个工具: + +| 操作需求 | 使用工具 | 说明 | +|---------|---------|------| +| 查看已有图表 | `+chart-list` | 获取图表的类型、数据源和样式配置 | +| 创建/更新/删除图表 | `+chart-{create|update|delete}` | 对图表对象执行写入操作 | + +典型工作流:先读取现有图表了解配置 → 执行创建/更新/删除 → 再次读取验证结果。 + +## 需求→图表类型映射(创建前必查) + +| 用户说 | 图表类型 | 备注 | +|--------|---------|------| +| "占比"、"比例"、"各XX占多少" | 饼图(pie) | 单维度占比首选 | +| "对比"、"各XX的YY" | 柱状图(bar) | 多类别数值对比 | +| "趋势"、"变化"、"走势" | 折线图(line) | 时间序列首选 | +| "堆积"、"组成构成" | 堆积柱状图(bar + stack) | 多系列累加 | +| "分布"、"相关性" | 散点图(scatter) | 两变量关系 | + +**多图表需求**:当用户同时提到多种分析(如"统计占比 + 对比数量"),必须创建多个图表,每个对应一种类型,不要只做一个。 + +**常见配置错误(必须注意)**: +- **图表类型选择错误**:用户说"堆积柱状图/百分比堆积"时,应在 `properties.snapshot.plotArea.plot.extra.stack` 中配置堆叠;百分比堆叠需在该 stack 下设置 `percentage: true`。用户说"占比/比例"时,优先考虑饼图或百分比堆积图 +- **数据标签缺失**:用户需要看到具体数值时,需配置 `properties.snapshot.plotArea.plot.labels`(数据标签)相关字段 +- **数据源范围与系列名来源要对齐**: + - **默认情况(inline 模式)**:`refs` 范围**应包含表头行**(首行/首列即系列名),且范围要精确覆盖目标数据,不要多选或少选。 + - **合并标题行要跳过**:如果表格在表头上方存在合并的标题行(如"员工统计表"横跨多列的大标题),`refs` 必须跳过标题行、从真正的列标题行开始。例如表头在第 3 行、数据在第 4-20 行,则 `refs` 应为 `A3:G20` 而非 `A1:G20`。包含合并标题行会导致列名识别错误、表头被当作数据参与聚合计算。 + - **数据与表头分离时必须用 detached 模式**:当 `refs` 只覆盖完整数据的一个子集(按筛选/分组只画其中一段),而真正的语义表头在该子集之外时,**必须**设置 `data.headerMode='detached'`:refs 仅传纯数据范围,维度名/系列名通过 `dim1.serie.nameRef` / `dim2.series[].nameRef` 指向真正的表头单元格。详见下文"硬性规则:数据与表头分离场景必须使用 detached 模式"。 +- **axes[].label 不接受 `format` / `number_format` 字段**:想给坐标轴数值加千分位、百分号等格式化时,不要在 `axes[i].label` 里传 `format` 或 `number_format`(schema 未定义,会报 `unexpected property "format" is not defined in schema`)。数值格式化统一在源数据单元格的 `cell_styles.number_format` 里设置(写 set_cell_range 时),图表会沿用单元格格式。 +- **创建后必须验证**:图表创建后必须调用 `+chart-list` 验证配置是否正确 + +> **⚠️ 硬性规则:当用户通过列标题名称(而非列索引)指定横轴/纵轴系列时,必须先读取表格首行(表头)来确定列名与列索引的对应关系,再设置 `dim1`/`dim2` 的 `index`。** +> 例如用户说"横轴为车型系列,纵轴为Q1-Q4的销量",你不能猜测列索引,必须先通过读取表格数据源范围的首行内容(使用 `lark-sheets-read-data` 的 `+cells-get` 或其他读取单元格的工具),确认"车型系列"是第几列、"Q1"~"Q4"分别是第几列,然后再将正确的列索引填入 `dim1.serie.index` 和 `dim2.series[].index`。 + +> **⚠️ 硬性规则:数据与表头分离场景必须使用 detached 模式。** 当 `refs` 仅覆盖数据的一个子集,而真正的语义表头行/列位于该子集之外时,**必须** `data.headerMode='detached'` 并配上 `nameRef`。不能用 inline 模式 + 把 refs 多带 1 行兜底表头来替代——那种写法已废弃。否则图表会把错误的首行/首列当系列名,或图例显示成"系列1/系列2"等默认名,或者 refs 里混入相邻分组的数据。 +> +> **触发该规则的典型信号**(满足任意一条都必须走 detached): +> - 用户要求"针对 X 类的数据画图"、"只看某个分组"、"只画筛选后的部分",而 X 类对应的行段在数据中间或末尾,与表头不连续; +> - 用户要求"按 X 分别画图"、"按某个维度(部门/品类/地区/时间段等)拆图"——**多张图共享同一组表头**; +> - `refs` 起始行 > 表头行(如表头在第 1 行,但 `refs` 从第 11 行开始); +> - `refs` 起始列 > 表头列(如表头在 A 列,但 `refs` 从 C 列开始)。 +> +> **正确做法**: +> 1. 在 `data` 下显式设置 `"headerMode": "detached"`; +> 2. `refs` **只覆盖该子集的纯数据**,不要向上/向左多带 1 行/列,也不要把全局表头整段并进来(否则会把其它分组的数据混进图); +> 3. **`nameRef` 必填**:给 `dim1.serie.nameRef` 写真正表头中"类别名"那一格的 A1 引用(如 `Sheet2!A1`),给每个 `dim2.series[i].nameRef` 写对应数值列的 A1 引用(如 `Sheet2!C1`、`Sheet2!D1`)。任一缺失会被校验拦下并报 `headerMode=detached requires ... nameRef`; +> 4. `refs[i].value` 必须是单元格或普通矩形范围(CELL / NORMAL),不接受整行/整列/开区间;`direction='column'` 时起始行必须 > 0,`direction='row'` 时起始列必须 > 0; +> 5. `index` 仍按 `refs` 内的列/行号填,从 1 开始。 +> +> **两种场景对照(互斥,二选一)**: +> +> | 场景 | 何时命中 | 写法 | +> |---|---|---| +> | A. 表头与数据连在一起 | 单张图、refs 首行/首列就是表头(典型整段画图) | **省略 headerMode**(默认 inline),refs 含表头,**不写 nameRef** | +> | B. 表头与数据分离 | 上面 4 条信号任一命中(数据子集、按维度拆图等) | **`headerMode='detached'`**,refs 仅纯数据,**`nameRef` 必填** | +> +> **反向约束**:场景 A 下不要写 `nameRef`——首行命名已经生效,多写反而冗余。`nameRef` 仅在场景 B 下使用(且必填)。 + +## ⚠️ chart 数据源引用 pivot 时必须排除总计行(高频致命错误) + +当 chart 要基于刚创建的 pivot 产物画图时,**禁止凭猜写 `refs`**。pivot 默认启用 `show_row_grand_total` / `show_col_grand_total`,产物最后一行/一列通常是"总计"。如果 `refs` 把总计行一并框进去: +- **柱状图**末尾会多一根天文数字柱子(=所有数据求和),把其他柱子压扁到看不见 +- **饼图**会多一个"总计"扇区占 33%+,真实类别的比例完全失真 + +**正确流程**: +1. `+pivot-create` 返回 `sheet_id` + `pivot_table_id` +2. 调 `+csv-get(sheet_id, 'A1:E30')` 或 `+pivot-list` 读 pivot 产物的**实际数据范围** +3. 识别并排除"总计"/"小计"行(通常最后一行;嵌套 pivot 还要排除中间层小计) +4. `+chart-create` 时 `data.refs` 精确到数据行(如 pivot 占 A1:D9、总计在 row9 → chart 用 `A1:D8`) + +详细规则见 `lark-sheets-pivot-table` skill 第 5 节"pivot → chart 组合场景"。 + +## 图表位置选择(创建前必做) + +凭感觉挑列号/行号会被 API 拒(`position is out of sheet range`),浪费一轮调用。按以下四步走: + +1. **查尺寸**:`+sheet-info` 拿 `rowCount` / `columnCount`。 +2. **估跨度**:默认单元格 **105 px 宽 × 27 px 高**,`needCols = ceil(width/105)`,`needRows = ceil(height/27)`。 +3. **校验**:`position.row + needRows ≤ rowCount` 且 `col_idx + needCols ≤ columnCount`(col 按 A=0、B=1、…、Z=25、AA=26… 换算)。 +4. **不够就先扩表**,二选一,禁止硬塞越界位置: + - **优先**放数据下方空区:`position = {row: data_end_row + 2, col: "A"}`; + - 否则先调 `+dim-insert(operation="insert")`(`lark-sheets-sheet-structure` skill)扩行/列,再 create。 + +**示例**:21 列 sheet 放 600×400 图 → `needCols=6, needRows=15` +- ❌ `{row: 0, col: "W"}` — col=22 越界 +- ✅ `{row: 42, col: "A"}` — 放数据下方 +- ✅ 先 `insert position="U" count=6 side="after"`,再 `{row: 0, col: "V"}` + +## Shortcuts + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 + +| MCP tool | CLI shortcut | Risk | 分组 | +| --- | --- | --- | --- | +| `get_chart_objects` | `+chart-list` | read | 对象 | +| `manage_chart_object` | `+chart-create` | write | 对象 | +| | `+chart-update` | write | 对象 | +| | `+chart-delete` | high-risk-write | 对象 | + +## Flags + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 + +### `+chart-list` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--chart-id` | 专有 | string | 否 | 指定单个图表 reference_id 过滤 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+chart-create` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--data` | 专有 | string + File + Stdin | 是 | 图表完整配置 JSON(`position` / `data` / `properties` 等),结构嵌套深,统一走 JSON 注入 | +| `--dry-run` | 系统 | bool | 否 | 零副作用,输出请求模板 | + +### `+chart-update` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--chart-id` | 专有 | string | 是 | 目标图表 reference_id | +| `--data` | 专有 | string + File + Stdin | 是 | 完整或足够完整的图表配置 JSON(先 `+chart-list` 回读再 patch) | +| `--dry-run` | 系统 | bool | 否 | | + +### `+chart-delete` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--chart-id` | 专有 | string | 是 | 目标图表 reference_id | +| `--yes` | 系统 | bool | 是 | 二次确认(不带时退出码 10) | +| `--dry-run` | 系统 | bool | 否 | | + +## Schemas + +> 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 + +### `+chart-create` `--data` / `+chart-update` `--data` + +_创建/更新的图表属性_ + +**顶层字段**: +- `offset` (object?) — 可选 { col_offset?: number, row_offset?: number } +- `position` (object?) — 必填 { col: string, row: number } +- `size` (object?) — 必填 { height: number, width: number } +- `snapshot` (object?) — 图表快照配置 { data?: object, legend?: oneOf, plotArea?: object, style?: object, subTitle?: object, …共 6 项 } + +## Examples + +> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR 规则同 `+csv-get`)。 + +### `+chart-list` + +输出契约:返回按工作表分组的图表列表,每个图表含 `chart_id` / `position` / `details.snapshot` 等。 + +### `+chart-create` + +示例: + +```bash +# 内联 JSON +lark-cli sheets +chart-create --url "https://example.feishu.cn/sheets/shtXXX" \ + --sheet-name "Sheet1" --data '{"position":{"row":42,"col":"A"},"data":{...}}' + +# 走文件(推荐配置较多时) +lark-cli sheets +chart-create --url "https://example.feishu.cn/sheets/shtXXX" \ + --sheet-name "Sheet1" --data @chart-config.json +``` + +> **配置 JSON 关键字段**(详见上方语义内容章节): +> - `position.row` / `position.col` 必须留足空间,越界会被 API 拒 +> - `data.headerMode`:默认 inline;当 refs 仅覆盖数据子集且语义表头在子集之外,必须 `detached` + `nameRef` +> - chart 引用 pivot 输出时,`data_range` 必须排除总计 / 小计行 + +### `+chart-update` + +> 更新前必须先 `+chart-list --chart-id ` 回读完整配置,再在其基础上修改,避免漏字段把图表回退到默认状态。 + +### `+chart-delete` + +示例: + +```bash +# dry-run 先看会删什么 +lark-cli sheets +chart-delete --url "https://example.feishu.cn/sheets/shtXXX" \ + --chart-id "chrXXX" --dry-run + +# 真正执行 +lark-cli sheets +chart-delete --url "https://example.feishu.cn/sheets/shtXXX" \ + --chart-id "chrXXX" --yes +``` + +### Validate / DryRun / Execute 约束 + +- `Validate`:XOR 公共四件套;`+chart-create` / `+chart-update` 的 `--data` 必须能解析为合法 JSON;`+chart-delete`(high-risk-write)校验 `--yes` 或 `--dry-run` 至少一个。 +- `DryRun`:`+chart-create` / `+chart-update` 输出"将要 POST 的 body 模板";`+chart-delete` 输出"将要删除的 chart_id 及隶属 sheet",零网络副作用。 +- `Execute`:写操作执行后自动调用 `+chart-list` 回读对比,记录到 `envelope.meta.verification`,便于上层根据回读结果判定是否符合预期。 + +> `+chart-create` / `+chart-update` 是 write 级别,按需可用 `--dry-run` 预览,不要求 `--yes`。只有 `+chart-delete`(high-risk-write)必须 `--yes`。 diff --git a/skills/lark-sheets/references/lark-sheets-conditional-format.md b/skills/lark-sheets/references/lark-sheets-conditional-format.md new file mode 100644 index 000000000..4739e65d4 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-conditional-format.md @@ -0,0 +1,171 @@ +# Lark Sheet Conditional Format + +## 真对象硬约束 + 触发词清单 + +用户出现以下口语指令时,**强制**走 `+cond-{format-create|format-update|format-delete}`,**禁止**用 `+cells-set` 写静态背景色 / 字体色代替: + +- **颜色动作**:"标红 / 标黄 / 标绿 / 上色 / 染色 / 涂色 / 表红色 / 表黄色" +- **视觉强调**:"高亮 / 突出 / 标记 / 标注 / 区分" +- **条件触发**:"重复的标出来 / 异常的圈出来 / 过期的染红 / 大于 X 的标黄 / 不达标的标红" +- **联动语义**:"颜色随数据变 / 联动 / 自动更新 / 改了数据颜色也跟着变" +- **数值可视化**:"数据条 / 色阶 / 渐变色 / 进度条样式" + +飞书表格的"颜色标记"语义 = 条件格式规则 ≠ 静态背景色。如果用 `+cells-set` 写静态,源数据变化时颜色不会跟着变(典型反例:用户要求"过期单元格标红"时,模型用静态填充——日期变化后单元格颜色不再准确反映过期状态)。 + +**判断标准**:交付后 `+cond-format-list` 必须能返回该规则;否则视为违规。 + +**大数据量加分项**:当数据量 > 1000 行时,条件格式是首选——它由飞书自身渲染,不会触发 doubao_code_interpreter 50 秒超时(同 R8)。 + +## 使用场景 + +读写条件格式对象。本 Skill 包含两个工具: + +| 操作需求 | 使用工具 | 说明 | +|---------|---------|------| +| 查看已有条件格式 | `+cond-format-list` | 获取规则类型、范围和样式配置 | +| 创建/更新/删除条件格式 | `+cond-{format-create|format-update|format-delete}` | 对条件格式规则执行写入操作 | + +典型工作流:先读取现有条件格式了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**。 + +**常见配置错误(必须注意)**: +- **创建后必须验证**:条件格式创建后必须调用 `+cond-format-list` 验证规则是否生效。如果验证发现规则未生效或配置不正确,应立即修复并重试 +- **范围要精确**:条件格式的应用范围必须精确覆盖用户指定的列/行,不要遗漏 +- **`style.back_color` vs `style.fore_color` 的中文语义**:用户中文语境下的"**标红/高亮/染色/标记**"指**单元格背景色**,用 `back_color`;"**文字红/字体红/把字变红**"才用 `fore_color`。默认无说明时选 `back_color`。把过期数据涂红、重复值高亮等都应该是 `back_color: "#FFE6E6"`(或类似浅红)配合可选的 `fore_color` 加深字体 +- **日期/空值比较必须防空**:用户说"过期的标红"时,除了 `TODAY()`,公式必须排除空单元格,否则空白格也会被误判为"早于今天"而全表标红。正确公式:`=AND(E1<>"", E1<=TODAY())`;错误公式:`=E1<=TODAY()`(空值会被当作 0 判为过期) +- **公式条件注意引用方式**:自定义公式条件中的单元格引用需要根据实际场景选择相对/绝对引用(如 `=E1<=TODAY()` 而非 `=$E$1<=TODAY()`,后者只比较一个格) + +⚠️ **用户明确要求"辅助列+条件格式"两步走时,禁止用 `expression` 绕过(高频致命错误)**:当用户说以下任意一种表达时,必须按两步走(先建辅助列 → 再基于辅助列做条件格式),**禁止**直接用一个 `rule_type: "expression"` 公式一步完成: + +- "**增加辅助列**,再/然后标记……" +- "**先计算/判断** XX **是否** YY,**再**标记……" +- "**新建一列**放结果,再用结果染色" +- 明确要求用 "辅助列"、"辅助字段"、"判断列"、"标记列" + +**正确做法(两步走)**: + +``` +Step 1: set_cell_range 在新列写判断公式(形成"是/否"或布尔辅助列) + range="H2", cells=[[{formula: "=IF(A2>B2, \"是\", \"否\")"}]], copy_to_range="H2:H100" + +Step 2: 基于辅助列值做条件格式(用 cellIs 或引用辅助列的 expression) + manage_conditional_format_object create + rule_type: "expression" + ranges: ["A2:H100"] // 整行高亮 + attrs: [{formula: ["=$H2=\"是\""]}] // 引用辅助列 + style: {back_color: "#FFECEC"} +``` + +**错误做法(一步走绕过辅助列)**: + +``` +manage_conditional_format_object create + rule_type: "expression" + ranges: ["2:145"] + attrs: [{formula: ["=$O2>$H2"]}] ← 虽然逻辑等价,但产物里缺辅助列 → 扣配置需求分 +``` + +为什么禁止一步走:用户明确要求辅助列是有**业务意图**的——让人肉眼能在表里看到"是/否"列;条件格式只是视觉辅助。一步 expression 虽然效果对了,但用户打开表格看不到辅助列,被视为"操作不完整/未采用公式"。 + +`expression` 单独使用的场景是:用户**没有**明确要求辅助列、只要"标红符合条件的行"时。 + +⚠️ **创建条件格式前必须读数据行确认列对应**:仅读首行表头(`get_range_as_csv range="A1:Z1"`)不够——如果表头语义含糊(比如"时间"、"日期"这种多列同义词),formula 里引用的列字母可能张冠李戴。必须再读 3-5 行**数据样本**(如 `range="A2:Z6"`)确认:①列名对应的实际值;②字段含义匹配用户描述;③数据类型是日期/数字/文本。特别是比较类条件格式(`=$A2>$B2` 这种),列字母选错整条规则就废了。 + +## Shortcuts + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 + +| MCP tool | CLI shortcut | Risk | 分组 | +| --- | --- | --- | --- | +| `get_conditional_format_objects` | `+cond-format-list` | read | 对象 | +| `manage_conditional_format_object` | `+cond-format-create` | write | 对象 | +| | `+cond-format-update` | write | 对象 | +| | `+cond-format-delete` | high-risk-write | 对象 | + +## Flags + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 + +### `+cond-format-list` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--rule-id` | 专有 | string | 否 | 按规则 id 过滤 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+cond-format-create` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--data` | 专有 | string + File + Stdin | 是 | JSON:`{"range":"Sheet1!A2:F1000","rule":{"type":"cell_value","operator":"greater_than","value":100,"style":{...}}}`,type 可选 `cell_value` / `duplicate` / `data_bar` / `color_scale` / `rank` / `formula` | +| `--dry-run` | 系统 | bool | 否 | | + +### `+cond-format-update` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--rule-id` | 专有 | string | 是 | 目标规则 id | +| `--data` | 专有 | string + File + Stdin | 是 | 完整或足够完整的规则配置(先 `+cond-format-list --rule-id ` 回读再 patch) | +| `--dry-run` | 系统 | bool | 否 | | + +### `+cond-format-delete` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--rule-id` | 专有 | string | 是 | 目标规则 id | +| `--yes` | 系统 | bool | 是 | `high-risk-write`,删除不可逆 | +| `--dry-run` | 系统 | bool | 否 | | + +## Schemas + +> 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 + +### `+cond-format-create` `--data` / `+cond-format-update` `--data` + +_创建/更新的条件格式属性_ + +**顶层字段**: +- `attrs` (array?) — 规则参数列表 +- `has_ref` (boolean?) — 可选 +- `ranges` (array) — 应用条件格式的 A1 范围列表 +- `rule_type` (enum) — 条件格式规则类型 [duplicateValues / uniqueValues / cellIs / containsText / timePeriod / containsBlanks / notContainsBlanks / dataBar / …共 13 项] +- `style` (object) — 命中规则时应用的单元格样式 { back_color?: string, font?: enum, fore_color?: string, text_decoration?: enum } + +## Examples + +> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。 + +### `+cond-format-list` + +### `+cond-format-create` + +```bash +lark-cli sheets +cond-format-create --url "..." --sheet-id "$SID" --data @rule.json +``` + +### `+cond-format-update` + +### `+cond-format-delete` + +### Validate / DryRun / Execute 约束 + +- `Validate`:XOR 公共四件套;`--data.range` 与 `--data.rule.type` 必填;按 type 检查必填子字段(`cell_value` 需 `operator` + `value`、`formula` 需 `expression`、`color_scale` 需 `min/mid/max` 配色等);`+cond-format-delete` 强制 `--yes` 或 `--dry-run`。 +- `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 conditional_format 请求模板"。 +- `Execute`:写后调用 `+cond-format-list --rule-id ` 回读,envelope.meta.verification 给出规则 / 范围 / 样式对比。 diff --git a/skills/lark-sheets/references/lark-sheets-core-operations.md b/skills/lark-sheets/references/lark-sheets-core-operations.md new file mode 100644 index 000000000..bca60f939 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-core-operations.md @@ -0,0 +1,362 @@ +# 飞书表格核心操作:分析、编辑与可视化 + +## 概览 + +这是面向"已有飞书表格"的核心工作流。核心原则是:先了解,再分析或写入,最后验证。 + +## 编辑前必读:8 条默认规则 + +> 所有编辑类任务(修改 / 排序 / 筛选 / 删除 / 透视 / 批量填充)**必须**先满足以下 8 条,再进入下方「硬性规则」和具体子 skill。任何子 skill 不得放宽这 8 条。 + +1. **R1 最小改动**:除用户明示要修改的单元格 / 列外,原表其它单元格、行列结构、Sheet 名称、合并区域、格式必须 1:1 保持。中间结果 / 标注列优先放在原数据列**右侧**;当中间结果会与原数据混淆,或需要承载结构化对象(透视表 / 图表)时可**新建空白 Sheet**。**禁止**擅自删除 / 重命名 / 隐藏 / 移动**已存在**的原 Sheet(新建是允许的,节制使用即可)。 +2. **R2 真实写回**:编辑任务的最终交付必须是对在线表格的真实写入并回读校验。**严禁**只在文本里描述"已完成 X" 没有任何写入;**严禁**用普通公式 / 文本汇总假装"透视表 / 筛选 / 图表 / 条件格式 / 迷你图"等结构化对象;**严禁**只输出 `{"type": "LarkExcelCard", "refs": [...]}` 形式的引用作为交付——LarkExcelCard 引用 ≠ 真实写入,必须有对应的 `+cells-set` / `manage_*_object` 工具调用并能用 `get_*` 工具回读到改动结果。 +3. **R3 计算复现**:涉及计算、排序、筛选、聚合、批量数据提取的任务,必须用 `doubao_code_interpreter` 独立复现一份预期值,与回读结果对照通过后再交付。设计公式 / 筛选条件前先 sample 至少 50 行识别该列所有值类型变体(纯数值 / 公式文本 / 多种日期格式 / 空值),不能只看前 10 行。 +4. **R4 处理完整性**:全量逐条处理类任务(翻译 / 打标 / 删除指定行 / 批量公式落地 / 按条件保留),落地前先把"预期处理条数"硬编码进代码,处理完后 `assert actual == expected`。**严禁**输出"已完成前 N 条,剩余将继续"这类半成品文案。 +5. **R5 指令语义还原**:"按 X 排序" / "筛选 X" / "把 X 删除" 落地前先回答:① X 在哪一列?该列实际值类型是什么?② 期望结果集大小是多少?答完再动手;禁止直接对混合文本列做字符串排序 / 筛选。 +6. **R6 任务拆解为可验证 checklist**:用户的指令落地前,**必须**先拆成所有"独立可验证子要点",每点对应一个 `assert`,全部通过才交付: + - **多维度操作**("按部门排序"含一/二/三级)→ 每维度一个 assert + - **多目标操作**("删除 N 条 / N 行")→ 每目标一个 assert + - **多格式兼容**(日期 YYYYMM / YYYY-MM-DD / 时间戳)→ 每种格式至少一个样本通过 + - **范围类操作**("加边框 A1:H11"、"覆盖第 2~218 行")→ 起始 / 末行 / 末列三个边界都要核 + - **单一指令隐含多个失败模式**(如"用公式算面积"隐含"提取数值 / 乘以数量 / 单位转换 / 公式而非硬编码 / 不超时"等多个验收点)→ 每点独立 assert + 只完成第一个要点就交付(典型如:按部门只排一级、删 3 行只删 1 行、兼容日期只兼容 YYYYMM)属于违规。 +7. **R7 公式模式延续**:扩展 / 续写 / 新增行列时,**必须**先用 `+cells-get` 读原数据区域的 `formula` 字段,识别公式模式,新行新列必须延续相同模式。**禁止**把原表 `=C3/B3` / `=SUM(B3:B9)` 模式在新行替换为硬编码常数(如 `0.85` / `50`)这种破坏数据驱动性的写法。**用户口头操作("分列 / 排序 / 提取 / 求和")也必须落地为公式或原生工具**(SORT 公式 / 分列 / `TEXTBEFORE` / `MID` / `+filter-{create|update|delete}` / `+pivot-{create|update|delete}` 等),不能只写静态结果——否则用户改源数据时结果不再联动,等同破坏了表格的数据驱动性。 +8. **R8 大数据量超时降级走原生工具**:当任务涉及 **> 1000 行**数据 / 预估 doubao_code_interpreter 超 50 秒时,**禁止**继续用代码逐行处理,**必须**改走原生工具: + + | 任务类型 | 必须使用 | 禁止 | + |---|---|---| + | 重复检测 / 条件高亮 / 颜色标记 | `+cond-{format-create|format-update|format-delete}` | doubao_code_interpreter 逐行 + set_cell_range 静态背景色 | + | 大批量行公式填充 | 模板公式 + `copy_to_range "X:X"` | doubao_code_interpreter 计算每行 + 静态写入 | + | 大数据筛选 | `+filter-{create|update|delete}` 或 `+pivot-{create|update|delete}` | 复制到新 sheet 后覆盖 | + | 大批量数据 set | 分批 `+cells-set`,每批 ≤ 100 行 | 一次写 1000+ 行 | + | 大批量翻译 / NLP | 分 30 行/批,每批后立即写回 | 一次性处理全表后才写回 | + + **严禁**遇到超时仅输出"由于数据量过大,无法自动完成"的文本说明 + 手动操作步骤——这等同于零分交付。**严禁**输出 LarkExcelCard 引用作为"已完成"的证据(同 R2)。 + +## 硬性规则 + +1. **先读 skill 再调工具,但要高效读取**:每个工具的参数约束、边界条件和常见陷阱都记录在对应的子 skill 中。跳过 skill 直接调工具,容易传错参数或遗漏关键步骤。**但必须控制 read_skill 的调用次数**: + - 在开始操作前,先规划本次任务需要哪些工具,一次性列出要读取的 skill 清单,而不是用一个读一个 + - 如果本轮对话中已经读取过某个 skill,不要重复读取 + - 本 skill(`lark-sheets-core-operations`)+ `lark-sheets-workbook` 是几乎每次都需要的基础 skill,读完后应立即进入操作,不要在读取阶段停留过久 + +2. **先了解结构再操作**:飞书表格的行列数、冻结位置、合并区域等信息不可猜测,猜错会导致写入越界或覆盖数据。操作前先调用 `+workbook-info` 获取子表概览;然后根据任务类型选择读取方式(三个读取工具均在 `lark-sheets-read-data` skill 中): + - **批量填充/补齐/完善/修正多行**类任务 → **默认走 `export_sheet_to_sandbox` + Python**(路径 A),用 `df.info()` 的 non-null 数和 `len(df)` 确定真实数据行数。**禁止**对这类任务直接用 `+csv-get` 探 10 行就进入写入——实测会漏写表尾多行(高频致命错误)。 + - 数据分析/清洗/可视化/大数据集 → 同上 `export_sheet_to_sandbox` + - 快速查看少量数据或简单问答(只读、不回写) → `+csv-get` 读取到对话上下文 + - 需要公式/样式/批注 → `+cells-get` + - **续写/扩展已有内容** → 必须用 `+cells-get` 读取源区块样式 + `+sheet-info` 读取行高和合并信息(见硬性规则 12) + + **读取前 10 行后按表格形态分流(路径 B/C 批量写入前强制)**:表格形态决定第二步读不读、读什么。先用 `range: "A1:Z10"` 探查,然后根据返回的 `annotated_csv` 分类: + + - **交叉表/透视布局**:左侧 1-2 列是**行标签**(日期/类别/编号/名称等枚举每一行的含义),其余列是维度(如门店/产品/月份)或指标。**只按横向读前 N 行,只能看到横向表头 + 前 N 个行标,看不全纵向表头**——这是"只改前 N 行、其余未更新"类故障的根因。跟读了多少行无关,哪怕首次读到 20/50 行,只要真实数据超过就一样会漏。 + → **必做**:再调一次 `range: "A:A"` 或 `A:C` 单独读纵向表头所在列到底,拿到全部行标;这类列通常只有 1-3 列、每行一个短标签,整列读完也不会爆上下文 + - **扁平列表**:每行一条独立记录,列是字段(如 ID/姓名/部门/金额/日期),无"行标签"概念 + → 不需要补读左侧列;但回写前仍要靠下面的兜底字段确认数据范围 + + 除此之外,每次读完还必须查三个字段——交叉表和扁平列表都用得到: + - `actual_range`:本次实际返回的范围(请求超边界时自动裁剪) + - `current_region`:连续数据矩形(Excel Ctrl+Shift+\*);若末行 > 请求 range 末行说明数据还没读全 + - `has_more`:因 `max_bytes` 被截断时为 `true`,按 `actual_range` 末行+1 分页续读,或改走路径 A + + **`current_region` 远大于可灌入上下文的量(如几百行)时**:切换到路径 A(`export_sheet_to_sandbox` + Python),不要用路径 B 翻页硬塞。 + + **禁止仅凭首次探查范围就进入批量写入**——不管什么形态,都要先由形态判断决定是否补读纵向表头,再用 `current_region` / `has_more` 兜底确认没漏行。 + +3. **结果写回表格**:纯分析问答可以只读;但当用户需要多行结果、持续更新或可视化输出时,优先写回飞书表格——这样用户能直接在表格中查看和复用,而不是只看到一段文本。 + +4. **公式优先于硬编码值**:写公式(如 `=SUM(B2:B9)`)而非计算后的静态数值(如 `5000`),因为公式会在源数据变化时自动重算。写死数值后,用户改了源数据结果就不对了。 + +5. **区分公式语法和工具参数语法**:公式字符串中的范围引用(如 `H:H`、`$A$2:$B$5`)遵循飞书公式语法;而 MCP tool 的 `range` / `ranges` / `copy_to_range` 参数使用 A1 表示法(如 `A1:D3`、`1:1`)。两者写法不同,混淆会导致调用失败。 + +6. **合并单元格需特殊处理**:合并区域只有左上角单元格存储数据,其余位置读取为空——这不代表”无内容”,而是合并的正常行为。写入时只能写左上角,写其他位置会报错或被忽略。如需修改合并区域中间的某格,先取消合并再操作。**在合并区域中间行插入数据之前,必须先调用 `+cells-get` 或 `+sheet-info` 确认目标行是否落在某个合并区域内**——直接用 `+cells-set` 写入合并区域的非左上角单元格,后端会返回 `cell at row N, col M is inside a merged region` 错误;即使 LLM 响应错误后改调 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` 插入行,行号也可能因合并扩展而错位。同理,**新增列后若原表存在合并的标题行(如 A1:F1),需要手动用 `+cells-{merge|unmerge}` 扩展合并范围到新列(如 A1:I1)**,否则标题行不会跟着变宽。 + +7. **多步写入优先用 `+batch-update`**:当任务涉及多个连续写入操作时,优先使用 `lark-sheets-batch-update` skill 中的 `+batch-update` 将它们合并为单次请求,减少调用轮次。**特别是以下场景必须用 `+batch-update`**: + - 需要对多个不同区域执行 `+cells-{merge|unmerge}`(如按合同编号合并多列相同内容)→ 将所有 merge 操作放进一个 `+batch-update` + - 需要对多个不同区域执行 `+dim-resize`(如统一调整多列列宽或多行行高)→ 将所有 resize 操作放进一个 `+batch-update` + - 需要先插入行列再写入数据 → 将 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` + `+cells-set` 放进一个 `+batch-update` + - 当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求 + +8. **结构操作用专用工具**:插入/删除行列用 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}`(一次操作可处理数千行),不要用 `+cells-set` 逐行搬数据——后者既慢又容易出错。用户给多步骤请求时,每个步骤都要执行,执行后重新验证。**`+dim-insert` 只继承相邻行的值/公式/边框,不继承 `row_height`**:插入后新行行高会回落到默认值(约 22px),若原行是高行高(容纳多行自动换行文本),新行长文本会被裁切(显示不全,末尾字符被截断)。**在中间插入行并填入文本前,必须先用 `+sheet-info` 读取相邻原行的 `row_height`,再用 `+batch-update` 合并 `+dim-resize` 把同一高度应用到新行**——"在中间插入行填数据"本质等价于"续写已有内容",必须走硬性规则 12 的同一套样式继承流程(行高、边框、合并、对齐)。 + +9. **写入前精确定位表头和数据区域**:在执行任何写操作之前,必须先通过读取数据确认: + - 表头在哪一行(不要假设表头一定在第 1 行,可能存在标题行、空行等) + - 数据区域的实际起止范围(行数、列数)——可通过 `+csv-get` 返回的 `current_region` 快速获知连续数据区域的实际边界 + - **确认数据真实结束行**:`current_region` 末尾可能包含汇总行(合计/总计/小计)、签名/审批行(编制人/审核人)、空行、备注脚注等非数据内容,必须再读末尾 5~10 行排除这些行;最终数据范围 = 起始行 ~ 最后一条有效数据行。识别规则与完整示例见 `lark-sheets-read-data` skill 的「确定数据范围的正确流程」 + - 目标列的实际列字母——**必须通过 `col_indices[j]` 获取,禁止通过手动计数 CSV 表头的逗号或字段来确定列位置**。手动数列在列数较多(>10 列)时极易产生 off-by-one 偏移错误 + - **区分"表尺寸"与"数据占用范围"(新增列场景关键)**:`+workbook-info` 返回的 `column_count`(如 20 列)和 `row_count` 是**整个 sheet 的物理尺寸**,默认值可能远大于真实数据范围(比如一张只有 6 列数据的表可能 `column_count=20` 甚至 `column_count=100`)。**新增列 / 插入列之前,必须先调用一次 `+csv-get`(请求 `range: "A1:Z1"` 或表头附近一小块即可),读取返回值里的 `current_region` 作为真实数据矩形**,再基于 `current_region` 的右边界决定插入位置。例如 `current_region: "A1:F72"` → 数据末列是 F → 新增 3 列应插到 G/H/I,禁止插到 T(表尺寸末列)。否则新列和原数据之间会有一大片空列,用户看到的是"数据没动,三个空列在表尾"。 + 如果表头定位错误,后续所有公式和写入都会偏移,导致整体失败。 + +10. **公式填充必须用 `copy_to_range`,禁止逐行写入**:当同一公式需要向下填充到多行时,必须先用 `+cells-set` 在第一行写入模板公式,再用 `copy_to_range` 填充整列(如 `"copy_to_range": "H2:H100"` 或 `"copy_to_range": "H:H"`)。**禁止**对每一行单独调用 `+cells-set` 写入相同结构的公式——这会浪费大量调用轮次。 + +11. **分组汇总必须用透视表**:当用户说"按XX统计YY"、"分组汇总"、"各部门/地区的数量/金额"、"汇总每个XX的YY"时,必须使用 `+pivot-{create|update|delete}` 创建透视表(推荐省略 sheet_id 自动新建子表)。禁止用 SUMIF/COUNTIF 公式或 doubao_code_interpreter 代码替代——后者会导致统计结果覆盖原表数据。 + +12. **续写/扩展已有内容时必须继承样式(高频致命错误)**:当用户要求"按已有格式续写"、"继续填充"、"复制区块"、"增加到第N周/月"等扩展任务时,**禁止只用 `+csv-get` 读值后只写值**。必须按以下顺序执行: + - 用 `+cells-get` 读取源区块的 `cell_styles` 和 `border_styles` + - 用 `+sheet-info` 读取源区块的行高、合并单元格信息 + - 写入时 cells 中同时携带 `value` + `cell_styles` + `border_styles` + - 用 `+batch-update` 批量执行 `+cells-{merge|unmerge}`(复制合并)和 `+dim-resize`(复制行高) + - 只写值不写样式会导致新区块与源区块视觉完全不一致,这是最常见的致命错误 + +13. **"新增列"任务必须跑完整 checklist(高频致命错误)**:当用户要求"在表格中增加列/新增列/加几列"时,心智模型不是"只写表头 + 填公式"。**执行前必须完成以下 4 步 checklist,禁止跳步**: + - **Step 1 — 读原表 row1 的合并区域**:用 `+cells-get` 或 `+sheet-info` 读取 row1 的合并信息。**如果 row1 存在跨数据区的合并标题行(如 A1:F1 合并为一个大标题),新增 N 列后必须用 `+cells-{merge|unmerge}` 扩展合并范围到新列末**(如新增 3 列 → 合并改为 A1:I1)。否则新列在 row1 裸露在原标题区之外,视觉割裂。这一步被跳过会被 PM 判定"操作不完整"。 + - **Step 2 — 读表头和原数据行的完整样式**:用 `+cells-get` 读原表头行(如 row2/row3)和数据行(row4)的 `cell_styles` **和 `border_styles`**,记录字体/加粗/对齐/**边框**/数字格式等。 + - **Step 3 — 新列 cells 必须同时带 `cell_styles` + `border_styles`**:写新列时 cells 里的每个对象都要完整复制原表样式(包括边框),不能只传 font_size / alignment 就算"样式一致"。 + - **Step 4 — 列宽对齐**:用 `+batch-update` 合并 `+dim-resize` 把新列列宽与原数据列保持一致。 + - 典型反模式:AI 只在 batch_update 里放 3 个 `+cells-set`(表头 + 空列样式 + 公式列),完全跳过 Step 1 的合并扩展 和 Step 2-3 的边框复制 → row1 不跟着变宽、新列无边框,用户打开产物感受"新列被孤立在原表外"。 + +## 推荐工作流程 + +### 第 0 步(最优先):按「任务类型 × 用户需求语义」直接锁定读取路径 + +**在做任何其他判断之前**,先按任务类型选路径,跳过这一步直接进入"探表"是"只改前 N 行"故障的根源。 + +| 用户需求语义 | 强制路径 | 写入范围默认值 | +|------------|---------|-------------| +| **"完善 / 补齐 / 填空 / 修正所有 XX"** | **路径 A(export_sheet_to_sandbox + Python)** | **覆盖所有对应类别的完整数据行**——不以用户 `` 圈选为准(圈选通常只是光标位置) | +| "加一列 / 加 N 行 / 扩展到第 X 周" 等**扩展**类 | 路径 D(参见硬性规则 12/13) | 按用户指定或选区末行 | +| "查一下 / 看看 / 统计 / 汇总" 等**只读**类 | 路径 B (`+csv-get`) | n/a | +| 其他复杂任务 | 按任务类型在下方 A/B/C/D 路径中选 | — | + +**【高频致命错误 绝对禁止】** 对"完善 / 补齐 / 填空"类任务用路径 B 探 10 行就进入写入——`get_range_as_csv A1:Z10` → `set_cell_range ?3:?10` 的模式实测会漏写表尾多行。 + +1. 选择相关 skill +根据任务组合相关 skill。涉及样式、美化、补齐边框和格式时,参考 `lark-sheets-visual-standards`。不要只读完本 skill 就直接调用具体工具;应继续读取对应的工具 skill,包括 `lark-sheets-workbook`、`lark-sheets-write-cells`、`lark-sheets-filter`、`lark-sheets-pivot-table` 等,再执行工具调用。 + +2. 了解工作簿与工作表 +先用 `lark-sheets-workbook` 获取工作簿与子表概览。涉及隐藏、分组、合并、列宽或行高等布局信息时,再使用 `lark-sheets-sheet-structure`。 + +3. 读取数据(按第 0 步锁定的路径) + +**路径 A:数据分析/清洗/可视化/大数据集/"完善 / 补齐 / 填空 / 修正所有 XX"** → 用 `export_sheet_to_sandbox` 导出到沙箱,再写 Python 代码: +```python +import pandas as pd +df = pd.read_csv(file_path) # file_path 从 export_sheet_to_sandbox 返回值获取 +print(df.info()) # 必做:获得「实际数据行数」(non-null count) 和列类型 +print(df.head(10)) # 必做:横向——确认表头行 + 前 10 行数据样貌 + +# 看完 head(10) 后判断表格形态: +# (a) 交叉表/透视布局:最左侧 1-2 列是行标签(如 日期/类别/编号枚举每一行含义),其余列是维度/指标 +# → 必做:print(df.iloc[:, :2].to_string()) 把左侧 1-2 列全部行标打到底 +# → 写入 range 的末行必须 == 纵向表头列读到的最后一行的表格行号(由 [row=N] 前缀取) +# (b) 扁平列表:每行一条独立记录,列是字段(如 ID/姓名/部门/金额),无"行标签"概念 +# → 不需要补读左侧列;但如果 len(df) 或 info() 的 non-null > 10,写入前仍需 print(df.to_string()) +# 或 print(df.iloc[10:].to_string()) 看完所有行再规划写入范围 +# print(df.describe()) # 按需:涉及数值分析(统计、异常值、分布)时再调用 +``` + +**路径 B:快速查看少量数据/简单问答(只读场景)** + +> **【场景限制 绝对禁止】** 本路径**仅适用于只读问答/简单查询/只需要看头部几行**。**绝对禁止**用于批量写入/完善/补齐/填空类任务——对这类任务踩本路径必漏写表尾(见第 0 步)。若任务最终要回写多行数据,直接去路径 A。 + +用 `+csv-get` 读取到上下文。若本路径真的适用(确认是只读场景),按以下顺序: + +1. `range: "A1:Z10"` — 横向读前 10 行探表头结构 +2. 看完第 1 步的 `annotated_csv` 后,**判断表格形态**: + - 左侧 1-2 列是行标签(日期/类别/编号枚举每行含义,其余列是维度/指标)→ 交叉表/透视布局 → **必做**:再调一次 `range: "A:A"` 或 `A:C` 读纵向表头列到底,拿到全部行标 + - 每行是独立记录、列是字段(如 ID/姓名/部门/金额)→ 扁平列表 → 不需补读左侧列;但写入前仍要靠 `current_region` 末行确认数据范围,末行 > 第 1 步读到的行数时需再读一次覆盖 `current_region` 全区 + +若 `current_region` 远大于 `actual_range`(比如几百行),**切换到路径 A**,不要用路径 B 翻页硬灌。 + +**路径 C:需要公式/样式/批注** → 用 `+cells-get` + +**路径 D:续写/扩展/完善已有内容(必须走此路径)** → 当用户要求"按已有格式续写"、"继续填充"、"复制区块"、"增加到第N周/月"、"帮我完善 XX"、"补齐 XX"、"填空"时,**必须**同时读取值和样式:先用 `+csv-get` 快速了解数据结构和范围,再用 `+cells-get` 读取源区块的 `cell_styles` + `border_styles`,并用 `+sheet-info` 读取行高和合并信息。**禁止跳过样式读取直接写入。** 这类任务默认要覆盖所有待补齐的行,**禁止只处理 `head(10)` 可见的行**——必须按 `df.info()` 的 non-null 数或 `current_region` 的实际行数确定写入范围。 + +需要按关键字定位区域时使用 `lark-sheets-search-replace` skill 中的 `+cells-search`。 + +4. 写入前重新确认数据边界(批量写入/修改时必做) + +批量填充、续写、补齐、完善、替换、覆盖多行等写入场景,在动手写入前必须先拿到两个量:**"已读取到的完整数据范围末行"** 和 **"真实数据末行"**,然后按下面的强校验逻辑确认。 + +**强校验(必做,任一条不满足都不得写入)**: + +1. **已读完整数据范围末行 ≥ 真实数据末行**——注意,比较对象是**真实数据末行**(不是规划写入范围末行)。只读了前 10 行、规划写 10 行、二者相等,**形式上满足 "已读 ≥ 写入" 但实际漏了 11 行之后的数据**——这种"看起来满足校验"的场景属于 loophole,**默认判为违规**。 +2. **交叉表场景下,写入 range 末行必须 == 纵向表头列读到的最后一行的行号**。禁止以首次横向探查的末行(如 `A1:Z10` 的第 10 行)作为写入末行,即使已读 range 覆盖到了。 +3. **"完善 / 补齐 / 填空 / 修正所有 XX" 类需求**,写入范围默认 = 真实数据末行(所有待补齐行),**以用户需求语义为最高优先级**,不以用户 `` 圈选为准(圈选通常只是光标位置,不代表修改意图)。 + +**真实数据末行**的来源随读取路径不同: + +| 读取路径 | 真实数据末行的字段 | 触发再读的条件 | +|---------|-------------------|--------------| +| 路径 A(Python/export,强制路径) | `df.info()` 的 non-null 最大值 **或** `len(df)`;交叉表场景还要看 `df.iloc[:, :2].to_string()` 的行数 | non-null > head 行数 → 执行 `print(df.to_string())` 或 `print(df.iloc[N:].to_string())` | +| 路径 B(get_range_as_csv,仅只读场景) | 响应里的 `current_region` 末行;交叉表还要看纵向表头列 `A:A` / `A:C` 读到底的最后一行 | `current_region` 末行 > 首次请求 range 的末行 → 再调一次 `+csv-get` 覆盖全区 | +| 路径 C(get_cell_ranges) | 响应里的 `cell_range.end_row` | `end_row` > 首次读 range 的末行 → 再读一次 | + +**共同规则(强制)**: +- **禁止**在首次读 range 写死 `10` / `20` / `100` 等经验数字就直接进入写入。 +- **禁止**用首次读的末行当"已读完整数据范围末行"——要先确认 `current_region` / `non-null` / `end_row` 都不大于它,否则必须再读。 +- 写入范围应根据**用户意图 + 真实数据末行**推断: + - 补齐/完善/修正类任务 → 写入末行 = 真实数据末行(覆盖所有待补齐行) + - 扩展/新增类任务 → 写入末行 = 用户指定或选区末行 +- **反例(违规操作,必查)**:仅探查前 10 行、用 10 当作"已读 range 末行"、规划写入 10 行、形式上"已读 ≥ 写入" → 实际真实数据有 15 行 → **属于违规**,必须再读完剩余 11-15 行后再写。 + +只读问答或简单查询(路径 B 且只需统计/查询、不写回)可以跳过本步。 + +5. 理解数据语义(写入前必做) +在动手写入或创建公式/透视表之前,先建立字段映射: +- 读取表头 + 3-5 行样本数据,确认各列含义和数据格式(文本/数字/日期/混合) +- 需要写公式时:先分析样本值的格式模式(如"长2900*高3650"的分隔符),再选提取策略 +- 需要创建透视表时:先列出"行字段 = 分组维度、值字段 = 聚合指标"的对应关系 +- 需求模糊时(如"加入加减乘除函数"但未说明逻辑):基于表头和已有公式推断,不确定时询问用户,禁止臆造业务逻辑 + +6. 分析与计算(原生工具优先,代码兜底) +飞书表格的原生能力(公式、透视表、图表、筛选、条件格式)可以随数据变化自动更新,**必须优先使用**。只有原生能力确实无法完成时才退化到代码。 + +**场景→工具强制路由(禁止跳过直接用代码)**: + +| 用户需求 | 必须用的原生工具 | 禁止用代码替代 | +|---------|----------------|--------------| +| 按XX统计YY、分组汇总 | `+pivot-{create|update|delete}` | pandas groupby → set_cell_range | +| 求和/计数/平均/占比 | 公式(SUM/COUNT/AVERAGE) | Python 计算 → 写静态值 | +| 画图表、可视化 | `+chart-{create|update|delete}` | matplotlib/seaborn 画图 | +| 条件高亮、色阶 | `+cond-{format-create|format-update|format-delete}` | 逐单元格设样式 | +| 数据筛选 | `+filter-{create|update|delete}` | pandas filter → 覆盖写入 | +| 文本提取/转换 | 公式(REGEXEXTRACT/TEXT/VALUE) | Python 正则 → 写静态值 | +| 查找匹配 | 公式(VLOOKUP/INDEX+MATCH) | pandas merge → 写静态值 | + +**只有以下场景才用代码**:多步数据清洗(正则+拆分+合并流水线)、统计建模、公式试错 3 次仍失败时的降级方案。代码计算结果写回表格时: + - **数据已经在沙箱里(由 Python 清洗/聚合/筛选得到的大块纯值)** → 优先 `import_sandbox_to_sheet`(只传 file_uri,CSV 不进对话上下文,最省 token) + - **模型手里就有 CSV 文本(小规模手动构造,或从 `+csv-get` 取到后简单加工)** → `+csv-put`(直接传 CSV 文本 + start_cell) + - **少量数据或需要公式/样式** → `+cells-set` + - **能用飞书公式表达的** → 写飞书公式(源数据变化时自动重算) + +7. 写入与修改 +- 范围写入使用 `lark-sheets-write-cells`。`+cells-set` 的 `range` 必须落在当前已有行列范围内,`cells` 二维数组必须与 `range` 严格同维度;若是大块 CSV 纯值回写,沙箱路径优先用 `import_sandbox_to_sheet`,已有 CSV 文本用 `+csv-put`(两者必要时自动扩容)。 +- 如需在表尾追加数据,先插入行或列,再执行写入。 +- **多步写入优先用 `+batch-update`**(见硬性规则 7):将多个写入操作合并为一次 `+batch-update` 调用,减少调用轮次。尤其是多次 merge_cells、多次 resize_range、多次 set_cell_range 场景,必须合并。 +- **公式填充必须用 `copy_to_range`(见硬性规则 10)**:先写一行模板公式,再用 `copy_to_range` 一次填充整列或整区域。示例:在 H2 写 `=SUM(B2:G2)` 后,设 `copy_to_range: "H2:H100"` 即可填充 99 行。**禁止逐行调用 set_cell_range 写入相同结构的公式。** +- 对整行/整列统一设置值、公式、格式或批注时,优先写一个模板单元格,再用 `copy_to_range` 扩展到 `1:1`、`J:J`、`A:A` 等目标范围。 +- 当用户请求“宽一点 / 高一点 / 和其他行一样高 / 和其他列一样宽”时,先读取相邻可见行列的当前尺寸,再决定使用精确尺寸、`standard` 或 `auto`,不要随意猜测数值。 +- 对图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象,按各自 skill 的“先读后改后验证”工作流执行。 + +8. 验证 +- 重新读取受影响单元格区域,确认值、公式、样式、批注/备注符合预期。 +- 对图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象,重新读取对象配置确认结果。 +- 如出现错误,优先定位错误类型、受影响区域和根因,再修复后重新验证。 + +## 公式策略 + +### 优先使用公式,而非硬编码值 + +当可以用飞书表格公式表达计算逻辑时,必须写公式,而不是在 Python 中计算后写入静态结果。这样当源数据变化时,表格仍能自动重算。 +这适用于总计、平均值、增长率、占比、差值等常见计算。 + +### 飞书公式差异与限制 + +飞书表格公式与 Excel 公式基本相同,但需要特别注意以下差异: + +- 公式来自 Excel 或包含数组场景时,先读取 `lark-sheets-formula-translation` skill 完成改写,再生成公式。 +- 数组公式必须写成 `=ARRAYFORMULA(数组公式)` 语法。 +- 在公式字符串中,数据范围应使用飞书支持的语法,例如 `H:H`、`A2:B5`。禁止使用不符合飞书公式语法的写法,如 `H2:H`、`2:2` 等。 +- 飞书表格不支持以下函数,禁止主动使用;当用户明确要求使用这些函数时,应拒绝并说明飞书不支持: + - CUBE 相关函数,如 `CUBEMEMBER`、`CUBESET`、`CUBEVALUE`、`CUBERANK` + - `GOOGLEFINANCE`、`GOOGLETRANSLATE` 等 Google 特有函数 + - `FORECAST.ETS` 相关函数,如 `FORECAST.ETS`、`FORECAST.ETS.STAT` + - `INFO`、`RTD` 等系统信息相关函数 + - `PIVOT` + - `AMORDEGRC` + - `PHONETIC` + - `DETECTLANGUAGE` + +### 向下填充与绝对引用 + +对需要向下、向右填充的公式,必须先检查哪些引用需要固定,并正确使用绝对引用 `$`。 + +常见需要绝对引用的情况: + +- 用户要求中提到的特定单元格,例如 `$C$3`、`$A$2` +- 公式中需要固定的数据范围,例如 `$A$2:$B$5` +- 需要锁定列但允许行变化,或锁定行但允许列变化的场景,例如 `$A2`、`B$1` + +填充前应检查: + +- 是否需要固定汇率、税率、基准值等单个参数单元格 +- 是否需要固定查找表、权重表、映射表的数据范围 +- 同一列或同一行的公式结构是否一致 + +## 常见陷阱 + +- NaN / 空值处理:检查空值,避免直接参与运算 +- 多重匹配:搜索时检查所有匹配位置,而不是只取第一个 +- 除数为零:优先使用 `IF` 或 `IFERROR` +- 公式容错必须预置:日期公式(DATEDIF/DATEVALUE)、查找公式(VLOOKUP/INDEX)、数值转换必须用 `IFERROR` 包裹,避免单个异常值导致整列报错 +- 公式写入后必须校验:写入公式后读取结果列前 5 行 + 末 5 行,检查是否有 #VALUE!/#NUM!/#REF! 等错误值。发现错误时定位异常行并修复 +- 公式试错上限 3 次:同一个公式方案尝试 3 次仍失败时,改用代码计算并以值写入,不要无限循环 +- 操作语义映射:「改写」/「替换」= 覆盖原位数据(辅助列写公式 → 复制为值 → 粘贴到原列 → 删辅助列);「新增列」/「添加列」= 在旁边加列。搞反会破坏原表或不符合预期 +- 引用错误:验证所有单元格引用是否仍然有效 +- 跨工作表引用:使用 `Sheet!A1` 风格引用 +- 整行整列语义丢失:用户说“这行 / 这列 / 首行 / 整列”时,不要把操作范围截断为当前读取到的 `A1:U1`、`J1:J41` 等局部范围 +- **重复写入未使用 `copy_to_range`(高频致命错误)**:整列公式、整列格式、首行样式、向下复制等场景,**必须**用模板单元格 + `copy_to_range`,**禁止**逐行 `+cells-set`。这是最常见的导致轮次耗尽的错误 +- **重复调用 `+cells-{merge|unmerge}` / `+dim-resize` 未合并为 `+batch-update`(高频致命错误)**:当需要合并/调整多个区域时,**必须**使用 `+batch-update` 将多个操作合并为单次调用。逐个调用会快速耗尽轮次上限(60R) +- 多步骤请求漏做:若用户要求”先重命名,再新建”,两个动作都必须执行 +- **表头定位不精确导致写入全偏(高频致命错误)**:不要假设表头在第 1 行。很多表格有标题行、说明行或空行,实际表头可能在第 2、3 行甚至更后。写入公式或数据前,必须先读取前几行确认表头行号和各列实际含义,再基于确认后的行列号构造写入 range +- **参数冗余**:只需修改 10 个单元格时,不要把全表重写一遍;`+cells-set` 的 range 和 cells 应精确覆盖变更区域 +- **表头理解路径不对**:要了解表格结构和字段含义时,优先通过 `export_sheet_to_sandbox` 导出到沙箱后用 `df.info()` + `df.head()` 查看;简单场景也可用 `+csv-get` 读取前 5-10 行。不要一行行用 `+cells-get` 逐行读取,也不要依赖 `+cells-search` 去”猜”字段名 +- **隐藏行列导致定位偏移**:`+csv-get` 默认 `skip_hidden=false`(返回完整数据含隐藏行列)。如需只看可见数据,显式设 `skip_hidden=true`,但注意跳过隐藏行后返回数据的行序号与实际行号不对应 +- **写入前读取范围不充分**:涉及批量写入或修改时,必须先读取足够的数据范围。如果表格有 100 行而只读了 20 行,后续操作会漏掉剩余数据。使用 `+workbook-info` 获取行数后,根据实际行数决定读取范围。注意:了解表头和数据结构只需前几行,但批量操作前需要掌握完整数据 +- **`+cells-search` 不是万能的**:用户说”汇总金额”是一个操作动作(求和),不是要搜索”汇总金额”这个文本。只有当确实需要定位某个文本值的位置时才用 `+cells-search` +- **跨 sheet 对象**:图表、条件格式、透视表、浮动图片可能分布在多个子表中。操作前先用 `+workbook-info` 掌握全局,不要只看当前子表 +- **copy_to_range 不含行列尺寸**:`copy_to_range` 复制的是值、公式和样式,不包含行高列宽设置。需要统一行列尺寸时,应另行调用 `lark-sheets-range-operations` 中的 `+dim-resize` +- **写入前先确保行列存在**:`+cells-set` 不会自动扩展表格。如果要写入的 range 超出当前行列范围,必须先用 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` 插入行列 +- **写入后保持原表样式(高频致命错误)**:原表已有边框线、背景色、行高、合并单元格等样式时,写入新数据后**必须**延续相同样式,不要只写值不管格式。具体做法:先用 `+cells-get` 读取源区域的样式(`cell_styles`、`border_styles`),写入时在 cells 中携带相同的样式字段;若源区域有合并单元格,用 `+cells-{merge|unmerge}` 对新区域做相同合并;若源区域有特殊行高,用 `+dim-resize` 对新区域设置相同行高。详见下方"特殊场景 → 续写/复制已有区块格式" +- **CSV 行号按物理换行计数导致行号全错(高频致命错误)**:`+csv-get` 返回的 CSV 中,被双引号包裹的字段内换行符是**单元格内换行**,不是新行。例如 `"2026年3月2日\n星期一"` 是**一个单元格**,不是两行。计算行号时必须按逻辑记录计数。详见 `lark-sheets-read-data` skill 中的"CSV 行号计算规则" + +## Skill Set + +通过 `read_skill` 读取对应 Skill 获取详细用法。涉及样式/美化时,同时参考 `lark-sheets-visual-standards`。 + +| 类别 | skill | 一句话用途 | 包含工具 | +|------|------|-----------|---------| +| 读写 | `lark-sheets-workbook` | 获取工作簿全局结构(首步必调);增删/重命名/移动/复制子表 | `+workbook-info`、`+sheet-{create|delete|rename|move|copy|hide|unhide|set-tab-color}` | +| 读取 | `lark-sheets-read-data` | 读取单元格数据:导出到沙箱供 Python 分析、CSV 格式快速查看、含公式/样式/批注的详细信息 | `export_sheet_to_sandbox`(数据分析首选)、`+csv-get`(快速查看)、`+cells-get`(公式/样式/批注) | +| 读写 | `lark-sheets-sheet-structure` | 获取子表行列布局;增删/隐藏/冻结/分组行列 | `+sheet-info`、`+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` | +| 读写 | `lark-sheets-search-replace` | 按关键字搜索定位单元格;查找并替换文本 | `+cells-search`、`+cells-replace` | +| 写入 | `lark-sheets-write-cells` | 向指定区域写入值/公式/样式/批注,或批量导入 CSV 纯值(沙箱路径 / 已有 CSV 文本两条路) | `+cells-set`(精确控制)、`import_sandbox_to_sheet`(沙箱结果回写)、`+csv-put`(已有 CSV 文本直接铺) | +| 写入 | `lark-sheets-range-operations` | 清除内容、合并单元格、调整行列尺寸、排序、移动/复制区域(支持格式刷:仅复制值/公式/格式) | `+cells-clear`、`+cells-{merge|unmerge}`、`+dim-resize`、`+range-{move|copy|fill|sort}` | +| 写入 | `lark-sheets-batch-update` | 将多个写入操作合并为单次请求,减少调用次数 | `+batch-update` | +| 对象 | `lark-sheets-chart` | 查询、创建、更新或删除图表 | `+chart-list`、`+chart-{create|update|delete}` | +| 对象 | `lark-sheets-pivot-table` | 查询、创建、更新或删除数据透视表 | `+pivot-list`、`+pivot-{create|update|delete}` | +| 对象 | `lark-sheets-conditional-format` | 查询、创建、更新或删除条件格式规则 | `+cond-format-list`、`+cond-{format-create|format-update|format-delete}` | +| 对象 | `lark-sheets-filter` | 查询、创建、更新或删除筛选器 | `+filter-list`、`+filter-{create|update|delete}` | +| 对象 | `lark-sheets-sparkline` | 查询、创建、更新或删除迷你图 | `+sparkline-list`、`+sparkline-{create|update|delete}` | +| 对象 | `lark-sheets-float-image` | 查询、创建、更新或删除浮动图片 | `+float-image-list`、`+float-{image-create|image-update|image-delete}` | + +## 特殊场景 + +### 续写/复制已有区块格式 + +当用户要求"按照已有内容格式继续填充"(如"按前两周格式续写到第 20 周"、"把第一个模块复制 N 遍并改日期")时,必须按以下步骤执行: + +1. **用 `+cells-get` 读取源区块的样式**:`+csv-get` 只返回值,无法获取样式。必须用 `+cells-get` 读取源区块,获取每个单元格的 `cell_styles`(字体、背景色、对齐等)和 `border_styles`(边框) +2. **用 `+sheet-info` 读取布局信息**:获取源区块的行高、列宽、合并单元格信息 +3. **规划写入范围并扩行**:计算目标行数,若超出当前 sheet 边界,先用 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` 插入行 +4. **带样式写入数据**:`+cells-set` 的 cells 中同时携带 `value` + `cell_styles` + `border_styles`。推荐使用"内容与样式分离写入"策略(见 `lark-sheets-write-cells` skill):先写值,再用模板单元格 + `copy_to_range` 刷样式 +5. **合并单元格**:对标题行等需要合并的区域,用 `+batch-update` 批量调用 `+cells-{merge|unmerge}` +6. **设置行高**:用 `+batch-update` 批量调用 `+dim-resize`,统一设置新区域的行高与源区块一致 +7. **回读校验**:用 `+csv-get` 校验值,用 `+cells-get` 抽查样式 + +> **反面案例**:只用 `+csv-get` 读值 → 只传 `{"value": ...}` 写入 → 新区块没有边框、没有合并、没有行高,与源区块视觉不一致。这是本场景最常见的错误。 + +### NLP 任务处理 + +当任务涉及语义理解、翻译、改写、摘要、分类、抽取或多行内容聚合时,应以 NLP 方式处理,不要试图用纯规则代码替代语义理解。 + +判断标准: + +特征 | 示例 +---|--- +内容转换 | 翻译、改写、摘要 +内容分析 | 情感分析、分类 +语义提取 | 提取人名、日期、金额 +内容聚合 | 多行信息合并 + +注意: + +- NLP 处理本身不应退化为纯规则代码;但可以使用代码做分批、行号映射、结果拼装和写回。 +- 数据量大时**必须**分批处理,通常 30 行一批。每批处理完后立即写回表格,不要等全部处理完再一次性写入。 +- 为避免超时,NLP 处理通常不超过 300 行;超过时根据任务性质选择抽样或分批执行,并向用户明确处理范围。 +- 翻译、信息提取等任务,优先使用 `+batch-update` 将多批写入合并,减少调用轮次。 + +### 格式处理优先公式 + +当用户需求涉及"去除多余零"、"提取数字"、"文本格式转换"、"日期格式化"等数据清洗操作时,**必须优先使用公式方案**(如 `SUBSTITUTE`、`TEXT`、`VALUE`、`LEFT`、`RIGHT`、`MID` 等函数),而非逐行读取数据后手动修改。公式方案只需写一个模板 + `copy_to_range` 即可完成整列处理,远比逐行修改高效。 diff --git a/skills/lark-sheets/references/lark-sheets-dropdown.md b/skills/lark-sheets/references/lark-sheets-dropdown.md deleted file mode 100644 index 2086b9621..000000000 --- a/skills/lark-sheets/references/lark-sheets-dropdown.md +++ /dev/null @@ -1,133 +0,0 @@ -# Sheets Dropdown - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -这份 reference 汇总下拉列表配置: - -- `+set-dropdown` -- `+update-dropdown` -- `+get-dropdown` -- `+delete-dropdown` - -> **关键规则:** 使用 `multipleValue` 写入前,必须先设置下拉列表;否则值会被当成纯文本。 - - -## `+set-dropdown` - -对应命令:`lark-cli sheets +set-dropdown` - -```bash -lark-cli sheets +set-dropdown --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --range "!A2:A100" --condition-values '["选项1", "选项2", "选项3"]' -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--range` | 是 | 范围(如 `!A2:A100`) | -| `--condition-values` | 是 | 下拉选项 JSON 数组 | -| `--multiple` | 否 | 是否多选 | -| `--highlight` | 否 | 是否着色 | -| `--colors` | 否 | 颜色 JSON 数组 | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出:`code`、`msg` - - -## `+update-dropdown` - -对应命令:`lark-cli sheets +update-dropdown` - -```bash -lark-cli sheets +update-dropdown --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" \ - --ranges '["!A1:A100"]' \ - --condition-values '["选项A", "选项B"]' -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--ranges` | 是 | 范围 JSON 数组 | -| `--condition-values` | 是 | 选项 JSON 数组 | -| `--multiple` | 否 | 是否多选 | -| `--highlight` | 否 | 是否着色 | -| `--colors` | 否 | 颜色 JSON 数组 | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出:`spreadsheetToken`、`sheetId`、`dataValidation` - - -## `+get-dropdown` - -对应命令:`lark-cli sheets +get-dropdown` - -```bash -lark-cli sheets +get-dropdown --spreadsheet-token "shtxxxxxxxx" \ - --range "!A2:A100" -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--range` | 是 | 查询范围 | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `dataValidations[].conditionValues` -- `dataValidations[].ranges` -- `dataValidations[].options.multipleValues` -- `dataValidations[].options.highlightValidData` -- `dataValidations[].options.colorValueMap` - - -## `+delete-dropdown` - -对应命令:`lark-cli sheets +delete-dropdown` - -```bash -lark-cli sheets +delete-dropdown --spreadsheet-token "shtxxxxxxxx" \ - --ranges '["!A2:A100", "!C1:C50"]' -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--ranges` | 是 | 范围 JSON 数组 | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `rangeResults[].range` -- `rangeResults[].success` -- `rangeResults[].updatedCells` - -## 典型流程 - -```bash -# 1. 配置下拉 -lark-cli sheets +set-dropdown --url "" \ - --range "!J2:J100" --condition-values '["选项1","选项2"]' --multiple - -# 2. 再写入 multipleValue -lark-cli sheets +write --url "" --sheet-id "" --range "J2" \ - --values '[[{"type":"multipleValue","values":["选项1","选项2"]}]]' -``` - -## 参考 - -- [cell-data](lark-sheets-cell-data.md#write) — 写入普通单元格数据 diff --git a/skills/lark-sheets/references/lark-sheets-filter-view.md b/skills/lark-sheets/references/lark-sheets-filter-view.md new file mode 100644 index 000000000..32701c91f --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-filter-view.md @@ -0,0 +1,146 @@ +# Lark Sheet Filter View + +## 概念回顾 + +筛选视图是 sheet 内的多份独立筛选配置,每个视图持有自己的 `range` 和 `rules`,由独立 `view_id`(10 位随机字符串)标识。一个 sheet 可有多个视图,视图的隐藏行仅在用户进入该视图时本地生效,不影响其他协作者,也不与该 sheet 上可能并存的筛选器(filter)互相影响。 + +`+filter-{view-create|view-update|view-delete}` 负责视图本身的 CRUD(create / update / delete);视图的"进入 / 退出"(激活态)是本地状态,不在工具语义内。 + +## 使用场景 + +读写筛选视图对象。本 Skill 包含两个工具: + +| 操作需求 | 使用工具 | 说明 | +|---------|---------|------| +| 查看已有筛选视图 | `+filter-view-list` | 获取 sheet 上所有视图(视图名、范围、规则) | +| 创建 / 更新 / 删除筛选视图 | `+filter-{view-create|view-update|view-delete}` | 3 种 operation:create / update / delete | + +典型工作流:先读取现有视图了解配置 → 执行创建 / 更新 / 删除 → **必须再次读取验证结果**。 + +**常见配置错误(必须注意)**: +- **视图范围必须覆盖表头行**:视图的 range 必须从表头行开始(如 `A1:F100`),不能只包含数据行 +- **更新前先读取**:用户说"调整这个视图"时,先用 `+filter-view-list` 拉到目标视图当前 rules,**只改差异列**再回写 +- **多次 create 不能复用 view_id**:复用应走 `update`,重复 `create` 会产生新视图 +- **筛选不支持正则表达式**:飞书表格筛选器不支持正则表达式,传入正则会当成普通文本处理 + +## Shortcuts + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 + +| MCP tool | CLI shortcut | Risk | 分组 | +| --- | --- | --- | --- | +| `get_filter_view_objects` | `+filter-view-list` | read | 对象 | +| `manage_filter_view_object` | `+filter-view-create` | write | 对象 | +| | `+filter-view-update` | write | 对象 | +| | `+filter-view-delete` | high-risk-write | 对象 | + +## Flags + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 + +### `+filter-view-list` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--view-id` | 专有 | string | 否 | 按筛选视图 reference_id 过滤(命中即只返回单个视图) | +| `--dry-run` | 系统 | bool | 否 | | + +### `+filter-view-create` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--data` | 专有 | string + File + Stdin | 是 | 视图配置 JSON:`{"view_name":"...","range":"A1:Z100","rules":[...]}`;省略 view_id 表示 create;range 必填且必须覆盖表头行 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+filter-view-update` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--view-id` | 专有 | string | 是 | 目标视图 reference_id | +| `--data` | 专有 | string + File + Stdin | 是 | 部分更新 JSON:含 view_name / range / rules 之一即可;先 +filter-view-list 回读再 patch | +| `--dry-run` | 系统 | bool | 否 | | + +### `+filter-view-delete` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--view-id` | 专有 | string | 是 | 目标视图 reference_id | +| `--yes` | 系统 | bool | 是 | `high-risk-write`,必须二次确认(不带时退出码 10) | +| `--dry-run` | 系统 | bool | 否 | | + +## Schemas + +> 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 + +### `+filter-view-create` `--data` / `+filter-view-update` `--data` + +_create / update 的视图属性_ + +**顶层字段**: +- `filtered_columns` (array?) — 可选 +- `range` (string?) — 视图作用的单元格范围(A1 表示法) +- `rules` (array?) — 列级筛选规则列表,每一项对应一个具体列的筛选条件 each: { column_index: string, conditions: array, filtered_rows?: array } +- `view_name` (string?) — 可选 + +## Examples + +> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 + +> ⚠️ 本 skill 是 **CLI 独有**(meta `surface: cli-only`);`generate_mcp` 跳过,不会进 sheet-ai-skills SKILL 集。AI/MCP 侧暂不暴露筛选视图能力。 + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。`view_id` 是 10 位随机字符串,每个 sheet 可有多个视图。 + +### `+filter-view-list` + +```bash +# 列出某个 sheet 的全部筛选视图 +lark-cli sheets +filter-view-list --url "..." --sheet-id "$SID" + +# 按 view_id 精确定位 +lark-cli sheets +filter-view-list --url "..." --sheet-id "$SID" --view-id vAbcde1234 +``` + +### `+filter-view-create` + +```bash +lark-cli sheets +filter-view-create --url "..." --sheet-id "$SID" \ + --data '{ + "view_name": "活跃用户", + "range": "A1:F1000", + "rules": [ + {"col": "C", "filter_type": "number", "compare": "greater", "expected": [100]} + ] + }' +``` + +> `range` **必须覆盖表头行**(如 `A1:F1000`),不能只包含数据行;`view_name` 重名时服务端自动改名。 + +### `+filter-view-update` + +> ⚠️ update 是 patch:传 `view_name` / `range` / `rules` 任意一个或多个;先 `+filter-view-list` 读取当前 rules 再回写差异。重复 `+filter-view-create` 不会复用 view_id,会产生新视图。 + +### `+filter-view-delete` + +> ⚠️ 视图删除不可逆;视图不存在按幂等成功处理。先 `--dry-run` 看 view_id 确认。 + +### Validate / DryRun / Execute 约束 + +- `Validate`:XOR 公共四件套;`+filter-view-create` 校验 `--data.range` 起始行为表头(第一行);`+filter-view-update` 必须先 `+filter-view-list` 确认 view 存在;`+filter-view-delete` 强制 `--yes` 或 `--dry-run`。 +- `DryRun`:输出"将要 POST/PATCH/DELETE 的 view 请求模板",零网络副作用;`--sheet-name` 在 dry-run 输出里生成为 `` 占位符。 +- `Execute`:写后调用 `+filter-view-list --view-id ` 回读,envelope.meta.verification 给出当前 range + rules 与请求体的对比。 diff --git a/skills/lark-sheets/references/lark-sheets-filter-views.md b/skills/lark-sheets/references/lark-sheets-filter-views.md deleted file mode 100644 index 0535799b2..000000000 --- a/skills/lark-sheets/references/lark-sheets-filter-views.md +++ /dev/null @@ -1,193 +0,0 @@ -# Sheets Filter Views - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -这份 reference 汇总筛选视图和筛选条件: - -- `+create-filter-view` -- `+update-filter-view` -- `+list-filter-views` -- `+get-filter-view` -- `+delete-filter-view` -- `+create-filter-view-condition` -- `+update-filter-view-condition` -- `+list-filter-view-conditions` -- `+get-filter-view-condition` -- `+delete-filter-view-condition` - - -## `+create-filter-view` - -对应命令:`lark-cli sheets +create-filter-view` - -在工作表中创建筛选视图,每个工作表最多 150 个。 - -```bash -lark-cli sheets +create-filter-view --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --range "!A1:H14" - -lark-cli sheets +create-filter-view --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --range "!A1:H14" --filter-view-name "我的筛选" -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--range` | 是 | 筛选范围 | -| `--filter-view-name` | 否 | 显示名称 | -| `--filter-view-id` | 否 | 自定义 10 位字母数字 ID | - -输出:`filter_view` - - -## `+update-filter-view` - -对应命令:`lark-cli sheets +update-filter-view` - -```bash -lark-cli sheets +update-filter-view --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --filter-view-id "" --range "!A1:J20" -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--filter-view-id` | 是 | 筛选视图 ID | -| `--range` | 否 | 新范围 | -| `--filter-view-name` | 否 | 新显示名称 | - - -## `+list-filter-views` - -对应命令:`lark-cli sheets +list-filter-views` - -```bash -lark-cli sheets +list-filter-views --spreadsheet-token "shtxxxxxxxx" --sheet-id "" -``` - -输出:`items[]`(`filter_view_id`、`filter_view_name`、`range`) - - -## `+get-filter-view` - -对应命令:`lark-cli sheets +get-filter-view` - -```bash -lark-cli sheets +get-filter-view --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --filter-view-id "" -``` - -输出:`filter_view` - - -## `+delete-filter-view` - -对应命令:`lark-cli sheets +delete-filter-view` - -```bash -lark-cli sheets +delete-filter-view --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --filter-view-id "" -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--filter-view-id` | 是 | 筛选视图 ID | - - -## `+create-filter-view-condition` - -对应命令:`lark-cli sheets +create-filter-view-condition` - -为筛选视图的指定列创建筛选条件。 - -```bash -# 数值筛选:E 列 < 6 -lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --filter-view-id "" \ - --condition-id "E" --filter-type "number" --compare-type "less" --expected '["6"]' - -# 文本筛选:G 列以 a 开头 -lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --filter-view-id "" \ - --condition-id "G" --filter-type "text" --compare-type "beginsWith" --expected '["a"]' -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--filter-view-id` | 是 | 筛选视图 ID | -| `--condition-id` | 是 | 列字母,如 `E` | -| `--filter-type` | 是 | `hiddenValue` / `number` / `text` / `color` | -| `--compare-type` | 否 | 比较运算符 | -| `--expected` | 是 | 筛选值 JSON 数组 | - -输出:`condition` - - -## `+update-filter-view-condition` - -对应命令:`lark-cli sheets +update-filter-view-condition` - -```bash -lark-cli sheets +update-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --filter-view-id "" --condition-id "E" \ - --filter-type "number" --compare-type "between" --expected '["2","10"]' -``` - -参数与创建条件相同,但 `filter-type` / `compare-type` / `expected` 可按需部分更新。 - - -## `+list-filter-view-conditions` - -对应命令:`lark-cli sheets +list-filter-view-conditions` - -```bash -lark-cli sheets +list-filter-view-conditions --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --filter-view-id "" -``` - -输出:`items[]` - - -## `+get-filter-view-condition` - -对应命令:`lark-cli sheets +get-filter-view-condition` - -```bash -lark-cli sheets +get-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --filter-view-id "" --condition-id "E" -``` - -输出:`condition` - - -## `+delete-filter-view-condition` - -对应命令:`lark-cli sheets +delete-filter-view-condition` - -```bash -lark-cli sheets +delete-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --filter-view-id "" --condition-id "E" -``` - -## 参考 - -- [dropdown](lark-sheets-dropdown.md) — 需要下拉值配合筛选时 -- [cell-data](lark-sheets-cell-data.md#find) — 只查数据时用 `+find` diff --git a/skills/lark-sheets/references/lark-sheets-filter.md b/skills/lark-sheets/references/lark-sheets-filter.md new file mode 100644 index 000000000..6232cf4ba --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-filter.md @@ -0,0 +1,128 @@ +# Lark Sheet Filter + +## 真对象硬约束 + 数量校验 + +1. **真对象**:当用户要求"筛选 / 只看 / 仅保留 X"时,**必须**通过 `+filter-{create|update|delete}` 创建真实的筛选器对象。**禁止**用"删除不符合条件的行" / "新建子表只放符合条件的行" / 用 `+cells-set` 覆盖原表来代替——这些做法会让原数据丢失或不可恢复。 +2. **筛选数量必校**:执行筛选后**必须**回读,断言 `len(visible_rows) == expected_count`。`expected_count` 来自先用 `doubao_code_interpreter` 在源数据上独立复现该筛选条件得到的结果数。两者不一致时禁止交付,需排查筛选条件 / 数据列类型问题。 +3. **混合文本列禁止字面比较**:筛选 key 是公式文本(如 `1000+200=1200`)或带单位的混合文本时,先在辅助列里抽出纯数值再筛选;不能直接用文本比较。 + +## 使用场景 + +读写筛选器对象。本 Skill 包含两个工具: + +| 操作需求 | 使用工具 | 说明 | +|---------|---------|------| +| 查看已有筛选器 | `+filter-list` | 获取筛选器的范围、规则和条件配置 | +| 创建/更新/删除筛选器 | `+filter-{create|update|delete}` | 对筛选器执行写入操作 | + +典型工作流:先读取现有筛选器了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**。 + +**只读场景例外**:用户只是想知道哪些数据满足条件、并不要求修改表格展示时,可以走 `lark-sheets-read-data` 读后文本回答,不必创建筛选器。 + +**常见配置错误(必须注意)**: +- **筛选范围必须覆盖表头行**:筛选器的 range 必须从表头行开始(如 `A1:F100`),不能只包含数据行。缺少表头会导致筛选条件无法正确匹配列 +- **更新已有筛选器前先读取**:如果子表上已存在筛选器,直接创建会报错或覆盖原有配置。应先用 `+filter-list` 查看是否存在筛选器,存在时使用 update 而非 create +- **筛选条件的列索引要精确**:筛选条件中的列标识必须与实际数据列精确对应,不要凭猜测填写 +- **”调整筛选逻辑”要先读旧配置**:用户说”调整筛选”时,先读取现有筛选器的完整配置,理解当前规则后再修改,不要从零创建 +- **创建后必须验证**:调用 `+filter-list` 确认筛选器配置正确且生效 +- **筛选不支持正则表达式**:飞书表格筛选器不支持正则表达式,传入正则会当成普通文本处理。 + +## Shortcuts + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 + +| MCP tool | CLI shortcut | Risk | 分组 | +| --- | --- | --- | --- | +| `get_filter_objects` | `+filter-list` | read | 对象 | +| `manage_filter_object` | `+filter-create` | write | 对象 | +| | `+filter-update` | write | 对象 | +| | `+filter-delete` | high-risk-write | 对象 | + +## Flags + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 + +### `+filter-list` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--dry-run` | 系统 | bool | 否 | | + +### `+filter-create` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--range` | 专有 | string | 是 | 筛选范围,含表头行(如 `A1:F1000`) | +| `--data` | 专有 | string + File + Stdin | 否 | JSON:`{"conditions":[{"col":"B","filter_type":"multiValue","expected":["北京","上海"]}]}`;省略则只建空筛选 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+filter-update` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--data` | 专有 | string + File + Stdin | 是 | JSON:可改 `range` 或追加 / 替换 `conditions[]`;先 `+filter-list` 回读再 patch | +| `--dry-run` | 系统 | bool | 否 | | + +### `+filter-delete` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--yes` | 系统 | bool | 是 | `high-risk-write`,删除不可逆 | +| `--dry-run` | 系统 | bool | 否 | | + +## Schemas + +> 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 + +### `+filter-create` `--data` / `+filter-update` `--data` + +_创建/更新的筛选器属性_ + +**顶层字段**: +- `filtered_columns` (array?) — 可选 +- `range` (string) — 筛选对象作用的单元格范围(A1 表示法) +- `rules` (array) — 列级筛选规则列表,每一项对应一个具体列的筛选条件 each: { column_index: string, conditions: array, filtered_rows?: array } + +## Examples + +> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。`filter_id` 等同于 `sheet_id`(每个工作表至多一个筛选器)。 + +### `+filter-list` + +### `+filter-create` + +```bash +lark-cli sheets +filter-create --url "..." --sheet-id "$SID" \ + --range "A1:F1000" \ + --data '{"conditions":[{"col":"B","filter_type":"multiValue","expected":["北京","上海"]}]}' +``` + +### `+filter-update` + +> ⚠️ update 是覆盖式:传 `conditions` 会用整组新条件替换旧组。如只想加一条,要带上已有的全部条件再追加。 + +### `+filter-delete` + +### Validate / DryRun / Execute 约束 + +- `Validate`:XOR 公共四件套;`+filter-create` 校验 `--range` 至少 2 行(表头 + 至少 1 行数据);`+filter-update` 必须先 `+filter-list` 确认目标存在;`+filter-delete` 强制 `--yes` 或 `--dry-run`。 +- `DryRun`:输出"将要 POST/PATCH/DELETE 的 filter 请求模板"。 +- `Execute`:写后调用 `+filter-list` 回读,envelope.meta.verification 给出当前筛选条件 + 已过滤行数。 diff --git a/skills/lark-sheets/references/lark-sheets-float-image.md b/skills/lark-sheets/references/lark-sheets-float-image.md new file mode 100644 index 000000000..a4f6e219f --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-float-image.md @@ -0,0 +1,139 @@ +# Lark Sheet Float Image + +> **单元格图片 vs 浮动图片**:飞书表格有两种图片类型,请根据需求选择正确的工具: +> - **单元格图片**:图片嵌入在单元格内部,随单元格移动,属于单元格内容的一部分。→ 使用 `+cells-set`,在 `rich_text` 中设置 `type: "embed-image"`(见 lark_sheet_write_cells Skill)。 +> - **浮动图片**(本 Skill):图片悬浮在单元格上方,可自由指定位置、大小和层级,不属于任何单元格的内容。→ 使用本 Skill 的 `+float-{image-create|image-update|image-delete}`。 + +## 真对象硬约束 + +当用户要求"插入图片 / 添加 logo / 放一张图"时,**必须**通过 `+float-{image-create|image-update|image-delete}`(浮动图片)或 `+cells-set` 的 `embed-image`(单元格图片)创建真实的图片对象。**禁止**只在文本回复中给出图片链接 / 描述图片内容代替插入。判断标准:交付后 `+float-image-list` 或单元格 `rich_text` 必须能读到该图片对象。 + +## 使用场景 + +读写**浮动图片**对象(悬浮在单元格上方的图片,不属于单元格内容)。本 Skill 包含两个工具: + +| 操作需求 | 使用工具 | 说明 | +|---------|---------|------| +| 查看已有浮动图片 | `+float-image-list` | 获取浮动图片的位置、大小和层级配置 | +| 创建/更新/删除浮动图片 | `+float-{image-create|image-update|image-delete}` | 对浮动图片执行写入操作 | + +典型工作流:先读取现有浮动图片了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**。 + +**常见配置错误(必须注意)**: +- **单元格图片 vs 浮动图片选择错误**:如果用户希望图片嵌入单元格内部(随单元格移动),应使用 `+cells-set` 的 `rich_text` + `embed-image`,而非本 Skill +- **图片位置参数要精确**:锚点单元格的行列索引和偏移量决定了图片位置,设置不当会导致图片遮挡数据 +- **创建后必须验证**:调用 `+float-image-list` 确认图片位置和大小正确 + +reference_id 的映射规则: +- `image_uri`:`<|image|>:abcdef` 或者 `<|superscript|>:abcdef-<|image|>:abcdef` +- `float_image_id`:`<|float_image|>:abcdef` +其中 `abcdef` 为实际的对象 ID,占位符仅用于示意,不可直接使用。 + +`image_uri` 与 `image_token` 是「指定图片资源」的两种等价方式(与 `+cells-set` 中 `embed-image` 的语义一致): +- `image_uri`:上传链路给到的图片 reference_id(如 `<|image|>:abcdef`),由系统自动转 fileToken +- `image_token`:图片 fileToken,常见来源是 `+float-image-list` 返回的 `image_token`(适合"换皮不换位置"等基于已有图片的复用场景) +- create 时二者必须有其一;update 时**仅在需要替换图片本身时**传入新的 `image_uri` 或 `image_token`,不传则保留原图。 + +## Shortcuts + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 + +| MCP tool | CLI shortcut | Risk | 分组 | +| --- | --- | --- | --- | +| `get_float_image_objects` | `+float-image-list` | read | 对象 | +| `manage_float_image_object` | `+float-image-create` | write | 对象 | +| | `+float-image-update` | write | 对象 | +| | `+float-image-delete` | high-risk-write | 对象 | + +## Flags + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 + +### `+float-image-list` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--float-image-id` | 专有 | string | 否 | 按 id 过滤;省略时列工作表全部 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+float-image-create` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--data` | 专有 | string + File + Stdin | 是 | JSON:`{"image_uri":"...","image_name":"foo.png","position":{"row":2,"col":"D"},"size":{"width":300,"height":200},"offset":{"x":0,"y":0}}` | +| `--dry-run` | 系统 | bool | 否 | | + +### `+float-image-update` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--float-image-id` | 专有 | string | 是 | 目标图片 id | +| `--data` | 专有 | string + File + Stdin | 是 | 完整或足够完整的配置 JSON(先 `+float-image-list --float-image-id ` 回读再 patch) | +| `--dry-run` | 系统 | bool | 否 | | + +### `+float-image-delete` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--float-image-id` | 专有 | string | 是 | 目标图片 id | +| `--yes` | 系统 | bool | 是 | `high-risk-write`,删除不可逆 | +| `--dry-run` | 系统 | bool | 否 | | + +## Schemas + +> 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 + +### `+float-image-create` `--data` / `+float-image-update` `--data` + +_创建/更新的浮动图片属性_ + +**顶层字段**: +- `image_name` (string) — 图片名称,含拓展名,create 时必填 +- `image_token` (string?) — 图片 fileToken(与 image_uri 二选一) +- `image_uri` (string?) — 图片的 reference_id(与 image_token 二选一) +- `offset` (object?) — 可选 { col_offset?: number, row_offset?: number } +- `position` (object) — 必填 { col: string, row: number } +- `size` (object) — 必填 { height: number, width: number } +- `z_index` (number?) — 可选 + +## Examples + +> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。浮动图片是 sheet 级对象——和单元格内嵌图片不同(后者走 `+cells-set`)。 + +### `+float-image-list` + +### `+float-image-create` + +> `image_uri` 通常是先用 `upload_sheet_asset`(暂无 CLI shortcut,走 raw API)上传后拿到的 token,或者用 https URL(部分租户可直接引用)。 + +```bash +lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" --data @img.json +``` + +### `+float-image-update` + +### `+float-image-delete` + +### Validate / DryRun / Execute 约束 + +- `Validate`:XOR 公共四件套;`+float-image-create` 校验 `--data.image_uri` 非空、`position` / `size` 合法;`+float-image-update` 必须 `--float-image-id`;`+float-image-delete` 强制 `--yes` 或 `--dry-run`。 +- `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 float_image 请求模板"。 +- `Execute`:写后调用 `+float-image-list --float-image-id ` 回读,envelope.meta.verification 给出新位置 / 尺寸对比。 diff --git a/skills/lark-sheets/references/lark-sheets-float-images.md b/skills/lark-sheets/references/lark-sheets-float-images.md deleted file mode 100644 index 7c5f3f1b4..000000000 --- a/skills/lark-sheets/references/lark-sheets-float-images.md +++ /dev/null @@ -1,125 +0,0 @@ -# Sheets Float Images - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -这份 reference 汇总浮动图片相关能力: - -- `+media-upload` -- `+create-float-image` -- `+update-float-image` -- `+get-float-image` -- `+list-float-images` -- `+delete-float-image` - - -## `+media-upload` - -对应命令:`lark-cli sheets +media-upload` - -把本地图片上传到指定电子表格的素材空间,返回 `file_token`,供 `+create-float-image` 使用。 - -```bash -lark-cli sheets +media-upload --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --file ./image.png -``` - -说明: - -- 内部调用 `drive/v1/medias/upload_all` -- `>20MB` 自动分片上传 -- `--file` 只能是当前工作目录下的相对路径 - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--file` | 是 | 本地图片路径,必须是相对路径 | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出:`file_token`、`file_name`、`size`、`spreadsheet_token` - - -## `+create-float-image` - -对应命令:`lark-cli sheets +create-float-image` - -```bash -lark-cli sheets +create-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "" --float-image-token "boxcnXXXX" \ - --range "!A1:A1" --width 200 --height 150 -``` - -关键规则: - -- `--float-image-token` 必须来自 `+media-upload` -- `--range` 必须锚定单个单元格 -- `width` / `height` 必须 `>=20` -- `offset-x` / `offset-y` 必须 `>=0` - -输出:`float_image` - - -## `+update-float-image` - -对应命令:`lark-cli sheets +update-float-image` - -```bash -lark-cli sheets +update-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "" --float-image-id "fi12345678" \ - --width 400 --height 300 --offset-y 20 -``` - -至少需要传一个更新字段:`--range` / `--width` / `--height` / `--offset-x` / `--offset-y` - -输出:更新后的 `float_image` - - -## `+get-float-image` - -对应命令:`lark-cli sheets +get-float-image` - -```bash -lark-cli sheets +get-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "" --float-image-id "fi12345678" -``` - -输出:`float_image` - - -## `+list-float-images` - -对应命令:`lark-cli sheets +list-float-images` - -```bash -lark-cli sheets +list-float-images --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "" -``` - -输出:`items[]` - - -## `+delete-float-image` - -对应命令:`lark-cli sheets +delete-float-image` - -```bash -lark-cli sheets +delete-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "" --float-image-id "fi12345678" -``` - -输出:`code`、`msg` - -## 读取图片内容 - -上述读接口只返回元数据,不返回图片字节。要读取图片内容,用 `float_image_token` 调: - -```bash -lark-cli docs +media-preview --token "" --output ./image.png -``` - -## 参考 - -- [cell-images](lark-sheets-cell-images.md) — 写入到单元格的图片 -- [spreadsheet-management](lark-sheets-spreadsheet-management.md#info) — 先获取 `sheet_id` diff --git a/skills/lark-sheets/references/lark-sheets-formula-translation.md b/skills/lark-sheets/references/lark-sheets-formula-translation.md new file mode 100644 index 000000000..82884915f --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-formula-translation.md @@ -0,0 +1,245 @@ +# 飞书表格公式生成规则 + +**核心原则:飞书不像 Excel 365 那样默认 spill(溢出展开)。飞书普通公式遇到区域时默认"投影"(只取当前行/列对应的单个值),必须显式使用 `ARRAYFORMULA` 或原生数组函数才能逐项展开。** + +## 翻译后必做:代码复现校验 + +公式语法翻译完之后,**必须**用 `doubao_code_interpreter` 在源数据上独立复现一份"等价计算结果"再写入。流程: + +1. **挑 3-5 个代表性输入行**(首行 / 中段 / 末行 / 含空值 / 含异常格式各一) +2. **用 Python 复现 Excel 原公式的语义**(不是飞书译文的语义,而是用户原本想要的结果) +3. **写入飞书译文公式后回读这几行的实际值** +4. **三方对照**:`Excel 原公式语义 == Python 复现 == 飞书译文回读值`,全部一致才交付;不一致先排查(数组语义?日期差?范围引用?) + +**理由**:Excel→飞书的语法翻译很容易在 spill / 数组 / 日期差 / 范围引用上出现等价性偏差,仅靠语法转换通过不足以保证业务结果正确。 + +## 决策流程 + +1. 最终结果是**标量**(单值)→ 通常不需要 `ARRAYFORMULA` +2. 最终结果是**一维或二维数组**: + - 公式中**包含**飞书原生数组函数(如 FILTER、XLOOKUP、MAP 等)→ 无需加 `ARRAYFORMULA`,数组语义会自动传播到整个公式,包括原生数组函数外层接的标量运算(如 `+1`、`*100`) + - 公式中**不包含**任何原生数组函数,但在对区域做标量计算 → 加 `ARRAYFORMULA(<整个表达式>)` +3. Excel 依赖 `ROW(range)` 逐项驱动 `SUBTOTAL/INDIRECT/OFFSET` → 改用 `MAP(ARRAYFORMULA(ROW(...)), LAMBDA(r, ...))` +4. 内层 `INDEX/INDIRECT/OFFSET` 返回范围,外层 `SUMIF/COUNTIF/SUMIFS` 还要继续吃这些范围 → 改用 `MAP(..., LAMBDA(...))` 或 `REDUCE(..., LAMBDA(...))` +5. 公式意图是"对多个区域分别计算再汇总"(例如用 INDIRECT/OFFSET 对每行生成一个范围,再对所有范围聚合)→ 飞书不能直接返回"区域的列表",必须明确降维:用 `VSTACK` 垂直合并、`HSTACK` 水平合并、`TOCOL/TOROW` 展平,或 `REDUCE` 归约成标量 +6. 算日期差 → 不要写 `DAY(end-start)`,用 `DAYS`、`DATEDIF` 或直接 `end-start` + +## 飞书的投影行为(不是默认 spill) + +飞书普通公式对引用区域默认"投影"而不是"spill": + +- 单列区域 → 按当前公式所在行取值 +- 单行区域 → 按当前公式所在列取值 +- 二维区域 → 只有当前公式位置能映射到该区域时才取值,否则报错 +- 数组常量 `{...}` 或函数返回矩阵,在普通标量上下文里通常只取左上角 + +因此: +- `=A1:A2` 在飞书普通公式里不会 spill,只会投影到当前行 +- `=ABS(A2:B2)` 不会得到一整行,要写 `=ARRAYFORMULA(ABS(A2:B2))` +- `=TRUNC({1.1111,2.222},{1,2})` 要得到一整行,写 `=ARRAYFORMULA(TRUNC({1.1111,2.222},{1,2}))` + +## ARRAYFORMULA 使用规则 + +**前提:以下规则适用于公式中没有任何原生数组函数的情况。** 若公式中已有原生数组函数(如 FILTER、XLOOKUP、MAP 等),数组语义会自动传播到整个公式的求值过程,后续标量运算无需额外包 `ARRAYFORMULA`(见下一节)。 + +需要加 `ARRAYFORMULA` 的典型场景(公式中无原生数组函数时): + +- 算术运算:`+ - * / ^ %` +- 比较运算:`= <> > >= < <=` +- 标量数学函数:`ABS ROUND INT TRUNC MOD LOG LN SQRT SIN COS TAN ...` +- 文本函数:`LEN LEFT RIGHT MID UPPER LOWER TRIM TEXT VALUE ...` +- 日期函数:`YEAR MONTH DAY DATE TIME EDATE EOMONTH ...` +- 条件函数:`IF IFS IFERROR IFNA NOT ISNUMBER ISTEXT ISBLANK ...` +- 引用函数(高风险):`INDEX OFFSET COLUMN ROW MATCH` + +### 公式中有原生数组函数时,整个公式已进入数组模式 + +飞书的数组语义会在整个公式求值过程中累积传播:一旦某个原生数组函数运行,后续所有运算符和函数也会自动逐元素处理,无论它们出现在哪一层。 + +因此,以下写法**无需**额外包 `ARRAYFORMULA`: + +- `=FILTER(A2:A10,B2:B10="x")+1` ✓ +- `=XLOOKUP(E2:E10,A2:A10,B2:B10)*100` ✓ +- `=ABS(FILTER(A2:A10,B2:B10>0))` ✓ +- `=MAP(A2:A10,LAMBDA(x,x*2))-1` ✓ + +对比:**没有原生数组函数**时必须加: + +- `=A2:A100*B2:B100` → `=ARRAYFORMULA(A2:A100*B2:B100)` ✓ +- `=IF(A2:A100>0,B2:B100,"")` → `=ARRAYFORMULA(IF(A2:A100>0,B2:B100,""))` ✓ + +## 飞书原生数组函数清单 + +以下函数按数组语义工作,通常**不需要额外包 `ARRAYFORMULA`**: + +`ARRAYFORMULA` `ARRAY_CONSTRAIN` `BYCOL` `BYROW` `CELL` `CHOOSECOLS` `CHOOSEROWS` `DROP` `EXPAND` `FILTER` `FLATTEN` `FREQUENCY` `GROWTH` `HSTACK` `IMPORTDATA` `IMPORTFEED` `IMPORTHTML` `IMPORTRANGE` `IMPORTXML` `LINEST` `LOGEST` `LOOKUP` `MAKEARRAY` `MAP` `MINVERSE` `MMULT` `MUNIT` `QUERY` `RANDARRAY` `REDUCE` `REGEXEXTRACT` `SCAN` `SEQUENCE` `SORT` `SORTBY` `SORTN` `SPLIT` `SUMPRODUCT` `SWITCH` `TAKE` `TEXTSPLIT` `TOCOL` `TOROW` `TRANSPOSE` `TREND` `UNIQUE` `VSTACK` `WRAPCOLS` `WRAPROWS` `XLOOKUP` + +> **注意:`SWITCH` 在飞书里被当作原生数组函数处理,这与 Excel 行为不同,不需要额外包 `ARRAYFORMULA`。** + +## INDEX / OFFSET / COLUMN / ROW / MATCH 是高风险函数 + +这组函数容易让人误以为会自动把多值铺开,但在飞书里不能这样假设。 + +**高风险信号:** + +- 行号 / 列号 / 偏移量本身是数组 +- 结果本来应该是一行或一块二维区域 +- 外层还有算术、比较、`IF` 等继续处理它 + +更稳的写法: + +- `=ARRAYFORMULA(INDEX(...))` +- `=ARRAYFORMULA(OFFSET(...))` +- `=ARRAYFORMULA(COLUMN(...))` +- `=ARRAYFORMULA(ROW(...))` + +**例外:** 如果返回值只是立刻交给聚合函数消费,不需要额外包: + +- `=SUM(INDEX(A1:B2,0,1))` ✓ + +## Excel 隐式逐项求值,飞书里要显式写 MAP + +**典型特征:** + +- 外层是 `SUMPRODUCT`、`SUM` 等聚合 +- 内层用了 `SUBTOTAL`、`INDIRECT`、`OFFSET` 等更偏"单值/单引用"的函数 +- Excel 会把中间结果逐项带进去算 +- 飞书里直接照抄,往往不能得到同样的逐项语义 + +同类本质也包括:`INDEX/INDIRECT/OFFSET` 先返回范围,外层再把这些范围交给 `SUMIF`、`COUNTIF`、`AVERAGEIF`、`SUMIFS` 等范围感知函数 —— 飞书里这些外层函数不会自动二次展开内层范围。 + +这时不要只会补 `ARRAYFORMULA`,要显式写"遍历"。最常用模板: + +```excel +=SUMPRODUCT( + MAP( + ARRAYFORMULA(ROW(目标范围)), + LAMBDA(r, 单行计算逻辑) + ) +) +``` + +同类场景也优先考虑 `MAP`: + +- `INDIRECT("A"&ROW(...))` +- `OFFSET(...,ROW(...)-ROW(...),...)` +- `SUBTOTAL(...)` +- `SUMIF(内层返回范围, ...)` +- `COUNTIF(内层返回范围, ...)` +- `SUMIFS(内层返回范围, ...)` +- 任何"希望对每一行 / 每一列各算一次"的模式 + +## 多层范围结果与三维以上结果 + +飞书公式结果只能是二维区域,不能是"数组的数组"。 + +### 多层范围不能自动二次展开 + +内层 `INDEX/INDIRECT/OFFSET` 返回的是二维范围,外层还想继续对这些范围做范围计算时,不要假设飞书会"再展开一层"。改用: + +- `MAP(..., LAMBDA(...))` 显式逐项算 +- `REDUCE(..., LAMBDA(...))` 显式累加/归约 + +### 真正的三维或更高维结果不能直接返回 + +典型触发场景:想把多个不同区域或不同条件的结果合并展示,例如: +- 对 A 列、B 列、C 列分别做 FILTER,想把三列结果并排展示 +- 对多个月份分别生成数据行,想把所有月份上下堆叠展示 + +飞书无法直接返回"多个区域的集合",必须先决定降维方式: + +- 上下堆叠:`=VSTACK(slice1, slice2, slice3)` +- 左右拼接:`=HSTACK(slice1, slice2, slice3)` +- 压成单列:`=TOCOL(...)` +- 压成单行:`=TOROW(...)` +- 只保留聚合值:`=REDUCE(slice1, {slice2,slice3}, LAMBDA(acc,x,acc+x))` + +不要替用户"偷定"第三维展示方式;如果用户没有明确说明怎么展示,至少先把结果改写成可见的二维形状。 + +## 不能机械照抄的 Excel 语法 + +### `@` 隐式交叉 + +Excel:`=@A1:A10`(强制单值,取当前行对应的值) + +飞书没有 `@` 运算符。飞书普通公式对引用区域默认就有投影语义,去掉 `@` 即可: + +- Excel: `=@A1:A10` +- 飞书: `=A1:A10` + +### `#` spill range + +Excel:`=A1#`(引用 A1 公式溢出的整片区域) + +飞书没有此语法,迁移方式: + +- spill 区域已知 → 改成明确范围 +- spill 区域未知 → 回到源公式重写,或用 `TAKE` / `DROP` / `ARRAY_CONSTRAIN` + +### 结构化引用 + +Excel:`=SUM(Table1[Amount])` + +飞书不支持结构化引用,改成显式 A1 区域:`=SUM(A2:A100)` + +### 老式 CSE 花括号 + +Excel:`{=A1:A10*B1:B10}`(Ctrl+Shift+Enter 输入) + +飞书改为:`=ARRAYFORMULA(A1:A10*B1:B10)` + +## 日期序列与日期差 + +飞书日期序列:`0 = 1899-12-30`,`1 = 1899-12-31`,没有 Excel 的 1900 年闰年兼容问题。 + +**高频错误写法(不要用):** + +- `=DAY(B2-A2)` ✗ — 差值会被当成日期序列号再拆字段 +- `=MONTH(B2-A2)` ✗ +- `=YEAR(B2-A2)` ✗ + +**正确写法:** + +- 天数差:`=DAYS(B2,A2)` 或 `=DATEDIF(A2,B2,"D")` 或 `=B2-A2` +- 月份差:`=DATEDIF(A2,B2,"M")` +- 年份差:`=DATEDIF(A2,B2,"Y")` +- 工作日差:`=NETWORKDAYS(A2,B2)` + +## 飞书不支持的 Excel 专有函数 + +以下函数在飞书里不存在,遇到时需要告知用户并提供替代方案: + +- `STOCKHISTORY` — 实时股票数据,飞书无等价函数,需手动导入数据 +- `WEBSERVICE` — 外部 HTTP 请求,飞书无等价函数 +- `CUBEVALUE`、`CUBEMEMBER`、`CUBESET` 等 — OLAP cube 函数,飞书不支持 + +## 代表性改写示例 + +- 基础逐项计算 + - Excel: `=A2:A100*B2:B100` + - 飞书: `=ARRAYFORMULA(A2:A100*B2:B100)` +- 条件判断 + - Excel: `=IF(A2:A100>0,B2:B100,"")` + - 飞书: `=ARRAYFORMULA(IF(A2:A100>0,B2:B100,""))` +- 原生数组函数(无需改动) + - Excel: `=FILTER(A2:C100,B2:B100="East")` + - 飞书: `=FILTER(A2:C100,B2:B100="East")` +- 原生数组函数 + 标量运算(无需改动,数组语义自动传播) + - Excel: `=XLOOKUP(E2:E10,A2:A10,B2:B10)*100` + - 飞书: `=XLOOKUP(E2:E10,A2:A10,B2:B10)*100` +- 高风险引用函数 + - Excel: `=INDEX(A1:D2,{2,1},0)` + - 飞书: `=ARRAYFORMULA(INDEX(A1:D2,{2,1},0))` +- 日期差 + - 错误: `=DAY(B2-A2)` + - 推荐: `=DAYS(B2,A2)` 或 `=DATEDIF(A2,B2,"D")` 或 `=B2-A2` +- Excel 隐式逐项求值 + - Excel: `=SUMPRODUCT(SUBTOTAL(103,INDIRECT("E"&ROW($E$16:$E$387))))` + - 飞书: `=SUMPRODUCT(MAP(ARRAYFORMULA(ROW($E$16:$E$387)),LAMBDA(row,SUBTOTAL(103,INDIRECT("E"&row)))))` +- 多层范围 / 二次展开 + - 错误思路: `=SUMIF(INDIRECT("E"&ROW($E$16:$E$387)),">0")` + - 飞书: `=MAP(ARRAYFORMULA(ROW($E$16:$E$387)),LAMBDA(r,SUMIF(INDIRECT("E"&r),">0")))` +- 三维降二维(保留所有层) + - 飞书: `=VSTACK(slice1,slice2,slice3)` 或 `=HSTACK(slice1,slice2,slice3)` +- 三维降二维(只保留聚合值) + - 飞书: `=REDUCE(slice1,{slice2,slice3},LAMBDA(acc,x,acc+x))` diff --git a/skills/lark-sheets/references/lark-sheets-formula.md b/skills/lark-sheets/references/lark-sheets-formula.md deleted file mode 100644 index a00460924..000000000 --- a/skills/lark-sheets/references/lark-sheets-formula.md +++ /dev/null @@ -1,88 +0,0 @@ -# 飞书表格公式规则 - -> 生成或改写飞书电子表格公式时的参考规则。飞书不像 Excel 365 默认 spill,普通公式对区域默认“投影”(只取当前行/列对应的单值),必须显式使用 `ARRAYFORMULA` 或原生数组函数才能逐项展开。 - -## 写入方式 - -公式必须使用对象格式写入(参见 SKILL.md「单元格数据类型」): - -```bash ---values '[[{"type":"formula","text":"=SUM(A1:A10)"}]]' -``` - -## ARRAYFORMULA 判断流程 - -1. 结果是**标量**(单值)→ 不需要 -2. 结果是**数组**,且公式中**有**原生数组函数 → 不需要(数组语义自动传播) -3. 结果是**数组**,且公式中**无**原生数组函数,对区域做标量计算 → 加 `ARRAYFORMULA` - -```text -# 有原生数组函数,无需包裹 -=FILTER(A2:A10,B2:B10="x")+1 ✓ -=XLOOKUP(E2:E10,A2:A10,B2:B10)*100 ✓ -=MAP(A2:A10,LAMBDA(x,x*2))-1 ✓ - -# 无原生数组函数,必须包裹 -=ARRAYFORMULA(A2:A100*B2:B100) ✓ -=ARRAYFORMULA(IF(A2:A100>0,B2:B100,""))✓ -``` - -## 原生数组函数清单(无需 ARRAYFORMULA) - -`ARRAYFORMULA` `ARRAY_CONSTRAIN` `BYCOL` `BYROW` `CELL` `CHOOSECOLS` `CHOOSEROWS` `DROP` `EXPAND` `FILTER` `FLATTEN` `FREQUENCY` `GROWTH` `HSTACK` `IMPORTDATA` `IMPORTFEED` `IMPORTHTML` `IMPORTRANGE` `IMPORTXML` `LINEST` `LOGEST` `LOOKUP` `MAKEARRAY` `MAP` `MINVERSE` `MMULT` `MUNIT` `QUERY` `RANDARRAY` `REDUCE` `REGEXEXTRACT` `SCAN` `SEQUENCE` `SORT` `SORTBY` `SORTN` `SPLIT` `SUMPRODUCT` `SWITCH` `TAKE` `TEXTSPLIT` `TOCOL` `TOROW` `TRANSPOSE` `TREND` `UNIQUE` `VSTACK` `WRAPCOLS` `WRAPROWS` `XLOOKUP` - -## 高风险函数:INDEX / OFFSET / ROW / COLUMN / MATCH - -行号/列号/偏移量本身是数组时,必须显式包裹: - -```text -=ARRAYFORMULA(INDEX(...)) -=ARRAYFORMULA(ROW(...)) -``` - -例外:结果直接交给聚合函数消费时不需要:`=SUM(INDEX(A1:B2,0,1))` ✓ - -## 隐式逐项求值 → MAP/LAMBDA - -Excel 中 `SUBTOTAL`、`INDIRECT`、`OFFSET` 等在 `SUMPRODUCT` 内会隐式逐行求值,飞书不会。用 `MAP` 显式遍历: - -```text -# Excel -=SUMPRODUCT(SUBTOTAL(103,INDIRECT("E"&ROW($E$16:$E$387)))) - -# 飞书 -=SUMPRODUCT(MAP(ARRAYFORMULA(ROW($E$16:$E$387)),LAMBDA(r,SUBTOTAL(103,INDIRECT("E"&r))))) -``` - -同类场景:`SUMIF/COUNTIF/SUMIFS` 的范围参数来自 `INDIRECT/OFFSET` 时也需要 `MAP`。 - -## 多维结果降维 - -飞书公式结果只能是二维,不能返回“区域的列表”。合并多个区域时: - -| 需求 | 写法 | -|------|------| -| 上下堆叠 | `=VSTACK(a, b, c)` | -| 左右拼接 | `=HSTACK(a, b, c)` | -| 压成单列 | `=TOCOL(...)` | -| 压成单行 | `=TOROW(...)` | -| 归约为标量 | `=REDUCE(init, arr, LAMBDA(acc, x, ...))` | - -## 日期差 - -| 需求 | 正确写法 | 错误写法 | -|------|---------|---------| -| 天数差 | `=DAYS(B2,A2)` 或 `=DATEDIF(A2,B2,"D")` 或 `=B2-A2` | `=DAY(B2-A2)` | -| 月份差 | `=DATEDIF(A2,B2,"M")` | `=MONTH(B2-A2)` | -| 年份差 | `=DATEDIF(A2,B2,"Y")` | `=YEAR(B2-A2)` | -| 工作日差 | `=NETWORKDAYS(A2,B2)` | — | - -## 飞书不支持的 Excel 语法 - -| Excel 语法 | 飞书替代 | -|-----------|---------| -| `=@A1:A10`(隐式交叉) | `=A1:A10`(飞书默认投影,去掉 `@`) | -| `=A1#`(spill range) | 改成明确范围,或用 `TAKE`/`DROP`/`ARRAY_CONSTRAIN` | -| `=SUM(Table1[Amount])`(结构化引用) | `=SUM(A2:A100)`(改为 A1 区域) | -| `{=A1:A10*B1:B10}`(CSE 花括号) | `=ARRAYFORMULA(A1:A10*B1:B10)` | -| `STOCKHISTORY` / `WEBSERVICE` / `CUBE*` | 飞书无等价函数 | diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md new file mode 100644 index 000000000..6c5f11866 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -0,0 +1,151 @@ +# Lark Sheet Pivot Table + +## 真对象硬约束 + +当用户要求"透视表 / 分组汇总 / 交叉分析 / 按 X 统计 Y"时,**必须**通过 `+pivot-{create|update|delete}` 创建真实的透视表对象。**禁止**用 `SUMIFS` / `COUNTIFS` 等普通公式 + `+cells-set` 在原表中拼一张"看起来像透视表的汇总表"来代替。判断标准:交付后 `+pivot-list` 必须能返回该对象。 + +## 使用场景 + +读写透视表对象。本 Skill 包含两个工具: + +| 操作需求 | 使用工具 | 说明 | +|---------|---------|------| +| 查看已有透视表 | `+pivot-list` | 获取透视表的结构、数据源和配置 | +| 创建/更新/删除透视表 | `+pivot-{create|update|delete}` | 对透视表执行写入操作 | + +典型工作流:先读取现有透视表了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**。 + +## 行/值字段映射(创建前必做) + +创建透视表前先识别用户需求中的分组维度和聚合指标,**不要搞反**: + +- **rows(行字段)** = 分组维度,即"按什么分组"。例:部门、地区、医生、产品类别 +- **values(值字段)** = 聚合指标,即"统计什么数值"。例:SUM(销售额)、COUNT(订单数) +- **columns(列字段)** = 交叉维度(可选),即"再按什么横向展开"。例:月份、性别 + +| 用户说 | rows | values | columns | +|--------|------|--------|---------| +| "按部门统计人数" | 部门 | COUNT(姓名) | — | +| "按医生统计费用和结余" | 主管医生 | SUM(费用), SUM(结余) | — | +| "各部门男女人数" | 部门 | COUNT(姓名) | 性别 | + +**常见配置错误(必须注意)**: +- **数据源范围必须精确**:透视表的数据源范围必须包含表头行,且精确覆盖全部数据行列。范围过大(包含空行/空列)或过小(遗漏数据列)都会导致透视表结果错误 +- **行列字段选择要匹配用户意图**:用户说"按商品统计金额"→ 行字段=商品,值字段=金额(SUM)。不要把行列字段搞反 +- **聚合类型要匹配**:用户说"统计数量"→ COUNT;"统计总额"→ SUM;"统计平均"→ AVERAGE。默认不要用 COUNT 替代 SUM +- **参数长度限制**:如果透视表配置 JSON 过长(数据源范围跨越大量行列),可能导致工具调用失败。此时应先确认数据范围的精确边界,避免传入过大的 range +- **创建后必须验证**:调用 `+pivot-list` 确认透视表结构正确 + +## Shortcuts + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 + +| MCP tool | CLI shortcut | Risk | 分组 | +| --- | --- | --- | --- | +| `get_pivot_table_objects` | `+pivot-list` | read | 对象 | +| `manage_pivot_table_object` | `+pivot-create` | write | 对象 | +| | `+pivot-update` | write | 对象 | +| | `+pivot-delete` | high-risk-write | 对象 | + +## Flags + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 + +### `+pivot-list` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--pivot-table-id` | 专有 | string | 否 | 按 id 过滤 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+pivot-create` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--data` | 专有 | string + File + Stdin | 是 | JSON:`{"data_range":"Sheet1!A1:F1000","rows":[...],"columns":[...],"values":[...],"filters":[...],"show_row_grand_total":true,"show_col_grand_total":true}` | +| `--target-sheet-id` | 专有 | string | 否 | 透视表落点子表 id;省略时自动新建子表(推荐) | +| `--target-position` | 专有 | string | 否 | 落点起始 cell(如 `A1`),默认 `A1` | +| `--dry-run` | 系统 | bool | 否 | | + +### `+pivot-update` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--pivot-table-id` | 专有 | string | 是 | 目标透视表 id | +| `--data` | 专有 | string + File + Stdin | 是 | 完整或足够完整的配置(先 `+pivot-list --pivot-table-id ` 回读再 patch) | +| `--dry-run` | 系统 | bool | 否 | | + +### `+pivot-delete` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--pivot-table-id` | 专有 | string | 是 | 目标透视表 id | +| `--yes` | 系统 | bool | 是 | `high-risk-write`,删除不可逆 | +| `--dry-run` | 系统 | bool | 否 | | + +## Schemas + +> 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 + +### `+pivot-create` `--data` / `+pivot-update` `--data` + +_创建/更新的透视表属性_ + +**顶层字段**: +- `auto_fit_col` (boolean?) — 是否自动调整列宽以适应内容 +- `calculated_fields` (array?) — 计算字段列表 each: { formula: string, name: string, summarize_by?: enum } +- `collapse` (object?) — 行字段展开/折叠状态:字段名 -> 要折叠的项目列表 +- `columns` (array?) — 横向分组字段(列字段) each: { condition_filter?: object, display_name?: string, field: string, filter?: object, group?: object, …共 6 项 } +- `filters` (array?) — 筛选区域字段(页字段) each: { condition_filter?: object, display_name?: string, field: string, filter?: object, group?: object } +- `range` (string?) — 放置透视表的左上角单元格 A1 地址(例如:'F1')(仅 create 时有效) +- `repeat_row_labels` (boolean?) — 是否显示重复项标签 +- `rows` (array?) — 纵向分组字段(行字段) each: { condition_filter?: object, display_name?: string, field: string, filter?: object, group?: object, …共 6 项 } +- `show_col_grand_total` (boolean?) — 是否显示列总计(默认 true) +- `show_row_grand_total` (boolean?) — 是否显示行总计(默认 true) +- `show_subtotals` (boolean?) — 是否显示分类小计(默认 true,应用于所有字段) +- `source` (string?) — 源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100') +- `values` (array?) — 要汇总的字段(至少需要 1 个) each: { base_field?: string, display_name?: string, field: string, show_data_as?: enum, summarize_by?: enum } + +## Examples + +> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。`+pivot-create` 默认自动新建子表存放透视表产物(推荐)。 + +### `+pivot-list` + +### `+pivot-create` + +> 数据源 `data_range` 必须从表头行开始;空行 / 汇总行会被当作数据参与聚合,需提前用 `+csv-get` 确认起止边界。 + +```bash +lark-cli sheets +pivot-create --url "..." --sheet-id "$SRC_SID" --data @pivot.json +``` + +### `+pivot-update` + +### `+pivot-delete` + +### Validate / DryRun / Execute 约束 + +- `Validate`:XOR 公共四件套;`+pivot-create` 的 `--data.data_range` 必须含表头行;`rows`/`columns`/`values` 至少非空之一;`+pivot-delete` 强制 `--yes` 或 `--dry-run`。 +- `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 pivot 请求模板"+ 预估输出尺寸(行数 × 列数)。 +- `Execute`:写后调用 `+pivot-list --pivot-table-id ` 回读 + `+csv-get` 抽样读透视产物,envelope.meta.verification 给出实际输出尺寸 + 总计行位置。 + +> ⚠️ pivot 输出包含总计 / 小计行;后续 chart 引用 pivot 时,`data_range` 必须排除这些行(见 `lark_sheet_chart` 决策段)。 diff --git a/skills/lark-sheets/references/lark-sheets-range-operations.md b/skills/lark-sheets/references/lark-sheets-range-operations.md new file mode 100644 index 000000000..0ffc81bd2 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-range-operations.md @@ -0,0 +1,279 @@ +# Lark Sheet Range Operations + +## 结构性操作影响面预检(清除 / 合并 / 排序 / 移动前必做) + +`+cells-clear`、`+cells-{merge|unmerge}`、`+range-{move|copy|fill|sort}`(移动 / 复制 / 排序 / 自动填充)都会让既有引用关系发生偏移或失效。**操作前必须**先确认以下两点;否则禁止执行: + +1. **打印当前合并单元格 + 公式引用 + 数据验证范围**:用 `+sheet-info` + `+cells-get` 抽样目标区域和它周边的公式 / 透视表 / 图表 / 条件格式 / 筛选器的数据源;评估操作后这些引用是否仍指向正确数据。 +2. **`+cells-clear` 不得侵入用户授权范围之外**:清除范围只能是用户明示要清的区域;不要顺手清除"看起来没用"的相邻单元格。 + +排序场景的存储类型识别 + 辅助列抽数值的细则见下方「sort 操作前必读」章节。 + +## 使用场景 + +写入。对指定区域执行结构性操作。本 Skill 包含四个工具: + +| 操作需求 | 使用工具 | 说明 | +|---------|---------|------| +| 清除内容/格式 | `+cells-clear` | "清空"、"删除内容"、"去掉格式" | +| 合并/取消合并单元格 | `+cells-{merge|unmerge}` | "合并单元格"、"取消合并" | +| 调整行高/列宽 | `+dim-resize` | "加宽列"、"调整行高"、"自适应列宽" | +| 移动/复制/填充/排序 | `+range-{move|copy|fill|sort}` | "移动数据"、"复制到"、"自动填充"、"按某列排序" | + +注意: + +- 用户说"这行 / 整行 / 首行"时,优先使用整行范围如 `1:1`;"这列 / 整列"时使用 `J:J`。不要截断为局部矩形 +- 合并后只保留左上角单元格的内容,其余清除。写入合并区域用 `+cells-set` 对左上角单元格操作 +- 调整行高列宽时,先读取相邻行列尺寸再决定像素值,不要随意猜测 +- `copy_to_range`(`+cells-set` 的参数)复制的是值/公式/样式,不含行高列宽。需要统一尺寸时另行调用 `+dim-resize` + +## 写入后列宽自适应(防内容遮挡) + +写入文本 / 数值后**必须**主动检查列宽是否适配,否则会出现"内容被截断 / 长数字显示为科学计数法 / 文本溢出被相邻列遮挡"等用户感知问题: + +1. **写入后回读最长内容字符数**:用 `+csv-get` 读目标列的实际写入内容,统计最长单元格的字符数(`max(len(cell) for cell in col)`)。汉字按 2 字符宽度估算,半角字母数字按 1 字符。 +2. **判定阈值**:当前列宽(用 `get_sheet_structure --info_type=row_heights_column_widths` 拿)≥ 最长字符数 × 字体宽度系数 + buffer 才算适配。默认列宽 11 通常只够 11 个半角字符或 5-6 个汉字,写长文本前必扩宽。 +3. **修复二选一**: + - **扩列宽**:用 `+dim-resize` 把目标列宽设为 `max(表头字符数, 内容采样最长字符数) × 8 + 16` 像素(经验值) + - **自动换行**:在 `+cells-set` 时给单元格设置 `cell_styles.word_wrap="auto-wrap"`(可选值:`overflow` / `auto-wrap` / `word-clip`),并用 `+dim-resize` 调高对应行的行高 +4. **新增列默认列宽规则**:新增列宽度 ≥ `max(表头字符数, 内容采样最长字符数) × 8 + 16` 像素,**禁止**用默认 11 直接交付。 + +**典型反例**:默认列宽 11 但内容含 12+ 字符的中文 / 含单位的数值(如 `109.10μmol/L`)/ 长数字未设 `number_format` 显示为科学计数法 —— 用户在结果表里看不到完整原值。 + +**⚠️ 合并单元格安全操作规则**(`+cells-{merge|unmerge}` 必读): + +1. **先读后写**:操作前必须用 `+sheet-info`(`info_type: merged_cells_infos`)或 `+cells-get` 识别已有合并区域(特征:多个连续单元格中只有左上角有值,其余为空)。 +2. **不要对已合并区域重复 merge**:对已合并的区域再次调用 merge 会报错或产生不可预期结果。 +3. **修改合并区域的正确顺序**:先 `unmerge` → 修改内容/样式 → 再 `merge`。 +4. **对合并区域设置样式**:只对完整 range 设置一次 `cell_styles`(写在左上角单元格),其余位置用 `{}` 占位。 +5. **新增合并时数据保护**:合并前确认目标区域只有左上角有数据,其余单元格为空,否则合并会导致非左上角的数据丢失。 +6. **批量取消合并一次调用即可**:当一个范围(整列 `A:A`、整行 `3:3`、矩形 `A1:D100`)内存在多个合并区域,直接调一次 `+cells-{merge|unmerge}(operation: unmerge)` 传入这个大范围,会一次性取消该范围内所有合并区域;**不要**为每个合并区域单独调用 unmerge,也不要用 `+batch-update` 拆成多次 unmerge。 + +**⚠️ 批量操作必须用 `+batch-update`**: + +当需要对**多个**不同区域执行 `+cells-{merge|unmerge}`(merge)或 `+dim-resize` 时,**禁止逐个调用**,必须使用 `+batch-update`(参见 `lark-sheets-batch-update` skill)将所有操作合并为一次请求。逐个调用会快速耗尽工具调用轮次上限。 + +**例外**:`+cells-{merge|unmerge}(operation: unmerge)` 原生支持对覆盖多个合并区域的大 range 一次性取消,应直接单次调用,**不要**拆进 `+batch-update`。 + +示例:需要合并 A1:A3、B1:B3、C1:C3 三个区域时,应使用: +```json +{ + "excel_id": "${excel_id}", + "operations": [ + {"tool_name": "merge_cells", "input": {"sheet_id": "${sheet_id}", "range": "A1:A3", "operation": "merge"}}, + {"tool_name": "merge_cells", "input": {"sheet_id": "${sheet_id}", "range": "B1:B3", "operation": "merge"}}, + {"tool_name": "merge_cells", "input": {"sheet_id": "${sheet_id}", "range": "C1:C3", "operation": "merge"}} + ] +} +``` +而不是分三次单独调用 `+cells-{merge|unmerge}`。 + +示例:需要将 A、B、C 三列列宽设为 120px,同时将第 1-3 行行高设为 40px 时,应使用: +```json +{ + "excel_id": "${excel_id}", + "operations": [ + {"tool_name": "resize_range", "input": {"sheet_id": "${sheet_id}", "range": "A:A", "resize_width": {"type": "pixel", "value": 120}}}, + {"tool_name": "resize_range", "input": {"sheet_id": "${sheet_id}", "range": "B:B", "resize_width": {"type": "pixel", "value": 120}}}, + {"tool_name": "resize_range", "input": {"sheet_id": "${sheet_id}", "range": "C:C", "resize_width": {"type": "pixel", "value": 120}}}, + {"tool_name": "resize_range", "input": {"sheet_id": "${sheet_id}", "range": "1:3", "resize_height": {"type": "pixel", "value": 40}}} + ] +} +``` +而不是分四次单独调用 `+dim-resize`。 + +**⚠️ sort 操作前必读:确认目标列的数据类型** + +排序按单元格的**存储类型**比较:纯数字按数值排序;文本字符串按**字典序**(`"1000"` 排在 `"999"` 之前,与数值相反);日期按时间戳排序。 + +以下形态**看起来像数字但实际是字符串**,直接 sort 会得到错误结果: + +| 示例 | 说明 | +|------|------| +| `843688.69+20042.35=863731.04` | 表达式文本(无前导 `=` 不是公式,整串按字典序比较) | +| `¥1,234.56` / `$1,234` | 带货币符号 | +| `1.2万` / `3.5亿` / `100kg` | 带中文 / 英文单位 | +| 前后含空格或不可见字符的数字串 | 被当文本 | +| 同列混文本和数字 | 排序后分块 | + +**硬性流程**: + +1. sort 前先用 `+csv-get` 抽样目标列的前 3–5 行,或用 `+cells-get`(`value_render_option: "raw_value"` 看原始值;默认 `formatted_value` 返回显示值)确认原始值形态,不要只看列名和用户问题就直接排。 +2. 若是纯数字或日期 → 直接 sort。 +3. 若是带符号 / 表达式 / 单位的文本 → **不要直接排**: + - 简单场景(货币、千分位、单位前缀):新增辅助列,用公式提取数值(如 `=VALUE(SUBSTITUTE(SUBSTITUTE(A2,"¥",""),",",""))`),按辅助列排序,排完可按需清除辅助列。 + - 复杂场景(多段表达式、中文单位、混合格式):`export_sheet_to_sandbox` + `doubao_code_interpreter` 在沙箱里按数值排序后 `+cells-set` 回写。 + +## Shortcuts + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 + +| MCP tool | CLI shortcut | Risk | 分组 | +| --- | --- | --- | --- | +| `clear_cell_range` | `+cells-clear` | high-risk-write | 单元格 | +| `merge_cells` | `+cells-merge` | write | 单元格 | +| | `+cells-unmerge` | write | 单元格 | +| `resize_range` | `+dim-resize` | write | 工作表 | +| `transform_range` | `+range-move` | write | 区域 | +| | `+range-copy` | write | 区域 | +| | `+range-fill` | write | 区域 | +| | `+range-sort` | write | 区域 | + +## Flags + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 + +### `+cells-clear` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--range` | 专有 | string | 是 | 清除范围 A1 格式 | +| `--scope` | 专有 | string + Enum | 否 | `content` / `formats` / `all`,默认 `content`(仅清内容) | +| `--yes` | 系统 | bool | 是 | `high-risk-write`,清除不可逆 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+cells-merge` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--range` | 专有 | string | 是 | 待合并 / 取消合并的范围 | +| `--merge-type` | 专有 | string + Enum | 否 | (仅 `+cells-merge`)`all` / `rows` / `columns`,默认 `all` | +| `--dry-run` | 系统 | bool | 否 | | + +### `+cells-unmerge` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--range` | 专有 | string | 是 | 待合并 / 取消合并的范围 | +| `--merge-type` | 专有 | string + Enum | 否 | (仅 `+cells-merge`)`all` / `rows` / `columns`,默认 `all` | +| `--dry-run` | 系统 | bool | 否 | | + +### `+dim-resize` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--dimension` | 专有 | string + Enum | 是 | `row` / `column` | +| `--start` | 专有 | int | 是 | 范围 | +| `--end` | 专有 | int | 是 | 范围 | +| `--size` | 专有 | int | 否 | 像素值 | +| `--reset` | 专有 | bool | 否 | 还原默认 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+range-move` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--source-range` | 专有 | string | 是 | 源 A1 范围 | +| `--target-sheet-id` | 专有 | string | 否 | 目标子表;省略时同 sheet | +| `--target-range` | 专有 | string | 是 | 目标 A1 范围(起点 cell 即可,按源尺寸自动推断) | +| `--paste-type` | 专有 | string + Enum | 否 | (仅 `+range-copy`)`values` / `formulas` / `formats` / `all`,默认 `all` | +| `--dry-run` | 系统 | bool | 否 | | + +### `+range-copy` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--source-range` | 专有 | string | 是 | 源 A1 范围 | +| `--target-sheet-id` | 专有 | string | 否 | 目标子表;省略时同 sheet | +| `--target-range` | 专有 | string | 是 | 目标 A1 范围(起点 cell 即可,按源尺寸自动推断) | +| `--paste-type` | 专有 | string + Enum | 否 | (仅 `+range-copy`)`values` / `formulas` / `formats` / `all`,默认 `all` | +| `--dry-run` | 系统 | bool | 否 | | + +### `+range-fill` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--source-range` | 专有 | string | 是 | 填充模板范围(系列起始 cells) | +| `--target-range` | 专有 | string | 是 | 目标填充范围 | +| `--series-type` | 专有 | string + Enum | 否 | `auto` / `linear` / `growth` / `date` / `copy`,默认 `auto` | +| `--dry-run` | 系统 | bool | 否 | | + +### `+range-sort` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--range` | 专有 | string | 是 | 排序范围(含或不含表头由 `--has-header` 决定) | +| `--sort-keys` | 专有 | string + File + Stdin | 是 | JSON:`[{"col":"B","order":"asc"},{"col":"D","order":"desc"}]` | +| `--has-header` | 专有 | bool | 否 | 第一行是表头不参与排序,默认 false | +| `--dry-run` | 系统 | bool | 否 | | + +## Schemas + +> 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 + +### `+range-sort` `--sort-keys` + +_排序条件列表(仅 sort 操作)_ + +**数组项**(类型 object): +- `ascending` (boolean) — 是否升序排序 +- `column` (string) — 排序依据的列字母(如 "C"、"D"),必须在 range 范围内 + +## Examples + +> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 + +> ⚠️ 本 skill 派生的 7 条 shortcut 跨 3 个分组:`+dim-resize` → 工作表,`+cells-*` → 单元格,`+range-*` → 区域。skill 视角统一在这里讲解。 + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。 + +### `+cells-clear` + +```bash +# dry-run 先看 +lark-cli sheets +cells-clear --url "..." --sheet-id "$SID" --range "A2:Z1000" --scope all --dry-run +# 执行 +lark-cli sheets +cells-clear --url "..." --sheet-id "$SID" --range "A2:Z1000" --scope all --yes +``` + +### `+cells-merge` / `+cells-unmerge` + +### `+dim-resize` + +> 同时出现在 `lark_sheet_sheet_structure/cli-shortcuts.md` —— 行高 / 列宽调整也算行列结构层动作。 + +### `+range-move` / `+range-copy` + +> `+range-move` 会**清空源区域**(move = copy + clear_source);`+range-copy` 不动源。 + +### `+range-fill` + +### `+range-sort` + +### Validate / DryRun / Execute 约束 + +- `Validate`:XOR 公共四件套;`+cells-clear` 强制 `--yes` 或 `--dry-run`;`+range-*` 校验源 / 目标 range 在同一 spreadsheet;`+range-sort` 的 `--sort-keys` 必须合法 JSON 数组且 col 都在 `--range` 内。 +- `DryRun`:所有写操作输出"将要 PATCH 的 range + 受影响 cell 数估算"。 +- `Execute`:写后调用 `+cells-get --ranges <影响范围>` 抽样回读对比,envelope.meta.verification 沉淀对比结果。 diff --git a/skills/lark-sheets/references/lark-sheets-read-data.md b/skills/lark-sheets/references/lark-sheets-read-data.md new file mode 100644 index 000000000..2a0366392 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-read-data.md @@ -0,0 +1,165 @@ +# Lark Sheet Read Data + +> ⚠️ **沙箱类工具在 CLI 中不存在**:`export_sheet_to_sandbox` 是 sheet-ai-skills 侧(AI/MCP 消费方)的沙箱 IO 工具,本 reference 中提到它们的段落对 CLI 不适用。CLI 处理大数据请走 `+csv-get --max-rows N` 分页读取 + 本地 Python 处理;写回用 `+csv-put` 或 `+cells-set`。 + +## 列格式多样性预探(写公式 / 排序 / 筛选前必做) + +> 对应 `lark-sheets-core-operations` 的 **R3 计算复现**——本节是 R3 在 read_data 工具层的具体落地。 + +对参与后续**计算 / 排序 / 筛选 / 公式提取**的列,**必须**先 sample **至少 50 行**(小表则全量),识别该列所有值类型变体后再设计公式 / 条件。只看前 10 行不够,因为下列差异通常潜伏在表尾或中段: + +- **日期列同时出现多种格式**:`YYYYMM`、`YYYY-MM-DD`、`YYYY/M/D`、带时间戳、文本"未知" +- **数值列混入公式文本 / 单位 / 注释**:`1000+200=1200`、`100元`、`/(合同未明确)`、`#N/A` +- **空值与 0 / "0" 混杂** +- **大小写 / 全角半角差异**("办公费" vs "办公费 "、"Sales" vs "sales") + +预探后必须在公式 / 筛选条件里用 `IFERROR` / `IFS` / 提取数值的辅助列处理所有变体;不能为了通过 head(10) 的样本就直接落地。一旦设计的逻辑只覆盖 sample 中出现的格式,就属于违规。 + +## 使用场景 + +读取。从飞书表格中读取单元格数据。本 skill 包含三个工具,根据读取目的选择: + +| 读取目的 | 使用工具 | 数据去向 | 说明 | +|---------|---------|---------|------| +| 数据分析、清洗、可视化、大数据集 | `export_sheet_to_sandbox` | 沙箱文件系统(不进 context) | **数据分析首选**。导出为 CSV 到沙箱,模型写 Python 代码读取分析。仅文件路径和元信息占用 token | +| 快速查看少量数据、简单问答 | `+csv-get` | 对话上下文 | 返回 CSV 文本,适合几十行以内的快速查看 | +| 查看公式、样式、批注、数据验证 | `+cells-get` | 对话上下文 | 返回单元格完整信息,token 开销较大 | + +**选择原则**: +- 数据分析场景(统计、聚合、清洗、画图)→ 优先使用 `export_sheet_to_sandbox`,数据不进 context,在沙箱中用 pandas 处理 +- 快速查看少量数据 → 使用 `+csv-get` +- 需要公式/样式/批注 → 使用 `+cells-get` + +**`+csv-get` 返回值核心设计**: +- `annotated_csv` — **CSV 数据唯一入口**。每一逻辑行前加 `[row=N] ` 前缀(N = 真实表格行号)。任何需要行号的下游操作(合并、写入、清空、格式化、插入/删除、条件格式、筛选、图表/透视表范围、搜索替换等),**行号一律直接从 `[row=N]` 读取**。若需要纯 CSV(如喂给 pandas 做 context 内解析),去前缀即可:`line.replace(/^\[row=\d+\] /, '')`——但大数据集走 `export_sheet_to_sandbox`,不要用本工具。 +- `col_indices` — **定位列字母唯一入口**。在表头中找到目标字段是第 j 个(0-based),用 `col_indices[j]` 取列字母。**禁止手数逗号**——列数超过 10 时极易 off-by-one(例如把 W 误判为 X)。 +- `row_indices` — 程序化引用的备用数组。LLM 推理请用 `annotated_csv` 的前缀,不要查这个数组里的 index(把行号当数值用容易心算出错)。 +- `current_region` — 从请求范围扩展到被空行空列包围的连续数据区域(等价于 Excel Ctrl+Shift+*),适合先读少量行探表头、同时获知整表实际范围。 + +注意: + +- `+csv-get` 和 `+cells-get` 支持分页/截断,注意检查 `has_more` / `truncated` 标志;使用 `+cells-get` 时,在读取 `cells` 之前还必须先看 `warning_message`,并用每个 range 的 `actual_range` / `row_indices` / `col_indices` 判断真实位置 +- 隐藏行列默认包含在返回结果中(`skip_hidden=false`),如需只看可见数据设为 `true` + +**常见配置错误(必须注意)**: +- **全量读取导致上下文溢出(高频致命错误)**:不要对大表(数百行以上)直接用 `+csv-get` 或 `+cells-get` 读取全部数据到上下文。大表场景必须使用 `export_sheet_to_sandbox` 导出到沙箱后用 Python 处理,或分批读取:`+csv-get` 控制 `max_rows` / `max_chars`,`+cells-get` 控制 `ranges` / `cell_limit` / `max_chars` +- **了解结构 ≠ 读取全量数据**:探表不用读全表,但必须同时探两个方向的表头: + - **横向(列头)**:先读前几行,且**列范围必须覆盖所有列**——用 `+workbook-info` 拿总列数,`range` 末列填到最后一列(例如总列数是 N,则 `range: "A1:[列N]10"`)。列范围截短会遗漏右侧字段、后续写入列定位错误。 + - **纵向(行标)**:若左侧 1-2 列是行标签(日期/类别/编号枚举每行含义,典型交叉表/透视布局),**必须再读 `A:A` 或 `A:B` 把行标列读到底**,拿全部行标。只读前几行会看不全表尾的行,导致批量写入漏改——这是"只改前 N 行、其余未更新"的主要成因。扁平列表(每行独立记录、列是字段)可跳过这一步,但仍要靠 `current_region` 兜底。 + - 数据量大或会进入上下文上限时,直接走 `export_sheet_to_sandbox`,不要用 `+csv-get` 翻页硬塞。 +- **`+cells-get` 滥用**:当只需要数据值时,使用 `+csv-get`(token 开销约为 `+cells-get` 的 1/5)。只有确实需要公式、样式或批注时才用 `+cells-get` +- **忽略分页标志**:读取返回 `has_more=true` 时,说明还有更多数据。如果任务需要完整数据,必须继续分页读取,不能只处理第一页就开始写入 +- **直接按 `+cells-get` 返回二维数组下标推导真实位置(高频错误)**:`ranges[n].cells[i][j]` 里的 `i/j` 只是返回数组下标,不等于真实表格行列。定位真实行号必须用 `ranges[n].row_indices[i]`,定位真实列字母必须用 `ranges[n].col_indices[j]`;若 `skip_hidden=true`、请求范围越界被裁剪,或最后一行是部分返回,错误地自己数下标会立刻错位 +- **CSV 行号计数错误(高频致命错误)**:`+csv-get` 返回的 CSV 遵循 RFC 4180 标准,被双引号 `"..."` 包裹的字段中的换行符属于**字段内容的一部分**(即单元格内换行),不代表新的一行。计算行号时必须按**逻辑记录**计数,而非按物理换行符 `\n` 计数 +- **手动数列确定列号(高频致命错误)**:禁止通过在 CSV 表头中手动数逗号/字段来确定目标列的列字母。当列数超过 10 时,手动计数极易产生 off-by-one 偏移(例如把 W 列误判为 X 列)。**必须使用 `col_indices`**:先在 CSV 表头中找到目标字段名是第 j 个字段(0-based),再用 `col_indices[j]` 获取该列的实际列字母 +- **用数据列的值推导行号(高频致命错误,常被巧合掩盖)**:CSV 中常见"序号 / ID / 编号 / No."等形似行号的列,其值与实际表格行号**没有任何绑定关系**——序号可能跳号(1,2,3,5,6...)、可能从非 1 开始、可能有重复或被中途重置。此规则适用于**所有需要行号的下游操作**:合并单元格、区间写入/清空/格式化、插入/删除行、条件格式范围、筛选器范围、图表数据源、透视表范围、搜索替换范围等等——**凡是要把行号填进任何工具参数的场景,行号一律从 `annotated_csv` 中目标行开头的 `[row=N]` 前缀直接读取**,禁止用"序号=行号"、"表头占 1 行所以数据从第 2 行开始"、"第 N 个序号就在第 N+1 行"等心算,也禁止先心算再"事后核对"。**危险特征**:前几十行中序号恰好等于表格行号(典型成因:表头 +1 与一次跳号 -1 的偏移互相抵消形成巧合),模型一旦把这个巧合当作规律,会在后续所有行沿用;而中间再出现跳号时,从该行起整块区域全部错位,且错位不自查很难发现。**正确工作流**:①在 `annotated_csv` 里定位目标逻辑行(按字段内容匹配);②直接读取该行开头的 `[row=N]` 前缀得到真实表格行号;③把这个行号填进下游工具参数。区间操作时,起始行用 start 行的 `[row=N]`、结束行用 end 行的 `[row=N]`。**自检**:动手前,在 `annotated_csv` 靠后位置再抽 1~2 行,核对 `[row=N]` 是否与首列"序号"一致——不一致(典型:`[row=57] 58,...`)即说明有跳号/隐藏行,更要严格从 `[row=N]` 取值,不要被序号列迷惑 +- **current_region 当作纯数据范围(高频致命错误)**:`current_region` 返回的是从请求范围向四周扩展到被空行空列包围的**连续非空区域**,等价于 Excel 的 Ctrl+Shift+\*。它包含该区域内**所有非空行**——不仅包含数据行,还可能包含标题行、汇总行(如"总计")、签名行(如"编制人/审批人")、脚注等非数据内容。**严禁直接将 `current_region` 的末尾行作为数据范围的结束行**。正确做法见下方「确定数据范围的正确流程」 + +### 确定数据范围的正确流程(排序、筛选、批量写入等操作前必做) + +当后续操作需要精确的数据范围(如排序、筛选、删除、批量写入)时,仅靠 `current_region` 探测到的范围是不够的——必须同时确认数据的**起始行**和**结束行**。具体步骤: + +1. **确认起始行**:读取前 5~10 行,识别表头行位置,数据起始行 = 表头行 + 1 +2. **确认结束行**(关键步骤,不可跳过):读取 `current_region` 末尾附近的若干行(建议读取末尾 5~10 行),逐行检查内容,排除非数据行: + - **汇总行**:内容为"合计"、"总计"、"小计"、"总计:"等 + - **签名/审批行**:内容为"编制人"、"审核人"、"部门负责人"等 + - **空行或分隔行**:整行为空或仅有边框 + - **备注/脚注行**:注释性文字、说明文字等 +3. **最终数据范围** = 起始行 ~ 最后一条有效数据行(排除非数据行) + +**示例**:`current_region` 返回 `A1:N51`,读取 Row 48~51 发现: + +- Row 49: 序号=47, 姓名=xxx, 有正常数据 → ✅ 数据行 +- Row 50: "总计", 有合并单元格 → ❌ 汇总行 +- Row 51: "总经理:...", "编制人:..." → ❌ 签名行 +- **正确数据范围 = A3:N49**(而非 A3:N51) + +## Shortcuts + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 + +| MCP tool | CLI shortcut | Risk | 分组 | +| --- | --- | --- | --- | +| `export_sheet_to_sandbox` | _Sheet Tool 独有,CLI 不实现_ | — | — | +| `get_cell_ranges` | `+cells-get` | read | 单元格 | +| `get_range_as_csv` | `+csv-get` | read | 单元格 | + +## Flags + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 + +### `+cells-get` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--ranges` | 专有 | string_array | 是 | 多 range,可重复:`--ranges A1:B2 --ranges D1:E5` | +| `--include` | 专有 | string_slice + Enum | 否 | `value` / `formula` / `style` / `comment` / `data_validation`,逗号拆分 | +| `--cell-limit` | 专有 | int + Hidden | 否 | 防爆,默认 5000 | +| `--max-chars` | 专有 | int + Hidden | 否 | 防爆,默认 200000 | +| `--skip-hidden` | 专有 | bool | 否 | 同上 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+csv-get` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称 | +| `--range` | 专有 | string | 否 | A1 格式范围;省略时读整表的 `current_region` | +| `--value-render-option` | 专有 | string + Enum | 否 | `ToString` / `FormattedValue` / `Formula` / `UnformattedValue` | +| `--max-rows` | 专有 | int + Hidden | 否 | 防爆,默认 100000 | +| `--max-chars` | 专有 | int + Hidden | 否 | 防爆,默认 200000 | +| `--include-row-prefix` | 专有 | bool | 否 | 是否在每行前加 `[row=N]` 前缀,默认 `true` | +| `--skip-hidden` | 专有 | bool | 否 | 跳过隐藏行列,默认 `false` | +| `--dry-run` | 系统 | bool | 否 | 仅打印请求路径与参数,不执行 | + +## Examples + +> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 + +### `+csv-get` + +公共四件套:`--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(前两者 XOR,后两者 XOR)。 + +示例: + +```bash +# 简单读 +lark-cli sheets +csv-get --url "https://example.feishu.cn/sheets/shtXXX" --range "Sheet1!A1:F30" + +# 用 sheet-name 模糊定位(运行时框架会先解析到 sheet-id) +lark-cli sheets +csv-get --spreadsheet-token shtXXX --sheet-name "销售明细" --range "A1:F30" +``` + +输出契约(envelope.data): + +- `annotated_csv` — 含 `[row=N]` 前缀的 CSV 主入口 +- `col_indices` / `row_indices` — 列字母 / 行号映射数组 +- `current_region` — 自动扩展到非空连续区域的 A1 范围 +- `has_more` — 是否截断;截断后续读用 `--range` 接着读 + +### `+cells-get` + +示例: + +```bash +# 读 A1:F10 的公式 + 样式 +lark-cli sheets +cells-get --url "https://example.feishu.cn/sheets/shtXXX" \ + --ranges "Sheet1!A1:F10" --include formula,style +``` + +> ⚠️ 调用方在 `cells[i][j]` 中**不能**用下标推真实行列:必须读 `ranges[n].row_indices[i]` / `ranges[n].col_indices[j]`。 + +### Validate / DryRun / Execute 约束 + +- `Validate` 阶段只做 XOR 检查、Enum 合法性、防爆参数上限校验;**禁止**联网(如不能用 `--sheet-name` 提前去查 `sheet-id`)。 +- `DryRun` 输出请求模板:`--sheet-name` 在 dry-run 输出里生成为 `` 占位符,不实际解析。 +- `Execute` 阶段才进行 sheet-name → sheet-id 解析与 API 调用。 + +> `export_sheet_to_sandbox` 是 Sheet Tool 独有的沙箱导出工具,CLI 不提供等价 shortcut(见 `## Shortcuts` 段标注)。 diff --git a/skills/lark-sheets/references/lark-sheets-row-column-management.md b/skills/lark-sheets/references/lark-sheets-row-column-management.md deleted file mode 100644 index 1c4d553a6..000000000 --- a/skills/lark-sheets/references/lark-sheets-row-column-management.md +++ /dev/null @@ -1,151 +0,0 @@ -# Sheets Row and Column Management - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -这份 reference 汇总行列结构操作: - -- `+add-dimension` -- `+insert-dimension` -- `+update-dimension` -- `+move-dimension` -- `+delete-dimension` - - -## `+add-dimension` - -对应命令:`lark-cli sheets +add-dimension` - -在工作表末尾追加空行或空列,不影响已有数据。 - -```bash -lark-cli sheets +add-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --dimension ROWS --length 10 -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--dimension` | 是 | `ROWS` 或 `COLUMNS` | -| `--length` | 是 | 追加数量(1-5000) | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出:`addCount`、`majorDimension` - - -## `+insert-dimension` - -对应命令:`lark-cli sheets +insert-dimension` - -在指定位置插入空行或空列,已有数据向下或向右移动。 - -```bash -lark-cli sheets +insert-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --dimension ROWS --start-index 3 --end-index 7 -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--dimension` | 是 | `ROWS` 或 `COLUMNS` | -| `--start-index` | 是 | 起始位置(0-indexed) | -| `--end-index` | 是 | 结束位置(0-indexed,不含) | -| `--inherit-style` | 否 | `BEFORE` 或 `AFTER` | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出:成功时 `data` 为空对象 `{}` - - -## `+update-dimension` - -对应命令:`lark-cli sheets +update-dimension` - -更新指定范围行/列的显隐状态和行高/列宽。 - -```bash -lark-cli sheets +update-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --dimension ROWS --start-index 1 --end-index 3 \ - --visible=false -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--dimension` | 是 | `ROWS` 或 `COLUMNS` | -| `--start-index` | 是 | 起始位置(**1-indexed**,含) | -| `--end-index` | 是 | 结束位置(**1-indexed**,含) | -| `--visible` | 否 | `--visible=true` 或 `--visible=false` | -| `--fixed-size` | 否 | 行高或列宽(像素) | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出:成功时 `data` 为空对象 `{}` - - -## `+move-dimension` - -对应命令:`lark-cli sheets +move-dimension` - -将指定范围的行/列移动到目标位置。 - -```bash -lark-cli sheets +move-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --dimension ROWS \ - --start-index 0 --end-index 1 --destination-index 4 -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--dimension` | 是 | `ROWS` 或 `COLUMNS` | -| `--start-index` | 是 | 源起始位置(0-indexed) | -| `--end-index` | 是 | 源结束位置(0-indexed,含) | -| `--destination-index` | 是 | 目标位置(0-indexed) | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出:成功时 `data` 为空对象 `{}` - - -## `+delete-dimension` - -对应命令:`lark-cli sheets +delete-dimension` - -删除指定范围的行或列。 - -```bash -lark-cli sheets +delete-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --dimension ROWS --start-index 3 --end-index 7 -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--dimension` | 是 | `ROWS` 或 `COLUMNS` | -| `--start-index` | 是 | 起始位置(**1-indexed**,含) | -| `--end-index` | 是 | 结束位置(**1-indexed**,含) | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出:`delCount`、`majorDimension` - -## 参考 - -- [spreadsheet-management](lark-sheets-spreadsheet-management.md#info) — 查看当前工作表信息 -- [cell-style-and-merge](lark-sheets-cell-style-and-merge.md) — 调整样式或合并单元格 diff --git a/skills/lark-sheets/references/lark-sheets-search-replace.md b/skills/lark-sheets/references/lark-sheets-search-replace.md new file mode 100644 index 000000000..84bf056ed --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-search-replace.md @@ -0,0 +1,117 @@ +# Lark Sheet Search & Replace + +## 替换前 dry-run + 范围明确(替换前必做) + +`+cells-replace` 的副作用是不可逆的(除非另写代码回滚)。执行前必须: + +1. **明确替换范围**:必须显式说明"只替换 X 列 / X 区域,还是全表替换"。**禁止**默认全表替换——容易误改无关列。范围应由用户指令决定,模糊时主动询问。 +2. **dry-run 命中数量**:先用 `+cells-search` 在同一范围、同一关键词、同一匹配选项(大小写 / 精确 / 正则)下统计命中数量。把数量和**期望命中数**(用户明示的或基于业务理解推断的)对照——一致才进入 `+cells-replace`,不一致先排查(关键词太宽?范围太大?)。 +3. **替换后回读校验**:执行后再次 `+cells-search` 旧关键词,预期为 0;并对替换后的若干代表性单元格回读确认值符合预期。 + +## 使用场景 + +读写。在飞书表格中搜索和替换文本。本 Skill 包含两个工具: + +| 操作需求 | 使用工具 | 说明 | +|---------|---------|------| +| 搜索/定位文本 | `+cells-search` | 返回匹配的单元格位置,支持正则、精确匹配等 | +| 查找并替换文本 | `+cells-replace` | 批量替换文本,支持正则捕获组引用 | + +**常见配置错误(必须注意)**: +- **不要把操作动词当搜索词**:用户说"汇总金额"是一个操作动作(求和),不是要搜索"汇总金额"这个文本。只有当确实需要定位某个文本值的位置时才用 `+cells-search` +- **不要用搜索来了解表格结构**:要了解表头和数据结构时,应使用 `+csv-get` 读取前几行,而不是用 `+cells-search` 逐个猜测字段名 +- **注意正则特殊字符**:使用正则匹配时,`.`、`*`、`(`、`)` 等特殊字符需要转义 + +## Shortcuts + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 + +| MCP tool | CLI shortcut | Risk | 分组 | +| --- | --- | --- | --- | +| `search_data` | `+cells-search` | read | 单元格 | +| `replace_data` | `+cells-replace` | write | 单元格 | + +## Flags + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 + +### `+cells-search` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--find` | 专有 | string | 是 | 待查找文本(与 `--regex` 配合时是正则) | +| `--range` | 专有 | string | 否 | A1 格式查找范围;省略时整表 | +| `--match-case` | 专有 | bool | 否 | 大小写敏感 | +| `--match-entire-cell` | 专有 | bool | 否 | 完全匹配整个单元格 | +| `--regex` | 专有 | bool | 否 | `--find` 当正则解释 | +| `--include-formulas` | 专有 | bool | 否 | 也在公式文本中搜索 | +| `--max-matches` | 专有 | int + Hidden | 否 | 防爆,默认 5000 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+cells-replace` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--find` | 专有 | string | 是 | 待替换文本 | +| `--replacement` | 专有 | string | 是 | 替换为;传 `""` 等价于"删除内容" | +| `--range` | 专有 | string | 否 | 范围;省略时整表 | +| `--match-case` | 专有 | bool | 否 | 同 `+cells-search` | +| `--match-entire-cell` | 专有 | bool | 否 | 同 `+cells-search` | +| `--regex` | 专有 | bool | 否 | 同 `+cells-search` | +| `--include-formulas` | 专有 | bool | 否 | 同 `+cells-search` | +| `--dry-run` | 系统 | bool | 否 | 必跑:输出 `would_replace_count` 让用户先确认 | + +## Examples + +> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR 规则)。 + +### `+cells-search` + +示例: + +```bash +# 普通查找 +lark-cli sheets +cells-search --url "https://example.feishu.cn/sheets/shtXXX" \ + --sheet-name "Sheet1" --find "张三" + +# 正则 + 范围限定 +lark-cli sheets +cells-search --spreadsheet-token shtXXX --sheet-id "$SID" \ + --find "^[A-Z]{2}-\\d{4}$" --regex --range "A2:A1000" +``` + +输出契约(envelope.data): + +- `matched_cells` — 命中 cell 列表,每条含 `cell`(A1)+ `value` + `sheet_id` +- `total_matches` — 匹配总数 + +### `+cells-replace` + +示例: + +```bash +# 先 dry-run 预览 +lark-cli sheets +cells-replace --url "https://example.feishu.cn/sheets/shtXXX" \ + --sheet-name "Sheet1" --find "v1" --replacement "v2" --dry-run + +# 确认后执行 +lark-cli sheets +cells-replace --url "https://example.feishu.cn/sheets/shtXXX" \ + --sheet-name "Sheet1" --find "v1" --replacement "v2" +``` + +> `+cells-replace` 虽然 Risk = write,但范围大或正则错可能改一堆。**强烈推荐工作流**:先 `+cells-search` 看匹配数,再 `+cells-replace --dry-run` 预览,最后真正执行。 + +### Validate / DryRun / Execute 约束 + +- `Validate`:XOR 公共四件套;`--find` 非空;正则模式下 `--find` 必须是合法正则。 +- `DryRun`:`+cells-search` 输出请求模板;`+cells-replace` 额外返回预估替换数(`would_replace_count`)。 +- `Execute`:写后自动回读匹配范围抽样验证,`envelope.meta.verification` 给出"预估替换数 vs 实际替换数"对比。 diff --git a/skills/lark-sheets/references/lark-sheets-sheet-management.md b/skills/lark-sheets/references/lark-sheets-sheet-management.md deleted file mode 100644 index 089059c91..000000000 --- a/skills/lark-sheets/references/lark-sheets-sheet-management.md +++ /dev/null @@ -1,164 +0,0 @@ -# Sheets Sheet Management - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -这份 reference 汇总工作表级操作: - -- `+create-sheet` -- `+copy-sheet` -- `+delete-sheet` -- `+update-sheet` - -其中 `+create-sheet` / `+copy-sheet` / `+delete-sheet` 底层封装官方“操作工作表(operate-sheets)”接口;`+update-sheet` 封装“更新工作表属性”接口。 - - -## `+create-sheet` - -对应命令:`lark-cli sheets +create-sheet` - -```bash -# 在表格末尾或服务端默认位置创建工作表 -lark-cli sheets +create-sheet --spreadsheet-token "shtxxxxxxxx" \ - --title "明细" - -# 指定插入位置(0-based) -lark-cli sheets +create-sheet --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --title "汇总" --index 0 -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--title` | 否 | 工作表标题,最长 100 字符,不能包含 `/ \ ? * [ ] :` | -| `--index` | 否 | 工作表位置(从 0 开始) | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `spreadsheet_token` -- `sheet.sheet_id` -- `sheet.title` -- `sheet.index` - - -## `+copy-sheet` - -对应命令:`lark-cli sheets +copy-sheet` - -```bash -# 按默认位置复制 -lark-cli sheets +copy-sheet --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" - -# 指定副本名称和位置 -lark-cli sheets +copy-sheet --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "" --title "销售副本" --index 2 -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 源工作表 ID | -| `--title` | 否 | 新工作表标题,最长 100 字符,不能包含 `/ \ ? * [ ] :` | -| `--index` | 否 | 新工作表位置(从 0 开始) | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -说明: - -- 传 `--index` 时,CLI 会先复制,再追加一次位置更新,把副本移动到目标索引 - -输出: - -- `spreadsheet_token` -- `sheet.sheet_id` -- `sheet.title` -- `sheet.index` - - -## `+delete-sheet` - -对应命令:`lark-cli sheets +delete-sheet` - -> [!CAUTION] -> 这是**高风险删除操作**。CLI 会要求显式确认;可以先用 `--dry-run` 预览。 - -```bash -lark-cli sheets +delete-sheet --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 要删除的工作表 ID | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `deleted` -- `spreadsheet_token` -- `sheet_id` - - -## `+update-sheet` - -对应命令:`lark-cli sheets +update-sheet` - -用于更新工作表标题、位置、隐藏状态、冻结行列和保护设置。 - -```bash -# 改名 + 调整冻结 -lark-cli sheets +update-sheet --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --title "汇总表" --frozen-row-count 2 --frozen-col-count 1 - -# 隐藏工作表 -lark-cli sheets +update-sheet --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "" --hidden=true - -# 开启保护并授权额外编辑人 -lark-cli sheets +update-sheet --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --lock LOCK --lock-info "仅财务维护" \ - --user-id-type open_id --user-ids '["ou_xxx","ou_yyy"]' -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 要更新的工作表 ID | -| `--title` | 否 | 新标题,最长 100 字符,不能包含 `/ \ ? * [ ] :` | -| `--index` | 否 | 新位置(从 0 开始) | -| `--hidden` | 否 | `--hidden=true` 隐藏,`--hidden=false` 取消隐藏 | -| `--frozen-row-count` | 否 | 冻结行数,`0` 表示取消冻结 | -| `--frozen-col-count` | 否 | 冻结列数,`0` 表示取消冻结 | -| `--lock` | 否 | 保护模式:`LOCK` / `UNLOCK` | -| `--lock-info` | 否 | 保护备注;要求 `--lock LOCK` | -| `--user-id-type` | 否 | `--user-ids` 的 ID 类型:`open_id` / `union_id` / `lark_id` / `user_id` | -| `--user-ids` | 否 | 额外可编辑用户 ID 的 JSON 数组;要求 `--lock LOCK` | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `spreadsheet_token` -- `sheet.sheet_id` -- `sheet.title` -- `sheet.hidden` -- `sheet.grid_properties.frozen_row_count` -- `sheet.grid_properties.frozen_column_count` -- `sheet.protect` - -## 参考 - -- [spreadsheet-management](lark-sheets-spreadsheet-management.md#info) — 先获取 `sheet_id` -- [row-column-management](lark-sheets-row-column-management.md) — 需要改行列结构时用这组命令 diff --git a/skills/lark-sheets/references/lark-sheets-sheet-structure.md b/skills/lark-sheets/references/lark-sheets-sheet-structure.md new file mode 100644 index 000000000..85a25ffdd --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-sheet-structure.md @@ -0,0 +1,215 @@ +# Lark Sheet Sheet Structure + +## 结构性操作影响面预检(插入 / 删除行列前必做) + +插入 / 删除行列、隐藏 / 取消隐藏、冻结、行列分组都会让原表的引用关系发生偏移。**操作前必须**先打印以下三类信息,并评估操作是否会让它们失效;否则禁止执行: + +1. **当前合并单元格范围**(来自 `+sheet-info` 的 `merged_cells`):插入行 / 列时,跨过插入位置的合并区域可能扩张或断裂;删除行 / 列时合并区域可能直接消失。 +2. **现有公式的引用范围**(用 `+cells-get` 抽样附近行 + 跨表引用 + 透视表 / 图表 / 条件格式 / 筛选器的数据源 range):插入 / 删除会导致 `=SUM(B4:B13)` 这种相对引用偏移;如果操作发生在引用范围内部,可能产生 `#REF!`。 +3. **数据验证(下拉列表)规则的应用范围**:列表来源是某个区域时,区域被部分删除会让规则失效。 + +不可逆的影响必须先在回复中告知用户,得到确认再执行。 + +## 使用场景 + +读写。管理子表结构与布局。本 Skill 包含两个工具: + +| 操作需求 | 使用工具 | 说明 | +|---------|---------|------| +| 查看子表布局 | `+sheet-info` | 获取行高、列宽、隐藏行列、行列分组、合并单元格等信息 | +| 变更子表结构 | `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` | 插入/删除/隐藏/取消隐藏/冻结行列、行列分组操作 | + +注意: + +- 当表格存在合并单元格时,应结合返回的 `merged_cells` 判断表头、分组标题和区域语义 +- 不要把合并区域中非左上角的空白单元格理解为"无内容";通常应将左上角单元格的内容视为整个合并区域的语义内容 +- 当前插入语义使用 `operation="insert"` + `position` + `count` + `side` +- 处理"在第 N 行后追加"这类请求时,要显式区分 `before` 和 `after`,避免 off-by-one +- 例如"在第 20 行后新增 116 行",应优先理解为 `position="20"`、`side="after"`、`count=116` + +**常见配置错误(必须注意)**: +- **插入列位置偏移**:插入列时 `position` 是基于 0 的列索引,不是列字母。插入前先通过 `+workbook-info` 或读取表头确认目标位置的实际列索引,不要凭猜测 +- **插入后引用偏移**:插入行/列后,原有数据的行列号会发生偏移。如果插入后还需要对原有区域执行写入操作,必须重新计算偏移后的行列号 +- **删除行列前先确认范围**:删除操作不可逆,执行前应确认 `position` 和 `count` 精确无误。可先用 `+csv-get` 读取目标区域验证内容 +- **"在左侧新增一列"的正确写法**:用户说"在 D 列左侧新增一列"时,应使用 `position` 对应 D 列索引 + `side="before"`,而不是 C 列 + `side="after"`(两者效果一样但前者语义更清晰) +- **插入列后必须检查多行表头合并区域**:很多表格有 2-3 行的合并表头。插入列后,原有的合并区域不会自动扩展到新列。必须先用 `+sheet-info`(`info_type: merged_cells_infos`)读取合并区域,插入后将跨越插入位置的合并区域重新设置(用 `+cells-{merge|unmerge}`),否则新列的表头会是空的、格式不连续 +- **公式写入范围跳过表头行**:写入公式时从数据行开始(不是第 1 行)。先确认表头占几行(可能 1-3 行),公式的起始行 = 表头行数 + 1 + +## Shortcuts + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 + +| MCP tool | CLI shortcut | Risk | 分组 | +| --- | --- | --- | --- | +| `get_sheet_structure` | `+sheet-info` | read | 工作表 | +| `modify_sheet_structure` | `+dim-insert` | write | 工作表 | +| | `+dim-delete` | high-risk-write | 工作表 | +| | `+dim-hide` | write | 工作表 | +| | `+dim-unhide` | write | 工作表 | +| | `+dim-freeze` | write | 工作表 | +| | `+dim-group` | write | 工作表 | +| | `+dim-ungroup` | write | 工作表 | +| `move_dimension` | `+dim-move` | write | 工作表 | + +## Flags + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 + +### `+sheet-info` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--include` | 专有 | string_slice + Enum | 否 | `merges` / `row_heights` / `col_widths` / `hidden_rows` / `hidden_cols` / `groups` / `frozen`,逗号拆分 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+dim-insert` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--dimension` | 专有 | string + Enum | 是 | `row` / `column` | +| `--start` | 专有 | int | 是 | 插入起始位置(0-based) | +| `--end` | 专有 | int | 是 | 插入结束位置(exclusive) | +| `--inherit-style` | 专有 | string + Enum | 否 | `before` / `after` / `none`;默认 `none` | +| `--dry-run` | 系统 | bool | 否 | | + +### `+dim-delete` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--dimension` | 专有 | string + Enum | 是 | `row` / `column` | +| `--start` | 专有 | int | 是 | 起始(0-based) | +| `--end` | 专有 | int | 是 | 结束(exclusive) | +| `--yes` | 系统 | bool | 是 | `high-risk-write`,删除行/列不可逆 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+dim-hide` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--dimension` | 专有 | string + Enum | 是 | `row` / `column` | +| `--start` | 专有 | int | 是 | 范围 | +| `--end` | 专有 | int | 是 | 范围 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+dim-unhide` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--dimension` | 专有 | string + Enum | 是 | `row` / `column` | +| `--start` | 专有 | int | 是 | 范围 | +| `--end` | 专有 | int | 是 | 范围 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+dim-freeze` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--dimension` | 专有 | string + Enum | 是 | `row` / `column` | +| `--count` | 专有 | int | 是 | 冻结前 N 行/列;传 0 解除冻结 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+dim-group` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--dimension` | 专有 | string + Enum | 是 | `row` / `column` | +| `--start` | 专有 | int | 是 | 范围 | +| `--end` | 专有 | int | 是 | 范围 | +| `--depth` | 专有 | int | 否 | 嵌套层级(`+dim-group` 用),默认 1 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+dim-ungroup` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--dimension` | 专有 | string + Enum | 是 | `row` / `column` | +| `--start` | 专有 | int | 是 | 范围 | +| `--end` | 专有 | int | 是 | 范围 | +| `--depth` | 专有 | int | 否 | 嵌套层级(`+dim-group` 用),默认 1 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+dim-move` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--dimension` | 专有 | string + Enum | 是 | `row` / `column` | +| `--start` | 专有 | int | 是 | 源起始位置(0-indexed,inclusive) | +| `--end` | 专有 | int | 是 | 源结束位置(0-indexed,inclusive) | +| `--target` | 专有 | int | 是 | 目标位置(move 到该 index 前面,0-indexed) | +| `--dry-run` | 系统 | bool | 否 | | + +## Examples + +> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。 + +### `+sheet-info` + +输出契约:返回子表的行高 / 列宽 / 隐藏 / 合并 / 分组等布局元信息。 + +### `+dim-insert` + +示例: + +```bash +# 在第 10 行前插 3 行,继承上方样式 +lark-cli sheets +dim-insert --url "https://example.feishu.cn/sheets/shtXXX" \ + --sheet-id "$SID" --dimension row --start 10 --end 13 --inherit-style before +``` + +### `+dim-delete` + +### `+dim-hide` / `+dim-unhide` + +### `+dim-resize` + +> ⚠️ 该 shortcut 来自 `lark_sheet_range_operations` 的 `resize_range` tool(分组在"工作表"是为了发现性)。详细参数也在 `lark_sheet_range_operations/cli-shortcuts.md` 出现。 + +### `+dim-freeze` + +### `+dim-group` / `+dim-ungroup`(大纲) + +> 仅当用户明确说"行分组 / 列分组 / 大纲 / outline"时触发;按字段做数据分组用 `+pivot-create`。 + +### Validate / DryRun / Execute 约束 + +- `Validate`:XOR 公共四件套;`--start < --end`;`+dim-delete` 强制 `--yes` 或 `--dry-run`;`+dim-resize` 必须 `--size` 或 `--reset` 至少一个。 +- `DryRun`:写操作输出"将要 PATCH 的 dimension 区间 + 目标参数"。 +- `Execute`:写后自动调用 `+sheet-info --include row_heights,col_widths,hidden_rows,hidden_cols,groups,frozen` 回读对比,envelope.meta.verification 给出受影响的范围。 diff --git a/skills/lark-sheets/references/lark-sheets-sparkline.md b/skills/lark-sheets/references/lark-sheets-sparkline.md new file mode 100644 index 000000000..c856a5f8e --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-sparkline.md @@ -0,0 +1,120 @@ +# Lark Sheet Sparkline + +## 真对象硬约束 + +当用户要求"迷你图 / 趋势线 / 单元格内图表"时,**必须**通过 `+sparkline-{create|update|delete}` 创建真实的迷你图对象。**禁止**用文本字符(如 `▁▂▃▅▇`)拼接在单元格里、或用 `SPARKLINE()` 公式函数(已禁用)代替。判断标准:交付后 `+sparkline-list` 必须能返回该对象。 + +## 使用场景 + +读写迷你图对象。本 Skill 包含两个工具: + +| 操作需求 | 使用工具 | 说明 | +|---------|---------|------| +| 查看已有迷你图 | `+sparkline-list` | 获取迷你图的类型、数据源和样式配置 | +| 创建/更新/删除迷你图 | `+sparkline-{create|update|delete}` | 对迷你图执行写入操作 | + +典型工作流:先读取现有迷你图了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**。 + +**常见配置错误(必须注意)**: +- **数据源范围要精确**:迷你图的数据源范围必须与实际数据行列精确对应,范围偏移会导致图形展示错误 +- **不要与 SPARKLINE() 公式混淆**:飞书表格的 `SPARKLINE()` 公式函数已被禁用,迷你图只能通过本 Skill 的对象方式创建 +- **创建后必须验证**:调用 `+sparkline-list` 确认迷你图配置正确 + +## Shortcuts + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 + +| MCP tool | CLI shortcut | Risk | 分组 | +| --- | --- | --- | --- | +| `get_sparkline_objects` | `+sparkline-list` | read | 对象 | +| `manage_sparkline_object` | `+sparkline-create` | write | 对象 | +| | `+sparkline-update` | write | 对象 | +| | `+sparkline-delete` | high-risk-write | 对象 | + +## Flags + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 + +### `+sparkline-list` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--group-id` | 专有 | string | 否 | 按 group_id 过滤 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+sparkline-create` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--data` | 专有 | string + File + Stdin | 是 | JSON:`{"type":"line\\ | +| `--dry-run` | 系统 | bool | 否 | | + +### `+sparkline-update` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--group-id` | 专有 | string | 是 | 目标组 id | +| `--data` | 专有 | string + File + Stdin | 是 | 完整或足够完整的配置(先 `+sparkline-list --group-id ` 回读再 patch) | +| `--dry-run` | 系统 | bool | 否 | | + +### `+sparkline-delete` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--group-id` | 专有 | string | 是 | 目标组 id | +| `--yes` | 系统 | bool | 是 | `high-risk-write`,删除不可逆 | +| `--dry-run` | 系统 | bool | 否 | | + +## Schemas + +> 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 + +### `+sparkline-create` `--data` / `+sparkline-update` `--data` + +_创建/更新/部分删除的迷你图属性_ + +**顶层字段**: +- `config` (object?) — 迷你图样式配置, 相同 groupId 的迷你图共享相同的样式 { axis?: object, contain_hidden_cells?: boolean, empty_show_as?: enum, extremum_max?: object, extremum_min?: object, …共 13 项 } +- `sparklines` (array?) — 迷你图项列表 each: { position?: object, source?: string, source_range?: object, sparkline_id?: string } + +## Examples + +> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。迷你图按 `group_id` 管理——一组同形态的迷你图共享类型 / 样式 / 数据源映射。注意:不等同于已禁用的 `SPARKLINE()` 公式函数。 + +### `+sparkline-list` + +### `+sparkline-create` + +> `data_range` 是每个迷你图的数据序列;`target_range` 是迷你图生成的目标 cells(通常每个 cell 一个迷你图)。 + +```bash +lark-cli sheets +sparkline-create --url "..." --sheet-id "$SID" --data @sparkline.json +``` + +### `+sparkline-update` + +### `+sparkline-delete` + +### Validate / DryRun / Execute 约束 + +- `Validate`:XOR 公共四件套;`--data.type` 必须命中 enum;`--data.data_range` 与 `--data.target_range` 行/列数需对齐;`+sparkline-delete` 强制 `--yes` 或 `--dry-run`。 +- `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 sparkline group 请求模板"。 +- `Execute`:写后调用 `+sparkline-list --group-id ` 回读,envelope.meta.verification 给出 type / style / 生成范围对比。 diff --git a/skills/lark-sheets/references/lark-sheets-spreadsheet-management.md b/skills/lark-sheets/references/lark-sheets-spreadsheet-management.md deleted file mode 100644 index b6bfe591e..000000000 --- a/skills/lark-sheets/references/lark-sheets-spreadsheet-management.md +++ /dev/null @@ -1,140 +0,0 @@ -# Sheets Spreadsheet Management - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -这份 reference 汇总电子表格对象级操作: - -- `+create`:创建电子表格 -- `+info`:查看电子表格元信息和工作表列表 -- `+export`:导出电子表格 - - -## `+create` - -对应命令:`lark-cli sheets +create` - -特性: - -- 一步创建表格并返回 URL -- 可选 `--headers/--data` 在创建后自动写入第一个工作表的 A1 开始 -- `--as bot` 创建成功后,CLI 会尝试为当前 CLI 用户自动授予 `full_access` - -```bash -# 只创建表格 -lark-cli sheets +create --title "仓库管理营收报表" - -# 创建并写入表头 + 初始数据 -lark-cli sheets +create --title "仓库管理营收报表" \ - --headers '["仓库","统计月份","入库金额","出库金额","销售收入","毛利率"]' \ - --data '[["华东一仓","2026-03",125000,98000,168000,"41.7%"]]' - -# 创建到指定文件夹 -lark-cli sheets +create --title "测试表" --folder-token "fldbc_xxx" - -# 仅预览请求 -lark-cli sheets +create --title "测试表" --dry-run -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--title` | 是 | 表格标题 | -| `--folder-token` | 否 | 创建到指定文件夹 | -| `--headers` | 否 | 一维数组 JSON,作为表头写入 | -| `--data` | 否 | 二维数组 JSON,作为初始数据写入 | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `spreadsheet_token` -- `title` -- `url` -- `permission_grant`(仅 `--as bot` 时返回) - - -## `+info` - -对应命令:`lark-cli sheets +info` - -用于: - -- 从表格 URL / token 获取 `spreadsheet_token` -- 获取电子表格标题、URL、所有者等元信息 -- 列出工作表的 `sheet_id`、标题、行列数、冻结状态等信息 - -权限说明: - -- 该 shortcut 声明了 `sheets:spreadsheet.meta:read` 和 `sheets:spreadsheet:read`,本地 scope preflight 要求两者同时满足 -- `spreadsheet` 元信息来自 `spreadsheets/:token` 查询,工作表列表来自额外的 `spreadsheets/:token/sheets/query` 查询 - -```bash -# 传 URL(支持 wiki URL) -lark-cli sheets +info --url "https://example.larksuite.com/sheets/shtxxxxxxxx" - -# 传 spreadsheet_token -lark-cli sheets +info --spreadsheet-token "shtxxxxxxxx" - -# 仅预览请求 -lark-cli sheets +info --url "https://..." --dry-run -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一;支持 wiki URL) | -| `--spreadsheet-token` | 否 | 电子表格 token | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `spreadsheet.spreadsheet.token` -- `spreadsheet.spreadsheet.url` -- `sheets.sheets[]` - - -## `+export` - -对应命令:`lark-cli sheets +export` - -特性: - -- 创建导出任务并轮询完成 -- 支持导出 `xlsx` 或 `csv` -- 提供 `--output-path` 时自动下载,否则只返回 `file_token` - -```bash -# 导出 xlsx 并保存到本地 -lark-cli sheets +export --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --file-extension xlsx --output-path "./report.xlsx" - -# 导出 csv(必须指定 sheet-id) -lark-cli sheets +export --spreadsheet-token "shtxxxxxxxx" \ - --file-extension csv --sheet-id "" --output-path "./report.csv" - -# 只返回导出文件 token -lark-cli sheets +export --spreadsheet-token "shtxxxxxxxx" --file-extension xlsx -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 电子表格 token | -| `--file-extension` | 是 | `xlsx` 或 `csv` | -| `--sheet-id` | 否 | 导出 `csv` 时必填 | -| `--output-path` | 否 | 保存到本地的路径 | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- 提供 `--output-path`:`saved_path`、`file_name`、`file_size` -- 不提供 `--output-path`:`file_token`、`file_name`、`file_size` - -## 参考 - -- [sheet-management](lark-sheets-sheet-management.md) — 管理工作表 -- [cell-data](lark-sheets-cell-data.md) — 读写单元格数据 -- [float-images](lark-sheets-float-images.md) — 上传和管理浮动图片 diff --git a/skills/lark-sheets/references/lark-sheets-visual-standards.md b/skills/lark-sheets/references/lark-sheets-visual-standards.md new file mode 100644 index 000000000..73c54e9e5 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-visual-standards.md @@ -0,0 +1,201 @@ +# 飞书表格样式与配色规范 + +## 最高优先级原则 + +- **用户指令优先**:用户明确提出的格式要求(如"使用红色背景")具有最高权重,即使与通用审美冲突。 +- **继承原表风格**:编辑前先采样原文件视觉特征(色系、边框、对齐、数字格式),新增内容必须与之对齐。严禁对已有风格的文件强行施加通用标准化格式。 +- **扩展而非覆盖**:新增行列或追加数据时,目标是"扩展原模板"——继承邻近区域的表头风格、条纹节奏、边框层级、对齐方式、数字格式和列宽/行高策略。 +- **美化只动样式属性,不动数据**:对**已有区域**做美化时,**只能**修改 `font` / `fill` / `border` / `alignment` / `number_format` 这 5 类样式属性。**禁止**改动原始单元格的 `value` / `formula`、合并区域、行列结构、Sheet 名称。如果美化需求需要改变数据布局(例如"汇总行加进表里"),必须把"加汇总行"和"美化"拆成两步,前者属于编辑动作、需另行得到用户授权。 +- **不可见视觉属性也属保护对象**:原表的**合并范围、对齐方式(H-Align/V-Align)、行高列宽、数字格式**是用户能感知但不一定会明示的视觉属性。即使用户没说"保留这些",**禁止**因写入新内容而修改它们;写公式 / 写值 / 写新列时只传 `value` / `formula`,不要重置 `alignment` / `number_format` 等字段为默认值(重置等同于改动)。**例外**:用户明示要修改这些属性时(如"调整对齐 / 合并 / 列宽")才能动。 +- **美化范围必须覆盖所有用户语义目标**:用户说"给表格加边框 / 美化整个表"时,范围 = 实际数据区域**含所有数据行**(含汇总行、总计行、表尾备注行),不能停在"看起来主体内容结束"的地方。落地前先用 `current_region` + 末尾 5~10 行核对真实末行(同 `lark-sheets-read-data` 的「确定数据范围的正确流程」),再设置美化范围。范围漏掉用户提到的目标行 / 列**直接判失败**。 + +## 美化任务 5 维度 checklist(用户说"美化 / 整理 / 让表更清晰 / 适合打印"时必做) + +当用户用"美化 / 整理表格 / 让表清晰 / 适合打印 / 调整样式"等口语表达**主动美化需求**时,**必须**遍历以下 5 个维度逐一落地,**只动一处就交付**(如只加边框)属于违规: + +1. **表头格式区分**:表头行加粗 + 背景色填充(与数据行有色差)+ 居中对齐;多行表头时全部行同步处理 +2. **对齐方式**:文本列左对齐、数值 / 货币 / 百分比列右对齐、日期 / 分类列居中;垂直方向统一居中 +3. **数值格式**:每列统一小数位 + 千分位(用 `number_format`);金额列统一货币符号;同一列内**禁止**出现 0 位 / 1 位 / 2 位小数混杂 +4. **边框**:覆盖范围按上方「美化范围必须覆盖所有用户语义目标」规则(含汇总 / 总计 / 表尾说明行),内外框线清晰 +5. **列宽 + 行高 + 自动换行**:详细规则见 `lark-sheets-range-operations` 的「写入后列宽自适应」章节(按最长字符数扩列宽 / 长文本设置 `wrap=true` + 调高行高 / 长数字设置 `number_format` 防科学计数法) + +**差异化标注场景**:用户要求"重复行 / 异常值 / 重要项视觉区分"时,标注列 / 行必须设置与普通数据**显著不同**的 `cell_styles`(背景色 + 加粗 + 字体色至少改一项),不能与普通数据格式完全一致。 + +## 通用样式规范 + +### 1. 表头样式 + +- 表头/汇总行须与数据区域有明确视觉区分。 +- 使用低饱和度背景色搭配字体颜色(如深蓝 + 白字,浅蓝 + 黑字),文字加粗、水平居中。 +- 表头覆盖多列时使用合并单元格。 + +### 2. 数据区域样式 + +- 减少垂直线条,优先使用水平浅灰细线。 +- **对齐方式**:文本左对齐,数值/货币/百分比右对齐,日期或分类居中,所有内容垂直居中。 +- 次要信息(备注、次要日期等)使用缩小字号或浅灰色。 +- **Zebra Stripes**:数据行 > 10 行时可使用交替背景色引导视线。 + - 设置前先清理原区域背景色为白色(#FFFFFF),再设置斑马纹色,避免新旧混杂。 + - 优先直接设置单元格背景色,而非条件格式(除非用户要求)。 + - 推荐配色:奇数行 #FFFFFF,偶数行 #F3F4F6 或 #EBF1F8。 + +### 3. 数值格式 + +- 百分比使用 `%` 符号,适当注明单位和货币符号(¥、$)。 +- 大于 1000 的数字使用千分位符,保留一致的小数位数(1–2 位)。 +- 涉及数据检索的须注明数据来源。 +- 可使用数据条/色阶/条件格式增强可视化。 + +### 4. 整体结构 + +- 长表/宽表考虑冻结行列,方便滚动查看。 +- **长文本处理**:启用自动换行,行高合理调整以确保阅读舒适,添加适当垂直留白,目标是清晰、专业、不拥挤的布局。 +- 保持表格简洁,合理分组(可用合并单元格展示分组),在适当位置添加合计或汇总行。 +- **区域分隔**:多阶段或多类别时,使用柔和背景色块进行逻辑分区,而非简单边框。 +- **增删行列的样式规则**: + - 新增整列继承同组列的表头样式、列宽、对齐和数字格式;新增整行继承同层级数据行或汇总行风格,避免写成表头风格。追加列时需判断是否应加入已有合并单元格(常见于顶部标题行)。 + - 若追加位置紧邻汇总行、说明区或空白分隔区,先判断真实数据区域边界再操作,避免破坏原有结构。 + - **Zebra Stripes 维护**:插入或删除行后若影响后续行奇偶性,须从受影响行往后重建条纹(先清理再重设)。少量增删用局部重建,大量变动用全局清理+统一重建。 + - 具体采样与复制流程见下方「场景二:从已有区域继承美化」。 +- **列宽调整**(飞书 `+dim-resize` 按 pixel 传值): + - 禁止硬编码固定列宽,须根据该列实际内容长度估算像素。 + - 经验估算:中文每字约 15-18px,英文/数字每字约 7-9px,外加 10-16px padding。 + - 上下限建议 80~400px;超上限启用自动换行(`word_wrap: auto-wrap`)+ 调整行高,而非无限加宽。 + - 合并单元格不参与列宽计算,避免撑宽单列。 + - 复制自原文件的列优先沿用原列宽,不重新计算覆盖。 + +### 5. 配色 + +- 优先沿用原文件配色。 +- 背景填充选择柔和色(如浅蓝 `#DDEBF7`),区分颜色时优先同一主题色不同深浅,避免超过 3 种主题色。 +- 新增区域禁止凭空换色,须沿用原表色板与明暗层级,确保视觉连续。 + +### 6. 图表展示 + +- 遵循用户指令选择图表类型,或匹配用户意图(饼图/环形图 → 占比,折线图 → 趋势)。 +- 包含必要元素:标题、图例、数据标签、坐标轴标题。 +- 调整至合适大小,避免数据和标签过多堆叠。 +- **图表放置防重叠**:新增图表前须计算放置区域,避免与已有图表重叠。具体步骤: + 1. 调用 `+chart-list` 获取当前工作表所有已有图表的 `position`(锚点单元格:`row` 行索引、`col` 列索引如 "A"/"B")、`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. 为新图表选定大小后,候选放置位置应避开所有已有矩形区域;若存在重叠则向下或向右偏移,直至找到无冲突位置。 + 5. 若工作表已无足够空间,优先向下方空白区域放置,保持图表间至少 1 行或 1 列的间距。 + +> 飞书表格中颜色需带 `#` 前缀(如 `#0070C0`),与 openpyxl 的无前缀写法不同。 +> 具体工具调用参数格式,请读取对应工具 skill(`lark-sheets-write-cells`、`lark-sheets-conditional-format`、`lark-sheets-range-operations` 等)。 + +--- + +## 场景化操作指南 + +### 场景一:新增独立样式 + +> 适用情况:在表格中创建全新的、具有独立视觉特征的区域,如汇总行、新表头、独立数据表等。 + +#### 1A. 添加汇总行 / 表头行 + +**决策流程:** +1. 先用 `+cells-get` 读取目标位置上方的数据区域,确认数据边界和已有样式(背景色、字体大小等) +2. 如果需要新增空行,先用 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` 插入行 +3. 用 `+cells-set` 写入汇总公式 + 特殊样式(背景色区分 + 加粗 + 边框) +4. 如果汇总行标题需要跨列显示,追加 `+cells-{merge|unmerge}` 合并标题区域 + +**样式要点:** +- 汇总行使用比数据区域更深的同色系背景(如数据区 #EBF1F8 → 汇总行 #D6E4F0 或 #4472C4 + 白字) +- 必须加粗,水平对齐方式与数据列一致(数值列右对齐,文本列左对齐) +- 上方加一条较粗的边框线,与数据区域形成视觉分隔 + +#### 1B. 添加独立数据表/独立区域 + +**决策流程:** + +1. 新建 sheet,或用 `+cells-get` 或 `+workbook-info` 确认已有表格的占用范围,找到空闲区域 +2. 用 `+cells-get` 采样已有表格的表头样式(背景色、字体大小、字重、对齐方式)和数据区域样式 +3. 新表头复用已有表头的配色和字体参数(保持风格统一),但内容和列宽可独立 +4. 新数据区域复用已有数据区域的对齐规则、边框风格、数字格式 +5. 用 `+cells-set` 一次性写入新表头 + 数据 + +**样式要点:** +- 必须复用:背景色色系、字体大小、字重、边框风格 +- 可以独立:列宽、行高、具体数字格式(根据新数据的类型调整) +- 新旧表格之间至少留 1~2 行空白作为视觉分隔 + +### 场景二:从已有区域继承美化 + +> 适用情况:新增的行/列/区域与已有内容性质相同(数据类型、层级一致),需要无缝衔接已有格式。 + +#### 2A. 继续补充行/列(数据性质与已有内容一致) + +**核心规则**:采样紧邻 2 行样式 → 延续 Zebra Stripes 奇偶性 → 写入时原样应用 `cell_styles` + `border_styles`。 + +**关键样式要点**: + +- 至少读 2 行(末行 + 倒数第二行)才能判断是否有斑马纹交替色 +- 若倒数两行背景色不同(如 #FFFFFF 与 #F3F4F6),新行按奇偶延续,不要固定一个色 +- `border_styles` 最易遗漏(四边都要复制),否则新行会缺框线 + +> 具体采样调用与工作流见 `lark-sheets-read-data` 的「格式采样工作流(新增行时继承原有格式)」章节。 + +#### 2B. 基于模板区域的修改(copy 保留所有格式) + +**核心思路:三步分层法** + +``` +Step 1 — 格式铺开:batch_update + transform_range(copy/fill) + └── 将模板行/区域的 **全部格式**(样式、边框、数字格式、数据验证等)复制到目标区域 + └── 推荐传 paste_type: "format_only"(仅复制格式,目标值/公式保留),即"格式刷" + └── 若需连带公式平移填充(如公式列结构一致),改用 fill(copyCells) 或 copy 默认的 paste_type: "all" + +Step 2 — 内容覆写:batch_update + set_cell_range(仅传 value/formula,不传任何样式) + └── 将每行的实际数据写入,cell_styles 全部省略,因为格式已在 Step 1 中就位 + +Step 3 — 微调收尾:batch_update + resize_range / merge_cells 等 + └── 调整行高列宽、处理合并单元格、扩展条件格式范围等边缘情况 +``` + +**关键注意事项:** +- Step 1 用 `paste_type: "format_only"` 时,目标区域的值/公式不会被覆盖,Step 2 的 `+cells-set` 无需 `allow_overwrite: true`;若 Step 1 用默认 `all` 连带复制了值/公式,则 Step 2 需要 `allow_overwrite: true` +- `+range-{move|copy|fill|sort}(fill)` 的 `fillSeries` 模式会自动递增数字序列(1→2→3)和日期序列,`copyCells` 则原样复制值但公式引用会自动平移 +- 如果模板区域存在合并单元格,copy/fill 不会复制合并状态,必须在 Step 3 中用 `+cells-{merge|unmerge}` 补全 +- 如果模板区域有条件格式,需要在 Step 3 中通过 `+cond-{format-create|format-update|format-delete}(update)` 扩展 ranges + +**场景:纯"格式刷"(用户说"把 A 列样式应用到 B 列"、"格式复制过去"、"只刷格式不改数据")** + +单步即可,无需三步分层:调用 `+range-copy(operation=copy, paste_type="format_only")`,源区域为样式来源,`destination_range` 为目标起点。参数细节见 `lark-sheets-range-operations`。 + +### 场景三:已有区域格式美化 + +> 适用情况:对已存在数据的区域进行格式美化(不改变数据内容),重点处理表头、汇总行等特殊行的识别与格式设置,需特别注意合并单元格的安全操作。 + +#### 整体操作流程 + +``` +1. 探查阶段 + ├── get_workbook_structure → 获取子表列表、行列数、冻结位置 + ├── get_sheet_structure(info_type: merged_cells_infos)→ 获取合并区域 + ├── get_cell_ranges(前几行 + 末尾几行,include_styles: true)→ 采样表头/数据区/汇总行样式 + └── 分析结果 → 建立区域地图(表头行号、数据起止行号、汇总行号、合并区域列表) + +2. 规划阶段 + ├── 判断表头行:通常第 1 行或前 2 行,特征为加粗/背景色/合并/居中 + ├── 判断汇总行:通常最后 1~2 行,特征为加粗/SUM/AVERAGE 公式/更深背景色 + ├── 判断合并区域:从 get_cell_ranges 返回中识别(多个单元格同值且样式相同通常暗示合并) + └── 制定美化方案:按区域分别设置样式 + +3. 执行阶段(按顺序) + ├── 先处理合并单元格(如需取消合并再重新合并,必须先 unmerge 再 merge) + ├── 设置表头样式 + ├── 设置数据区域样式 + ├── 设置汇总行样式 + └── 调整列宽行高 +``` + +#### 美化中的合并单元格要点 + +- 编辑前先识别已有合并区域(见探查阶段),避免破坏原有语义分区。 +- 美化表头/分组标题时,若需修改合并区域的范围或样式,遵循"先 `unmerge` → 修改 → 再 `merge`"顺序。 +- 合并区域样式只写左上角,不要对合并内的其他单元格重复写入样式。 + +> 合并单元格完整的安全操作规则(含数据保护、样式占位等 5 条)见 `lark-sheets-range-operations` 的 `+cells-{merge|unmerge}` 章节。 + diff --git a/skills/lark-sheets/references/lark-sheets-workbook.md b/skills/lark-sheets/references/lark-sheets-workbook.md new file mode 100644 index 000000000..d7d2a7eb6 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-workbook.md @@ -0,0 +1,210 @@ +# Lark Sheet Workbook + +## Sheet 结构变更保守化(编辑类任务必做) + +`+sheet-{create|delete|rename|move|copy|hide|unhide|set-tab-color}` 会改变原表的物理结构,是高副作用动作。执行前必须遵守: + +1. **删除 / 重命名 / 隐藏 / 移动原 Sheet 需用户明示**:除非用户明示要这些操作,**禁止**擅自对**已存在**的 Sheet 执行 delete / rename / hide / move。新建 Sheet 是允许的(用于承载中间结果或透视表 / 图表对象),但应优先在原表右侧加列;只有当中间结果数量较大或会与原数据混淆时,才新建空白 Sheet(同 R1)。 +2. **Sheet 级操作前先列清单**:调用 `+sheet-{create|delete|rename|move|copy|hide|unhide|set-tab-color}` 之前,必须先调用 `+workbook-info`,把"当前所有 Sheet 名 + 可见性 + 行列数"列出来,再决定是否操作。禁止跳过列清单直接 create / delete / rename。 +3. **删除 / 重命名前向用户确认**:删除是不可逆的,重命名会让其他公式 / 透视表 / 图表的数据源失效——执行前必须在回复里确认"将删除 / 改名 X,影响 Y 个引用"。 + +## 使用场景 + +读写。管理工作簿结构。本 Skill 包含两个工具: + +| 操作需求 | 使用工具 | 说明 | +|---------|---------|------| +| 查看工作簿结构 | `+workbook-info` | 获取子表列表、名称、行列数、冻结位置等元数据 | +| 变更工作簿结构 | `+sheet-{create|delete|rename|move|copy|hide|unhide|set-tab-color}` | 新建/删除/移动/重命名/复制/隐藏子表、修改标签颜色 | + +注意: + +- 如果用户请求包含多个动作,例如"先重命名,再新建工作表",请按顺序发起多次调用,覆盖全部动作 +- `create` 时若用户指定了工作表名称,应显式传入 `sheet_name`;不要省略后依赖默认命名 +- 若 `+workbook-info` 返回包含 `warning_message`,说明部分 `sheet_id` 已失效(被删除/改名或输入错误),应停止复用这些 id,重新不带 `sheet_ids` 全量获取结构后再继续操作 + +**常见配置错误(必须注意)**: +- **获取结构是第一步**:任何表格操作前必须先调用 `+workbook-info`,不要跳过直接操作。返回的行列数、子表列表是后续所有操作的基础 +- **sheet_id 不要写错**:从 `+workbook-info` 返回值中精确获取 `sheet_id`,不要手动拼写或从 URL 中猜测 +- **优先使用 `sheet_id`**:虽然飞书表格不允许子表重名,但 `sheet_id` 是稳定标识符,跨多轮操作时不会因用户中途重命名而失效 + +## Shortcuts + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 + +| MCP tool | CLI shortcut | Risk | 分组 | +| --- | --- | --- | --- | +| `get_workbook_structure` | `+workbook-info` | read | 工作簿 | +| `modify_workbook_structure` | `+sheet-create` | write | 工作簿 | +| | `+sheet-delete` | high-risk-write | 工作簿 | +| | `+sheet-rename` | write | 工作簿 | +| | `+sheet-move` | write | 工作簿 | +| | `+sheet-copy` | write | 工作簿 | +| | `+sheet-hide` | write | 工作簿 | +| | `+sheet-unhide` | write | 工作簿 | +| | `+sheet-set-tab-color` | write | 工作簿 | +| `create_workbook` | `+workbook-create` | write | 工作簿 | +| `export_workbook` | `+workbook-export` | read | 工作簿 | + +## Flags + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 + +### `+workbook-info` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet 定位 | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet 定位 | +| `--include-properties` | 专有 | bool | 否 | 是否返回每个 sheet 的扩展属性(默认 true) | +| `--dry-run` | 系统 | bool | 否 | | + +### `+sheet-create` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--title` | 专有 | string | 是 | 新工作表名称 | +| `--index` | 专有 | int | 否 | 插入位置;省略时附加到末尾 | +| `--row-count` | 专有 | int | 否 | 初始行数,默认 100 | +| `--col-count` | 专有 | int | 否 | 初始列数,默认 26 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+sheet-delete` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--yes` | 系统 | bool | 是 | `high-risk-write`,必须二次确认(不带时退出码 10) | +| `--dry-run` | 系统 | bool | 否 | | + +### `+sheet-rename` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--title` | 专有 | string | 是 | 新名称 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+sheet-move` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--index` | 专有 | int | 是 | 目标位置(0-based) | +| `--dry-run` | 系统 | bool | 否 | | + +### `+sheet-copy` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--title` | 专有 | string | 否 | 副本名称;省略时由服务端生成 | +| `--index` | 专有 | int | 否 | 副本插入位置 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+sheet-hide` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--dry-run` | 系统 | bool | 否 | | + +### `+sheet-unhide` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--dry-run` | 系统 | bool | 否 | | + +### `+sheet-set-tab-color` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--color` | 专有 | string | 是 | Hex 色值如 `#FF0000`,传空 `""` 清除 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+workbook-create` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--title` | 专有 | string | 是 | 新 spreadsheet 标题 | +| `--folder-token` | 专有 | string | 否 | 目标文件夹 token;省略放根目录 | +| `--headers` | 专有 | string + File + Stdin | 否 | 表头行 JSON 数组:`["列A","列B"]` | +| `--data` | 专有 | string + File + Stdin | 否 | 初始数据 JSON 二维数组:`[["alice",95]]` | +| `--dry-run` | 系统 | bool | 否 | | + +### `+workbook-export` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--file-extension` | 专有 | string + Enum | 否 | `xlsx` / `csv`,默认 `xlsx`;csv 模式必须配 `--sheet-id` | +| `--sheet-id` | 专有 | string | 否 | 仅 csv 模式必填:指定要导出的 sheet reference_id | +| `--output-path` | 专有 | string | 否 | 本地保存路径;省略只触发导出不下载 | +| `--dry-run` | 系统 | bool | 否 | | + +## Examples + +> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。`+workbook-info` 只用前两者;`+sheet-*` 系列对单个工作表操作,需 `--sheet-id` 或 `--sheet-name`。 + +### `+workbook-info` + +输出契约:返回 `sheets[]`,每个含 `sheet_id` / `title` / `row_count` / `column_count` / `frozen_row_count` / `frozen_col_count` / `index` / `hidden`。是操作飞书表格的第一步——任何后续 sheet 级动作都需要先拿这里的 sheet_id。 + +### `+sheet-create` + +示例: + +```bash +lark-cli sheets +sheet-create --url "https://example.feishu.cn/sheets/shtXXX" \ + --title "汇总" --index 0 +``` + +### `+sheet-delete` + +> ⚠️ 工作表删除不可逆;先 `--dry-run` 看输出 sheet_id + title 确认是要删的那张。 + +### `+sheet-rename` + +### `+sheet-move` + +### `+sheet-copy` + +### `+sheet-hide` / `+sheet-unhide` + +### `+sheet-set-tab-color` + +### Validate / DryRun / Execute 约束 + +- `Validate`:XOR 公共四件套;`+sheet-create` 校验 `--title` 非空、`--row-count` ≤ 50000、`--col-count` ≤ 200;`+sheet-delete` 必须 `--yes` 或 `--dry-run`。 +- `DryRun`:`+sheet-*` 写操作输出"将要 PATCH 的 sheet metadata";`--sheet-name` 在 dry-run 输出里生成为 `` 占位符,不实际解析为 sheet-id。 +- `Execute`:所有写操作执行后自动调用 `+workbook-info` 回读,envelope.meta.verification 包含目标 sheet 的新状态。 diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md new file mode 100644 index 000000000..40dfa1f71 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -0,0 +1,387 @@ +# Lark Sheet Write Cells + +> ⚠️ **沙箱类工具在 CLI 中不存在**:`import_sandbox_to_sheet` 是 sheet-ai-skills 侧(AI/MCP 消费方)的沙箱 IO 工具,本 reference 中提到它们的段落对 CLI 不适用。CLI 处理大数据请走 `+csv-get --max-rows N` 分页读取 + 本地 Python 处理;写回用 `+csv-put` 或 `+cells-set`。 + +## 写入边界 + 回读校验(编辑类任务必做) + +1. **明确写入边界**:写入前必须能回答"目标 range 的起止行列号是多少?是否落在用户授权范围内?"。除用户明示要修改的区域外,禁止扩张到原数据列以外或新建 Sheet。 +2. **完整性断言**:批量写入前先把"预期写入条数"硬编码到代码里(如要填 106 条翻译 → `expected = 106`),写完后回读断言 `actual == expected`。少于预期就继续写,禁止交付半成品。 +3. **回读抽样校验**:写完关键值 / 公式后,用 `+csv-get` 或 `+cells-get` 重新读取写入区域,至少抽样 3-5 个代表性单元格(首 / 中 / 末),核对值与预期一致(与 `doubao_code_interpreter` 计算的预期值对照)。公式特定的"先验证模板再 copy_to_range / 修完再读回"细则见下方相关章节。 + +## 新增列 / 新增行的样式继承(防止视觉风格不一致) + +新增列 / 新增行**必须**先用 `+cells-get` 读相邻原列 / 原行的完整样式作为模板,**禁止**只传 `value` 期望默认样式与原表一致——飞书新单元格默认对齐通常是 `H:right, V:bottom`,与多数原表的 `H:center, V:middle` 不一致。 + +**完整继承清单**(写新列 / 新行时 cells 数组必须同时携带): + +1. `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`(背景色) +5. `border_styles`(四边框) +6. **`merged_cells`(合并范围)**——续写场景必查(高频致命错误):用 `get_sheet_structure --info_type=merged_cells_infos` 读原数据区域的合并信息。**原行有跨列合并**(如标题行 `A1:G1` 合并)时,新行**必须**用 `+cells-{merge|unmerge}` 工具复制相同合并模式到新行(如续写第 3 个周报块的标题行 `A23:G23` 必须合并)。仅传 cells 数组的 5 类样式不够——合并范围要单独靠 `+cells-{merge|unmerge}` 工具落地(典型反例:续写多周记录表时,新增周次的标题行未合并,视觉上与原前几周风格不一致) + +**采样模板的正确做法**: +- 表头新列 → 读相邻表头单元格(如新加 D1 → 读 A1/B1/C1 任一) +- 数据新列 → 读相邻数据行单元格(如新加 M5:M100 → 读 L5 / L6 / L7) +- 续写新行 → 读最近一行已有数据(如续写第 20 行 → 读 19 行所有列) + +**反模式**(违规): +- 只传 `{"value": "四级菜单"}` 给 D1,不传 `cell_styles` → D1 默认非加粗、非居中,与 A1/B1/C1 风格断裂 +- 新列 M5 写入 `=SUM(F5:L5)` 时只传 `formula`,不传 `cell_styles.horizontal_alignment / vertical_alignment / number_format` → M 列对齐变 `H:right`,数字格式变默认 + +## 长数字防科学计数法(数值列写入必查) + +写入或计算结果可能产生长数字(≥ 12 位整数 / 高精度小数)的列,**必须**在 `cell_styles.number_format` 显式设置非通用格式,否则飞书会自动用科学计数法显示,用户看到的就是"内容被截断 / 看不清原值"。 + +| 场景 | 必加的 `number_format` | +|---|---| +| 长整数(订单号 / 身份证 / 单据号) | `"0"` 或 `"@"`(强制文本,避免精度丢失) | +| 金额 / 千分位 | `"#,##0.00"` | +| 百分比 | `"0.00%"` | +| 数量 / 计数 | `"0"`(整数) | +| 日期 | `"yyyy-mm-dd"` 或 `"yyyy/m/d"` | + +**典型反例**:长数字列(如审批单号、流水号)未设 `number_format`,飞书显示为 `1.23E+15`,用户复制出来已经丢失精度。 + +## 使用场景 + +写入。为一块单元格区域设置值、公式、批注/备注和/或格式。也支持通过 `rich_text` 中 `type: "embed-image"` 在单元格内嵌入图片(单元格图片)。关键:数组维度必须严格匹配——`cells` 二维数组必须与 `range` 的行列维度完全一致,range 是闭区间,否则会触发 `InvalidCellRangeError`。计算示例:区域 `A1:D3` = 3 行 × 4 列 = `[[r1c1,r1c2,r1c3,r1c4],[r2c1,r2c2,r2c3,r2c4],[r3c1,r3c2,r3c3,r3c4]]`;区域 `A41:N48` = 8 行 × 14 列 = 8 个数组且每个数组 14 个单元;单个单元格 `A1` = `[[cell]]`;单列区域 `B5:B7` = `[[cell1],[cell2],[cell3]]`。空单元请使用 `{}`。**如果填写的区域存在大量重复内容,务必优先使用 `copy_to_range` 字段复制,可大幅减少 `cells` 长度。** + +> **单元格图片 vs 浮动图片**: +> - **单元格图片**(本工具):图片嵌入在单元格内部,属于单元格内容,随单元格移动。通过 `rich_text` 中 `type: "embed-image"` 写入。 +> - **浮动图片**:图片悬浮在单元格上方,可自由定位和调整大小,不属于单元格内容。→ 使用 lark_sheet_float_image Skill。 + +高频模式(**必须遵守,禁止逐行写入替代**): + +- 整列公式:先在 `H2` 写一个公式,再用 `copy_to_range: "H2:H100"` 或 `copy_to_range: "H:H"` 向下填充。**禁止对每一行单独调用 set_cell_range 写入相同结构的公式** +- 整列格式:先在 `J1` 写一个带样式的模板单元格,再用 `copy_to_range: "J:J"` +- 首行样式:先在 `A1` 写一个模板单元格,再用 `copy_to_range: "1:1"` +- 用户说”这列 / 整列 / 这行 / 首行 / 向下复制”时,**必须**使用模板单元格 + `copy_to_range` +- 多区域写入相同格式/公式结构时,优先写一个模板,再用 `copy_to_range` 复制到所有目标区域 + +⚠️ **逐行写入公式是最常见的致命错误**:对每一行单独调用 `+cells-set` 写入公式(如调用 26 次),会快速耗尽轮次上限导致操作不完整。正确做法是 1 次模板写入 + 1 次 `copy_to_range` = 2 次调用完成。 + +💡 **写入公式前先按迁移规则改写**:如果公式来自 Excel 或包含数组场景,先读取并遵循 `lark-sheets-formula-translation` skill 的规则完成改写,再把最终公式写入 `formula` 字段。 + +💡 **内容与样式分离写入(推荐)**:当需要同时写入内容和样式时,`cells` 中每个单元格都带上 `cell_styles` / `border_styles` 会导致入参非常冗长。由于同一区域的样式通常高度重复(如整列统一背景色、统一边框),推荐拆成两步: +1. **先写内容**:`+cells-set` 只传 `value` / `formula`,不带样式,`cells` 入参精简 +2. **再批量刷样式**:对区域中的一个单元格写入目标样式作为模板,再用 `copy_to_range` 将样式扩展到整列 / 整行 / 整个区域(`copy_to_range` 会复制值、公式和样式,所以模板单元格应已包含正确的值) + +示例:要对 A2:A100 写入数据并统一设置蓝色背景 + 边框: +``` +Step 1: set_cell_range — range="A2:A100", cells 只含 value(无样式,入参短) +Step 2: set_cell_range — range="A2", cells 含 value + cell_styles + border_styles(单个模板), copy_to_range="A2:A100" +``` +这比在 99 个单元格中都重复写样式 JSON 高效得多。 + +💡 **大批量数据分批写入(推荐)**:当需要写入大量行(如几十行以上)时,不要试图在一次调用中生成全部 `cells` 数据——`cells` 数组过大会导致模型生成内容过长而超时。应将数据拆分为多批,每批 20-50 行,分多次调用 `+cells-set` 逐批生成并写入(如先写 `A2:D21`,再写 `A22:D41`,依此类推)。每次调用只需生成当前批次的数据,控制单次生成量,避免超时。 + +注意: + +- 不要把 `cells` 写成字符串化 JSON +- 如果目标区域中已有值、公式或样式需要被覆盖,显式设置 `allow_overwrite=true` +- 若目标区域涉及合并单元格,不要向合并区域中的非左上角单元格写入数据;如需写入,应改写合并区域左上角单元格,或先调整/取消合并区域 +- **构造 `range` 时行号必须基于逻辑行号**:如果之前通过 `+csv-get` 读取了数据,CSV 中被双引号包裹的多行字段(如 `"2026年3月2日\n星期一"`)是**一个单元格**,不是两行。写入时的行号必须按逻辑记录计算,不能按物理换行符计数,否则 `range` 会整体偏移导致写入到错误位置 + +⚠️ **"样式与原表一致"必须包含 `border_styles`(高频致命错误)**:当用户说"样式和原表一致"、"保持原表格式"、"边框继承"等要求时,cells 里的 `cell_styles` **不能只传 `font_size` / `horizontal_alignment` / `vertical_alignment`**——这几项只覆盖字体和对齐,**不包含边框**。边框必须用独立的 `border_styles` 字段传(或在源 cell 用 `+cells-get` 读出来再原样复制)。 +- **反模式**:`cells=[[{cell_styles:{font_size:16, horizontal_alignment:"center", vertical_alignment:"middle"}}]]`(字体+对齐都有,但**新 cell 仍然没边框**,视觉上与原表断裂) +- **正确做法**:`cell_styles` + `border_styles` 一起传,`border_styles` 覆盖 top/bottom/left/right 四条边(或至少 data 区该加的几条),确保视觉连续 +- 特别是**新列/新行**场景,新 cell 底子里本来就没边框,如果不显式传 `border_styles`,copy_to_range 复制的模板也没边框 → 整列/整行无边框 + +⚠️ **公式写入必须自己校验结果(后端不会报语法错)**:`+cells-set` 写公式时,即便公式有括号不配对(如 `=IFERROR(VALUE(REGEXEXTRACT(D5, "\d+"))), 0)` 比 IFERROR 多一个 `)`)或函数名拼错(`=UNIQUE(...)` 飞书不支持),**后端工具也会返回 `updated_cells_count=N, rc=0` 的"成功"**——错误会静默写进单元格显示为 `#VALUE!` / `#NAME?` / `#REF!`。因此: +1. **写完立即读回**:`+cells-set` 后紧跟 `+csv-get`(或 `+cells-get`)读目标范围前几行,检查是否出现 `#VALUE!` / `#NAME?` / `#REF!` / `#N/A` / `#DIV/0!` / `#NUM!` +2. **看到 `#` 开头的错误值**立即修公式:`#NAME?` 多半是函数名拼错或飞书不支持(UNIQUE/DISTINCT 等);`#VALUE!` 多半是类型不匹配或括号错位;`#REF!` 是引用错误;`~CIRCULAR~REF~` 是循环引用(公式引用了自身或会闭环) +3. **`copy_to_range` 扩展前先验证模板**:模板单元格公式自己都算错,`copy_to_range` 复制到 100 行就是 100 个错误 +4. **飞书不支持的函数**:`UNIQUE` / `DISTINCT` / `FILTER`(部分)—— 对应"去重"场景改用透视表(`+pivot-{create|update|delete}`,值字段聚合方式选 count) +5. **循环引用预检(高频致命错误)**:写聚合公式(SUM / AVERAGE / COUNT 等)前必须明确**引用范围不包含目标单元格自身或其传递依赖**。典型反例:在 C3 写 `=SUMIF(B:B,LEFT(B3,9)&"*",C:C)`,B 列匹配 B3 前 9 位时 C3 自己也命中,导致 C3 自引用 → `~CIRCULAR~REF~`。修法:用辅助列 / 显式排除自身(`SUMIFS(C:C, B:B, ..., A:A, "<>"&A3)`)/ 缩小范围避开自己 +6. **REGEX 模式覆盖率验证**:公式里的 `REGEXEXTRACT` / `REGEXMATCH` / `REGEXREPLACE` 等正则模式落地前必须用 `doubao_code_interpreter` 在源列上跑一遍命中率统计(`df[col].str.contains(pattern).mean()`);命中率 < 100% 时必须扩展 pattern 或加多分支(IFS / 多个 IFERROR 串联)兜底,**禁止**只覆盖样本前 N 行就交付(典型反例:用 `REGEXEXTRACT(D5,"长(\d+)")` 只匹配带"长"前缀的尺寸文本,对"宽×高"、"×"、"*"等其它分隔符直接漏匹配) +7. **公式范围与用户指令字面对齐**:用户说"对 F 至 L 列求和"就必须写 `SUM(F2:L2)` 或 `F2+G2+H2+I2+J2+K2+L2`,**不能漏列、多列、错列**。写完用 `+cells-get` 拿回 `formula` 字符串,与用户原话逐字对照(参与求和的列名一致 / 起止列号一致 / 运算符一致),不一致就是违规 + +⚠️ **收到 `formula_errors` 反馈后不要只打补丁(高频致命错误)**:`+cells-set` 返回值里若出现 `formula_errors: [{cell, formula, error_type, detail}]`,说明某些 cell 公式编译失败(`error_type=compile_failed` 通常是函数语法错如 `SPLIT(x)[1]` 飞书不支持;`non_formula` 是 `=` 开头但解析不通过)。此时**禁止只聚焦修报错点的局部语法**(如仅把 `[1]` 换成 `INDEX(..,1)`),必须: + +1. **重新审视整条公式的完整性**:被 formula_errors 标出的那一行,公式除了下标语法错,还可能有其他先天缺陷(字符清洗不全、IFERROR 兜底漏条件、引用列写错),修完语法错后立即整体复核 +2. **同步对称修复所有相似列**:如果同一任务涉及多列相似处理(如"算 H 列面积"用 D 列尺寸、"算 I 列面积"用 E 列尺寸),**修完一列必须把同样的清洗/兜底逻辑同步到所有相似列**,禁止出现 H 列用 `SUBSTITUTE(长)+SUBSTITUTE(高)+SUBSTITUTE(×)` 而 I 列只用 `SUBSTITUTE(×)` 这种不对称处理——会导致一列编译通过有值、另一列编译通过但 IFERROR 全返回空,用户看到的是"数据为空"而非"公式错" +3. **修完再读回验证**:不只看 `formula_errors` 为空(这只证明编译通过,不证明运行时有值),必须 `+csv-get` 读目标列前 3-5 行,确认**非空源数据对应的目标列有非空计算结果** +4. **核心心智**:`formula_errors` 是"帮你暴露编译错"的工具,不是"修掉它就收工"的通行证。编译通过 + 运行时 IFERROR 兜底空 = 用户视角的"没算出来" + +⚠️ **新增行的边框/样式禁止用 `{}` 跳过(高频致命错误)**:`cells` 数组里 `{}` 的语义是"**此单元格不做任何修改、保留原状态**"。这在写入**已有行**时是安全的(原有边框/样式保持不变),但在写入**新行**(比如表尾追加汇总行、扩展行)时是灾难:新行底子里本来就没边框,`{}` 不修改 = 保留无边框状态,导致该 cell 视觉断裂。 + +⚠️ **"汇总行"识别 → 读 `lark-sheets-visual-standards` 拿完整样式规范**:下述双重条件**同时满足**才是汇总行,禁止仅凭"有 AVERAGE"就判定: +- **语义信号**(二选一):用户 prompt 含"合计/汇总/总计/统计/各科平均分/最下面加一行算…/底部总计"等意图词;或上下文明确是"表尾追加一行做聚合" +- **结构信号**:新行全行都在做聚合(含 `=SUM/AVERAGE/COUNT/MAX/MIN/SUBTOTAL(...)`,支持 IFERROR 包裹),**不是**单个 cell 算个参考值或每行都算的派生列 + +满足上述时,**不要在本 skill 里猜样式**,直接去读 `lark-sheets-visual-standards` 的"汇总行规范"章节,按那里的规则配齐 `font.bold / horizontal_alignment / background_color / border_styles`。 + +反例(**不是**汇总行,禁止自动加粗): +- 用户说"在 H5 帮我算个 AVERAGE 参考"→ 单 cell 计算 +- 每行都有 `=AVERAGE(本行区间)` 的派生列 → 属数据列 +- 用户明确说"不要加粗/样式和数据行保持一致"→ 遵循用户意图 + +**正确做法**(二选一): + +**做法 A(推荐):两步走——先铺样式、再覆内容** + +``` +Step 1: 用模板单元格 + copy_to_range 铺"完整样式"(不是只铺 border)到新区域 + set_cell_range — range="A11", cells=[[{ + border_styles: {...}, + cell_styles: { /* 按行性质填充:数据行继承数据区样式;汇总行见 lark_sheet_visual_standards */ } + }]], copy_to_range="A11:H11" + +Step 2: 再用 set_cell_range 单独写具体 value/formula(不再传样式,避免覆盖) + set_cell_range — range="A11", cells=[[{value: "平均分"}]] + set_cell_range — range="C11:F11", cells=[[{formula: "=AVERAGE(C2:C10)"}, {formula: "=AVERAGE(D2:D10)"}, ...]] +``` + +⚠️ **Step 1 `cell_styles` 禁止留空**:只铺 border、不铺 `cell_styles`,等于新行从格式上"裸奔"——没字体、没对齐、没背景色。如果新行是汇总行,这意味着 bold 丢失,用户感受"没做样式"。Step 1 的 `cell_styles` 要么继承源区块(`+cells-get` 读相邻已有行样式后复用),要么按汇总行规范(见 `lark-sheets-visual-standards`)配齐。 + +**做法 B:一次写入但每个 cell 都显式带样式** + +``` +set_cell_range — range="A11:H11", cells=[[ + {value: "平均分", cell_styles: {...}, border_styles: {...}}, + {value: "", cell_styles: {...}, border_styles: {...}}, ← B11 不能是 {},要显式带 border + {formula: "=AVERAGE(C2:C10)", cell_styles: {...}, border_styles: {...}}, + {formula: "=AVERAGE(D2:D10)", cell_styles: {...}, border_styles: {...}}, + ... +]] +``` + +**判断是不是"新行"**:`+csv-get` 返回的 `current_region` 是 `A1:H10`,你要写入的 range 是 `A11:H11`(超出 `current_region` 右/下边界),就是新行——必须按上述做法处理边框。 + +## 工具选择 + +本 skill 提供三个写入工具,按数据来源 + 内容形态选: + +| 场景 | 工具 | 原因 | +|------|------|------| +| 写入含公式、样式、批注、图片、数据校验等任意富写入 | `+cells-set` | 唯一支持完整字段的工具 | +| 数据已经在沙箱里(Python 清洗/聚合/筛选/合并的大块纯值) | `import_sandbox_to_sheet` | 只传 `file_uri`,CSV 不进对话上下文,最省 token | +| 模型手里已经有 CSV 文本(小规模手动构造、从 `+csv-get` 取到后简单加工) | `+csv-put` | 直接传 CSV 文本 + start_cell,不用自己拼二维 cells 数组;必要时自动扩容行列 | +| 大量纯值 + 需要表头样式/边框 | 先用 `import_sandbox_to_sheet` 或 `+csv-put` 写值,再用 `+cells-set` 补样式 | 分工配合,入参最短 | + +**优先级**:沙箱路径(`import_sandbox_to_sheet`)> CSV 文本(`+csv-put`)> 逐格写(`+cells-set`)。能把数据保留在沙箱里的就别让 CSV 进上下文。 + +⚠️ `import_sandbox_to_sheet` 和 `+csv-put` 都只写纯值,**不会**携带公式/样式/批注/图片;公式字符串以 `=` 开头会被当作字面量文本落地。如果数据里需要公式或样式,**必须**用 `+cells-set`(或"写值 + 补样式"两步法)。 + +## Shortcuts + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 + +| MCP tool | CLI shortcut | Risk | 分组 | +| --- | --- | --- | --- | +| `set_cell_range` | `+cells-set` | write | 单元格 | +| | `+cells-set-style` | write | 单元格 | +| | `+cells-batch-set-style` | write | 批量 | +| | `+cells-set-image` | write | 单元格 | +| | `+dropdown-set` | write | 对象 | +| | `+dropdown-get` | read | 对象 | +| | `+dropdown-update` | write | 对象 | +| | `+dropdown-delete` | high-risk-write | 对象 | +| `set_range_from_csv` | `+csv-put` | write | 单元格 | +| `import_sandbox_to_sheet` | _Sheet Tool 独有,CLI 不实现_ | — | — | + +## Flags + +> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 + +### `+cells-set` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--range` | 专有 | string | 是 | 写入区域 A1 格式 | +| `--data` | 专有 | string + File + Stdin | 是 | JSON:`{"values": [[...], ...]}`;可含 `formula` / `cell_styles` / `comments` / `embed_image` 富信息 | +| `--allow-overwrite` | 专有 | bool | 否 | 允许覆盖非空 cell;默认 false 时遇非空 cell 报错 | +| `--max-cells` | 专有 | int + Hidden | 否 | 防爆,默认 50000 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+cells-set-style` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--range` | 专有 | string | 是 | 目标范围 A1 格式(如 `A1:B2`) | +| `--style` | 专有 | string + File + Stdin | 是 | 样式 JSON:`{"font":{"bold":true},"backColor":"#fff","border_styles":{...}}`;只改样式,不动 value/formula(底层走 set_cell_range 的 cell_styles + border_styles 字段) | +| `--dry-run` | 系统 | bool | 否 | | + +### `+cells-batch-set-style` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--data` | 专有 | string + File + Stdin | 是 | JSON 数组 `[{"ranges":["sheet1!A1:B2"],"style":{...}}]`;每个 ranges 元素必须带 sheet 前缀 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+cells-set-image` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--range` | 专有 | string | 是 | 目标 cell A1(必须单 cell,如 `A1`;起止 cell 须相同) | +| `--image` | 专有 | string | 是 | 本地图片路径(支持 PNG / JPEG / JPG / GIF / BMP / JFIF / EXIF / TIFF / BPG / HEIC) | +| `--name` | 专有 | string | 否 | 图片文件名(含扩展名);省略时取 `--image` 的 basename | +| `--dry-run` | 系统 | bool | 否 | | + +### `+dropdown-set` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--range` | 专有 | string | 是 | 目标范围 A1 格式(如 `A2:A100`) | +| `--options` | 专有 | string + File + Stdin | 是 | 选项 JSON 数组 `["opt1","opt2"]`;最多 500 项,每项 ≤100 字符,不含逗号 | +| `--colors` | 专有 | string + File + Stdin | 否 | RGB hex 颜色数组(如 `["#1FB6C1","#F006C2"]`),长度必须与 `--options` 一致 | +| `--multiple` | 专有 | bool | 否 | 启用多选;默认 `false` | +| `--highlight` | 专有 | bool | 否 | 选项配色显示;默认 `false` | +| `--dry-run` | 系统 | bool | 否 | | + +### `+dropdown-get` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--range` | 专有 | string | 是 | 目标范围 A1 格式(含 sheet 前缀,如 `sheet1!A2:A100`) | +| `--dry-run` | 系统 | bool | 否 | | + +### `+dropdown-update` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--ranges` | 专有 | string + File + Stdin | 是 | 目标范围 JSON 数组(如 `["sheet1!A2:A100"]`),每项必须带 sheet 前缀 | +| `--options` | 专有 | string + File + Stdin | 是 | 选项 JSON 数组 | +| `--colors` | 专有 | string + File + Stdin | 否 | 颜色数组(与 `--options` 等长) | +| `--multiple` | 专有 | bool | 否 | 启用多选 | +| `--highlight` | 专有 | bool | 否 | 选项配色 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+dropdown-delete` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--ranges` | 专有 | string + File + Stdin | 是 | 目标范围 JSON 数组(最多 100 个,每项带 sheet 前缀) | +| `--yes` | 系统 | bool | 是 | `high-risk-write`,必须二次确认(不带时退出码 10) | +| `--dry-run` | 系统 | bool | 否 | | + +### `+csv-put` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--range` | 专有 | string | 是 | 目标区域起点 A1(如 `Sheet1!A1`);自动按 CSV 行列数推断终点 | +| `--csv` | 专有 | string + File + Stdin | 是 | RFC 4180 CSV 文本;只写纯值,不带公式/样式/批注 | +| `--allow-overwrite` | 专有 | bool | 否 | 允许覆盖;默认 false 时若目标非空报错 | +| `--dry-run` | 系统 | bool | 否 | | + +## Schemas + +> 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 + +### `+cells-set` `--data` + + +**顶层字段**: +- `border_styles` (object?) — 单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top) { bottom?: object, left?: object, right?: object, top?: object } +- `cell_styles` (object?) — 单元格样式属性,包括字体、颜色、对齐方式和数字格式 { background_color?: string, font_color?: string, font_line?: enum, font_size?: number, font_style?: enum, …共 10 项 } +- `data_validation` (object?) — 数据验证配置 { help_text?: string, items?: array, operator?: enum, range?: string, support_multiple_values?: boolean, …共 7 项 } +- `formula` (string?) — 以 '=' 开头的单元格公式(例如:'=SUM(A1:A10)') +- `multiple_values` (array?) — 多值内容,用于支持多选的列表验证单元格 each: { format?: string, value: oneOf } +- `note` (string?) — 单元格批注/备注 +- `rich_text` (array?) — 富文本内容 each: { attachment_name?: string, attachment_token?: string, attachment_uri?: string, file_size?: number, image_height?: number, …共 17 项 } +- `value` (oneOf?) — 静态单元格值(文本、数字、布尔) + +### `+cells-set-style` `--style` / `+cells-batch-set-style` `--data` + +_单元格样式属性,包括字体、颜色、对齐方式和数字格式_ + +**顶层字段**: +- `background_color` (string?) — 背景颜色(十六进制,例如 "#ffffff") +- `font_color` (string?) — 字体颜色(十六进制,例如 "#000000") +- `font_line` (enum?) — 字体线条样式 [none / underline / line-through] +- `font_size` (number?) — 字体大小(单位:px/像素,例如 10、12、14) +- `font_style` (enum?) — 字体样式 [normal / italic] +- `font_weight` (enum?) — 字重 [normal / bold] +- `horizontal_alignment` (enum?) — 水平对齐方式 [left / center / right] +- `number_format` (string?) — 数字格式(例如:文本用 "@"、数字用 "0.00"、货币用 "$#,##0.00"、日期用 "mm/dd/yyyy") +- `vertical_alignment` (enum?) — 垂直对齐方式 [top / middle / bottom] +- `word_wrap` (enum?) — 是否自动换行,默认溢出,可选自动换行或裁剪 [overflow / auto-wrap / word-clip] + +### `+dropdown-set` `--options` / `+dropdown-update` `--options` + +_数据验证配置_ + +**顶层字段**: +- `help_text` (string?) — 验证失败时显示的提示文本 +- `items` (array?) — 列表选项(type='list' 时必填) +- `operator` (enum?) — 比较运算符(type='number'/'date'/'textLength' 时必填) [equal / notEqual / greaterThan / greaterThanOrEqual / lessThan / lessThanOrEqual / between / notBetween] +- `range` (string?) — 源数据区域(type='listFromRange' 时必填,格式:'SheetName!A1:A10') +- `support_multiple_values` (boolean?) — 列表验证是否支持多选(type='list'/'listFromRange' 时可选,默认 false) +- `type` (enum) — 数据验证类型:list(下拉列表)、listFromRange(引用范围下拉列表)、number(数字)、date(日期)、textLength(文本长度)、… [list / listFromRange / number / date / textLength / checkbox] +- `values` (array?) — 比较值(operator 为 'between'/'notBetween' 时需要两个值,其它运算符需要一个值) + +## Examples + +> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。 + +### `+cells-set` + +示例: + +```bash +# 纯值(数组形态) +lark-cli sheets +cells-set --url "https://example.feishu.cn/sheets/shtXXX" \ + --sheet-name "Sheet1" --range "A1:B2" --allow-overwrite \ + --data '{"values":[["name","score"],["alice",95]]}' + +# 富 cell(公式 + 样式) +lark-cli sheets +cells-set --spreadsheet-token shtXXX --sheet-id "$SID" \ + --range "C2:C10" --data @rich-cells.json +``` + +`--data` 富格式见 SKILL.md 上方"使用场景"段;值 / 公式 / 样式 / 批注 / 嵌入图片可同一次写入混合提交。 + +### `+csv-put` + +示例: + +```bash +# 内联 CSV +lark-cli sheets +csv-put --url "https://example.feishu.cn/sheets/shtXXX" \ + --sheet-name "Sheet1" --range "A1" --allow-overwrite \ + --csv $'name,score\nalice,95\nbob,87' + +# 从文件 +lark-cli sheets +csv-put --spreadsheet-token shtXXX --sheet-id "$SID" \ + --range "A1" --csv @data.csv --allow-overwrite +``` + +> `+csv-put` 比 `+cells-set` 短得多——只想批量灌纯值时优先用它。需要公式/样式才换 `+cells-set`。 + +### Validate / DryRun / Execute 约束 + +- `Validate`:XOR 公共四件套;`+cells-set` 的 `--data.values` 必须矩形(每行列数相等);`+csv-put` 的 `--csv` 必须能按 RFC 4180 解析;防爆参数上限校验。 +- `DryRun`:输出目标 range + 推断尺寸 + 是否覆盖非空 cell 警告,零网络副作用。 +- `Execute`:写后调用 `+cells-get --ranges <写入区域> --include value,formula` 抽样回读,envelope.meta.verification 给出"预期 vs 实际"对比。 From b33a06c1e48c69d53a5a1288b4e52d030d32446d Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Fri, 15 May 2026 23:57:09 +0800 Subject: [PATCH 002/114] feat(sheets): implement lark_sheet_workbook shortcuts (B1) Land the 8 modify_workbook_structure shortcuts that round out the lark_sheet_workbook canonical skill alongside the existing +workbook-info: +sheet-create / +sheet-delete / +sheet-rename / +sheet-move / +sheet-copy / +sheet-hide / +sheet-unhide / +sheet-set-tab-color. All eight call modify_workbook_structure via the One-OpenAPI invoke_write endpoint, dispatched by the `operation` enum. Helpers in helpers.go grow publicSheetFlags() / resolveSheetSelector() / sheetSelectorForToolInput() / sheetSelectorPlaceholder() so future sheet-level shortcuts share the public --sheet-id / --sheet-name XOR treatment. +sheet-create intentionally drops the sheet selector pair since create has no existing-sheet anchor (matches the spec fix in tool-shortcut-map.json). +sheet-delete is the first high-risk-write shortcut in the canonical package; the framework requires --yes (exit code 10 otherwise). +sheet-move's tool requires source_index in addition to target_index. The CLI accepts an optional --source-index override and falls back to a single get_workbook_structure read to derive it (and to resolve sheet_id from --sheet-name). DryRun stays network-free by rendering placeholders for any field that would need that read. --- shortcuts/sheets/helpers.go | 53 ++ shortcuts/sheets/lark_sheet_workbook.go | 513 +++++++++++++++++- shortcuts/sheets/shortcuts.go | 8 + .../references/lark-sheets-workbook.md | 3 +- 4 files changed, 571 insertions(+), 6 deletions(-) diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go index 2fb2e64ef..4fbcfb730 100644 --- a/shortcuts/sheets/helpers.go +++ b/shortcuts/sheets/helpers.go @@ -65,3 +65,56 @@ func publicTokenFlags() []common.Flag { {Name: "spreadsheet-token", Desc: "spreadsheet token (XOR --url)"}, } } + +// publicSheetFlags extends publicTokenFlags with the sheet selector pair. +// Use for any +sheet-* / +cells-* / +dim-* / object shortcut that operates +// on an existing single sub-sheet. +func publicSheetFlags() []common.Flag { + return append(publicTokenFlags(), + common.Flag{Name: "sheet-id", Desc: "sheet reference_id (XOR --sheet-name)"}, + common.Flag{Name: "sheet-name", Desc: "sheet title (XOR --sheet-id)"}, + ) +} + +// resolveSheetSelector validates the --sheet-id / --sheet-name XOR and +// returns whichever was supplied. Network-free. +// +// Returned tuple: (sheetID, sheetName). Exactly one is non-empty — callers +// pass both through to the tool input; the server picks whichever fits. +func resolveSheetSelector(runtime *common.RuntimeContext) (sheetID, sheetName string, err error) { + if err := common.ExactlyOne(runtime, "sheet-id", "sheet-name"); err != nil { + return "", "", err + } + if id := strings.TrimSpace(runtime.Str("sheet-id")); id != "" { + if err := validate.RejectControlChars(id, "sheet-id"); err != nil { + return "", "", common.FlagErrorf("%v", err) + } + return id, "", nil + } + name := strings.TrimSpace(runtime.Str("sheet-name")) + if err := validate.RejectControlChars(name, "sheet-name"); err != nil { + return "", "", common.FlagErrorf("%v", err) + } + return "", name, nil +} + +// sheetSelectorForToolInput packs --sheet-id / --sheet-name into the tool +// input map, omitting empty fields. Use after resolveSheetSelector returns. +func sheetSelectorForToolInput(input map[string]interface{}, sheetID, sheetName string) { + if sheetID != "" { + input["sheet_id"] = sheetID + } + if sheetName != "" { + input["sheet_name"] = sheetName + } +} + +// sheetSelectorPlaceholder returns a human-readable identifier for the +// selected sheet, suitable for DryRun output. Avoids leaking that --sheet-name +// would be resolved server-side at execute time. +func sheetSelectorPlaceholder(sheetID, sheetName string) string { + if sheetID != "" { + return sheetID + } + return "" +} diff --git a/shortcuts/sheets/lark_sheet_workbook.go b/shortcuts/sheets/lark_sheet_workbook.go index 8ba1f42a6..f7b87ac83 100644 --- a/shortcuts/sheets/lark_sheet_workbook.go +++ b/shortcuts/sheets/lark_sheet_workbook.go @@ -5,14 +5,31 @@ package sheets import ( "context" + "fmt" + "strings" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" "github.com/larksuite/cli/shortcuts/common" ) -// WorkbookInfo wraps the get_workbook_structure tool: list a workbook's -// sub-sheets with their metadata (sheet_id, title, dimensions, freeze rows -// and cols, index, hidden). This is the first step for every sheets task — -// downstream sheet-level operations all depend on the sheet_id returned here. +// ─── lark_sheet_workbook ────────────────────────────────────────────── +// +// Wraps two tools behind the One-OpenAPI: get_workbook_structure (read) and +// modify_workbook_structure (write, dispatched by `operation` enum). +// +// CLI Risk tiers diverge intentionally from the tool's single endpoint: +// - +sheet-delete is high-risk-write (irreversible) +// - everything else is plain write +// +// +sheet-create only carries --url / --spreadsheet-token (no sheet selector): +// the create tool path needs no existing-sheet anchor, so the public sheet +// selector pair is dropped here to avoid a misleading XOR requirement. + +// WorkbookInfo wraps get_workbook_structure: list a workbook's sub-sheets +// with their metadata (sheet_id, title, dimensions, freeze rows and cols, +// index, hidden). First step for every sheets task — downstream sheet-level +// operations all depend on the sheet_id returned here. var WorkbookInfo = common.Shortcut{ Service: "sheets", Command: "+workbook-info", @@ -50,3 +67,491 @@ var WorkbookInfo = common.Shortcut{ "First step for every sheets task — capture sheet_id from the result before doing any sheet-level operation.", }, } + +// SheetCreate creates a new sub-sheet. --title is the new sheet's name; +// --index inserts at a specific position (omitted → appended). Default +// dimensions match the canonical schema (rows=100, cols=26 when omitted — +// tool's defaults differ but CLI surface stays predictable). +var SheetCreate = common.Shortcut{ + Service: "sheets", + Command: "+sheet-create", + Description: "Create a new sub-sheet with an optional position and initial dimensions.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicTokenFlags(), + common.Flag{Name: "title", Desc: "new sheet title", Required: true}, + common.Flag{Name: "index", Type: "int", Default: "-1", Desc: "insertion position (0-based); omit to append"}, + common.Flag{Name: "row-count", Type: "int", Default: "0", Desc: "initial row count; omit for tool default (200)"}, + common.Flag{Name: "col-count", Type: "int", Default: "0", Desc: "initial column count; omit for tool default (20)"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("title")) == "" { + return common.FlagErrorf("--title is required") + } + if n := runtime.Int("row-count"); n < 0 || n > 50000 { + return common.FlagErrorf("--row-count must be between 0 and 50000") + } + if n := runtime.Int("col-count"); n < 0 || n > 200 { + return common.FlagErrorf("--col-count must be between 0 and 200") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", sheetCreateInput(runtime, token)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", sheetCreateInput(runtime, token)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func sheetCreateInput(runtime *common.RuntimeContext, token string) map[string]interface{} { + input := map[string]interface{}{ + "excel_id": token, + "operation": "create", + "sheet_name": strings.TrimSpace(runtime.Str("title")), + } + if runtime.Changed("index") { + input["target_index"] = runtime.Int("index") + } + if n := runtime.Int("row-count"); n > 0 { + input["rows"] = n + } + if n := runtime.Int("col-count"); n > 0 { + input["columns"] = n + } + return input +} + +// SheetDelete deletes a sub-sheet. high-risk-write — framework rejects +// without --yes. Always preview with --dry-run first to confirm the target. +var SheetDelete = common.Shortcut{ + Service: "sheets", + Command: "+sheet-delete", + Description: "Delete a sub-sheet (irreversible).", + Risk: "high-risk-write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: publicSheetFlags(), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + _, _, err := resolveSheetSelector(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input := map[string]interface{}{"excel_id": token, "operation": "delete"} + sheetSelectorForToolInput(input, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input := map[string]interface{}{"excel_id": token, "operation": "delete"} + sheetSelectorForToolInput(input, sheetID, sheetName) + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + Tips: []string{ + "Sheet deletion is irreversible. Always run with --dry-run first to verify the target sheet_id/sheet_name.", + }, +} + +// SheetRename renames a sub-sheet via --title (mapped to tool's new_name). +var SheetRename = common.Shortcut{ + Service: "sheets", + Command: "+sheet-rename", + Description: "Rename a sub-sheet.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "title", Desc: "new sheet title", Required: true}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("title")) == "" { + return common.FlagErrorf("--title is required") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input := map[string]interface{}{ + "excel_id": token, + "operation": "rename", + "new_name": strings.TrimSpace(runtime.Str("title")), + } + sheetSelectorForToolInput(input, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input := map[string]interface{}{ + "excel_id": token, + "operation": "rename", + "new_name": strings.TrimSpace(runtime.Str("title")), + } + sheetSelectorForToolInput(input, sheetID, sheetName) + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +// SheetMove moves a sub-sheet to a new index. The tool requires sheet_id +// and source_index in addition to target_index. The CLI accepts: +// - --sheet-id / --sheet-name to identify the sheet +// - --source-index (optional) for explicit source position +// +// When --source-index is omitted, or when --sheet-name is used instead of +// --sheet-id, Execute issues a single get_workbook_structure read to derive +// the missing pieces. DryRun stays network-free: it uses placeholders +// for any field that would need that read. +var SheetMove = common.Shortcut{ + Service: "sheets", + Command: "+sheet-move", + Description: "Move a sub-sheet to a new position.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:read", "sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "index", Type: "int", Required: true, Desc: "target position (0-based)"}, + common.Flag{Name: "source-index", Type: "int", Default: "-1", Desc: "source position (0-based); omitted → auto-derived from --sheet-id/--sheet-name's current workbook position"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if !runtime.Changed("index") { + return common.FlagErrorf("--index is required") + } + if runtime.Int("index") < 0 { + return common.FlagErrorf("--index must be >= 0") + } + if runtime.Changed("source-index") && runtime.Int("source-index") < 0 { + return common.FlagErrorf("--source-index must be >= 0") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input := map[string]interface{}{ + "excel_id": token, + "operation": "move", + "sheet_id": sheetSelectorPlaceholder(sheetID, sheetName), + "target_index": runtime.Int("index"), + "source_index": sourceIndexOrPlaceholder(runtime), + } + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + + resolvedID := sheetID + var sourceIndex int + needIDLookup := sheetID == "" + needIndexLookup := !runtime.Changed("source-index") + if needIDLookup || needIndexLookup { + lookedID, lookedIdx, err := lookupSheetIndex(ctx, runtime, token, sheetID, sheetName) + if err != nil { + return err + } + resolvedID = lookedID + sourceIndex = lookedIdx + } + if runtime.Changed("source-index") { + sourceIndex = runtime.Int("source-index") + } + + input := map[string]interface{}{ + "excel_id": token, + "operation": "move", + "sheet_id": resolvedID, + "source_index": sourceIndex, + "target_index": runtime.Int("index"), + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + Tips: []string{ + "Pass --source-index when you already know it to avoid the extra read; otherwise CLI derives it from --sheet-id/--sheet-name.", + }, +} + +// sourceIndexOrPlaceholder returns the user-supplied source-index, or the +// string "" when DryRun should signal that Execute will derive it. +func sourceIndexOrPlaceholder(runtime *common.RuntimeContext) interface{} { + if runtime.Changed("source-index") { + return runtime.Int("source-index") + } + return "" +} + +// SheetCopy duplicates a sub-sheet. --title (optional) names the copy; +// --index (optional) places it. +var SheetCopy = common.Shortcut{ + Service: "sheets", + Command: "+sheet-copy", + Description: "Duplicate a sub-sheet, optionally renaming and repositioning the copy.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "title", Desc: "title for the duplicated sheet (server-generated when omitted)"}, + common.Flag{Name: "index", Type: "int", Default: "-1", Desc: "insertion position for the copy (0-based); omit to append"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + _, _, err := resolveSheetSelector(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", sheetCopyInput(runtime, token, sheetID, sheetName)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", sheetCopyInput(runtime, token, sheetID, sheetName)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func sheetCopyInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { + input := map[string]interface{}{"excel_id": token, "operation": "duplicate"} + sheetSelectorForToolInput(input, sheetID, sheetName) + if t := strings.TrimSpace(runtime.Str("title")); t != "" { + input["new_name"] = t + } + if runtime.Changed("index") { + input["target_index"] = runtime.Int("index") + } + return input +} + +// SheetHide / SheetUnhide toggle visibility. Visible bool semantics live in +// the operation enum so callers don't need a --visible flag. +var SheetHide = newSheetVisibilityShortcut( + "+sheet-hide", "Hide a sub-sheet from the tabs bar.", "hide", +) + +var SheetUnhide = newSheetVisibilityShortcut( + "+sheet-unhide", "Restore a hidden sub-sheet.", "unhide", +) + +func newSheetVisibilityShortcut(command, desc, op string) common.Shortcut { + return common.Shortcut{ + Service: "sheets", + Command: command, + Description: desc, + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: publicSheetFlags(), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + _, _, err := resolveSheetSelector(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input := map[string]interface{}{"excel_id": token, "operation": op} + sheetSelectorForToolInput(input, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input := map[string]interface{}{"excel_id": token, "operation": op} + sheetSelectorForToolInput(input, sheetID, sheetName) + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + } +} + +// SheetSetTabColor sets the tab color of a sub-sheet. --color "" clears. +var SheetSetTabColor = common.Shortcut{ + Service: "sheets", + Command: "+sheet-set-tab-color", + Description: "Set or clear the tab color of a sub-sheet.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "color", Desc: "hex color like #FF0000; pass empty string to clear"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if !runtime.Changed("color") { + return common.FlagErrorf("--color is required (empty string clears)") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input := map[string]interface{}{ + "excel_id": token, + "operation": "set_tab_color", + "tab_color": runtime.Str("color"), + } + sheetSelectorForToolInput(input, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input := map[string]interface{}{ + "excel_id": token, + "operation": "set_tab_color", + "tab_color": runtime.Str("color"), + } + sheetSelectorForToolInput(input, sheetID, sheetName) + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +// lookupSheetIndex finds a sub-sheet by id or name and returns its canonical +// id + current 0-based index. Caller is responsible for ensuring at least one +// of sheetID/sheetName is non-empty. +func lookupSheetIndex(ctx context.Context, runtime *common.RuntimeContext, token, sheetID, sheetName string) (resolvedID string, index int, err error) { + out, err := callTool(ctx, runtime, token, ToolKindRead, "get_workbook_structure", map[string]interface{}{ + "excel_id": token, + }) + if err != nil { + return "", 0, err + } + m, ok := out.(map[string]interface{}) + if !ok { + return "", 0, output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned non-object output") + } + sheets, _ := m["sheets"].([]interface{}) + for _, raw := range sheets { + sm, ok := raw.(map[string]interface{}) + if !ok { + continue + } + id, _ := sm["sheet_id"].(string) + name, _ := sm["sheet_name"].(string) + if (sheetID != "" && id == sheetID) || (sheetName != "" && name == sheetName) { + idx, ok := util.ToFloat64(sm["index"]) + if !ok { + return "", 0, output.Errorf(output.ExitAPI, "tool_output", "sheet entry missing index field") + } + return id, int(idx), nil + } + } + target := sheetID + if target == "" { + target = sheetName + } + return "", 0, output.Errorf(output.ExitAPI, "not_found", fmt.Sprintf("sheet %q not found in workbook", target)) +} diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index 47e6b5106..f1a911783 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -12,5 +12,13 @@ func Shortcuts() []common.Shortcut { return []common.Shortcut{ // lark_sheet_workbook WorkbookInfo, + SheetCreate, + SheetDelete, + SheetRename, + SheetMove, + SheetCopy, + SheetHide, + SheetUnhide, + SheetSetTabColor, } } diff --git a/skills/lark-sheets/references/lark-sheets-workbook.md b/skills/lark-sheets/references/lark-sheets-workbook.md index d7d2a7eb6..78148673b 100644 --- a/skills/lark-sheets/references/lark-sheets-workbook.md +++ b/skills/lark-sheets/references/lark-sheets-workbook.md @@ -65,8 +65,6 @@ | --- | --- | --- | --- | --- | | `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | | `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | | `--title` | 专有 | string | 是 | 新工作表名称 | | `--index` | 专有 | int | 否 | 插入位置;省略时附加到末尾 | | `--row-count` | 专有 | int | 否 | 初始行数,默认 100 | @@ -104,6 +102,7 @@ | `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | | `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | | `--index` | 专有 | int | 是 | 目标位置(0-based) | +| `--source-index` | 专有 | int | 否 | 源位置(0-based);可选,未传时由 CLI runtime 根据 --sheet-id / --sheet-name 当前在工作簿中的 index 自动派生 | | `--dry-run` | 系统 | bool | 否 | | ### `+sheet-copy` From ae728fe7ec6f9b421382091b97945479dd6908b7 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Sat, 16 May 2026 00:09:22 +0800 Subject: [PATCH 003/114] feat(sheets): implement lark_sheet_sheet_structure shortcuts (B2) Add 8 shortcuts under the lark_sheet_sheet_structure canonical skill: +sheet-info (get_sheet_structure) plus +dim-insert / +dim-delete / +dim-hide / +dim-unhide / +dim-freeze / +dim-group / +dim-ungroup (modify_sheet_structure, dispatched by operation enum). Two reusable conversion helpers cover the impedance mismatch between the CLI surface and the tool input: - dimRange / dimPosition translate the CLI's 0-based exclusive-end range into the tool's 1-based A1 notation. row 5..8 becomes position "6" + count 3 (insert) or range "6:8" (range ops); column 26..29 becomes "AA:AC". - infoTypeFromInclude maps the fine-grained --include vocabulary (row_heights / col_widths / merges / hidden_rows / hidden_cols / groups / frozen) to the coarse info_type enum the tool accepts; mixed categories collapse to "all". +dim-delete is high-risk-write (irreversible row/column removal). +dim-freeze --count 0 auto-dispatches to operation=unfreeze. +dim-group accepts --depth for forward-compat with a future server-side nested group endpoint but does not pass it through today. --- .../sheets/lark_sheet_sheet_structure.go | 497 ++++++++++++++++++ shortcuts/sheets/shortcuts.go | 10 + 2 files changed, 507 insertions(+) create mode 100644 shortcuts/sheets/lark_sheet_sheet_structure.go diff --git a/shortcuts/sheets/lark_sheet_sheet_structure.go b/shortcuts/sheets/lark_sheet_sheet_structure.go new file mode 100644 index 000000000..0e1626cca --- /dev/null +++ b/shortcuts/sheets/lark_sheet_sheet_structure.go @@ -0,0 +1,497 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "fmt" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── lark_sheet_sheet_structure ─────────────────────────────────────── +// +// Wraps get_sheet_structure (read) and modify_sheet_structure (write, +// operation-enum dispatch). CLI's --start/--end are 0-based with exclusive +// end; the tool wants 1-based inclusive row numbers ("3:7") or column +// letters ("C:F"). The conversion lives in dimRange / dimPosition below. +// +// +dim-resize lives in lark_sheet_range_operations (different tool); it is +// only grouped under "工作表" for discoverability. + +// SheetInfo wraps get_sheet_structure: row heights, column widths, hidden +// rows/cols, merged cells, row/column groups, and freeze counts for one +// sub-sheet (optionally limited to a range). +var SheetInfo = common.Shortcut{ + Service: "sheets", + Command: "+sheet-info", + Description: "Get a sub-sheet's layout metadata: row heights, column widths, hidden rows/cols, merges, groups, freeze.", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "range", Desc: "optional A1-style range to scope the query (e.g. A1:C20 / 3:6 / C:E); omit for whole sheet"}, + common.Flag{ + Name: "include", + Type: "string_slice", + Enum: []string{"merges", "row_heights", "col_widths", "hidden_rows", "hidden_cols", "groups", "frozen"}, + Desc: "filter returned categories (comma-separated). Omit for all.", + }, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + _, _, err := resolveSheetSelector(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindRead, "get_sheet_structure", sheetInfoInput(runtime, token, sheetID, sheetName)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindRead, "get_sheet_structure", sheetInfoInput(runtime, token, sheetID, sheetName)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + Tips: []string{ + "Frozen rows / columns are top-level fields and are returned regardless of --include.", + }, +} + +func sheetInfoInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { + input := map[string]interface{}{"excel_id": token} + sheetSelectorForToolInput(input, sheetID, sheetName) + if r := strings.TrimSpace(runtime.Str("range")); r != "" { + input["range"] = r + } + if include := runtime.StrSlice("include"); len(include) > 0 { + if t := infoTypeFromInclude(include); t != "" { + input["info_type"] = t + } + } + return input +} + +// infoTypeFromInclude maps the fine-grained --include vocabulary to the +// tool's coarse info_type enum. When --include spans multiple categories +// (or asks for "frozen", which is always returned), we fall back to "all". +func infoTypeFromInclude(include []string) string { + groups := map[string]string{ + "row_heights": "row_heights_column_widths", + "col_widths": "row_heights_column_widths", + "hidden_rows": "hidden_infos", + "hidden_cols": "hidden_infos", + "groups": "group_infos", + "merges": "merged_cells_infos", + "frozen": "", // any info_type returns frozen; falling back to all is fine + } + seen := map[string]struct{}{} + for _, v := range include { + g, ok := groups[v] + if !ok || g == "" { + return "all" + } + seen[g] = struct{}{} + } + if len(seen) != 1 { + return "all" + } + for g := range seen { + return g + } + return "all" +} + +// ─── +dim-* (modify_sheet_structure) ────────────────────────────────── + +// dimEnum bounds the allowed values for --dimension across every +dim-* shortcut. +var dimEnum = []string{"row", "column"} + +// DimInsert inserts blank rows / columns and optionally inherits style from +// the adjacent dimension. +var DimInsert = common.Shortcut{ + Service: "sheets", + Command: "+dim-insert", + Description: "Insert blank rows or columns at a given range.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "dimension", Required: true, Enum: dimEnum, Desc: "`row` or `column`"}, + common.Flag{Name: "start", Type: "int", Required: true, Desc: "0-based start position (inclusive)"}, + common.Flag{Name: "end", Type: "int", Required: true, Desc: "0-based end position (exclusive)"}, + common.Flag{Name: "inherit-style", Enum: []string{"before", "after", "none"}, Default: "none", Desc: "inherit cell style from the row/column before, after, or neither"}, + ), + Validate: validateDimRange, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", dimInsertInput(runtime, token, sheetID, sheetName)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", dimInsertInput(runtime, token, sheetID, sheetName)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func dimInsertInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { + dim := runtime.Str("dimension") + start := runtime.Int("start") + end := runtime.Int("end") + input := map[string]interface{}{ + "excel_id": token, + "operation": "insert", + "position": dimPosition(dim, start), + "count": end - start, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + switch runtime.Str("inherit-style") { + case "before": + input["side"] = "before" + case "after": + input["side"] = "after" + } + return input +} + +// DimDelete deletes rows / columns — irreversible, high-risk-write. +var DimDelete = common.Shortcut{ + Service: "sheets", + Command: "+dim-delete", + Description: "Delete rows or columns (irreversible).", + Risk: "high-risk-write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "dimension", Required: true, Enum: dimEnum, Desc: "`row` or `column`"}, + common.Flag{Name: "start", Type: "int", Required: true, Desc: "0-based start position (inclusive)"}, + common.Flag{Name: "end", Type: "int", Required: true, Desc: "0-based end position (exclusive)"}, + ), + Validate: validateDimRange, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", dimRangeOpInput(runtime, token, sheetID, sheetName, "delete")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", dimRangeOpInput(runtime, token, sheetID, sheetName, "delete")) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + Tips: []string{ + "Row/column deletion is irreversible. Always preview with --dry-run first.", + }, +} + +// DimHide / DimUnhide toggle visibility on a row/column range. +var DimHide = newDimRangeOpShortcut( + "+dim-hide", "Hide rows or columns within a range.", "hide", "write", +) +var DimUnhide = newDimRangeOpShortcut( + "+dim-unhide", "Unhide rows or columns within a range.", "unhide", "write", +) + +// DimGroup / DimUngroup manage row/column outline groups. +var DimGroup = newDimGroupShortcut( + "+dim-group", "Group rows or columns into an outline (collapsible).", "group", +) +var DimUngroup = newDimGroupShortcut( + "+dim-ungroup", "Remove a row/column outline group.", "ungroup", +) + +// DimFreeze freezes the first N rows or columns; --count 0 unfreezes that +// dimension. +var DimFreeze = common.Shortcut{ + Service: "sheets", + Command: "+dim-freeze", + Description: "Freeze the first N rows or columns; --count 0 unfreezes the chosen dimension.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "dimension", Required: true, Enum: dimEnum, Desc: "`row` or `column`"}, + common.Flag{Name: "count", Type: "int", Required: true, Desc: "number of leading rows/columns to freeze; 0 unfreezes the chosen dimension"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if !runtime.Changed("dimension") { + return common.FlagErrorf("--dimension is required") + } + if !runtime.Changed("count") { + return common.FlagErrorf("--count is required (0 unfreezes)") + } + if runtime.Int("count") < 0 { + return common.FlagErrorf("--count must be >= 0") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", dimFreezeInput(runtime, token, sheetID, sheetName)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", dimFreezeInput(runtime, token, sheetID, sheetName)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func dimFreezeInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { + dim := runtime.Str("dimension") + count := runtime.Int("count") + op := "freeze" + if count == 0 { + op = "unfreeze" + } + input := map[string]interface{}{"excel_id": token, "operation": op} + sheetSelectorForToolInput(input, sheetID, sheetName) + if op == "freeze" { + if dim == "row" { + input["freeze_rows"] = count + } else { + input["freeze_columns"] = count + } + } + return input +} + +// validateDimRange validates the public XOR pair and dimension/start/end +// triple shared by insert/delete/hide/unhide/group/ungroup. +func validateDimRange(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if !runtime.Changed("dimension") { + return common.FlagErrorf("--dimension is required") + } + if !runtime.Changed("start") || !runtime.Changed("end") { + return common.FlagErrorf("--start and --end are required") + } + start := runtime.Int("start") + end := runtime.Int("end") + if start < 0 { + return common.FlagErrorf("--start must be >= 0") + } + if end <= start { + return common.FlagErrorf("--end (%d) must be greater than --start (%d)", end, start) + } + return nil +} + +// dimRangeOpInput builds the tool input for delete/hide/unhide which all +// take a `range` field. dimRange handles 0-based exclusive → 1-based inclusive. +func dimRangeOpInput(runtime *common.RuntimeContext, token, sheetID, sheetName, op string) map[string]interface{} { + input := map[string]interface{}{ + "excel_id": token, + "operation": op, + "range": dimRange(runtime.Str("dimension"), runtime.Int("start"), runtime.Int("end")), + } + sheetSelectorForToolInput(input, sheetID, sheetName) + return input +} + +// newDimRangeOpShortcut builds the shared shape for hide / unhide. +func newDimRangeOpShortcut(command, desc, op, risk string) common.Shortcut { + return common.Shortcut{ + Service: "sheets", + Command: command, + Description: desc, + Risk: risk, + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "dimension", Required: true, Enum: dimEnum, Desc: "`row` or `column`"}, + common.Flag{Name: "start", Type: "int", Required: true, Desc: "0-based start position (inclusive)"}, + common.Flag{Name: "end", Type: "int", Required: true, Desc: "0-based end position (exclusive)"}, + ), + Validate: validateDimRange, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", dimRangeOpInput(runtime, token, sheetID, sheetName, op)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", dimRangeOpInput(runtime, token, sheetID, sheetName, op)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + } +} + +// newDimGroupShortcut builds the shared shape for group / ungroup. It adds +// --depth (currently unused server-side — accepted for forward-compat per +// the canonical spec) and --group-state (group only, defaults to expand). +func newDimGroupShortcut(command, desc, op string) common.Shortcut { + flags := append(publicSheetFlags(), + common.Flag{Name: "dimension", Required: true, Enum: dimEnum, Desc: "`row` or `column`"}, + common.Flag{Name: "start", Type: "int", Required: true, Desc: "0-based start position (inclusive)"}, + common.Flag{Name: "end", Type: "int", Required: true, Desc: "0-based end position (exclusive)"}, + common.Flag{Name: "depth", Type: "int", Default: "1", Desc: "nesting level (currently honored only when the server-side endpoint supports it)"}, + ) + if op == "group" { + flags = append(flags, + common.Flag{Name: "group-state", Enum: []string{"expand", "fold"}, Default: "expand", Desc: "initial state of the new group"}, + ) + } + return common.Shortcut{ + Service: "sheets", + Command: command, + Description: desc, + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flags, + Validate: validateDimRange, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", dimGroupInput(runtime, token, sheetID, sheetName, op)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", dimGroupInput(runtime, token, sheetID, sheetName, op)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + } +} + +func dimGroupInput(runtime *common.RuntimeContext, token, sheetID, sheetName, op string) map[string]interface{} { + input := dimRangeOpInput(runtime, token, sheetID, sheetName, op) + if op == "group" { + if gs := runtime.Str("group-state"); gs != "" { + input["group_state"] = gs + } + } + return input +} + +// ─── dimension formatting helpers ───────────────────────────────────── + +// dimRange formats a CLI (0-based exclusive end) range as the tool's +// 1-based inclusive A1-style range string. row → "3:7", column → "C:F". +// A single-element range collapses to "3" / "C". +func dimRange(dimension string, start, end int) string { + if dimension == "column" { + startLetter := columnIndexToLetter(start) + endLetter := columnIndexToLetter(end - 1) + if start == end-1 { + return startLetter + } + return startLetter + ":" + endLetter + } + if start == end-1 { + return fmt.Sprintf("%d", start+1) + } + return fmt.Sprintf("%d:%d", start+1, end) +} + +// dimPosition formats a single CLI 0-based index as the tool's 1-based row +// number string or column letter. +func dimPosition(dimension string, idx int) string { + if dimension == "column" { + return columnIndexToLetter(idx) + } + return fmt.Sprintf("%d", idx+1) +} + +// columnIndexToLetter converts a 0-based column index to the spreadsheet +// letter notation (0 → "A", 25 → "Z", 26 → "AA", 701 → "ZZ", 702 → "AAA"). +func columnIndexToLetter(idx int) string { + if idx < 0 { + return "" + } + idx++ + var out []byte + for idx > 0 { + idx-- + out = append([]byte{byte('A' + idx%26)}, out...) + idx /= 26 + } + return string(out) +} diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index f1a911783..2ad6a4b5f 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -20,5 +20,15 @@ func Shortcuts() []common.Shortcut { SheetHide, SheetUnhide, SheetSetTabColor, + + // lark_sheet_sheet_structure + SheetInfo, + DimInsert, + DimDelete, + DimHide, + DimUnhide, + DimFreeze, + DimGroup, + DimUngroup, } } From 8494534c8fcfdf320aa669f7f7dce146a4ee3010 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Sat, 16 May 2026 22:26:17 +0800 Subject: [PATCH 004/114] feat(sheets): implement read_data / search_replace / write_cells shortcuts (B3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land 11 shortcuts across three canonical skills: - lark_sheet_read_data (3): +cells-get / +csv-get / +dropdown-get - lark_sheet_search_replace (2): +cells-search / +cells-replace - lark_sheet_write_cells (6): +cells-set / +cells-set-style / +csv-put / +dropdown-set / +dropdown-update / +dropdown-delete +dropdown-get reads the data_validation field via get_cell_ranges with the range carrying its own sheet prefix (no --sheet-id needed). The fine-grained --include vocabulary (value / formula / style / comment / data_validation) maps to the tool's coarse include_styles bool plus value_render_option enum. +csv-get's --include-row-prefix=false strips the [row=N] prefix client-side because the tool only emits the annotated form. +cells-search / +cells-replace flatten the tool's options sub-object into four independent flags (--match-case / --match-entire-cell / --regex / --include-formulas) per the flat-flag rule, then repack them on the way in. +cells-set takes a raw --data JSON body whose `cells` array must match the --range dimensions. +cells-set-style fans a single --style block out to every cell in the range via a new fillCellsMatrix helper; the range parser (rangeDimensions / splitCellRef / letterToColumnIndex) only accepts rectangular A1:B2 forms — whole-column / whole-row need sheet totals and are deferred. +dropdown-set fans the validation block out to one range; +dropdown- update / +dropdown-delete iterate sheet-prefixed --ranges and call set_cell_range sequentially (partial failure leaves earlier ranges already mutated; the Tip calls this out). +dropdown-delete is high-risk-write and requires --yes. +cells-set-image stays deferred to the cli-only batch (needs the shared local-file upload helper alongside +workbook-create / +dim-move / +workbook-export). --- shortcuts/sheets/helpers.go | 48 ++ shortcuts/sheets/lark_sheet_read_data.go | 271 +++++++ shortcuts/sheets/lark_sheet_search_replace.go | 189 +++++ shortcuts/sheets/lark_sheet_write_cells.go | 750 ++++++++++++++++++ shortcuts/sheets/shortcuts.go | 17 + .../references/lark-sheets-batch-update.md | 26 + .../references/lark-sheets-read-data.md | 10 + .../references/lark-sheets-write-cells.md | 22 +- 8 files changed, 1312 insertions(+), 21 deletions(-) create mode 100644 shortcuts/sheets/lark_sheet_read_data.go create mode 100644 shortcuts/sheets/lark_sheet_search_replace.go create mode 100644 shortcuts/sheets/lark_sheet_write_cells.go diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go index 4fbcfb730..51874f4d6 100644 --- a/shortcuts/sheets/helpers.go +++ b/shortcuts/sheets/helpers.go @@ -8,6 +8,7 @@ package sheets import ( + "encoding/json" "strings" "github.com/larksuite/cli/internal/validate" @@ -118,3 +119,50 @@ func sheetSelectorPlaceholder(sheetID, sheetName string) string { } return "" } + +// parseJSONFlag parses a JSON string from a flag value. Returns nil when the +// flag is empty (caller decides if that's acceptable). Used by --data / +// --style / --options / --ranges / --colors and friends. +func parseJSONFlag(runtime *common.RuntimeContext, name string) (interface{}, error) { + raw := strings.TrimSpace(runtime.Str(name)) + if raw == "" { + return nil, nil + } + var out interface{} + if err := json.Unmarshal([]byte(raw), &out); err != nil { + return nil, common.FlagErrorf("--%s: invalid JSON: %v", name, err) + } + return out, nil +} + +// requireJSONObject is parseJSONFlag + a type assertion to map[string]interface{}. +func requireJSONObject(runtime *common.RuntimeContext, name string) (map[string]interface{}, error) { + v, err := parseJSONFlag(runtime, name) + if err != nil { + return nil, err + } + if v == nil { + return nil, common.FlagErrorf("--%s is required", name) + } + m, ok := v.(map[string]interface{}) + if !ok { + return nil, common.FlagErrorf("--%s must be a JSON object", name) + } + return m, nil +} + +// requireJSONArray is parseJSONFlag + a type assertion to []interface{}. +func requireJSONArray(runtime *common.RuntimeContext, name string) ([]interface{}, error) { + v, err := parseJSONFlag(runtime, name) + if err != nil { + return nil, err + } + if v == nil { + return nil, common.FlagErrorf("--%s is required", name) + } + a, ok := v.([]interface{}) + if !ok { + return nil, common.FlagErrorf("--%s must be a JSON array", name) + } + return a, nil +} diff --git a/shortcuts/sheets/lark_sheet_read_data.go b/shortcuts/sheets/lark_sheet_read_data.go new file mode 100644 index 000000000..428c209ab --- /dev/null +++ b/shortcuts/sheets/lark_sheet_read_data.go @@ -0,0 +1,271 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── lark_sheet_read_data ───────────────────────────────────────────── +// +// Wraps: +// - get_cell_ranges (powers +cells-get and +dropdown-get) +// - get_range_as_csv (powers +csv-get) +// +// The sandbox tool (export_sheet_to_sandbox) is Sheet-Tool-only and has no +// CLI surface here. + +var cellsGetIncludeEnum = []string{"value", "formula", "style", "comment", "data_validation"} + +// CellsGet wraps get_cell_ranges: read multiple A1 ranges and return per-cell +// values, formulas, styles, and other metadata as requested via --include. +var CellsGet = common.Shortcut{ + Service: "sheets", + Command: "+cells-get", + Description: "Read one or more cell ranges with values, formulas, and optional styles / comments / data validation.", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "ranges", Type: "string_array", Required: true, Desc: "A1 ranges (repeat: --ranges A1:B2 --ranges D1:E5)"}, + common.Flag{Name: "include", Type: "string_slice", Enum: cellsGetIncludeEnum, Desc: "categories to include (default: value+style). value|formula|style|comment|data_validation"}, + common.Flag{Name: "skip-hidden", Type: "bool", Desc: "skip hidden rows/cols"}, + common.Flag{Name: "cell-limit", Type: "int", Default: "5000", Hidden: true, Desc: "anti-burst cell scan cap"}, + common.Flag{Name: "max-chars", Type: "int", Default: "200000", Hidden: true, Desc: "anti-burst response char cap"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if len(runtime.StrArray("ranges")) == 0 { + return common.FlagErrorf("--ranges is required") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindRead, "get_cell_ranges", cellsGetInput(runtime, token, sheetID, sheetName)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindRead, "get_cell_ranges", cellsGetInput(runtime, token, sheetID, sheetName)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func cellsGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { + input := map[string]interface{}{ + "excel_id": token, + "ranges": runtime.StrArray("ranges"), + } + sheetSelectorForToolInput(input, sheetID, sheetName) + applyIncludeToCellsGet(input, runtime.StrSlice("include")) + if runtime.Bool("skip-hidden") { + input["skip_hidden"] = true + } + if n := runtime.Int("cell-limit"); n > 0 { + input["cell_limit"] = n + } + if n := runtime.Int("max-chars"); n > 0 { + input["max_chars"] = n + } + return input +} + +// applyIncludeToCellsGet maps the fine-grained --include vocabulary to the +// tool's two coarse switches: +// +// - include_styles (bool) — toggled by "style" presence +// - value_render_option (enum) — "formula" → formula; otherwise omitted +// +// "value", "comment", and "data_validation" are always returned by the tool +// per the schema; they have no dedicated knob today but are accepted in +// --include for forward-compat with finer-grained server support. +func applyIncludeToCellsGet(input map[string]interface{}, include []string) { + if len(include) == 0 { + return + } + want := map[string]bool{} + for _, v := range include { + want[v] = true + } + if want["style"] { + input["include_styles"] = true + } else { + input["include_styles"] = false + } + if want["formula"] { + input["value_render_option"] = "formula" + } +} + +// CsvGet wraps get_range_as_csv: pull one range as RFC 4180 CSV with optional +// [row=N] line prefix for easy row-number lookup. +var CsvGet = common.Shortcut{ + Service: "sheets", + Command: "+csv-get", + Description: "Read a range as CSV (with [row=N] line prefix by default).", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "range", Desc: "A1 range; omit for the sheet's current_region"}, + common.Flag{ + Name: "value-render-option", Enum: []string{"formatted_value", "raw_value", "formula"}, + Desc: "value rendering: formatted_value (default) / raw_value / formula", + }, + common.Flag{Name: "include-row-prefix", Type: "bool", Default: "true", Desc: "keep [row=N] line prefix; pass --include-row-prefix=false to strip"}, + common.Flag{Name: "skip-hidden", Type: "bool", Desc: "skip hidden rows/cols"}, + common.Flag{Name: "max-rows", Type: "int", Default: "100000", Hidden: true, Desc: "anti-burst row cap"}, + common.Flag{Name: "max-chars", Type: "int", Default: "200000", Hidden: true, Desc: "anti-burst char cap"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + _, _, err := resolveSheetSelector(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindRead, "get_range_as_csv", csvGetInput(runtime, token, sheetID, sheetName)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindRead, "get_range_as_csv", csvGetInput(runtime, token, sheetID, sheetName)) + if err != nil { + return err + } + if !runtime.Bool("include-row-prefix") { + out = stripRowPrefixFromCsvOutput(out) + } + runtime.Out(out, nil) + return nil + }, +} + +func csvGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { + input := map[string]interface{}{"excel_id": token} + sheetSelectorForToolInput(input, sheetID, sheetName) + if r := strings.TrimSpace(runtime.Str("range")); r != "" { + input["range"] = r + } + if v := runtime.Str("value-render-option"); v != "" { + input["value_render_option"] = v + } + if runtime.Bool("skip-hidden") { + input["skip_hidden"] = true + } + if n := runtime.Int("max-rows"); n > 0 { + input["max_rows"] = n + } + if n := runtime.Int("max-chars"); n > 0 { + input["max_chars"] = n + } + return input +} + +// stripRowPrefixFromCsvOutput removes "[row=N]" line prefixes from the tool's +// annotated_csv field. Operates client-side because the tool only emits the +// annotated form. +func stripRowPrefixFromCsvOutput(out interface{}) interface{} { + m, ok := out.(map[string]interface{}) + if !ok { + return out + } + csv, ok := m["annotated_csv"].(string) + if !ok { + return out + } + lines := strings.Split(csv, "\n") + for i, line := range lines { + if idx := strings.Index(line, "]"); idx >= 0 && strings.HasPrefix(line, "[row=") { + rest := line[idx+1:] + lines[i] = strings.TrimPrefix(rest, ",") + } + } + m["annotated_csv"] = strings.Join(lines, "\n") + return m +} + +// DropdownGet wraps get_cell_ranges scoped to data_validation: read the +// dropdown configuration on a range. The range carries its own sheet prefix +// (e.g. "sheet1!A2:A100"), so no separate --sheet-id / --sheet-name is needed. +var DropdownGet = common.Shortcut{ + Service: "sheets", + Command: "+dropdown-get", + Description: "Read the dropdown / data-validation configuration on a sheet-prefixed range.", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicTokenFlags(), + common.Flag{Name: "range", Required: true, Desc: "A1 range with sheet prefix (e.g. sheet1!A2:A100)"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return common.FlagErrorf("--range is required") + } + if !strings.Contains(runtime.Str("range"), "!") { + return common.FlagErrorf("--range must include a sheet prefix (e.g. sheet1!A2:A100)") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + return invokeToolDryRun(token, ToolKindRead, "get_cell_ranges", dropdownGetInput(runtime, token)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindRead, "get_cell_ranges", dropdownGetInput(runtime, token)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func dropdownGetInput(runtime *common.RuntimeContext, token string) map[string]interface{} { + return map[string]interface{}{ + "excel_id": token, + "ranges": []string{strings.TrimSpace(runtime.Str("range"))}, + "include_styles": false, + "value_render_option": "formatted_value", + } +} diff --git a/shortcuts/sheets/lark_sheet_search_replace.go b/shortcuts/sheets/lark_sheet_search_replace.go new file mode 100644 index 000000000..a332b207f --- /dev/null +++ b/shortcuts/sheets/lark_sheet_search_replace.go @@ -0,0 +1,189 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── lark_sheet_search_replace ──────────────────────────────────────── +// +// Wraps search_data (read) and replace_data (write). Both tools take an +// `options` sub-object; the CLI flattens its common booleans +// (--match-case / --match-entire-cell / --regex / --include-formulas) into +// independent flags per the铁律. + +// CellsSearch wraps search_data: find cell coordinates matching --find, +// with optional case / regex / whole-cell / formula-text controls. +var CellsSearch = common.Shortcut{ + Service: "sheets", + Command: "+cells-search", + Description: "Find cells matching --find in a spreadsheet (case / regex / whole-cell / formula-text controls).", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "find", Required: true, Desc: "text to search for (regex when --regex is set)"}, + common.Flag{Name: "range", Desc: "optional A1 range to scope the search"}, + common.Flag{Name: "match-case", Type: "bool", Desc: "case-sensitive match"}, + common.Flag{Name: "match-entire-cell", Type: "bool", Desc: "match the entire cell content only"}, + common.Flag{Name: "regex", Type: "bool", Desc: "treat --find as a regular expression"}, + common.Flag{Name: "include-formulas", Type: "bool", Desc: "also search inside formula text"}, + common.Flag{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset (use next_offset from previous page)"}, + common.Flag{Name: "max-matches", Type: "int", Default: "5000", Hidden: true, Desc: "anti-burst match cap"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("find")) == "" { + return common.FlagErrorf("--find is required") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindRead, "search_data", searchInput(runtime, token, sheetID, sheetName)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindRead, "search_data", searchInput(runtime, token, sheetID, sheetName)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func searchInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { + input := map[string]interface{}{ + "excel_id": token, + "search_term": runtime.Str("find"), + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if r := strings.TrimSpace(runtime.Str("range")); r != "" { + input["range"] = r + } + if runtime.Changed("offset") && runtime.Int("offset") > 0 { + input["offset"] = runtime.Int("offset") + } + if opts := searchReplaceOptions(runtime); len(opts) > 0 { + input["options"] = opts + } + if n := runtime.Int("max-matches"); n > 0 { + input["max_matches"] = n + } + return input +} + +// searchReplaceOptions packs the four shared boolean flags into the tool's +// `options` sub-object. Empty result → caller should omit the field. +func searchReplaceOptions(runtime *common.RuntimeContext) map[string]interface{} { + opts := map[string]interface{}{} + if runtime.Bool("match-case") { + opts["match_case"] = true + } + if runtime.Bool("match-entire-cell") { + opts["match_entire_cell"] = true + } + if runtime.Bool("regex") { + opts["regex"] = true + } + if runtime.Bool("include-formulas") { + opts["include_formulas"] = true + } + return opts +} + +// CellsReplace wraps replace_data: find and replace text across a +// spreadsheet, with the same option controls as +cells-search. +var CellsReplace = common.Shortcut{ + Service: "sheets", + Command: "+cells-replace", + Description: "Find and replace text in a spreadsheet (case / regex / whole-cell / formula-text controls).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "find", Required: true, Desc: "text to find (regex when --regex is set)"}, + common.Flag{Name: "replace", Required: true, Desc: "replacement text (empty string deletes the match)"}, + common.Flag{Name: "range", Desc: "optional A1 range to scope the replace"}, + common.Flag{Name: "match-case", Type: "bool", Desc: "case-sensitive match"}, + common.Flag{Name: "match-entire-cell", Type: "bool", Desc: "match the entire cell content only"}, + common.Flag{Name: "regex", Type: "bool", Desc: "treat --find as a regular expression"}, + common.Flag{Name: "include-formulas", Type: "bool", Desc: "also replace inside formula text"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("find")) == "" { + return common.FlagErrorf("--find is required") + } + if !runtime.Changed("replace") { + return common.FlagErrorf("--replace is required (pass an empty string to delete matches)") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindWrite, "replace_data", replaceInput(runtime, token, sheetID, sheetName)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "replace_data", replaceInput(runtime, token, sheetID, sheetName)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + Tips: []string{ + "Always preview with --dry-run before running — replace can mutate every matching cell across the sheet.", + }, +} + +func replaceInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { + input := map[string]interface{}{ + "excel_id": token, + "search_term": runtime.Str("find"), + "replace_term": runtime.Str("replace"), + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if r := strings.TrimSpace(runtime.Str("range")); r != "" { + input["range"] = r + } + if opts := searchReplaceOptions(runtime); len(opts) > 0 { + input["options"] = opts + } + return input +} diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go new file mode 100644 index 000000000..f757a9f4c --- /dev/null +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -0,0 +1,750 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── lark_sheet_write_cells ─────────────────────────────────────────── +// +// Wraps: +// - set_cell_range (powers +cells-set / +cells-set-style / +// +dropdown-set / +dropdown-update / +dropdown-delete) +// - set_range_from_csv (powers +csv-put) +// +// +cells-set-image is a `cli_only_derivative` shortcut (needs a local file +// upload before calling set_cell_range); it lives in the cli-only batch +// where the upload helper is shared with +workbook-create / +dim-move / +// +workbook-export. +// +// All set_cell_range-backed shortcuts construct a cells matrix whose +// dimensions exactly match the target range — the tool errors on mismatch. + +// CellsSet wraps set_cell_range with raw --data: caller provides the cells +// matrix (and any optional copy_to_range / resize_* fields) as JSON. +var CellsSet = common.Shortcut{ + Service: "sheets", + Command: "+cells-set", + Description: "Write values / formulas / styles / comments / data validation / embed-image to a cell range.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "range", Required: true, Desc: "target A1 range (e.g. A1:C10); cells dimensions must match"}, + common.Flag{Name: "data", Input: []string{common.File, common.Stdin}, Required: true, + Desc: "JSON body: { \"cells\": [[{value|formula|cell_styles|...}, ...]], optional copy_to_range / resize_width / resize_height }"}, + common.Flag{Name: "allow-overwrite", Type: "bool", Default: "true", Desc: "allow overwriting non-empty cells (default true)"}, + common.Flag{Name: "max-cells", Type: "int", Default: "50000", Hidden: true, Desc: "anti-burst cells write cap"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return common.FlagErrorf("--range is required") + } + body, err := requireJSONObject(runtime, "data") + if err != nil { + return err + } + if _, ok := body["cells"]; !ok { + return common.FlagErrorf("--data must include a \"cells\" field") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := cellsSetInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "set_cell_range", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := cellsSetInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func cellsSetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) (map[string]interface{}, error) { + body, err := requireJSONObject(runtime, "data") + if err != nil { + return nil, err + } + input := map[string]interface{}{ + "excel_id": token, + "range": strings.TrimSpace(runtime.Str("range")), + } + sheetSelectorForToolInput(input, sheetID, sheetName) + // --data fields override any of these except the core selectors. + for k, v := range body { + switch k { + case "excel_id", "range", "sheet_id", "sheet_name": + // reserved for flat flags + default: + input[k] = v + } + } + if !runtime.Bool("allow-overwrite") { + input["allow_overwrite"] = false + } + return input, nil +} + +// CellsSetStyle wraps set_cell_range applied to a uniform style: parse +// --style once, fan it out to a (rows × cols) cells matrix, and let +// set_cell_range stamp every cell in the range with that style. +var CellsSetStyle = common.Shortcut{ + Service: "sheets", + Command: "+cells-set-style", + Description: "Apply a single style block to every cell in a range (values / formulas untouched).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "range", Required: true, Desc: "target A1 range (e.g. A1:B2)"}, + common.Flag{Name: "style", Input: []string{common.File, common.Stdin}, Required: true, + Desc: "style JSON: { font, backColor, horizontal_alignment, vertical_alignment, ... , optional border_styles }"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + r := strings.TrimSpace(runtime.Str("range")) + if r == "" { + return common.FlagErrorf("--range is required") + } + if _, _, err := rangeDimensions(r); err != nil { + return common.FlagErrorf("--range %q: %v", r, err) + } + if _, err := requireJSONObject(runtime, "style"); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := cellsSetStyleInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "set_cell_range", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := cellsSetStyleInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func cellsSetStyleInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) (map[string]interface{}, error) { + style, err := requireJSONObject(runtime, "style") + if err != nil { + return nil, err + } + rangeStr := strings.TrimSpace(runtime.Str("range")) + rows, cols, err := rangeDimensions(rangeStr) + if err != nil { + return nil, common.FlagErrorf("--range %q: %v", rangeStr, err) + } + // Split border_styles out of the style block since the tool models it + // as a sibling field of cell_styles. + cellStyle := map[string]interface{}{} + var borderStyles interface{} + for k, v := range style { + if k == "border_styles" { + borderStyles = v + continue + } + cellStyle[k] = v + } + cells := make([][]interface{}, rows) + for r := range cells { + row := make([]interface{}, cols) + for c := range row { + cell := map[string]interface{}{"cell_styles": cellStyle} + if borderStyles != nil { + cell["border_styles"] = borderStyles + } + row[c] = cell + } + cells[r] = row + } + input := map[string]interface{}{ + "excel_id": token, + "range": rangeStr, + "cells": cells, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + return input, nil +} + +// CsvPut wraps set_range_from_csv: dump a CSV blob into a sheet, only writing +// plain values. Use +cells-set for anything richer (formula / style / note). +var CsvPut = common.Shortcut{ + Service: "sheets", + Command: "+csv-put", + Description: "Paste RFC-4180 CSV into a sheet at --start-cell (plain values only, auto-expands sheet if needed).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "csv", Input: []string{common.File, common.Stdin}, Required: true, + Desc: "CSV text (RFC 4180); supports @file or stdin via -"}, + common.Flag{Name: "start-cell", Default: "A1", Required: true, Desc: "single A1 anchor cell, e.g. A1 / B5"}, + common.Flag{Name: "allow-overwrite", Type: "bool", Default: "true", + Desc: "allow overwriting non-empty cells (default true); false errors if any target cell is non-empty"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("csv")) == "" { + return common.FlagErrorf("--csv is required") + } + anchor := strings.TrimSpace(runtime.Str("start-cell")) + if anchor == "" { + return common.FlagErrorf("--start-cell is required") + } + if _, _, ok := splitCellRef(anchor); !ok { + return common.FlagErrorf("--start-cell %q must be a single cell ref (e.g. A1)", anchor) + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindWrite, "set_range_from_csv", csvPutInput(runtime, token, sheetID, sheetName)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_range_from_csv", csvPutInput(runtime, token, sheetID, sheetName)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func csvPutInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { + input := map[string]interface{}{ + "excel_id": token, + "csv": runtime.Str("csv"), + "start_cell": strings.TrimSpace(runtime.Str("start-cell")), + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if !runtime.Bool("allow-overwrite") { + input["allow_overwrite"] = false + } + return input +} + +// ─── +dropdown-* (set_cell_range via data_validation) ───────────────── +// +// All three dropdown shortcuts stamp a `data_validation` block on every cell +// of the target range(s). set / update / delete differ in (a) how many +// ranges they accept and (b) whether the block is populated or null. + +// DropdownSet places a single dropdown on one range. +var DropdownSet = common.Shortcut{ + Service: "sheets", + Command: "+dropdown-set", + Description: "Attach a dropdown / data-validation list to every cell in --range.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "range", Required: true, Desc: "target A1 range (e.g. A2:A100)"}, + common.Flag{Name: "options", Input: []string{common.File, common.Stdin}, Required: true, + Desc: "options JSON array (e.g. [\"opt1\",\"opt2\"]); ≤500 items, ≤100 chars each, no commas"}, + common.Flag{Name: "colors", Input: []string{common.File, common.Stdin}, + Desc: "optional RGB hex array (e.g. [\"#1FB6C1\",\"#F006C2\"]); length must equal --options"}, + common.Flag{Name: "multiple", Type: "bool", Desc: "enable multi-select; default false"}, + common.Flag{Name: "highlight", Type: "bool", Desc: "color-highlight options; default false"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + r := strings.TrimSpace(runtime.Str("range")) + if r == "" { + return common.FlagErrorf("--range is required") + } + if _, _, err := rangeDimensions(r); err != nil { + return common.FlagErrorf("--range %q: %v", r, err) + } + if _, err := validateDropdownOptionsColors(runtime); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := dropdownSetInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "set_cell_range", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := dropdownSetInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func dropdownSetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) (map[string]interface{}, error) { + validation, err := buildDropdownValidation(runtime) + if err != nil { + return nil, err + } + rangeStr := strings.TrimSpace(runtime.Str("range")) + rows, cols, err := rangeDimensions(rangeStr) + if err != nil { + return nil, common.FlagErrorf("--range %q: %v", rangeStr, err) + } + cells := fillCellsMatrix(rows, cols, map[string]interface{}{"data_validation": validation}) + input := map[string]interface{}{ + "excel_id": token, + "range": rangeStr, + "cells": cells, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + return input, nil +} + +// DropdownUpdate replaces (or installs) dropdowns on multiple ranges via +// sequential set_cell_range calls. Sheet ids are derived from the per-range +// sheet prefix; the public --sheet-id / --sheet-name flags are not used here. +var DropdownUpdate = common.Shortcut{ + Service: "sheets", + Command: "+dropdown-update", + Description: "Update dropdown configuration across multiple sheet-prefixed ranges.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicTokenFlags(), + common.Flag{Name: "ranges", Input: []string{common.File, common.Stdin}, Required: true, + Desc: "JSON array of sheet-prefixed A1 ranges (e.g. [\"sheet1!A2:A100\"])"}, + common.Flag{Name: "options", Input: []string{common.File, common.Stdin}, Required: true, Desc: "options JSON array"}, + common.Flag{Name: "colors", Input: []string{common.File, common.Stdin}, Desc: "optional hex color array (must equal --options length)"}, + common.Flag{Name: "multiple", Type: "bool", Desc: "enable multi-select"}, + common.Flag{Name: "highlight", Type: "bool", Desc: "color-highlight options"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, err := validateDropdownRanges(runtime); err != nil { + return err + } + if _, err := validateDropdownOptionsColors(runtime); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + validation, _ := buildDropdownValidation(runtime) + ranges, _ := validateDropdownRanges(runtime) + dry := common.NewDryRunAPI() + for _, rng := range ranges { + sheet, sub, _ := splitSheetPrefixedRange(rng) + rows, cols, _ := rangeDimensions(sub) + cells := fillCellsMatrix(rows, cols, map[string]interface{}{"data_validation": validation}) + body, _ := buildToolBody("set_cell_range", map[string]interface{}{ + "excel_id": token, + "range": sub, + "sheet_name": sheet, + "cells": cells, + }) + dry.POST(toolInvokePath(token, ToolKindWrite)).Body(body) + } + return dry. + Set("spreadsheet_token", token). + Set("tool_name", "set_cell_range"). + Set("invocation_count", len(ranges)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + validation, err := buildDropdownValidation(runtime) + if err != nil { + return err + } + ranges, err := validateDropdownRanges(runtime) + if err != nil { + return err + } + results := make([]interface{}, 0, len(ranges)) + for _, rng := range ranges { + sheet, sub, err := splitSheetPrefixedRange(rng) + if err != nil { + return err + } + rows, cols, err := rangeDimensions(sub) + if err != nil { + return common.FlagErrorf("--ranges %q: %v", rng, err) + } + cells := fillCellsMatrix(rows, cols, map[string]interface{}{"data_validation": validation}) + input := map[string]interface{}{ + "excel_id": token, + "range": sub, + "sheet_name": sheet, + "cells": cells, + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", input) + if err != nil { + return fmt.Errorf("range %q failed: %w (partial: %d/%d done)", rng, err, len(results), len(ranges)) + } + results = append(results, map[string]interface{}{"range": rng, "result": out}) + } + runtime.Out(map[string]interface{}{"results": results}, nil) + return nil + }, + Tips: []string{ + "Calls set_cell_range once per range. A mid-batch failure leaves earlier ranges already updated — use --dry-run first to confirm the list.", + }, +} + +// DropdownDelete clears dropdowns from one or more sheet-prefixed ranges. +// high-risk-write — irreversible removal of validation rules. +var DropdownDelete = common.Shortcut{ + Service: "sheets", + Command: "+dropdown-delete", + Description: "Remove dropdowns from one or more sheet-prefixed ranges (irreversible).", + Risk: "high-risk-write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicTokenFlags(), + common.Flag{Name: "ranges", Input: []string{common.File, common.Stdin}, Required: true, + Desc: "JSON array of sheet-prefixed A1 ranges (max 100)"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + ranges, err := validateDropdownRanges(runtime) + if err != nil { + return err + } + if len(ranges) > 100 { + return common.FlagErrorf("--ranges accepts at most 100 entries; got %d", len(ranges)) + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + ranges, _ := validateDropdownRanges(runtime) + dry := common.NewDryRunAPI() + for _, rng := range ranges { + sheet, sub, _ := splitSheetPrefixedRange(rng) + rows, cols, _ := rangeDimensions(sub) + cells := fillCellsMatrix(rows, cols, map[string]interface{}{"data_validation": nil}) + body, _ := buildToolBody("set_cell_range", map[string]interface{}{ + "excel_id": token, + "range": sub, + "sheet_name": sheet, + "cells": cells, + }) + dry.POST(toolInvokePath(token, ToolKindWrite)).Body(body) + } + return dry. + Set("spreadsheet_token", token). + Set("tool_name", "set_cell_range"). + Set("invocation_count", len(ranges)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + ranges, err := validateDropdownRanges(runtime) + if err != nil { + return err + } + results := make([]interface{}, 0, len(ranges)) + for _, rng := range ranges { + sheet, sub, err := splitSheetPrefixedRange(rng) + if err != nil { + return err + } + rows, cols, err := rangeDimensions(sub) + if err != nil { + return common.FlagErrorf("--ranges %q: %v", rng, err) + } + cells := fillCellsMatrix(rows, cols, map[string]interface{}{"data_validation": nil}) + input := map[string]interface{}{ + "excel_id": token, + "range": sub, + "sheet_name": sheet, + "cells": cells, + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", input) + if err != nil { + return fmt.Errorf("range %q failed: %w (partial: %d/%d done)", rng, err, len(results), len(ranges)) + } + results = append(results, map[string]interface{}{"range": rng, "result": out}) + } + runtime.Out(map[string]interface{}{"results": results}, nil) + return nil + }, + Tips: []string{ + "Calls set_cell_range once per range. A mid-batch failure leaves earlier ranges already cleared.", + }, +} + +// ─── shared dropdown helpers ────────────────────────────────────────── + +// buildDropdownValidation packs --options / --colors / --multiple / --highlight +// into the data_validation block expected by set_cell_range. +func buildDropdownValidation(runtime *common.RuntimeContext) (map[string]interface{}, error) { + options, err := requireJSONArray(runtime, "options") + if err != nil { + return nil, err + } + dv := map[string]interface{}{ + "type": "list", + "values": options, + } + if runtime.Str("colors") != "" { + colors, err := requireJSONArray(runtime, "colors") + if err != nil { + return nil, err + } + if len(colors) != len(options) { + return nil, common.FlagErrorf("--colors length (%d) must equal --options length (%d)", len(colors), len(options)) + } + dv["colors"] = colors + } + if runtime.Bool("multiple") { + dv["multiple_values"] = true + } + if runtime.Bool("highlight") { + dv["highlight_options"] = true + } + return dv, nil +} + +// validateDropdownRanges parses --ranges, requires every entry to carry a +// sheet prefix, and returns the parsed list. +func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) { + raw, err := requireJSONArray(runtime, "ranges") + if err != nil { + return nil, err + } + out := make([]string, 0, len(raw)) + for i, v := range raw { + s, ok := v.(string) + if !ok { + return nil, common.FlagErrorf("--ranges[%d] must be a string", i) + } + s = strings.TrimSpace(s) + if !strings.Contains(s, "!") { + return nil, common.FlagErrorf("--ranges[%d] (%q) must include a sheet prefix", i, s) + } + out = append(out, s) + } + return out, nil +} + +// validateDropdownOptionsColors validates --options is a JSON array and that +// --colors (when set) has matching length. Used by +dropdown-set Validate. +func validateDropdownOptionsColors(runtime *common.RuntimeContext) (int, error) { + options, err := requireJSONArray(runtime, "options") + if err != nil { + return 0, err + } + if runtime.Str("colors") != "" { + colors, err := requireJSONArray(runtime, "colors") + if err != nil { + return 0, err + } + if len(colors) != len(options) { + return 0, common.FlagErrorf("--colors length (%d) must equal --options length (%d)", len(colors), len(options)) + } + } + return len(options), nil +} + +// ─── range parsing helpers ──────────────────────────────────────────── + +// rangeDimensions parses an A1 range like "A1:C5" / "A1" / "sheet1!B2:D10" +// and returns its row / column counts. Errors on non-rectangular forms like +// "A:C" (whole-column) or "3:6" (whole-row) — those need a row/col total +// from get_sheet_structure, outside the scope of pure local parsing. +func rangeDimensions(rangeStr string) (rows, cols int, err error) { + if idx := strings.Index(rangeStr, "!"); idx >= 0 { + rangeStr = rangeStr[idx+1:] + } + rangeStr = strings.TrimSpace(rangeStr) + if rangeStr == "" { + return 0, 0, fmt.Errorf("empty range") + } + parts := strings.SplitN(rangeStr, ":", 2) + if len(parts) == 1 { + // single cell, e.g. "A1" + if _, _, ok := splitCellRef(parts[0]); !ok { + return 0, 0, fmt.Errorf("invalid cell ref %q", parts[0]) + } + 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) + } + if endRow < startRow || endCol < startCol { + return 0, 0, fmt.Errorf("end %q must be at or after start %q", parts[1], parts[0]) + } + return endRow - startRow + 1, endCol - startCol + 1, nil +} + +// splitCellRef parses "A1" → (col=0, row=0, true). Returns false for any +// non-rectangular form (pure column "A", pure row "1", invalid chars). +func splitCellRef(s string) (col, row int, ok bool) { + s = strings.TrimSpace(s) + if s == "" { + return 0, 0, false + } + var colEnd int + for i, r := range s { + if r >= '0' && r <= '9' { + colEnd = i + break + } + colEnd = i + 1 + } + if colEnd == 0 || colEnd == len(s) { + return 0, 0, false + } + col = letterToColumnIndex(s[:colEnd]) + if col < 0 { + return 0, 0, false + } + n, err := strconv.Atoi(s[colEnd:]) + if err != nil || n < 1 { + return 0, 0, false + } + return col, n - 1, true +} + +// letterToColumnIndex converts spreadsheet letter notation to a 0-based +// column index ("A" → 0, "Z" → 25, "AA" → 26). Returns -1 on bad input. +func letterToColumnIndex(letters string) int { + letters = strings.ToUpper(strings.TrimSpace(letters)) + if letters == "" { + return -1 + } + n := 0 + for _, c := range letters { + if c < 'A' || c > 'Z' { + return -1 + } + n = n*26 + int(c-'A'+1) + } + return n - 1 +} + +// splitSheetPrefixedRange splits "sheet1!A2:A100" into ("sheet1", "A2:A100"). +func splitSheetPrefixedRange(rng string) (sheet, sub string, err error) { + idx := strings.Index(rng, "!") + if idx <= 0 || idx == len(rng)-1 { + return "", "", common.FlagErrorf("range %q must use sheet!range form", rng) + } + return strings.TrimSpace(rng[:idx]), strings.TrimSpace(rng[idx+1:]), 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. +func fillCellsMatrix(rows, cols int, prototype map[string]interface{}) [][]interface{} { + cells := make([][]interface{}, rows) + for r := range cells { + row := make([]interface{}, cols) + for c := range row { + cell := make(map[string]interface{}, len(prototype)) + for k, v := range prototype { + cell[k] = v + } + row[c] = cell + } + cells[r] = row + } + return cells +} diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index 2ad6a4b5f..816f16caf 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -30,5 +30,22 @@ func Shortcuts() []common.Shortcut { DimFreeze, DimGroup, DimUngroup, + + // lark_sheet_read_data + CellsGet, + CsvGet, + DropdownGet, + + // lark_sheet_search_replace + CellsSearch, + CellsReplace, + + // lark_sheet_write_cells + CellsSet, + CellsSetStyle, + CsvPut, + DropdownSet, + DropdownUpdate, + DropdownDelete, } } diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 853c8b6d5..71c2fd2ce 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -27,6 +27,7 @@ | MCP tool | CLI shortcut | Risk | 分组 | | --- | --- | --- | --- | | `batch_update` | `+batch-update` | high-risk-write | 批量 | +| | `+cells-batch-set-style` | write | 批量 | ## Flags @@ -42,6 +43,15 @@ | `--yes` | 系统 | bool | 是 | `high-risk-write`,必须二次确认(不带时退出码 10) | | `--dry-run` | 系统 | bool | 否 | 输出每个子操作的请求模板,零网络副作用 | +### `+cells-batch-set-style` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--data` | 专有 | string + File + Stdin | 是 | JSON 数组 `[{"ranges":["sheet1!A1:B2"],"style":{...}}]`;每个 ranges 元素必须带 sheet 前缀 | +| `--dry-run` | 系统 | bool | 否 | | + ## Schemas > 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 @@ -54,6 +64,22 @@ _要批量执行的操作列表,按顺序依次执行_ - `input` (object) — 对应工具的入参,结构与单独调用该工具时完全一致 - `tool_name` (string) — 要执行的工具名称,如 "set_cell_range"、"clear_cell_range"、"modify_sheet_structure" 等 +### `+cells-batch-set-style` `--data` + +_单元格样式属性,包括字体、颜色、对齐方式和数字格式_ + +**顶层字段**: +- `background_color` (string?) — 背景颜色(十六进制,例如 "#ffffff") +- `font_color` (string?) — 字体颜色(十六进制,例如 "#000000") +- `font_line` (enum?) — 字体线条样式 [none / underline / line-through] +- `font_size` (number?) — 字体大小(单位:px/像素,例如 10、12、14) +- `font_style` (enum?) — 字体样式 [normal / italic] +- `font_weight` (enum?) — 字重 [normal / bold] +- `horizontal_alignment` (enum?) — 水平对齐方式 [left / center / right] +- `number_format` (string?) — 数字格式(例如:文本用 "@"、数字用 "0.00"、货币用 "$#,##0.00"、日期用 "mm/dd/yyyy") +- `vertical_alignment` (enum?) — 垂直对齐方式 [top / middle / bottom] +- `word_wrap` (enum?) — 是否自动换行,默认溢出,可选自动换行或裁剪 [overflow / auto-wrap / word-clip] + ## Examples > shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 diff --git a/skills/lark-sheets/references/lark-sheets-read-data.md b/skills/lark-sheets/references/lark-sheets-read-data.md index 2a0366392..0791149a7 100644 --- a/skills/lark-sheets/references/lark-sheets-read-data.md +++ b/skills/lark-sheets/references/lark-sheets-read-data.md @@ -82,6 +82,7 @@ | --- | --- | --- | --- | | `export_sheet_to_sandbox` | _Sheet Tool 独有,CLI 不实现_ | — | — | | `get_cell_ranges` | `+cells-get` | read | 单元格 | +| | `+dropdown-get` | read | 对象 | | `get_range_as_csv` | `+csv-get` | read | 单元格 | ## Flags @@ -103,6 +104,15 @@ | `--skip-hidden` | 专有 | bool | 否 | 同上 | | `--dry-run` | 系统 | bool | 否 | | +### `+dropdown-get` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--range` | 专有 | string | 是 | 目标范围 A1 格式(含 sheet 前缀,如 `sheet1!A2:A100`) | +| `--dry-run` | 系统 | bool | 否 | | + ### `+csv-get` | Flag | 分类 | Type | 必填 | 说明 | diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 40dfa1f71..8b3f6282c 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -173,10 +173,8 @@ set_cell_range — range="A11:H11", cells=[[ | --- | --- | --- | --- | | `set_cell_range` | `+cells-set` | write | 单元格 | | | `+cells-set-style` | write | 单元格 | -| | `+cells-batch-set-style` | write | 批量 | | | `+cells-set-image` | write | 单元格 | | | `+dropdown-set` | write | 对象 | -| | `+dropdown-get` | read | 对象 | | | `+dropdown-update` | write | 对象 | | | `+dropdown-delete` | high-risk-write | 对象 | | `set_range_from_csv` | `+csv-put` | write | 单元格 | @@ -212,15 +210,6 @@ set_cell_range — range="A11:H11", cells=[[ | `--style` | 专有 | string + File + Stdin | 是 | 样式 JSON:`{"font":{"bold":true},"backColor":"#fff","border_styles":{...}}`;只改样式,不动 value/formula(底层走 set_cell_range 的 cell_styles + border_styles 字段) | | `--dry-run` | 系统 | bool | 否 | | -### `+cells-batch-set-style` - -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--data` | 专有 | string + File + Stdin | 是 | JSON 数组 `[{"ranges":["sheet1!A1:B2"],"style":{...}}]`;每个 ranges 元素必须带 sheet 前缀 | -| `--dry-run` | 系统 | bool | 否 | | - ### `+cells-set-image` | Flag | 分类 | Type | 必填 | 说明 | @@ -249,15 +238,6 @@ set_cell_range — range="A11:H11", cells=[[ | `--highlight` | 专有 | bool | 否 | 选项配色显示;默认 `false` | | `--dry-run` | 系统 | bool | 否 | | -### `+dropdown-get` - -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--range` | 专有 | string | 是 | 目标范围 A1 格式(含 sheet 前缀,如 `sheet1!A2:A100`) | -| `--dry-run` | 系统 | bool | 否 | | - ### `+dropdown-update` | Flag | 分类 | Type | 必填 | 说明 | @@ -311,7 +291,7 @@ set_cell_range — range="A11:H11", cells=[[ - `rich_text` (array?) — 富文本内容 each: { attachment_name?: string, attachment_token?: string, attachment_uri?: string, file_size?: number, image_height?: number, …共 17 项 } - `value` (oneOf?) — 静态单元格值(文本、数字、布尔) -### `+cells-set-style` `--style` / `+cells-batch-set-style` `--data` +### `+cells-set-style` `--style` _单元格样式属性,包括字体、颜色、对齐方式和数字格式_ From 0d351179e4ac48754bcc38f978e4f4621c1319f4 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Sat, 16 May 2026 22:43:17 +0800 Subject: [PATCH 005/114] refactor(sheets): move +dropdown-update / +dropdown-delete to lark_sheet_batch_update Follow-up to B3 after the spec re-mapped these two shortcuts to the batch_update tool (atomic multi-range CRUD) instead of fan-out via set_cell_range. Drop their Go implementations + helper validateDropdownRanges + splitSheetPrefixedRange from lark_sheet_write_cells.go and remove the registrations from Shortcuts(); the shortcuts will reappear under lark_sheet_batch_update during B7. Also pull in the re-rendered reference docs: - skills/lark-sheets/references/lark-sheets-write-cells.md - skills/lark-sheets/references/lark-sheets-batch-update.md --- shortcuts/sheets/lark_sheet_write_cells.go | 219 +----------------- shortcuts/sheets/shortcuts.go | 2 - .../references/lark-sheets-batch-update.md | 38 +++ .../references/lark-sheets-write-cells.md | 27 +-- 4 files changed, 43 insertions(+), 243 deletions(-) diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index f757a9f4c..1e34b3ac0 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -384,190 +384,10 @@ func dropdownSetInput(runtime *common.RuntimeContext, token, sheetID, sheetName return input, nil } -// DropdownUpdate replaces (or installs) dropdowns on multiple ranges via -// sequential set_cell_range calls. Sheet ids are derived from the per-range -// sheet prefix; the public --sheet-id / --sheet-name flags are not used here. -var DropdownUpdate = common.Shortcut{ - Service: "sheets", - Command: "+dropdown-update", - Description: "Update dropdown configuration across multiple sheet-prefixed ranges.", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only"}, - AuthTypes: []string{"user", "bot"}, - HasFormat: true, - Flags: append(publicTokenFlags(), - common.Flag{Name: "ranges", Input: []string{common.File, common.Stdin}, Required: true, - Desc: "JSON array of sheet-prefixed A1 ranges (e.g. [\"sheet1!A2:A100\"])"}, - common.Flag{Name: "options", Input: []string{common.File, common.Stdin}, Required: true, Desc: "options JSON array"}, - common.Flag{Name: "colors", Input: []string{common.File, common.Stdin}, Desc: "optional hex color array (must equal --options length)"}, - common.Flag{Name: "multiple", Type: "bool", Desc: "enable multi-select"}, - common.Flag{Name: "highlight", Type: "bool", Desc: "color-highlight options"}, - ), - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, err := validateDropdownRanges(runtime); err != nil { - return err - } - if _, err := validateDropdownOptionsColors(runtime); err != nil { - return err - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := resolveSpreadsheetToken(runtime) - validation, _ := buildDropdownValidation(runtime) - ranges, _ := validateDropdownRanges(runtime) - dry := common.NewDryRunAPI() - for _, rng := range ranges { - sheet, sub, _ := splitSheetPrefixedRange(rng) - rows, cols, _ := rangeDimensions(sub) - cells := fillCellsMatrix(rows, cols, map[string]interface{}{"data_validation": validation}) - body, _ := buildToolBody("set_cell_range", map[string]interface{}{ - "excel_id": token, - "range": sub, - "sheet_name": sheet, - "cells": cells, - }) - dry.POST(toolInvokePath(token, ToolKindWrite)).Body(body) - } - return dry. - Set("spreadsheet_token", token). - Set("tool_name", "set_cell_range"). - Set("invocation_count", len(ranges)) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, err := resolveSpreadsheetToken(runtime) - if err != nil { - return err - } - validation, err := buildDropdownValidation(runtime) - if err != nil { - return err - } - ranges, err := validateDropdownRanges(runtime) - if err != nil { - return err - } - results := make([]interface{}, 0, len(ranges)) - for _, rng := range ranges { - sheet, sub, err := splitSheetPrefixedRange(rng) - if err != nil { - return err - } - rows, cols, err := rangeDimensions(sub) - if err != nil { - return common.FlagErrorf("--ranges %q: %v", rng, err) - } - cells := fillCellsMatrix(rows, cols, map[string]interface{}{"data_validation": validation}) - input := map[string]interface{}{ - "excel_id": token, - "range": sub, - "sheet_name": sheet, - "cells": cells, - } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", input) - if err != nil { - return fmt.Errorf("range %q failed: %w (partial: %d/%d done)", rng, err, len(results), len(ranges)) - } - results = append(results, map[string]interface{}{"range": rng, "result": out}) - } - runtime.Out(map[string]interface{}{"results": results}, nil) - return nil - }, - Tips: []string{ - "Calls set_cell_range once per range. A mid-batch failure leaves earlier ranges already updated — use --dry-run first to confirm the list.", - }, -} - -// DropdownDelete clears dropdowns from one or more sheet-prefixed ranges. -// high-risk-write — irreversible removal of validation rules. -var DropdownDelete = common.Shortcut{ - Service: "sheets", - Command: "+dropdown-delete", - Description: "Remove dropdowns from one or more sheet-prefixed ranges (irreversible).", - Risk: "high-risk-write", - Scopes: []string{"sheets:spreadsheet:write_only"}, - AuthTypes: []string{"user", "bot"}, - HasFormat: true, - Flags: append(publicTokenFlags(), - common.Flag{Name: "ranges", Input: []string{common.File, common.Stdin}, Required: true, - Desc: "JSON array of sheet-prefixed A1 ranges (max 100)"}, - ), - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - ranges, err := validateDropdownRanges(runtime) - if err != nil { - return err - } - if len(ranges) > 100 { - return common.FlagErrorf("--ranges accepts at most 100 entries; got %d", len(ranges)) - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := resolveSpreadsheetToken(runtime) - ranges, _ := validateDropdownRanges(runtime) - dry := common.NewDryRunAPI() - for _, rng := range ranges { - sheet, sub, _ := splitSheetPrefixedRange(rng) - rows, cols, _ := rangeDimensions(sub) - cells := fillCellsMatrix(rows, cols, map[string]interface{}{"data_validation": nil}) - body, _ := buildToolBody("set_cell_range", map[string]interface{}{ - "excel_id": token, - "range": sub, - "sheet_name": sheet, - "cells": cells, - }) - dry.POST(toolInvokePath(token, ToolKindWrite)).Body(body) - } - return dry. - Set("spreadsheet_token", token). - Set("tool_name", "set_cell_range"). - Set("invocation_count", len(ranges)) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, err := resolveSpreadsheetToken(runtime) - if err != nil { - return err - } - ranges, err := validateDropdownRanges(runtime) - if err != nil { - return err - } - results := make([]interface{}, 0, len(ranges)) - for _, rng := range ranges { - sheet, sub, err := splitSheetPrefixedRange(rng) - if err != nil { - return err - } - rows, cols, err := rangeDimensions(sub) - if err != nil { - return common.FlagErrorf("--ranges %q: %v", rng, err) - } - cells := fillCellsMatrix(rows, cols, map[string]interface{}{"data_validation": nil}) - input := map[string]interface{}{ - "excel_id": token, - "range": sub, - "sheet_name": sheet, - "cells": cells, - } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", input) - if err != nil { - return fmt.Errorf("range %q failed: %w (partial: %d/%d done)", rng, err, len(results), len(ranges)) - } - results = append(results, map[string]interface{}{"range": rng, "result": out}) - } - runtime.Out(map[string]interface{}{"results": results}, nil) - return nil - }, - Tips: []string{ - "Calls set_cell_range once per range. A mid-batch failure leaves earlier ranges already cleared.", - }, -} +// NOTE: +dropdown-update and +dropdown-delete were originally drafted here +// but moved to lark_sheet_batch_update (B7) per the spec: multi-range +// dropdown CRUD now goes through batch_update for atomicity. They'll land in +// the batch_update file alongside +cells-batch-set-style. // ─── shared dropdown helpers ────────────────────────────────────────── @@ -601,28 +421,6 @@ func buildDropdownValidation(runtime *common.RuntimeContext) (map[string]interfa return dv, nil } -// validateDropdownRanges parses --ranges, requires every entry to carry a -// sheet prefix, and returns the parsed list. -func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) { - raw, err := requireJSONArray(runtime, "ranges") - if err != nil { - return nil, err - } - out := make([]string, 0, len(raw)) - for i, v := range raw { - s, ok := v.(string) - if !ok { - return nil, common.FlagErrorf("--ranges[%d] must be a string", i) - } - s = strings.TrimSpace(s) - if !strings.Contains(s, "!") { - return nil, common.FlagErrorf("--ranges[%d] (%q) must include a sheet prefix", i, s) - } - out = append(out, s) - } - return out, nil -} - // validateDropdownOptionsColors validates --options is a JSON array and that // --colors (when set) has matching length. Used by +dropdown-set Validate. func validateDropdownOptionsColors(runtime *common.RuntimeContext) (int, error) { @@ -721,15 +519,6 @@ func letterToColumnIndex(letters string) int { return n - 1 } -// splitSheetPrefixedRange splits "sheet1!A2:A100" into ("sheet1", "A2:A100"). -func splitSheetPrefixedRange(rng string) (sheet, sub string, err error) { - idx := strings.Index(rng, "!") - if idx <= 0 || idx == len(rng)-1 { - return "", "", common.FlagErrorf("range %q must use sheet!range form", rng) - } - return strings.TrimSpace(rng[:idx]), strings.TrimSpace(rng[idx+1:]), 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. diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index 816f16caf..49e001d5e 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -45,7 +45,5 @@ func Shortcuts() []common.Shortcut { CellsSetStyle, CsvPut, DropdownSet, - DropdownUpdate, - DropdownDelete, } } diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 71c2fd2ce..7e37414cf 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -28,6 +28,8 @@ | --- | --- | --- | --- | | `batch_update` | `+batch-update` | high-risk-write | 批量 | | | `+cells-batch-set-style` | write | 批量 | +| | `+dropdown-update` | write | 对象 | +| | `+dropdown-delete` | high-risk-write | 对象 | ## Flags @@ -52,6 +54,29 @@ | `--data` | 专有 | string + File + Stdin | 是 | JSON 数组 `[{"ranges":["sheet1!A1:B2"],"style":{...}}]`;每个 ranges 元素必须带 sheet 前缀 | | `--dry-run` | 系统 | bool | 否 | | +### `+dropdown-update` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--ranges` | 专有 | string + File + Stdin | 是 | 目标范围 JSON 数组(如 `["sheet1!A2:A100"]`),每项必须带 sheet 前缀 | +| `--options` | 专有 | string + File + Stdin | 是 | 选项 JSON 数组 | +| `--colors` | 专有 | string + File + Stdin | 否 | 颜色数组(与 `--options` 等长) | +| `--multiple` | 专有 | bool | 否 | 启用多选 | +| `--highlight` | 专有 | bool | 否 | 选项配色 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+dropdown-delete` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--ranges` | 专有 | string + File + Stdin | 是 | 目标范围 JSON 数组(最多 100 个,每项带 sheet 前缀) | +| `--yes` | 系统 | bool | 是 | `high-risk-write`,必须二次确认(不带时退出码 10) | +| `--dry-run` | 系统 | bool | 否 | | + ## Schemas > 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 @@ -80,6 +105,19 @@ _单元格样式属性,包括字体、颜色、对齐方式和数字格式_ - `vertical_alignment` (enum?) — 垂直对齐方式 [top / middle / bottom] - `word_wrap` (enum?) — 是否自动换行,默认溢出,可选自动换行或裁剪 [overflow / auto-wrap / word-clip] +### `+dropdown-update` `--options` + +_数据验证配置_ + +**顶层字段**: +- `help_text` (string?) — 验证失败时显示的提示文本 +- `items` (array?) — 列表选项(type='list' 时必填) +- `operator` (enum?) — 比较运算符(type='number'/'date'/'textLength' 时必填) [equal / notEqual / greaterThan / greaterThanOrEqual / lessThan / lessThanOrEqual / between / notBetween] +- `range` (string?) — 源数据区域(type='listFromRange' 时必填,格式:'SheetName!A1:A10') +- `support_multiple_values` (boolean?) — 列表验证是否支持多选(type='list'/'listFromRange' 时可选,默认 false) +- `type` (enum) — 数据验证类型:list(下拉列表)、listFromRange(引用范围下拉列表)、number(数字)、date(日期)、textLength(文本长度)、… [list / listFromRange / number / date / textLength / checkbox] +- `values` (array?) — 比较值(operator 为 'between'/'notBetween' 时需要两个值,其它运算符需要一个值) + ## Examples > shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 8b3f6282c..5bf1e06d1 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -175,8 +175,6 @@ set_cell_range — range="A11:H11", cells=[[ | | `+cells-set-style` | write | 单元格 | | | `+cells-set-image` | write | 单元格 | | | `+dropdown-set` | write | 对象 | -| | `+dropdown-update` | write | 对象 | -| | `+dropdown-delete` | high-risk-write | 对象 | | `set_range_from_csv` | `+csv-put` | write | 单元格 | | `import_sandbox_to_sheet` | _Sheet Tool 独有,CLI 不实现_ | — | — | @@ -238,29 +236,6 @@ set_cell_range — range="A11:H11", cells=[[ | `--highlight` | 专有 | bool | 否 | 选项配色显示;默认 `false` | | `--dry-run` | 系统 | bool | 否 | | -### `+dropdown-update` - -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--ranges` | 专有 | string + File + Stdin | 是 | 目标范围 JSON 数组(如 `["sheet1!A2:A100"]`),每项必须带 sheet 前缀 | -| `--options` | 专有 | string + File + Stdin | 是 | 选项 JSON 数组 | -| `--colors` | 专有 | string + File + Stdin | 否 | 颜色数组(与 `--options` 等长) | -| `--multiple` | 专有 | bool | 否 | 启用多选 | -| `--highlight` | 专有 | bool | 否 | 选项配色 | -| `--dry-run` | 系统 | bool | 否 | | - -### `+dropdown-delete` - -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--ranges` | 专有 | string + File + Stdin | 是 | 目标范围 JSON 数组(最多 100 个,每项带 sheet 前缀) | -| `--yes` | 系统 | bool | 是 | `high-risk-write`,必须二次确认(不带时退出码 10) | -| `--dry-run` | 系统 | bool | 否 | | - ### `+csv-put` | Flag | 分类 | Type | 必填 | 说明 | @@ -307,7 +282,7 @@ _单元格样式属性,包括字体、颜色、对齐方式和数字格式_ - `vertical_alignment` (enum?) — 垂直对齐方式 [top / middle / bottom] - `word_wrap` (enum?) — 是否自动换行,默认溢出,可选自动换行或裁剪 [overflow / auto-wrap / word-clip] -### `+dropdown-set` `--options` / `+dropdown-update` `--options` +### `+dropdown-set` `--options` _数据验证配置_ From b3e99de06c5623087dd079d31944de5314cc6597 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Sat, 16 May 2026 22:51:08 +0800 Subject: [PATCH 006/114] feat(sheets): implement lark_sheet_range_operations shortcuts (B4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land 8 shortcuts across four canonical tools: - clear_cell_range → +cells-clear (high-risk-write) - merge_cells → +cells-merge / +cells-unmerge - resize_range → +dim-resize - transform_range → +range-move / +range-copy / +range-fill / +range-sort Three CLI↔tool vocabulary bridges live in this file: - +cells-clear: --scope content normalizes to the tool's clear_type "contents" (singular/plural spec mismatch is absorbed in the CLI). - +dim-resize: --size wraps as resize_{height,width}:{value:N}; --reset wraps as {reset:true}. The two flags are mutually exclusive and at least one is required. - +range-fill: CLI's five-valued --series-type collapses to the tool's binary fill_type — `copy` → "copyCells", anything else → "fillSeries" (the actual series progression is inferred server-side from the seed cells in --source-range). - +range-copy: --paste-type {values, formulas, formats} maps to the tool's {value_only, formula_only, format_only}; "all" omits the field entirely so the server applies its default. +cells-clear is the second high-risk-write shortcut in the package; the framework enforces --yes with exit code 10 as usual. --- .../sheets/lark_sheet_range_operations.go | 553 ++++++++++++++++++ shortcuts/sheets/shortcuts.go | 10 + 2 files changed, 563 insertions(+) create mode 100644 shortcuts/sheets/lark_sheet_range_operations.go diff --git a/shortcuts/sheets/lark_sheet_range_operations.go b/shortcuts/sheets/lark_sheet_range_operations.go new file mode 100644 index 000000000..3711e3401 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_range_operations.go @@ -0,0 +1,553 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── lark_sheet_range_operations ────────────────────────────────────── +// +// Four tools, eight shortcuts: +// +// - clear_cell_range → +cells-clear (high-risk-write) +// - merge_cells → +cells-merge / +cells-unmerge +// - resize_range → +dim-resize +// - transform_range → +range-move / +range-copy / +range-fill / +range-sort +// +// +dim-resize is grouped under "工作表" for CLI discoverability even though +// the backing tool lives in this skill. + +// CellsClear wraps clear_cell_range. +// +// CLI's --scope vocabulary (content / formats / all) is normalized to the +// tool's clear_type vocabulary (contents / formats / all) — the spec's +// singular/plural mismatch is intentionally absorbed here. +var CellsClear = common.Shortcut{ + Service: "sheets", + Command: "+cells-clear", + Description: "Clear cell content, formats, or both within a range (irreversible).", + Risk: "high-risk-write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "range", Required: true, Desc: "A1 range to clear (e.g. A1:C10 / D3:D / 3:3)"}, + common.Flag{Name: "scope", Enum: []string{"content", "formats", "all"}, Default: "content", + Desc: "what to clear: content (values+formulas only, default) / formats / all"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return common.FlagErrorf("--range is required") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindWrite, "clear_cell_range", cellsClearInput(runtime, token, sheetID, sheetName)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "clear_cell_range", cellsClearInput(runtime, token, sheetID, sheetName)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + Tips: []string{ + "high-risk-write — always preview with --dry-run; clear is not undoable.", + }, +} + +func cellsClearInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { + scope := runtime.Str("scope") + clearType := "contents" + switch scope { + case "content", "": + clearType = "contents" + case "formats", "all": + clearType = scope + } + input := map[string]interface{}{ + "excel_id": token, + "range": strings.TrimSpace(runtime.Str("range")), + "clear_type": clearType, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + return input +} + +// CellsMerge / CellsUnmerge share the merge_cells tool, dispatched by the +// `operation` enum. --merge-type applies to merge only and maps to tool +// field merge_type (`all` / `rows` / `columns`). +var CellsMerge = newMergeShortcut( + "+cells-merge", "Merge cells in a range.", "merge", true, +) +var CellsUnmerge = newMergeShortcut( + "+cells-unmerge", "Unmerge cells in a range.", "unmerge", false, +) + +func newMergeShortcut(command, desc, op string, withMergeType bool) common.Shortcut { + flags := append(publicSheetFlags(), + common.Flag{Name: "range", Required: true, Desc: "A1 range to merge / unmerge (e.g. A1:C3)"}, + ) + if withMergeType { + flags = append(flags, common.Flag{ + Name: "merge-type", Enum: []string{"all", "rows", "columns"}, Default: "all", + Desc: "merge strategy: all (one cell) / rows / columns", + }) + } + return common.Shortcut{ + Service: "sheets", + Command: command, + Description: desc, + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flags, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return common.FlagErrorf("--range is required") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindWrite, "merge_cells", mergeInput(runtime, token, sheetID, sheetName, op, withMergeType)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "merge_cells", mergeInput(runtime, token, sheetID, sheetName, op, withMergeType)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + } +} + +func mergeInput(runtime *common.RuntimeContext, token, sheetID, sheetName, op string, withMergeType bool) map[string]interface{} { + input := map[string]interface{}{ + "excel_id": token, + "range": strings.TrimSpace(runtime.Str("range")), + "operation": op, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if withMergeType { + if mt := runtime.Str("merge-type"); mt != "" && mt != "all" { + input["merge_type"] = mt + } else { + input["merge_type"] = "all" + } + } + return input +} + +// DimResize wraps resize_range to set row heights or column widths. --size +// is the target pixel count; --reset restores the sheet default. +// +// The tool's resize_height / resize_width fields take an object shape; until +// the new endpoint is observable in production we wrap the pixel value as +// {value: }. Pass --reset to send {reset: true} instead. +var DimResize = common.Shortcut{ + Service: "sheets", + Command: "+dim-resize", + Description: "Set row heights or column widths in a range (--size px or --reset to default).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "dimension", Required: true, Enum: dimEnum, Desc: "`row` or `column`"}, + common.Flag{Name: "start", Type: "int", Required: true, Desc: "0-based start position (inclusive)"}, + common.Flag{Name: "end", Type: "int", Required: true, Desc: "0-based end position (exclusive)"}, + common.Flag{Name: "size", Type: "int", Default: "0", Desc: "target size in pixels"}, + common.Flag{Name: "reset", Type: "bool", Desc: "reset to default size (mutually exclusive with --size)"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if !runtime.Changed("dimension") { + return common.FlagErrorf("--dimension is required") + } + if !runtime.Changed("start") || !runtime.Changed("end") { + return common.FlagErrorf("--start and --end are required") + } + if runtime.Int("start") < 0 || runtime.Int("end") <= runtime.Int("start") { + return common.FlagErrorf("invalid range: --start (%d) must be >= 0 and --end (%d) must be greater", runtime.Int("start"), runtime.Int("end")) + } + hasSize := runtime.Changed("size") && runtime.Int("size") > 0 + if !hasSize && !runtime.Bool("reset") { + return common.FlagErrorf("specify either --size or --reset") + } + if hasSize && runtime.Bool("reset") { + return common.FlagErrorf("--size and --reset are mutually exclusive") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindWrite, "resize_range", dimResizeInput(runtime, token, sheetID, sheetName)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "resize_range", dimResizeInput(runtime, token, sheetID, sheetName)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func dimResizeInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { + dim := runtime.Str("dimension") + rangeStr := dimRange(dim, runtime.Int("start"), runtime.Int("end")) + input := map[string]interface{}{ + "excel_id": token, + "range": rangeStr, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + var sizeBlock interface{} + if runtime.Bool("reset") { + sizeBlock = map[string]interface{}{"reset": true} + } else { + sizeBlock = map[string]interface{}{"value": runtime.Int("size")} + } + if dim == "row" { + input["resize_height"] = sizeBlock + } else { + input["resize_width"] = sizeBlock + } + return input +} + +// ─── transform_range (4 shortcuts) ──────────────────────────────────── +// +// move / copy take --source-range + --target-range (+ optional cross-sheet +// target). fill takes --source-range + --target-range + --series-type. sort +// takes --range + --sort-keys + --has-header. + +// RangeMove cuts data from --source-range and pastes at --target-range, +// optionally on another sheet. +var RangeMove = common.Shortcut{ + Service: "sheets", + Command: "+range-move", + Description: "Cut a range and paste it at a new location (optionally cross-sheet).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "source-range", Required: true, Desc: "source A1 range (e.g. A1:C5)"}, + common.Flag{Name: "target-range", Required: true, Desc: "target A1 starting cell (size derived from source)"}, + common.Flag{Name: "target-sheet-id", Desc: "destination sheet id (cross-sheet); omit for same sheet"}, + ), + Validate: validateRangeMoveOrCopy, + DryRun: transformDryRunFn("move", false, false), + Execute: transformExecuteFn("move", false, false), +} + +// RangeCopy duplicates a range to a new location with optional paste-type +// filter (values / formulas / formats / all). +var RangeCopy = common.Shortcut{ + Service: "sheets", + Command: "+range-copy", + Description: "Copy a range to a new location (--paste-type controls what is copied).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "source-range", Required: true, Desc: "source A1 range"}, + common.Flag{Name: "target-range", Required: true, Desc: "target A1 starting cell"}, + common.Flag{Name: "target-sheet-id", Desc: "destination sheet id (cross-sheet); omit for same sheet"}, + common.Flag{Name: "paste-type", Enum: []string{"values", "formulas", "formats", "all"}, Default: "all", + Desc: "what to copy: values / formulas / formats / all (default)"}, + ), + Validate: validateRangeMoveOrCopy, + DryRun: transformDryRunFn("copy", true, false), + Execute: transformExecuteFn("copy", true, false), +} + +// RangeFill performs autofill from a template range into a target range. +// --series-type is a 5-value CLI vocabulary; the tool only distinguishes +// `copyCells` from `fillSeries`. The mapping is documented in +// fillSeriesToToolType. +var RangeFill = common.Shortcut{ + Service: "sheets", + Command: "+range-fill", + Description: "Autofill a target range from a source template (copy / linear / growth / date series).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "source-range", Required: true, Desc: "template A1 range with seed cells"}, + common.Flag{Name: "target-range", Required: true, Desc: "target fill range (must be disjoint from source)"}, + common.Flag{Name: "series-type", Enum: []string{"auto", "linear", "growth", "date", "copy"}, Default: "auto", + Desc: "auto / linear / growth / date → tool fillSeries; copy → tool copyCells"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("source-range")) == "" { + return common.FlagErrorf("--source-range is required") + } + if strings.TrimSpace(runtime.Str("target-range")) == "" { + return common.FlagErrorf("--target-range is required") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindWrite, "transform_range", rangeFillInput(runtime, token, sheetID, sheetName)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "transform_range", rangeFillInput(runtime, token, sheetID, sheetName)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +// RangeSort sorts rows within a range by one or more columns. +var RangeSort = common.Shortcut{ + Service: "sheets", + Command: "+range-sort", + Description: "Sort rows within a range by one or more columns.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "range", Required: true, Desc: "A1 range to sort"}, + common.Flag{Name: "sort-keys", Input: []string{common.File, common.Stdin}, Required: true, + Desc: "sort keys JSON, e.g. [{\"col\":\"B\",\"order\":\"asc\"},{\"col\":\"D\",\"order\":\"desc\"}]"}, + common.Flag{Name: "has-header", Type: "bool", Desc: "treat first row as header (excluded from sort); default false"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return common.FlagErrorf("--range is required") + } + if _, err := requireJSONArray(runtime, "sort-keys"); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := rangeSortInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "transform_range", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := rangeSortInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "transform_range", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +// ─── transform_range helpers ────────────────────────────────────────── + +func validateRangeMoveOrCopy(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("source-range")) == "" { + return common.FlagErrorf("--source-range is required") + } + if strings.TrimSpace(runtime.Str("target-range")) == "" { + return common.FlagErrorf("--target-range is required") + } + return nil +} + +func transformDryRunFn(op string, withPasteType, _ bool) func(context.Context, *common.RuntimeContext) *common.DryRunAPI { + return func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindWrite, "transform_range", + transformMoveCopyInput(runtime, token, sheetID, sheetName, op, withPasteType)) + } +} + +func transformExecuteFn(op string, withPasteType, _ bool) func(context.Context, *common.RuntimeContext) error { + return func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "transform_range", + transformMoveCopyInput(runtime, token, sheetID, sheetName, op, withPasteType)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + } +} + +func transformMoveCopyInput(runtime *common.RuntimeContext, token, sheetID, sheetName, op string, withPasteType bool) map[string]interface{} { + input := map[string]interface{}{ + "excel_id": token, + "operation": op, + "range": strings.TrimSpace(runtime.Str("source-range")), + "destination_range": strings.TrimSpace(runtime.Str("target-range")), + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if tgt := strings.TrimSpace(runtime.Str("target-sheet-id")); tgt != "" { + input["destination_sheet_id"] = tgt + } + if withPasteType { + if pt := runtime.Str("paste-type"); pt != "" && pt != "all" { + input["paste_type"] = pasteTypeToTool(pt) + } + } + return input +} + +// pasteTypeToTool maps the CLI vocabulary (values / formulas / formats / all) +// to the tool's paste_type field (all / value_only / formula_only / format_only). +func pasteTypeToTool(pt string) string { + switch pt { + case "values": + return "value_only" + case "formulas": + return "formula_only" + case "formats": + return "format_only" + } + return "all" +} + +func rangeFillInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { + input := map[string]interface{}{ + "excel_id": token, + "operation": "fill", + "range": strings.TrimSpace(runtime.Str("source-range")), + "destination_range": strings.TrimSpace(runtime.Str("target-range")), + "fill_type": fillSeriesToToolType(runtime.Str("series-type")), + } + sheetSelectorForToolInput(input, sheetID, sheetName) + return input +} + +// fillSeriesToToolType maps the CLI series vocabulary to the tool's fill_type. +// The tool only distinguishes copy vs series; the CLI's series flavor (linear / +// growth / date / auto) all collapse to fillSeries — the actual progression is +// inferred by the server from the source cells. +func fillSeriesToToolType(seriesType string) string { + if seriesType == "copy" { + return "copyCells" + } + return "fillSeries" +} + +func rangeSortInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) (map[string]interface{}, error) { + keys, err := requireJSONArray(runtime, "sort-keys") + if err != nil { + return nil, err + } + input := map[string]interface{}{ + "excel_id": token, + "operation": "sort", + "range": strings.TrimSpace(runtime.Str("range")), + "sort_conditions": keys, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if runtime.Bool("has-header") { + input["has_header"] = true + } + return input, nil +} diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index 49e001d5e..36b2934ed 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -45,5 +45,15 @@ func Shortcuts() []common.Shortcut { CellsSetStyle, CsvPut, DropdownSet, + + // lark_sheet_range_operations + CellsClear, + CellsMerge, + CellsUnmerge, + DimResize, + RangeMove, + RangeCopy, + RangeFill, + RangeSort, } } From ee4096f141474cc078b7dd69012040d65881cfb4 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Sat, 16 May 2026 22:54:24 +0800 Subject: [PATCH 007/114] feat(sheets): implement object-list shortcuts (B5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land 7 read shortcuts, one per object skill — chart / pivot table / conditional format / filter / filter view / sparkline / float image. All share the same shape (public sheet selector + optional -id filter) so they're declared via newObjectListShortcut + an objectListSpec. Notes: - +cond-format-list exposes --rule-id, which is renamed to conditional_format_id on the wire (the tool's full field name). - +sparkline-list exposes --group-id (the higher-level handle); the tool also accepts sparkline_id, intentionally not surfaced. - +filter-list takes no id filter — at most one sheet-level filter per sheet, so the listing is already unique. - +filter-view-list is `cli_status: cli-only` but get_filter_view_objects is in mcp-tools.json and dispatches through the same One-OpenAPI endpoint; no special path required. --- shortcuts/sheets/lark_sheet_object_list.go | 170 +++++++++++++++++++++ shortcuts/sheets/shortcuts.go | 9 ++ 2 files changed, 179 insertions(+) create mode 100644 shortcuts/sheets/lark_sheet_object_list.go diff --git a/shortcuts/sheets/lark_sheet_object_list.go b/shortcuts/sheets/lark_sheet_object_list.go new file mode 100644 index 000000000..cb2bc0d82 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_object_list.go @@ -0,0 +1,170 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── object list shortcuts ──────────────────────────────────────────── +// +// Seven object-collection skills each expose a single "list" read shortcut +// that lives next to their CRUD siblings (chart / pivot / cond-format / +// filter / filter-view / sparkline / float-image). All seven share the +// exact same shape — public sheet selector + optional -- filter — so +// they're declared via newObjectListShortcut. +// +// +filter-view-list is `cli_status: cli-only`, but the underlying tool +// get_filter_view_objects is in mcp-tools.json and dispatches through the +// same One-OpenAPI endpoint as everything else; no special path needed. + +// objectListSpec describes a single list-style read shortcut. +type objectListSpec struct { + command string // CLI command, e.g. "+chart-list" + description string // one-liner for --help + toolName string // MCP tool name, e.g. "get_chart_objects" + + // Optional id filter. Empty filterFlag → no filter flag exposed. + filterFlag string // CLI flag name (without leading --), e.g. "chart-id" + filterField string // tool input key, e.g. "chart_id" + filterDesc string // flag help text +} + +func newObjectListShortcut(spec objectListSpec) common.Shortcut { + flags := publicSheetFlags() + if spec.filterFlag != "" { + flags = append(flags, common.Flag{ + Name: spec.filterFlag, + Desc: spec.filterDesc, + }) + } + return common.Shortcut{ + Service: "sheets", + Command: spec.command, + Description: spec.description, + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flags, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + _, _, err := resolveSheetSelector(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindRead, spec.toolName, objectListInput(runtime, token, sheetID, sheetName, spec)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindRead, spec.toolName, objectListInput(runtime, token, sheetID, sheetName, spec)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + } +} + +func objectListInput(runtime *common.RuntimeContext, token, sheetID, sheetName string, spec objectListSpec) map[string]interface{} { + input := map[string]interface{}{"excel_id": token} + sheetSelectorForToolInput(input, sheetID, sheetName) + if spec.filterFlag != "" { + if v := strings.TrimSpace(runtime.Str(spec.filterFlag)); v != "" { + input[spec.filterField] = v + } + } + return input +} + +// ─── shortcut declarations ──────────────────────────────────────────── + +// ChartList — list charts on a sheet (optionally filtered to one chart_id). +var ChartList = newObjectListShortcut(objectListSpec{ + command: "+chart-list", + description: "List charts on a sheet, optionally filtered to a single chart_id.", + toolName: "get_chart_objects", + filterFlag: "chart-id", + filterField: "chart_id", + filterDesc: "optional chart reference_id; returns just that chart when set", +}) + +// PivotList — list pivot tables on a sheet. +var PivotList = newObjectListShortcut(objectListSpec{ + command: "+pivot-list", + description: "List pivot tables on a sheet, optionally filtered to a single pivot_table_id.", + toolName: "get_pivot_table_objects", + filterFlag: "pivot-table-id", + filterField: "pivot_table_id", + filterDesc: "optional pivot table reference_id; returns just that pivot when set", +}) + +// CondFormatList — list conditional format rules. CLI's --rule-id maps to +// the tool's conditional_format_id (CLI uses the shorter common term). +var CondFormatList = newObjectListShortcut(objectListSpec{ + command: "+cond-format-list", + description: "List conditional format rules on a sheet, optionally filtered to a single rule.", + toolName: "get_conditional_format_objects", + filterFlag: "rule-id", + filterField: "conditional_format_id", + filterDesc: "optional rule reference_id (maps to conditional_format_id server-side)", +}) + +// FilterList — list active sheet-level filters. No id filter because each +// sheet carries at most one filter. +var FilterList = newObjectListShortcut(objectListSpec{ + command: "+filter-list", + description: "List active sheet-level filters across the workbook (or one sheet).", + toolName: "get_filter_objects", +}) + +// FilterViewList — list filter views on a sheet. `cli-only` skill (not +// exposed as MCP tool catalog), but the tool itself is dispatched through +// the same One-OpenAPI endpoint. +var FilterViewList = newObjectListShortcut(objectListSpec{ + command: "+filter-view-list", + description: "List filter views on a sheet, optionally filtered to a single view_id.", + toolName: "get_filter_view_objects", + filterFlag: "view-id", + filterField: "view_id", + filterDesc: "optional filter-view reference_id; returns just that view when set", +}) + +// SparklineList — list sparkline groups on a sheet. The tool also accepts +// a per-sparkline id (`sparkline_id`); CLI exposes the higher-level +// --group-id which is what callers usually care about. +var SparklineList = newObjectListShortcut(objectListSpec{ + command: "+sparkline-list", + description: "List sparkline groups on a sheet, optionally filtered by group_id.", + toolName: "get_sparkline_objects", + filterFlag: "group-id", + filterField: "group_id", + filterDesc: "optional sparkline group reference_id; returns all sparklines in that group", +}) + +// FloatImageList — list floating images on a sheet (vs. embedded +// cell-images which live in cell metadata). +var FloatImageList = newObjectListShortcut(objectListSpec{ + command: "+float-image-list", + description: "List floating images on a sheet, optionally filtered to a single float_image_id.", + toolName: "get_float_image_objects", + filterFlag: "float-image-id", + filterField: "float_image_id", + filterDesc: "optional floating-image reference_id; returns just that image when set", +}) diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index 36b2934ed..415bd0cd0 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -55,5 +55,14 @@ func Shortcuts() []common.Shortcut { RangeCopy, RangeFill, RangeSort, + + // Object list (one read shortcut per object skill) + ChartList, + PivotList, + CondFormatList, + FilterList, + FilterViewList, + SparklineList, + FloatImageList, } } From 1cbc049700a2e45ff2d2baedd7c6e34f2aa641d7 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Sat, 16 May 2026 23:01:59 +0800 Subject: [PATCH 008/114] feat(sheets): implement object CRUD shortcuts (B6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land 21 shortcuts — three (create / update / delete) per object skill — backed by the manage__object tools dispatched on the operation enum. Five standard objects (chart / cond-format / sparkline / float-image / filter-view) share an objectCRUDSpec factory; pivot and filter are special-cased. Shared wire contract: excel_id + sheet_id|sheet_name + operation + [_id] + [properties] CLI --data is passed through as the tool's `properties` field as-is, so callers shape it per each object's spec doc. Special cases: - pivot adds optional --target-sheet-id / --target-position on create (siblings of properties, not inside it). - cond-format exposes --rule-id (short CLI name) wired to the tool's conditional_format_id on the wire. - sparkline uses --group-id (higher-level object handle) instead of sparkline_id. - filter has no separate id flag — at most one filter per sheet, so filter_id is implicit. +filter-create promotes --range to a first- class flag (instead of burying it inside --data). - filter-view CRUD are `cli_status: cli-only` but manage_filter_view_object is in mcp-tools.json, so they go through callTool / One-OpenAPI alongside everything else. All delete shortcuts are high-risk-write and require --yes. --- shortcuts/sheets/lark_sheet_object_crud.go | 558 +++++++++++++++++++++ shortcuts/sheets/shortcuts.go | 9 + 2 files changed, 567 insertions(+) create mode 100644 shortcuts/sheets/lark_sheet_object_crud.go diff --git a/shortcuts/sheets/lark_sheet_object_crud.go b/shortcuts/sheets/lark_sheet_object_crud.go new file mode 100644 index 000000000..a0c94222b --- /dev/null +++ b/shortcuts/sheets/lark_sheet_object_crud.go @@ -0,0 +1,558 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── object CRUD shortcuts ──────────────────────────────────────────── +// +// Six object skills (chart / pivot table / conditional format / filter / +// filter view / sparkline / float image) each expose a uniform create / +// update / delete trio backed by their manage__object tool. +// +// Shared shape: +// excel_id + sheet_id|sheet_name + operation + [_id] + [properties] +// +// CLI `--data` is passed through as the tool's `properties` payload as-is — +// callers shape it per the spec doc for each object (which is what makes +// the surface narrow even though everything funnels through one tool). +// +// Five of the seven objects share the factory below (newObjectCRUDShortcuts). +// pivot adds optional --target-sheet-id / --target-position on create, +// declared with extraCreateFlags. filter is special-cased further down +// (no separate id flag — filter_id is implicit per sheet — and --range is +// a first-class create flag, not buried in --data). + +// objectCRUDSpec describes a 3-shortcut create/update/delete cluster. +// idFlag / idField empty → no per-object id flag (only filter uses that +// today, and it has its own bespoke shortcuts further down). +type objectCRUDSpec struct { + commandPrefix string // e.g. "+chart" → +chart-create / -update / -delete + toolName string // e.g. "manage_chart_object" + idFlag string // e.g. "chart-id" + idField string // e.g. "chart_id" + createDataDesc string // help text for --data on create + updateDataDesc string // help text for --data on update + createExtraFlags []common.Flag + // createExtraInput, when set, mutates the tool input after the standard + // fields are written. Used by pivot to inject --target-sheet-id / + // --target-position alongside properties. + createExtraInput func(rt *common.RuntimeContext, input map[string]interface{}) +} + +func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { + flags := append(publicSheetFlags(), + common.Flag{Name: "data", Input: []string{common.File, common.Stdin}, Required: true, Desc: spec.createDataDesc}, + ) + flags = append(flags, spec.createExtraFlags...) + return common.Shortcut{ + Service: "sheets", + Command: spec.commandPrefix + "-create", + Description: "Create a " + strings.TrimPrefix(spec.commandPrefix, "+") + " object via the manage_*_object tool.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flags, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + _, err := requireJSONObject(runtime, "data") + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := objectCreateInput(runtime, token, sheetID, sheetName, spec) + return invokeToolDryRun(token, ToolKindWrite, spec.toolName, input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := objectCreateInput(runtime, token, sheetID, sheetName, spec) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, spec.toolName, input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + } +} + +func objectCreateInput(runtime *common.RuntimeContext, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) { + props, err := requireJSONObject(runtime, "data") + if err != nil { + return nil, err + } + input := map[string]interface{}{ + "excel_id": token, + "operation": "create", + "properties": props, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if spec.createExtraInput != nil { + spec.createExtraInput(runtime, input) + } + return input, nil +} + +func newObjectUpdateShortcut(spec objectCRUDSpec) common.Shortcut { + flags := publicSheetFlags() + if spec.idFlag != "" { + flags = append(flags, common.Flag{ + Name: spec.idFlag, Required: true, + Desc: "target object reference_id (maps to " + spec.idField + " on the wire)", + }) + } + flags = append(flags, common.Flag{ + Name: "data", Input: []string{common.File, common.Stdin}, Required: true, + Desc: spec.updateDataDesc, + }) + return common.Shortcut{ + Service: "sheets", + Command: spec.commandPrefix + "-update", + Description: "Update an existing " + strings.TrimPrefix(spec.commandPrefix, "+") + " object (read-modify-write; consult --list first).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flags, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if spec.idFlag != "" && strings.TrimSpace(runtime.Str(spec.idFlag)) == "" { + return common.FlagErrorf("--%s is required", spec.idFlag) + } + _, err := requireJSONObject(runtime, "data") + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := objectUpdateInput(runtime, token, sheetID, sheetName, spec) + return invokeToolDryRun(token, ToolKindWrite, spec.toolName, input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := objectUpdateInput(runtime, token, sheetID, sheetName, spec) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, spec.toolName, input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + } +} + +func objectUpdateInput(runtime *common.RuntimeContext, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) { + props, err := requireJSONObject(runtime, "data") + if err != nil { + return nil, err + } + input := map[string]interface{}{ + "excel_id": token, + "operation": "update", + "properties": props, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if spec.idFlag != "" { + input[spec.idField] = strings.TrimSpace(runtime.Str(spec.idFlag)) + } + return input, nil +} + +func newObjectDeleteShortcut(spec objectCRUDSpec) common.Shortcut { + flags := publicSheetFlags() + if spec.idFlag != "" { + flags = append(flags, common.Flag{ + Name: spec.idFlag, Required: true, + Desc: "target object reference_id (maps to " + spec.idField + " on the wire)", + }) + } + return common.Shortcut{ + Service: "sheets", + Command: spec.commandPrefix + "-delete", + Description: "Delete a " + strings.TrimPrefix(spec.commandPrefix, "+") + " object (irreversible).", + Risk: "high-risk-write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flags, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if spec.idFlag != "" && strings.TrimSpace(runtime.Str(spec.idFlag)) == "" { + return common.FlagErrorf("--%s is required", spec.idFlag) + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindWrite, spec.toolName, objectDeleteInput(runtime, token, sheetID, sheetName, spec)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, spec.toolName, objectDeleteInput(runtime, token, sheetID, sheetName, spec)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + } +} + +func objectDeleteInput(runtime *common.RuntimeContext, token, sheetID, sheetName string, spec objectCRUDSpec) map[string]interface{} { + input := map[string]interface{}{ + "excel_id": token, + "operation": "delete", + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if spec.idFlag != "" { + input[spec.idField] = strings.TrimSpace(runtime.Str(spec.idFlag)) + } + return input +} + +// ─── per-object instantiations ──────────────────────────────────────── + +// chart +var chartSpec = objectCRUDSpec{ + commandPrefix: "+chart", + toolName: "manage_chart_object", + idFlag: "chart-id", + idField: "chart_id", + createDataDesc: "chart properties JSON (position / data / properties etc.); see lark-sheets-chart.md for the shape", + updateDataDesc: "full or partial chart properties JSON (`+chart-list --chart-id ` first, then patch)", +} +var ChartCreate = newObjectCreateShortcut(chartSpec) +var ChartUpdate = newObjectUpdateShortcut(chartSpec) +var ChartDelete = newObjectDeleteShortcut(chartSpec) + +// pivot — adds --target-sheet-id / --target-position on create +var pivotSpec = objectCRUDSpec{ + commandPrefix: "+pivot", + toolName: "manage_pivot_table_object", + idFlag: "pivot-table-id", + idField: "pivot_table_id", + createDataDesc: "pivot table properties JSON: { data_range, rows, columns, values, filters, show_row_grand_total, show_col_grand_total }", + updateDataDesc: "full or partial pivot properties JSON (`+pivot-list --pivot-table-id ` first, then patch)", + createExtraFlags: []common.Flag{ + {Name: "target-sheet-id", Desc: "destination sheet id for the pivot table; omit to auto-create a fresh sheet (recommended)"}, + {Name: "target-position", Default: "A1", Desc: "destination start cell, default A1"}, + }, + createExtraInput: func(rt *common.RuntimeContext, input map[string]interface{}) { + if v := strings.TrimSpace(rt.Str("target-sheet-id")); v != "" { + input["target_sheet_id"] = v + } + if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" { + input["target_position"] = v + } + }, +} +var PivotCreate = newObjectCreateShortcut(pivotSpec) +var PivotUpdate = newObjectUpdateShortcut(pivotSpec) +var PivotDelete = newObjectDeleteShortcut(pivotSpec) + +// conditional format — CLI surface uses --rule-id (short), wired to the +// tool's conditional_format_id on the wire. +var condFormatSpec = objectCRUDSpec{ + commandPrefix: "+cond-format", + toolName: "manage_conditional_format_object", + idFlag: "rule-id", + idField: "conditional_format_id", + createDataDesc: "rule JSON: { range, rule: { type: cell_value|duplicate|data_bar|color_scale|rank|formula, ... } }", + updateDataDesc: "full or partial rule JSON (`+cond-format-list --rule-id ` first, then patch)", +} +var CondFormatCreate = newObjectCreateShortcut(condFormatSpec) +var CondFormatUpdate = newObjectUpdateShortcut(condFormatSpec) +var CondFormatDelete = newObjectDeleteShortcut(condFormatSpec) + +// sparkline — CLI uses --group-id (higher level) as the object selector. +var sparklineSpec = objectCRUDSpec{ + commandPrefix: "+sparkline", + toolName: "manage_sparkline_object", + idFlag: "group-id", + idField: "group_id", + createDataDesc: "sparkline group JSON: { type: line|column|win_loss, source_range, target_range, ... }", + updateDataDesc: "full or partial sparkline group JSON (`+sparkline-list --group-id ` first, then patch)", +} +var SparklineCreate = newObjectCreateShortcut(sparklineSpec) +var SparklineUpdate = newObjectUpdateShortcut(sparklineSpec) +var SparklineDelete = newObjectDeleteShortcut(sparklineSpec) + +// float image +var floatImageSpec = objectCRUDSpec{ + commandPrefix: "+float-image", + toolName: "manage_float_image_object", + idFlag: "float-image-id", + idField: "float_image_id", + createDataDesc: "float image JSON: { image_uri, image_name, position:{row,col}, size:{width,height}, offset:{x,y} } — image_uri must be pre-uploaded", + updateDataDesc: "full or partial float image JSON (`+float-image-list --float-image-id ` first, then patch)", +} +var FloatImageCreate = newObjectCreateShortcut(floatImageSpec) +var FloatImageUpdate = newObjectUpdateShortcut(floatImageSpec) +var FloatImageDelete = newObjectDeleteShortcut(floatImageSpec) + +// filter view — cli_status: cli-only but the tool is in mcp-tools.json so +// it dispatches via the same One-OpenAPI endpoint as every other shortcut. +var filterViewSpec = objectCRUDSpec{ + commandPrefix: "+filter-view", + toolName: "manage_filter_view_object", + idFlag: "view-id", + idField: "view_id", + createDataDesc: "filter view JSON: { view_name, range (required, covers header), rules: [...] }", + updateDataDesc: "partial update JSON: any of { view_name, range, rules }; `+filter-view-list --view-id ` first", +} +var FilterViewCreate = newObjectCreateShortcut(filterViewSpec) +var FilterViewUpdate = newObjectUpdateShortcut(filterViewSpec) +var FilterViewDelete = newObjectDeleteShortcut(filterViewSpec) + +// ─── filter (sheet-scoped, no separate filter_id) ───────────────────── +// +// At most one filter per sheet, so filter_id is implicit (the tool treats +// filter_id and sheet_id as the same value). create requires --range +// (covering the header) and an optional --data with conditions; update +// patches conditions / range; delete drops the entire filter. + +// FilterCreate creates a sheet-level filter. --range covers the data +// (header inclusive). --data is optional — empty filter is valid. +var FilterCreate = common.Shortcut{ + Service: "sheets", + Command: "+filter-create", + Description: "Create a sheet-level filter (one per sheet).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "range", Required: true, Desc: "filter range including the header row (e.g. A1:F1000)"}, + common.Flag{Name: "data", Input: []string{common.File, common.Stdin}, + Desc: "optional conditions JSON: { conditions: [{col, filter_type, expected, ...}] }; empty filter when omitted"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return common.FlagErrorf("--range is required") + } + if runtime.Str("data") != "" { + if _, err := requireJSONObject(runtime, "data"); err != nil { + return err + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := filterCreateInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "manage_filter_object", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := filterCreateInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_filter_object", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func filterCreateInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) (map[string]interface{}, error) { + props := map[string]interface{}{ + "range": strings.TrimSpace(runtime.Str("range")), + } + if runtime.Str("data") != "" { + extra, err := requireJSONObject(runtime, "data") + if err != nil { + return nil, err + } + for k, v := range extra { + if k == "range" { + continue // --range wins + } + props[k] = v + } + } + input := map[string]interface{}{ + "excel_id": token, + "operation": "create", + "properties": props, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + return input, nil +} + +// FilterUpdate patches the sheet-level filter — change range or +// add/replace conditions. filter_id is implicit (sheet-scoped). +var FilterUpdate = common.Shortcut{ + Service: "sheets", + Command: "+filter-update", + Description: "Update the sheet-level filter (patch range or conditions).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "data", Input: []string{common.File, common.Stdin}, Required: true, + Desc: "patch JSON: { range?, conditions?: [...] } — read with +filter-list first"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + _, err := requireJSONObject(runtime, "data") + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := filterUpdateInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "manage_filter_object", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := filterUpdateInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_filter_object", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func filterUpdateInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) (map[string]interface{}, error) { + props, err := requireJSONObject(runtime, "data") + if err != nil { + return nil, err + } + input := map[string]interface{}{ + "excel_id": token, + "operation": "update", + "properties": props, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + return input, nil +} + +// FilterDelete drops the sheet-level filter entirely. high-risk-write. +var FilterDelete = common.Shortcut{ + Service: "sheets", + Command: "+filter-delete", + Description: "Remove the sheet-level filter (irreversible).", + Risk: "high-risk-write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: publicSheetFlags(), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + _, _, err := resolveSheetSelector(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input := map[string]interface{}{"excel_id": token, "operation": "delete"} + sheetSelectorForToolInput(input, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "manage_filter_object", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input := map[string]interface{}{"excel_id": token, "operation": "delete"} + sheetSelectorForToolInput(input, sheetID, sheetName) + out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_filter_object", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index 415bd0cd0..0a0d02906 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -64,5 +64,14 @@ func Shortcuts() []common.Shortcut { FilterViewList, SparklineList, FloatImageList, + + // Object CRUD (3 per skill) + ChartCreate, ChartUpdate, ChartDelete, + PivotCreate, PivotUpdate, PivotDelete, + CondFormatCreate, CondFormatUpdate, CondFormatDelete, + FilterCreate, FilterUpdate, FilterDelete, + FilterViewCreate, FilterViewUpdate, FilterViewDelete, + SparklineCreate, SparklineUpdate, SparklineDelete, + FloatImageCreate, FloatImageUpdate, FloatImageDelete, } } From 705844f3126f0865fe5ff06e8f6a646c275727f5 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Sat, 16 May 2026 23:06:25 +0800 Subject: [PATCH 009/114] feat(sheets): implement lark_sheet_batch_update shortcuts (B7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land 4 shortcuts that all funnel through the batch_update tool's atomic operations array: - +batch-update raw passthrough; --data carries the full { operations: [{tool, params}, ...] } payload plus optional continue_on_error. high-risk-write since the caller may stuff anything inside. - +cells-batch-set-style --data is [{ranges, style}, ...]; CLI flattens each (entry × range) pair into a set_cell_range op with a fan-out cells matrix carrying cell_styles + border_styles. - +dropdown-update --ranges + --options (+ --colors / --multiple / --highlight) — installs/replaces one dropdown across many ranges, each becoming a separate set_cell_range op with data_validation in cells. - +dropdown-delete --ranges — clears data_validation across many ranges (high-risk-write). Default is strict transaction: if any sub-tool fails the whole batch rolls back. +batch-update exposes --continue-on-error to flip the policy; the three fan-out shortcuts leave it strict (they're meant to be all-or-nothing). Reinstates validateDropdownRanges + splitSheetPrefixedRange that were removed during B3 → B7 relocation. --- shortcuts/sheets/lark_sheet_batch_update.go | 416 ++++++++++++++++++++ shortcuts/sheets/shortcuts.go | 6 + 2 files changed, 422 insertions(+) create mode 100644 shortcuts/sheets/lark_sheet_batch_update.go diff --git a/shortcuts/sheets/lark_sheet_batch_update.go b/shortcuts/sheets/lark_sheet_batch_update.go new file mode 100644 index 000000000..97c991d4b --- /dev/null +++ b/shortcuts/sheets/lark_sheet_batch_update.go @@ -0,0 +1,416 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── lark_sheet_batch_update ────────────────────────────────────────── +// +// One tool (batch_update), four shortcuts: +// +// - +batch-update raw passthrough of an operations array +// (high-risk-write — anything can be inside) +// - +cells-batch-set-style fan a single style across many ranges +// - +dropdown-update install/replace the same dropdown across +// many ranges in one atomic batch +// - +dropdown-delete clear data_validation across many ranges +// (high-risk-write) +// +// The tool's contract: +// { excel_id, operations: [{tool, params}, ...], continue_on_error? } +// +// continue_on_error defaults to false (strict transaction): any failure +// rolls back the whole batch. CLI leaves the default in place for the +// three "fan-out" shortcuts since they're meant to be all-or-nothing; +// only +batch-update lets callers flip it via --continue-on-error. + +// BatchUpdate is the raw passthrough — caller hands in the operations +// array as --data. high-risk-write because it can wrap anything. +var BatchUpdate = common.Shortcut{ + Service: "sheets", + Command: "+batch-update", + Description: "Execute a batch of write tools as a single atomic request (rolls back on failure by default).", + Risk: "high-risk-write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicTokenFlags(), + common.Flag{Name: "data", Input: []string{common.File, common.Stdin}, Required: true, + Desc: "batch payload JSON: { operations: [{tool, params}, ...] }"}, + common.Flag{Name: "continue-on-error", Type: "bool", Desc: "flip the default strict transaction off; partial success is kept on disk"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + body, err := requireJSONObject(runtime, "data") + if err != nil { + return err + } + ops, ok := body["operations"].([]interface{}) + if !ok || len(ops) == 0 { + return common.FlagErrorf("--data.operations must be a non-empty JSON array") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + input, _ := batchUpdateRawInput(runtime, token) + return invokeToolDryRun(token, ToolKindWrite, "batch_update", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + input, err := batchUpdateRawInput(runtime, token) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + Tips: []string{ + "Default is strict transaction — any sub-tool failure rolls the whole batch back. Pass --continue-on-error to keep partial successes.", + }, +} + +func batchUpdateRawInput(runtime *common.RuntimeContext, token string) (map[string]interface{}, error) { + body, err := requireJSONObject(runtime, "data") + if err != nil { + return nil, err + } + ops, _ := body["operations"].([]interface{}) + input := map[string]interface{}{ + "excel_id": token, + "operations": ops, + } + if runtime.Bool("continue-on-error") { + input["continue_on_error"] = true + } else if v, ok := body["continue_on_error"].(bool); ok && v { + // honor an inline override from --data when the flag is unset + input["continue_on_error"] = true + } + return input, nil +} + +// CellsBatchSetStyle stamps one style block across many ranges atomically. +// --data is an array of {ranges: [...], style: {...}} entries; CLI flattens +// each (entry × range) pair into a set_cell_range operation in the batch. +var CellsBatchSetStyle = common.Shortcut{ + Service: "sheets", + Command: "+cells-batch-set-style", + Description: "Apply styles to many sheet-prefixed ranges in one atomic batch.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicTokenFlags(), + common.Flag{Name: "data", Input: []string{common.File, common.Stdin}, Required: true, + Desc: "JSON array: [{ranges: [\"sheet1!A1:B2\", ...], style: {...}}, ...] (each range must carry a sheet prefix)"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + _, err := batchStyleEntries(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + input, _ := cellsBatchSetStyleInput(runtime, token) + return invokeToolDryRun(token, ToolKindWrite, "batch_update", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + input, err := cellsBatchSetStyleInput(runtime, token) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +// batchStyleEntries validates --data is the expected array shape. +func batchStyleEntries(runtime *common.RuntimeContext) ([]map[string]interface{}, error) { + raw, err := requireJSONArray(runtime, "data") + if err != nil { + return nil, err + } + if len(raw) == 0 { + return nil, common.FlagErrorf("--data must contain at least one entry") + } + out := make([]map[string]interface{}, 0, len(raw)) + for i, v := range raw { + entry, ok := v.(map[string]interface{}) + if !ok { + return nil, common.FlagErrorf("--data[%d] must be an object", i) + } + rangesRaw, ok := entry["ranges"].([]interface{}) + if !ok || len(rangesRaw) == 0 { + return nil, common.FlagErrorf("--data[%d].ranges must be a non-empty array", i) + } + for j, r := range rangesRaw { + s, ok := r.(string) + if !ok || !strings.Contains(s, "!") { + return nil, common.FlagErrorf("--data[%d].ranges[%d] must be a sheet-prefixed string", i, j) + } + } + if _, ok := entry["style"].(map[string]interface{}); !ok { + return nil, common.FlagErrorf("--data[%d].style must be a JSON object", i) + } + out = append(out, entry) + } + return out, nil +} + +func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[string]interface{}, error) { + entries, err := batchStyleEntries(runtime) + if err != nil { + return nil, err + } + var ops []interface{} + for _, entry := range entries { + style := entry["style"].(map[string]interface{}) + // Split border_styles out into its sibling field per set_cell_range's contract. + cellStyle := map[string]interface{}{} + var borderStyles interface{} + for k, v := range style { + if k == "border_styles" { + borderStyles = v + continue + } + cellStyle[k] = v + } + ranges, _ := entry["ranges"].([]interface{}) + for _, r := range ranges { + rng := r.(string) + sheet, sub, err := splitSheetPrefixedRange(rng) + if err != nil { + return nil, err + } + rows, cols, err := rangeDimensions(sub) + if err != nil { + return nil, common.FlagErrorf("range %q: %v", rng, err) + } + proto := map[string]interface{}{"cell_styles": cellStyle} + if borderStyles != nil { + proto["border_styles"] = borderStyles + } + cells := fillCellsMatrix(rows, cols, proto) + ops = append(ops, map[string]interface{}{ + "tool": "set_cell_range", + "params": map[string]interface{}{ + "excel_id": token, + "sheet_name": sheet, + "range": sub, + "cells": cells, + }, + }) + } + } + return map[string]interface{}{ + "excel_id": token, + "operations": ops, + }, nil +} + +// DropdownUpdate installs/replaces a single dropdown on many ranges in one +// atomic batch. Sheet ids come from the per-range sheet prefix. +var DropdownUpdate = common.Shortcut{ + Service: "sheets", + Command: "+dropdown-update", + Description: "Install or replace one dropdown across many sheet-prefixed ranges atomically.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicTokenFlags(), + common.Flag{Name: "ranges", Input: []string{common.File, common.Stdin}, Required: true, + Desc: "JSON array of sheet-prefixed A1 ranges (e.g. [\"sheet1!A2:A100\"])"}, + common.Flag{Name: "options", Input: []string{common.File, common.Stdin}, Required: true, + Desc: "options JSON array (e.g. [\"alpha\",\"beta\"])"}, + common.Flag{Name: "colors", Input: []string{common.File, common.Stdin}, + Desc: "optional RGB hex color array (must equal --options length)"}, + common.Flag{Name: "multiple", Type: "bool", Desc: "enable multi-select"}, + common.Flag{Name: "highlight", Type: "bool", Desc: "color-highlight options"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, err := validateDropdownRanges(runtime); err != nil { + return err + } + if _, err := validateDropdownOptionsColors(runtime); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + input, _ := dropdownBatchInput(runtime, token, false) + return invokeToolDryRun(token, ToolKindWrite, "batch_update", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + input, err := dropdownBatchInput(runtime, token, false) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +// DropdownDelete clears data_validation across many ranges atomically. +var DropdownDelete = common.Shortcut{ + Service: "sheets", + Command: "+dropdown-delete", + Description: "Clear dropdowns from many sheet-prefixed ranges atomically (irreversible).", + Risk: "high-risk-write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicTokenFlags(), + common.Flag{Name: "ranges", Input: []string{common.File, common.Stdin}, Required: true, + Desc: "JSON array of sheet-prefixed A1 ranges (max 100)"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + ranges, err := validateDropdownRanges(runtime) + if err != nil { + return err + } + if len(ranges) > 100 { + return common.FlagErrorf("--ranges accepts at most 100 entries; got %d", len(ranges)) + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + input, _ := dropdownBatchInput(runtime, token, true) + return invokeToolDryRun(token, ToolKindWrite, "batch_update", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + input, err := dropdownBatchInput(runtime, token, true) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +// dropdownBatchInput builds the batch_update payload for both +// +dropdown-update (clear=false, data_validation populated) and +// +dropdown-delete (clear=true, data_validation: null). +func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool) (map[string]interface{}, error) { + ranges, err := validateDropdownRanges(runtime) + if err != nil { + return nil, err + } + var prototype map[string]interface{} + if clear { + prototype = map[string]interface{}{"data_validation": nil} + } else { + validation, err := buildDropdownValidation(runtime) + if err != nil { + return nil, err + } + prototype = map[string]interface{}{"data_validation": validation} + } + var ops []interface{} + for _, rng := range ranges { + sheet, sub, err := splitSheetPrefixedRange(rng) + if err != nil { + return nil, err + } + rows, cols, err := rangeDimensions(sub) + if err != nil { + return nil, common.FlagErrorf("range %q: %v", rng, err) + } + cells := fillCellsMatrix(rows, cols, prototype) + ops = append(ops, map[string]interface{}{ + "tool": "set_cell_range", + "params": map[string]interface{}{ + "excel_id": token, + "sheet_name": sheet, + "range": sub, + "cells": cells, + }, + }) + } + return map[string]interface{}{ + "excel_id": token, + "operations": ops, + }, nil +} + +// ─── helpers resurrected from B3 (used here + future skills) ────────── + +// validateDropdownRanges parses --ranges, requires every entry to carry a +// sheet prefix, and returns the parsed list. +func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) { + raw, err := requireJSONArray(runtime, "ranges") + if err != nil { + return nil, err + } + out := make([]string, 0, len(raw)) + for i, v := range raw { + s, ok := v.(string) + if !ok { + return nil, common.FlagErrorf("--ranges[%d] must be a string", i) + } + s = strings.TrimSpace(s) + if !strings.Contains(s, "!") { + return nil, common.FlagErrorf("--ranges[%d] (%q) must include a sheet prefix", i, s) + } + out = append(out, s) + } + return out, nil +} + +// splitSheetPrefixedRange splits "sheet1!A2:A100" into ("sheet1", "A2:A100"). +func splitSheetPrefixedRange(rng string) (sheet, sub string, err error) { + idx := strings.Index(rng, "!") + if idx <= 0 || idx == len(rng)-1 { + return "", "", common.FlagErrorf("range %q must use sheet!range form", rng) + } + return strings.TrimSpace(rng[:idx]), strings.TrimSpace(rng[idx+1:]), nil +} diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index 0a0d02906..fd63fc9cd 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -73,5 +73,11 @@ func Shortcuts() []common.Shortcut { FilterViewCreate, FilterViewUpdate, FilterViewDelete, SparklineCreate, SparklineUpdate, SparklineDelete, FloatImageCreate, FloatImageUpdate, FloatImageDelete, + + // lark_sheet_batch_update + BatchUpdate, + CellsBatchSetStyle, + DropdownUpdate, + DropdownDelete, } } From 9898024392234bd682ad44637e29bb4da678d133 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Sat, 16 May 2026 23:16:13 +0800 Subject: [PATCH 010/114] =?UTF-8?q?feat(sheets):=20implement=20cli-only=20?= =?UTF-8?q?shortcuts=20(B8)=20=E2=80=94=2070/70=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land the four cli-only shortcuts that can't route through the One-OpenAPI dispatcher (their backing capabilities aren't in mcp-tools.json): - +workbook-create POST /open-apis/sheets/v3/spreadsheets + optional set_cell_range follow-up that zips --headers and --data into the first sheet starting at A1. - +workbook-export POST /open-apis/drive/v1/export_tasks (type=sheet) → poll /export_tasks/:ticket up to ~30s → optional GET /export_tasks/file/:file_token/download. CSV mode requires --sheet-id (single sheet export). - +dim-move POST /open-apis/sheets/v2/spreadsheets/:token /dimension_range CLI is 0-indexed inclusive (--start / --end); the v2 endpoint expects half-open [startIndex, endIndex) so the body uses endIndex = --end + 1. --sheet-name is resolved client-side to sheet_id via lookupSheetIndex when needed. - +cells-set-image common.UploadDriveMediaAll (parent_type=sheet_image, parent_node=token) then callTool set_cell_range with cells carrying rich_text: [{type:"embed-image", attachment_token, attachment_name}]. --range must be exactly one cell. All four use runtime.CallAPI / DoAPI directly; only +cells-set-image combines a legacy upload with the new One-OpenAPI for the second step (set_cell_range is in mcp-tools.json so callTool is the right path). This closes the migration: 70 shortcuts × 17 canonical skills × matching the sheet-skill-spec v0.5.0 tool-shortcut-map. --- shortcuts/sheets/lark_sheet_cli_only.go | 634 ++++++++++++++++++++++++ shortcuts/sheets/shortcuts.go | 6 + 2 files changed, 640 insertions(+) create mode 100644 shortcuts/sheets/lark_sheet_cli_only.go diff --git a/shortcuts/sheets/lark_sheet_cli_only.go b/shortcuts/sheets/lark_sheet_cli_only.go new file mode 100644 index 000000000..acb013a6f --- /dev/null +++ b/shortcuts/sheets/lark_sheet_cli_only.go @@ -0,0 +1,634 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "fmt" + "net/http" + "path/filepath" + "strings" + "time" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── cli-only shortcuts (legacy OAPI direct calls) ──────────────────── +// +// Four shortcuts that don't fit the One-OpenAPI dispatcher because their +// backing capability isn't in the MCP tool catalog: +// +// - +workbook-create POST /open-apis/sheets/v3/spreadsheets +// + optional set_cell_range follow-up (headers / data) +// +// - +workbook-export POST /open-apis/drive/v1/export_tasks +// → poll /export_tasks/:ticket +// → optional GET /export_tasks/file/:file_token/download +// +// - +dim-move POST /open-apis/sheets/v2/spreadsheets/:token +// /dimension_range +// +// - +cells-set-image POST /open-apis/drive/v1/medias/upload_all +// (parent_type=sheet_image) → callTool set_cell_range +// with rich_text embed-image +// +// These do NOT go through the One-OpenAPI; CLI talks directly to the +// classic Feishu open APIs via runtime.CallAPI / DoAPI. + +// ─── +workbook-create ───────────────────────────────────────────────── + +// WorkbookCreate creates a brand-new spreadsheet in the user's drive +// (optionally inside --folder-token) and can pre-fill the first row of +// headers and an initial data block. +var WorkbookCreate = common.Shortcut{ + Service: "sheets", + Command: "+workbook-create", + Description: "Create a new spreadsheet (optionally pre-filled with --headers and --data).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:create", "sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "title", Required: true, Desc: "spreadsheet title"}, + {Name: "folder-token", Desc: "destination folder token; omit to land at the drive root"}, + {Name: "headers", Input: []string{common.File, common.Stdin}, Desc: "header row JSON array, e.g. [\"列A\",\"列B\"]"}, + {Name: "data", Input: []string{common.File, common.Stdin}, Desc: "initial data JSON 2D array, e.g. [[\"alice\",95]]"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("title")) == "" { + return common.FlagErrorf("--title is required") + } + if runtime.Str("headers") != "" { + v, err := parseJSONFlag(runtime, "headers") + if err != nil { + return err + } + if _, ok := v.([]interface{}); !ok { + return common.FlagErrorf("--headers must be a JSON array") + } + } + if runtime.Str("data") != "" { + v, err := parseJSONFlag(runtime, "data") + if err != nil { + return err + } + rows, ok := v.([]interface{}) + if !ok { + return common.FlagErrorf("--data must be a JSON 2D array") + } + for i, r := range rows { + if _, ok := r.([]interface{}); !ok { + return common.FlagErrorf("--data[%d] must be an array", i) + } + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{"title": strings.TrimSpace(runtime.Str("title"))} + if v := strings.TrimSpace(runtime.Str("folder-token")); v != "" { + body["folder_token"] = v + } + dry := common.NewDryRunAPI(). + POST("/open-apis/sheets/v3/spreadsheets"). + Desc("create spreadsheet"). + Body(body) + if runtime.Str("headers") != "" || runtime.Str("data") != "" { + fill, _ := buildInitialFillInput(runtime) + wireBody, _ := buildToolBody("set_cell_range", fill) + dry.POST("/open-apis/sheet_ai/v2/spreadsheets//tools/invoke_write"). + Desc("fill headers + data via set_cell_range"). + Body(wireBody) + } + return dry + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + body := map[string]interface{}{"title": strings.TrimSpace(runtime.Str("title"))} + if v := strings.TrimSpace(runtime.Str("folder-token")); v != "" { + body["folder_token"] = v + } + data, err := runtime.CallAPI("POST", "/open-apis/sheets/v3/spreadsheets", nil, body) + if err != nil { + return err + } + ss := common.GetMap(data, "spreadsheet") + token := common.GetString(ss, "spreadsheet_token") + if token == "" { + token = common.GetString(ss, "token") + } + if token == "" { + return output.Errorf(output.ExitAPI, "api_error", "spreadsheet created but token missing in response") + } + + result := map[string]interface{}{"spreadsheet": ss} + + if runtime.Str("headers") != "" || runtime.Str("data") != "" { + fill, err := buildInitialFillInput(runtime) + if err != nil { + return err + } + fill["excel_id"] = token + fillOut, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", fill) + if err != nil { + // Spreadsheet exists; surface the fill failure but keep the new + // token in the envelope so the caller can recover or retry. + return fmt.Errorf("spreadsheet %s created but initial fill failed: %w", token, err) + } + result["initial_fill"] = fillOut + } + runtime.Out(result, nil) + return nil + }, + Tips: []string{ + "--headers and --data are optional follow-up writes. They use the same set_cell_range tool as +cells-set; partial failure leaves the spreadsheet created but empty.", + }, +} + +// buildInitialFillInput zips --headers + --data into a single set_cell_range +// payload writing to the first sheet starting at A1. +func buildInitialFillInput(runtime *common.RuntimeContext) (map[string]interface{}, error) { + var rows [][]interface{} + if runtime.Str("headers") != "" { + v, _ := parseJSONFlag(runtime, "headers") + headerArr, _ := v.([]interface{}) + row := make([]interface{}, 0, len(headerArr)) + for _, h := range headerArr { + row = append(row, map[string]interface{}{"value": h}) + } + rows = append(rows, row) + } + if runtime.Str("data") != "" { + v, _ := parseJSONFlag(runtime, "data") + dataArr, _ := v.([]interface{}) + for _, r := range dataArr { + cells, _ := r.([]interface{}) + row := make([]interface{}, 0, len(cells)) + for _, c := range cells { + row = append(row, map[string]interface{}{"value": c}) + } + rows = append(rows, row) + } + } + if len(rows) == 0 { + return nil, nil + } + maxCols := 0 + for _, r := range rows { + if len(r) > maxCols { + maxCols = len(r) + } + } + // Normalize rows to the same length so cells matrix is rectangular. + for i := range rows { + for len(rows[i]) < maxCols { + rows[i] = append(rows[i], map[string]interface{}{}) + } + } + endCol := columnIndexToLetter(maxCols - 1) + rangeStr := fmt.Sprintf("A1:%s%d", endCol, len(rows)) + return map[string]interface{}{ + "range": rangeStr, + "cells": rows, + "sheet_id": "", // filled in by caller if sheet_id known; otherwise server picks first sheet + }, nil +} + +// ─── +workbook-export ───────────────────────────────────────────────── + +// WorkbookExport drives the three-step export flow: create task → poll → +// optionally download. CSV mode requires --sheet-id (the API exports one +// sheet at a time as csv). +var WorkbookExport = common.Shortcut{ + Service: "sheets", + Command: "+workbook-export", + Description: "Export a spreadsheet to xlsx or a single sheet to csv (async + poll + optional download).", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read", "docs:document:export", "drive:drive.metadata:readonly"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicTokenFlags(), + common.Flag{Name: "file-extension", Enum: []string{"xlsx", "csv"}, Default: "xlsx", Desc: "xlsx (whole workbook) or csv (one sheet via --sheet-id)"}, + common.Flag{Name: "sheet-id", Desc: "csv mode only: target sheet reference_id to export"}, + common.Flag{Name: "output-path", Desc: "local file path to save into; omit to just trigger and report the file_token"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + ext := runtime.Str("file-extension") + if ext == "" { + ext = "xlsx" + } + if ext == "csv" && strings.TrimSpace(runtime.Str("sheet-id")) == "" { + return common.FlagErrorf("--sheet-id is required when --file-extension=csv") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + ext := runtime.Str("file-extension") + if ext == "" { + ext = "xlsx" + } + body := map[string]interface{}{ + "token": token, + "type": "sheet", + "file_extension": ext, + } + if sid := strings.TrimSpace(runtime.Str("sheet-id")); sid != "" { + body["sub_id"] = sid + } + dry := common.NewDryRunAPI(). + POST("/open-apis/drive/v1/export_tasks"). + Desc("create export task"). + Body(body). + GET("/open-apis/drive/v1/export_tasks/"). + Desc("poll task status"). + Params(map[string]interface{}{"token": token}) + if strings.TrimSpace(runtime.Str("output-path")) != "" { + dry.GET("/open-apis/drive/v1/export_tasks/file//download"). + Desc("download exported file") + } + return dry + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + ext := runtime.Str("file-extension") + if ext == "" { + ext = "xlsx" + } + body := map[string]interface{}{ + "token": token, + "type": "sheet", + "file_extension": ext, + } + if sid := strings.TrimSpace(runtime.Str("sheet-id")); sid != "" { + body["sub_id"] = sid + } + taskData, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, body) + if err != nil { + return err + } + ticket := common.GetString(taskData, "ticket") + if ticket == "" { + return output.Errorf(output.ExitAPI, "api_error", "export task created but ticket missing") + } + + result := map[string]interface{}{ + "ticket": ticket, + "file_extension": ext, + } + + // Poll up to ~30s for completion. + var fileToken, fileName string + for attempt := 0; attempt < 15; attempt++ { + status, err := pollExportTask(runtime, token, ticket) + if err != nil { + return err + } + switch status.JobStatus { + case 0: // success + fileToken = status.FileToken + fileName = status.FileName + result["file_token"] = fileToken + result["file_name"] = fileName + result["file_size"] = status.FileSize + attempt = 999 // break outer loop + case 1, 2: // pending / in progress + time.Sleep(2 * time.Second) + continue + default: // any non-zero status outside the in-progress window is a failure + if status.JobErrorMsg != "" { + return output.Errorf(output.ExitAPI, "api_error", "export task %s failed: %s", ticket, status.JobErrorMsg) + } + return output.Errorf(output.ExitAPI, "api_error", "export task %s failed with job_status=%d", ticket, status.JobStatus) + } + } + if fileToken == "" { + result["status"] = "polling_timeout" + runtime.Out(result, nil) + return nil + } + + outPath := strings.TrimSpace(runtime.Str("output-path")) + if outPath == "" { + runtime.Out(result, nil) + return nil + } + + saved, err := downloadExportFile(ctx, runtime, fileToken, outPath, fileName) + if err != nil { + return err + } + result["saved_path"] = saved + runtime.Out(result, nil) + return nil + }, + Tips: []string{ + "Polls up to ~30s (15 × 2s). For very large workbooks rerun and pass --output-path to capture the file once status flips to success.", + }, +} + +type exportTaskStatus struct { + JobStatus int + JobErrorMsg string + FileToken string + FileName string + FileSize int64 + FileExtension string +} + +func pollExportTask(runtime *common.RuntimeContext, token, ticket string) (exportTaskStatus, error) { + data, err := runtime.CallAPI( + "GET", + fmt.Sprintf("/open-apis/drive/v1/export_tasks/%s", validate.EncodePathSegment(ticket)), + map[string]interface{}{"token": token}, + nil, + ) + if err != nil { + return exportTaskStatus{}, err + } + result := common.GetMap(data, "result") + if result == nil { + return exportTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "export task %s: empty result", ticket) + } + js, _ := util.ToFloat64(result["job_status"]) + fs, _ := util.ToFloat64(result["file_size"]) + return exportTaskStatus{ + JobStatus: int(js), + JobErrorMsg: common.GetString(result, "job_error_msg"), + FileToken: common.GetString(result, "file_token"), + FileName: common.GetString(result, "file_name"), + FileSize: int64(fs), + FileExtension: common.GetString(result, "file_extension"), + }, nil +} + +func downloadExportFile(ctx context.Context, runtime *common.RuntimeContext, fileToken, outPath, preferredName string) (string, error) { + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)), + }, larkcore.WithFileDownload()) + if err != nil { + return "", output.ErrNetwork("download failed: %s", err) + } + if apiResp.StatusCode >= 400 { + return "", output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody)) + } + target := outPath + if info, statErr := runtime.FileIO().Stat(outPath); statErr == nil && info.IsDir() { + name := strings.TrimSpace(preferredName) + if name == "" { + name = client.ResolveFilename(apiResp) + } + target = filepath.Join(outPath, name) + } + if _, err := runtime.FileIO().Save(target, fileio.SaveOptions{ + ContentType: apiResp.Header.Get("Content-Type"), + ContentLength: int64(len(apiResp.RawBody)), + }, strings.NewReader(string(apiResp.RawBody))); err != nil { + return "", common.WrapSaveErrorByCategory(err, "io") + } + resolved, _ := runtime.FileIO().ResolvePath(target) + if resolved == "" { + resolved = target + } + return resolved, nil +} + +// ─── +dim-move ──────────────────────────────────────────────────────── + +// DimMove moves a contiguous block of rows or columns to a new index in the +// same sheet. The CLI flag semantic is 0-based inclusive (--start / --end); +// the legacy v2 endpoint expects half-open [startIndex, endIndex). +var DimMove = common.Shortcut{ + Service: "sheets", + Command: "+dim-move", + Description: "Move a contiguous block of rows or columns to a new position (re-numbers neighbors).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "dimension", Required: true, Enum: dimEnum, Desc: "`row` or `column`"}, + common.Flag{Name: "start", Type: "int", Required: true, Desc: "source start (0-indexed, inclusive)"}, + common.Flag{Name: "end", Type: "int", Required: true, Desc: "source end (0-indexed, inclusive)"}, + common.Flag{Name: "target", Type: "int", Required: true, Desc: "destination index (0-indexed); rows/cols move to land BEFORE this index"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if !runtime.Changed("dimension") || !runtime.Changed("start") || !runtime.Changed("end") || !runtime.Changed("target") { + return common.FlagErrorf("--dimension / --start / --end / --target are all required") + } + if runtime.Int("start") < 0 || runtime.Int("end") < runtime.Int("start") { + return common.FlagErrorf("--end (%d) must be >= --start (%d) (both 0-indexed, inclusive)", runtime.Int("end"), runtime.Int("start")) + } + if runtime.Int("target") < 0 { + return common.FlagErrorf("--target must be >= 0") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + body := dimMoveBody(runtime, sheetSelectorPlaceholder(sheetID, sheetName)) + return common.NewDryRunAPI(). + POST(fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", token)). + Body(body). + Set("spreadsheet_token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + // Legacy v2 endpoint needs sheet_id. Resolve sheet_name client-side + // when needed (reuses lookupSheetIndex which fetches workbook structure). + if sheetID == "" { + lookedID, _, err := lookupSheetIndex(ctx, runtime, token, "", sheetName) + if err != nil { + return err + } + sheetID = lookedID + } + body := dimMoveBody(runtime, sheetID) + data, err := runtime.CallAPI( + "POST", + fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)), + nil, body, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +func dimMoveBody(runtime *common.RuntimeContext, sheetID string) map[string]interface{} { + dim := "ROWS" + if runtime.Str("dimension") == "column" { + dim = "COLUMNS" + } + return map[string]interface{}{ + "source": map[string]interface{}{ + "sheetId": sheetID, + "majorDimension": dim, + "startIndex": runtime.Int("start"), + "endIndex": runtime.Int("end") + 1, // CLI inclusive → API exclusive + }, + "destinationIndex": runtime.Int("target"), + } +} + +// ─── +cells-set-image ───────────────────────────────────────────────── + +// CellsSetImage uploads a local image to drive (parent_type=sheet_image, +// parent_node=spreadsheet token) and then writes a rich_text embed-image +// into the target single-cell range via the set_cell_range tool. +var CellsSetImage = common.Shortcut{ + Service: "sheets", + Command: "+cells-set-image", + Description: "Embed a local image into a single cell (uploads via drive, then set_cell_range with rich_text embed-image).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "drive:file:upload"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "range", Required: true, Desc: "single target cell (e.g. A1; start/end must equal)"}, + common.Flag{Name: "image", Required: true, Desc: "local image path (PNG/JPEG/JPG/GIF/BMP/JFIF/EXIF/TIFF/BPG/HEIC)"}, + common.Flag{Name: "name", Desc: "uploaded file name (with extension); defaults to basename(--image)"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + r := strings.TrimSpace(runtime.Str("range")) + if r == "" { + return common.FlagErrorf("--range is required") + } + rows, cols, err := rangeDimensions(r) + if err != nil { + return common.FlagErrorf("--range %q: %v", r, err) + } + if rows != 1 || cols != 1 { + return common.FlagErrorf("--range %q must be exactly one cell (got %d×%d)", r, rows, cols) + } + if strings.TrimSpace(runtime.Str("image")) == "" { + return common.FlagErrorf("--image is required") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + imgPath := strings.TrimSpace(runtime.Str("image")) + fileName := strings.TrimSpace(runtime.Str("name")) + if fileName == "" { + fileName = filepath.Base(imgPath) + } + setCellBody, _ := buildToolBody("set_cell_range", map[string]interface{}{ + "excel_id": token, + "range": strings.TrimSpace(runtime.Str("range")), + "sheet_id": sheetSelectorPlaceholder(sheetID, sheetName), + "cells": [][]interface{}{{map[string]interface{}{ + "rich_text": []map[string]interface{}{{ + "type": "embed-image", + "attachment_token": "", + "attachment_name": fileName, + }}, + }}}, + }) + return common.NewDryRunAPI(). + POST("/open-apis/drive/v1/medias/upload_all"). + Desc("upload local image to drive (parent_type=sheet_image)"). + Body(map[string]interface{}{ + "file_name": fileName, + "parent_type": "sheet_image", + "parent_node": token, + "size": "", + "file": "@" + imgPath, + }). + POST(toolInvokePath(token, ToolKindWrite)). + Desc("embed file_token into the cell via set_cell_range"). + Body(setCellBody) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + imgPath := strings.TrimSpace(runtime.Str("image")) + fileName := strings.TrimSpace(runtime.Str("name")) + if fileName == "" { + fileName = filepath.Base(imgPath) + } + info, err := runtime.FileIO().Stat(imgPath) + if err != nil { + return common.WrapInputStatError(err) + } + fileToken, err := common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ + FilePath: imgPath, + FileName: fileName, + FileSize: info.Size(), + ParentType: "sheet_image", + ParentNode: &token, + }) + if err != nil { + return err + } + + setCellInput := map[string]interface{}{ + "excel_id": token, + "range": strings.TrimSpace(runtime.Str("range")), + "cells": [][]interface{}{{map[string]interface{}{ + "rich_text": []map[string]interface{}{{ + "type": "embed-image", + "attachment_token": fileToken, + "attachment_name": fileName, + }}, + }}}, + } + sheetSelectorForToolInput(setCellInput, sheetID, sheetName) + setCellOut, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", setCellInput) + if err != nil { + return fmt.Errorf("image uploaded (file_token=%s) but cell write failed: %w", fileToken, err) + } + runtime.Out(map[string]interface{}{ + "file_token": fileToken, + "file_name": fileName, + "set_cell_range": setCellOut, + }, nil) + return nil + }, + Tips: []string{ + "--range must be a single cell. The uploaded image becomes a cell-internal embed; use +float-image-create for floating images.", + }, +} diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index fd63fc9cd..8d56b5532 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -79,5 +79,11 @@ func Shortcuts() []common.Shortcut { CellsBatchSetStyle, DropdownUpdate, DropdownDelete, + + // cli-only (legacy OAPI direct calls) + WorkbookCreate, + WorkbookExport, + DimMove, + CellsSetImage, } } From 96f57425115f8f0617e7e65f1b16a49e2bd0ccfc Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Sat, 16 May 2026 23:37:58 +0800 Subject: [PATCH 011/114] test(sheets): cover all 70 shortcuts with dry-run + execute-path tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelve _test.go files alongside the implementation, mirroring the legacy package's coverage style: - testhelpers_test.go shared rig: TestFactory + Mount + dry-run capture + JSON-input decode + envelope helpers. - lark_sheet_*_test.go one test file per implementation file (9 files), table-driven dry-run cases per shortcut plus targeted validation guards. - execute_paths_test.go end-to-end execute paths via httpmock stubs. Covers callTool unwrap, JSON-string output decoding, two-step lookup (+sheet-move), batch_update fan-out, dropdown atomic writes, and the legacy OAPI shortcuts (+workbook-create, +dim-move) including CLI inclusive → API half-open index conversion. Test coverage on the sheets package is 60.5 % of statements with -race clean, meeting the dev manual's ≥ 60 % patch-coverage gate. --- shortcuts/sheets/execute_paths_test.go | 398 ++++++++++++++++++ .../sheets/lark_sheet_batch_update_test.go | 184 ++++++++ shortcuts/sheets/lark_sheet_cli_only_test.go | 216 ++++++++++ .../sheets/lark_sheet_object_crud_test.go | 219 ++++++++++ .../sheets/lark_sheet_object_list_test.go | 112 +++++ .../lark_sheet_range_operations_test.go | 221 ++++++++++ shortcuts/sheets/lark_sheet_read_data_test.go | 127 ++++++ .../sheets/lark_sheet_search_replace_test.go | 103 +++++ .../sheets/lark_sheet_sheet_structure_test.go | 188 +++++++++ shortcuts/sheets/lark_sheet_workbook_test.go | 323 ++++++++++++++ .../sheets/lark_sheet_write_cells_test.go | 205 +++++++++ shortcuts/sheets/testhelpers_test.go | 203 +++++++++ 12 files changed, 2499 insertions(+) create mode 100644 shortcuts/sheets/execute_paths_test.go create mode 100644 shortcuts/sheets/lark_sheet_batch_update_test.go create mode 100644 shortcuts/sheets/lark_sheet_cli_only_test.go create mode 100644 shortcuts/sheets/lark_sheet_object_crud_test.go create mode 100644 shortcuts/sheets/lark_sheet_object_list_test.go create mode 100644 shortcuts/sheets/lark_sheet_range_operations_test.go create mode 100644 shortcuts/sheets/lark_sheet_read_data_test.go create mode 100644 shortcuts/sheets/lark_sheet_search_replace_test.go create mode 100644 shortcuts/sheets/lark_sheet_sheet_structure_test.go create mode 100644 shortcuts/sheets/lark_sheet_workbook_test.go create mode 100644 shortcuts/sheets/lark_sheet_write_cells_test.go create mode 100644 shortcuts/sheets/testhelpers_test.go diff --git a/shortcuts/sheets/execute_paths_test.go b/shortcuts/sheets/execute_paths_test.go new file mode 100644 index 000000000..21957527b --- /dev/null +++ b/shortcuts/sheets/execute_paths_test.go @@ -0,0 +1,398 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +// TestExecute_WorkbookInfo_Happy stubs the invoke_read endpoint and +// verifies the shortcut decodes the JSON-string output, surfaces it as +// envelope data, and finishes without error. +func TestExecute_WorkbookInfo_Happy(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","title":"Sheet1","row_count":1000,"column_count":26,"index":0}]}`) + out, err := runShortcutWithStubs(t, WorkbookInfo, []string{"--url", testURL}, stub) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + data := decodeEnvelopeData(t, out) + sheets, _ := data["sheets"].([]interface{}) + if len(sheets) != 1 { + t.Fatalf("sheets len = %d, want 1", len(sheets)) + } + sheet, _ := sheets[0].(map[string]interface{}) + if sheet["sheet_id"] != "sh1" || sheet["title"] != "Sheet1" { + t.Errorf("unexpected sheet: %#v", sheet) + } +} + +// TestExecute_WorkbookInfo_ToolError surfaces a non-zero code in the +// envelope shape and asserts CLI returns an error envelope. +func TestExecute_WorkbookInfo_ToolError(t *testing.T) { + t.Parallel() + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_read", + Body: map[string]interface{}{ + "code": 1310201, + "msg": "spreadsheet not found", + "data": map[string]interface{}{}, + }, + } + stdout, stderr, err := func() (string, string, error) { + parent, stdout, stderr, reg := newTestRig(t, WorkbookInfo) + reg.Register(stub) + parent.SetArgs([]string{"+workbook-info", "--url", testURL}) + err := parent.Execute() + return stdout.String(), stderr.String(), err + }() + if err == nil { + t.Fatalf("expected non-zero code to surface as error; stdout=%s stderr=%s", stdout, stderr) + } + combined := stdout + stderr + err.Error() + if !strings.Contains(combined, "1310201") && !strings.Contains(combined, "not found") { + t.Errorf("expected error code in envelope; got=%s|%s|%v", stdout, stderr, err) + } +} + +// TestExecute_SheetMove_LookupsIndex covers the two-step path: SheetMove +// when only --sheet-name is given (and --source-index omitted) first +// reads the workbook structure to derive sheet_id + source_index, then +// posts the modify_workbook_structure call. +func TestExecute_SheetMove_LookupsIndex(t *testing.T) { + t.Parallel() + lookup := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","sheet_name":"汇总","index":3}]}`) + move := toolOutputStub(testToken, "write", `{"sheet_id":"sh1"}`) + out, err := runShortcutWithStubs(t, SheetMove, + []string{"--url", testURL, "--sheet-name", "汇总", "--index", "0"}, + lookup, move, + ) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + // Inspect the captured move body: source_index should be 3 (looked up), + // not , and sheet_id should be the resolved id. + if move.CapturedBody == nil { + t.Fatal("move stub didn't capture a body") + } + body := decodeRawEnvelopeBody(t, move.CapturedBody) + input := decodeToolInput(t, body, "modify_workbook_structure") + if input["sheet_id"] != "sh1" { + t.Errorf("sheet_id = %v, want sh1 (resolved from --sheet-name)", input["sheet_id"]) + } + if input["source_index"].(float64) != 3 { + t.Errorf("source_index = %v, want 3 (from lookup)", input["source_index"]) + } + if input["target_index"].(float64) != 0 { + t.Errorf("target_index = %v, want 0", input["target_index"]) + } +} + +// TestExecute_CellsGet covers a multi-range read end-to-end. +func TestExecute_CellsGet(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "read", `{"ranges":[{"range":"A1:B2","cells":[[{"value":1}]]}]}`) + out, err := runShortcutWithStubs(t, CellsGet, + []string{"--url", testURL, "--sheet-id", testSheetID, "--ranges", "A1:B2"}, stub) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + if data := decodeEnvelopeData(t, out); data["ranges"] == nil { + t.Fatalf("expected ranges in output; got=%#v", data) + } +} + +// TestExecute_CellsSet covers the write path including allow-overwrite +// override. +func TestExecute_CellsSet(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "write", `{"updated_cells":2}`) + out, err := runShortcutWithStubs(t, CellsSet, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1:B1", + "--data", `{"cells":[[{"value":"x"},{"value":"y"}]]}`, + }, stub) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + body := decodeRawEnvelopeBody(t, stub.CapturedBody) + input := decodeToolInput(t, body, "set_cell_range") + if input["range"] != "A1:B1" { + t.Errorf("wire range = %v", input["range"]) + } + if data := decodeEnvelopeData(t, out); data["updated_cells"].(float64) != 2 { + t.Errorf("updated_cells = %v", data["updated_cells"]) + } +} + +// TestExecute_DropdownSet covers the fan-out → set_cell_range write. +func TestExecute_DropdownSet(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "write", `{}`) + _, err := runShortcutWithStubs(t, DropdownSet, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A2:A4", + "--options", `["x","y"]`, + "--multiple", + }, stub) + if err != nil { + t.Fatalf("execute failed: %v", err) + } + body := decodeRawEnvelopeBody(t, stub.CapturedBody) + input := decodeToolInput(t, body, "set_cell_range") + cells, _ := input["cells"].([]interface{}) + if len(cells) != 3 { + t.Errorf("wire cells rows = %d, want 3", len(cells)) + } +} + +// TestExecute_DropdownUpdate_Batch covers the batch_update fan-out for +// dropdown-update. Verifies the captured request has 2 ops. +func TestExecute_DropdownUpdate_Batch(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true},{"ok":true}]}`) + _, err := runShortcutWithStubs(t, DropdownUpdate, []string{ + "--url", testURL, + "--ranges", `["sheet1!A2:A5","sheet1!C2:C5"]`, + "--options", `["a","b"]`, + }, stub) + if err != nil { + t.Fatalf("execute failed: %v", err) + } + body := decodeRawEnvelopeBody(t, stub.CapturedBody) + input := decodeToolInput(t, body, "batch_update") + ops, _ := input["operations"].([]interface{}) + if len(ops) != 2 { + t.Errorf("operations len = %d, want 2", len(ops)) + } +} + +// TestExecute_CellsSearch covers the search read path with options. +func TestExecute_CellsSearch(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "read", `{"matches":[{"cell":"B2"}],"has_more":false}`) + out, err := runShortcutWithStubs(t, CellsSearch, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--find", "foo", "--match-case", + }, stub) + if err != nil { + t.Fatalf("execute failed: %v", err) + } + data := decodeEnvelopeData(t, out) + if data["matches"] == nil { + t.Errorf("matches missing: %#v", data) + } +} + +// TestExecute_RangeMove covers the transform_range write path. +func TestExecute_RangeMove(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "write", `{"moved":true}`) + out, err := runShortcutWithStubs(t, RangeMove, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--source-range", "A1:C5", + "--target-range", "D1", + }, stub) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + body := decodeRawEnvelopeBody(t, stub.CapturedBody) + input := decodeToolInput(t, body, "transform_range") + if input["operation"] != "move" { + t.Errorf("operation = %v, want move", input["operation"]) + } +} + +// TestExecute_FilterCreate covers the filter special case (range mandatory, +// optional --data conditions merge). +func TestExecute_FilterCreate(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "write", `{"filter_id":"sh1"}`) + out, err := runShortcutWithStubs(t, FilterCreate, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1:F100", + "--data", `{"conditions":[{"col":"B","filter_type":"multiValue","expected":["x"]}]}`, + }, stub) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + body := decodeRawEnvelopeBody(t, stub.CapturedBody) + input := decodeToolInput(t, body, "manage_filter_object") + props, _ := input["properties"].(map[string]interface{}) + if props["range"] != "A1:F100" { + t.Errorf("properties.range = %v", props["range"]) + } + if props["conditions"] == nil { + t.Errorf("conditions missing: %#v", props) + } +} + +// TestExecute_BatchUpdate_Raw covers the raw passthrough including +// continue_on_error. +func TestExecute_BatchUpdate_Raw(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`) + _, err := runShortcutWithStubs(t, BatchUpdate, []string{ + "--url", testURL, + "--data", `{"operations":[{"tool":"set_cell_range","params":{"excel_id":"shtcnTestTOK","range":"A1","cells":[[{"value":1}]]}}]}`, + "--continue-on-error", + "--yes", + }, stub) + if err != nil { + t.Fatalf("execute failed: %v", err) + } + body := decodeRawEnvelopeBody(t, stub.CapturedBody) + input := decodeToolInput(t, body, "batch_update") + if input["continue_on_error"] != true { + t.Errorf("continue_on_error not propagated: %#v", input) + } +} + +// TestExecute_WorkbookCreate covers the legacy POST + optional +// set_cell_range follow-up. Stubs both endpoints. +func TestExecute_WorkbookCreate(t *testing.T) { + t.Parallel() + create := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "spreadsheet": map[string]interface{}{ + "spreadsheet_token": "shtcnBRAND", + "title": "Sales", + }, + }, + }, + } + fill := toolOutputStub("shtcnBRAND", "write", `{"updated_cells":4}`) + out, err := runShortcutWithStubs(t, WorkbookCreate, []string{ + "--title", "Sales", + "--headers", `["Name","Score"]`, + "--data", `[["alice",95]]`, + }, create, fill) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + data := decodeEnvelopeData(t, out) + ss, _ := data["spreadsheet"].(map[string]interface{}) + if ss["spreadsheet_token"] != "shtcnBRAND" { + t.Errorf("spreadsheet_token = %v", ss["spreadsheet_token"]) + } + if data["initial_fill"] == nil { + t.Errorf("initial_fill missing in envelope") + } +} + +// TestExecute_DimMove covers the legacy v2 dimension_range call with +// CLI inclusive → API exclusive end-index conversion. +func TestExecute_DimMove(t *testing.T) { + t.Parallel() + move := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/" + testToken + "/dimension_range", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{"moved": true}, + }, + } + _, err := runShortcutWithStubs(t, DimMove, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--dimension", "row", "--start", "0", "--end", "2", "--target", "10", + }, move) + if err != nil { + t.Fatalf("execute failed: %v", err) + } + body := decodeRawEnvelopeBody(t, move.CapturedBody) + src, _ := body["source"].(map[string]interface{}) + if src["startIndex"].(float64) != 0 || src["endIndex"].(float64) != 3 { + t.Errorf("indices = (%v,%v), want (0,3)", src["startIndex"], src["endIndex"]) + } +} + +// TestExecute_ChartCreate covers the object-CRUD factory's create path. +func TestExecute_ChartCreate(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "write", `{"chart_id":"chartNEW"}`) + out, err := runShortcutWithStubs(t, ChartCreate, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--data", `{"type":"line"}`, + }, stub) + if err != nil { + t.Fatalf("execute failed: %v", err) + } + data := decodeEnvelopeData(t, out) + if data["chart_id"] != "chartNEW" { + t.Errorf("chart_id = %v", data["chart_id"]) + } +} + +// TestExecute_SheetCreate hits the workbook write path with all four +// optional flags so the input builder + callTool wiring is exercised. +func TestExecute_SheetCreate(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "write", `{"sheet_id":"sh99","sheet_name":"Q4","index":2}`) + out, err := runShortcutWithStubs(t, SheetCreate, []string{ + "--url", testURL, + "--title", "Q4", + "--index", "2", + "--row-count", "300", + "--col-count", "12", + }, stub) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + body := decodeRawEnvelopeBody(t, stub.CapturedBody) + input := decodeToolInput(t, body, "modify_workbook_structure") + if input["operation"] != "create" || input["sheet_name"] != "Q4" { + t.Errorf("input shape wrong: %#v", input) + } + if input["rows"].(float64) != 300 || input["columns"].(float64) != 12 { + t.Errorf("dimensions = (%v, %v), want (300, 12)", input["rows"], input["columns"]) + } +} + +// TestExecute_RangeSort exercises the sort_conditions JSON parsing +// alongside the boolean has_header. +func TestExecute_RangeSort(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "write", `{"sorted":true}`) + _, err := runShortcutWithStubs(t, RangeSort, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1:D50", + "--has-header", + "--sort-keys", `[{"col":"B","order":"asc"}]`, + }, stub) + if err != nil { + t.Fatalf("execute failed: %v", err) + } + body := decodeRawEnvelopeBody(t, stub.CapturedBody) + input := decodeToolInput(t, body, "transform_range") + if input["operation"] != "sort" || input["has_header"] != true { + t.Errorf("input wrong: %#v", input) + } + conds, _ := input["sort_conditions"].([]interface{}) + if len(conds) != 1 { + t.Errorf("sort_conditions len = %d", len(conds)) + } +} + +// decodeRawEnvelopeBody parses the raw JSON request body captured by an +// httpmock stub. Used by execute tests to inspect what the CLI sent on +// the wire (vs. dry-run tests that render the body up-front). +func decodeRawEnvelopeBody(t *testing.T, raw []byte) map[string]interface{} { + t.Helper() + var body map[string]interface{} + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("captured body parse error: %v\nraw=%s", err, string(raw)) + } + return body +} diff --git a/shortcuts/sheets/lark_sheet_batch_update_test.go b/shortcuts/sheets/lark_sheet_batch_update_test.go new file mode 100644 index 000000000..2ae045b09 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_batch_update_test.go @@ -0,0 +1,184 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestBatchUpdate_RawPassthrough verifies +batch-update threads +// --data.operations into the tool input as-is and honors +// --continue-on-error. +func TestBatchUpdate_RawPassthrough(t *testing.T) { + t.Parallel() + + body := parseDryRunBody(t, BatchUpdate, []string{ + "--url", testURL, + "--data", `{"operations":[{"tool":"set_cell_range","params":{"excel_id":"shtcnTOK","sheet_id":"sh1","range":"A1","cells":[[{"value":42}]]}}]}`, + "--continue-on-error", + "--yes", + }) + input := decodeToolInput(t, body, "batch_update") + ops, _ := input["operations"].([]interface{}) + if len(ops) != 1 { + t.Fatalf("operations length = %d, want 1", len(ops)) + } + if input["continue_on_error"] != true { + t.Errorf("continue_on_error = %v, want true", input["continue_on_error"]) + } +} + +func TestBatchUpdate_HighRiskWriteRequiresYes(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{ + "--url", testURL, + "--data", `{"operations":[{"tool":"set_cell_range","params":{}}]}`, + }) + if err == nil { + t.Fatalf("expected confirmation_required; stdout=%s stderr=%s", stdout, stderr) + } +} + +// TestCellsBatchSetStyle_FansOutOps verifies 2 entries × multiple ranges +// produce one set_cell_range op per (entry, range). +func TestCellsBatchSetStyle_FansOutOps(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, CellsBatchSetStyle, []string{ + "--url", testURL, + "--data", `[{"ranges":["sheet1!A1:B2","sheet1!D1:E2"],"style":{"font":{"bold":true}}},{"ranges":["sheet1!A5:A6"],"style":{"backColor":"#ff0"}}]`, + }) + input := decodeToolInput(t, body, "batch_update") + ops, _ := input["operations"].([]interface{}) + if len(ops) != 3 { + t.Fatalf("operations length = %d, want 3 (2 ranges × entry1 + 1 range × entry2)", len(ops)) + } + // Every op should target set_cell_range with sheet_name carrying the prefix. + for i, raw := range ops { + op, _ := raw.(map[string]interface{}) + if op["tool"] != "set_cell_range" { + t.Errorf("op[%d].tool = %v, want set_cell_range", i, op["tool"]) + } + params, _ := op["params"].(map[string]interface{}) + if params["sheet_name"] != "sheet1" { + t.Errorf("op[%d].sheet_name = %v, want sheet1", i, params["sheet_name"]) + } + } +} + +// TestDropdownUpdate_BatchPayload verifies the multi-range dropdown +// update fans out into a single batch_update with one set_cell_range +// op per range. +func TestDropdownUpdate_BatchPayload(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, DropdownUpdate, []string{ + "--url", testURL, + "--ranges", `["sheet1!A2:A5","sheet1!C2:C5"]`, + "--options", `["a","b","c"]`, + "--multiple", + }) + input := decodeToolInput(t, body, "batch_update") + ops, _ := input["operations"].([]interface{}) + if len(ops) != 2 { + t.Fatalf("operations length = %d, want 2", len(ops)) + } + for i, raw := range ops { + op, _ := raw.(map[string]interface{}) + params, _ := op["params"].(map[string]interface{}) + cells, _ := params["cells"].([]interface{}) + if len(cells) != 4 { + t.Errorf("op[%d] cells rows = %d, want 4 (A2:A5 / C2:C5)", i, len(cells)) + } + row0, _ := cells[0].([]interface{}) + cell, _ := row0[0].(map[string]interface{}) + dv, _ := cell["data_validation"].(map[string]interface{}) + if dv == nil || dv["type"] != "list" { + t.Errorf("op[%d] missing data_validation list: %#v", i, cell) + } + if dv["multiple_values"] != true { + t.Errorf("op[%d] multiple_values = %v, want true", i, dv["multiple_values"]) + } + } +} + +// TestDropdownDelete_BatchClearsValidation verifies delete sets +// data_validation: null on every cell. +func TestDropdownDelete_BatchClearsValidation(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, DropdownDelete, []string{ + "--url", testURL, + "--ranges", `["sheet1!A2:A4"]`, + "--yes", + }) + input := decodeToolInput(t, body, "batch_update") + ops, _ := input["operations"].([]interface{}) + if len(ops) != 1 { + t.Fatalf("operations length = %d, want 1", len(ops)) + } + op := ops[0].(map[string]interface{}) + params, _ := op["params"].(map[string]interface{}) + cells, _ := params["cells"].([]interface{}) + for i, raw := range cells { + row, _ := raw.([]interface{}) + cell, _ := row[0].(map[string]interface{}) + if _, present := cell["data_validation"]; !present { + t.Errorf("row %d: data_validation key missing", i) + continue + } + if cell["data_validation"] != nil { + t.Errorf("row %d: data_validation = %v, want null", i, cell["data_validation"]) + } + } +} + +func TestBatchUpdate_ValidationGuards(t *testing.T) { + t.Parallel() + cases := []struct { + name string + sc interface{ shortcut() } + args []string + want string + }{} + _ = cases + + // dropdown-update with sheetless range + stdout, stderr, err := runShortcutCapturingErr(t, DropdownUpdate, []string{ + "--url", testURL, + "--ranges", `["A2:A5"]`, + "--options", `["a"]`, + "--dry-run", + }) + if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must include a sheet prefix") { + t.Errorf("expected sheet-prefix guard for +dropdown-update; got=%s|%s|%v", stdout, stderr, err) + } + + // batch-update with empty operations + stdout, stderr, err = runShortcutCapturingErr(t, BatchUpdate, []string{ + "--url", testURL, + "--data", `{"operations":[]}`, + "--yes", + "--dry-run", + }) + if err == nil || !strings.Contains(stdout+stderr+err.Error(), "non-empty JSON array") { + t.Errorf("expected empty-operations guard; got=%s|%s|%v", stdout, stderr, err) + } +} + +// TestSplitSheetPrefixedRange exercises the helper directly. +func TestSplitSheetPrefixedRange(t *testing.T) { + t.Parallel() + sheet, sub, err := splitSheetPrefixedRange("sheet1!A2:A100") + if err != nil || sheet != "sheet1" || sub != "A2:A100" { + t.Errorf("split = (%q,%q,%v), want (sheet1, A2:A100, nil)", sheet, sub, err) + } + if _, _, err := splitSheetPrefixedRange("A2:A100"); err == nil { + t.Error("expected error on missing prefix") + } + if _, _, err := splitSheetPrefixedRange("!A2"); err == nil { + t.Error("expected error on empty sheet name") + } + // Compile-time use of json import + _ = json.Marshal +} diff --git a/shortcuts/sheets/lark_sheet_cli_only_test.go b/shortcuts/sheets/lark_sheet_cli_only_test.go new file mode 100644 index 000000000..168082bb2 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_cli_only_test.go @@ -0,0 +1,216 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "strings" + "testing" +) + +// TestWorkbookCreate_DryRun verifies the two-step plan (create +// spreadsheet + optional set_cell_range follow-up) is rendered. +func TestWorkbookCreate_DryRun(t *testing.T) { + t.Parallel() + + t.Run("minimal title only", func(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, WorkbookCreate, []string{"--title", "MySheet"}) + if len(calls) != 1 { + t.Fatalf("api calls = %d, want 1 (no headers/data)", len(calls)) + } + c := calls[0].(map[string]interface{}) + if c["url"] != "/open-apis/sheets/v3/spreadsheets" { + t.Errorf("url = %v, want /open-apis/sheets/v3/spreadsheets", c["url"]) + } + body, _ := c["body"].(map[string]interface{}) + if body["title"] != "MySheet" { + t.Errorf("body.title = %v, want MySheet", body["title"]) + } + }) + + t.Run("with headers and data → 2-step plan", func(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, WorkbookCreate, []string{ + "--title", "Sales", + "--headers", `["Name","Score"]`, + "--data", `[["alice",95],["bob",88]]`, + }) + if len(calls) != 2 { + t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls)) + } + fill := calls[1].(map[string]interface{}) + if !strings.Contains(fill["url"].(string), "/sheet_ai/v2/spreadsheets/") { + t.Errorf("fill url = %v, want sheet_ai/v2 path", fill["url"]) + } + body, _ := fill["body"].(map[string]interface{}) + input := decodeToolInput(t, body, "set_cell_range") + if input["range"] != "A1:B3" { + t.Errorf("fill range = %v, want A1:B3 (1 header + 2 data rows × 2 cols)", input["range"]) + } + }) +} + +// TestWorkbookCreate_DataValidation rejects bad JSON shape. +func TestWorkbookCreate_DataValidation(t *testing.T) { + t.Parallel() + cases := []struct { + name string + args []string + want string + }{ + {"headers not array", []string{"--title", "X", "--headers", `"abc"`}, "must be a JSON array"}, + {"data not 2D", []string{"--title", "X", "--data", `["a","b"]`}, "must be an array"}, + } + for _, tt := range cases { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, WorkbookCreate, append(tt.args, "--dry-run")) + if err == nil || !strings.Contains(stdout+stderr+err.Error(), tt.want) { + t.Errorf("expected %q; got=%s|%s|%v", tt.want, stdout, stderr, err) + } + }) + } +} + +// TestWorkbookExport_DryRun checks the 2-or-3 step plan depending on +// --output-path. The order should be: POST → GET (poll) → optional GET +// (download). +func TestWorkbookExport_DryRun(t *testing.T) { + t.Parallel() + + t.Run("xlsx without --output-path → 2 steps", func(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, WorkbookExport, []string{"--url", testURL, "--file-extension", "xlsx"}) + if len(calls) != 2 { + t.Fatalf("api calls = %d, want 2 (create + poll)", len(calls)) + } + create := calls[0].(map[string]interface{}) + if create["url"] != "/open-apis/drive/v1/export_tasks" { + t.Errorf("first url = %v", create["url"]) + } + body, _ := create["body"].(map[string]interface{}) + if body["type"] != "sheet" || body["file_extension"] != "xlsx" || body["token"] != testToken { + t.Errorf("create body = %#v", body) + } + }) + + t.Run("csv → 3 steps, with sub_id", func(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, WorkbookExport, []string{ + "--url", testURL, "--file-extension", "csv", "--sheet-id", "sh1", + "--output-path", "/tmp/out.csv", + }) + if len(calls) != 3 { + t.Fatalf("api calls = %d, want 3", len(calls)) + } + body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{}) + if body["sub_id"] != "sh1" { + t.Errorf("csv export missing sub_id: %#v", body) + } + dl := calls[2].(map[string]interface{}) + if !strings.Contains(dl["url"].(string), "/export_tasks/file/") { + t.Errorf("download url = %v", dl["url"]) + } + }) + + t.Run("csv requires --sheet-id", func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, WorkbookExport, []string{ + "--url", testURL, "--file-extension", "csv", "--dry-run", + }) + if err == nil || !strings.Contains(stdout+stderr+err.Error(), "--sheet-id is required") { + t.Errorf("expected sheet-id guard; got=%s|%s|%v", stdout, stderr, err) + } + }) +} + +// TestDimMove_DryRun verifies the legacy v2 dimension_range payload +// shape. CLI's 0-based inclusive (--start / --end) becomes the v2 +// endpoint's half-open [startIndex, endIndex). +func TestDimMove_DryRun(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, DimMove, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--dimension", "row", "--start", "0", "--end", "2", "--target", "10", + }) + if len(calls) != 1 { + t.Fatalf("api calls = %d, want 1", len(calls)) + } + c := calls[0].(map[string]interface{}) + if !strings.Contains(c["url"].(string), "/sheets/v2/spreadsheets/") { + t.Errorf("url = %v, want sheets/v2 path", c["url"]) + } + body, _ := c["body"].(map[string]interface{}) + src, _ := body["source"].(map[string]interface{}) + if src["sheetId"] != testSheetID { + t.Errorf("source.sheetId = %v", src["sheetId"]) + } + if src["majorDimension"] != "ROWS" { + t.Errorf("source.majorDimension = %v, want ROWS", src["majorDimension"]) + } + if src["startIndex"].(float64) != 0 { + t.Errorf("startIndex = %v, want 0", src["startIndex"]) + } + if src["endIndex"].(float64) != 3 { + t.Errorf("endIndex = %v, want 3 (CLI end+1 for half-open)", src["endIndex"]) + } + if body["destinationIndex"].(float64) != 10 { + t.Errorf("destinationIndex = %v, want 10", body["destinationIndex"]) + } +} + +// TestCellsSetImage_DryRun verifies the 2-step plan (upload + embed) is +// rendered, including the parent_type=sheet_image upload metadata. +func TestCellsSetImage_DryRun(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, CellsSetImage, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1", + "--image", "./README.md", // any existing-shaped path; dry-run skips stat + }) + if len(calls) != 2 { + t.Fatalf("api calls = %d, want 2 (upload + set_cell_range)", len(calls)) + } + upload := calls[0].(map[string]interface{}) + if upload["url"] != "/open-apis/drive/v1/medias/upload_all" { + t.Errorf("upload url = %v", upload["url"]) + } + ubody, _ := upload["body"].(map[string]interface{}) + if ubody["parent_type"] != "sheet_image" { + t.Errorf("parent_type = %v, want sheet_image", ubody["parent_type"]) + } + if ubody["parent_node"] != testToken { + t.Errorf("parent_node = %v, want token", ubody["parent_node"]) + } + + embed := calls[1].(map[string]interface{}) + body, _ := embed["body"].(map[string]interface{}) + input := decodeToolInput(t, body, "set_cell_range") + cells, _ := input["cells"].([]interface{}) + row, _ := cells[0].([]interface{}) + cell, _ := row[0].(map[string]interface{}) + rt, _ := cell["rich_text"].([]interface{}) + if len(rt) != 1 { + t.Fatalf("rich_text len = %d, want 1", len(rt)) + } + item, _ := rt[0].(map[string]interface{}) + if item["type"] != "embed-image" { + t.Errorf("rich_text.type = %v, want embed-image", item["type"]) + } + if item["attachment_name"] != "README.md" { + t.Errorf("attachment_name = %v, want README.md (basename)", item["attachment_name"]) + } +} + +func TestCellsSetImage_RangeMustBeSingleCell(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, CellsSetImage, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1:B2", "--image", "./foo.png", "--dry-run", + }) + if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must be exactly one cell") { + t.Errorf("expected single-cell guard; got=%s|%s|%v", stdout, stderr, err) + } +} diff --git a/shortcuts/sheets/lark_sheet_object_crud_test.go b/shortcuts/sheets/lark_sheet_object_crud_test.go new file mode 100644 index 000000000..0cbfe721c --- /dev/null +++ b/shortcuts/sheets/lark_sheet_object_crud_test.go @@ -0,0 +1,219 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +// TestObjectCRUDShortcuts_DryRun walks the create / update / delete trio +// for each object skill. Together these cover all 21 CRUD shortcuts plus +// the per-object id flag renames (rule-id, group-id, view-id, etc.). +func TestObjectCRUDShortcuts_DryRun(t *testing.T) { + t.Parallel() + + type spec struct { + name string + sc common.Shortcut + args []string + toolName string + wantInput map[string]interface{} + } + + tests := []spec{ + // chart + { + name: "+chart-create", + sc: ChartCreate, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--data", `{"type":"line"}`}, + toolName: "manage_chart_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "create", + "properties": map[string]interface{}{"type": "line"}, + }, + }, + { + name: "+chart-update", + sc: ChartUpdate, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chartXYZ", "--data", `{"type":"bar"}`}, + toolName: "manage_chart_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "update", + "chart_id": "chartXYZ", + "properties": map[string]interface{}{"type": "bar"}, + }, + }, + // pivot — has extra create flags + { + name: "+pivot-create with target flags", + sc: PivotCreate, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--data", `{"data_range":"Sheet1!A1:F1000"}`, "--target-sheet-id", "sh2", "--target-position", "B5"}, + toolName: "manage_pivot_table_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "create", + "target_sheet_id": "sh2", + "target_position": "B5", + }, + }, + { + name: "+pivot-delete", + sc: PivotDelete, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--pivot-table-id", "ptA"}, + toolName: "manage_pivot_table_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "delete", + "pivot_table_id": "ptA", + }, + }, + // cond-format — --rule-id rename + { + name: "+cond-format-update id rename", + sc: CondFormatUpdate, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--rule-id", "ruleA", "--data", `{"rule":{"type":"cell_value"}}`}, + toolName: "manage_conditional_format_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "update", + "conditional_format_id": "ruleA", + "properties": map[string]interface{}{"rule": map[string]interface{}{"type": "cell_value"}}, + }, + }, + // filter — special, no id flag + { + name: "+filter-create without --data sends properties.range only", + sc: FilterCreate, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:F1000"}, + toolName: "manage_filter_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "create", + "properties": map[string]interface{}{"range": "A1:F1000"}, + }, + }, + { + name: "+filter-create with --data merges conditions", + sc: FilterCreate, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:F1000", "--data", `{"conditions":[{"col":"B"}]}`}, + toolName: "manage_filter_object", + wantInput: map[string]interface{}{ + "properties": map[string]interface{}{ + "range": "A1:F1000", + "conditions": []interface{}{map[string]interface{}{"col": "B"}}, + }, + }, + }, + { + name: "+filter-delete (no id flag, sheet-scoped)", + sc: FilterDelete, + args: []string{"--url", testURL, "--sheet-id", testSheetID}, + toolName: "manage_filter_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "delete", + }, + }, + // filter-view CRUD (cli-only via callTool) + { + name: "+filter-view-create", + sc: FilterViewCreate, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--data", `{"view_name":"v1","range":"A1:Z100"}`}, + toolName: "manage_filter_view_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "create", + "properties": map[string]interface{}{"view_name": "v1", "range": "A1:Z100"}, + }, + }, + { + name: "+filter-view-update with --view-id", + sc: FilterViewUpdate, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--view-id", "vABC", "--data", `{"view_name":"renamed"}`}, + toolName: "manage_filter_view_object", + wantInput: map[string]interface{}{ + "view_id": "vABC", + "operation": "update", + }, + }, + // sparkline --group-id + { + name: "+sparkline-update --group-id → group_id", + sc: SparklineUpdate, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--group-id", "grpA", "--data", `{"type":"line"}`}, + toolName: "manage_sparkline_object", + wantInput: map[string]interface{}{ + "group_id": "grpA", + "operation": "update", + "properties": map[string]interface{}{"type": "line"}, + }, + }, + // float-image + { + name: "+float-image-create", + sc: FloatImageCreate, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--data", `{"image_uri":"u","image_name":"x.png"}`}, + toolName: "manage_float_image_object", + wantInput: map[string]interface{}{ + "operation": "create", + "properties": map[string]interface{}{"image_uri": "u", "image_name": "x.png"}, + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, tt.sc, tt.args) + got := decodeToolInput(t, body, tt.toolName) + assertInputEquals(t, got, tt.wantInput) + }) + } +} + +// TestObjectDelete_AllHighRisk asserts every delete shortcut blocks +// without --yes (framework-enforced). +func TestObjectDelete_AllHighRisk(t *testing.T) { + t.Parallel() + cases := []struct { + name string + sc common.Shortcut + args []string + }{ + {"chart", ChartDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "x"}}, + {"pivot", PivotDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--pivot-table-id", "x"}}, + {"cond-format", CondFormatDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--rule-id", "x"}}, + {"filter", FilterDelete, []string{"--url", testURL, "--sheet-id", testSheetID}}, + {"filter-view", FilterViewDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--view-id", "x"}}, + {"sparkline", SparklineDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--group-id", "x"}}, + {"float-image", FloatImageDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--float-image-id", "x"}}, + } + for _, tt := range cases { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args) + if err == nil { + t.Fatalf("expected confirmation_required; stdout=%s stderr=%s", stdout, stderr) + } + combined := stdout + stderr + err.Error() + if !strings.Contains(combined, "confirmation_required") && !strings.Contains(combined, "requires confirmation") { + t.Errorf("expected confirmation gate; got=%s|%s|%v", stdout, stderr, err) + } + }) + } +} diff --git a/shortcuts/sheets/lark_sheet_object_list_test.go b/shortcuts/sheets/lark_sheet_object_list_test.go new file mode 100644 index 000000000..4df0973db --- /dev/null +++ b/shortcuts/sheets/lark_sheet_object_list_test.go @@ -0,0 +1,112 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +// TestObjectListShortcuts_DryRun covers all 7 object-list shortcuts. +// Each spec asserts the tool name + that the optional filter flag maps +// to the right tool field (including the --rule-id → conditional_format_id +// rename). +func TestObjectListShortcuts_DryRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sc common.Shortcut + args []string + toolName string + wantInput map[string]interface{} + }{ + { + name: "+chart-list no filter", + sc: ChartList, + args: []string{"--url", testURL, "--sheet-id", testSheetID}, + toolName: "get_chart_objects", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + }, + }, + { + name: "+chart-list with filter", + sc: ChartList, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chartXYZ"}, + toolName: "get_chart_objects", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "chart_id": "chartXYZ", + }, + }, + { + name: "+pivot-list filter", + sc: PivotList, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--pivot-table-id", "ptA"}, + toolName: "get_pivot_table_objects", + wantInput: map[string]interface{}{ + "pivot_table_id": "ptA", + }, + }, + { + name: "+cond-format-list --rule-id → conditional_format_id", + sc: CondFormatList, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--rule-id", "ruleA"}, + toolName: "get_conditional_format_objects", + wantInput: map[string]interface{}{ + "conditional_format_id": "ruleA", + }, + }, + { + name: "+filter-list (no filter flag) by sheet-name", + sc: FilterList, + args: []string{"--url", testURL, "--sheet-name", "Sheet1"}, + toolName: "get_filter_objects", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_name": "Sheet1", + }, + }, + { + name: "+filter-view-list cli-only via callTool", + sc: FilterViewList, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--view-id", "viewABC"}, + toolName: "get_filter_view_objects", + wantInput: map[string]interface{}{ + "view_id": "viewABC", + }, + }, + { + name: "+sparkline-list --group-id", + sc: SparklineList, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--group-id", "grpA"}, + toolName: "get_sparkline_objects", + wantInput: map[string]interface{}{ + "group_id": "grpA", + }, + }, + { + name: "+float-image-list", + sc: FloatImageList, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--float-image-id", "imgA"}, + toolName: "get_float_image_objects", + wantInput: map[string]interface{}{ + "float_image_id": "imgA", + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, tt.sc, tt.args) + got := decodeToolInput(t, body, tt.toolName) + assertInputEquals(t, got, tt.wantInput) + }) + } +} diff --git a/shortcuts/sheets/lark_sheet_range_operations_test.go b/shortcuts/sheets/lark_sheet_range_operations_test.go new file mode 100644 index 000000000..547c776c3 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_range_operations_test.go @@ -0,0 +1,221 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +func TestRangeOperationsShortcuts_DryRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sc common.Shortcut + args []string + toolName string + wantInput map[string]interface{} + }{ + { + name: "+cells-clear scope=content → clear_type=contents", + sc: CellsClear, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C5", "--scope", "content"}, + toolName: "clear_cell_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "range": "A1:C5", + "clear_type": "contents", + }, + }, + { + name: "+cells-clear scope=all passthrough", + sc: CellsClear, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C5", "--scope", "all"}, + toolName: "clear_cell_range", + wantInput: map[string]interface{}{ + "clear_type": "all", + }, + }, + { + name: "+cells-merge with merge-type", + sc: CellsMerge, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--merge-type", "rows"}, + toolName: "merge_cells", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "range": "A1:B2", + "operation": "merge", + "merge_type": "rows", + }, + }, + { + name: "+cells-unmerge (no merge-type flag)", + sc: CellsUnmerge, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2"}, + toolName: "merge_cells", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "range": "A1:B2", + "operation": "unmerge", + }, + }, + { + name: "+dim-resize row --size 200", + sc: DimResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--start", "0", "--end", "5", "--size", "200"}, + toolName: "resize_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "range": "1:5", + "resize_height": map[string]interface{}{ + "value": float64(200), + }, + }, + }, + { + name: "+dim-resize column --reset", + sc: DimResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "column", "--start", "1", "--end", "4", "--reset"}, + toolName: "resize_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "range": "B:D", + "resize_width": map[string]interface{}{ + "reset": true, + }, + }, + }, + { + name: "+range-move cross-sheet", + sc: RangeMove, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--source-range", "A1:C5", "--target-range", "D1", "--target-sheet-id", testSheetID2}, + toolName: "transform_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "move", + "range": "A1:C5", + "destination_range": "D1", + "destination_sheet_id": testSheetID2, + }, + }, + { + name: "+range-copy paste-type values → value_only", + sc: RangeCopy, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--source-range", "A1:C5", "--target-range", "E1", "--paste-type", "values"}, + toolName: "transform_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "copy", + "range": "A1:C5", + "destination_range": "E1", + "paste_type": "value_only", + }, + }, + { + name: "+range-copy paste-type all → field omitted", + sc: RangeCopy, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--source-range", "A1:C5", "--target-range", "E1"}, + toolName: "transform_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "copy", + "range": "A1:C5", + "destination_range": "E1", + }, + }, + { + name: "+range-fill series=copy → copyCells", + sc: RangeFill, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--source-range", "A1:A3", "--target-range", "A4:A10", "--series-type", "copy"}, + toolName: "transform_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "fill", + "range": "A1:A3", + "destination_range": "A4:A10", + "fill_type": "copyCells", + }, + }, + { + name: "+range-fill series=linear → fillSeries", + sc: RangeFill, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--source-range", "A1:A3", "--target-range", "A4:A10", "--series-type", "linear"}, + toolName: "transform_range", + wantInput: map[string]interface{}{ + "fill_type": "fillSeries", + }, + }, + { + name: "+range-sort multi-key with header", + sc: RangeSort, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:E100", "--has-header", "--sort-keys", `[{"col":"B","order":"asc"},{"col":"D","order":"desc"}]`}, + toolName: "transform_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "sort", + "range": "A1:E100", + "has_header": true, + "sort_conditions": []interface{}{ + map[string]interface{}{"col": "B", "order": "asc"}, + map[string]interface{}{"col": "D", "order": "desc"}, + }, + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, tt.sc, tt.args) + got := decodeToolInput(t, body, tt.toolName) + assertInputEquals(t, got, tt.wantInput) + }) + } +} + +func TestDimResize_MutualExclusion(t *testing.T) { + t.Parallel() + cases := []struct { + name string + args []string + want string + }{ + { + name: "missing both --size and --reset", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--start", "0", "--end", "3"}, + want: "specify either --size or --reset", + }, + { + name: "both --size and --reset", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--start", "0", "--end", "3", "--size", "200", "--reset"}, + want: "mutually exclusive", + }, + } + for _, tt := range cases { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, DimResize, append(tt.args, "--dry-run")) + if err == nil { + t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) + } + if !strings.Contains(stdout+stderr+err.Error(), tt.want) { + t.Errorf("expected %q; got=%s|%s|%v", tt.want, stdout, stderr, err) + } + }) + } +} diff --git a/shortcuts/sheets/lark_sheet_read_data_test.go b/shortcuts/sheets/lark_sheet_read_data_test.go new file mode 100644 index 000000000..c59441417 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_read_data_test.go @@ -0,0 +1,127 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +func TestReadDataShortcuts_DryRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sc common.Shortcut + args []string + toolName string + wantInput map[string]interface{} + }{ + { + name: "+cells-get multi-range + include=style,formula", + sc: CellsGet, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--ranges", "A1:B2", "--ranges", "D1:E5", "--include", "style,formula"}, + toolName: "get_cell_ranges", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "ranges": []interface{}{"A1:B2", "D1:E5"}, + "include_styles": true, + "value_render_option": "formula", + }, + }, + { + name: "+csv-get with value-render-option", + sc: CsvGet, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C10", "--value-render-option", "formula"}, + toolName: "get_range_as_csv", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "range": "A1:C10", + "value_render_option": "formula", + }, + }, + { + name: "+dropdown-get range with sheet prefix only", + sc: DropdownGet, + args: []string{"--url", testURL, "--range", "sheet1!A2:A100"}, + toolName: "get_cell_ranges", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "ranges": []interface{}{"sheet1!A2:A100"}, + "include_styles": false, + "value_render_option": "formatted_value", + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, tt.sc, tt.args) + got := decodeToolInput(t, body, tt.toolName) + assertInputEquals(t, got, tt.wantInput) + }) + } +} + +func TestDropdownGet_RequiresSheetPrefix(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, DropdownGet, []string{ + "--url", testURL, "--range", "A2:A100", "--dry-run", + }) + if err == nil { + t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) + } + if !strings.Contains(stdout+stderr+err.Error(), "must include a sheet prefix") { + t.Errorf("expected sheet-prefix guard; got=%s|%s|%v", stdout, stderr, err) + } +} + +// TestInfoTypeFromInclude exercises the fine-grained → coarse mapping +// directly (white-box). +func TestInfoTypeFromInclude(t *testing.T) { + t.Parallel() + // Caller (sheetInfoInput) skips infoTypeFromInclude when len(include)==0, + // so the helper only ever sees non-empty input. + cases := []struct { + include []string + want string + }{ + {[]string{"row_heights"}, "row_heights_column_widths"}, + {[]string{"row_heights", "col_widths"}, "row_heights_column_widths"}, + {[]string{"hidden_rows", "hidden_cols"}, "hidden_infos"}, + {[]string{"groups"}, "group_infos"}, + {[]string{"merges"}, "merged_cells_infos"}, + {[]string{"row_heights", "merges"}, "all"}, // mixed + {[]string{"frozen"}, "all"}, // frozen alone falls back to all + {[]string{"unknown"}, "all"}, // unknown → all + } + for _, c := range cases { + if got := infoTypeFromInclude(c.include); got != c.want { + t.Errorf("infoTypeFromInclude(%v) = %q, want %q", c.include, got, c.want) + } + } +} + +// TestCsvGet_StripRowPrefix verifies the client-side post-process for +// --include-row-prefix=false. +func TestCsvGet_StripRowPrefix(t *testing.T) { + t.Parallel() + in := map[string]interface{}{ + "annotated_csv": "[row=1] a,b,c\n[row=2] d,e,f", + "other": "untouched", + } + out := stripRowPrefixFromCsvOutput(in).(map[string]interface{}) + csv := out["annotated_csv"].(string) + if csv != " a,b,c\n d,e,f" { + t.Errorf("annotated_csv = %q, want stripped prefix", csv) + } + if out["other"] != "untouched" { + t.Errorf("other field corrupted: %v", out["other"]) + } +} diff --git a/shortcuts/sheets/lark_sheet_search_replace_test.go b/shortcuts/sheets/lark_sheet_search_replace_test.go new file mode 100644 index 000000000..f0e43e20c --- /dev/null +++ b/shortcuts/sheets/lark_sheet_search_replace_test.go @@ -0,0 +1,103 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +func TestSearchReplaceShortcuts_DryRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sc common.Shortcut + args []string + toolName string + wantInput map[string]interface{} + wantOptions map[string]interface{} + }{ + { + name: "+cells-search regex + match-case", + sc: CellsSearch, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--find", "foo", "--regex", "--match-case"}, + toolName: "search_data", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "search_term": "foo", + }, + wantOptions: map[string]interface{}{ + "match_case": true, + "regex": true, + }, + }, + { + name: "+cells-search all four options", + sc: CellsSearch, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--find", "x", "--match-case", "--match-entire-cell", "--regex", "--include-formulas"}, + toolName: "search_data", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "search_term": "x", + }, + wantOptions: map[string]interface{}{ + "match_case": true, + "match_entire_cell": true, + "regex": true, + "include_formulas": true, + }, + }, + { + name: "+cells-replace empty replace deletes match", + sc: CellsReplace, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--find", "foo", "--replace", ""}, + toolName: "replace_data", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "search_term": "foo", + "replace_term": "", + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, tt.sc, tt.args) + got := decodeToolInput(t, body, tt.toolName) + assertInputEquals(t, got, tt.wantInput) + if tt.wantOptions != nil { + opts, _ := got["options"].(map[string]interface{}) + if opts == nil { + t.Fatalf("options missing: %#v", got) + } + for k, want := range tt.wantOptions { + if opts[k] != want { + t.Errorf("options[%q] = %v, want %v", k, opts[k], want) + } + } + } + }) + } +} + +func TestCellsReplace_RequireFlag(t *testing.T) { + t.Parallel() + // --replace not passed at all (vs empty string) should error. + stdout, stderr, err := runShortcutCapturingErr(t, CellsReplace, []string{ + "--url", testURL, "--sheet-id", testSheetID, "--find", "foo", "--dry-run", + }) + if err == nil { + t.Fatalf("expected error when --replace omitted; stdout=%s stderr=%s", stdout, stderr) + } + if !strings.Contains(stdout+stderr+err.Error(), "replace") { + t.Errorf("expected message about --replace; got=%s|%s|%v", stdout, stderr, err) + } +} diff --git a/shortcuts/sheets/lark_sheet_sheet_structure_test.go b/shortcuts/sheets/lark_sheet_sheet_structure_test.go new file mode 100644 index 000000000..dc1743cd0 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_sheet_structure_test.go @@ -0,0 +1,188 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +// TestSheetStructureShortcuts_DryRun covers all 8 shortcuts in +// lark_sheet_sheet_structure (sheet-info + 7 dim-*) and verifies the +// CLI 0-based exclusive-end → tool 1-based inclusive A1 conversion. +func TestSheetStructureShortcuts_DryRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sc common.Shortcut + args []string + toolName string + wantInput map[string]interface{} + }{ + { + name: "+sheet-info with include single category → narrow info_type", + sc: SheetInfo, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--include", "row_heights,col_widths"}, + toolName: "get_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "info_type": "row_heights_column_widths", + }, + }, + { + name: "+sheet-info with mixed include → all", + sc: SheetInfo, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--include", "row_heights,merges"}, + toolName: "get_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "info_type": "all", + }, + }, + { + name: "+dim-insert row 5..8 inherit-before → position 6 + count 3 + side", + sc: DimInsert, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--start", "5", "--end", "8", "--inherit-style", "before"}, + toolName: "modify_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "insert", + "sheet_id": testSheetID, + "position": "6", + "count": float64(3), + "side": "before", + }, + }, + { + name: "+dim-delete column B..D", + sc: DimDelete, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "column", "--start", "1", "--end", "4"}, + toolName: "modify_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "delete", + "sheet_id": testSheetID, + "range": "B:D", + }, + }, + { + name: "+dim-hide row 2..5 → range 3:5", + sc: DimHide, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--start", "2", "--end", "5"}, + toolName: "modify_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "hide", + "sheet_id": testSheetID, + "range": "3:5", + }, + }, + { + name: "+dim-unhide column 26..29 → AA:AC", + sc: DimUnhide, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "column", "--start", "26", "--end", "29"}, + toolName: "modify_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "unhide", + "sheet_id": testSheetID, + "range": "AA:AC", + }, + }, + { + name: "+dim-freeze row count=2 → freeze_rows", + sc: DimFreeze, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--count", "2"}, + toolName: "modify_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "freeze", + "sheet_id": testSheetID, + "freeze_rows": float64(2), + }, + }, + { + name: "+dim-freeze count=0 → unfreeze", + sc: DimFreeze, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "column", "--count", "0"}, + toolName: "modify_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "unfreeze", + "sheet_id": testSheetID, + }, + }, + { + name: "+dim-group with state", + sc: DimGroup, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--start", "0", "--end", "5", "--group-state", "fold"}, + toolName: "modify_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "group", + "sheet_id": testSheetID, + "range": "1:5", + "group_state": "fold", + }, + }, + { + name: "+dim-ungroup", + sc: DimUngroup, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--start", "0", "--end", "5"}, + toolName: "modify_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "ungroup", + "sheet_id": testSheetID, + "range": "1:5", + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, tt.sc, tt.args) + got := decodeToolInput(t, body, tt.toolName) + assertInputEquals(t, got, tt.wantInput) + }) + } +} + +func TestDimRange_StartEndValidation(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, DimHide, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--dimension", "row", "--start", "5", "--end", "3", "--dry-run", + }) + if err == nil { + t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) + } + if !strings.Contains(stdout+stderr+err.Error(), "must be greater than --start") { + t.Errorf("expected end>start guard; got=%s|%s|%v", stdout, stderr, err) + } +} + +// TestColumnIndexToLetter exercises the corner cases of the letter helper: +// single, double, and triple-letter spans. +func TestColumnIndexToLetter(t *testing.T) { + t.Parallel() + cases := []struct { + idx int + want string + }{ + {0, "A"}, {25, "Z"}, {26, "AA"}, {27, "AB"}, {51, "AZ"}, + {52, "BA"}, {701, "ZZ"}, {702, "AAA"}, + } + for _, c := range cases { + if got := columnIndexToLetter(c.idx); got != c.want { + t.Errorf("columnIndexToLetter(%d) = %q, want %q", c.idx, got, c.want) + } + } +} diff --git a/shortcuts/sheets/lark_sheet_workbook_test.go b/shortcuts/sheets/lark_sheet_workbook_test.go new file mode 100644 index 000000000..ea7d6705d --- /dev/null +++ b/shortcuts/sheets/lark_sheet_workbook_test.go @@ -0,0 +1,323 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +// TestWorkbookShortcuts_DryRun covers all 9 lark_sheet_workbook shortcuts +// (WorkbookInfo + 8 sheet-* variants) by asserting the One-OpenAPI body +// the dry-run renders. Together they exercise every dispatch arm of +// modify_workbook_structure plus the read tool. +func TestWorkbookShortcuts_DryRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sc common.Shortcut + args []string + toolName string + wantInput map[string]interface{} + }{ + { + name: "+workbook-info read", + sc: WorkbookInfo, + args: []string{"--url", testURL}, + toolName: "get_workbook_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + }, + }, + { + name: "+sheet-create with all options", + sc: SheetCreate, + args: []string{"--url", testURL, "--title", "Q1", "--index", "1", "--row-count", "300", "--col-count", "10"}, + toolName: "modify_workbook_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "create", + "sheet_name": "Q1", + "target_index": float64(1), + "rows": float64(300), + "columns": float64(10), + }, + }, + { + name: "+sheet-delete by id", + sc: SheetDelete, + args: []string{"--url", testURL, "--sheet-id", testSheetID}, + toolName: "modify_workbook_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "delete", + "sheet_id": testSheetID, + }, + }, + { + name: "+sheet-rename by name", + sc: SheetRename, + args: []string{"--url", testURL, "--sheet-name", "汇总", "--title", "Q1 汇总"}, + toolName: "modify_workbook_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "rename", + "sheet_name": "汇总", + "new_name": "Q1 汇总", + }, + }, + { + name: "+sheet-copy without explicit title", + sc: SheetCopy, + args: []string{"--url", testURL, "--sheet-id", testSheetID}, + toolName: "modify_workbook_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "duplicate", + "sheet_id": testSheetID, + }, + }, + { + name: "+sheet-copy with new title and index", + sc: SheetCopy, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--title", "副本", "--index", "0"}, + toolName: "modify_workbook_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "duplicate", + "sheet_id": testSheetID, + "new_name": "副本", + "target_index": float64(0), + }, + }, + { + name: "+sheet-hide", + sc: SheetHide, + args: []string{"--url", testURL, "--sheet-id", testSheetID}, + toolName: "modify_workbook_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "hide", + "sheet_id": testSheetID, + }, + }, + { + name: "+sheet-unhide", + sc: SheetUnhide, + args: []string{"--url", testURL, "--sheet-id", testSheetID}, + toolName: "modify_workbook_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "unhide", + "sheet_id": testSheetID, + }, + }, + { + name: "+sheet-set-tab-color hex", + sc: SheetSetTabColor, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--color", "#FF0000"}, + toolName: "modify_workbook_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "set_tab_color", + "sheet_id": testSheetID, + "tab_color": "#FF0000", + }, + }, + { + name: "+sheet-set-tab-color empty clears", + sc: SheetSetTabColor, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--color", ""}, + toolName: "modify_workbook_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "set_tab_color", + "sheet_id": testSheetID, + "tab_color": "", + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, tt.sc, tt.args) + got := decodeToolInput(t, body, tt.toolName) + assertInputEquals(t, got, tt.wantInput) + }) + } +} + +// TestSheetMove_DryRunResolvePlaceholders verifies the move shortcut emits +// placeholders for fields it would otherwise have to look up +// at execute time. DryRun must stay network-free. +func TestSheetMove_DryRunResolvePlaceholders(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + wantSheetID string + wantSourceIdx interface{} + }{ + { + name: "id only, no source-index → both literal + placeholder", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--index", "0"}, + wantSheetID: testSheetID, + wantSourceIdx: "", + }, + { + name: "name only → sheet_id placeholder + source_index placeholder", + args: []string{"--url", testURL, "--sheet-name", "汇总", "--index", "0"}, + wantSheetID: "", + wantSourceIdx: "", + }, + { + name: "id + source-index → both literal", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--index", "0", "--source-index", "5"}, + wantSheetID: testSheetID, + wantSourceIdx: float64(5), + }, + } + for _, tt := range cases { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, SheetMove, tt.args) + input := decodeToolInput(t, body, "modify_workbook_structure") + if got := input["sheet_id"]; got != tt.wantSheetID { + t.Errorf("sheet_id = %#v, want %#v", got, tt.wantSheetID) + } + if got := input["source_index"]; got != tt.wantSourceIdx { + t.Errorf("source_index = %#v, want %#v", got, tt.wantSourceIdx) + } + if got := input["target_index"]; got != float64(0) { + t.Errorf("target_index = %#v, want 0", got) + } + }) + } +} + +// TestSheetDelete_HighRiskWriteRequiresYes verifies the framework gate on +// high-risk-write — exit code 10 (confirmation_required) without --yes. +func TestSheetDelete_HighRiskWriteRequiresYes(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, SheetDelete, []string{"--url", testURL, "--sheet-id", testSheetID}) + if err == nil { + t.Fatalf("expected confirmation_required error; got nil. stdout=%s stderr=%s", stdout, stderr) + } + combined := stdout + stderr + err.Error() + if !strings.Contains(combined, "confirmation_required") && !strings.Contains(combined, "requires confirmation") { + t.Errorf("expected confirmation envelope; got=%s|%s|%v", stdout, stderr, err) + } +} + +// TestWorkbook_Validation covers a few critical validation paths shared +// across the package's helpers (XOR token, XOR sheet selector, required +// flags). +func TestWorkbook_Validation(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + sc common.Shortcut + args []string + wantMsg string + }{ + { + name: "+workbook-info needs --url or --spreadsheet-token", + sc: WorkbookInfo, + args: []string{}, + wantMsg: "at least one of --url or --spreadsheet-token", + }, + { + name: "+workbook-info rejects both url and token", + sc: WorkbookInfo, + args: []string{"--url", testURL, "--spreadsheet-token", testToken}, + wantMsg: "mutually exclusive", + }, + { + name: "+sheet-delete needs sheet selector", + sc: SheetDelete, + args: []string{"--url", testURL}, + wantMsg: "at least one of --sheet-id or --sheet-name", + }, + { + name: "+sheet-create requires --title", + sc: SheetCreate, + args: []string{"--url", testURL}, + wantMsg: "required flag(s) \"title\" not set", + }, + { + name: "+sheet-create row-count over cap", + sc: SheetCreate, + args: []string{"--url", testURL, "--title", "X", "--row-count", "999999"}, + wantMsg: "--row-count must be between", + }, + } + for _, tt := range cases { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run")) + if err == nil { + t.Fatalf("expected validation error; got nil. stdout=%s stderr=%s", stdout, stderr) + } + combined := stdout + stderr + err.Error() + if !strings.Contains(combined, tt.wantMsg) { + t.Errorf("error message missing %q; got=%s", tt.wantMsg, combined) + } + }) + } +} + +// assertInputEquals compares the decoded tool input map against the wanted +// fields. Extra fields in `got` are allowed (defaults, optional fields); +// every key in `want` must match exactly. +func assertInputEquals(t *testing.T, got, want map[string]interface{}) { + t.Helper() + for k, wv := range want { + gv, ok := got[k] + if !ok { + t.Errorf("missing input key %q (got=%#v)", k, got) + continue + } + if !deepEqualJSON(gv, wv) { + t.Errorf("input[%q] = %#v, want %#v", k, gv, wv) + } + } +} + +// deepEqualJSON compares JSON-shaped values (post-Unmarshal) — handles +// the fact that numbers come back as float64 and maps as map[string]interface{}. +func deepEqualJSON(a, b interface{}) bool { + switch av := a.(type) { + case map[string]interface{}: + bv, ok := b.(map[string]interface{}) + if !ok || len(av) != len(bv) { + return false + } + for k, v := range av { + if !deepEqualJSON(v, bv[k]) { + return false + } + } + return true + case []interface{}: + bv, ok := b.([]interface{}) + if !ok || len(av) != len(bv) { + return false + } + for i := range av { + if !deepEqualJSON(av[i], bv[i]) { + return false + } + } + return true + } + return a == b +} diff --git a/shortcuts/sheets/lark_sheet_write_cells_test.go b/shortcuts/sheets/lark_sheet_write_cells_test.go new file mode 100644 index 000000000..621601e26 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_write_cells_test.go @@ -0,0 +1,205 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +func TestWriteCellsShortcuts_DryRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sc common.Shortcut + args []string + toolName string + wantInput map[string]interface{} + }{ + { + name: "+cells-set with --data cells passthrough", + sc: CellsSet, + args: []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1:B2", + "--data", `{"cells":[[{"value":1},{"value":2}],[{"value":3},{"value":4}]]}`, + }, + toolName: "set_cell_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "range": "A1:B2", + "cells": []interface{}{[]interface{}{map[string]interface{}{"value": float64(1)}, map[string]interface{}{"value": float64(2)}}, []interface{}{map[string]interface{}{"value": float64(3)}, map[string]interface{}{"value": float64(4)}}}, + }, + }, + { + name: "+cells-set --allow-overwrite=false sends false explicitly", + sc: CellsSet, + args: []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1", + "--data", `{"cells":[[{"value":1}]]}`, + "--allow-overwrite=false", + }, + toolName: "set_cell_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "range": "A1", + "allow_overwrite": false, + }, + }, + { + name: "+csv-put inline csv", + sc: CsvPut, + args: []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--csv", "a,b,c\n1,2,3", + "--start-cell", "B3", + }, + toolName: "set_range_from_csv", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "csv": "a,b,c\n1,2,3", + "start_cell": "B3", + }, + }, + { + name: "+dropdown-set fans out cells matrix", + sc: DropdownSet, + args: []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A2:A4", + "--options", `["a","b"]`, + "--multiple", "--highlight", + }, + toolName: "set_cell_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "range": "A2:A4", + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, tt.sc, tt.args) + got := decodeToolInput(t, body, tt.toolName) + assertInputEquals(t, got, tt.wantInput) + }) + } +} + +// TestDropdownSet_CellsShape inspects the 3×1 matrix produced from +// --range A2:A4 to confirm the data_validation prototype is replicated. +func TestDropdownSet_CellsShape(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, DropdownSet, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A2:A4", "--options", `["a","b"]`, "--multiple", + }) + input := decodeToolInput(t, body, "set_cell_range") + cells, _ := input["cells"].([]interface{}) + if len(cells) != 3 { + t.Fatalf("cells rows = %d, want 3 (A2:A4)", len(cells)) + } + for i, row := range cells { + r, _ := row.([]interface{}) + if len(r) != 1 { + t.Errorf("row %d cols = %d, want 1", i, len(r)) + } + cell, _ := r[0].(map[string]interface{}) + dv, _ := cell["data_validation"].(map[string]interface{}) + if dv == nil { + t.Errorf("row %d cell missing data_validation: %#v", i, cell) + continue + } + if dv["type"] != "list" { + t.Errorf("row %d data_validation.type = %v, want list", i, dv["type"]) + } + if dv["multiple_values"] != true { + t.Errorf("row %d data_validation.multiple_values = %v, want true", i, dv["multiple_values"]) + } + } +} + +// TestCellsSetStyle_FanOutsBorderStylesOut confirms the border_styles +// field is split out of --style and placed as a sibling of cell_styles +// per the tool contract. +func TestCellsSetStyle_FanOutsBorderStylesOut(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, CellsSetStyle, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1:B1", + "--style", `{"font":{"bold":true},"border_styles":{"top":{"style":"thick"}}}`, + }) + input := decodeToolInput(t, body, "set_cell_range") + cells, _ := input["cells"].([]interface{}) + row, _ := cells[0].([]interface{}) + cell, _ := row[0].(map[string]interface{}) + if cell["border_styles"] == nil { + t.Fatalf("border_styles missing on cell: %#v", cell) + } + style, _ := cell["cell_styles"].(map[string]interface{}) + if _, leaked := style["border_styles"]; leaked { + t.Errorf("border_styles leaked into cell_styles: %#v", style) + } +} + +func TestCellsSet_RequiresCellsField(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, CellsSet, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1", "--data", `{"foo":"bar"}`, "--dry-run", + }) + if err == nil { + t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) + } + if !strings.Contains(stdout+stderr+err.Error(), "must include a \"cells\" field") { + t.Errorf("expected cells-field guard; got=%s|%s|%v", stdout, stderr, err) + } +} + +// TestRangeDimensions exercises the A1 parser's corner cases used by +// cells-set-style / dropdown-set / dim-resize. +func TestRangeDimensions(t *testing.T) { + t.Parallel() + cases := []struct { + in string + wantRows int + wantCols int + wantErr bool + }{ + {"A1", 1, 1, false}, + {"A1:B2", 2, 2, false}, + {"sheet1!C3:E10", 8, 3, false}, + {"A:C", 0, 0, true}, // whole column not supported + {"3:6", 0, 0, true}, // whole row not supported + {"B2:A1", 0, 0, true}, // end before start + {"", 0, 0, true}, + } + var unusedSheet common.Shortcut = CellsSet // touch the common import + _ = unusedSheet + for _, c := range cases { + rows, cols, err := rangeDimensions(c.in) + if c.wantErr { + if err == nil { + t.Errorf("rangeDimensions(%q): want error, got rows=%d cols=%d", c.in, rows, cols) + } + continue + } + if err != nil { + t.Errorf("rangeDimensions(%q) unexpected error: %v", c.in, err) + } + if rows != c.wantRows || cols != c.wantCols { + t.Errorf("rangeDimensions(%q) = (%d,%d), want (%d,%d)", c.in, rows, cols, c.wantRows, c.wantCols) + } + } +} diff --git a/shortcuts/sheets/testhelpers_test.go b/shortcuts/sheets/testhelpers_test.go new file mode 100644 index 000000000..15eb06166 --- /dev/null +++ b/shortcuts/sheets/testhelpers_test.go @@ -0,0 +1,203 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +// testConfig returns a CliConfig wired with a stable user identity. Tests +// keep the AppID test-prefixed so logs / metrics can spot them. +func testConfig(t *testing.T) *core.CliConfig { + t.Helper() + replacer := strings.NewReplacer("/", "-", " ", "-") + suffix := replacer.Replace(strings.ToLower(t.Name())) + return &core.CliConfig{ + AppID: "test-sheets-" + suffix, + AppSecret: "secret-sheets-" + suffix, + Brand: core.BrandFeishu, + UserOpenId: "ou_test_user", + } +} + +// newTestRig spins up a Factory wired with httpmock + the given shortcut +// mounted into a "sheets" parent command. Returns the cobra.Command ready +// to SetArgs / Execute, plus the stdout / stderr buffers and the registry. +func newTestRig(t *testing.T, sc common.Shortcut) (*cobra.Command, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) { + t.Helper() + f, stdout, stderr, reg := cmdutil.TestFactory(t, testConfig(t)) + parent := &cobra.Command{Use: "sheets"} + sc.Mount(parent, f) + parent.SilenceErrors = true + parent.SilenceUsage = true + return parent, stdout, stderr, reg +} + +// runShortcut executes the shortcut with the given args and returns the +// captured stdout text. Mirrors the legacy package's parent.Execute() +// flow so test cases stay close to real CLI behavior. +func runShortcut(t *testing.T, sc common.Shortcut, args []string) (string, error) { + t.Helper() + parent, stdout, _, _ := newTestRig(t, sc) + parent.SetArgs(append([]string{sc.Command}, args...)) + err := parent.Execute() + return stdout.String(), err +} + +// runShortcutCapturingErr is runShortcut but also returns the stderr text +// so validation tests can inspect error envelopes. +func runShortcutCapturingErr(t *testing.T, sc common.Shortcut, args []string) (stdoutStr, stderrStr string, err error) { + t.Helper() + parent, stdout, stderr, _ := newTestRig(t, sc) + parent.SetArgs(append([]string{sc.Command}, args...)) + err = parent.Execute() + return stdout.String(), stderr.String(), err +} + +// runShortcutWithStubs is runShortcut + a slice of httpmock stubs. +// Stubs are registered before execute so the recorded API calls are +// served from the registry instead of touching the network. +func runShortcutWithStubs(t *testing.T, sc common.Shortcut, args []string, stubs ...*httpmock.Stub) (string, error) { + t.Helper() + parent, stdout, _, reg := newTestRig(t, sc) + for _, s := range stubs { + reg.Register(s) + } + parent.SetArgs(append([]string{sc.Command}, args...)) + err := parent.Execute() + return stdout.String(), err +} + +// parseDryRunBody runs the shortcut in --dry-run and returns the first +// api call's body. The dry-run output format is: +// +// === Dry Run === +// { "api": [{...}], ... } +// +// Tests use this to assert the One-OpenAPI wire body is constructed +// correctly without exercising the real endpoint. +func parseDryRunBody(t *testing.T, sc common.Shortcut, args []string) map[string]interface{} { + t.Helper() + out, err := runShortcut(t, sc, append(args, "--dry-run")) + if err != nil { + t.Fatalf("dry-run failed: %v\noutput=%s", err, out) + } + return decodeDryRunFirstCall(t, out) +} + +// parseDryRunAPI returns the full list of `api` entries from a dry-run +// output — used by shortcuts that emit multiple calls (e.g. +// +workbook-export, +cells-set-image, +cells-batch-set-style). +func parseDryRunAPI(t *testing.T, sc common.Shortcut, args []string) []interface{} { + 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, _ := dryRun["api"].([]interface{}) + return calls +} + +func decodeDryRunRaw(t *testing.T, out string) map[string]interface{} { + t.Helper() + idx := strings.Index(out, "{") + if idx < 0 { + t.Fatalf("dry-run output has no JSON body:\n%s", out) + } + var m map[string]interface{} + if err := json.Unmarshal([]byte(out[idx:]), &m); err != nil { + t.Fatalf("failed to parse dry-run JSON: %v\nraw=%s", err, out) + } + return m +} + +func decodeDryRunFirstCall(t *testing.T, out string) map[string]interface{} { + t.Helper() + 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{}) + body, _ := call["body"].(map[string]interface{}) + if body == nil { + t.Fatalf("dry-run first call has no body: %#v", call) + } + return body +} + +// decodeToolInput parses the JSON-string `input` field embedded in a +// dry-run body whose tool_name matches `expected`. Returns the decoded +// tool input map so tests can assert on specific input fields. +func decodeToolInput(t *testing.T, body map[string]interface{}, expectedToolName string) map[string]interface{} { + t.Helper() + if got, _ := body["tool_name"].(string); got != expectedToolName { + t.Fatalf("tool_name = %q, want %q", got, expectedToolName) + } + rawInput, _ := body["input"].(string) + if rawInput == "" { + t.Fatalf("body.input is empty: %#v", body) + } + var input map[string]interface{} + if err := json.Unmarshal([]byte(rawInput), &input); err != nil { + t.Fatalf("failed to parse tool input JSON: %v\nraw=%s", err, rawInput) + } + return input +} + +// decodeEnvelopeData parses a successful envelope's data field — used by +// execute-path tests that go through the full callTool stack with stubs. +func decodeEnvelopeData(t *testing.T, out string) map[string]interface{} { + t.Helper() + var envelope map[string]interface{} + if err := json.Unmarshal([]byte(out), &envelope); err != nil { + t.Fatalf("failed to decode envelope: %v\nraw=%s", err, out) + } + if ok, _ := envelope["ok"].(bool); !ok { + t.Fatalf("envelope.ok=false: %#v", envelope) + } + data, _ := envelope["data"].(map[string]interface{}) + return data +} + +// toolOutputStub builds an httpmock stub for the One-OpenAPI invoke_read +// or invoke_write endpoint. `outputJSON` is the JSON string the tool +// returns in data.output. +func toolOutputStub(token, kind string, outputJSON string) *httpmock.Stub { + suffix := "invoke_read" + if kind == "write" { + suffix = "invoke_write" + } + return &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheet_ai/v2/spreadsheets/" + token + "/tools/" + suffix, + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "output": outputJSON, + }, + }, + } +} + +// commonArgsURL is the typical --url and --sheet-id pair used by sheet- +// level tests. +const ( + testToken = "shtcnTestTOK" + testURL = "https://example.feishu.cn/sheets/shtcnTestTOK" + testSheetID = "shtSubA" + testSheetID2 = "shtSubB" +) From b9a175209524b8756ce6569374233e40a358e33c Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Sat, 16 May 2026 23:48:56 +0800 Subject: [PATCH 012/114] refactor(sheets): inline cli-only shortcuts into their canonical skill files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two naming cleanups: - lark_sheet_cli_only.go is gone. The four shortcuts it grouped (+workbook-create / +workbook-export / +dim-move / +cells-set-image) were bundled by their implementation pattern (legacy OAPI direct calls) rather than by canonical skill. The whole sheets package IS the CLI implementation, so "cli only" wasn't a meaningful grouping at the Go layer. Each shortcut now lives next to its skill peers: +workbook-create / +workbook-export → lark_sheet_workbook.go +dim-move → lark_sheet_sheet_structure.go +cells-set-image → lark_sheet_write_cells.go Per-skill shortcut counts now match tool-shortcut-map.json exactly (workbook: 11, sheet_structure: 9, write_cells: 5). Helpers (buildInitialFillInput, pollExportTask, downloadExportFile, dimMoveBody) move with their shortcuts; nothing else in the package referenced them. - testhelpers_test.go → helpers_test.go. The _test.go suffix already conveys "test"; the leading "test" was redundant. Matches the helpers.go naming convention. Behavior unchanged. go test -race -cover stays at 60.5 %. --- .../{testhelpers_test.go => helpers_test.go} | 0 shortcuts/sheets/lark_sheet_cli_only.go | 634 ------------------ shortcuts/sheets/lark_sheet_cli_only_test.go | 216 ------ .../sheets/lark_sheet_sheet_structure.go | 97 +++ .../sheets/lark_sheet_sheet_structure_test.go | 35 + shortcuts/sheets/lark_sheet_workbook.go | 381 +++++++++++ shortcuts/sheets/lark_sheet_workbook_test.go | 120 ++++ shortcuts/sheets/lark_sheet_write_cells.go | 141 ++++ .../sheets/lark_sheet_write_cells_test.go | 54 ++ shortcuts/sheets/shortcuts.go | 10 +- 10 files changed, 832 insertions(+), 856 deletions(-) rename shortcuts/sheets/{testhelpers_test.go => helpers_test.go} (100%) delete mode 100644 shortcuts/sheets/lark_sheet_cli_only.go delete mode 100644 shortcuts/sheets/lark_sheet_cli_only_test.go diff --git a/shortcuts/sheets/testhelpers_test.go b/shortcuts/sheets/helpers_test.go similarity index 100% rename from shortcuts/sheets/testhelpers_test.go rename to shortcuts/sheets/helpers_test.go diff --git a/shortcuts/sheets/lark_sheet_cli_only.go b/shortcuts/sheets/lark_sheet_cli_only.go deleted file mode 100644 index acb013a6f..000000000 --- a/shortcuts/sheets/lark_sheet_cli_only.go +++ /dev/null @@ -1,634 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "context" - "fmt" - "net/http" - "path/filepath" - "strings" - "time" - - larkcore "github.com/larksuite/oapi-sdk-go/v3/core" - - "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/client" - "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/internal/util" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/shortcuts/common" -) - -// ─── cli-only shortcuts (legacy OAPI direct calls) ──────────────────── -// -// Four shortcuts that don't fit the One-OpenAPI dispatcher because their -// backing capability isn't in the MCP tool catalog: -// -// - +workbook-create POST /open-apis/sheets/v3/spreadsheets -// + optional set_cell_range follow-up (headers / data) -// -// - +workbook-export POST /open-apis/drive/v1/export_tasks -// → poll /export_tasks/:ticket -// → optional GET /export_tasks/file/:file_token/download -// -// - +dim-move POST /open-apis/sheets/v2/spreadsheets/:token -// /dimension_range -// -// - +cells-set-image POST /open-apis/drive/v1/medias/upload_all -// (parent_type=sheet_image) → callTool set_cell_range -// with rich_text embed-image -// -// These do NOT go through the One-OpenAPI; CLI talks directly to the -// classic Feishu open APIs via runtime.CallAPI / DoAPI. - -// ─── +workbook-create ───────────────────────────────────────────────── - -// WorkbookCreate creates a brand-new spreadsheet in the user's drive -// (optionally inside --folder-token) and can pre-fill the first row of -// headers and an initial data block. -var WorkbookCreate = common.Shortcut{ - Service: "sheets", - Command: "+workbook-create", - Description: "Create a new spreadsheet (optionally pre-filled with --headers and --data).", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:create", "sheets:spreadsheet:write_only"}, - AuthTypes: []string{"user", "bot"}, - HasFormat: true, - Flags: []common.Flag{ - {Name: "title", Required: true, Desc: "spreadsheet title"}, - {Name: "folder-token", Desc: "destination folder token; omit to land at the drive root"}, - {Name: "headers", Input: []string{common.File, common.Stdin}, Desc: "header row JSON array, e.g. [\"列A\",\"列B\"]"}, - {Name: "data", Input: []string{common.File, common.Stdin}, Desc: "initial data JSON 2D array, e.g. [[\"alice\",95]]"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if strings.TrimSpace(runtime.Str("title")) == "" { - return common.FlagErrorf("--title is required") - } - if runtime.Str("headers") != "" { - v, err := parseJSONFlag(runtime, "headers") - if err != nil { - return err - } - if _, ok := v.([]interface{}); !ok { - return common.FlagErrorf("--headers must be a JSON array") - } - } - if runtime.Str("data") != "" { - v, err := parseJSONFlag(runtime, "data") - if err != nil { - return err - } - rows, ok := v.([]interface{}) - if !ok { - return common.FlagErrorf("--data must be a JSON 2D array") - } - for i, r := range rows { - if _, ok := r.([]interface{}); !ok { - return common.FlagErrorf("--data[%d] must be an array", i) - } - } - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - body := map[string]interface{}{"title": strings.TrimSpace(runtime.Str("title"))} - if v := strings.TrimSpace(runtime.Str("folder-token")); v != "" { - body["folder_token"] = v - } - dry := common.NewDryRunAPI(). - POST("/open-apis/sheets/v3/spreadsheets"). - Desc("create spreadsheet"). - Body(body) - if runtime.Str("headers") != "" || runtime.Str("data") != "" { - fill, _ := buildInitialFillInput(runtime) - wireBody, _ := buildToolBody("set_cell_range", fill) - dry.POST("/open-apis/sheet_ai/v2/spreadsheets//tools/invoke_write"). - Desc("fill headers + data via set_cell_range"). - Body(wireBody) - } - return dry - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - body := map[string]interface{}{"title": strings.TrimSpace(runtime.Str("title"))} - if v := strings.TrimSpace(runtime.Str("folder-token")); v != "" { - body["folder_token"] = v - } - data, err := runtime.CallAPI("POST", "/open-apis/sheets/v3/spreadsheets", nil, body) - if err != nil { - return err - } - ss := common.GetMap(data, "spreadsheet") - token := common.GetString(ss, "spreadsheet_token") - if token == "" { - token = common.GetString(ss, "token") - } - if token == "" { - return output.Errorf(output.ExitAPI, "api_error", "spreadsheet created but token missing in response") - } - - result := map[string]interface{}{"spreadsheet": ss} - - if runtime.Str("headers") != "" || runtime.Str("data") != "" { - fill, err := buildInitialFillInput(runtime) - if err != nil { - return err - } - fill["excel_id"] = token - fillOut, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", fill) - if err != nil { - // Spreadsheet exists; surface the fill failure but keep the new - // token in the envelope so the caller can recover or retry. - return fmt.Errorf("spreadsheet %s created but initial fill failed: %w", token, err) - } - result["initial_fill"] = fillOut - } - runtime.Out(result, nil) - return nil - }, - Tips: []string{ - "--headers and --data are optional follow-up writes. They use the same set_cell_range tool as +cells-set; partial failure leaves the spreadsheet created but empty.", - }, -} - -// buildInitialFillInput zips --headers + --data into a single set_cell_range -// payload writing to the first sheet starting at A1. -func buildInitialFillInput(runtime *common.RuntimeContext) (map[string]interface{}, error) { - var rows [][]interface{} - if runtime.Str("headers") != "" { - v, _ := parseJSONFlag(runtime, "headers") - headerArr, _ := v.([]interface{}) - row := make([]interface{}, 0, len(headerArr)) - for _, h := range headerArr { - row = append(row, map[string]interface{}{"value": h}) - } - rows = append(rows, row) - } - if runtime.Str("data") != "" { - v, _ := parseJSONFlag(runtime, "data") - dataArr, _ := v.([]interface{}) - for _, r := range dataArr { - cells, _ := r.([]interface{}) - row := make([]interface{}, 0, len(cells)) - for _, c := range cells { - row = append(row, map[string]interface{}{"value": c}) - } - rows = append(rows, row) - } - } - if len(rows) == 0 { - return nil, nil - } - maxCols := 0 - for _, r := range rows { - if len(r) > maxCols { - maxCols = len(r) - } - } - // Normalize rows to the same length so cells matrix is rectangular. - for i := range rows { - for len(rows[i]) < maxCols { - rows[i] = append(rows[i], map[string]interface{}{}) - } - } - endCol := columnIndexToLetter(maxCols - 1) - rangeStr := fmt.Sprintf("A1:%s%d", endCol, len(rows)) - return map[string]interface{}{ - "range": rangeStr, - "cells": rows, - "sheet_id": "", // filled in by caller if sheet_id known; otherwise server picks first sheet - }, nil -} - -// ─── +workbook-export ───────────────────────────────────────────────── - -// WorkbookExport drives the three-step export flow: create task → poll → -// optionally download. CSV mode requires --sheet-id (the API exports one -// sheet at a time as csv). -var WorkbookExport = common.Shortcut{ - Service: "sheets", - Command: "+workbook-export", - Description: "Export a spreadsheet to xlsx or a single sheet to csv (async + poll + optional download).", - Risk: "read", - Scopes: []string{"sheets:spreadsheet:read", "docs:document:export", "drive:drive.metadata:readonly"}, - AuthTypes: []string{"user", "bot"}, - HasFormat: true, - Flags: append(publicTokenFlags(), - common.Flag{Name: "file-extension", Enum: []string{"xlsx", "csv"}, Default: "xlsx", Desc: "xlsx (whole workbook) or csv (one sheet via --sheet-id)"}, - common.Flag{Name: "sheet-id", Desc: "csv mode only: target sheet reference_id to export"}, - common.Flag{Name: "output-path", Desc: "local file path to save into; omit to just trigger and report the file_token"}, - ), - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - ext := runtime.Str("file-extension") - if ext == "" { - ext = "xlsx" - } - if ext == "csv" && strings.TrimSpace(runtime.Str("sheet-id")) == "" { - return common.FlagErrorf("--sheet-id is required when --file-extension=csv") - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := resolveSpreadsheetToken(runtime) - ext := runtime.Str("file-extension") - if ext == "" { - ext = "xlsx" - } - body := map[string]interface{}{ - "token": token, - "type": "sheet", - "file_extension": ext, - } - if sid := strings.TrimSpace(runtime.Str("sheet-id")); sid != "" { - body["sub_id"] = sid - } - dry := common.NewDryRunAPI(). - POST("/open-apis/drive/v1/export_tasks"). - Desc("create export task"). - Body(body). - GET("/open-apis/drive/v1/export_tasks/"). - Desc("poll task status"). - Params(map[string]interface{}{"token": token}) - if strings.TrimSpace(runtime.Str("output-path")) != "" { - dry.GET("/open-apis/drive/v1/export_tasks/file//download"). - Desc("download exported file") - } - return dry - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, err := resolveSpreadsheetToken(runtime) - if err != nil { - return err - } - ext := runtime.Str("file-extension") - if ext == "" { - ext = "xlsx" - } - body := map[string]interface{}{ - "token": token, - "type": "sheet", - "file_extension": ext, - } - if sid := strings.TrimSpace(runtime.Str("sheet-id")); sid != "" { - body["sub_id"] = sid - } - taskData, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, body) - if err != nil { - return err - } - ticket := common.GetString(taskData, "ticket") - if ticket == "" { - return output.Errorf(output.ExitAPI, "api_error", "export task created but ticket missing") - } - - result := map[string]interface{}{ - "ticket": ticket, - "file_extension": ext, - } - - // Poll up to ~30s for completion. - var fileToken, fileName string - for attempt := 0; attempt < 15; attempt++ { - status, err := pollExportTask(runtime, token, ticket) - if err != nil { - return err - } - switch status.JobStatus { - case 0: // success - fileToken = status.FileToken - fileName = status.FileName - result["file_token"] = fileToken - result["file_name"] = fileName - result["file_size"] = status.FileSize - attempt = 999 // break outer loop - case 1, 2: // pending / in progress - time.Sleep(2 * time.Second) - continue - default: // any non-zero status outside the in-progress window is a failure - if status.JobErrorMsg != "" { - return output.Errorf(output.ExitAPI, "api_error", "export task %s failed: %s", ticket, status.JobErrorMsg) - } - return output.Errorf(output.ExitAPI, "api_error", "export task %s failed with job_status=%d", ticket, status.JobStatus) - } - } - if fileToken == "" { - result["status"] = "polling_timeout" - runtime.Out(result, nil) - return nil - } - - outPath := strings.TrimSpace(runtime.Str("output-path")) - if outPath == "" { - runtime.Out(result, nil) - return nil - } - - saved, err := downloadExportFile(ctx, runtime, fileToken, outPath, fileName) - if err != nil { - return err - } - result["saved_path"] = saved - runtime.Out(result, nil) - return nil - }, - Tips: []string{ - "Polls up to ~30s (15 × 2s). For very large workbooks rerun and pass --output-path to capture the file once status flips to success.", - }, -} - -type exportTaskStatus struct { - JobStatus int - JobErrorMsg string - FileToken string - FileName string - FileSize int64 - FileExtension string -} - -func pollExportTask(runtime *common.RuntimeContext, token, ticket string) (exportTaskStatus, error) { - data, err := runtime.CallAPI( - "GET", - fmt.Sprintf("/open-apis/drive/v1/export_tasks/%s", validate.EncodePathSegment(ticket)), - map[string]interface{}{"token": token}, - nil, - ) - if err != nil { - return exportTaskStatus{}, err - } - result := common.GetMap(data, "result") - if result == nil { - return exportTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "export task %s: empty result", ticket) - } - js, _ := util.ToFloat64(result["job_status"]) - fs, _ := util.ToFloat64(result["file_size"]) - return exportTaskStatus{ - JobStatus: int(js), - JobErrorMsg: common.GetString(result, "job_error_msg"), - FileToken: common.GetString(result, "file_token"), - FileName: common.GetString(result, "file_name"), - FileSize: int64(fs), - FileExtension: common.GetString(result, "file_extension"), - }, nil -} - -func downloadExportFile(ctx context.Context, runtime *common.RuntimeContext, fileToken, outPath, preferredName string) (string, error) { - apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ - HttpMethod: http.MethodGet, - ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)), - }, larkcore.WithFileDownload()) - if err != nil { - return "", output.ErrNetwork("download failed: %s", err) - } - if apiResp.StatusCode >= 400 { - return "", output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody)) - } - target := outPath - if info, statErr := runtime.FileIO().Stat(outPath); statErr == nil && info.IsDir() { - name := strings.TrimSpace(preferredName) - if name == "" { - name = client.ResolveFilename(apiResp) - } - target = filepath.Join(outPath, name) - } - if _, err := runtime.FileIO().Save(target, fileio.SaveOptions{ - ContentType: apiResp.Header.Get("Content-Type"), - ContentLength: int64(len(apiResp.RawBody)), - }, strings.NewReader(string(apiResp.RawBody))); err != nil { - return "", common.WrapSaveErrorByCategory(err, "io") - } - resolved, _ := runtime.FileIO().ResolvePath(target) - if resolved == "" { - resolved = target - } - return resolved, nil -} - -// ─── +dim-move ──────────────────────────────────────────────────────── - -// DimMove moves a contiguous block of rows or columns to a new index in the -// same sheet. The CLI flag semantic is 0-based inclusive (--start / --end); -// the legacy v2 endpoint expects half-open [startIndex, endIndex). -var DimMove = common.Shortcut{ - Service: "sheets", - Command: "+dim-move", - Description: "Move a contiguous block of rows or columns to a new position (re-numbers neighbors).", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, - AuthTypes: []string{"user", "bot"}, - HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "dimension", Required: true, Enum: dimEnum, Desc: "`row` or `column`"}, - common.Flag{Name: "start", Type: "int", Required: true, Desc: "source start (0-indexed, inclusive)"}, - common.Flag{Name: "end", Type: "int", Required: true, Desc: "source end (0-indexed, inclusive)"}, - common.Flag{Name: "target", Type: "int", Required: true, Desc: "destination index (0-indexed); rows/cols move to land BEFORE this index"}, - ), - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, _, err := resolveSheetSelector(runtime); err != nil { - return err - } - if !runtime.Changed("dimension") || !runtime.Changed("start") || !runtime.Changed("end") || !runtime.Changed("target") { - return common.FlagErrorf("--dimension / --start / --end / --target are all required") - } - if runtime.Int("start") < 0 || runtime.Int("end") < runtime.Int("start") { - return common.FlagErrorf("--end (%d) must be >= --start (%d) (both 0-indexed, inclusive)", runtime.Int("end"), runtime.Int("start")) - } - if runtime.Int("target") < 0 { - return common.FlagErrorf("--target must be >= 0") - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := resolveSpreadsheetToken(runtime) - sheetID, sheetName, _ := resolveSheetSelector(runtime) - body := dimMoveBody(runtime, sheetSelectorPlaceholder(sheetID, sheetName)) - return common.NewDryRunAPI(). - POST(fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", token)). - Body(body). - Set("spreadsheet_token", token) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, err := resolveSpreadsheetToken(runtime) - if err != nil { - return err - } - sheetID, sheetName, err := resolveSheetSelector(runtime) - if err != nil { - return err - } - // Legacy v2 endpoint needs sheet_id. Resolve sheet_name client-side - // when needed (reuses lookupSheetIndex which fetches workbook structure). - if sheetID == "" { - lookedID, _, err := lookupSheetIndex(ctx, runtime, token, "", sheetName) - if err != nil { - return err - } - sheetID = lookedID - } - body := dimMoveBody(runtime, sheetID) - data, err := runtime.CallAPI( - "POST", - fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)), - nil, body, - ) - if err != nil { - return err - } - runtime.Out(data, nil) - return nil - }, -} - -func dimMoveBody(runtime *common.RuntimeContext, sheetID string) map[string]interface{} { - dim := "ROWS" - if runtime.Str("dimension") == "column" { - dim = "COLUMNS" - } - return map[string]interface{}{ - "source": map[string]interface{}{ - "sheetId": sheetID, - "majorDimension": dim, - "startIndex": runtime.Int("start"), - "endIndex": runtime.Int("end") + 1, // CLI inclusive → API exclusive - }, - "destinationIndex": runtime.Int("target"), - } -} - -// ─── +cells-set-image ───────────────────────────────────────────────── - -// CellsSetImage uploads a local image to drive (parent_type=sheet_image, -// parent_node=spreadsheet token) and then writes a rich_text embed-image -// into the target single-cell range via the set_cell_range tool. -var CellsSetImage = common.Shortcut{ - Service: "sheets", - Command: "+cells-set-image", - Description: "Embed a local image into a single cell (uploads via drive, then set_cell_range with rich_text embed-image).", - Risk: "write", - Scopes: []string{"sheets:spreadsheet:write_only", "drive:file:upload"}, - AuthTypes: []string{"user", "bot"}, - HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "range", Required: true, Desc: "single target cell (e.g. A1; start/end must equal)"}, - common.Flag{Name: "image", Required: true, Desc: "local image path (PNG/JPEG/JPG/GIF/BMP/JFIF/EXIF/TIFF/BPG/HEIC)"}, - common.Flag{Name: "name", Desc: "uploaded file name (with extension); defaults to basename(--image)"}, - ), - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, _, err := resolveSheetSelector(runtime); err != nil { - return err - } - r := strings.TrimSpace(runtime.Str("range")) - if r == "" { - return common.FlagErrorf("--range is required") - } - rows, cols, err := rangeDimensions(r) - if err != nil { - return common.FlagErrorf("--range %q: %v", r, err) - } - if rows != 1 || cols != 1 { - return common.FlagErrorf("--range %q must be exactly one cell (got %d×%d)", r, rows, cols) - } - if strings.TrimSpace(runtime.Str("image")) == "" { - return common.FlagErrorf("--image is required") - } - return nil - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - token, _ := resolveSpreadsheetToken(runtime) - sheetID, sheetName, _ := resolveSheetSelector(runtime) - imgPath := strings.TrimSpace(runtime.Str("image")) - fileName := strings.TrimSpace(runtime.Str("name")) - if fileName == "" { - fileName = filepath.Base(imgPath) - } - setCellBody, _ := buildToolBody("set_cell_range", map[string]interface{}{ - "excel_id": token, - "range": strings.TrimSpace(runtime.Str("range")), - "sheet_id": sheetSelectorPlaceholder(sheetID, sheetName), - "cells": [][]interface{}{{map[string]interface{}{ - "rich_text": []map[string]interface{}{{ - "type": "embed-image", - "attachment_token": "", - "attachment_name": fileName, - }}, - }}}, - }) - return common.NewDryRunAPI(). - POST("/open-apis/drive/v1/medias/upload_all"). - Desc("upload local image to drive (parent_type=sheet_image)"). - Body(map[string]interface{}{ - "file_name": fileName, - "parent_type": "sheet_image", - "parent_node": token, - "size": "", - "file": "@" + imgPath, - }). - POST(toolInvokePath(token, ToolKindWrite)). - Desc("embed file_token into the cell via set_cell_range"). - Body(setCellBody) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - token, err := resolveSpreadsheetToken(runtime) - if err != nil { - return err - } - sheetID, sheetName, err := resolveSheetSelector(runtime) - if err != nil { - return err - } - imgPath := strings.TrimSpace(runtime.Str("image")) - fileName := strings.TrimSpace(runtime.Str("name")) - if fileName == "" { - fileName = filepath.Base(imgPath) - } - info, err := runtime.FileIO().Stat(imgPath) - if err != nil { - return common.WrapInputStatError(err) - } - fileToken, err := common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ - FilePath: imgPath, - FileName: fileName, - FileSize: info.Size(), - ParentType: "sheet_image", - ParentNode: &token, - }) - if err != nil { - return err - } - - setCellInput := map[string]interface{}{ - "excel_id": token, - "range": strings.TrimSpace(runtime.Str("range")), - "cells": [][]interface{}{{map[string]interface{}{ - "rich_text": []map[string]interface{}{{ - "type": "embed-image", - "attachment_token": fileToken, - "attachment_name": fileName, - }}, - }}}, - } - sheetSelectorForToolInput(setCellInput, sheetID, sheetName) - setCellOut, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", setCellInput) - if err != nil { - return fmt.Errorf("image uploaded (file_token=%s) but cell write failed: %w", fileToken, err) - } - runtime.Out(map[string]interface{}{ - "file_token": fileToken, - "file_name": fileName, - "set_cell_range": setCellOut, - }, nil) - return nil - }, - Tips: []string{ - "--range must be a single cell. The uploaded image becomes a cell-internal embed; use +float-image-create for floating images.", - }, -} diff --git a/shortcuts/sheets/lark_sheet_cli_only_test.go b/shortcuts/sheets/lark_sheet_cli_only_test.go deleted file mode 100644 index 168082bb2..000000000 --- a/shortcuts/sheets/lark_sheet_cli_only_test.go +++ /dev/null @@ -1,216 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "strings" - "testing" -) - -// TestWorkbookCreate_DryRun verifies the two-step plan (create -// spreadsheet + optional set_cell_range follow-up) is rendered. -func TestWorkbookCreate_DryRun(t *testing.T) { - t.Parallel() - - t.Run("minimal title only", func(t *testing.T) { - t.Parallel() - calls := parseDryRunAPI(t, WorkbookCreate, []string{"--title", "MySheet"}) - if len(calls) != 1 { - t.Fatalf("api calls = %d, want 1 (no headers/data)", len(calls)) - } - c := calls[0].(map[string]interface{}) - if c["url"] != "/open-apis/sheets/v3/spreadsheets" { - t.Errorf("url = %v, want /open-apis/sheets/v3/spreadsheets", c["url"]) - } - body, _ := c["body"].(map[string]interface{}) - if body["title"] != "MySheet" { - t.Errorf("body.title = %v, want MySheet", body["title"]) - } - }) - - t.Run("with headers and data → 2-step plan", func(t *testing.T) { - t.Parallel() - calls := parseDryRunAPI(t, WorkbookCreate, []string{ - "--title", "Sales", - "--headers", `["Name","Score"]`, - "--data", `[["alice",95],["bob",88]]`, - }) - if len(calls) != 2 { - t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls)) - } - fill := calls[1].(map[string]interface{}) - if !strings.Contains(fill["url"].(string), "/sheet_ai/v2/spreadsheets/") { - t.Errorf("fill url = %v, want sheet_ai/v2 path", fill["url"]) - } - body, _ := fill["body"].(map[string]interface{}) - input := decodeToolInput(t, body, "set_cell_range") - if input["range"] != "A1:B3" { - t.Errorf("fill range = %v, want A1:B3 (1 header + 2 data rows × 2 cols)", input["range"]) - } - }) -} - -// TestWorkbookCreate_DataValidation rejects bad JSON shape. -func TestWorkbookCreate_DataValidation(t *testing.T) { - t.Parallel() - cases := []struct { - name string - args []string - want string - }{ - {"headers not array", []string{"--title", "X", "--headers", `"abc"`}, "must be a JSON array"}, - {"data not 2D", []string{"--title", "X", "--data", `["a","b"]`}, "must be an array"}, - } - for _, tt := range cases { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - stdout, stderr, err := runShortcutCapturingErr(t, WorkbookCreate, append(tt.args, "--dry-run")) - if err == nil || !strings.Contains(stdout+stderr+err.Error(), tt.want) { - t.Errorf("expected %q; got=%s|%s|%v", tt.want, stdout, stderr, err) - } - }) - } -} - -// TestWorkbookExport_DryRun checks the 2-or-3 step plan depending on -// --output-path. The order should be: POST → GET (poll) → optional GET -// (download). -func TestWorkbookExport_DryRun(t *testing.T) { - t.Parallel() - - t.Run("xlsx without --output-path → 2 steps", func(t *testing.T) { - t.Parallel() - calls := parseDryRunAPI(t, WorkbookExport, []string{"--url", testURL, "--file-extension", "xlsx"}) - if len(calls) != 2 { - t.Fatalf("api calls = %d, want 2 (create + poll)", len(calls)) - } - create := calls[0].(map[string]interface{}) - if create["url"] != "/open-apis/drive/v1/export_tasks" { - t.Errorf("first url = %v", create["url"]) - } - body, _ := create["body"].(map[string]interface{}) - if body["type"] != "sheet" || body["file_extension"] != "xlsx" || body["token"] != testToken { - t.Errorf("create body = %#v", body) - } - }) - - t.Run("csv → 3 steps, with sub_id", func(t *testing.T) { - t.Parallel() - calls := parseDryRunAPI(t, WorkbookExport, []string{ - "--url", testURL, "--file-extension", "csv", "--sheet-id", "sh1", - "--output-path", "/tmp/out.csv", - }) - if len(calls) != 3 { - t.Fatalf("api calls = %d, want 3", len(calls)) - } - body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{}) - if body["sub_id"] != "sh1" { - t.Errorf("csv export missing sub_id: %#v", body) - } - dl := calls[2].(map[string]interface{}) - if !strings.Contains(dl["url"].(string), "/export_tasks/file/") { - t.Errorf("download url = %v", dl["url"]) - } - }) - - t.Run("csv requires --sheet-id", func(t *testing.T) { - t.Parallel() - stdout, stderr, err := runShortcutCapturingErr(t, WorkbookExport, []string{ - "--url", testURL, "--file-extension", "csv", "--dry-run", - }) - if err == nil || !strings.Contains(stdout+stderr+err.Error(), "--sheet-id is required") { - t.Errorf("expected sheet-id guard; got=%s|%s|%v", stdout, stderr, err) - } - }) -} - -// TestDimMove_DryRun verifies the legacy v2 dimension_range payload -// shape. CLI's 0-based inclusive (--start / --end) becomes the v2 -// endpoint's half-open [startIndex, endIndex). -func TestDimMove_DryRun(t *testing.T) { - t.Parallel() - calls := parseDryRunAPI(t, DimMove, []string{ - "--url", testURL, "--sheet-id", testSheetID, - "--dimension", "row", "--start", "0", "--end", "2", "--target", "10", - }) - if len(calls) != 1 { - t.Fatalf("api calls = %d, want 1", len(calls)) - } - c := calls[0].(map[string]interface{}) - if !strings.Contains(c["url"].(string), "/sheets/v2/spreadsheets/") { - t.Errorf("url = %v, want sheets/v2 path", c["url"]) - } - body, _ := c["body"].(map[string]interface{}) - src, _ := body["source"].(map[string]interface{}) - if src["sheetId"] != testSheetID { - t.Errorf("source.sheetId = %v", src["sheetId"]) - } - if src["majorDimension"] != "ROWS" { - t.Errorf("source.majorDimension = %v, want ROWS", src["majorDimension"]) - } - if src["startIndex"].(float64) != 0 { - t.Errorf("startIndex = %v, want 0", src["startIndex"]) - } - if src["endIndex"].(float64) != 3 { - t.Errorf("endIndex = %v, want 3 (CLI end+1 for half-open)", src["endIndex"]) - } - if body["destinationIndex"].(float64) != 10 { - t.Errorf("destinationIndex = %v, want 10", body["destinationIndex"]) - } -} - -// TestCellsSetImage_DryRun verifies the 2-step plan (upload + embed) is -// rendered, including the parent_type=sheet_image upload metadata. -func TestCellsSetImage_DryRun(t *testing.T) { - t.Parallel() - calls := parseDryRunAPI(t, CellsSetImage, []string{ - "--url", testURL, "--sheet-id", testSheetID, - "--range", "A1", - "--image", "./README.md", // any existing-shaped path; dry-run skips stat - }) - if len(calls) != 2 { - t.Fatalf("api calls = %d, want 2 (upload + set_cell_range)", len(calls)) - } - upload := calls[0].(map[string]interface{}) - if upload["url"] != "/open-apis/drive/v1/medias/upload_all" { - t.Errorf("upload url = %v", upload["url"]) - } - ubody, _ := upload["body"].(map[string]interface{}) - if ubody["parent_type"] != "sheet_image" { - t.Errorf("parent_type = %v, want sheet_image", ubody["parent_type"]) - } - if ubody["parent_node"] != testToken { - t.Errorf("parent_node = %v, want token", ubody["parent_node"]) - } - - embed := calls[1].(map[string]interface{}) - body, _ := embed["body"].(map[string]interface{}) - input := decodeToolInput(t, body, "set_cell_range") - cells, _ := input["cells"].([]interface{}) - row, _ := cells[0].([]interface{}) - cell, _ := row[0].(map[string]interface{}) - rt, _ := cell["rich_text"].([]interface{}) - if len(rt) != 1 { - t.Fatalf("rich_text len = %d, want 1", len(rt)) - } - item, _ := rt[0].(map[string]interface{}) - if item["type"] != "embed-image" { - t.Errorf("rich_text.type = %v, want embed-image", item["type"]) - } - if item["attachment_name"] != "README.md" { - t.Errorf("attachment_name = %v, want README.md (basename)", item["attachment_name"]) - } -} - -func TestCellsSetImage_RangeMustBeSingleCell(t *testing.T) { - t.Parallel() - stdout, stderr, err := runShortcutCapturingErr(t, CellsSetImage, []string{ - "--url", testURL, "--sheet-id", testSheetID, - "--range", "A1:B2", "--image", "./foo.png", "--dry-run", - }) - if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must be exactly one cell") { - t.Errorf("expected single-cell guard; got=%s|%s|%v", stdout, stderr, err) - } -} diff --git a/shortcuts/sheets/lark_sheet_sheet_structure.go b/shortcuts/sheets/lark_sheet_sheet_structure.go index 0e1626cca..f24869b05 100644 --- a/shortcuts/sheets/lark_sheet_sheet_structure.go +++ b/shortcuts/sheets/lark_sheet_sheet_structure.go @@ -8,6 +8,7 @@ import ( "fmt" "strings" + "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -495,3 +496,99 @@ func columnIndexToLetter(idx int) string { } return string(out) } + +// ─── +dim-move (legacy OAPI, cli_status: cli-only) ─────────────────── +// +// Moves a contiguous block of rows or columns to a new index in the same +// sheet via the legacy v2 endpoint (not the One-OpenAPI dispatcher). +// CLI's --start / --end are 0-based inclusive; the endpoint expects +// half-open [startIndex, endIndex). + +var DimMove = common.Shortcut{ + Service: "sheets", + Command: "+dim-move", + Description: "Move a contiguous block of rows or columns to a new position (re-numbers neighbors).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "dimension", Required: true, Enum: dimEnum, Desc: "`row` or `column`"}, + common.Flag{Name: "start", Type: "int", Required: true, Desc: "source start (0-indexed, inclusive)"}, + common.Flag{Name: "end", Type: "int", Required: true, Desc: "source end (0-indexed, inclusive)"}, + common.Flag{Name: "target", Type: "int", Required: true, Desc: "destination index (0-indexed); rows/cols move to land BEFORE this index"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if !runtime.Changed("dimension") || !runtime.Changed("start") || !runtime.Changed("end") || !runtime.Changed("target") { + return common.FlagErrorf("--dimension / --start / --end / --target are all required") + } + if runtime.Int("start") < 0 || runtime.Int("end") < runtime.Int("start") { + return common.FlagErrorf("--end (%d) must be >= --start (%d) (both 0-indexed, inclusive)", runtime.Int("end"), runtime.Int("start")) + } + if runtime.Int("target") < 0 { + return common.FlagErrorf("--target must be >= 0") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + body := dimMoveBody(runtime, sheetSelectorPlaceholder(sheetID, sheetName)) + return common.NewDryRunAPI(). + POST(fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", token)). + Body(body). + Set("spreadsheet_token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + // Legacy v2 endpoint needs sheet_id. Resolve sheet_name client-side + // when needed (reuses lookupSheetIndex which fetches workbook structure). + if sheetID == "" { + lookedID, _, err := lookupSheetIndex(ctx, runtime, token, "", sheetName) + if err != nil { + return err + } + sheetID = lookedID + } + body := dimMoveBody(runtime, sheetID) + data, err := runtime.CallAPI( + "POST", + fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)), + nil, body, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +func dimMoveBody(runtime *common.RuntimeContext, sheetID string) map[string]interface{} { + dim := "ROWS" + if runtime.Str("dimension") == "column" { + dim = "COLUMNS" + } + return map[string]interface{}{ + "source": map[string]interface{}{ + "sheetId": sheetID, + "majorDimension": dim, + "startIndex": runtime.Int("start"), + "endIndex": runtime.Int("end") + 1, // CLI inclusive → API exclusive + }, + "destinationIndex": runtime.Int("target"), + } +} diff --git a/shortcuts/sheets/lark_sheet_sheet_structure_test.go b/shortcuts/sheets/lark_sheet_sheet_structure_test.go index dc1743cd0..a5e6a3ff1 100644 --- a/shortcuts/sheets/lark_sheet_sheet_structure_test.go +++ b/shortcuts/sheets/lark_sheet_sheet_structure_test.go @@ -169,6 +169,41 @@ func TestDimRange_StartEndValidation(t *testing.T) { } } +// TestDimMove_DryRun verifies the legacy v2 dimension_range payload +// shape. CLI's 0-based inclusive (--start / --end) becomes the v2 +// endpoint's half-open [startIndex, endIndex). +func TestDimMove_DryRun(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, DimMove, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--dimension", "row", "--start", "0", "--end", "2", "--target", "10", + }) + if len(calls) != 1 { + t.Fatalf("api calls = %d, want 1", len(calls)) + } + c := calls[0].(map[string]interface{}) + if !strings.Contains(c["url"].(string), "/sheets/v2/spreadsheets/") { + t.Errorf("url = %v, want sheets/v2 path", c["url"]) + } + body, _ := c["body"].(map[string]interface{}) + src, _ := body["source"].(map[string]interface{}) + if src["sheetId"] != testSheetID { + t.Errorf("source.sheetId = %v", src["sheetId"]) + } + if src["majorDimension"] != "ROWS" { + t.Errorf("source.majorDimension = %v, want ROWS", src["majorDimension"]) + } + if src["startIndex"].(float64) != 0 { + t.Errorf("startIndex = %v, want 0", src["startIndex"]) + } + if src["endIndex"].(float64) != 3 { + t.Errorf("endIndex = %v, want 3 (CLI end+1 for half-open)", src["endIndex"]) + } + if body["destinationIndex"].(float64) != 10 { + t.Errorf("destinationIndex = %v, want 10", body["destinationIndex"]) + } +} + // TestColumnIndexToLetter exercises the corner cases of the letter helper: // single, double, and triple-letter spans. func TestColumnIndexToLetter(t *testing.T) { diff --git a/shortcuts/sheets/lark_sheet_workbook.go b/shortcuts/sheets/lark_sheet_workbook.go index f7b87ac83..e545cbb30 100644 --- a/shortcuts/sheets/lark_sheet_workbook.go +++ b/shortcuts/sheets/lark_sheet_workbook.go @@ -6,10 +6,18 @@ package sheets import ( "context" "fmt" + "net/http" + "path/filepath" "strings" + "time" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -519,6 +527,379 @@ var SheetSetTabColor = common.Shortcut{ }, } +// ─── +workbook-create (legacy OAPI, cli_status: cli-only) ──────────── +// +// Creates a brand-new spreadsheet via POST /sheets/v3/spreadsheets, then +// optionally fills the first sheet's header row and initial data block +// via a follow-up callTool(set_cell_range). Not exposed as an MCP tool — +// hence the direct legacy OAPI call instead of going through callTool. + +// WorkbookCreate creates a brand-new spreadsheet in the user's drive +// (optionally inside --folder-token) and can pre-fill the first row of +// headers and an initial data block. +var WorkbookCreate = common.Shortcut{ + Service: "sheets", + Command: "+workbook-create", + Description: "Create a new spreadsheet (optionally pre-filled with --headers and --data).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:create", "sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "title", Required: true, Desc: "spreadsheet title"}, + {Name: "folder-token", Desc: "destination folder token; omit to land at the drive root"}, + {Name: "headers", Input: []string{common.File, common.Stdin}, Desc: "header row JSON array, e.g. [\"列A\",\"列B\"]"}, + {Name: "data", Input: []string{common.File, common.Stdin}, Desc: "initial data JSON 2D array, e.g. [[\"alice\",95]]"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("title")) == "" { + return common.FlagErrorf("--title is required") + } + if runtime.Str("headers") != "" { + v, err := parseJSONFlag(runtime, "headers") + if err != nil { + return err + } + if _, ok := v.([]interface{}); !ok { + return common.FlagErrorf("--headers must be a JSON array") + } + } + if runtime.Str("data") != "" { + v, err := parseJSONFlag(runtime, "data") + if err != nil { + return err + } + rows, ok := v.([]interface{}) + if !ok { + return common.FlagErrorf("--data must be a JSON 2D array") + } + for i, r := range rows { + if _, ok := r.([]interface{}); !ok { + return common.FlagErrorf("--data[%d] must be an array", i) + } + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{"title": strings.TrimSpace(runtime.Str("title"))} + if v := strings.TrimSpace(runtime.Str("folder-token")); v != "" { + body["folder_token"] = v + } + dry := common.NewDryRunAPI(). + POST("/open-apis/sheets/v3/spreadsheets"). + Desc("create spreadsheet"). + Body(body) + if runtime.Str("headers") != "" || runtime.Str("data") != "" { + fill, _ := buildInitialFillInput(runtime) + wireBody, _ := buildToolBody("set_cell_range", fill) + dry.POST("/open-apis/sheet_ai/v2/spreadsheets//tools/invoke_write"). + Desc("fill headers + data via set_cell_range"). + Body(wireBody) + } + return dry + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + body := map[string]interface{}{"title": strings.TrimSpace(runtime.Str("title"))} + if v := strings.TrimSpace(runtime.Str("folder-token")); v != "" { + body["folder_token"] = v + } + data, err := runtime.CallAPI("POST", "/open-apis/sheets/v3/spreadsheets", nil, body) + if err != nil { + return err + } + ss := common.GetMap(data, "spreadsheet") + token := common.GetString(ss, "spreadsheet_token") + if token == "" { + token = common.GetString(ss, "token") + } + if token == "" { + return output.Errorf(output.ExitAPI, "api_error", "spreadsheet created but token missing in response") + } + + result := map[string]interface{}{"spreadsheet": ss} + + if runtime.Str("headers") != "" || runtime.Str("data") != "" { + fill, err := buildInitialFillInput(runtime) + if err != nil { + return err + } + fill["excel_id"] = token + fillOut, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", fill) + if err != nil { + // Spreadsheet exists; surface the fill failure but keep the new + // token in the envelope so the caller can recover or retry. + return fmt.Errorf("spreadsheet %s created but initial fill failed: %w", token, err) + } + result["initial_fill"] = fillOut + } + runtime.Out(result, nil) + return nil + }, + Tips: []string{ + "--headers and --data are optional follow-up writes. They use the same set_cell_range tool as +cells-set; partial failure leaves the spreadsheet created but empty.", + }, +} + +// buildInitialFillInput zips --headers + --data into a single set_cell_range +// payload writing to the first sheet starting at A1. +func buildInitialFillInput(runtime *common.RuntimeContext) (map[string]interface{}, error) { + var rows [][]interface{} + if runtime.Str("headers") != "" { + v, _ := parseJSONFlag(runtime, "headers") + headerArr, _ := v.([]interface{}) + row := make([]interface{}, 0, len(headerArr)) + for _, h := range headerArr { + row = append(row, map[string]interface{}{"value": h}) + } + rows = append(rows, row) + } + if runtime.Str("data") != "" { + v, _ := parseJSONFlag(runtime, "data") + dataArr, _ := v.([]interface{}) + for _, r := range dataArr { + cells, _ := r.([]interface{}) + row := make([]interface{}, 0, len(cells)) + for _, c := range cells { + row = append(row, map[string]interface{}{"value": c}) + } + rows = append(rows, row) + } + } + if len(rows) == 0 { + return nil, nil + } + maxCols := 0 + for _, r := range rows { + if len(r) > maxCols { + maxCols = len(r) + } + } + // Normalize rows to the same length so cells matrix is rectangular. + for i := range rows { + for len(rows[i]) < maxCols { + rows[i] = append(rows[i], map[string]interface{}{}) + } + } + endCol := columnIndexToLetter(maxCols - 1) + rangeStr := fmt.Sprintf("A1:%s%d", endCol, len(rows)) + return map[string]interface{}{ + "range": rangeStr, + "cells": rows, + "sheet_id": "", // filled in by caller if sheet_id known; otherwise server picks first sheet + }, nil +} + +// ─── +workbook-export (legacy OAPI, cli_status: cli-only) ──────────── +// +// Drives the three-step export flow against the classic drive endpoints: +// create export task → poll task status → optional binary download. +// Not exposed as an MCP tool. + +// WorkbookExport drives the three-step export flow: create task → poll → +// optionally download. CSV mode requires --sheet-id (the API exports one +// sheet at a time as csv). +var WorkbookExport = common.Shortcut{ + Service: "sheets", + Command: "+workbook-export", + Description: "Export a spreadsheet to xlsx or a single sheet to csv (async + poll + optional download).", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read", "docs:document:export", "drive:drive.metadata:readonly"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicTokenFlags(), + common.Flag{Name: "file-extension", Enum: []string{"xlsx", "csv"}, Default: "xlsx", Desc: "xlsx (whole workbook) or csv (one sheet via --sheet-id)"}, + common.Flag{Name: "sheet-id", Desc: "csv mode only: target sheet reference_id to export"}, + common.Flag{Name: "output-path", Desc: "local file path to save into; omit to just trigger and report the file_token"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + ext := runtime.Str("file-extension") + if ext == "" { + ext = "xlsx" + } + if ext == "csv" && strings.TrimSpace(runtime.Str("sheet-id")) == "" { + return common.FlagErrorf("--sheet-id is required when --file-extension=csv") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + ext := runtime.Str("file-extension") + if ext == "" { + ext = "xlsx" + } + body := map[string]interface{}{ + "token": token, + "type": "sheet", + "file_extension": ext, + } + if sid := strings.TrimSpace(runtime.Str("sheet-id")); sid != "" { + body["sub_id"] = sid + } + dry := common.NewDryRunAPI(). + POST("/open-apis/drive/v1/export_tasks"). + Desc("create export task"). + Body(body). + GET("/open-apis/drive/v1/export_tasks/"). + Desc("poll task status"). + Params(map[string]interface{}{"token": token}) + if strings.TrimSpace(runtime.Str("output-path")) != "" { + dry.GET("/open-apis/drive/v1/export_tasks/file//download"). + Desc("download exported file") + } + return dry + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + ext := runtime.Str("file-extension") + if ext == "" { + ext = "xlsx" + } + body := map[string]interface{}{ + "token": token, + "type": "sheet", + "file_extension": ext, + } + if sid := strings.TrimSpace(runtime.Str("sheet-id")); sid != "" { + body["sub_id"] = sid + } + taskData, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, body) + if err != nil { + return err + } + ticket := common.GetString(taskData, "ticket") + if ticket == "" { + return output.Errorf(output.ExitAPI, "api_error", "export task created but ticket missing") + } + + result := map[string]interface{}{ + "ticket": ticket, + "file_extension": ext, + } + + // Poll up to ~30s for completion. + var fileToken, fileName string + for attempt := 0; attempt < 15; attempt++ { + status, err := pollExportTask(runtime, token, ticket) + if err != nil { + return err + } + switch status.JobStatus { + case 0: // success + fileToken = status.FileToken + fileName = status.FileName + result["file_token"] = fileToken + result["file_name"] = fileName + result["file_size"] = status.FileSize + attempt = 999 // break outer loop + case 1, 2: // pending / in progress + time.Sleep(2 * time.Second) + continue + default: // any non-zero status outside the in-progress window is a failure + if status.JobErrorMsg != "" { + return output.Errorf(output.ExitAPI, "api_error", "export task %s failed: %s", ticket, status.JobErrorMsg) + } + return output.Errorf(output.ExitAPI, "api_error", "export task %s failed with job_status=%d", ticket, status.JobStatus) + } + } + if fileToken == "" { + result["status"] = "polling_timeout" + runtime.Out(result, nil) + return nil + } + + outPath := strings.TrimSpace(runtime.Str("output-path")) + if outPath == "" { + runtime.Out(result, nil) + return nil + } + + saved, err := downloadExportFile(ctx, runtime, fileToken, outPath, fileName) + if err != nil { + return err + } + result["saved_path"] = saved + runtime.Out(result, nil) + return nil + }, + Tips: []string{ + "Polls up to ~30s (15 × 2s). For very large workbooks rerun and pass --output-path to capture the file once status flips to success.", + }, +} + +type exportTaskStatus struct { + JobStatus int + JobErrorMsg string + FileToken string + FileName string + FileSize int64 + FileExtension string +} + +func pollExportTask(runtime *common.RuntimeContext, token, ticket string) (exportTaskStatus, error) { + data, err := runtime.CallAPI( + "GET", + fmt.Sprintf("/open-apis/drive/v1/export_tasks/%s", validate.EncodePathSegment(ticket)), + map[string]interface{}{"token": token}, + nil, + ) + if err != nil { + return exportTaskStatus{}, err + } + result := common.GetMap(data, "result") + if result == nil { + return exportTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "export task %s: empty result", ticket) + } + js, _ := util.ToFloat64(result["job_status"]) + fs, _ := util.ToFloat64(result["file_size"]) + return exportTaskStatus{ + JobStatus: int(js), + JobErrorMsg: common.GetString(result, "job_error_msg"), + FileToken: common.GetString(result, "file_token"), + FileName: common.GetString(result, "file_name"), + FileSize: int64(fs), + FileExtension: common.GetString(result, "file_extension"), + }, nil +} + +func downloadExportFile(ctx context.Context, runtime *common.RuntimeContext, fileToken, outPath, preferredName string) (string, error) { + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)), + }, larkcore.WithFileDownload()) + if err != nil { + return "", output.ErrNetwork("download failed: %s", err) + } + if apiResp.StatusCode >= 400 { + return "", output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody)) + } + target := outPath + if info, statErr := runtime.FileIO().Stat(outPath); statErr == nil && info.IsDir() { + name := strings.TrimSpace(preferredName) + if name == "" { + name = client.ResolveFilename(apiResp) + } + target = filepath.Join(outPath, name) + } + if _, err := runtime.FileIO().Save(target, fileio.SaveOptions{ + ContentType: apiResp.Header.Get("Content-Type"), + ContentLength: int64(len(apiResp.RawBody)), + }, strings.NewReader(string(apiResp.RawBody))); err != nil { + return "", common.WrapSaveErrorByCategory(err, "io") + } + resolved, _ := runtime.FileIO().ResolvePath(target) + if resolved == "" { + resolved = target + } + return resolved, nil +} + // lookupSheetIndex finds a sub-sheet by id or name and returns its canonical // id + current 0-based index. Caller is responsible for ensuring at least one // of sheetID/sheetName is non-empty. diff --git a/shortcuts/sheets/lark_sheet_workbook_test.go b/shortcuts/sheets/lark_sheet_workbook_test.go index ea7d6705d..532ddc305 100644 --- a/shortcuts/sheets/lark_sheet_workbook_test.go +++ b/shortcuts/sheets/lark_sheet_workbook_test.go @@ -275,6 +275,126 @@ func TestWorkbook_Validation(t *testing.T) { } } +// ─── +workbook-create / +workbook-export (legacy OAPI) ─────────────── + +// TestWorkbookCreate_DryRun verifies the two-step plan (create +// spreadsheet + optional set_cell_range follow-up) is rendered. +func TestWorkbookCreate_DryRun(t *testing.T) { + t.Parallel() + + t.Run("minimal title only", func(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, WorkbookCreate, []string{"--title", "MySheet"}) + if len(calls) != 1 { + t.Fatalf("api calls = %d, want 1 (no headers/data)", len(calls)) + } + c := calls[0].(map[string]interface{}) + if c["url"] != "/open-apis/sheets/v3/spreadsheets" { + t.Errorf("url = %v, want /open-apis/sheets/v3/spreadsheets", c["url"]) + } + body, _ := c["body"].(map[string]interface{}) + if body["title"] != "MySheet" { + t.Errorf("body.title = %v, want MySheet", body["title"]) + } + }) + + t.Run("with headers and data → 2-step plan", func(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, WorkbookCreate, []string{ + "--title", "Sales", + "--headers", `["Name","Score"]`, + "--data", `[["alice",95],["bob",88]]`, + }) + if len(calls) != 2 { + t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls)) + } + fill := calls[1].(map[string]interface{}) + if !strings.Contains(fill["url"].(string), "/sheet_ai/v2/spreadsheets/") { + t.Errorf("fill url = %v, want sheet_ai/v2 path", fill["url"]) + } + body, _ := fill["body"].(map[string]interface{}) + input := decodeToolInput(t, body, "set_cell_range") + if input["range"] != "A1:B3" { + t.Errorf("fill range = %v, want A1:B3 (1 header + 2 data rows × 2 cols)", input["range"]) + } + }) +} + +// TestWorkbookCreate_DataValidation rejects bad JSON shape. +func TestWorkbookCreate_DataValidation(t *testing.T) { + t.Parallel() + cases := []struct { + name string + args []string + want string + }{ + {"headers not array", []string{"--title", "X", "--headers", `"abc"`}, "must be a JSON array"}, + {"data not 2D", []string{"--title", "X", "--data", `["a","b"]`}, "must be an array"}, + } + for _, tt := range cases { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, WorkbookCreate, append(tt.args, "--dry-run")) + if err == nil || !strings.Contains(stdout+stderr+err.Error(), tt.want) { + t.Errorf("expected %q; got=%s|%s|%v", tt.want, stdout, stderr, err) + } + }) + } +} + +// TestWorkbookExport_DryRun checks the 2-or-3 step plan depending on +// --output-path. The order should be: POST → GET (poll) → optional GET +// (download). +func TestWorkbookExport_DryRun(t *testing.T) { + t.Parallel() + + t.Run("xlsx without --output-path → 2 steps", func(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, WorkbookExport, []string{"--url", testURL, "--file-extension", "xlsx"}) + if len(calls) != 2 { + t.Fatalf("api calls = %d, want 2 (create + poll)", len(calls)) + } + create := calls[0].(map[string]interface{}) + if create["url"] != "/open-apis/drive/v1/export_tasks" { + t.Errorf("first url = %v", create["url"]) + } + body, _ := create["body"].(map[string]interface{}) + if body["type"] != "sheet" || body["file_extension"] != "xlsx" || body["token"] != testToken { + t.Errorf("create body = %#v", body) + } + }) + + t.Run("csv → 3 steps, with sub_id", func(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, WorkbookExport, []string{ + "--url", testURL, "--file-extension", "csv", "--sheet-id", "sh1", + "--output-path", "/tmp/out.csv", + }) + if len(calls) != 3 { + t.Fatalf("api calls = %d, want 3", len(calls)) + } + body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{}) + if body["sub_id"] != "sh1" { + t.Errorf("csv export missing sub_id: %#v", body) + } + dl := calls[2].(map[string]interface{}) + if !strings.Contains(dl["url"].(string), "/export_tasks/file/") { + t.Errorf("download url = %v", dl["url"]) + } + }) + + t.Run("csv requires --sheet-id", func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, WorkbookExport, []string{ + "--url", testURL, "--file-extension", "csv", "--dry-run", + }) + if err == nil || !strings.Contains(stdout+stderr+err.Error(), "--sheet-id is required") { + t.Errorf("expected sheet-id guard; got=%s|%s|%v", stdout, stderr, err) + } + }) +} + // assertInputEquals compares the decoded tool input map against the wanted // fields. Extra fields in `got` are allowed (defaults, optional fields); // every key in `want` must match exactly. diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index 1e34b3ac0..cfba894f4 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -6,6 +6,7 @@ package sheets import ( "context" "fmt" + "path/filepath" "strconv" "strings" @@ -537,3 +538,143 @@ func fillCellsMatrix(rows, cols int, prototype map[string]interface{}) [][]inter } return cells } + +// ─── +cells-set-image (cli_only_derivative) ────────────────────────── +// +// The backing tool (set_cell_range) is in mcp-tools.json, but the CLI +// shortcut also needs a local-file upload before it can call the tool. +// That extra step doesn't fit the One-OpenAPI dispatcher, so the spec +// marks this shortcut cli_only_derivative — the CLI uploads the image +// to drive (parent_type=sheet_image) and then writes the returned +// file_token into the target cell via callTool(set_cell_range) with a +// rich_text embed-image entry. + +// CellsSetImage uploads a local image to drive (parent_type=sheet_image, +// parent_node=spreadsheet token) and then writes a rich_text embed-image +// into the target single-cell range via the set_cell_range tool. +var CellsSetImage = common.Shortcut{ + Service: "sheets", + Command: "+cells-set-image", + Description: "Embed a local image into a single cell (uploads via drive, then set_cell_range with rich_text embed-image).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "drive:file:upload"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "range", Required: true, Desc: "single target cell (e.g. A1; start/end must equal)"}, + common.Flag{Name: "image", Required: true, Desc: "local image path (PNG/JPEG/JPG/GIF/BMP/JFIF/EXIF/TIFF/BPG/HEIC)"}, + common.Flag{Name: "name", Desc: "uploaded file name (with extension); defaults to basename(--image)"}, + ), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + r := strings.TrimSpace(runtime.Str("range")) + if r == "" { + return common.FlagErrorf("--range is required") + } + rows, cols, err := rangeDimensions(r) + if err != nil { + return common.FlagErrorf("--range %q: %v", r, err) + } + if rows != 1 || cols != 1 { + return common.FlagErrorf("--range %q must be exactly one cell (got %d×%d)", r, rows, cols) + } + if strings.TrimSpace(runtime.Str("image")) == "" { + return common.FlagErrorf("--image is required") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + imgPath := strings.TrimSpace(runtime.Str("image")) + fileName := strings.TrimSpace(runtime.Str("name")) + if fileName == "" { + fileName = filepath.Base(imgPath) + } + setCellBody, _ := buildToolBody("set_cell_range", map[string]interface{}{ + "excel_id": token, + "range": strings.TrimSpace(runtime.Str("range")), + "sheet_id": sheetSelectorPlaceholder(sheetID, sheetName), + "cells": [][]interface{}{{map[string]interface{}{ + "rich_text": []map[string]interface{}{{ + "type": "embed-image", + "attachment_token": "", + "attachment_name": fileName, + }}, + }}}, + }) + return common.NewDryRunAPI(). + POST("/open-apis/drive/v1/medias/upload_all"). + Desc("upload local image to drive (parent_type=sheet_image)"). + Body(map[string]interface{}{ + "file_name": fileName, + "parent_type": "sheet_image", + "parent_node": token, + "size": "", + "file": "@" + imgPath, + }). + POST(toolInvokePath(token, ToolKindWrite)). + Desc("embed file_token into the cell via set_cell_range"). + Body(setCellBody) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + imgPath := strings.TrimSpace(runtime.Str("image")) + fileName := strings.TrimSpace(runtime.Str("name")) + if fileName == "" { + fileName = filepath.Base(imgPath) + } + info, err := runtime.FileIO().Stat(imgPath) + if err != nil { + return common.WrapInputStatError(err) + } + fileToken, err := common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ + FilePath: imgPath, + FileName: fileName, + FileSize: info.Size(), + ParentType: "sheet_image", + ParentNode: &token, + }) + if err != nil { + return err + } + + setCellInput := map[string]interface{}{ + "excel_id": token, + "range": strings.TrimSpace(runtime.Str("range")), + "cells": [][]interface{}{{map[string]interface{}{ + "rich_text": []map[string]interface{}{{ + "type": "embed-image", + "attachment_token": fileToken, + "attachment_name": fileName, + }}, + }}}, + } + sheetSelectorForToolInput(setCellInput, sheetID, sheetName) + setCellOut, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", setCellInput) + if err != nil { + return fmt.Errorf("image uploaded (file_token=%s) but cell write failed: %w", fileToken, err) + } + runtime.Out(map[string]interface{}{ + "file_token": fileToken, + "file_name": fileName, + "set_cell_range": setCellOut, + }, nil) + return nil + }, + Tips: []string{ + "--range must be a single cell. The uploaded image becomes a cell-internal embed; use +float-image-create for floating images.", + }, +} diff --git a/shortcuts/sheets/lark_sheet_write_cells_test.go b/shortcuts/sheets/lark_sheet_write_cells_test.go index 621601e26..a7bdc89fa 100644 --- a/shortcuts/sheets/lark_sheet_write_cells_test.go +++ b/shortcuts/sheets/lark_sheet_write_cells_test.go @@ -167,6 +167,60 @@ func TestCellsSet_RequiresCellsField(t *testing.T) { } } +// TestCellsSetImage_DryRun verifies the 2-step plan (upload + embed) is +// rendered, including the parent_type=sheet_image upload metadata. +func TestCellsSetImage_DryRun(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, CellsSetImage, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1", + "--image", "./README.md", // any existing-shaped path; dry-run skips stat + }) + if len(calls) != 2 { + t.Fatalf("api calls = %d, want 2 (upload + set_cell_range)", len(calls)) + } + upload := calls[0].(map[string]interface{}) + if upload["url"] != "/open-apis/drive/v1/medias/upload_all" { + t.Errorf("upload url = %v", upload["url"]) + } + ubody, _ := upload["body"].(map[string]interface{}) + if ubody["parent_type"] != "sheet_image" { + t.Errorf("parent_type = %v, want sheet_image", ubody["parent_type"]) + } + if ubody["parent_node"] != testToken { + t.Errorf("parent_node = %v, want token", ubody["parent_node"]) + } + + embed := calls[1].(map[string]interface{}) + body, _ := embed["body"].(map[string]interface{}) + input := decodeToolInput(t, body, "set_cell_range") + cells, _ := input["cells"].([]interface{}) + row, _ := cells[0].([]interface{}) + cell, _ := row[0].(map[string]interface{}) + rt, _ := cell["rich_text"].([]interface{}) + if len(rt) != 1 { + t.Fatalf("rich_text len = %d, want 1", len(rt)) + } + item, _ := rt[0].(map[string]interface{}) + if item["type"] != "embed-image" { + t.Errorf("rich_text.type = %v, want embed-image", item["type"]) + } + if item["attachment_name"] != "README.md" { + t.Errorf("attachment_name = %v, want README.md (basename)", item["attachment_name"]) + } +} + +func TestCellsSetImage_RangeMustBeSingleCell(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, CellsSetImage, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1:B2", "--image", "./foo.png", "--dry-run", + }) + if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must be exactly one cell") { + t.Errorf("expected single-cell guard; got=%s|%s|%v", stdout, stderr, err) + } +} + // TestRangeDimensions exercises the A1 parser's corner cases used by // cells-set-style / dropdown-set / dim-resize. func TestRangeDimensions(t *testing.T) { diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index 8d56b5532..fb140bd38 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -20,6 +20,8 @@ func Shortcuts() []common.Shortcut { SheetHide, SheetUnhide, SheetSetTabColor, + WorkbookCreate, + WorkbookExport, // lark_sheet_sheet_structure SheetInfo, @@ -30,6 +32,7 @@ func Shortcuts() []common.Shortcut { DimFreeze, DimGroup, DimUngroup, + DimMove, // lark_sheet_read_data CellsGet, @@ -43,6 +46,7 @@ func Shortcuts() []common.Shortcut { // lark_sheet_write_cells CellsSet, CellsSetStyle, + CellsSetImage, CsvPut, DropdownSet, @@ -79,11 +83,5 @@ func Shortcuts() []common.Shortcut { CellsBatchSetStyle, DropdownUpdate, DropdownDelete, - - // cli-only (legacy OAPI direct calls) - WorkbookCreate, - WorkbookExport, - DimMove, - CellsSetImage, } } From 2acff2b17f7367c4b54101a63074746a1ad085c6 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Mon, 18 May 2026 17:43:00 +0800 Subject: [PATCH 013/114] refactor(sheets): sync shortcut flags with sheet-skill-spec v0.5.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream hoisted a batch of high-frequency scalar fields out of --data into independent flags and renamed several composite-JSON flags to match their semantic content. CLI catches up. Renames (drop-in, same payload semantics): - +cells-replace --replace → --replacement - +cells-set --data → --cells - +workbook-create --data → --values - +batch-update --data → --operations (now a bare array; still accepts the envelope form for back-compat with continue_on_error) Flat-flag hoists out of --style / --data: - +cells-set-style / +cells-batch-set-style --style JSON drops; replaced by 11 flat style 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 one field that's still nested. Both shortcuts share styleFlatFlags() + buildCellStyleFromFlags(). - +cells-batch-set-style also drops the [{ranges, style}] array shape in favor of one --ranges + the same flat style flags applied to all of them. Object CRUD --data → --properties everywhere (chart / pivot / cond-format / filter / filter-view / sparkline / float-image). Per-skill scalar hoists merged into properties via an enhanceCreate/UpdateInput callback: - +pivot-create adds --source (required), --range (and continues to expose --target-sheet-id / --target-position at top level) - +cond-format-{create,update} adds --rule-type (enum) + --ranges (JSON array); merged into properties.rule.type and properties.ranges respectively - +filter-view-{create,update} adds --view-name and --range; both override their properties.* counterparts - +filter-update adds first-class --range (was buried in --data) Float-image is fully hoisted — no --properties flag at all. Ten flat flags (--image-name / --image-token | --image-uri / --position-row / --position-col / --size-width / --size-height / --offset-row / --offset-col / --z-index) compose the properties block. Implemented as its own factory (newFloatImageWriteShortcut) since it diverges from the shared CRUD spec. Tests track every flag renamed and add explicit cases for the new flag combos. go test -race -cover stays at 60.3 %. --- shortcuts/sheets/execute_paths_test.go | 14 +- shortcuts/sheets/helpers.go | 98 +++++ shortcuts/sheets/lark_sheet_batch_update.go | 175 +++++---- .../sheets/lark_sheet_batch_update_test.go | 35 +- shortcuts/sheets/lark_sheet_object_crud.go | 343 +++++++++++++++--- .../sheets/lark_sheet_object_crud_test.go | 94 +++-- shortcuts/sheets/lark_sheet_search_replace.go | 8 +- .../sheets/lark_sheet_search_replace_test.go | 2 +- shortcuts/sheets/lark_sheet_workbook.go | 24 +- shortcuts/sheets/lark_sheet_workbook_test.go | 4 +- shortcuts/sheets/lark_sheet_write_cells.go | 57 ++- .../sheets/lark_sheet_write_cells_test.go | 34 +- .../references/lark-sheets-batch-update.md | 49 ++- .../references/lark-sheets-chart.md | 12 +- .../lark-sheets-conditional-format.md | 16 +- .../references/lark-sheets-filter-view.md | 16 +- .../references/lark-sheets-filter.md | 15 +- .../references/lark-sheets-float-image.md | 45 +-- .../references/lark-sheets-pivot-table.md | 14 +- .../lark-sheets-range-operations.md | 8 +- .../references/lark-sheets-read-data.md | 6 - .../references/lark-sheets-search-replace.md | 6 - .../references/lark-sheets-sheet-structure.md | 6 - .../references/lark-sheets-sparkline.md | 12 +- .../references/lark-sheets-workbook.md | 10 +- .../references/lark-sheets-write-cells.md | 46 ++- 26 files changed, 742 insertions(+), 407 deletions(-) diff --git a/shortcuts/sheets/execute_paths_test.go b/shortcuts/sheets/execute_paths_test.go index 21957527b..c36c2fc41 100644 --- a/shortcuts/sheets/execute_paths_test.go +++ b/shortcuts/sheets/execute_paths_test.go @@ -116,7 +116,7 @@ func TestExecute_CellsSet(t *testing.T) { out, err := runShortcutWithStubs(t, CellsSet, []string{ "--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B1", - "--data", `{"cells":[[{"value":"x"},{"value":"y"}]]}`, + "--cells", `{"cells":[[{"value":"x"},{"value":"y"}]]}`, }, stub) if err != nil { t.Fatalf("execute failed: %v\nout=%s", err, out) @@ -217,7 +217,7 @@ func TestExecute_FilterCreate(t *testing.T) { out, err := runShortcutWithStubs(t, FilterCreate, []string{ "--url", testURL, "--sheet-id", testSheetID, "--range", "A1:F100", - "--data", `{"conditions":[{"col":"B","filter_type":"multiValue","expected":["x"]}]}`, + "--properties", `{"rules":[{"col":"B","filter_type":"multiValue","expected":["x"]}]}`, }, stub) if err != nil { t.Fatalf("execute failed: %v\nout=%s", err, out) @@ -228,8 +228,8 @@ func TestExecute_FilterCreate(t *testing.T) { if props["range"] != "A1:F100" { t.Errorf("properties.range = %v", props["range"]) } - if props["conditions"] == nil { - t.Errorf("conditions missing: %#v", props) + if props["rules"] == nil { + t.Errorf("rules missing: %#v", props) } } @@ -240,7 +240,7 @@ func TestExecute_BatchUpdate_Raw(t *testing.T) { stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`) _, err := runShortcutWithStubs(t, BatchUpdate, []string{ "--url", testURL, - "--data", `{"operations":[{"tool":"set_cell_range","params":{"excel_id":"shtcnTestTOK","range":"A1","cells":[[{"value":1}]]}}]}`, + "--operations", `[{"tool":"set_cell_range","params":{"excel_id":"shtcnTestTOK","range":"A1","cells":[[{"value":1}]]}}]`, "--continue-on-error", "--yes", }, stub) @@ -276,7 +276,7 @@ func TestExecute_WorkbookCreate(t *testing.T) { out, err := runShortcutWithStubs(t, WorkbookCreate, []string{ "--title", "Sales", "--headers", `["Name","Score"]`, - "--data", `[["alice",95]]`, + "--values", `[["alice",95]]`, }, create, fill) if err != nil { t.Fatalf("execute failed: %v\nout=%s", err, out) @@ -324,7 +324,7 @@ func TestExecute_ChartCreate(t *testing.T) { stub := toolOutputStub(testToken, "write", `{"chart_id":"chartNEW"}`) out, err := runShortcutWithStubs(t, ChartCreate, []string{ "--url", testURL, "--sheet-id", testSheetID, - "--data", `{"type":"line"}`, + "--properties", `{"type":"line"}`, }, stub) if err != nil { t.Fatalf("execute failed: %v", err) diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go index 51874f4d6..931f3eb5c 100644 --- a/shortcuts/sheets/helpers.go +++ b/shortcuts/sheets/helpers.go @@ -166,3 +166,101 @@ func requireJSONArray(runtime *common.RuntimeContext, name string) ([]interface{ } return a, nil } + +// ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─ + +var ( + fontStyleEnum = []string{"normal", "italic"} + fontWeightEnum = []string{"normal", "bold"} + fontLineEnum = []string{"none", "underline", "line-through"} + hAlignEnum = []string{"left", "center", "right"} + vAlignEnum = []string{"top", "middle", "bottom"} + wordWrapEnum = []string{"overflow", "auto-wrap", "word-clip"} +) + +// styleFlatFlags returns the 11 flat style flags + --border-styles that both +// +cells-set-style and +cells-batch-set-style expose. Keeping them in one +// place stops the two shortcuts from drifting apart. +func styleFlatFlags() []common.Flag { + return []common.Flag{ + {Name: "background-color", Desc: "hex background color (e.g. #ffffff)"}, + {Name: "font-color", Desc: "hex font color (e.g. #000000)"}, + {Name: "font-size", Type: "int", Desc: "font size in pixels (e.g. 10, 12, 14)"}, + {Name: "font-style", Enum: fontStyleEnum, Desc: "normal / italic"}, + {Name: "font-weight", Enum: fontWeightEnum, Desc: "normal / bold"}, + {Name: "font-line", Enum: fontLineEnum, Desc: "none / underline / line-through"}, + {Name: "horizontal-alignment", Enum: hAlignEnum, Desc: "left / center / right"}, + {Name: "vertical-alignment", Enum: vAlignEnum, Desc: "top / middle / bottom"}, + {Name: "word-wrap", Enum: wordWrapEnum, Desc: "overflow (default) / auto-wrap / word-clip"}, + {Name: "number-format", Desc: "number format string (e.g. @, 0.00, $#,##0.00, mm/dd/yyyy)"}, + {Name: "border-styles", Input: []string{common.File, common.Stdin}, + Desc: "border JSON: { top, bottom, left, right } each = { color, style, weight }"}, + } +} + +// buildCellStyleFromFlags reads the 11 flat style flags and returns the +// cell_styles map expected by set_cell_range. Skips any flag the user +// didn't set so partial styles work. +func buildCellStyleFromFlags(runtime *common.RuntimeContext) map[string]interface{} { + style := map[string]interface{}{} + if v := runtime.Str("background-color"); v != "" { + style["background_color"] = v + } + if v := runtime.Str("font-color"); v != "" { + style["font_color"] = v + } + if runtime.Changed("font-size") && runtime.Int("font-size") > 0 { + style["font_size"] = runtime.Int("font-size") + } + if v := runtime.Str("font-style"); v != "" { + style["font_style"] = v + } + if v := runtime.Str("font-weight"); v != "" { + style["font_weight"] = v + } + if v := runtime.Str("font-line"); v != "" { + style["font_line"] = v + } + if v := runtime.Str("horizontal-alignment"); v != "" { + style["horizontal_alignment"] = v + } + if v := runtime.Str("vertical-alignment"); v != "" { + style["vertical_alignment"] = v + } + if v := runtime.Str("word-wrap"); v != "" { + style["word_wrap"] = v + } + if v := runtime.Str("number-format"); v != "" { + style["number_format"] = v + } + return style +} + +// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/ +// left/right with style sub-objects). Returns nil when the flag is empty. +func borderStylesFromFlag(runtime *common.RuntimeContext) (map[string]interface{}, error) { + if runtime.Str("border-styles") == "" { + return nil, nil + } + v, err := parseJSONFlag(runtime, "border-styles") + if err != nil { + return nil, err + } + m, ok := v.(map[string]interface{}) + if !ok { + return nil, common.FlagErrorf("--border-styles must be a JSON object") + } + return m, nil +} + +// requireAnyStyleFlag ensures at least one style-defining flag (style or +// border) is set — otherwise the request would do nothing. +func requireAnyStyleFlag(runtime *common.RuntimeContext) error { + if len(buildCellStyleFromFlags(runtime)) > 0 { + return nil + } + if runtime.Str("border-styles") != "" { + return nil + } + return common.FlagErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)") +} diff --git a/shortcuts/sheets/lark_sheet_batch_update.go b/shortcuts/sheets/lark_sheet_batch_update.go index 97c991d4b..4d7da18fc 100644 --- a/shortcuts/sheets/lark_sheet_batch_update.go +++ b/shortcuts/sheets/lark_sheet_batch_update.go @@ -41,21 +41,20 @@ var BatchUpdate = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: append(publicTokenFlags(), - common.Flag{Name: "data", Input: []string{common.File, common.Stdin}, Required: true, - Desc: "batch payload JSON: { operations: [{tool, params}, ...] }"}, + common.Flag{Name: "operations", Input: []string{common.File, common.Stdin}, Required: true, + Desc: "operations JSON array: [{tool, params}, ...] (or an envelope object with operations / continue_on_error)"}, common.Flag{Name: "continue-on-error", Type: "bool", Desc: "flip the default strict transaction off; partial success is kept on disk"}, ), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err } - body, err := requireJSONObject(runtime, "data") + ops, err := parseBatchOperationsFlag(runtime) if err != nil { return err } - ops, ok := body["operations"].([]interface{}) - if !ok || len(ops) == 0 { - return common.FlagErrorf("--data.operations must be a non-empty JSON array") + if len(ops) == 0 { + return common.FlagErrorf("--operations must be a non-empty JSON array") } return nil }, @@ -86,45 +85,83 @@ var BatchUpdate = common.Shortcut{ } func batchUpdateRawInput(runtime *common.RuntimeContext, token string) (map[string]interface{}, error) { - body, err := requireJSONObject(runtime, "data") + ops, err := parseBatchOperationsFlag(runtime) if err != nil { return nil, err } - ops, _ := body["operations"].([]interface{}) input := map[string]interface{}{ "excel_id": token, "operations": ops, } if runtime.Bool("continue-on-error") { input["continue_on_error"] = true - } else if v, ok := body["continue_on_error"].(bool); ok && v { - // honor an inline override from --data when the flag is unset - input["continue_on_error"] = true + } else if envelope, _ := parseJSONFlag(runtime, "operations"); envelope != nil { + // Honor an inline override when --operations is an envelope object + // rather than a bare operations array. + if m, ok := envelope.(map[string]interface{}); ok { + if v, ok := m["continue_on_error"].(bool); ok && v { + input["continue_on_error"] = true + } + } } return input, nil } -// CellsBatchSetStyle stamps one style block across many ranges atomically. -// --data is an array of {ranges: [...], style: {...}} entries; CLI flattens -// each (entry × range) pair into a set_cell_range operation in the batch. +// parseBatchOperationsFlag accepts --operations as either a JSON array (the +// operations list directly) or an envelope object { operations, continue_on_error } +// for back-compat with the legacy --data shape. Returns the operations array. +func parseBatchOperationsFlag(runtime *common.RuntimeContext) ([]interface{}, error) { + v, err := parseJSONFlag(runtime, "operations") + if err != nil { + return nil, err + } + if v == nil { + return nil, common.FlagErrorf("--operations is required") + } + if arr, ok := v.([]interface{}); ok { + return arr, nil + } + if m, ok := v.(map[string]interface{}); ok { + if ops, ok := m["operations"].([]interface{}); ok { + return ops, nil + } + } + return nil, common.FlagErrorf("--operations must be a JSON array (or { operations: [...] } envelope)") +} + +// CellsBatchSetStyle stamps one style block across many sheet-prefixed +// ranges atomically. --ranges is a JSON array of sheet-prefixed A1 +// strings; the style is composed from the same flat flags as +// +cells-set-style. CLI fans each range into a separate set_cell_range +// op inside one batch_update. var CellsBatchSetStyle = common.Shortcut{ Service: "sheets", Command: "+cells-batch-set-style", - Description: "Apply styles to many sheet-prefixed ranges in one atomic batch.", + Description: "Apply one style block to many sheet-prefixed ranges in one atomic batch.", Risk: "write", Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicTokenFlags(), - common.Flag{Name: "data", Input: []string{common.File, common.Stdin}, Required: true, - Desc: "JSON array: [{ranges: [\"sheet1!A1:B2\", ...], style: {...}}, ...] (each range must carry a sheet prefix)"}, + Flags: append( + append(publicTokenFlags(), + common.Flag{Name: "ranges", Input: []string{common.File, common.Stdin}, Required: true, + Desc: "JSON array of sheet-prefixed A1 ranges (e.g. [\"sheet1!A1:B2\", \"sheet1!D1:E2\"])"}), + styleFlatFlags()..., ), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err } - _, err := batchStyleEntries(runtime) - return err + if _, err := validateDropdownRanges(runtime); err != nil { + return err + } + if err := requireAnyStyleFlag(runtime); err != nil { + return err + } + if _, err := borderStylesFromFlag(runtime); err != nil { + return err + } + return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) @@ -149,83 +186,43 @@ var CellsBatchSetStyle = common.Shortcut{ }, } -// batchStyleEntries validates --data is the expected array shape. -func batchStyleEntries(runtime *common.RuntimeContext) ([]map[string]interface{}, error) { - raw, err := requireJSONArray(runtime, "data") +func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[string]interface{}, error) { + ranges, err := validateDropdownRanges(runtime) if err != nil { return nil, err } - if len(raw) == 0 { - return nil, common.FlagErrorf("--data must contain at least one entry") - } - out := make([]map[string]interface{}, 0, len(raw)) - for i, v := range raw { - entry, ok := v.(map[string]interface{}) - if !ok { - return nil, common.FlagErrorf("--data[%d] must be an object", i) - } - rangesRaw, ok := entry["ranges"].([]interface{}) - if !ok || len(rangesRaw) == 0 { - return nil, common.FlagErrorf("--data[%d].ranges must be a non-empty array", i) - } - for j, r := range rangesRaw { - s, ok := r.(string) - if !ok || !strings.Contains(s, "!") { - return nil, common.FlagErrorf("--data[%d].ranges[%d] must be a sheet-prefixed string", i, j) - } - } - if _, ok := entry["style"].(map[string]interface{}); !ok { - return nil, common.FlagErrorf("--data[%d].style must be a JSON object", i) - } - out = append(out, entry) - } - return out, nil -} - -func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[string]interface{}, error) { - entries, err := batchStyleEntries(runtime) + cellStyle := buildCellStyleFromFlags(runtime) + borderStyles, err := borderStylesFromFlag(runtime) if err != nil { return nil, err } + prototype := map[string]interface{}{} + if len(cellStyle) > 0 { + prototype["cell_styles"] = cellStyle + } + if borderStyles != nil { + prototype["border_styles"] = borderStyles + } var ops []interface{} - for _, entry := range entries { - style := entry["style"].(map[string]interface{}) - // Split border_styles out into its sibling field per set_cell_range's contract. - cellStyle := map[string]interface{}{} - var borderStyles interface{} - for k, v := range style { - if k == "border_styles" { - borderStyles = v - continue - } - cellStyle[k] = v - } - ranges, _ := entry["ranges"].([]interface{}) - for _, r := range ranges { - rng := r.(string) - sheet, sub, err := splitSheetPrefixedRange(rng) - if err != nil { - return nil, err - } - rows, cols, err := rangeDimensions(sub) - if err != nil { - return nil, common.FlagErrorf("range %q: %v", rng, err) - } - proto := map[string]interface{}{"cell_styles": cellStyle} - if borderStyles != nil { - proto["border_styles"] = borderStyles - } - cells := fillCellsMatrix(rows, cols, proto) - ops = append(ops, map[string]interface{}{ - "tool": "set_cell_range", - "params": map[string]interface{}{ - "excel_id": token, - "sheet_name": sheet, - "range": sub, - "cells": cells, - }, - }) + for _, rng := range ranges { + sheet, sub, err := splitSheetPrefixedRange(rng) + if err != nil { + return nil, err + } + rows, cols, err := rangeDimensions(sub) + if err != nil { + return nil, common.FlagErrorf("range %q: %v", rng, err) } + cells := fillCellsMatrix(rows, cols, prototype) + ops = append(ops, map[string]interface{}{ + "tool": "set_cell_range", + "params": map[string]interface{}{ + "excel_id": token, + "sheet_name": sheet, + "range": sub, + "cells": cells, + }, + }) } return map[string]interface{}{ "excel_id": token, diff --git a/shortcuts/sheets/lark_sheet_batch_update_test.go b/shortcuts/sheets/lark_sheet_batch_update_test.go index 2ae045b09..c74e3270f 100644 --- a/shortcuts/sheets/lark_sheet_batch_update_test.go +++ b/shortcuts/sheets/lark_sheet_batch_update_test.go @@ -17,7 +17,7 @@ func TestBatchUpdate_RawPassthrough(t *testing.T) { body := parseDryRunBody(t, BatchUpdate, []string{ "--url", testURL, - "--data", `{"operations":[{"tool":"set_cell_range","params":{"excel_id":"shtcnTOK","sheet_id":"sh1","range":"A1","cells":[[{"value":42}]]}}]}`, + "--operations", `[{"tool":"set_cell_range","params":{"excel_id":"shtcnTOK","sheet_id":"sh1","range":"A1","cells":[[{"value":42}]]}}]`, "--continue-on-error", "--yes", }) @@ -35,27 +35,28 @@ func TestBatchUpdate_HighRiskWriteRequiresYes(t *testing.T) { t.Parallel() stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{ "--url", testURL, - "--data", `{"operations":[{"tool":"set_cell_range","params":{}}]}`, + "--operations", `[{"tool":"set_cell_range","params":{}}]`, }) if err == nil { t.Fatalf("expected confirmation_required; stdout=%s stderr=%s", stdout, stderr) } } -// TestCellsBatchSetStyle_FansOutOps verifies 2 entries × multiple ranges -// produce one set_cell_range op per (entry, range). +// TestCellsBatchSetStyle_FansOutOps verifies multiple ranges produce one +// set_cell_range op each, sharing the same style flags. func TestCellsBatchSetStyle_FansOutOps(t *testing.T) { t.Parallel() body := parseDryRunBody(t, CellsBatchSetStyle, []string{ "--url", testURL, - "--data", `[{"ranges":["sheet1!A1:B2","sheet1!D1:E2"],"style":{"font":{"bold":true}}},{"ranges":["sheet1!A5:A6"],"style":{"backColor":"#ff0"}}]`, + "--ranges", `["sheet1!A1:B2","sheet1!D1:E2","sheet1!A5:A6"]`, + "--font-weight", "bold", + "--background-color", "#ffff00", }) input := decodeToolInput(t, body, "batch_update") ops, _ := input["operations"].([]interface{}) if len(ops) != 3 { - t.Fatalf("operations length = %d, want 3 (2 ranges × entry1 + 1 range × entry2)", len(ops)) + t.Fatalf("operations length = %d, want 3 (one per range)", len(ops)) } - // Every op should target set_cell_range with sheet_name carrying the prefix. for i, raw := range ops { op, _ := raw.(map[string]interface{}) if op["tool"] != "set_cell_range" { @@ -65,6 +66,13 @@ func TestCellsBatchSetStyle_FansOutOps(t *testing.T) { if params["sheet_name"] != "sheet1" { t.Errorf("op[%d].sheet_name = %v, want sheet1", i, params["sheet_name"]) } + cells, _ := params["cells"].([]interface{}) + row, _ := cells[0].([]interface{}) + cell, _ := row[0].(map[string]interface{}) + style, _ := cell["cell_styles"].(map[string]interface{}) + if style["font_weight"] != "bold" || style["background_color"] != "#ffff00" { + t.Errorf("op[%d] cell_styles wrong: %#v", i, style) + } } } @@ -157,13 +165,24 @@ func TestBatchUpdate_ValidationGuards(t *testing.T) { // batch-update with empty operations stdout, stderr, err = runShortcutCapturingErr(t, BatchUpdate, []string{ "--url", testURL, - "--data", `{"operations":[]}`, + "--operations", `[]`, "--yes", "--dry-run", }) if err == nil || !strings.Contains(stdout+stderr+err.Error(), "non-empty JSON array") { t.Errorf("expected empty-operations guard; got=%s|%s|%v", stdout, stderr, err) } + + // dropdown-update with non-array --options (object instead) → array guard + stdout, stderr, err = runShortcutCapturingErr(t, DropdownUpdate, []string{ + "--url", testURL, + "--ranges", `["sheet1!A1:A2"]`, + "--options", `{"not":"array"}`, + "--dry-run", + }) + if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must be a JSON array") { + t.Errorf("expected JSON array guard; got=%s|%s|%v", stdout, stderr, err) + } } // TestSplitSheetPrefixedRange exercises the helper directly. diff --git a/shortcuts/sheets/lark_sheet_object_crud.go b/shortcuts/sheets/lark_sheet_object_crud.go index a0c94222b..d87eba32d 100644 --- a/shortcuts/sheets/lark_sheet_object_crud.go +++ b/shortcuts/sheets/lark_sheet_object_crud.go @@ -37,18 +37,22 @@ type objectCRUDSpec struct { toolName string // e.g. "manage_chart_object" idFlag string // e.g. "chart-id" idField string // e.g. "chart_id" - createDataDesc string // help text for --data on create - updateDataDesc string // help text for --data on update + createDataDesc string // help text for --properties on create + updateDataDesc string // help text for --properties on update createExtraFlags []common.Flag - // createExtraInput, when set, mutates the tool input after the standard - // fields are written. Used by pivot to inject --target-sheet-id / - // --target-position alongside properties. - createExtraInput func(rt *common.RuntimeContext, input map[string]interface{}) + updateExtraFlags []common.Flag + // enhanceCreateInput / enhanceUpdateInput, when set, mutate the tool + // input after the standard fields are written. Used to inject + // shortcut-specific flat flags into the input (typically into the + // properties map). The callback is responsible for navigating to the + // right nesting level. + enhanceCreateInput func(rt *common.RuntimeContext, input map[string]interface{}) + enhanceUpdateInput func(rt *common.RuntimeContext, input map[string]interface{}) } func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { flags := append(publicSheetFlags(), - common.Flag{Name: "data", Input: []string{common.File, common.Stdin}, Required: true, Desc: spec.createDataDesc}, + common.Flag{Name: "properties", Input: []string{common.File, common.Stdin}, Required: true, Desc: spec.createDataDesc}, ) flags = append(flags, spec.createExtraFlags...) return common.Shortcut{ @@ -67,7 +71,7 @@ func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { if _, _, err := resolveSheetSelector(runtime); err != nil { return err } - _, err := requireJSONObject(runtime, "data") + _, err := requireJSONObject(runtime, "properties") return err }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -100,7 +104,7 @@ func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { } func objectCreateInput(runtime *common.RuntimeContext, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) { - props, err := requireJSONObject(runtime, "data") + props, err := requireJSONObject(runtime, "properties") if err != nil { return nil, err } @@ -110,8 +114,8 @@ func objectCreateInput(runtime *common.RuntimeContext, token, sheetID, sheetName "properties": props, } sheetSelectorForToolInput(input, sheetID, sheetName) - if spec.createExtraInput != nil { - spec.createExtraInput(runtime, input) + if spec.enhanceCreateInput != nil { + spec.enhanceCreateInput(runtime, input) } return input, nil } @@ -125,9 +129,10 @@ func newObjectUpdateShortcut(spec objectCRUDSpec) common.Shortcut { }) } flags = append(flags, common.Flag{ - Name: "data", Input: []string{common.File, common.Stdin}, Required: true, + Name: "properties", Input: []string{common.File, common.Stdin}, Required: true, Desc: spec.updateDataDesc, }) + flags = append(flags, spec.updateExtraFlags...) return common.Shortcut{ Service: "sheets", Command: spec.commandPrefix + "-update", @@ -147,7 +152,7 @@ func newObjectUpdateShortcut(spec objectCRUDSpec) common.Shortcut { if spec.idFlag != "" && strings.TrimSpace(runtime.Str(spec.idFlag)) == "" { return common.FlagErrorf("--%s is required", spec.idFlag) } - _, err := requireJSONObject(runtime, "data") + _, err := requireJSONObject(runtime, "properties") return err }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -180,7 +185,7 @@ func newObjectUpdateShortcut(spec objectCRUDSpec) common.Shortcut { } func objectUpdateInput(runtime *common.RuntimeContext, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) { - props, err := requireJSONObject(runtime, "data") + props, err := requireJSONObject(runtime, "properties") if err != nil { return nil, err } @@ -193,6 +198,9 @@ func objectUpdateInput(runtime *common.RuntimeContext, token, sheetID, sheetName if spec.idFlag != "" { input[spec.idField] = strings.TrimSpace(runtime.Str(spec.idFlag)) } + if spec.enhanceUpdateInput != nil { + spec.enhanceUpdateInput(runtime, input) + } return input, nil } @@ -276,25 +284,38 @@ var ChartCreate = newObjectCreateShortcut(chartSpec) var ChartUpdate = newObjectUpdateShortcut(chartSpec) var ChartDelete = newObjectDeleteShortcut(chartSpec) -// pivot — adds --target-sheet-id / --target-position on create +// pivot — create exposes --target-sheet-id / --target-position (top-level +// of the tool input) plus --source / --range hoisted from properties. var pivotSpec = objectCRUDSpec{ commandPrefix: "+pivot", toolName: "manage_pivot_table_object", idFlag: "pivot-table-id", idField: "pivot_table_id", - createDataDesc: "pivot table properties JSON: { data_range, rows, columns, values, filters, show_row_grand_total, show_col_grand_total }", + createDataDesc: "pivot table properties JSON: { rows, columns, values, filters, show_row_grand_total, ... }; --source / --range cover the common scalar fields", updateDataDesc: "full or partial pivot properties JSON (`+pivot-list --pivot-table-id ` first, then patch)", createExtraFlags: []common.Flag{ {Name: "target-sheet-id", Desc: "destination sheet id for the pivot table; omit to auto-create a fresh sheet (recommended)"}, {Name: "target-position", Default: "A1", Desc: "destination start cell, default A1"}, + {Name: "source", Required: true, Desc: "pivot source range, e.g. Sheet1!A1:D100 (--source overrides any properties.source)"}, + {Name: "range", Desc: "destination top-left A1 cell, e.g. F1 (--range overrides any properties.range)"}, }, - createExtraInput: func(rt *common.RuntimeContext, input map[string]interface{}) { + enhanceCreateInput: func(rt *common.RuntimeContext, input map[string]interface{}) { if v := strings.TrimSpace(rt.Str("target-sheet-id")); v != "" { input["target_sheet_id"] = v } if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" { input["target_position"] = v } + props, _ := input["properties"].(map[string]interface{}) + if props == nil { + return + } + if v := strings.TrimSpace(rt.Str("source")); v != "" { + props["source"] = v + } + if v := strings.TrimSpace(rt.Str("range")); v != "" { + props["range"] = v + } }, } var PivotCreate = newObjectCreateShortcut(pivotSpec) @@ -302,14 +323,50 @@ var PivotUpdate = newObjectUpdateShortcut(pivotSpec) var PivotDelete = newObjectDeleteShortcut(pivotSpec) // conditional format — CLI surface uses --rule-id (short), wired to the -// tool's conditional_format_id on the wire. +// tool's conditional_format_id on the wire. --rule-type and --ranges are +// hoisted out of properties (both required, set on every CRUD write). +var condFormatRuleTypeEnum = []string{ + "cellValue", "formula", "duplicate", "unique", + "topBottom", "aboveBelowAverage", "dataBar", "colorScale", + "iconSet", "textContains", "dateOccurring", "blankCell", "errorCell", +} +var condFormatExtraFlags = []common.Flag{ + {Name: "rule-type", Required: true, Enum: condFormatRuleTypeEnum, + Desc: "rule type enum (cellValue / formula / duplicate / ...); merged into properties.rule.type"}, + {Name: "ranges", Input: []string{common.File, common.Stdin}, Required: true, + Desc: "A1 ranges JSON array (e.g. [\"A1:A100\",\"C2:C50\"]); merged into properties.ranges"}, +} +var condFormatEnhance = func(rt *common.RuntimeContext, input map[string]interface{}) { + props, _ := input["properties"].(map[string]interface{}) + if props == nil { + return + } + if ruleType := strings.TrimSpace(rt.Str("rule-type")); ruleType != "" { + rule, _ := props["rule"].(map[string]interface{}) + if rule == nil { + rule = map[string]interface{}{} + } + rule["type"] = ruleType + props["rule"] = rule + } + if rt.Str("ranges") != "" { + if arr, err := requireJSONArray(rt, "ranges"); err == nil { + props["ranges"] = arr + } + } +} + var condFormatSpec = objectCRUDSpec{ - commandPrefix: "+cond-format", - toolName: "manage_conditional_format_object", - idFlag: "rule-id", - idField: "conditional_format_id", - createDataDesc: "rule JSON: { range, rule: { type: cell_value|duplicate|data_bar|color_scale|rank|formula, ... } }", - updateDataDesc: "full or partial rule JSON (`+cond-format-list --rule-id ` first, then patch)", + commandPrefix: "+cond-format", + toolName: "manage_conditional_format_object", + idFlag: "rule-id", + idField: "conditional_format_id", + createDataDesc: "rule JSON: { rule: { operator, value, style, ... }, ... }; --rule-type and --ranges cover the common scalar fields", + updateDataDesc: "full or partial rule JSON (`+cond-format-list --rule-id ` first, then patch); --rule-type and --ranges still required", + createExtraFlags: condFormatExtraFlags, + updateExtraFlags: condFormatExtraFlags, + enhanceCreateInput: condFormatEnhance, + enhanceUpdateInput: condFormatEnhance, } var CondFormatCreate = newObjectCreateShortcut(condFormatSpec) var CondFormatUpdate = newObjectUpdateShortcut(condFormatSpec) @@ -328,28 +385,199 @@ var SparklineCreate = newObjectCreateShortcut(sparklineSpec) var SparklineUpdate = newObjectUpdateShortcut(sparklineSpec) var SparklineDelete = newObjectDeleteShortcut(sparklineSpec) -// float image -var floatImageSpec = objectCRUDSpec{ - commandPrefix: "+float-image", - toolName: "manage_float_image_object", - idFlag: "float-image-id", - idField: "float_image_id", - createDataDesc: "float image JSON: { image_uri, image_name, position:{row,col}, size:{width,height}, offset:{x,y} } — image_uri must be pre-uploaded", - updateDataDesc: "full or partial float image JSON (`+float-image-list --float-image-id ` first, then patch)", +// float image — fully hoisted to 10 flat flags. No --properties flag; +// the tool's properties is composed entirely from the position / size / +// offset / image_token / image_uri / z_index flat flags. + +var floatImageFlatFlags = []common.Flag{ + {Name: "image-name", Required: true, Desc: "image file name with extension (e.g. logo.png)"}, + {Name: "image-token", Desc: "image file_token (XOR --image-uri); commonly returned by +float-image-list"}, + {Name: "image-uri", Desc: "image reference_id (XOR --image-token); upstream-supplied like \"<|image|>:abcdef\""}, + {Name: "position-row", Type: "int", Required: true, Desc: "top-left row index (0-based)"}, + {Name: "position-col", Required: true, Desc: "top-left column letter (e.g. A, B)"}, + {Name: "size-width", Type: "int", Required: true, Desc: "image width in pixels"}, + {Name: "size-height", Type: "int", Required: true, Desc: "image height in pixels"}, + {Name: "offset-row", Type: "int", Desc: "in-cell row offset in pixels (optional)"}, + {Name: "offset-col", Type: "int", Desc: "in-cell column offset in pixels (optional)"}, + {Name: "z-index", Type: "int", Desc: "z-order layer for overlapping images (optional)"}, } -var FloatImageCreate = newObjectCreateShortcut(floatImageSpec) -var FloatImageUpdate = newObjectUpdateShortcut(floatImageSpec) -var FloatImageDelete = newObjectDeleteShortcut(floatImageSpec) + +// floatImageProperties assembles the tool's properties object from the +// 10 flat flags. Caller is responsible for marking required flags via +// cobra Required:true; this function only enforces the image_token XOR +// image_uri pair (one must be set). +func floatImageProperties(runtime *common.RuntimeContext) (map[string]interface{}, error) { + token := strings.TrimSpace(runtime.Str("image-token")) + uri := strings.TrimSpace(runtime.Str("image-uri")) + if token == "" && uri == "" { + return nil, common.FlagErrorf("either --image-token or --image-uri is required") + } + if token != "" && uri != "" { + return nil, common.FlagErrorf("--image-token and --image-uri are mutually exclusive") + } + props := map[string]interface{}{ + "image_name": strings.TrimSpace(runtime.Str("image-name")), + "position": map[string]interface{}{ + "row": runtime.Int("position-row"), + "col": strings.TrimSpace(runtime.Str("position-col")), + }, + "size": map[string]interface{}{ + "width": runtime.Int("size-width"), + "height": runtime.Int("size-height"), + }, + } + if token != "" { + props["image_token"] = token + } else { + props["image_uri"] = uri + } + if runtime.Changed("offset-row") || runtime.Changed("offset-col") { + offset := map[string]interface{}{} + if runtime.Changed("offset-row") { + offset["row_offset"] = runtime.Int("offset-row") + } + if runtime.Changed("offset-col") { + offset["col_offset"] = runtime.Int("offset-col") + } + props["offset"] = offset + } + if runtime.Changed("z-index") { + props["z_index"] = runtime.Int("z-index") + } + return props, nil +} + +func newFloatImageWriteShortcut(command, description, op string, withIDFlag, isHighRisk bool) common.Shortcut { + risk := "write" + if isHighRisk { + risk = "high-risk-write" + } + flags := publicSheetFlags() + if withIDFlag { + flags = append(flags, common.Flag{Name: "float-image-id", Required: true, Desc: "target image reference_id"}) + } + flags = append(flags, floatImageFlatFlags...) + return common.Shortcut{ + Service: "sheets", + Command: command, + Description: description, + Risk: risk, + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flags, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if withIDFlag && strings.TrimSpace(runtime.Str("float-image-id")) == "" { + return common.FlagErrorf("--float-image-id is required") + } + _, err := floatImageProperties(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := floatImageWriteInput(runtime, token, sheetID, sheetName, op, withIDFlag) + return invokeToolDryRun(token, ToolKindWrite, "manage_float_image_object", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := floatImageWriteInput(runtime, token, sheetID, sheetName, op, withIDFlag) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_float_image_object", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + } +} + +func floatImageWriteInput(runtime *common.RuntimeContext, token, sheetID, sheetName, op string, withIDFlag bool) (map[string]interface{}, error) { + props, err := floatImageProperties(runtime) + if err != nil { + return nil, err + } + input := map[string]interface{}{ + "excel_id": token, + "operation": op, + "properties": props, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if withIDFlag { + input["float_image_id"] = strings.TrimSpace(runtime.Str("float-image-id")) + } + return input, nil +} + +var FloatImageCreate = newFloatImageWriteShortcut( + "+float-image-create", + "Create a floating image (referenced by --image-token or --image-uri).", + "create", false, false, +) +var FloatImageUpdate = newFloatImageWriteShortcut( + "+float-image-update", + "Update an existing floating image (target by --float-image-id; provide the full set of flat flags).", + "update", true, false, +) + +// FloatImageDelete uses the standard CRUD delete factory since it only +// needs --float-image-id + --yes. +var floatImageDeleteSpec = objectCRUDSpec{ + commandPrefix: "+float-image", + toolName: "manage_float_image_object", + idFlag: "float-image-id", + idField: "float_image_id", +} +var FloatImageDelete = newObjectDeleteShortcut(floatImageDeleteSpec) // filter view — cli_status: cli-only but the tool is in mcp-tools.json so // it dispatches via the same One-OpenAPI endpoint as every other shortcut. +// --view-name and --range are hoisted out of properties (optional on both +// create and update; they always win over properties.{view_name, range}). +var filterViewExtraFlags = []common.Flag{ + {Name: "range", Desc: "filter view range (A1 covering the header, e.g. A1:F1000); overrides properties.range"}, + {Name: "view-name", Desc: "view title; create omits → server-generated, update omits → keep current. Overrides properties.view_name"}, +} +var filterViewEnhance = func(rt *common.RuntimeContext, input map[string]interface{}) { + props, _ := input["properties"].(map[string]interface{}) + if props == nil { + return + } + if v := strings.TrimSpace(rt.Str("range")); v != "" { + props["range"] = v + } + if v := strings.TrimSpace(rt.Str("view-name")); v != "" { + props["view_name"] = v + } +} + var filterViewSpec = objectCRUDSpec{ - commandPrefix: "+filter-view", - toolName: "manage_filter_view_object", - idFlag: "view-id", - idField: "view_id", - createDataDesc: "filter view JSON: { view_name, range (required, covers header), rules: [...] }", - updateDataDesc: "partial update JSON: any of { view_name, range, rules }; `+filter-view-list --view-id ` first", + commandPrefix: "+filter-view", + toolName: "manage_filter_view_object", + idFlag: "view-id", + idField: "view_id", + createDataDesc: "filter view JSON: { rules?: [...] , filtered_columns?: [...] }; --range / --view-name cover the scalar fields", + updateDataDesc: "partial update JSON: any of { rules, filtered_columns }; `+filter-view-list --view-id ` first", + createExtraFlags: filterViewExtraFlags, + updateExtraFlags: filterViewExtraFlags, + enhanceCreateInput: filterViewEnhance, + enhanceUpdateInput: filterViewEnhance, } var FilterViewCreate = newObjectCreateShortcut(filterViewSpec) var FilterViewUpdate = newObjectUpdateShortcut(filterViewSpec) @@ -374,8 +602,8 @@ var FilterCreate = common.Shortcut{ HasFormat: true, Flags: append(publicSheetFlags(), common.Flag{Name: "range", Required: true, Desc: "filter range including the header row (e.g. A1:F1000)"}, - common.Flag{Name: "data", Input: []string{common.File, common.Stdin}, - Desc: "optional conditions JSON: { conditions: [{col, filter_type, expected, ...}] }; empty filter when omitted"}, + common.Flag{Name: "properties", Input: []string{common.File, common.Stdin}, + Desc: "optional rules JSON: { rules: [...], filtered_columns?: [...] }; empty filter when omitted"}, ), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { @@ -387,8 +615,8 @@ var FilterCreate = common.Shortcut{ if strings.TrimSpace(runtime.Str("range")) == "" { return common.FlagErrorf("--range is required") } - if runtime.Str("data") != "" { - if _, err := requireJSONObject(runtime, "data"); err != nil { + if runtime.Str("properties") != "" { + if _, err := requireJSONObject(runtime, "properties"); err != nil { return err } } @@ -426,8 +654,8 @@ func filterCreateInput(runtime *common.RuntimeContext, token, sheetID, sheetName props := map[string]interface{}{ "range": strings.TrimSpace(runtime.Str("range")), } - if runtime.Str("data") != "" { - extra, err := requireJSONObject(runtime, "data") + if runtime.Str("properties") != "" { + extra, err := requireJSONObject(runtime, "properties") if err != nil { return nil, err } @@ -447,19 +675,21 @@ func filterCreateInput(runtime *common.RuntimeContext, token, sheetID, sheetName return input, nil } -// FilterUpdate patches the sheet-level filter — change range or -// add/replace conditions. filter_id is implicit (sheet-scoped). +// FilterUpdate patches the sheet-level filter. --properties carries the +// rules; --range is first-class and overrides any properties.range. +// filter_id is implicit (sheet-scoped). var FilterUpdate = common.Shortcut{ Service: "sheets", Command: "+filter-update", - Description: "Update the sheet-level filter (patch range or conditions).", + Description: "Update the sheet-level filter (overwrite rules + range).", Risk: "write", Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: append(publicSheetFlags(), - common.Flag{Name: "data", Input: []string{common.File, common.Stdin}, Required: true, - Desc: "patch JSON: { range?, conditions?: [...] } — read with +filter-list first"}, + common.Flag{Name: "properties", Input: []string{common.File, common.Stdin}, Required: true, + Desc: "patch JSON: { rules: [...], filtered_columns?: [...] } — read with +filter-list first"}, + common.Flag{Name: "range", Required: true, Desc: "filter range A1 (e.g. A1:F1000); overrides properties.range"}, ), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { @@ -468,7 +698,10 @@ var FilterUpdate = common.Shortcut{ if _, _, err := resolveSheetSelector(runtime); err != nil { return err } - _, err := requireJSONObject(runtime, "data") + if strings.TrimSpace(runtime.Str("range")) == "" { + return common.FlagErrorf("--range is required") + } + _, err := requireJSONObject(runtime, "properties") return err }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -500,10 +733,12 @@ var FilterUpdate = common.Shortcut{ } func filterUpdateInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) (map[string]interface{}, error) { - props, err := requireJSONObject(runtime, "data") + props, err := requireJSONObject(runtime, "properties") if err != nil { return nil, err } + // --range wins over any properties.range + props["range"] = strings.TrimSpace(runtime.Str("range")) input := map[string]interface{}{ "excel_id": token, "operation": "update", diff --git a/shortcuts/sheets/lark_sheet_object_crud_test.go b/shortcuts/sheets/lark_sheet_object_crud_test.go index 0cbfe721c..933da4a38 100644 --- a/shortcuts/sheets/lark_sheet_object_crud_test.go +++ b/shortcuts/sheets/lark_sheet_object_crud_test.go @@ -29,7 +29,7 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { { name: "+chart-create", sc: ChartCreate, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--data", `{"type":"line"}`}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--properties", `{"type":"line"}`}, toolName: "manage_chart_object", wantInput: map[string]interface{}{ "excel_id": testToken, @@ -41,7 +41,7 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { { name: "+chart-update", sc: ChartUpdate, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chartXYZ", "--data", `{"type":"bar"}`}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chartXYZ", "--properties", `{"type":"bar"}`}, toolName: "manage_chart_object", wantInput: map[string]interface{}{ "excel_id": testToken, @@ -51,18 +51,30 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { "properties": map[string]interface{}{"type": "bar"}, }, }, - // pivot — has extra create flags + // pivot — has extra create flags incl. required --source { - name: "+pivot-create with target flags", - sc: PivotCreate, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--data", `{"data_range":"Sheet1!A1:F1000"}`, "--target-sheet-id", "sh2", "--target-position", "B5"}, + name: "+pivot-create with target / source / range flags", + sc: PivotCreate, + args: []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--properties", `{"rows":[{"field":"A"}]}`, + "--source", "Sheet1!A1:F1000", + "--range", "F1", + "--target-sheet-id", "sh2", + "--target-position", "B5", + }, toolName: "manage_pivot_table_object", wantInput: map[string]interface{}{ - "excel_id": testToken, - "sheet_id": testSheetID, - "operation": "create", - "target_sheet_id": "sh2", - "target_position": "B5", + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "create", + "target_sheet_id": "sh2", + "target_position": "B5", + "properties": map[string]interface{}{ + "rows": []interface{}{map[string]interface{}{"field": "A"}}, + "source": "Sheet1!A1:F1000", + "range": "F1", + }, }, }, { @@ -77,23 +89,32 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { "pivot_table_id": "ptA", }, }, - // cond-format — --rule-id rename + // cond-format — --rule-id rename + --rule-type / --ranges hoist { - name: "+cond-format-update id rename", - sc: CondFormatUpdate, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--rule-id", "ruleA", "--data", `{"rule":{"type":"cell_value"}}`}, + name: "+cond-format-update id rename + rule-type/ranges", + sc: CondFormatUpdate, + args: []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--rule-id", "ruleA", + "--properties", `{"rule":{"operator":"greater_than","value":100}}`, + "--rule-type", "cellValue", + "--ranges", `["A1:A100"]`, + }, toolName: "manage_conditional_format_object", wantInput: map[string]interface{}{ "excel_id": testToken, "sheet_id": testSheetID, "operation": "update", "conditional_format_id": "ruleA", - "properties": map[string]interface{}{"rule": map[string]interface{}{"type": "cell_value"}}, + "properties": map[string]interface{}{ + "rule": map[string]interface{}{"operator": "greater_than", "value": float64(100), "type": "cellValue"}, + "ranges": []interface{}{"A1:A100"}, + }, }, }, // filter — special, no id flag { - name: "+filter-create without --data sends properties.range only", + name: "+filter-create without --properties sends properties.range only", sc: FilterCreate, args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:F1000"}, toolName: "manage_filter_object", @@ -105,14 +126,14 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { }, }, { - name: "+filter-create with --data merges conditions", + name: "+filter-create with --properties merges rules", sc: FilterCreate, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:F1000", "--data", `{"conditions":[{"col":"B"}]}`}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:F1000", "--properties", `{"rules":[{"col":"B"}]}`}, toolName: "manage_filter_object", wantInput: map[string]interface{}{ "properties": map[string]interface{}{ - "range": "A1:F1000", - "conditions": []interface{}{map[string]interface{}{"col": "B"}}, + "range": "A1:F1000", + "rules": []interface{}{map[string]interface{}{"col": "B"}}, }, }, }, @@ -131,7 +152,7 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { { name: "+filter-view-create", sc: FilterViewCreate, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--data", `{"view_name":"v1","range":"A1:Z100"}`}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--properties", `{"view_name":"v1","range":"A1:Z100"}`}, toolName: "manage_filter_view_object", wantInput: map[string]interface{}{ "excel_id": testToken, @@ -143,7 +164,7 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { { name: "+filter-view-update with --view-id", sc: FilterViewUpdate, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--view-id", "vABC", "--data", `{"view_name":"renamed"}`}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--view-id", "vABC", "--properties", `{"view_name":"renamed"}`}, toolName: "manage_filter_view_object", wantInput: map[string]interface{}{ "view_id": "vABC", @@ -154,7 +175,7 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { { name: "+sparkline-update --group-id → group_id", sc: SparklineUpdate, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--group-id", "grpA", "--data", `{"type":"line"}`}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--group-id", "grpA", "--properties", `{"type":"line"}`}, toolName: "manage_sparkline_object", wantInput: map[string]interface{}{ "group_id": "grpA", @@ -162,15 +183,28 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { "properties": map[string]interface{}{"type": "line"}, }, }, - // float-image + // float-image — fully hoisted to flat flags { - name: "+float-image-create", - sc: FloatImageCreate, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--data", `{"image_uri":"u","image_name":"x.png"}`}, + name: "+float-image-create with image-token + position/size", + sc: FloatImageCreate, + args: []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--image-name", "logo.png", + "--image-token", "tok_xyz", + "--position-row", "2", "--position-col", "D", + "--size-width", "300", "--size-height", "200", + }, toolName: "manage_float_image_object", wantInput: map[string]interface{}{ - "operation": "create", - "properties": map[string]interface{}{"image_uri": "u", "image_name": "x.png"}, + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "create", + "properties": map[string]interface{}{ + "image_name": "logo.png", + "image_token": "tok_xyz", + "position": map[string]interface{}{"row": float64(2), "col": "D"}, + "size": map[string]interface{}{"width": float64(300), "height": float64(200)}, + }, }, }, } diff --git a/shortcuts/sheets/lark_sheet_search_replace.go b/shortcuts/sheets/lark_sheet_search_replace.go index a332b207f..a6d94db98 100644 --- a/shortcuts/sheets/lark_sheet_search_replace.go +++ b/shortcuts/sheets/lark_sheet_search_replace.go @@ -124,7 +124,7 @@ var CellsReplace = common.Shortcut{ HasFormat: true, Flags: append(publicSheetFlags(), common.Flag{Name: "find", Required: true, Desc: "text to find (regex when --regex is set)"}, - common.Flag{Name: "replace", Required: true, Desc: "replacement text (empty string deletes the match)"}, + common.Flag{Name: "replacement", Required: true, Desc: "replacement text (empty string deletes the match)"}, common.Flag{Name: "range", Desc: "optional A1 range to scope the replace"}, common.Flag{Name: "match-case", Type: "bool", Desc: "case-sensitive match"}, common.Flag{Name: "match-entire-cell", Type: "bool", Desc: "match the entire cell content only"}, @@ -141,8 +141,8 @@ var CellsReplace = common.Shortcut{ if strings.TrimSpace(runtime.Str("find")) == "" { return common.FlagErrorf("--find is required") } - if !runtime.Changed("replace") { - return common.FlagErrorf("--replace is required (pass an empty string to delete matches)") + if !runtime.Changed("replacement") { + return common.FlagErrorf("--replacement is required (pass an empty string to delete matches)") } return nil }, @@ -176,7 +176,7 @@ func replaceInput(runtime *common.RuntimeContext, token, sheetID, sheetName stri input := map[string]interface{}{ "excel_id": token, "search_term": runtime.Str("find"), - "replace_term": runtime.Str("replace"), + "replace_term": runtime.Str("replacement"), } sheetSelectorForToolInput(input, sheetID, sheetName) if r := strings.TrimSpace(runtime.Str("range")); r != "" { diff --git a/shortcuts/sheets/lark_sheet_search_replace_test.go b/shortcuts/sheets/lark_sheet_search_replace_test.go index f0e43e20c..e3b4cc068 100644 --- a/shortcuts/sheets/lark_sheet_search_replace_test.go +++ b/shortcuts/sheets/lark_sheet_search_replace_test.go @@ -56,7 +56,7 @@ func TestSearchReplaceShortcuts_DryRun(t *testing.T) { { name: "+cells-replace empty replace deletes match", sc: CellsReplace, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--find", "foo", "--replace", ""}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--find", "foo", "--replacement", ""}, toolName: "replace_data", wantInput: map[string]interface{}{ "excel_id": testToken, diff --git a/shortcuts/sheets/lark_sheet_workbook.go b/shortcuts/sheets/lark_sheet_workbook.go index e545cbb30..cdfe6bcd6 100644 --- a/shortcuts/sheets/lark_sheet_workbook.go +++ b/shortcuts/sheets/lark_sheet_workbook.go @@ -540,7 +540,7 @@ var SheetSetTabColor = common.Shortcut{ var WorkbookCreate = common.Shortcut{ Service: "sheets", Command: "+workbook-create", - Description: "Create a new spreadsheet (optionally pre-filled with --headers and --data).", + Description: "Create a new spreadsheet (optionally pre-filled with --headers and --values).", Risk: "write", Scopes: []string{"sheets:spreadsheet:create", "sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, @@ -549,7 +549,7 @@ var WorkbookCreate = common.Shortcut{ {Name: "title", Required: true, Desc: "spreadsheet title"}, {Name: "folder-token", Desc: "destination folder token; omit to land at the drive root"}, {Name: "headers", Input: []string{common.File, common.Stdin}, Desc: "header row JSON array, e.g. [\"列A\",\"列B\"]"}, - {Name: "data", Input: []string{common.File, common.Stdin}, Desc: "initial data JSON 2D array, e.g. [[\"alice\",95]]"}, + {Name: "values", Input: []string{common.File, common.Stdin}, Desc: "initial data JSON 2D array, e.g. [[\"alice\",95]]"}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("title")) == "" { @@ -564,18 +564,18 @@ var WorkbookCreate = common.Shortcut{ return common.FlagErrorf("--headers must be a JSON array") } } - if runtime.Str("data") != "" { - v, err := parseJSONFlag(runtime, "data") + if runtime.Str("values") != "" { + v, err := parseJSONFlag(runtime, "values") if err != nil { return err } rows, ok := v.([]interface{}) if !ok { - return common.FlagErrorf("--data must be a JSON 2D array") + return common.FlagErrorf("--values must be a JSON 2D array") } for i, r := range rows { if _, ok := r.([]interface{}); !ok { - return common.FlagErrorf("--data[%d] must be an array", i) + return common.FlagErrorf("--values[%d] must be an array", i) } } } @@ -590,7 +590,7 @@ var WorkbookCreate = common.Shortcut{ POST("/open-apis/sheets/v3/spreadsheets"). Desc("create spreadsheet"). Body(body) - if runtime.Str("headers") != "" || runtime.Str("data") != "" { + if runtime.Str("headers") != "" || runtime.Str("values") != "" { fill, _ := buildInitialFillInput(runtime) wireBody, _ := buildToolBody("set_cell_range", fill) dry.POST("/open-apis/sheet_ai/v2/spreadsheets//tools/invoke_write"). @@ -619,7 +619,7 @@ var WorkbookCreate = common.Shortcut{ result := map[string]interface{}{"spreadsheet": ss} - if runtime.Str("headers") != "" || runtime.Str("data") != "" { + if runtime.Str("headers") != "" || runtime.Str("values") != "" { fill, err := buildInitialFillInput(runtime) if err != nil { return err @@ -637,11 +637,11 @@ var WorkbookCreate = common.Shortcut{ return nil }, Tips: []string{ - "--headers and --data are optional follow-up writes. They use the same set_cell_range tool as +cells-set; partial failure leaves the spreadsheet created but empty.", + "--headers and --values are optional follow-up writes. They use the same set_cell_range tool as +cells-set; partial failure leaves the spreadsheet created but empty.", }, } -// buildInitialFillInput zips --headers + --data into a single set_cell_range +// buildInitialFillInput zips --headers + --values into a single set_cell_range // payload writing to the first sheet starting at A1. func buildInitialFillInput(runtime *common.RuntimeContext) (map[string]interface{}, error) { var rows [][]interface{} @@ -654,8 +654,8 @@ func buildInitialFillInput(runtime *common.RuntimeContext) (map[string]interface } rows = append(rows, row) } - if runtime.Str("data") != "" { - v, _ := parseJSONFlag(runtime, "data") + if runtime.Str("values") != "" { + v, _ := parseJSONFlag(runtime, "values") dataArr, _ := v.([]interface{}) for _, r := range dataArr { cells, _ := r.([]interface{}) diff --git a/shortcuts/sheets/lark_sheet_workbook_test.go b/shortcuts/sheets/lark_sheet_workbook_test.go index 532ddc305..eaa49d16c 100644 --- a/shortcuts/sheets/lark_sheet_workbook_test.go +++ b/shortcuts/sheets/lark_sheet_workbook_test.go @@ -303,7 +303,7 @@ func TestWorkbookCreate_DryRun(t *testing.T) { calls := parseDryRunAPI(t, WorkbookCreate, []string{ "--title", "Sales", "--headers", `["Name","Score"]`, - "--data", `[["alice",95],["bob",88]]`, + "--values", `[["alice",95],["bob",88]]`, }) if len(calls) != 2 { t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls)) @@ -329,7 +329,7 @@ func TestWorkbookCreate_DataValidation(t *testing.T) { want string }{ {"headers not array", []string{"--title", "X", "--headers", `"abc"`}, "must be a JSON array"}, - {"data not 2D", []string{"--title", "X", "--data", `["a","b"]`}, "must be an array"}, + {"values not 2D", []string{"--title", "X", "--values", `["a","b"]`}, "must be an array"}, } for _, tt := range cases { tt := tt diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index cfba894f4..f9ea30ab1 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -40,7 +40,7 @@ var CellsSet = common.Shortcut{ HasFormat: true, Flags: append(publicSheetFlags(), common.Flag{Name: "range", Required: true, Desc: "target A1 range (e.g. A1:C10); cells dimensions must match"}, - common.Flag{Name: "data", Input: []string{common.File, common.Stdin}, Required: true, + common.Flag{Name: "cells", Input: []string{common.File, common.Stdin}, Required: true, Desc: "JSON body: { \"cells\": [[{value|formula|cell_styles|...}, ...]], optional copy_to_range / resize_width / resize_height }"}, common.Flag{Name: "allow-overwrite", Type: "bool", Default: "true", Desc: "allow overwriting non-empty cells (default true)"}, common.Flag{Name: "max-cells", Type: "int", Default: "50000", Hidden: true, Desc: "anti-burst cells write cap"}, @@ -55,12 +55,12 @@ var CellsSet = common.Shortcut{ if strings.TrimSpace(runtime.Str("range")) == "" { return common.FlagErrorf("--range is required") } - body, err := requireJSONObject(runtime, "data") + body, err := requireJSONObject(runtime, "cells") if err != nil { return err } if _, ok := body["cells"]; !ok { - return common.FlagErrorf("--data must include a \"cells\" field") + return common.FlagErrorf("--cells must include a \"cells\" field") } return nil }, @@ -93,7 +93,7 @@ var CellsSet = common.Shortcut{ } func cellsSetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) (map[string]interface{}, error) { - body, err := requireJSONObject(runtime, "data") + body, err := requireJSONObject(runtime, "cells") if err != nil { return nil, err } @@ -102,7 +102,7 @@ func cellsSetInput(runtime *common.RuntimeContext, token, sheetID, sheetName str "range": strings.TrimSpace(runtime.Str("range")), } sheetSelectorForToolInput(input, sheetID, sheetName) - // --data fields override any of these except the core selectors. + // --cells fields override any of these except the core selectors. for k, v := range body { switch k { case "excel_id", "range", "sheet_id", "sheet_name": @@ -117,21 +117,24 @@ func cellsSetInput(runtime *common.RuntimeContext, token, sheetID, sheetName str return input, nil } -// CellsSetStyle wraps set_cell_range applied to a uniform style: parse -// --style once, fan it out to a (rows × cols) cells matrix, and let -// set_cell_range stamp every cell in the range with that style. +// 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. var CellsSetStyle = common.Shortcut{ Service: "sheets", Command: "+cells-set-style", - Description: "Apply a single style block to every cell in a range (values / formulas untouched).", + Description: "Apply style flags to every cell in a range (values / formulas untouched).", Risk: "write", Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "range", Required: true, Desc: "target A1 range (e.g. A1:B2)"}, - common.Flag{Name: "style", Input: []string{common.File, common.Stdin}, Required: true, - Desc: "style JSON: { font, backColor, horizontal_alignment, vertical_alignment, ... , optional border_styles }"}, + Flags: append( + append(publicSheetFlags(), + common.Flag{Name: "range", Required: true, Desc: "target A1 range (e.g. A1:B2)"}), + styleFlatFlags()..., ), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { @@ -147,7 +150,10 @@ var CellsSetStyle = common.Shortcut{ if _, _, err := rangeDimensions(r); err != nil { return common.FlagErrorf("--range %q: %v", r, err) } - if _, err := requireJSONObject(runtime, "style"); err != nil { + if err := requireAnyStyleFlag(runtime); err != nil { + return err + } + if _, err := borderStylesFromFlag(runtime); err != nil { return err } return nil @@ -181,31 +187,24 @@ var CellsSetStyle = common.Shortcut{ } func cellsSetStyleInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) (map[string]interface{}, error) { - style, err := requireJSONObject(runtime, "style") - if err != nil { - return nil, err - } rangeStr := strings.TrimSpace(runtime.Str("range")) rows, cols, err := rangeDimensions(rangeStr) if err != nil { return nil, common.FlagErrorf("--range %q: %v", rangeStr, err) } - // Split border_styles out of the style block since the tool models it - // as a sibling field of cell_styles. - cellStyle := map[string]interface{}{} - var borderStyles interface{} - for k, v := range style { - if k == "border_styles" { - borderStyles = v - continue - } - cellStyle[k] = v + cellStyle := buildCellStyleFromFlags(runtime) + borderStyles, err := borderStylesFromFlag(runtime) + if err != nil { + return nil, err } cells := make([][]interface{}, rows) for r := range cells { row := make([]interface{}, cols) for c := range row { - cell := map[string]interface{}{"cell_styles": cellStyle} + cell := map[string]interface{}{} + if len(cellStyle) > 0 { + cell["cell_styles"] = cellStyle + } if borderStyles != nil { cell["border_styles"] = borderStyles } diff --git a/shortcuts/sheets/lark_sheet_write_cells_test.go b/shortcuts/sheets/lark_sheet_write_cells_test.go index a7bdc89fa..00092d27a 100644 --- a/shortcuts/sheets/lark_sheet_write_cells_test.go +++ b/shortcuts/sheets/lark_sheet_write_cells_test.go @@ -26,7 +26,7 @@ func TestWriteCellsShortcuts_DryRun(t *testing.T) { args: []string{ "--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", - "--data", `{"cells":[[{"value":1},{"value":2}],[{"value":3},{"value":4}]]}`, + "--cells", `{"cells":[[{"value":1},{"value":2}],[{"value":3},{"value":4}]]}`, }, toolName: "set_cell_range", wantInput: map[string]interface{}{ @@ -42,7 +42,7 @@ func TestWriteCellsShortcuts_DryRun(t *testing.T) { args: []string{ "--url", testURL, "--sheet-id", testSheetID, "--range", "A1", - "--data", `{"cells":[[{"value":1}]]}`, + "--cells", `{"cells":[[{"value":1}]]}`, "--allow-overwrite=false", }, toolName: "set_cell_range", @@ -130,34 +130,50 @@ func TestDropdownSet_CellsShape(t *testing.T) { } } -// TestCellsSetStyle_FanOutsBorderStylesOut confirms the border_styles -// field is split out of --style and placed as a sibling of cell_styles -// per the tool contract. -func TestCellsSetStyle_FanOutsBorderStylesOut(t *testing.T) { +// TestCellsSetStyle_FlatFlags verifies that the 11 flat style flags + +// --border-styles compose into cell_styles + border_styles per cell. +func TestCellsSetStyle_FlatFlags(t *testing.T) { t.Parallel() body := parseDryRunBody(t, CellsSetStyle, []string{ "--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B1", - "--style", `{"font":{"bold":true},"border_styles":{"top":{"style":"thick"}}}`, + "--font-weight", "bold", + "--background-color", "#ffff00", + "--horizontal-alignment", "center", + "--border-styles", `{"top":{"style":"thick"}}`, }) input := decodeToolInput(t, body, "set_cell_range") cells, _ := input["cells"].([]interface{}) row, _ := cells[0].([]interface{}) cell, _ := row[0].(map[string]interface{}) + style, _ := cell["cell_styles"].(map[string]interface{}) + if style["font_weight"] != "bold" || style["background_color"] != "#ffff00" || style["horizontal_alignment"] != "center" { + t.Errorf("cell_styles wrong: %#v", style) + } if cell["border_styles"] == nil { t.Fatalf("border_styles missing on cell: %#v", cell) } - style, _ := cell["cell_styles"].(map[string]interface{}) if _, leaked := style["border_styles"]; leaked { t.Errorf("border_styles leaked into cell_styles: %#v", style) } } +func TestCellsSetStyle_RequiresAtLeastOneFlag(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, CellsSetStyle, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1:B2", "--dry-run", + }) + if err == nil || !strings.Contains(stdout+stderr+err.Error(), "at least one style flag") { + t.Errorf("expected style-flag guard; got=%s|%s|%v", stdout, stderr, err) + } +} + func TestCellsSet_RequiresCellsField(t *testing.T) { t.Parallel() stdout, stderr, err := runShortcutCapturingErr(t, CellsSet, []string{ "--url", testURL, "--sheet-id", testSheetID, - "--range", "A1", "--data", `{"foo":"bar"}`, "--dry-run", + "--range", "A1", "--cells", `{"foo":"bar"}`, "--dry-run", }) if err == nil { t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 7e37414cf..3c0eab38d 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -22,8 +22,6 @@ ## Shortcuts -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 - | MCP tool | CLI shortcut | Risk | 分组 | | --- | --- | --- | --- | | `batch_update` | `+batch-update` | high-risk-write | 批量 | @@ -33,15 +31,13 @@ ## Flags -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 - ### `+batch-update` | Flag | 分类 | Type | 必填 | 说明 | | --- | --- | --- | --- | --- | | `--url` | 公共 | string | XOR | spreadsheet 定位(与子操作的 sheet 定位独立) | | `--spreadsheet-token` | 公共 | string | XOR | spreadsheet 定位(与子操作的 sheet 定位独立) | -| `--data` | 专有 | string + File + Stdin | 是 | JSON:`{"operations":[{"tool":"set_cell_range","params":{...}}, ...]}`;按数组顺序串行执行 | +| `--operations` | 专有 | string + File + Stdin(复合 JSON) | 是 | JSON:`{"operations":[{"tool":"set_cell_range","params":{...}}, ...]}`;按数组顺序串行执行 | | `--yes` | 系统 | bool | 是 | `high-risk-write`,必须二次确认(不带时退出码 10) | | `--dry-run` | 系统 | bool | 否 | 输出每个子操作的请求模板,零网络副作用 | @@ -51,7 +47,18 @@ | --- | --- | --- | --- | --- | | `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | | `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--data` | 专有 | string + File + Stdin | 是 | JSON 数组 `[{"ranges":["sheet1!A1:B2"],"style":{...}}]`;每个 ranges 元素必须带 sheet 前缀 | +| `--ranges` | 专有 | string + File + Stdin(简单 JSON) | 是 | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["sheet1!A1:B2","sheet1!D1:D10"]`);所有 range 应用同一组 style | +| `--background-color` | 专有 | string | 否 | 背景颜色(十六进制,如 `#ffffff`) | +| `--font-color` | 专有 | string | 否 | 字体颜色(十六进制,如 `#000000`) | +| `--font-size` | 专有 | number | 否 | 字体大小(px,例:10、12、14) | +| `--font-style` | 专有 | string + Enum | 否 | 字体样式 enum:`normal` / `italic` | +| `--font-weight` | 专有 | string + Enum | 否 | 字重 enum:`normal` / `bold` | +| `--font-line` | 专有 | string + Enum | 否 | 字体线条样式 enum:`none` / `underline` / `line-through` | +| `--horizontal-alignment` | 专有 | string + Enum | 否 | 水平对齐 enum:`left` / `center` / `right` | +| `--vertical-alignment` | 专有 | string + Enum | 否 | 垂直对齐 enum:`top` / `middle` / `bottom` | +| `--word-wrap` | 专有 | string + Enum | 否 | 换行策略 enum:`overflow` / `auto-wrap` / `word-clip`(默认 `overflow`) | +| `--number-format` | 专有 | string | 否 | 数字格式(例:文本 `@`、数字 `0.00`、货币 `$#,##0.00`、日期 `mm/dd/yyyy`) | +| `--border-styles` | 专有 | string + File + Stdin(复合 JSON) | 否 | 边框配置 JSON(结构同 +cells-set-style) | | `--dry-run` | 系统 | bool | 否 | | ### `+dropdown-update` @@ -60,9 +67,9 @@ | --- | --- | --- | --- | --- | | `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | | `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--ranges` | 专有 | string + File + Stdin | 是 | 目标范围 JSON 数组(如 `["sheet1!A2:A100"]`),每项必须带 sheet 前缀 | -| `--options` | 专有 | string + File + Stdin | 是 | 选项 JSON 数组 | -| `--colors` | 专有 | string + File + Stdin | 否 | 颜色数组(与 `--options` 等长) | +| `--ranges` | 专有 | string + File + Stdin(简单 JSON) | 是 | 目标范围 JSON 数组(如 `["sheet1!A2:A100"]`),每项必须带 sheet 前缀 | +| `--options` | 专有 | string + File + Stdin(复合 JSON) | 是 | 选项 JSON 数组 | +| `--colors` | 专有 | string + File + Stdin(简单 JSON) | 否 | 颜色数组(与 `--options` 等长) | | `--multiple` | 专有 | bool | 否 | 启用多选 | | `--highlight` | 专有 | bool | 否 | 选项配色 | | `--dry-run` | 系统 | bool | 否 | | @@ -73,7 +80,7 @@ | --- | --- | --- | --- | --- | | `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | | `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--ranges` | 专有 | string + File + Stdin | 是 | 目标范围 JSON 数组(最多 100 个,每项带 sheet 前缀) | +| `--ranges` | 专有 | string + File + Stdin(简单 JSON) | 是 | 目标范围 JSON 数组(最多 100 个,每项带 sheet 前缀) | | `--yes` | 系统 | bool | 是 | `high-risk-write`,必须二次确认(不带时退出码 10) | | `--dry-run` | 系统 | bool | 否 | | @@ -81,7 +88,7 @@ > 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 -### `+batch-update` `--data` +### `+batch-update` `--operations` _要批量执行的操作列表,按顺序依次执行_ @@ -89,21 +96,15 @@ _要批量执行的操作列表,按顺序依次执行_ - `input` (object) — 对应工具的入参,结构与单独调用该工具时完全一致 - `tool_name` (string) — 要执行的工具名称,如 "set_cell_range"、"clear_cell_range"、"modify_sheet_structure" 等 -### `+cells-batch-set-style` `--data` +### `+cells-batch-set-style` `--border-styles` -_单元格样式属性,包括字体、颜色、对齐方式和数字格式_ +_单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)_ **顶层字段**: -- `background_color` (string?) — 背景颜色(十六进制,例如 "#ffffff") -- `font_color` (string?) — 字体颜色(十六进制,例如 "#000000") -- `font_line` (enum?) — 字体线条样式 [none / underline / line-through] -- `font_size` (number?) — 字体大小(单位:px/像素,例如 10、12、14) -- `font_style` (enum?) — 字体样式 [normal / italic] -- `font_weight` (enum?) — 字重 [normal / bold] -- `horizontal_alignment` (enum?) — 水平对齐方式 [left / center / right] -- `number_format` (string?) — 数字格式(例如:文本用 "@"、数字用 "0.00"、货币用 "$#,##0.00"、日期用 "mm/dd/yyyy") -- `vertical_alignment` (enum?) — 垂直对齐方式 [top / middle / bottom] -- `word_wrap` (enum?) — 是否自动换行,默认溢出,可选自动换行或裁剪 [overflow / auto-wrap / word-clip] +- `bottom` (object?) { color?: string, style?: enum, weight?: enum } +- `left` (object?) { color?: string, style?: enum, weight?: enum } +- `right` (object?) { color?: string, style?: enum, weight?: enum } +- `top` (object?) { color?: string, style?: enum, weight?: enum } ### `+dropdown-update` `--options` @@ -120,8 +121,6 @@ _数据验证配置_ ## Examples -> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 - 公共四件套:`--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(前两者 XOR;`+batch-update` 本身不强制 sheet-id,子操作各自携带)。 ### `+batch-update` diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md index fc68c49b0..ca954b77f 100644 --- a/skills/lark-sheets/references/lark-sheets-chart.md +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -96,8 +96,6 @@ ## Shortcuts -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 - | MCP tool | CLI shortcut | Risk | 分组 | | --- | --- | --- | --- | | `get_chart_objects` | `+chart-list` | read | 对象 | @@ -107,8 +105,6 @@ ## Flags -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 - ### `+chart-list` | Flag | 分类 | Type | 必填 | 说明 | @@ -128,7 +124,7 @@ | `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | | `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | | `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--data` | 专有 | string + File + Stdin | 是 | 图表完整配置 JSON(`position` / `data` / `properties` 等),结构嵌套深,统一走 JSON 注入 | +| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 是 | 图表完整配置 JSON(`position` / `data` / `properties` 等),结构嵌套深,统一走 JSON 注入 | | `--dry-run` | 系统 | bool | 否 | 零副作用,输出请求模板 | ### `+chart-update` @@ -140,7 +136,7 @@ | `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | | `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | | `--chart-id` | 专有 | string | 是 | 目标图表 reference_id | -| `--data` | 专有 | string + File + Stdin | 是 | 完整或足够完整的图表配置 JSON(先 `+chart-list` 回读再 patch) | +| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 是 | 完整或足够完整的图表配置 JSON(先 `+chart-list` 回读再 patch) | | `--dry-run` | 系统 | bool | 否 | | ### `+chart-delete` @@ -159,7 +155,7 @@ > 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 -### `+chart-create` `--data` / `+chart-update` `--data` +### `+chart-create` `--properties` / `+chart-update` `--properties` _创建/更新的图表属性_ @@ -171,8 +167,6 @@ _创建/更新的图表属性_ ## Examples -> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 - 公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR 规则同 `+csv-get`)。 ### `+chart-list` diff --git a/skills/lark-sheets/references/lark-sheets-conditional-format.md b/skills/lark-sheets/references/lark-sheets-conditional-format.md index 4739e65d4..7fe8753be 100644 --- a/skills/lark-sheets/references/lark-sheets-conditional-format.md +++ b/skills/lark-sheets/references/lark-sheets-conditional-format.md @@ -72,8 +72,6 @@ manage_conditional_format_object create ## Shortcuts -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 - | MCP tool | CLI shortcut | Risk | 分组 | | --- | --- | --- | --- | | `get_conditional_format_objects` | `+cond-format-list` | read | 对象 | @@ -83,8 +81,6 @@ manage_conditional_format_object create ## Flags -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 - ### `+cond-format-list` | Flag | 分类 | Type | 必填 | 说明 | @@ -104,7 +100,9 @@ manage_conditional_format_object create | `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | | `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | | `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--data` | 专有 | string + File + Stdin | 是 | JSON:`{"range":"Sheet1!A2:F1000","rule":{"type":"cell_value","operator":"greater_than","value":100,"style":{...}}}`,type 可选 `cell_value` / `duplicate` / `data_bar` / `color_scale` / `rank` / `formula` | +| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 是 | +cond-format-create / --data: 规则配置 JSON,含 `style`(命中样式,必填)和 `attrs?`(规则参数列表,因 rule_type 不同结构而异)/ `has_ref?`。`rule_type` 和 `ranges` 已拎为独立 flag | +| `--rule-type` | 专有 | string + Enum | 是 | 条件格式规则类型 enum:`cellValue` / `formula` / `duplicate` / `unique` / `topBottom` / `aboveBelowAverage` / `dataBar` / `colorScale` / `iconSet` / `textContains` / `dateOccurring` / `blankCell` / `errorCell`(共 13 项);优先级高于 `--data` | +| `--ranges` | 专有 | string + File + Stdin(简单 JSON) | 是 | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--data` | | `--dry-run` | 系统 | bool | 否 | | ### `+cond-format-update` @@ -116,7 +114,9 @@ manage_conditional_format_object create | `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | | `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | | `--rule-id` | 专有 | string | 是 | 目标规则 id | -| `--data` | 专有 | string + File + Stdin | 是 | 完整或足够完整的规则配置(先 `+cond-format-list --rule-id ` 回读再 patch) | +| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 是 | +cond-format-update / --data: 同 +cond-format-create;update 是整组覆盖式 | +| `--rule-type` | 专有 | string + Enum | 是 | 条件格式规则类型 enum:`cellValue` / `formula` / `duplicate` / `unique` / `topBottom` / `aboveBelowAverage` / `dataBar` / `colorScale` / `iconSet` / `textContains` / `dateOccurring` / `blankCell` / `errorCell`(共 13 项);优先级高于 `--data` | +| `--ranges` | 专有 | string + File + Stdin(简单 JSON) | 是 | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--data` | | `--dry-run` | 系统 | bool | 否 | | ### `+cond-format-delete` @@ -135,7 +135,7 @@ manage_conditional_format_object create > 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 -### `+cond-format-create` `--data` / `+cond-format-update` `--data` +### `+cond-format-create` `--properties` / `+cond-format-update` `--properties` _创建/更新的条件格式属性_ @@ -148,8 +148,6 @@ _创建/更新的条件格式属性_ ## Examples -> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 - 公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。 ### `+cond-format-list` diff --git a/skills/lark-sheets/references/lark-sheets-filter-view.md b/skills/lark-sheets/references/lark-sheets-filter-view.md index 32701c91f..6f1dce115 100644 --- a/skills/lark-sheets/references/lark-sheets-filter-view.md +++ b/skills/lark-sheets/references/lark-sheets-filter-view.md @@ -25,8 +25,6 @@ ## Shortcuts -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 - | MCP tool | CLI shortcut | Risk | 分组 | | --- | --- | --- | --- | | `get_filter_view_objects` | `+filter-view-list` | read | 对象 | @@ -36,8 +34,6 @@ ## Flags -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 - ### `+filter-view-list` | Flag | 分类 | Type | 必填 | 说明 | @@ -57,7 +53,9 @@ | `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | | `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | | `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--data` | 专有 | string + File + Stdin | 是 | 视图配置 JSON:`{"view_name":"...","range":"A1:Z100","rules":[...]}`;省略 view_id 表示 create;range 必填且必须覆盖表头行 | +| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 是 | +filter-view-create / --data: 视图规则 JSON,含 `rules?`(列级筛选规则数组)和 `filtered_columns?`。`range` 和 `view_name` 已拎为独立 flag | +| `--range` | 专有 | string | 是 | 筛选作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--data` 中同名字段(create 必填,必须覆盖表头行) | +| `--view-name` | 专有 | string | 否 | 视图名称;create 不传时系统自动分配,update 不传时保留原名。优先级高于 `--data` 中同名字段 | | `--dry-run` | 系统 | bool | 否 | | ### `+filter-view-update` @@ -69,7 +67,9 @@ | `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | | `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | | `--view-id` | 专有 | string | 是 | 目标视图 reference_id | -| `--data` | 专有 | string + File + Stdin | 是 | 部分更新 JSON:含 view_name / range / rules 之一即可;先 +filter-view-list 回读再 patch | +| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 是 | +filter-view-update / --data: 视图规则 JSON,含 `rules?` 和 `filtered_columns?`。`range` 和 `view_name` 已拎为独立 flag;至少传 `--data.rules` / `--range` / `--view-name` 之一 | +| `--range` | 专有 | string | 否 | 筛选作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--data` 中同名字段(update 时省略表示保留当前 range) | +| `--view-name` | 专有 | string | 否 | 视图名称;create 不传时系统自动分配,update 不传时保留原名。优先级高于 `--data` 中同名字段 | | `--dry-run` | 系统 | bool | 否 | | ### `+filter-view-delete` @@ -88,7 +88,7 @@ > 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 -### `+filter-view-create` `--data` / `+filter-view-update` `--data` +### `+filter-view-create` `--properties` / `+filter-view-update` `--properties` _create / update 的视图属性_ @@ -100,8 +100,6 @@ _create / update 的视图属性_ ## Examples -> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 - > ⚠️ 本 skill 是 **CLI 独有**(meta `surface: cli-only`);`generate_mcp` 跳过,不会进 sheet-ai-skills SKILL 集。AI/MCP 侧暂不暴露筛选视图能力。 公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。`view_id` 是 10 位随机字符串,每个 sheet 可有多个视图。 diff --git a/skills/lark-sheets/references/lark-sheets-filter.md b/skills/lark-sheets/references/lark-sheets-filter.md index 6232cf4ba..35e9198c1 100644 --- a/skills/lark-sheets/references/lark-sheets-filter.md +++ b/skills/lark-sheets/references/lark-sheets-filter.md @@ -29,8 +29,6 @@ ## Shortcuts -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 - | MCP tool | CLI shortcut | Risk | 分组 | | --- | --- | --- | --- | | `get_filter_objects` | `+filter-list` | read | 对象 | @@ -40,8 +38,6 @@ ## Flags -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 - ### `+filter-list` | Flag | 分类 | Type | 必填 | 说明 | @@ -60,8 +56,8 @@ | `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | | `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | | `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--range` | 专有 | string | 是 | 筛选范围,含表头行(如 `A1:F1000`) | -| `--data` | 专有 | string + File + Stdin | 否 | JSON:`{"conditions":[{"col":"B","filter_type":"multiValue","expected":["北京","上海"]}]}`;省略则只建空筛选 | +| `--range` | 专有 | string | 是 | 筛选范围(A1 表示法,含表头行,如 `A1:F1000`);不要重复写入 --data 中的 range 字段 | +| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 否 | +filter-create / --data: 筛选规则 JSON,含 `rules`(列级筛选规则数组,必填)和 `filtered_columns?`(激活列索引提示)。`range` 是独立 flag(不要再放此 JSON 里) | | `--dry-run` | 系统 | bool | 否 | | ### `+filter-update` @@ -72,7 +68,8 @@ | `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | | `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | | `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--data` | 专有 | string + File + Stdin | 是 | JSON:可改 `range` 或追加 / 替换 `conditions[]`;先 `+filter-list` 回读再 patch | +| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 是 | +filter-update / --data: 筛选规则 JSON,含 `rules` 和 `filtered_columns?`;update 是整组覆盖式(传空 `rules: []` 清空)。`range` 已拎为独立 flag | +| `--range` | 专有 | string | 是 | 筛选作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--data` 中同名字段 | | `--dry-run` | 系统 | bool | 否 | | ### `+filter-delete` @@ -90,7 +87,7 @@ > 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 -### `+filter-create` `--data` / `+filter-update` `--data` +### `+filter-create` `--properties` / `+filter-update` `--properties` _创建/更新的筛选器属性_ @@ -101,8 +98,6 @@ _创建/更新的筛选器属性_ ## Examples -> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 - 公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。`filter_id` 等同于 `sheet_id`(每个工作表至多一个筛选器)。 ### `+filter-list` diff --git a/skills/lark-sheets/references/lark-sheets-float-image.md b/skills/lark-sheets/references/lark-sheets-float-image.md index a4f6e219f..9a77c13b3 100644 --- a/skills/lark-sheets/references/lark-sheets-float-image.md +++ b/skills/lark-sheets/references/lark-sheets-float-image.md @@ -36,8 +36,6 @@ reference_id 的映射规则: ## Shortcuts -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 - | MCP tool | CLI shortcut | Risk | 分组 | | --- | --- | --- | --- | | `get_float_image_objects` | `+float-image-list` | read | 对象 | @@ -47,8 +45,6 @@ reference_id 的映射规则: ## Flags -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 - ### `+float-image-list` | Flag | 分类 | Type | 必填 | 说明 | @@ -68,7 +64,16 @@ reference_id 的映射规则: | `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | | `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | | `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--data` | 专有 | string + File + Stdin | 是 | JSON:`{"image_uri":"...","image_name":"foo.png","position":{"row":2,"col":"D"},"size":{"width":300,"height":200},"offset":{"x":0,"y":0}}` | +| `--image-name` | 专有 | string | 是 | 图片名称,含拓展名(如 `logo.png`) | +| `--image-token` | 专有 | string | XOR | 图片 file_token(与 `--image-uri` 二选一)。常见来源:`+float-image-list` 返回的 `image_token` | +| `--image-uri` | 专有 | string | XOR | 图片 reference_id(与 `--image-token` 二选一);形如 `<\|image\|>:abcdef` 这种带前缀的字符串,从上游 SKILL.md 的素材引用约定取 | +| `--position-row` | 专有 | int | 是 | 图片左上角所在行(0-based) | +| `--position-col` | 专有 | string | 是 | 图片左上角所在列(列字母,如 `A` / `B`) | +| `--size-width` | 专有 | int | 是 | 图片宽度(像素) | +| `--size-height` | 专有 | int | 是 | 图片高度(像素) | +| `--offset-row` | 专有 | int | 否 | 在 position 基础上的行内偏移(像素) | +| `--offset-col` | 专有 | int | 否 | 在 position 基础上的列内偏移(像素) | +| `--z-index` | 专有 | int | 否 | 图片 Z 轴层级,控制重叠顺序 | | `--dry-run` | 系统 | bool | 否 | | ### `+float-image-update` @@ -80,7 +85,16 @@ reference_id 的映射规则: | `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | | `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | | `--float-image-id` | 专有 | string | 是 | 目标图片 id | -| `--data` | 专有 | string + File + Stdin | 是 | 完整或足够完整的配置 JSON(先 `+float-image-list --float-image-id ` 回读再 patch) | +| `--image-name` | 专有 | string | 是 | 图片名称,含拓展名(如 `logo.png`) | +| `--image-token` | 专有 | string | XOR | 图片 file_token(与 `--image-uri` 二选一)。常见来源:`+float-image-list` 返回的 `image_token` | +| `--image-uri` | 专有 | string | XOR | 图片 reference_id(与 `--image-token` 二选一);形如 `<\|image\|>:abcdef` 这种带前缀的字符串,从上游 SKILL.md 的素材引用约定取 | +| `--position-row` | 专有 | int | 是 | 图片左上角所在行(0-based) | +| `--position-col` | 专有 | string | 是 | 图片左上角所在列(列字母,如 `A` / `B`) | +| `--size-width` | 专有 | int | 是 | 图片宽度(像素) | +| `--size-height` | 专有 | int | 是 | 图片高度(像素) | +| `--offset-row` | 专有 | int | 否 | 在 position 基础上的行内偏移(像素) | +| `--offset-col` | 专有 | int | 否 | 在 position 基础上的列内偏移(像素) | +| `--z-index` | 专有 | int | 否 | 图片 Z 轴层级,控制重叠顺序 | | `--dry-run` | 系统 | bool | 否 | | ### `+float-image-delete` @@ -95,27 +109,8 @@ reference_id 的映射规则: | `--yes` | 系统 | bool | 是 | `high-risk-write`,删除不可逆 | | `--dry-run` | 系统 | bool | 否 | | -## Schemas - -> 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 - -### `+float-image-create` `--data` / `+float-image-update` `--data` - -_创建/更新的浮动图片属性_ - -**顶层字段**: -- `image_name` (string) — 图片名称,含拓展名,create 时必填 -- `image_token` (string?) — 图片 fileToken(与 image_uri 二选一) -- `image_uri` (string?) — 图片的 reference_id(与 image_token 二选一) -- `offset` (object?) — 可选 { col_offset?: number, row_offset?: number } -- `position` (object) — 必填 { col: string, row: number } -- `size` (object) — 必填 { height: number, width: number } -- `z_index` (number?) — 可选 - ## Examples -> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 - 公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。浮动图片是 sheet 级对象——和单元格内嵌图片不同(后者走 `+cells-set`)。 ### `+float-image-list` diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md index 6c5f11866..3b317e471 100644 --- a/skills/lark-sheets/references/lark-sheets-pivot-table.md +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -38,8 +38,6 @@ ## Shortcuts -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 - | MCP tool | CLI shortcut | Risk | 分组 | | --- | --- | --- | --- | | `get_pivot_table_objects` | `+pivot-list` | read | 对象 | @@ -49,8 +47,6 @@ ## Flags -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 - ### `+pivot-list` | Flag | 分类 | Type | 必填 | 说明 | @@ -70,9 +66,11 @@ | `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | | `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | | `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--data` | 专有 | string + File + Stdin | 是 | JSON:`{"data_range":"Sheet1!A1:F1000","rows":[...],"columns":[...],"values":[...],"filters":[...],"show_row_grand_total":true,"show_col_grand_total":true}` | +| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 是 | JSON:`{"data_range":"Sheet1!A1:F1000","rows":[...],"columns":[...],"values":[...],"filters":[...],"show_row_grand_total":true,"show_col_grand_total":true}` | | `--target-sheet-id` | 专有 | string | 否 | 透视表落点子表 id;省略时自动新建子表(推荐) | | `--target-position` | 专有 | string | 否 | 落点起始 cell(如 `A1`),默认 `A1` | +| `--source` | 专有 | string | 是 | 透视表源数据区域(A1 表示法,格式 `SheetName!StartCell:EndCell`,如 `Sheet1!A1:D100`) | +| `--range` | 专有 | string | 否 | 透视表放置位置(左上角 A1 单值,如 `F1`);省略时放在新建子表的左上角 | | `--dry-run` | 系统 | bool | 否 | | ### `+pivot-update` @@ -84,7 +82,7 @@ | `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | | `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | | `--pivot-table-id` | 专有 | string | 是 | 目标透视表 id | -| `--data` | 专有 | string + File + Stdin | 是 | 完整或足够完整的配置(先 `+pivot-list --pivot-table-id ` 回读再 patch) | +| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 是 | 完整或足够完整的配置(先 `+pivot-list --pivot-table-id ` 回读再 patch) | | `--dry-run` | 系统 | bool | 否 | | ### `+pivot-delete` @@ -103,7 +101,7 @@ > 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 -### `+pivot-create` `--data` / `+pivot-update` `--data` +### `+pivot-create` `--properties` / `+pivot-update` `--properties` _创建/更新的透视表属性_ @@ -124,8 +122,6 @@ _创建/更新的透视表属性_ ## Examples -> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 - 公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。`+pivot-create` 默认自动新建子表存放透视表产物(推荐)。 ### `+pivot-list` diff --git a/skills/lark-sheets/references/lark-sheets-range-operations.md b/skills/lark-sheets/references/lark-sheets-range-operations.md index 0ffc81bd2..2687470c3 100644 --- a/skills/lark-sheets/references/lark-sheets-range-operations.md +++ b/skills/lark-sheets/references/lark-sheets-range-operations.md @@ -106,8 +106,6 @@ ## Shortcuts -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 - | MCP tool | CLI shortcut | Risk | 分组 | | --- | --- | --- | --- | | `clear_cell_range` | `+cells-clear` | high-risk-write | 单元格 | @@ -121,8 +119,6 @@ ## Flags -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 - ### `+cells-clear` | Flag | 分类 | Type | 必填 | 说明 | @@ -225,7 +221,7 @@ | `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | | `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | | `--range` | 专有 | string | 是 | 排序范围(含或不含表头由 `--has-header` 决定) | -| `--sort-keys` | 专有 | string + File + Stdin | 是 | JSON:`[{"col":"B","order":"asc"},{"col":"D","order":"desc"}]` | +| `--sort-keys` | 专有 | string + File + Stdin(复合 JSON) | 是 | JSON:`[{"col":"B","order":"asc"},{"col":"D","order":"desc"}]` | | `--has-header` | 专有 | bool | 否 | 第一行是表头不参与排序,默认 false | | `--dry-run` | 系统 | bool | 否 | | @@ -243,8 +239,6 @@ _排序条件列表(仅 sort 操作)_ ## Examples -> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 - > ⚠️ 本 skill 派生的 7 条 shortcut 跨 3 个分组:`+dim-resize` → 工作表,`+cells-*` → 单元格,`+range-*` → 区域。skill 视角统一在这里讲解。 公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。 diff --git a/skills/lark-sheets/references/lark-sheets-read-data.md b/skills/lark-sheets/references/lark-sheets-read-data.md index 0791149a7..65a3ff0e3 100644 --- a/skills/lark-sheets/references/lark-sheets-read-data.md +++ b/skills/lark-sheets/references/lark-sheets-read-data.md @@ -76,8 +76,6 @@ ## Shortcuts -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 - | MCP tool | CLI shortcut | Risk | 分组 | | --- | --- | --- | --- | | `export_sheet_to_sandbox` | _Sheet Tool 独有,CLI 不实现_ | — | — | @@ -87,8 +85,6 @@ ## Flags -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 - ### `+cells-get` | Flag | 分类 | Type | 必填 | 说明 | @@ -131,8 +127,6 @@ ## Examples -> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 - ### `+csv-get` 公共四件套:`--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(前两者 XOR,后两者 XOR)。 diff --git a/skills/lark-sheets/references/lark-sheets-search-replace.md b/skills/lark-sheets/references/lark-sheets-search-replace.md index 84bf056ed..30a8eab10 100644 --- a/skills/lark-sheets/references/lark-sheets-search-replace.md +++ b/skills/lark-sheets/references/lark-sheets-search-replace.md @@ -24,8 +24,6 @@ ## Shortcuts -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 - | MCP tool | CLI shortcut | Risk | 分组 | | --- | --- | --- | --- | | `search_data` | `+cells-search` | read | 单元格 | @@ -33,8 +31,6 @@ ## Flags -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 - ### `+cells-search` | Flag | 分类 | Type | 必填 | 说明 | @@ -71,8 +67,6 @@ ## Examples -> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 - 公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR 规则)。 ### `+cells-search` diff --git a/skills/lark-sheets/references/lark-sheets-sheet-structure.md b/skills/lark-sheets/references/lark-sheets-sheet-structure.md index 85a25ffdd..c1e83e0b0 100644 --- a/skills/lark-sheets/references/lark-sheets-sheet-structure.md +++ b/skills/lark-sheets/references/lark-sheets-sheet-structure.md @@ -37,8 +37,6 @@ ## Shortcuts -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 - | MCP tool | CLI shortcut | Risk | 分组 | | --- | --- | --- | --- | | `get_sheet_structure` | `+sheet-info` | read | 工作表 | @@ -53,8 +51,6 @@ ## Flags -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 - ### `+sheet-info` | Flag | 分类 | Type | 必填 | 说明 | @@ -176,8 +172,6 @@ ## Examples -> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 - 公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。 ### `+sheet-info` diff --git a/skills/lark-sheets/references/lark-sheets-sparkline.md b/skills/lark-sheets/references/lark-sheets-sparkline.md index c856a5f8e..da3f7e5c9 100644 --- a/skills/lark-sheets/references/lark-sheets-sparkline.md +++ b/skills/lark-sheets/references/lark-sheets-sparkline.md @@ -22,8 +22,6 @@ ## Shortcuts -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 - | MCP tool | CLI shortcut | Risk | 分组 | | --- | --- | --- | --- | | `get_sparkline_objects` | `+sparkline-list` | read | 对象 | @@ -33,8 +31,6 @@ ## Flags -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 - ### `+sparkline-list` | Flag | 分类 | Type | 必填 | 说明 | @@ -54,7 +50,7 @@ | `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | | `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | | `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--data` | 专有 | string + File + Stdin | 是 | JSON:`{"type":"line\\ | +| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 是 | JSON:`{"type":"line\|column\|winLoss","data_range":"A2:F10","target_range":"G2:G10","style":{...},"special_points":{...}}`;type 三种 enum;data_range 与 target_range 行/列数需对齐 | | `--dry-run` | 系统 | bool | 否 | | ### `+sparkline-update` @@ -66,7 +62,7 @@ | `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | | `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | | `--group-id` | 专有 | string | 是 | 目标组 id | -| `--data` | 专有 | string + File + Stdin | 是 | 完整或足够完整的配置(先 `+sparkline-list --group-id ` 回读再 patch) | +| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 是 | 完整或足够完整的配置(先 `+sparkline-list --group-id ` 回读再 patch);可改 type / data_range / target_range / style / special_points 等字段 | | `--dry-run` | 系统 | bool | 否 | | ### `+sparkline-delete` @@ -85,7 +81,7 @@ > 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 -### `+sparkline-create` `--data` / `+sparkline-update` `--data` +### `+sparkline-create` `--properties` / `+sparkline-update` `--properties` _创建/更新/部分删除的迷你图属性_ @@ -95,8 +91,6 @@ _创建/更新/部分删除的迷你图属性_ ## Examples -> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 - 公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。迷你图按 `group_id` 管理——一组同形态的迷你图共享类型 / 样式 / 数据源映射。注意:不等同于已禁用的 `SPARKLINE()` 公式函数。 ### `+sparkline-list` diff --git a/skills/lark-sheets/references/lark-sheets-workbook.md b/skills/lark-sheets/references/lark-sheets-workbook.md index 78148673b..a8b518949 100644 --- a/skills/lark-sheets/references/lark-sheets-workbook.md +++ b/skills/lark-sheets/references/lark-sheets-workbook.md @@ -30,8 +30,6 @@ ## Shortcuts -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 - | MCP tool | CLI shortcut | Risk | 分组 | | --- | --- | --- | --- | | `get_workbook_structure` | `+workbook-info` | read | 工作簿 | @@ -48,8 +46,6 @@ ## Flags -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 - ### `+workbook-info` | Flag | 分类 | Type | 必填 | 说明 | @@ -154,8 +150,8 @@ | --- | --- | --- | --- | --- | | `--title` | 专有 | string | 是 | 新 spreadsheet 标题 | | `--folder-token` | 专有 | string | 否 | 目标文件夹 token;省略放根目录 | -| `--headers` | 专有 | string + File + Stdin | 否 | 表头行 JSON 数组:`["列A","列B"]` | -| `--data` | 专有 | string + File + Stdin | 否 | 初始数据 JSON 二维数组:`[["alice",95]]` | +| `--headers` | 专有 | string + File + Stdin(简单 JSON) | 否 | 表头行 JSON 数组:`["列A","列B"]` | +| `--values` | 专有 | string + File + Stdin(简单 JSON) | 否 | 初始数据 JSON 二维数组:`[["alice",95]]` | | `--dry-run` | 系统 | bool | 否 | | ### `+workbook-export` @@ -171,8 +167,6 @@ ## Examples -> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 - 公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。`+workbook-info` 只用前两者;`+sheet-*` 系列对单个工作表操作,需 `--sheet-id` 或 `--sheet-name`。 ### `+workbook-info` diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 5bf1e06d1..8120bf88b 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -167,8 +167,6 @@ set_cell_range — range="A11:H11", cells=[[ ## Shortcuts -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成。CLI 的 shortcut 拆分、Risk 分级、分组、flag 表是事实源;本节不要手维护。 - | MCP tool | CLI shortcut | Risk | 分组 | | --- | --- | --- | --- | | `set_cell_range` | `+cells-set` | write | 单元格 | @@ -180,8 +178,6 @@ set_cell_range — range="A11:H11", cells=[[ ## Flags -> 由 [`tool-shortcut-map.json`](../../../canonical-spec/tool-shortcut-map.json) 自动生成(包含从 base shortcut-flags 子表派生的 flag 信息)。本节不要手维护——改 base 表再 `npm run sync:tool-shortcut-map`。 - ### `+cells-set` | Flag | 分类 | Type | 必填 | 说明 | @@ -191,7 +187,7 @@ set_cell_range — range="A11:H11", cells=[[ | `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | | `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | | `--range` | 专有 | string | 是 | 写入区域 A1 格式 | -| `--data` | 专有 | string + File + Stdin | 是 | JSON:`{"values": [[...], ...]}`;可含 `formula` / `cell_styles` / `comments` / `embed_image` 富信息 | +| `--cells` | 专有 | string + File + Stdin(复合 JSON) | 是 | JSON:`{"values": [[...], ...]}`;可含 `formula` / `cell_styles` / `comments` / `embed_image` 富信息 | | `--allow-overwrite` | 专有 | bool | 否 | 允许覆盖非空 cell;默认 false 时遇非空 cell 报错 | | `--max-cells` | 专有 | int + Hidden | 否 | 防爆,默认 50000 | | `--dry-run` | 系统 | bool | 否 | | @@ -205,7 +201,17 @@ set_cell_range — range="A11:H11", cells=[[ | `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | | `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | | `--range` | 专有 | string | 是 | 目标范围 A1 格式(如 `A1:B2`) | -| `--style` | 专有 | string + File + Stdin | 是 | 样式 JSON:`{"font":{"bold":true},"backColor":"#fff","border_styles":{...}}`;只改样式,不动 value/formula(底层走 set_cell_range 的 cell_styles + border_styles 字段) | +| `--background-color` | 专有 | string | 否 | 背景颜色(十六进制,如 `#ffffff`) | +| `--font-color` | 专有 | string | 否 | 字体颜色(十六进制,如 `#000000`) | +| `--font-size` | 专有 | number | 否 | 字体大小(px,例:10、12、14) | +| `--font-style` | 专有 | string + Enum | 否 | 字体样式 enum:`normal` / `italic` | +| `--font-weight` | 专有 | string + Enum | 否 | 字重 enum:`normal` / `bold` | +| `--font-line` | 专有 | string + Enum | 否 | 字体线条样式 enum:`none` / `underline` / `line-through` | +| `--horizontal-alignment` | 专有 | string + Enum | 否 | 水平对齐 enum:`left` / `center` / `right` | +| `--vertical-alignment` | 专有 | string + Enum | 否 | 垂直对齐 enum:`top` / `middle` / `bottom` | +| `--word-wrap` | 专有 | string + Enum | 否 | 换行策略 enum:`overflow` / `auto-wrap` / `word-clip`(默认 `overflow`) | +| `--number-format` | 专有 | string | 否 | 数字格式(例:文本 `@`、数字 `0.00`、货币 `$#,##0.00`、日期 `mm/dd/yyyy`) | +| `--border-styles` | 专有 | string + File + Stdin(复合 JSON) | 否 | 边框配置 JSON:`{ top: {style,color,weight}, bottom: ..., left: ..., right: ... }`;4 方向结构相同 | | `--dry-run` | 系统 | bool | 否 | | ### `+cells-set-image` @@ -230,8 +236,8 @@ set_cell_range — range="A11:H11", cells=[[ | `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | | `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | | `--range` | 专有 | string | 是 | 目标范围 A1 格式(如 `A2:A100`) | -| `--options` | 专有 | string + File + Stdin | 是 | 选项 JSON 数组 `["opt1","opt2"]`;最多 500 项,每项 ≤100 字符,不含逗号 | -| `--colors` | 专有 | string + File + Stdin | 否 | RGB hex 颜色数组(如 `["#1FB6C1","#F006C2"]`),长度必须与 `--options` 一致 | +| `--options` | 专有 | string + File + Stdin(复合 JSON) | 是 | 选项 JSON 数组 `["opt1","opt2"]`;最多 500 项,每项 ≤100 字符,不含逗号 | +| `--colors` | 专有 | string + File + Stdin(简单 JSON) | 否 | RGB hex 颜色数组(如 `["#1FB6C1","#F006C2"]`),长度必须与 `--options` 一致 | | `--multiple` | 专有 | bool | 否 | 启用多选;默认 `false` | | `--highlight` | 专有 | bool | 否 | 选项配色显示;默认 `false` | | `--dry-run` | 系统 | bool | 否 | | @@ -245,7 +251,7 @@ set_cell_range — range="A11:H11", cells=[[ | `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | | `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | | `--range` | 专有 | string | 是 | 目标区域起点 A1(如 `Sheet1!A1`);自动按 CSV 行列数推断终点 | -| `--csv` | 专有 | string + File + Stdin | 是 | RFC 4180 CSV 文本;只写纯值,不带公式/样式/批注 | +| `--csv` | 专有 | string + File + Stdin(非 JSON 文本) | 是 | RFC 4180 CSV 文本;只写纯值,不带公式/样式/批注 | | `--allow-overwrite` | 专有 | bool | 否 | 允许覆盖;默认 false 时若目标非空报错 | | `--dry-run` | 系统 | bool | 否 | | @@ -253,7 +259,7 @@ set_cell_range — range="A11:H11", cells=[[ > 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 -### `+cells-set` `--data` +### `+cells-set` `--cells` **顶层字段**: @@ -266,21 +272,15 @@ set_cell_range — range="A11:H11", cells=[[ - `rich_text` (array?) — 富文本内容 each: { attachment_name?: string, attachment_token?: string, attachment_uri?: string, file_size?: number, image_height?: number, …共 17 项 } - `value` (oneOf?) — 静态单元格值(文本、数字、布尔) -### `+cells-set-style` `--style` +### `+cells-set-style` `--border-styles` -_单元格样式属性,包括字体、颜色、对齐方式和数字格式_ +_单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)_ **顶层字段**: -- `background_color` (string?) — 背景颜色(十六进制,例如 "#ffffff") -- `font_color` (string?) — 字体颜色(十六进制,例如 "#000000") -- `font_line` (enum?) — 字体线条样式 [none / underline / line-through] -- `font_size` (number?) — 字体大小(单位:px/像素,例如 10、12、14) -- `font_style` (enum?) — 字体样式 [normal / italic] -- `font_weight` (enum?) — 字重 [normal / bold] -- `horizontal_alignment` (enum?) — 水平对齐方式 [left / center / right] -- `number_format` (string?) — 数字格式(例如:文本用 "@"、数字用 "0.00"、货币用 "$#,##0.00"、日期用 "mm/dd/yyyy") -- `vertical_alignment` (enum?) — 垂直对齐方式 [top / middle / bottom] -- `word_wrap` (enum?) — 是否自动换行,默认溢出,可选自动换行或裁剪 [overflow / auto-wrap / word-clip] +- `bottom` (object?) { color?: string, style?: enum, weight?: enum } +- `left` (object?) { color?: string, style?: enum, weight?: enum } +- `right` (object?) { color?: string, style?: enum, weight?: enum } +- `top` (object?) { color?: string, style?: enum, weight?: enum } ### `+dropdown-set` `--options` @@ -297,8 +297,6 @@ _数据验证配置_ ## Examples -> shortcut 拆分 / Risk / 分组 / flag 表都由 [`tool-shortcut-map.json`](../../tool-shortcut-map.json) 自动注入到上方 `## Shortcuts` / `## Flags` 段。本节只承载手维护补充:命令示例、Validate / DryRun / Execute 约束。 - 公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。 ### `+cells-set` From be31975f7ee0ec9fb9fea5dfbcc0c3640d6af03f Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Mon, 18 May 2026 19:47:21 +0800 Subject: [PATCH 014/114] refactor(sheets): align batch_update + cells-set with synced reference docs Sync to upstream reference doc updates for 9 skills: - batch_update sub-ops: rewrite wire fields tool/params -> tool_name/input in CellsBatchSetStyle and DropdownUpdate/Delete fan-out (the actual server contract per Schemas section); update --operations flag desc and tests. - +cells-set --cells: accept bare 2D matrix [[{cell},...],...] instead of envelope {"cells":[[...]]}; spec example shows bare-array form. - sparkline createDataDesc enum: win_loss -> winLoss (camelCase). All other doc changes (float-image flat flags, cond-format --rule-type/--ranges, pivot create-only --source/--range, filter / filter-view extra flags, chart --properties) were already aligned in commit ce33315. --- shortcuts/sheets/execute_paths_test.go | 4 +-- shortcuts/sheets/lark_sheet_batch_update.go | 12 +++---- .../sheets/lark_sheet_batch_update_test.go | 14 ++++---- shortcuts/sheets/lark_sheet_object_crud.go | 2 +- shortcuts/sheets/lark_sheet_write_cells.go | 20 +++-------- .../sheets/lark_sheet_write_cells_test.go | 13 +++---- .../references/lark-sheets-batch-update.md | 29 ++++++++++------ .../references/lark-sheets-chart.md | 12 +++---- .../lark-sheets-conditional-format.md | 16 +++++++-- .../references/lark-sheets-filter-view.md | 17 ++++------ .../references/lark-sheets-filter.md | 6 ++-- .../references/lark-sheets-float-image.md | 16 +++++++-- .../references/lark-sheets-pivot-table.md | 9 +++-- .../references/lark-sheets-sparkline.md | 4 +-- .../references/lark-sheets-write-cells.md | 34 ++++++++++++++++--- 15 files changed, 127 insertions(+), 81 deletions(-) diff --git a/shortcuts/sheets/execute_paths_test.go b/shortcuts/sheets/execute_paths_test.go index c36c2fc41..b484d1b79 100644 --- a/shortcuts/sheets/execute_paths_test.go +++ b/shortcuts/sheets/execute_paths_test.go @@ -116,7 +116,7 @@ func TestExecute_CellsSet(t *testing.T) { out, err := runShortcutWithStubs(t, CellsSet, []string{ "--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B1", - "--cells", `{"cells":[[{"value":"x"},{"value":"y"}]]}`, + "--cells", `[[{"value":"x"},{"value":"y"}]]`, }, stub) if err != nil { t.Fatalf("execute failed: %v\nout=%s", err, out) @@ -240,7 +240,7 @@ func TestExecute_BatchUpdate_Raw(t *testing.T) { stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`) _, err := runShortcutWithStubs(t, BatchUpdate, []string{ "--url", testURL, - "--operations", `[{"tool":"set_cell_range","params":{"excel_id":"shtcnTestTOK","range":"A1","cells":[[{"value":1}]]}}]`, + "--operations", `[{"tool_name":"set_cell_range","input":{"excel_id":"shtcnTestTOK","range":"A1","cells":[[{"value":1}]]}}]`, "--continue-on-error", "--yes", }, stub) diff --git a/shortcuts/sheets/lark_sheet_batch_update.go b/shortcuts/sheets/lark_sheet_batch_update.go index 4d7da18fc..d91d34cee 100644 --- a/shortcuts/sheets/lark_sheet_batch_update.go +++ b/shortcuts/sheets/lark_sheet_batch_update.go @@ -23,7 +23,7 @@ import ( // (high-risk-write) // // The tool's contract: -// { excel_id, operations: [{tool, params}, ...], continue_on_error? } +// { excel_id, operations: [{tool_name, input}, ...], continue_on_error? } // // continue_on_error defaults to false (strict transaction): any failure // rolls back the whole batch. CLI leaves the default in place for the @@ -42,7 +42,7 @@ var BatchUpdate = common.Shortcut{ HasFormat: true, Flags: append(publicTokenFlags(), common.Flag{Name: "operations", Input: []string{common.File, common.Stdin}, Required: true, - Desc: "operations JSON array: [{tool, params}, ...] (or an envelope object with operations / continue_on_error)"}, + Desc: "operations JSON array: [{tool_name, input}, ...] (or an envelope object with operations / continue_on_error)"}, common.Flag{Name: "continue-on-error", Type: "bool", Desc: "flip the default strict transaction off; partial success is kept on disk"}, ), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { @@ -215,8 +215,8 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[ } cells := fillCellsMatrix(rows, cols, prototype) ops = append(ops, map[string]interface{}{ - "tool": "set_cell_range", - "params": map[string]interface{}{ + "tool_name": "set_cell_range", + "input": map[string]interface{}{ "excel_id": token, "sheet_name": sheet, "range": sub, @@ -364,8 +364,8 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool } cells := fillCellsMatrix(rows, cols, prototype) ops = append(ops, map[string]interface{}{ - "tool": "set_cell_range", - "params": map[string]interface{}{ + "tool_name": "set_cell_range", + "input": map[string]interface{}{ "excel_id": token, "sheet_name": sheet, "range": sub, diff --git a/shortcuts/sheets/lark_sheet_batch_update_test.go b/shortcuts/sheets/lark_sheet_batch_update_test.go index c74e3270f..8dcf23014 100644 --- a/shortcuts/sheets/lark_sheet_batch_update_test.go +++ b/shortcuts/sheets/lark_sheet_batch_update_test.go @@ -17,7 +17,7 @@ func TestBatchUpdate_RawPassthrough(t *testing.T) { body := parseDryRunBody(t, BatchUpdate, []string{ "--url", testURL, - "--operations", `[{"tool":"set_cell_range","params":{"excel_id":"shtcnTOK","sheet_id":"sh1","range":"A1","cells":[[{"value":42}]]}}]`, + "--operations", `[{"tool_name":"set_cell_range","input":{"excel_id":"shtcnTOK","sheet_id":"sh1","range":"A1","cells":[[{"value":42}]]}}]`, "--continue-on-error", "--yes", }) @@ -35,7 +35,7 @@ func TestBatchUpdate_HighRiskWriteRequiresYes(t *testing.T) { t.Parallel() stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{ "--url", testURL, - "--operations", `[{"tool":"set_cell_range","params":{}}]`, + "--operations", `[{"tool_name":"set_cell_range","input":{}}]`, }) if err == nil { t.Fatalf("expected confirmation_required; stdout=%s stderr=%s", stdout, stderr) @@ -59,10 +59,10 @@ func TestCellsBatchSetStyle_FansOutOps(t *testing.T) { } for i, raw := range ops { op, _ := raw.(map[string]interface{}) - if op["tool"] != "set_cell_range" { - t.Errorf("op[%d].tool = %v, want set_cell_range", i, op["tool"]) + if op["tool_name"] != "set_cell_range" { + t.Errorf("op[%d].tool_name = %v, want set_cell_range", i, op["tool_name"]) } - params, _ := op["params"].(map[string]interface{}) + params, _ := op["input"].(map[string]interface{}) if params["sheet_name"] != "sheet1" { t.Errorf("op[%d].sheet_name = %v, want sheet1", i, params["sheet_name"]) } @@ -94,7 +94,7 @@ func TestDropdownUpdate_BatchPayload(t *testing.T) { } for i, raw := range ops { op, _ := raw.(map[string]interface{}) - params, _ := op["params"].(map[string]interface{}) + params, _ := op["input"].(map[string]interface{}) cells, _ := params["cells"].([]interface{}) if len(cells) != 4 { t.Errorf("op[%d] cells rows = %d, want 4 (A2:A5 / C2:C5)", i, len(cells)) @@ -126,7 +126,7 @@ func TestDropdownDelete_BatchClearsValidation(t *testing.T) { t.Fatalf("operations length = %d, want 1", len(ops)) } op := ops[0].(map[string]interface{}) - params, _ := op["params"].(map[string]interface{}) + params, _ := op["input"].(map[string]interface{}) cells, _ := params["cells"].([]interface{}) for i, raw := range cells { row, _ := raw.([]interface{}) diff --git a/shortcuts/sheets/lark_sheet_object_crud.go b/shortcuts/sheets/lark_sheet_object_crud.go index d87eba32d..96910fa59 100644 --- a/shortcuts/sheets/lark_sheet_object_crud.go +++ b/shortcuts/sheets/lark_sheet_object_crud.go @@ -378,7 +378,7 @@ var sparklineSpec = objectCRUDSpec{ toolName: "manage_sparkline_object", idFlag: "group-id", idField: "group_id", - createDataDesc: "sparkline group JSON: { type: line|column|win_loss, source_range, target_range, ... }", + createDataDesc: "sparkline group JSON: { type: line|column|winLoss, source_range, target_range, ... }", updateDataDesc: "full or partial sparkline group JSON (`+sparkline-list --group-id ` first, then patch)", } var SparklineCreate = newObjectCreateShortcut(sparklineSpec) diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index f9ea30ab1..c22d43270 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -41,7 +41,7 @@ var CellsSet = common.Shortcut{ Flags: append(publicSheetFlags(), common.Flag{Name: "range", Required: true, Desc: "target A1 range (e.g. A1:C10); cells dimensions must match"}, common.Flag{Name: "cells", Input: []string{common.File, common.Stdin}, Required: true, - Desc: "JSON body: { \"cells\": [[{value|formula|cell_styles|...}, ...]], optional copy_to_range / resize_width / resize_height }"}, + Desc: "JSON 2D matrix of cell objects, e.g. [[{\"value\":\"a\"},{\"value\":\"b\"}],[{\"value\":1},{\"value\":2}]]; dimensions must match --range"}, common.Flag{Name: "allow-overwrite", Type: "bool", Default: "true", Desc: "allow overwriting non-empty cells (default true)"}, common.Flag{Name: "max-cells", Type: "int", Default: "50000", Hidden: true, Desc: "anti-burst cells write cap"}, ), @@ -55,13 +55,9 @@ var CellsSet = common.Shortcut{ if strings.TrimSpace(runtime.Str("range")) == "" { return common.FlagErrorf("--range is required") } - body, err := requireJSONObject(runtime, "cells") - if err != nil { + if _, err := requireJSONArray(runtime, "cells"); err != nil { return err } - if _, ok := body["cells"]; !ok { - return common.FlagErrorf("--cells must include a \"cells\" field") - } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -93,24 +89,16 @@ var CellsSet = common.Shortcut{ } func cellsSetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) (map[string]interface{}, error) { - body, err := requireJSONObject(runtime, "cells") + cells, err := requireJSONArray(runtime, "cells") if err != nil { return nil, err } input := map[string]interface{}{ "excel_id": token, "range": strings.TrimSpace(runtime.Str("range")), + "cells": cells, } sheetSelectorForToolInput(input, sheetID, sheetName) - // --cells fields override any of these except the core selectors. - for k, v := range body { - switch k { - case "excel_id", "range", "sheet_id", "sheet_name": - // reserved for flat flags - default: - input[k] = v - } - } if !runtime.Bool("allow-overwrite") { input["allow_overwrite"] = false } diff --git a/shortcuts/sheets/lark_sheet_write_cells_test.go b/shortcuts/sheets/lark_sheet_write_cells_test.go index 00092d27a..224c7baf1 100644 --- a/shortcuts/sheets/lark_sheet_write_cells_test.go +++ b/shortcuts/sheets/lark_sheet_write_cells_test.go @@ -21,12 +21,12 @@ func TestWriteCellsShortcuts_DryRun(t *testing.T) { wantInput map[string]interface{} }{ { - name: "+cells-set with --data cells passthrough", + name: "+cells-set with --cells bare 2D array", sc: CellsSet, args: []string{ "--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", - "--cells", `{"cells":[[{"value":1},{"value":2}],[{"value":3},{"value":4}]]}`, + "--cells", `[[{"value":1},{"value":2}],[{"value":3},{"value":4}]]`, }, toolName: "set_cell_range", wantInput: map[string]interface{}{ @@ -42,7 +42,7 @@ func TestWriteCellsShortcuts_DryRun(t *testing.T) { args: []string{ "--url", testURL, "--sheet-id", testSheetID, "--range", "A1", - "--cells", `{"cells":[[{"value":1}]]}`, + "--cells", `[[{"value":1}]]`, "--allow-overwrite=false", }, toolName: "set_cell_range", @@ -50,6 +50,7 @@ func TestWriteCellsShortcuts_DryRun(t *testing.T) { "excel_id": testToken, "sheet_id": testSheetID, "range": "A1", + "cells": []interface{}{[]interface{}{map[string]interface{}{"value": float64(1)}}}, "allow_overwrite": false, }, }, @@ -169,7 +170,7 @@ func TestCellsSetStyle_RequiresAtLeastOneFlag(t *testing.T) { } } -func TestCellsSet_RequiresCellsField(t *testing.T) { +func TestCellsSet_RequiresJSONArray(t *testing.T) { t.Parallel() stdout, stderr, err := runShortcutCapturingErr(t, CellsSet, []string{ "--url", testURL, "--sheet-id", testSheetID, @@ -178,8 +179,8 @@ func TestCellsSet_RequiresCellsField(t *testing.T) { if err == nil { t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) } - if !strings.Contains(stdout+stderr+err.Error(), "must include a \"cells\" field") { - t.Errorf("expected cells-field guard; got=%s|%s|%v", stdout, stderr, err) + if !strings.Contains(stdout+stderr+err.Error(), "must be a JSON array") { + t.Errorf("expected JSON-array guard; got=%s|%s|%v", stdout, stderr, err) } } diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 3c0eab38d..2ba05d8ea 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -129,19 +129,28 @@ _数据验证配置_ ```bash lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" --yes \ - --data @ops.json - -# ops.json: -# { -# "operations": [ -# {"tool": "modify_sheet_structure", "params": {"sheet_id":"...","operation":"insert","dimension":"row","start":10,"end":12}}, -# {"tool": "set_cell_range", "params": {"sheet_id":"...","range":"A11:B12","values":[["a","b"],["c","d"]]}} -# ] -# } + --operations @ops.json + +# ops.json (array<{tool_name, input}>): +# [ +# {"tool_name": "modify_sheet_structure", "input": {"sheet_id":"...","operation":"insert","dimension":"row","start":10,"end":12}}, +# {"tool_name": "set_cell_range", "input": {"sheet_id":"...","range":"A11:B12","cells":[[{"value":"a"},{"value":"b"}],[{"value":"c"},{"value":"d"}]]}} +# ] +``` + +### `+cells-batch-set-style` + +多 range 应用同一组 style(服务端走 `batch_update` 原子事务): + +```bash +# 表头行 + 汇总行同时刷成蓝底白字 +lark-cli sheets +cells-batch-set-style --url "..." \ + --ranges '["sheet1!A1:F1","sheet1!A30:F30"]' \ + --background-color "#1E5BC6" --font-color "#FFFFFF" --font-weight bold ``` ### Validate / DryRun / Execute 约束 -- `Validate`:`--data` 必须合法 JSON,且 `operations` 是非空数组;逐个子操作 `tool` / `params.sheet_id` 字段必填校验;**禁止嵌套 batch_update**。 +- `Validate`:`+batch-update` 的 `--operations` 必须合法 JSON,且为非空数组;逐个子操作 `tool_name` / `input` 字段必填校验;**禁止嵌套 batch_update**。`+cells-batch-set-style` 的 `--ranges` 必须 JSON 数组、每项带 sheet 前缀;样式 flag 至少一个非空(或带 `--border-styles`)。 - `DryRun`:按顺序输出每个子操作的目标 API + 请求 body 模板;首个失败则整批 fail-fast(不实际执行任何后续)。 - `Execute`:按声明顺序串行执行;任一子操作失败立即中断并回滚到该子操作前状态(具体回滚能力取决于子操作类型,沿用 MCP `batch_update` 的语义)。 diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md index ca954b77f..f880c40ad 100644 --- a/skills/lark-sheets/references/lark-sheets-chart.md +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -180,17 +180,17 @@ _创建/更新的图表属性_ ```bash # 内联 JSON lark-cli sheets +chart-create --url "https://example.feishu.cn/sheets/shtXXX" \ - --sheet-name "Sheet1" --data '{"position":{"row":42,"col":"A"},"data":{...}}' + --sheet-name "Sheet1" --properties '{"position":{"row":42,"col":"A"},"size":{"width":600,"height":400},"snapshot":{...}}' # 走文件(推荐配置较多时) lark-cli sheets +chart-create --url "https://example.feishu.cn/sheets/shtXXX" \ - --sheet-name "Sheet1" --data @chart-config.json + --sheet-name "Sheet1" --properties @chart-config.json ``` -> **配置 JSON 关键字段**(详见上方语义内容章节): +> **`--properties` JSON 关键字段**(结构见上方 `## Schemas` 段;详见语义内容章节): > - `position.row` / `position.col` 必须留足空间,越界会被 API 拒 -> - `data.headerMode`:默认 inline;当 refs 仅覆盖数据子集且语义表头在子集之外,必须 `detached` + `nameRef` -> - chart 引用 pivot 输出时,`data_range` 必须排除总计 / 小计行 +> - `snapshot.data.headerMode`:默认 inline;当 refs 仅覆盖数据子集且语义表头在子集之外,必须 `detached` + `nameRef` +> - chart 引用 pivot 输出时,`snapshot.data.data_range` 必须排除总计 / 小计行 ### `+chart-update` @@ -212,7 +212,7 @@ lark-cli sheets +chart-delete --url "https://example.feishu.cn/sheets/shtXXX" \ ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`+chart-create` / `+chart-update` 的 `--data` 必须能解析为合法 JSON;`+chart-delete`(high-risk-write)校验 `--yes` 或 `--dry-run` 至少一个。 +- `Validate`:XOR 公共四件套;`+chart-create` / `+chart-update` 的 `--properties` 必须能解析为合法 JSON;`+chart-delete`(high-risk-write)校验 `--yes` 或 `--dry-run` 至少一个。 - `DryRun`:`+chart-create` / `+chart-update` 输出"将要 POST 的 body 模板";`+chart-delete` 输出"将要删除的 chart_id 及隶属 sheet",零网络副作用。 - `Execute`:写操作执行后自动调用 `+chart-list` 回读对比,记录到 `envelope.meta.verification`,便于上层根据回读结果判定是否符合预期。 diff --git a/skills/lark-sheets/references/lark-sheets-conditional-format.md b/skills/lark-sheets/references/lark-sheets-conditional-format.md index 7fe8753be..5115dd2e8 100644 --- a/skills/lark-sheets/references/lark-sheets-conditional-format.md +++ b/skills/lark-sheets/references/lark-sheets-conditional-format.md @@ -154,16 +154,28 @@ _创建/更新的条件格式属性_ ### `+cond-format-create` +`--rule-type` / `--ranges` 是独立 flag(不要再放 `--properties`);`style` / `attrs` 等结构走 `--properties`: + ```bash -lark-cli sheets +cond-format-create --url "..." --sheet-id "$SID" --data @rule.json +# 重复值高亮 +lark-cli sheets +cond-format-create --url "..." --sheet-id "$SID" \ + --rule-type duplicate --ranges '["A1:A100"]' \ + --properties '{"style":{"background_color":"#FFD7D7"}}' + +# 数据条 +lark-cli sheets +cond-format-create --url "..." --sheet-id "$SID" \ + --rule-type dataBar --ranges '["B2:B100"]' \ + --properties @rule.json ``` ### `+cond-format-update` +整组覆盖式:先 `+cond-format-list --rule-id ` 拿当前完整配置,改后整组传回。 + ### `+cond-format-delete` ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`--data.range` 与 `--data.rule.type` 必填;按 type 检查必填子字段(`cell_value` 需 `operator` + `value`、`formula` 需 `expression`、`color_scale` 需 `min/mid/max` 配色等);`+cond-format-delete` 强制 `--yes` 或 `--dry-run`。 +- `Validate`:XOR 公共四件套;`--rule-type` / `--ranges` 必填;`--properties` 必须能解析为合法 JSON;按 `--rule-type` 检查必填子字段(`cellValue` 需 `attrs.operator` + `attrs.value`、`formula` 需 `attrs.expression`、`colorScale` 需 `min/mid/max` 配色等);`+cond-format-delete` 强制 `--yes` 或 `--dry-run`。 - `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 conditional_format 请求模板"。 - `Execute`:写后调用 `+cond-format-list --rule-id ` 回读,envelope.meta.verification 给出规则 / 范围 / 样式对比。 diff --git a/skills/lark-sheets/references/lark-sheets-filter-view.md b/skills/lark-sheets/references/lark-sheets-filter-view.md index 6f1dce115..1ad54201f 100644 --- a/skills/lark-sheets/references/lark-sheets-filter-view.md +++ b/skills/lark-sheets/references/lark-sheets-filter-view.md @@ -116,22 +116,19 @@ lark-cli sheets +filter-view-list --url "..." --sheet-id "$SID" --view-id vAbcde ### `+filter-view-create` +`--range`(必填)/ `--view-name`(可选)是独立 flag;`rules` 走 `--properties`: + ```bash lark-cli sheets +filter-view-create --url "..." --sheet-id "$SID" \ - --data '{ - "view_name": "活跃用户", - "range": "A1:F1000", - "rules": [ - {"col": "C", "filter_type": "number", "compare": "greater", "expected": [100]} - ] - }' + --view-name "活跃用户" --range "A1:F1000" \ + --properties '{"rules":[{"col":"C","filter_type":"number","compare":"greater","expected":[100]}]}' ``` -> `range` **必须覆盖表头行**(如 `A1:F1000`),不能只包含数据行;`view_name` 重名时服务端自动改名。 +> `--range` **必须覆盖表头行**(如 `A1:F1000`),不能只包含数据行;`--view-name` 重名时服务端自动改名。 ### `+filter-view-update` -> ⚠️ update 是 patch:传 `view_name` / `range` / `rules` 任意一个或多个;先 `+filter-view-list` 读取当前 rules 再回写差异。重复 `+filter-view-create` 不会复用 view_id,会产生新视图。 +> ⚠️ update 是 patch:`--view-name` / `--range` / `--properties.rules` 任传一个或多个;至少传一个。先 `+filter-view-list` 读取当前 rules 再回写差异。重复 `+filter-view-create` 不会复用 view_id,会产生新视图。 ### `+filter-view-delete` @@ -139,6 +136,6 @@ lark-cli sheets +filter-view-create --url "..." --sheet-id "$SID" \ ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`+filter-view-create` 校验 `--data.range` 起始行为表头(第一行);`+filter-view-update` 必须先 `+filter-view-list` 确认 view 存在;`+filter-view-delete` 强制 `--yes` 或 `--dry-run`。 +- `Validate`:XOR 公共四件套;`+filter-view-create` 校验 `--range` 起始行为表头(第一行);`+filter-view-update` 必须先 `+filter-view-list` 确认 view 存在,且 `--view-name` / `--range` / `--properties` 至少传一个;`+filter-view-delete` 强制 `--yes` 或 `--dry-run`。 - `DryRun`:输出"将要 POST/PATCH/DELETE 的 view 请求模板",零网络副作用;`--sheet-name` 在 dry-run 输出里生成为 `` 占位符。 - `Execute`:写后调用 `+filter-view-list --view-id ` 回读,envelope.meta.verification 给出当前 range + rules 与请求体的对比。 diff --git a/skills/lark-sheets/references/lark-sheets-filter.md b/skills/lark-sheets/references/lark-sheets-filter.md index 35e9198c1..f10fe22b2 100644 --- a/skills/lark-sheets/references/lark-sheets-filter.md +++ b/skills/lark-sheets/references/lark-sheets-filter.md @@ -104,15 +104,17 @@ _创建/更新的筛选器属性_ ### `+filter-create` +`--range` 是独立 flag(含表头行);`rules` 走 `--properties`: + ```bash lark-cli sheets +filter-create --url "..." --sheet-id "$SID" \ --range "A1:F1000" \ - --data '{"conditions":[{"col":"B","filter_type":"multiValue","expected":["北京","上海"]}]}' + --properties '{"rules":[{"col":"B","filter_type":"multiValue","expected":["北京","上海"]}]}' ``` ### `+filter-update` -> ⚠️ update 是覆盖式:传 `conditions` 会用整组新条件替换旧组。如只想加一条,要带上已有的全部条件再追加。 +> ⚠️ update 是覆盖式:`--properties` 中传新 `rules` 会替换旧组。如只想加一条,要带上已有的全部条件再追加。必填 `--range`。 ### `+filter-delete` diff --git a/skills/lark-sheets/references/lark-sheets-float-image.md b/skills/lark-sheets/references/lark-sheets-float-image.md index 9a77c13b3..857b84c04 100644 --- a/skills/lark-sheets/references/lark-sheets-float-image.md +++ b/skills/lark-sheets/references/lark-sheets-float-image.md @@ -117,18 +117,28 @@ reference_id 的映射规则: ### `+float-image-create` -> `image_uri` 通常是先用 `upload_sheet_asset`(暂无 CLI shortcut,走 raw API)上传后拿到的 token,或者用 https URL(部分租户可直接引用)。 +所有字段拍平为独立 flag:`--image-name` / `--image-token` 或 `--image-uri`(XOR) / `--position-{row,col}` / `--size-{width,height}` / `--offset-{row,col}` / `--z-index`。 ```bash -lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" --data @img.json +# 用 file_token(从 +float-image-list 返回的 image_token 或独立上传得到) +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(部分租户直接引用) +lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \ + --image-name "logo.png" --image-uri "<|image|>:abcdef" \ + --position-row 2 --position-col B --size-width 300 --size-height 200 --z-index 1 ``` ### `+float-image-update` +> 必须先 `+float-image-list --float-image-id ` 回读当前完整属性,再通过 `--image-name` / `--position-*` / `--size-*` 等独立 flag 改对应字段。 + ### `+float-image-delete` ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`+float-image-create` 校验 `--data.image_uri` 非空、`position` / `size` 合法;`+float-image-update` 必须 `--float-image-id`;`+float-image-delete` 强制 `--yes` 或 `--dry-run`。 +- `Validate`:XOR 公共四件套;`+float-image-create` 校验 `--image-name` 非空,`--image-token` 与 `--image-uri` 互斥且至少一个非空,`--position-row/col` 与 `--size-width/height` 必填且为合法整数;`+float-image-update` 必须 `--float-image-id`;`+float-image-delete` 强制 `--yes` 或 `--dry-run`。 - `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 float_image 请求模板"。 - `Execute`:写后调用 `+float-image-list --float-image-id ` 回读,envelope.meta.verification 给出新位置 / 尺寸对比。 diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md index 3b317e471..2e13fb7ed 100644 --- a/skills/lark-sheets/references/lark-sheets-pivot-table.md +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -128,19 +128,22 @@ _创建/更新的透视表属性_ ### `+pivot-create` -> 数据源 `data_range` 必须从表头行开始;空行 / 汇总行会被当作数据参与聚合,需提前用 `+csv-get` 确认起止边界。 +> 数据源 `--source` 必须从表头行开始;空行 / 汇总行会被当作数据参与聚合,需提前用 `+csv-get` 确认起止边界。`--source` 和 `--range` 是独立 flag(不要再放 `--properties`);`rows` / `columns` / `values` 等数组字段走 `--properties`。 ```bash -lark-cli sheets +pivot-create --url "..." --sheet-id "$SRC_SID" --data @pivot.json +lark-cli sheets +pivot-create --url "..." --sheet-id "$SRC_SID" \ + --source "Sheet1!A1:D100" --range "F1" --properties @pivot.json ``` ### `+pivot-update` +> 不允许改 `--source` / `--range`(透视表创建后位置/数据源固定);只能用 `--properties` 改 rows / columns / values / filters 等。先 `+pivot-list --pivot-table-id ` 回读再 patch,避免漏字段。 + ### `+pivot-delete` ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`+pivot-create` 的 `--data.data_range` 必须含表头行;`rows`/`columns`/`values` 至少非空之一;`+pivot-delete` 强制 `--yes` 或 `--dry-run`。 +- `Validate`:XOR 公共四件套;`+pivot-create` 的 `--source` 必填且必须含表头行;`--properties` 中 `rows` / `columns` / `values` 至少非空之一;`+pivot-delete` 强制 `--yes` 或 `--dry-run`。 - `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 pivot 请求模板"+ 预估输出尺寸(行数 × 列数)。 - `Execute`:写后调用 `+pivot-list --pivot-table-id ` 回读 + `+csv-get` 抽样读透视产物,envelope.meta.verification 给出实际输出尺寸 + 总计行位置。 diff --git a/skills/lark-sheets/references/lark-sheets-sparkline.md b/skills/lark-sheets/references/lark-sheets-sparkline.md index da3f7e5c9..81d0767bd 100644 --- a/skills/lark-sheets/references/lark-sheets-sparkline.md +++ b/skills/lark-sheets/references/lark-sheets-sparkline.md @@ -100,7 +100,7 @@ _创建/更新/部分删除的迷你图属性_ > `data_range` 是每个迷你图的数据序列;`target_range` 是迷你图生成的目标 cells(通常每个 cell 一个迷你图)。 ```bash -lark-cli sheets +sparkline-create --url "..." --sheet-id "$SID" --data @sparkline.json +lark-cli sheets +sparkline-create --url "..." --sheet-id "$SID" --properties @sparkline.json ``` ### `+sparkline-update` @@ -109,6 +109,6 @@ lark-cli sheets +sparkline-create --url "..." --sheet-id "$SID" --data @sparklin ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`--data.type` 必须命中 enum;`--data.data_range` 与 `--data.target_range` 行/列数需对齐;`+sparkline-delete` 强制 `--yes` 或 `--dry-run`。 +- `Validate`:XOR 公共四件套;`--properties.type` 必须命中 enum(`line` / `column` / `winLoss`);`--properties.data_range` 与 `--properties.target_range` 行/列数需对齐;`+sparkline-delete` 强制 `--yes` 或 `--dry-run`。 - `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 sparkline group 请求模板"。 - `Execute`:写后调用 `+sparkline-list --group-id ` 回读,envelope.meta.verification 给出 type / style / 生成范围对比。 diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 8120bf88b..1de922143 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -307,14 +307,38 @@ _数据验证配置_ # 纯值(数组形态) lark-cli sheets +cells-set --url "https://example.feishu.cn/sheets/shtXXX" \ --sheet-name "Sheet1" --range "A1:B2" --allow-overwrite \ - --data '{"values":[["name","score"],["alice",95]]}' + --cells '[[{"value":"name"},{"value":"score"}],[{"value":"alice"},{"value":95}]]' -# 富 cell(公式 + 样式) +# 富 cell(公式 + 样式,cells 是二维矩阵每元素一个 cell schema) lark-cli sheets +cells-set --spreadsheet-token shtXXX --sheet-id "$SID" \ - --range "C2:C10" --data @rich-cells.json + --range "C2:C10" --cells @rich-cells.json ``` -`--data` 富格式见 SKILL.md 上方"使用场景"段;值 / 公式 / 样式 / 批注 / 嵌入图片可同一次写入混合提交。 +`--cells` 富格式见 `## Schemas` 段(cells 元素含 value / formula / cell_styles / border_styles / data_validation / multiple_values / note / rich_text);值 / 公式 / 样式 / 批注 / 嵌入图片可同一次写入混合提交。 + +### `+cells-set-style` + +只改样式,不动 value / formula。10 个 cell_styles 字段拍平为独立 flag,边框走 `--border-styles` JSON。 + +```bash +# 加粗 + 黄底 +lark-cli sheets +cells-set-style --url "..." --sheet-name "Sheet1" \ + --range "A1:B2" --font-weight bold --background-color "#FFFF00" + +# 配套边框 +lark-cli sheets +cells-set-style --url "..." --sheet-id "$SID" \ + --range "A1:D10" --font-size 12 --horizontal-alignment center \ + --border-styles '{"top":{"style":"solid","color":"#000","weight":"thin"},"bottom":{"style":"solid","color":"#000","weight":"thin"}}' +``` + +### `+cells-set-image` + +把单张图片嵌入 cell(必须单 cell 范围): + +```bash +lark-cli sheets +cells-set-image --url "..." --sheet-name "Sheet1" \ + --range "A1" --image ./logo.png +``` ### `+csv-put` @@ -335,6 +359,6 @@ lark-cli sheets +csv-put --spreadsheet-token shtXXX --sheet-id "$SID" \ ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`+cells-set` 的 `--data.values` 必须矩形(每行列数相等);`+csv-put` 的 `--csv` 必须能按 RFC 4180 解析;防爆参数上限校验。 +- `Validate`:XOR 公共四件套;`+cells-set` 的 `--cells` 必须能解析为 JSON 二维矩阵且行列数与 `--range` 完全一致;`+cells-set-style` 的样式 flag 至少一个非空(或带 `--border-styles`);`+cells-set-image` 的 `--range` 必须是单 cell(起止 cell 相同);`+csv-put` 的 `--csv` 必须能按 RFC 4180 解析;防爆参数上限校验。 - `DryRun`:输出目标 range + 推断尺寸 + 是否覆盖非空 cell 警告,零网络副作用。 - `Execute`:写后调用 `+cells-get --ranges <写入区域> --include value,formula` 抽样回读,envelope.meta.verification 给出"预期 vs 实际"对比。 From 347d80361dcf6adc40692bf846fc3e649c5c7b7e Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Mon, 18 May 2026 20:06:31 +0800 Subject: [PATCH 015/114] fix(sheets): repair cells-set-image rich_text embed payload The server rejected set_cell_range calls from +cells-set-image with three distinct errors: missing "text" property, missing image_width/image_height, and unknown attachment_token field. Realign the rich_text element to the embed-image schema (text/image_token/image_width/image_height) and decode PNG/JPEG/GIF dimensions from the local file before the write. --- shortcuts/sheets/lark_sheet_write_cells.go | 29 +++++++++++++++---- .../sheets/lark_sheet_write_cells_test.go | 21 ++++++++++---- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index c22d43270..7d61f5a95 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -6,6 +6,10 @@ package sheets import ( "context" "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" "path/filepath" "strconv" "strings" @@ -589,9 +593,11 @@ var CellsSetImage = common.Shortcut{ "sheet_id": sheetSelectorPlaceholder(sheetID, sheetName), "cells": [][]interface{}{{map[string]interface{}{ "rich_text": []map[string]interface{}{{ - "type": "embed-image", - "attachment_token": "", - "attachment_name": fileName, + "type": "embed-image", + "text": "", + "image_token": "", + "image_width": "", + "image_height": "", }}, }}}, }) @@ -627,6 +633,15 @@ var CellsSetImage = common.Shortcut{ if err != nil { return common.WrapInputStatError(err) } + imgFile, err := runtime.FileIO().Open(imgPath) + if err != nil { + return common.WrapInputStatError(err) + } + imgCfg, _, err := image.DecodeConfig(imgFile) + imgFile.Close() + if err != nil { + return fmt.Errorf("decode image dimensions: %w", err) + } fileToken, err := common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ FilePath: imgPath, FileName: fileName, @@ -643,9 +658,11 @@ var CellsSetImage = common.Shortcut{ "range": strings.TrimSpace(runtime.Str("range")), "cells": [][]interface{}{{map[string]interface{}{ "rich_text": []map[string]interface{}{{ - "type": "embed-image", - "attachment_token": fileToken, - "attachment_name": fileName, + "type": "embed-image", + "text": "", + "image_token": fileToken, + "image_width": imgCfg.Width, + "image_height": imgCfg.Height, }}, }}}, } diff --git a/shortcuts/sheets/lark_sheet_write_cells_test.go b/shortcuts/sheets/lark_sheet_write_cells_test.go index 224c7baf1..e23f547d5 100644 --- a/shortcuts/sheets/lark_sheet_write_cells_test.go +++ b/shortcuts/sheets/lark_sheet_write_cells_test.go @@ -222,8 +222,17 @@ func TestCellsSetImage_DryRun(t *testing.T) { if item["type"] != "embed-image" { t.Errorf("rich_text.type = %v, want embed-image", item["type"]) } - if item["attachment_name"] != "README.md" { - t.Errorf("attachment_name = %v, want README.md (basename)", item["attachment_name"]) + if item["image_token"] != "" { + t.Errorf("image_token = %v, want ", item["image_token"]) + } + if item["text"] != "" { + t.Errorf("text = %v, want empty string", item["text"]) + } + if item["image_width"] != "" { + t.Errorf("image_width = %v, want ", item["image_width"]) + } + if item["image_height"] != "" { + t.Errorf("image_height = %v, want ", item["image_height"]) } } @@ -243,10 +252,10 @@ func TestCellsSetImage_RangeMustBeSingleCell(t *testing.T) { func TestRangeDimensions(t *testing.T) { t.Parallel() cases := []struct { - in string - wantRows int - wantCols int - wantErr bool + in string + wantRows int + wantCols int + wantErr bool }{ {"A1", 1, 1, false}, {"A1:B2", 2, 2, false}, From 184949ff0ce5d50948665c8e0151bf464834bfb0 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Tue, 19 May 2026 12:50:32 +0800 Subject: [PATCH 016/114] refactor(sheets)!: split +dim-resize into +rows-resize and +cols-resize Sync to upstream spec change that splits the legacy +dim-resize shortcut into +rows-resize and +cols-resize. Reasoning is that row vs column resize has divergent semantics (only rows support auto-fit) and the shared --dimension flag was hiding that. Behavior changes (BREAKING): - +dim-resize is removed; use +rows-resize or +cols-resize. - --dimension and --reset flags are gone. - --type enum replaces --size/--reset: pixel (requires --size) standard (reset to sheet default; no --size) auto (auto-fit row height; +rows-resize only) - --end is now inclusive (was exclusive). Old "--start 0 --end 5" (5 rows) becomes "--start 0 --end 4". - Wire payload for resize_height / resize_width changes from {value: N} | {reset: true} to {type: "pixel", value: N} | {type: "standard"} | {type: "auto"}. Tests cover both shortcuts across pixel / standard / auto and the new guard surface (--type pixel needs --size; standard/auto reject --size; +cols-resize rejects --type auto; --end < --start). Also pulls in synced reference docs for 5 skills (batch-update, core-operations, range-operations, sheet-structure, visual-standards) that update prose mentions of +dim-resize. --- .../sheets/lark_sheet_range_operations.go | 165 +++++++++++++----- .../lark_sheet_range_operations_test.go | 69 ++++++-- .../sheets/lark_sheet_sheet_structure.go | 4 +- .../sheets/lark_sheet_write_cells_test.go | 2 +- shortcuts/sheets/shortcuts.go | 3 +- .../references/lark-sheets-batch-update.md | 2 +- .../references/lark-sheets-core-operations.md | 18 +- .../lark-sheets-range-operations.md | 62 +++++-- .../references/lark-sheets-sheet-structure.md | 8 +- .../lark-sheets-visual-standards.md | 2 +- 10 files changed, 240 insertions(+), 95 deletions(-) diff --git a/shortcuts/sheets/lark_sheet_range_operations.go b/shortcuts/sheets/lark_sheet_range_operations.go index 3711e3401..f249f317a 100644 --- a/shortcuts/sheets/lark_sheet_range_operations.go +++ b/shortcuts/sheets/lark_sheet_range_operations.go @@ -12,15 +12,15 @@ import ( // ─── lark_sheet_range_operations ────────────────────────────────────── // -// Four tools, eight shortcuts: +// Four tools, nine shortcuts: // // - clear_cell_range → +cells-clear (high-risk-write) // - merge_cells → +cells-merge / +cells-unmerge -// - resize_range → +dim-resize +// - resize_range → +rows-resize / +cols-resize // - transform_range → +range-move / +range-copy / +range-fill / +range-sort // -// +dim-resize is grouped under "工作表" for CLI discoverability even though -// the backing tool lives in this skill. +// +rows-resize / +cols-resize are grouped under "工作表" for CLI discoverability +// even though the backing tool lives in this skill. // CellsClear wraps clear_cell_range. // @@ -178,56 +178,85 @@ func mergeInput(runtime *common.RuntimeContext, token, sheetID, sheetName, op st return input } -// DimResize wraps resize_range to set row heights or column widths. --size -// is the target pixel count; --reset restores the sheet default. +// resize_range now exposes two CLI shortcuts: // -// The tool's resize_height / resize_width fields take an object shape; until -// the new endpoint is observable in production we wrap the pixel value as -// {value: }. Pass --reset to send {reset: true} instead. -var DimResize = common.Shortcut{ +// +rows-resize / +cols-resize — set row heights / column widths. The new +// --type enum (pixel / standard / [auto]) replaces the old --size/--reset +// pair; --type pixel still takes a --size pixel value, --type standard +// restores the sheet default, --type auto auto-fits row heights (rows only). +// +// Wire shape: resize_height / resize_width carries { type, value? }, e.g. +// { "type": "pixel", "value": 30 } or { "type": "standard" }. +// +// Both shortcuts share the underlying resize_range tool; --end is inclusive +// in the new CLI surface (was exclusive in the legacy +dim-resize). + +var rowsResizeTypeEnum = []string{"pixel", "standard", "auto"} +var colsResizeTypeEnum = []string{"pixel", "standard"} + +// RowsResize wraps resize_range for row heights. --type auto enables +// auto-fit (rows only); --type pixel requires --size. +var RowsResize = common.Shortcut{ Service: "sheets", - Command: "+dim-resize", - Description: "Set row heights or column widths in a range (--size px or --reset to default).", + Command: "+rows-resize", + Description: "Resize rows by pixel / standard / auto (--type pixel needs --size; --start/--end are 0-based inclusive).", Risk: "write", Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: append(publicSheetFlags(), - common.Flag{Name: "dimension", Required: true, Enum: dimEnum, Desc: "`row` or `column`"}, - common.Flag{Name: "start", Type: "int", Required: true, Desc: "0-based start position (inclusive)"}, - common.Flag{Name: "end", Type: "int", Required: true, Desc: "0-based end position (exclusive)"}, - common.Flag{Name: "size", Type: "int", Default: "0", Desc: "target size in pixels"}, - common.Flag{Name: "reset", Type: "bool", Desc: "reset to default size (mutually exclusive with --size)"}, + common.Flag{Name: "start", Type: "int", Required: true, Desc: "0-based start row (inclusive)"}, + common.Flag{Name: "end", Type: "int", Required: true, Desc: "0-based end row (inclusive)"}, + common.Flag{Name: "type", Required: true, Enum: rowsResizeTypeEnum, + Desc: "sizing mode: `pixel` (needs --size) / `standard` (reset to default) / `auto` (auto-fit row height)"}, + common.Flag{Name: "size", Type: "int", Default: "0", Desc: "row height in pixels (e.g. 30); required with --type pixel, ignored otherwise"}, ), - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { + Validate: validateResize("row"), + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindWrite, "resize_range", resizeInput(runtime, token, sheetID, sheetName, "row")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { return err } - if _, _, err := resolveSheetSelector(runtime); err != nil { + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { return err } - if !runtime.Changed("dimension") { - return common.FlagErrorf("--dimension is required") - } - if !runtime.Changed("start") || !runtime.Changed("end") { - return common.FlagErrorf("--start and --end are required") - } - if runtime.Int("start") < 0 || runtime.Int("end") <= runtime.Int("start") { - return common.FlagErrorf("invalid range: --start (%d) must be >= 0 and --end (%d) must be greater", runtime.Int("start"), runtime.Int("end")) - } - hasSize := runtime.Changed("size") && runtime.Int("size") > 0 - if !hasSize && !runtime.Bool("reset") { - return common.FlagErrorf("specify either --size or --reset") - } - if hasSize && runtime.Bool("reset") { - return common.FlagErrorf("--size and --reset are mutually exclusive") + out, err := callTool(ctx, runtime, token, ToolKindWrite, "resize_range", resizeInput(runtime, token, sheetID, sheetName, "row")) + if err != nil { + return err } + runtime.Out(out, nil) return nil }, +} + +// ColsResize wraps resize_range for column widths. Column widths do not +// support auto-fit — --type only accepts pixel / standard. +var ColsResize = common.Shortcut{ + Service: "sheets", + Command: "+cols-resize", + Description: "Resize columns by pixel / standard (--type pixel needs --size; --start/--end are 0-based inclusive; no auto for cols).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: append(publicSheetFlags(), + common.Flag{Name: "start", Type: "int", Required: true, Desc: "0-based start column (inclusive)"}, + common.Flag{Name: "end", Type: "int", Required: true, Desc: "0-based end column (inclusive)"}, + common.Flag{Name: "type", Required: true, Enum: colsResizeTypeEnum, + Desc: "sizing mode: `pixel` (needs --size) / `standard` (reset to default); `auto` is rows-only"}, + common.Flag{Name: "size", Type: "int", Default: "0", Desc: "column width in pixels (e.g. 120); required with --type pixel, ignored otherwise"}, + ), + Validate: validateResize("column"), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - return invokeToolDryRun(token, ToolKindWrite, "resize_range", dimResizeInput(runtime, token, sheetID, sheetName)) + return invokeToolDryRun(token, ToolKindWrite, "resize_range", resizeInput(runtime, token, sheetID, sheetName, "column")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -238,7 +267,7 @@ var DimResize = common.Shortcut{ if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "resize_range", dimResizeInput(runtime, token, sheetID, sheetName)) + out, err := callTool(ctx, runtime, token, ToolKindWrite, "resize_range", resizeInput(runtime, token, sheetID, sheetName, "column")) if err != nil { return err } @@ -247,21 +276,65 @@ var DimResize = common.Shortcut{ }, } -func dimResizeInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { - dim := runtime.Str("dimension") - rangeStr := dimRange(dim, runtime.Int("start"), runtime.Int("end")) +// validateResize returns a Validate closure shared by both rows/cols shortcuts. +// dimension is either "row" or "column"; the closure rejects --type auto on +// columns (column widths do not support auto-fit). +func validateResize(dimension string) func(ctx context.Context, runtime *common.RuntimeContext) error { + return func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if !runtime.Changed("start") || !runtime.Changed("end") { + return common.FlagErrorf("--start and --end are required") + } + if runtime.Int("start") < 0 || runtime.Int("end") < runtime.Int("start") { + return common.FlagErrorf("invalid range: --start (%d) must be >= 0 and --end (%d) must be >= --start", runtime.Int("start"), runtime.Int("end")) + } + typ := strings.TrimSpace(runtime.Str("type")) + if typ == "" { + return common.FlagErrorf("--type is required (pixel / standard%s)", autoSuffix(dimension)) + } + if dimension == "column" && typ == "auto" { + return common.FlagErrorf("--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 common.FlagErrorf("--type pixel requires --size ") + } + if typ != "pixel" && hasSize { + return common.FlagErrorf("--size is only valid with --type pixel") + } + return nil + } +} + +// autoSuffix appends " / auto" to the enum hint for rows. +func autoSuffix(dimension string) string { + if dimension == "row" { + return " / auto" + } + return "" +} + +// resizeInput builds the resize_range tool input. dimension is "row" / +// "column"; --end is inclusive on the CLI surface, dimRange wants +// exclusive end, so it is bumped by one here. +func resizeInput(runtime *common.RuntimeContext, token, sheetID, sheetName, dimension string) map[string]interface{} { + rangeStr := dimRange(dimension, runtime.Int("start"), runtime.Int("end")+1) input := map[string]interface{}{ "excel_id": token, "range": rangeStr, } sheetSelectorForToolInput(input, sheetID, sheetName) - var sizeBlock interface{} - if runtime.Bool("reset") { - sizeBlock = map[string]interface{}{"reset": true} - } else { - sizeBlock = map[string]interface{}{"value": runtime.Int("size")} + typ := strings.TrimSpace(runtime.Str("type")) + sizeBlock := map[string]interface{}{"type": typ} + if typ == "pixel" { + sizeBlock["value"] = runtime.Int("size") } - if dim == "row" { + if dimension == "row" { input["resize_height"] = sizeBlock } else { input["resize_width"] = sizeBlock diff --git a/shortcuts/sheets/lark_sheet_range_operations_test.go b/shortcuts/sheets/lark_sheet_range_operations_test.go index 547c776c3..11bdb1c7e 100644 --- a/shortcuts/sheets/lark_sheet_range_operations_test.go +++ b/shortcuts/sheets/lark_sheet_range_operations_test.go @@ -67,30 +67,54 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) { }, }, { - name: "+dim-resize row --size 200", - sc: DimResize, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--start", "0", "--end", "5", "--size", "200"}, + name: "+rows-resize --type pixel --size 200", + sc: RowsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "0", "--end", "4", "--type", "pixel", "--size", "200"}, toolName: "resize_range", wantInput: map[string]interface{}{ "excel_id": testToken, "sheet_id": testSheetID, "range": "1:5", "resize_height": map[string]interface{}{ + "type": "pixel", "value": float64(200), }, }, }, { - name: "+dim-resize column --reset", - sc: DimResize, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "column", "--start", "1", "--end", "4", "--reset"}, + name: "+rows-resize --type auto omits --size", + sc: RowsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "0", "--end", "0", "--type", "auto"}, + toolName: "resize_range", + wantInput: map[string]interface{}{ + "range": "1", + "resize_height": map[string]interface{}{"type": "auto"}, + }, + }, + { + name: "+cols-resize --type standard (reset to default)", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "1", "--end", "3", "--type", "standard"}, toolName: "resize_range", wantInput: map[string]interface{}{ "excel_id": testToken, "sheet_id": testSheetID, "range": "B:D", "resize_width": map[string]interface{}{ - "reset": true, + "type": "standard", + }, + }, + }, + { + name: "+cols-resize --type pixel --size 120", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "0", "--end", "2", "--type", "pixel", "--size", "120"}, + toolName: "resize_range", + wantInput: map[string]interface{}{ + "range": "A:C", + "resize_width": map[string]interface{}{ + "type": "pixel", + "value": float64(120), }, }, }, @@ -187,29 +211,44 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) { } } -func TestDimResize_MutualExclusion(t *testing.T) { +func TestResize_TypeAndSizeGuards(t *testing.T) { t.Parallel() cases := []struct { name string + sc common.Shortcut args []string want string }{ { - name: "missing both --size and --reset", - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--start", "0", "--end", "3"}, - want: "specify either --size or --reset", + name: "+rows-resize --type pixel without --size", + sc: RowsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "0", "--end", "3", "--type", "pixel"}, + want: "--type pixel requires --size", + }, + { + name: "+rows-resize --type standard with --size", + sc: RowsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "0", "--end", "3", "--type", "standard", "--size", "30"}, + want: "--size is only valid with --type pixel", + }, + { + name: "+cols-resize rejects --type auto", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "0", "--end", "2", "--type", "auto"}, + want: "auto", // cobra Enum gate kicks first with "valid values are: pixel, standard" }, { - name: "both --size and --reset", - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--start", "0", "--end", "3", "--size", "200", "--reset"}, - want: "mutually exclusive", + name: "--end < --start", + sc: RowsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "5", "--end", "3", "--type", "standard"}, + want: "must be >= --start", }, } for _, tt := range cases { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - stdout, stderr, err := runShortcutCapturingErr(t, DimResize, append(tt.args, "--dry-run")) + stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run")) if err == nil { t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) } diff --git a/shortcuts/sheets/lark_sheet_sheet_structure.go b/shortcuts/sheets/lark_sheet_sheet_structure.go index f24869b05..acfe1feeb 100644 --- a/shortcuts/sheets/lark_sheet_sheet_structure.go +++ b/shortcuts/sheets/lark_sheet_sheet_structure.go @@ -19,8 +19,8 @@ import ( // end; the tool wants 1-based inclusive row numbers ("3:7") or column // letters ("C:F"). The conversion lives in dimRange / dimPosition below. // -// +dim-resize lives in lark_sheet_range_operations (different tool); it is -// only grouped under "工作表" for discoverability. +// +rows-resize / +cols-resize live in lark_sheet_range_operations (different +// tool); they are only grouped under "工作表" for discoverability. // SheetInfo wraps get_sheet_structure: row heights, column widths, hidden // rows/cols, merged cells, row/column groups, and freeze counts for one diff --git a/shortcuts/sheets/lark_sheet_write_cells_test.go b/shortcuts/sheets/lark_sheet_write_cells_test.go index e23f547d5..f3e8e319e 100644 --- a/shortcuts/sheets/lark_sheet_write_cells_test.go +++ b/shortcuts/sheets/lark_sheet_write_cells_test.go @@ -248,7 +248,7 @@ func TestCellsSetImage_RangeMustBeSingleCell(t *testing.T) { } // TestRangeDimensions exercises the A1 parser's corner cases used by -// cells-set-style / dropdown-set / dim-resize. +// cells-set-style / dropdown-set / rows-resize / cols-resize. func TestRangeDimensions(t *testing.T) { t.Parallel() cases := []struct { diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index fb140bd38..7dc00836c 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -54,7 +54,8 @@ func Shortcuts() []common.Shortcut { CellsClear, CellsMerge, CellsUnmerge, - DimResize, + RowsResize, + ColsResize, RangeMove, RangeCopy, RangeFill, diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 2ba05d8ea..53fee6a40 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -14,7 +14,7 @@ **⚠️ 何时必须使用 `+batch-update`(硬性要求)**: - 需要对**多个**不同区域执行 `+cells-{merge|unmerge}` 时(如按分组合并多列相同内容) -- 需要对**多个**不同区域执行 `+dim-resize` 时(如统一调整多列列宽或多行行高) +- 需要对**多个**不同区域执行 `+rows-resize / +cols-resize` 时(如统一调整多列列宽或多行行高) - 需要先插入行列再写入数据时(`+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` + `+cells-set`) - 需要对多个区域执行不同写入操作时(多次 `+cells-set` + `+cells-clear` 等组合) diff --git a/skills/lark-sheets/references/lark-sheets-core-operations.md b/skills/lark-sheets/references/lark-sheets-core-operations.md index bca60f939..e0fca5442 100644 --- a/skills/lark-sheets/references/lark-sheets-core-operations.md +++ b/skills/lark-sheets/references/lark-sheets-core-operations.md @@ -73,11 +73,11 @@ 7. **多步写入优先用 `+batch-update`**:当任务涉及多个连续写入操作时,优先使用 `lark-sheets-batch-update` skill 中的 `+batch-update` 将它们合并为单次请求,减少调用轮次。**特别是以下场景必须用 `+batch-update`**: - 需要对多个不同区域执行 `+cells-{merge|unmerge}`(如按合同编号合并多列相同内容)→ 将所有 merge 操作放进一个 `+batch-update` - - 需要对多个不同区域执行 `+dim-resize`(如统一调整多列列宽或多行行高)→ 将所有 resize 操作放进一个 `+batch-update` + - 需要对多个不同区域执行 `+rows-resize / +cols-resize`(如统一调整多列列宽或多行行高)→ 将所有 resize 操作放进一个 `+batch-update` - 需要先插入行列再写入数据 → 将 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` + `+cells-set` 放进一个 `+batch-update` - 当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求 -8. **结构操作用专用工具**:插入/删除行列用 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}`(一次操作可处理数千行),不要用 `+cells-set` 逐行搬数据——后者既慢又容易出错。用户给多步骤请求时,每个步骤都要执行,执行后重新验证。**`+dim-insert` 只继承相邻行的值/公式/边框,不继承 `row_height`**:插入后新行行高会回落到默认值(约 22px),若原行是高行高(容纳多行自动换行文本),新行长文本会被裁切(显示不全,末尾字符被截断)。**在中间插入行并填入文本前,必须先用 `+sheet-info` 读取相邻原行的 `row_height`,再用 `+batch-update` 合并 `+dim-resize` 把同一高度应用到新行**——"在中间插入行填数据"本质等价于"续写已有内容",必须走硬性规则 12 的同一套样式继承流程(行高、边框、合并、对齐)。 +8. **结构操作用专用工具**:插入/删除行列用 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}`(一次操作可处理数千行),不要用 `+cells-set` 逐行搬数据——后者既慢又容易出错。用户给多步骤请求时,每个步骤都要执行,执行后重新验证。**`+dim-insert` 只继承相邻行的值/公式/边框,不继承 `row_height`**:插入后新行行高会回落到默认值(约 22px),若原行是高行高(容纳多行自动换行文本),新行长文本会被裁切(显示不全,末尾字符被截断)。**在中间插入行并填入文本前,必须先用 `+sheet-info` 读取相邻原行的 `row_height`,再用 `+batch-update` 合并 `+rows-resize / +cols-resize` 把同一高度应用到新行**——"在中间插入行填数据"本质等价于"续写已有内容",必须走硬性规则 12 的同一套样式继承流程(行高、边框、合并、对齐)。 9. **写入前精确定位表头和数据区域**:在执行任何写操作之前,必须先通过读取数据确认: - 表头在哪一行(不要假设表头一定在第 1 行,可能存在标题行、空行等) @@ -95,14 +95,14 @@ - 用 `+cells-get` 读取源区块的 `cell_styles` 和 `border_styles` - 用 `+sheet-info` 读取源区块的行高、合并单元格信息 - 写入时 cells 中同时携带 `value` + `cell_styles` + `border_styles` - - 用 `+batch-update` 批量执行 `+cells-{merge|unmerge}`(复制合并)和 `+dim-resize`(复制行高) + - 用 `+batch-update` 批量执行 `+cells-{merge|unmerge}`(复制合并)和 `+rows-resize / +cols-resize`(复制行高) - 只写值不写样式会导致新区块与源区块视觉完全不一致,这是最常见的致命错误 13. **"新增列"任务必须跑完整 checklist(高频致命错误)**:当用户要求"在表格中增加列/新增列/加几列"时,心智模型不是"只写表头 + 填公式"。**执行前必须完成以下 4 步 checklist,禁止跳步**: - **Step 1 — 读原表 row1 的合并区域**:用 `+cells-get` 或 `+sheet-info` 读取 row1 的合并信息。**如果 row1 存在跨数据区的合并标题行(如 A1:F1 合并为一个大标题),新增 N 列后必须用 `+cells-{merge|unmerge}` 扩展合并范围到新列末**(如新增 3 列 → 合并改为 A1:I1)。否则新列在 row1 裸露在原标题区之外,视觉割裂。这一步被跳过会被 PM 判定"操作不完整"。 - **Step 2 — 读表头和原数据行的完整样式**:用 `+cells-get` 读原表头行(如 row2/row3)和数据行(row4)的 `cell_styles` **和 `border_styles`**,记录字体/加粗/对齐/**边框**/数字格式等。 - **Step 3 — 新列 cells 必须同时带 `cell_styles` + `border_styles`**:写新列时 cells 里的每个对象都要完整复制原表样式(包括边框),不能只传 font_size / alignment 就算"样式一致"。 - - **Step 4 — 列宽对齐**:用 `+batch-update` 合并 `+dim-resize` 把新列列宽与原数据列保持一致。 + - **Step 4 — 列宽对齐**:用 `+batch-update` 合并 `+rows-resize / +cols-resize` 把新列列宽与原数据列保持一致。 - 典型反模式:AI 只在 batch_update 里放 3 个 `+cells-set`(表头 + 空列样式 + 公式列),完全跳过 Step 1 的合并扩展 和 Step 2-3 的边框复制 → row1 不跟着变宽、新列无边框,用户打开产物感受"新列被孤立在原表外"。 ## 推荐工作流程 @@ -287,7 +287,7 @@ print(df.head(10)) # 必做:横向——确认表头行 + - 跨工作表引用:使用 `Sheet!A1` 风格引用 - 整行整列语义丢失:用户说“这行 / 这列 / 首行 / 整列”时,不要把操作范围截断为当前读取到的 `A1:U1`、`J1:J41` 等局部范围 - **重复写入未使用 `copy_to_range`(高频致命错误)**:整列公式、整列格式、首行样式、向下复制等场景,**必须**用模板单元格 + `copy_to_range`,**禁止**逐行 `+cells-set`。这是最常见的导致轮次耗尽的错误 -- **重复调用 `+cells-{merge|unmerge}` / `+dim-resize` 未合并为 `+batch-update`(高频致命错误)**:当需要合并/调整多个区域时,**必须**使用 `+batch-update` 将多个操作合并为单次调用。逐个调用会快速耗尽轮次上限(60R) +- **重复调用 `+cells-{merge|unmerge}` / `+rows-resize / +cols-resize` 未合并为 `+batch-update`(高频致命错误)**:当需要合并/调整多个区域时,**必须**使用 `+batch-update` 将多个操作合并为单次调用。逐个调用会快速耗尽轮次上限(60R) - 多步骤请求漏做:若用户要求”先重命名,再新建”,两个动作都必须执行 - **表头定位不精确导致写入全偏(高频致命错误)**:不要假设表头在第 1 行。很多表格有标题行、说明行或空行,实际表头可能在第 2、3 行甚至更后。写入公式或数据前,必须先读取前几行确认表头行号和各列实际含义,再基于确认后的行列号构造写入 range - **参数冗余**:只需修改 10 个单元格时,不要把全表重写一遍;`+cells-set` 的 range 和 cells 应精确覆盖变更区域 @@ -296,9 +296,9 @@ print(df.head(10)) # 必做:横向——确认表头行 + - **写入前读取范围不充分**:涉及批量写入或修改时,必须先读取足够的数据范围。如果表格有 100 行而只读了 20 行,后续操作会漏掉剩余数据。使用 `+workbook-info` 获取行数后,根据实际行数决定读取范围。注意:了解表头和数据结构只需前几行,但批量操作前需要掌握完整数据 - **`+cells-search` 不是万能的**:用户说”汇总金额”是一个操作动作(求和),不是要搜索”汇总金额”这个文本。只有当确实需要定位某个文本值的位置时才用 `+cells-search` - **跨 sheet 对象**:图表、条件格式、透视表、浮动图片可能分布在多个子表中。操作前先用 `+workbook-info` 掌握全局,不要只看当前子表 -- **copy_to_range 不含行列尺寸**:`copy_to_range` 复制的是值、公式和样式,不包含行高列宽设置。需要统一行列尺寸时,应另行调用 `lark-sheets-range-operations` 中的 `+dim-resize` +- **copy_to_range 不含行列尺寸**:`copy_to_range` 复制的是值、公式和样式,不包含行高列宽设置。需要统一行列尺寸时,应另行调用 `lark-sheets-range-operations` 中的 `+rows-resize / +cols-resize` - **写入前先确保行列存在**:`+cells-set` 不会自动扩展表格。如果要写入的 range 超出当前行列范围,必须先用 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` 插入行列 -- **写入后保持原表样式(高频致命错误)**:原表已有边框线、背景色、行高、合并单元格等样式时,写入新数据后**必须**延续相同样式,不要只写值不管格式。具体做法:先用 `+cells-get` 读取源区域的样式(`cell_styles`、`border_styles`),写入时在 cells 中携带相同的样式字段;若源区域有合并单元格,用 `+cells-{merge|unmerge}` 对新区域做相同合并;若源区域有特殊行高,用 `+dim-resize` 对新区域设置相同行高。详见下方"特殊场景 → 续写/复制已有区块格式" +- **写入后保持原表样式(高频致命错误)**:原表已有边框线、背景色、行高、合并单元格等样式时,写入新数据后**必须**延续相同样式,不要只写值不管格式。具体做法:先用 `+cells-get` 读取源区域的样式(`cell_styles`、`border_styles`),写入时在 cells 中携带相同的样式字段;若源区域有合并单元格,用 `+cells-{merge|unmerge}` 对新区域做相同合并;若源区域有特殊行高,用 `+rows-resize / +cols-resize` 对新区域设置相同行高。详见下方"特殊场景 → 续写/复制已有区块格式" - **CSV 行号按物理换行计数导致行号全错(高频致命错误)**:`+csv-get` 返回的 CSV 中,被双引号包裹的字段内换行符是**单元格内换行**,不是新行。例如 `"2026年3月2日\n星期一"` 是**一个单元格**,不是两行。计算行号时必须按逻辑记录计数。详见 `lark-sheets-read-data` skill 中的"CSV 行号计算规则" ## Skill Set @@ -312,7 +312,7 @@ print(df.head(10)) # 必做:横向——确认表头行 + | 读写 | `lark-sheets-sheet-structure` | 获取子表行列布局;增删/隐藏/冻结/分组行列 | `+sheet-info`、`+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` | | 读写 | `lark-sheets-search-replace` | 按关键字搜索定位单元格;查找并替换文本 | `+cells-search`、`+cells-replace` | | 写入 | `lark-sheets-write-cells` | 向指定区域写入值/公式/样式/批注,或批量导入 CSV 纯值(沙箱路径 / 已有 CSV 文本两条路) | `+cells-set`(精确控制)、`import_sandbox_to_sheet`(沙箱结果回写)、`+csv-put`(已有 CSV 文本直接铺) | -| 写入 | `lark-sheets-range-operations` | 清除内容、合并单元格、调整行列尺寸、排序、移动/复制区域(支持格式刷:仅复制值/公式/格式) | `+cells-clear`、`+cells-{merge|unmerge}`、`+dim-resize`、`+range-{move|copy|fill|sort}` | +| 写入 | `lark-sheets-range-operations` | 清除内容、合并单元格、调整行列尺寸、排序、移动/复制区域(支持格式刷:仅复制值/公式/格式) | `+cells-clear`、`+cells-{merge|unmerge}`、`+rows-resize / +cols-resize`、`+range-{move|copy|fill|sort}` | | 写入 | `lark-sheets-batch-update` | 将多个写入操作合并为单次请求,减少调用次数 | `+batch-update` | | 对象 | `lark-sheets-chart` | 查询、创建、更新或删除图表 | `+chart-list`、`+chart-{create|update|delete}` | | 对象 | `lark-sheets-pivot-table` | 查询、创建、更新或删除数据透视表 | `+pivot-list`、`+pivot-{create|update|delete}` | @@ -332,7 +332,7 @@ print(df.head(10)) # 必做:横向——确认表头行 + 3. **规划写入范围并扩行**:计算目标行数,若超出当前 sheet 边界,先用 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` 插入行 4. **带样式写入数据**:`+cells-set` 的 cells 中同时携带 `value` + `cell_styles` + `border_styles`。推荐使用"内容与样式分离写入"策略(见 `lark-sheets-write-cells` skill):先写值,再用模板单元格 + `copy_to_range` 刷样式 5. **合并单元格**:对标题行等需要合并的区域,用 `+batch-update` 批量调用 `+cells-{merge|unmerge}` -6. **设置行高**:用 `+batch-update` 批量调用 `+dim-resize`,统一设置新区域的行高与源区块一致 +6. **设置行高**:用 `+batch-update` 批量调用 `+rows-resize / +cols-resize`,统一设置新区域的行高与源区块一致 7. **回读校验**:用 `+csv-get` 校验值,用 `+cells-get` 抽查样式 > **反面案例**:只用 `+csv-get` 读值 → 只传 `{"value": ...}` 写入 → 新区块没有边框、没有合并、没有行高,与源区块视觉不一致。这是本场景最常见的错误。 diff --git a/skills/lark-sheets/references/lark-sheets-range-operations.md b/skills/lark-sheets/references/lark-sheets-range-operations.md index 2687470c3..35108a86b 100644 --- a/skills/lark-sheets/references/lark-sheets-range-operations.md +++ b/skills/lark-sheets/references/lark-sheets-range-operations.md @@ -17,7 +17,7 @@ |---------|---------|------| | 清除内容/格式 | `+cells-clear` | "清空"、"删除内容"、"去掉格式" | | 合并/取消合并单元格 | `+cells-{merge|unmerge}` | "合并单元格"、"取消合并" | -| 调整行高/列宽 | `+dim-resize` | "加宽列"、"调整行高"、"自适应列宽" | +| 调整行高/列宽 | `+rows-resize / +cols-resize` | "加宽列"、"调整行高"、"自适应列宽" | | 移动/复制/填充/排序 | `+range-{move|copy|fill|sort}` | "移动数据"、"复制到"、"自动填充"、"按某列排序" | 注意: @@ -25,7 +25,7 @@ - 用户说"这行 / 整行 / 首行"时,优先使用整行范围如 `1:1`;"这列 / 整列"时使用 `J:J`。不要截断为局部矩形 - 合并后只保留左上角单元格的内容,其余清除。写入合并区域用 `+cells-set` 对左上角单元格操作 - 调整行高列宽时,先读取相邻行列尺寸再决定像素值,不要随意猜测 -- `copy_to_range`(`+cells-set` 的参数)复制的是值/公式/样式,不含行高列宽。需要统一尺寸时另行调用 `+dim-resize` +- `copy_to_range`(`+cells-set` 的参数)复制的是值/公式/样式,不含行高列宽。需要统一尺寸时另行调用 `+rows-resize / +cols-resize` ## 写入后列宽自适应(防内容遮挡) @@ -34,8 +34,8 @@ 1. **写入后回读最长内容字符数**:用 `+csv-get` 读目标列的实际写入内容,统计最长单元格的字符数(`max(len(cell) for cell in col)`)。汉字按 2 字符宽度估算,半角字母数字按 1 字符。 2. **判定阈值**:当前列宽(用 `get_sheet_structure --info_type=row_heights_column_widths` 拿)≥ 最长字符数 × 字体宽度系数 + buffer 才算适配。默认列宽 11 通常只够 11 个半角字符或 5-6 个汉字,写长文本前必扩宽。 3. **修复二选一**: - - **扩列宽**:用 `+dim-resize` 把目标列宽设为 `max(表头字符数, 内容采样最长字符数) × 8 + 16` 像素(经验值) - - **自动换行**:在 `+cells-set` 时给单元格设置 `cell_styles.word_wrap="auto-wrap"`(可选值:`overflow` / `auto-wrap` / `word-clip`),并用 `+dim-resize` 调高对应行的行高 + - **扩列宽**:用 `+rows-resize / +cols-resize` 把目标列宽设为 `max(表头字符数, 内容采样最长字符数) × 8 + 16` 像素(经验值) + - **自动换行**:在 `+cells-set` 时给单元格设置 `cell_styles.word_wrap="auto-wrap"`(可选值:`overflow` / `auto-wrap` / `word-clip`),并用 `+rows-resize / +cols-resize` 调高对应行的行高 4. **新增列默认列宽规则**:新增列宽度 ≥ `max(表头字符数, 内容采样最长字符数) × 8 + 16` 像素,**禁止**用默认 11 直接交付。 **典型反例**:默认列宽 11 但内容含 12+ 字符的中文 / 含单位的数值(如 `109.10μmol/L`)/ 长数字未设 `number_format` 显示为科学计数法 —— 用户在结果表里看不到完整原值。 @@ -51,7 +51,7 @@ **⚠️ 批量操作必须用 `+batch-update`**: -当需要对**多个**不同区域执行 `+cells-{merge|unmerge}`(merge)或 `+dim-resize` 时,**禁止逐个调用**,必须使用 `+batch-update`(参见 `lark-sheets-batch-update` skill)将所有操作合并为一次请求。逐个调用会快速耗尽工具调用轮次上限。 +当需要对**多个**不同区域执行 `+cells-{merge|unmerge}`(merge)或 `+rows-resize / +cols-resize` 时,**禁止逐个调用**,必须使用 `+batch-update`(参见 `lark-sheets-batch-update` skill)将所有操作合并为一次请求。逐个调用会快速耗尽工具调用轮次上限。 **例外**:`+cells-{merge|unmerge}(operation: unmerge)` 原生支持对覆盖多个合并区域的大 range 一次性取消,应直接单次调用,**不要**拆进 `+batch-update`。 @@ -80,7 +80,7 @@ ] } ``` -而不是分四次单独调用 `+dim-resize`。 +而不是分四次单独调用 `+rows-resize / +cols-resize`。 **⚠️ sort 操作前必读:确认目标列的数据类型** @@ -111,7 +111,8 @@ | `clear_cell_range` | `+cells-clear` | high-risk-write | 单元格 | | `merge_cells` | `+cells-merge` | write | 单元格 | | | `+cells-unmerge` | write | 单元格 | -| `resize_range` | `+dim-resize` | write | 工作表 | +| `resize_range` | `+rows-resize` | write | 工作表 | +| | `+cols-resize` | write | 工作表 | | `transform_range` | `+range-move` | write | 区域 | | | `+range-copy` | write | 区域 | | | `+range-fill` | write | 区域 | @@ -156,7 +157,7 @@ | `--merge-type` | 专有 | string + Enum | 否 | (仅 `+cells-merge`)`all` / `rows` / `columns`,默认 `all` | | `--dry-run` | 系统 | bool | 否 | | -### `+dim-resize` +### `+rows-resize` | Flag | 分类 | Type | 必填 | 说明 | | --- | --- | --- | --- | --- | @@ -164,11 +165,24 @@ | `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | | `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | | `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--dimension` | 专有 | string + Enum | 是 | `row` / `column` | -| `--start` | 专有 | int | 是 | 范围 | -| `--end` | 专有 | int | 是 | 范围 | -| `--size` | 专有 | int | 否 | 像素值 | -| `--reset` | 专有 | bool | 否 | 还原默认 | +| `--start` | 专有 | int | 是 | 起始行(0-based,inclusive) | +| `--end` | 专有 | int | 是 | 结束行(0-based,inclusive) | +| `--type` | 专有 | string + Enum | 是 | 尺寸方式 enum:`pixel`(指定 px 像素值,需配 `--size`)/ `standard`(重置为默认标准行高)/ `auto`(自动适应内容) | +| `--size` | 专有 | int | 否 | 行高(像素,例:30 / 40 / 60);`--type pixel` 时必填,其它 type 忽略 | +| `--dry-run` | 系统 | bool | 否 | | + +### `+cols-resize` + +| Flag | 分类 | Type | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | +| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | +| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | +| `--start` | 专有 | int | 是 | 起始列(0-based,inclusive) | +| `--end` | 专有 | int | 是 | 结束列(0-based,inclusive) | +| `--type` | 专有 | string + Enum | 是 | 尺寸方式 enum:`pixel`(指定 px 像素值,需配 `--size`)/ `standard`(重置为默认标准列宽) | +| `--size` | 专有 | int | 否 | 列宽(像素,例:80 / 120 / 200);`--type pixel` 时必填,其它 type 忽略 | | `--dry-run` | 系统 | bool | 否 | | ### `+range-move` @@ -239,7 +253,7 @@ _排序条件列表(仅 sort 操作)_ ## Examples -> ⚠️ 本 skill 派生的 7 条 shortcut 跨 3 个分组:`+dim-resize` → 工作表,`+cells-*` → 单元格,`+range-*` → 区域。skill 视角统一在这里讲解。 +> ⚠️ 本 skill 派生的 shortcut 跨 3 个分组:`+rows-resize` / `+cols-resize` → 工作表,`+cells-*` → 单元格,`+range-*` → 区域。skill 视角统一在这里讲解。 公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。 @@ -254,7 +268,23 @@ lark-cli sheets +cells-clear --url "..." --sheet-id "$SID" --range "A2:Z1000" -- ### `+cells-merge` / `+cells-unmerge` -### `+dim-resize` +### `+rows-resize` / `+cols-resize` + +行高列宽分两条 shortcut,避免行 / 列在底层 schema 的差异(行支持 `auto`,列不支持)混在一起。每条 `--type` 必填: + +```bash +# 把第 2-10 行设为固定 30 px +lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --start 2 --end 10 --type pixel --size 30 + +# 把 A-C 列设为固定 120 px +lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --start 0 --end 2 --type pixel --size 120 + +# 行高自动适应内容(列宽不支持 auto) +lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --start 0 --end 0 --type auto + +# 重置为默认 +lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --start 0 --end 5 --type standard +``` > 同时出现在 `lark_sheet_sheet_structure/cli-shortcuts.md` —— 行高 / 列宽调整也算行列结构层动作。 @@ -268,6 +298,6 @@ lark-cli sheets +cells-clear --url "..." --sheet-id "$SID" --range "A2:Z1000" -- ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`+cells-clear` 强制 `--yes` 或 `--dry-run`;`+range-*` 校验源 / 目标 range 在同一 spreadsheet;`+range-sort` 的 `--sort-keys` 必须合法 JSON 数组且 col 都在 `--range` 内。 +- `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`(只行高支持自适应)。 - `DryRun`:所有写操作输出"将要 PATCH 的 range + 受影响 cell 数估算"。 - `Execute`:写后调用 `+cells-get --ranges <影响范围>` 抽样回读对比,envelope.meta.verification 沉淀对比结果。 diff --git a/skills/lark-sheets/references/lark-sheets-sheet-structure.md b/skills/lark-sheets/references/lark-sheets-sheet-structure.md index c1e83e0b0..c88b013d1 100644 --- a/skills/lark-sheets/references/lark-sheets-sheet-structure.md +++ b/skills/lark-sheets/references/lark-sheets-sheet-structure.md @@ -192,9 +192,11 @@ lark-cli sheets +dim-insert --url "https://example.feishu.cn/sheets/shtXXX" \ ### `+dim-hide` / `+dim-unhide` -### `+dim-resize` +### `+rows-resize` / `+cols-resize` -> ⚠️ 该 shortcut 来自 `lark_sheet_range_operations` 的 `resize_range` tool(分组在"工作表"是为了发现性)。详细参数也在 `lark_sheet_range_operations/cli-shortcuts.md` 出现。 +> ⚠️ 这两条 shortcut 来自 `lark_sheet_range_operations` 的 `resize_range` tool(分组在"工作表"是为了发现性)。详细参数和示例在 `lark_sheet_range_operations/cli-shortcuts.md`。 +> +> 行 vs 列底层 schema 有差异:`+rows-resize.--type` 支持 `pixel` / `standard` / `auto`,`+cols-resize.--type` 只支持 `pixel` / `standard`(列宽不支持自动适应)。 ### `+dim-freeze` @@ -204,6 +206,6 @@ lark-cli sheets +dim-insert --url "https://example.feishu.cn/sheets/shtXXX" \ ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`--start < --end`;`+dim-delete` 强制 `--yes` 或 `--dry-run`;`+dim-resize` 必须 `--size` 或 `--reset` 至少一个。 +- `Validate`:XOR 公共四件套;`--start ≤ --end`;`+dim-delete` 强制 `--yes` 或 `--dry-run`;`+rows-resize` / `+cols-resize` 的 `--type` 必填,`--type pixel` 时 `--size` 必填、其它 type 时 `--size` 应省略;`+cols-resize.--type` 不接受 `auto`(详见 `lark_sheet_range_operations/cli-shortcuts.md`)。 - `DryRun`:写操作输出"将要 PATCH 的 dimension 区间 + 目标参数"。 - `Execute`:写后自动调用 `+sheet-info --include row_heights,col_widths,hidden_rows,hidden_cols,groups,frozen` 回读对比,envelope.meta.verification 给出受影响的范围。 diff --git a/skills/lark-sheets/references/lark-sheets-visual-standards.md b/skills/lark-sheets/references/lark-sheets-visual-standards.md index 73c54e9e5..a094db08f 100644 --- a/skills/lark-sheets/references/lark-sheets-visual-standards.md +++ b/skills/lark-sheets/references/lark-sheets-visual-standards.md @@ -57,7 +57,7 @@ - 若追加位置紧邻汇总行、说明区或空白分隔区,先判断真实数据区域边界再操作,避免破坏原有结构。 - **Zebra Stripes 维护**:插入或删除行后若影响后续行奇偶性,须从受影响行往后重建条纹(先清理再重设)。少量增删用局部重建,大量变动用全局清理+统一重建。 - 具体采样与复制流程见下方「场景二:从已有区域继承美化」。 -- **列宽调整**(飞书 `+dim-resize` 按 pixel 传值): +- **列宽调整**(飞书 `+rows-resize / +cols-resize` 按 pixel 传值): - 禁止硬编码固定列宽,须根据该列实际内容长度估算像素。 - 经验估算:中文每字约 15-18px,英文/数字每字约 7-9px,外加 10-16px padding。 - 上下限建议 80~400px;超上限启用自动换行(`word_wrap: auto-wrap`)+ 调整行高,而非无限加宽。 From 50190e86385a3ed1605aeadafcaaf3c131895cca Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Tue, 19 May 2026 17:31:54 +0800 Subject: [PATCH 017/114] feat(sheets): add --print-schema runtime introspection for composite JSON flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composite JSON flags (--cells / --properties / --operations / --border-styles / --sort-keys / --options) carry non-trivial structured payloads. Reference docs cover top-level fields but agents writing those flags often need the full JSON Schema to build a valid payload. This adds a system-level introspection contract so any shortcut whose flags are tracked upstream can serve its schemas locally: lark-cli sheets --print-schema --flag-name lark-cli sheets --print-schema # list flags The schema data is embedded at build time from a synced artifact (shortcuts/sheets/data/flag-schemas.json). Upstream is the source of truth — never hand-edit the JSON; update the source Base table and rerun the sheet-skill-spec sync. Framework changes (shortcuts/common): - types.go: Shortcut gains an opt-in PrintFlagSchema hook (flagName -> bytes/error). When non-nil the framework auto-injects --print-schema / --flag-name and short-circuits Validate/Execute. - runner.go: register the two system flags when PrintFlagSchema is set; intercept in runShortcut before identity/scope/config so pure-local lookups don't trigger auth or network. Install a PreRunE that relaxes cobra's required-flag gate when --print-schema is set, since asking for a schema shouldn't need unrelated required flags. Sheets surface (shortcuts/sheets): - flag_schema.go (new): go:embed data/flag-schemas.json; expose printFlagSchemaFor(command) closure. When flagName is empty it emits a JSON listing of introspectable flags for discovery; otherwise it returns the schema subtree as pretty JSON. - flag_schema_test.go (new): cover embed parsing, listing / by-name lookup, unknown-flag error path, registration via Shortcuts(), and the full system-flag short-circuit through cobra (required flags relaxed, schema printed on stdout). - shortcuts.go: Shortcuts() now wraps shortcutList() and attaches PrintFlagSchema to every command present in flag-schemas.json, so shortcuts opt in by being listed upstream — no per-shortcut boilerplate. - data/flag-schemas.json (new, synced from sheet-skill-spec): 19 entries, schema_version "2". Generated upstream from the Lark Base source-of-truth (see sheet-skill-spec scripts/fetch_cli_flag_schema_map.mjs); ships only per-flag subtrees (not the full mcp-tools.json) to keep tool internals out of the open-source repo. Skill docs (skills/lark-sheets): - SKILL.md: system-flag table gains --print-schema / --flag-name and an "Agent 使用提示" note steering agents to prefer --print-schema over guessing JSON shape from the cheatsheet. - references/*.md: regenerated by upstream sync (Schemas-section boilerplate updated, plus accumulated upstream prose refinements). --- shortcuts/common/runner.go | 39 + shortcuts/common/types.go | 15 + shortcuts/sheets/data/flag-schemas.json | 6348 +++++++++++++++++ shortcuts/sheets/flag_schema.go | 120 + shortcuts/sheets/flag_schema_test.go | 180 + shortcuts/sheets/shortcuts.go | 15 + skills/lark-sheets/SKILL.md | 31 + .../references/lark-sheets-batch-update.md | 91 +- .../references/lark-sheets-chart.md | 57 +- .../lark-sheets-conditional-format.md | 65 +- .../references/lark-sheets-filter-view.md | 65 +- .../references/lark-sheets-filter.md | 53 +- .../references/lark-sheets-float-image.md | 91 +- .../references/lark-sheets-pivot-table.md | 65 +- .../lark-sheets-range-operations.md | 160 +- .../references/lark-sheets-read-data.md | 57 +- .../references/lark-sheets-search-replace.md | 50 +- .../references/lark-sheets-sheet-structure.md | 156 +- .../references/lark-sheets-sparkline.md | 57 +- .../references/lark-sheets-workbook.md | 145 +- .../references/lark-sheets-write-cells.md | 138 +- 21 files changed, 7282 insertions(+), 716 deletions(-) create mode 100644 shortcuts/sheets/data/flag-schemas.json create mode 100644 shortcuts/sheets/flag_schema.go create mode 100644 shortcuts/sheets/flag_schema_test.go diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index d6f4c1a5d..a97b6b46d 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -27,6 +27,7 @@ import ( "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/output" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) // RuntimeContext provides helpers for shortcut execution. @@ -732,6 +733,22 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f return runShortcut(cmd, f, &shortcut, botOnly) }, } + if shortcut.PrintFlagSchema != nil { + // --print-schema is pure local introspection; relax cobra's + // required-flag gate so callers don't need to fill in unrelated + // flags just to ask for a schema. ValidateRequiredFlags runs + // after PreRunE in cobra, so clearing the annotation here is the + // supported way to opt out. + cmd.PreRunE = func(c *cobra.Command, _ []string) error { + if want, _ := c.Flags().GetBool("print-schema"); !want { + return nil + } + c.Flags().VisitAll(func(fl *pflag.Flag) { + delete(fl.Annotations, cobra.BashCompOneRequiredFlag) + }) + return nil + } + } cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes) registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut) cmdutil.SetTips(cmd, shortcut.Tips) @@ -745,6 +762,24 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f // runShortcut is the execution pipeline for a declarative shortcut. // Each step is a clear phase: identity → config → scopes → context → validate → execute. func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error { + // --print-schema short-circuits everything below: it's pure local + // introspection, no identity / scope / network needed. The flag is + // only registered when the shortcut opts in via PrintFlagSchema. + if s.PrintFlagSchema != nil { + if want, _ := cmd.Flags().GetBool("print-schema"); want { + flagName, _ := cmd.Flags().GetString("flag-name") + out, err := s.PrintFlagSchema(strings.TrimSpace(flagName)) + if err != nil { + return err + } + if len(out) == 0 { + return nil + } + fmt.Fprintln(f.IOStreams.Out, string(out)) + return nil + } + } + as, err := resolveShortcutIdentity(cmd, f, s) if err != nil { return err @@ -1014,6 +1049,10 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f if s.Risk == "high-risk-write" { cmd.Flags().Bool("yes", false, "confirm high-risk operation") } + if s.PrintFlagSchema != nil { + cmd.Flags().Bool("print-schema", false, "print JSON Schema for a composite flag instead of executing") + cmd.Flags().String("flag-name", "", "flag whose schema to print (omit to list introspectable flags); used with --print-schema") + } cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output") cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes) if s.HasFormat { diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 76626c06c..51457b260 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -58,6 +58,21 @@ type Shortcut struct { Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic + // PrintFlagSchema, when non-nil, opts this shortcut into the + // `--print-schema --flag-name ` runtime introspection contract. + // The framework auto-injects those two system flags and short-circuits + // Validate/Execute when --print-schema is set, dispatching to this hook. + // + // Contract: + // - flagName == "" → list the flags this shortcut can describe + // (output is impl-defined; agents read this to + // discover which flags are introspectable). + // - flagName == "...": → return the JSON Schema (or schema-like blob) + // for that flag. + // Return value is written to stdout verbatim; callers typically format + // it as JSON. Returning an error surfaces as a normal command error. + PrintFlagSchema func(flagName string) ([]byte, error) + // PostMount is an optional hook called after the cobra.Command is fully // configured (flags registered, tips set) and after parent.AddCommand(cmd) // has attached it to the parent. Use it to install custom help functions or diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json new file mode 100644 index 000000000..21e920800 --- /dev/null +++ b/shortcuts/sheets/data/flag-schemas.json @@ -0,0 +1,6348 @@ +{ + "schema_version": "2", + "flags": { + "+batch-update": { + "operations": { + "description": "要批量执行的操作列表,按顺序依次执行。每个操作包含工具名称和对应的入参。", + "items": { + "properties": { + "input": { + "description": "对应工具的入参,结构与单独调用该工具时完全一致", + "type": "object" + }, + "tool_name": { + "description": "要执行的工具名称,如 \"set_cell_range\"、\"clear_cell_range\"、\"modify_sheet_structure\" 等。不支持 \"batch_update\" 嵌套。", + "type": "string" + } + }, + "required": [ + "tool_name", + "input" + ], + "type": "object" + }, + "type": "array" + } + }, + "+cells-batch-set-style": { + "border-styles": { + "description": "单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)。", + "properties": { + "bottom": { + "properties": { + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, + "style": { + "description": "边框线型", + "enum": [ + "solid", + "dashed", + "dotted", + "double" + ], + "type": "string" + }, + "weight": { + "description": "边框粗细/线宽", + "enum": [ + "thin", + "medium", + "thick" + ], + "type": "string" + } + }, + "type": "object" + }, + "left": { + "properties": { + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, + "style": { + "description": "边框线型", + "enum": [ + "solid", + "dashed", + "dotted", + "double" + ], + "type": "string" + }, + "weight": { + "description": "边框粗细/线宽", + "enum": [ + "thin", + "medium", + "thick" + ], + "type": "string" + } + }, + "type": "object" + }, + "right": { + "properties": { + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, + "style": { + "description": "边框线型", + "enum": [ + "solid", + "dashed", + "dotted", + "double" + ], + "type": "string" + }, + "weight": { + "description": "边框粗细/线宽", + "enum": [ + "thin", + "medium", + "thick" + ], + "type": "string" + } + }, + "type": "object" + }, + "top": { + "properties": { + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, + "style": { + "description": "边框线型", + "enum": [ + "solid", + "dashed", + "dotted", + "double" + ], + "type": "string" + }, + "weight": { + "description": "边框粗细/线宽", + "enum": [ + "thin", + "medium", + "thick" + ], + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "+cells-set": { + "cells": { + "properties": { + "border_styles": { + "description": "单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)。", + "properties": { + "bottom": { + "properties": { + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, + "style": { + "description": "边框线型", + "enum": [ + "solid", + "dashed", + "dotted", + "double" + ], + "type": "string" + }, + "weight": { + "description": "边框粗细/线宽", + "enum": [ + "thin", + "medium", + "thick" + ], + "type": "string" + } + }, + "type": "object" + }, + "left": { + "properties": { + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, + "style": { + "description": "边框线型", + "enum": [ + "solid", + "dashed", + "dotted", + "double" + ], + "type": "string" + }, + "weight": { + "description": "边框粗细/线宽", + "enum": [ + "thin", + "medium", + "thick" + ], + "type": "string" + } + }, + "type": "object" + }, + "right": { + "properties": { + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, + "style": { + "description": "边框线型", + "enum": [ + "solid", + "dashed", + "dotted", + "double" + ], + "type": "string" + }, + "weight": { + "description": "边框粗细/线宽", + "enum": [ + "thin", + "medium", + "thick" + ], + "type": "string" + } + }, + "type": "object" + }, + "top": { + "properties": { + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, + "style": { + "description": "边框线型", + "enum": [ + "solid", + "dashed", + "dotted", + "double" + ], + "type": "string" + }, + "weight": { + "description": "边框粗细/线宽", + "enum": [ + "thin", + "medium", + "thick" + ], + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "cell_styles": { + "description": "单元格样式属性,包括字体、颜色、对齐方式和数字格式", + "properties": { + "background_color": { + "description": "背景颜色(十六进制,例如 \"#ffffff\")", + "type": "string" + }, + "font_color": { + "description": "字体颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, + "font_line": { + "description": "字体线条样式", + "enum": [ + "none", + "underline", + "line-through" + ], + "type": "string" + }, + "font_size": { + "description": "字体大小(单位:px/像素,例如 10、12、14)", + "type": "number" + }, + "font_style": { + "description": "字体样式", + "enum": [ + "normal", + "italic" + ], + "type": "string" + }, + "font_weight": { + "description": "字重", + "enum": [ + "normal", + "bold" + ], + "type": "string" + }, + "horizontal_alignment": { + "description": "水平对齐方式", + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "number_format": { + "description": "数字格式(例如:文本用 \"@\"、数字用 \"0.00\"、货币用 \"$#,##0.00\"、日期用 \"mm/dd/yyyy\")", + "type": "string" + }, + "vertical_alignment": { + "description": "垂直对齐方式", + "enum": [ + "top", + "middle", + "bottom" + ], + "type": "string" + }, + "word_wrap": { + "description": "是否自动换行,默认溢出,可选自动换行或裁剪。", + "enum": [ + "overflow", + "auto-wrap", + "word-clip" + ], + "type": "string" + } + }, + "type": "object" + }, + "data_validation": { + "description": "数据验证配置。设为 null 可清除已有的数据验证。", + "properties": { + "help_text": { + "description": "验证失败时显示的提示文本", + "type": "string" + }, + "items": { + "description": "列表选项(type='list' 时必填)", + "items": { + "type": "string" + }, + "type": "array" + }, + "operator": { + "description": "比较运算符(type='number'/'date'/'textLength' 时必填)", + "enum": [ + "equal", + "notEqual", + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual", + "between", + "notBetween" + ], + "type": "string" + }, + "range": { + "description": "源数据区域(type='listFromRange' 时必填,格式:'SheetName!A1:A10')", + "type": "string" + }, + "support_multiple_values": { + "description": "列表验证是否支持多选(type='list'/'listFromRange' 时可选,默认 false)", + "type": "boolean" + }, + "type": { + "description": "数据验证类型:list(下拉列表)、listFromRange(引用范围下拉列表)、number(数字)、date(日期)、textLength(文本长度)、checkbox(复选框)", + "enum": [ + "list", + "listFromRange", + "number", + "date", + "textLength", + "checkbox" + ], + "type": "string" + }, + "values": { + "description": "比较值(operator 为 'between'/'notBetween' 时需要两个值,其它运算符需要一个值)", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "type": "array" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "formula": { + "description": "以 '=' 开头的单元格公式(例如:'=SUM(A1:A10)')。公式必须写在此字段,而不是 'value'。", + "type": "string" + }, + "multiple_values": { + "description": "多值内容,用于支持多选的列表验证单元格。设置后会忽略 value 和 rich_text 字段。", + "items": { + "properties": { + "format": { + "description": "可选的数字格式(例如 '$#,##0.00')", + "type": "string" + }, + "value": { + "description": "值(文本、数字、布尔)", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + }, + "required": [ + "value" + ], + "type": "object" + }, + "type": "array" + }, + "note": { + "description": "单元格批注/备注", + "type": "string" + }, + "rich_text": { + "description": "富文本内容。设置后会忽略 value 字段。可包含带样式的文本段 text、超链接 link、@提及 mention、单元格图片 embed-image、附件 attachment。", + "items": { + "properties": { + "attachment_name": { + "description": "附件文件名称(仅 type='attachment' 时使用,配合 attachment_reference_id 使用,创建新附件时必填)", + "type": "string" + }, + "attachment_token": { + "description": "附件文件 token,通过飞书 Drive 上传获取(仅 type='attachment' 时可选,修改已有附件时可从 get_cell_range 获取)", + "type": "string" + }, + "attachment_uri": { + "description": "附件文件 reference_id(仅 type='attachment' 时使用,与 attachment_token 二选一,如`<|attachment|>:abcdef` 或者 `<|superscript|>:abcdef-<|attachment|>:abcdef`,其中 `abcdef` 为实际的对象 ID,占位符仅用于示意)", + "type": "string" + }, + "file_size": { + "description": "附件文件大小(字节,仅 type='attachment' 时使用)", + "type": "number" + }, + "image_height": { + "description": "图片高度(像素,仅 type='embed-image' 时使用)", + "type": "number" + }, + "image_name": { + "description": "图片名称(仅 type='embed-image' 时使用,创建新图片时必填)", + "type": "string" + }, + "image_token": { + "description": "图片文件 token(仅 type='embed-image' 时可选,修改已有图片时可从 get_cell_range 获取)", + "type": "string" + }, + "image_uri": { + "description": "图片文件 reference_id(仅 type='embed-image' 时使用,与 image_token 二选一,如`<|image|>:abcdef` 或者 `<|superscript|>:abcdef-<|image|>:abcdef`,其中 `abcdef` 为实际的对象 ID,占位符仅用于示意)", + "type": "string" + }, + "image_width": { + "description": "图片宽度(像素,仅 type='embed-image' 时使用)", + "type": "number" + }, + "link": { + "description": "超链接地址(仅 type='link' 时必填)", + "type": "string" + }, + "mention_token": { + "description": "@提及目标的 token,如 userId 或 fileId(仅 type='mention' 时必填)", + "type": "string" + }, + "mention_type": { + "description": "@提及类型编号(仅 type='mention' 时可选)", + "type": "number" + }, + "mime_type": { + "description": "附件 MIME 类型(仅 type='attachment' 时使用,例如 'application/pdf')", + "type": "string" + }, + "notify": { + "description": "是否发送通知(仅 type='mention' 时可选,默认 true)", + "type": "boolean" + }, + "style": { + "description": "文本段样式(仅 type='text' 时生效),结构同 cell_styles", + "type": "object" + }, + "text": { + "description": "显示文本", + "type": "string" + }, + "type": { + "description": "段类型", + "enum": [ + "text", + "link", + "mention", + "embed-image", + "attachment" + ], + "type": "string" + } + }, + "required": [ + "type", + "text" + ], + "type": "object" + }, + "type": "array" + }, + "value": { + "description": "静态单元格值(文本、数字、布尔)。公式请优先使用 'formula' 字段;如果误把以 '=' 开头的公式字符串写到这里,工具会按 Excel 语义自动识别为公式入库,但仍应按 'formula' 字段的契约传参。", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + }, + "type": "object" + } + }, + "+cells-set-style": { + "border-styles": { + "description": "单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)。", + "properties": { + "bottom": { + "properties": { + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, + "style": { + "description": "边框线型", + "enum": [ + "solid", + "dashed", + "dotted", + "double" + ], + "type": "string" + }, + "weight": { + "description": "边框粗细/线宽", + "enum": [ + "thin", + "medium", + "thick" + ], + "type": "string" + } + }, + "type": "object" + }, + "left": { + "properties": { + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, + "style": { + "description": "边框线型", + "enum": [ + "solid", + "dashed", + "dotted", + "double" + ], + "type": "string" + }, + "weight": { + "description": "边框粗细/线宽", + "enum": [ + "thin", + "medium", + "thick" + ], + "type": "string" + } + }, + "type": "object" + }, + "right": { + "properties": { + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, + "style": { + "description": "边框线型", + "enum": [ + "solid", + "dashed", + "dotted", + "double" + ], + "type": "string" + }, + "weight": { + "description": "边框粗细/线宽", + "enum": [ + "thin", + "medium", + "thick" + ], + "type": "string" + } + }, + "type": "object" + }, + "top": { + "properties": { + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, + "style": { + "description": "边框线型", + "enum": [ + "solid", + "dashed", + "dotted", + "double" + ], + "type": "string" + }, + "weight": { + "description": "边框粗细/线宽", + "enum": [ + "thin", + "medium", + "thick" + ], + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "+chart-create": { + "properties": { + "additionalProperties": {}, + "description": "创建/更新的图表属性。", + "properties": { + "offset": { + "additionalProperties": false, + "description": "可选。图表在位置基础上的偏移量(像素)。", + "properties": { + "col_offset": { + "description": "列偏移量(像素)", + "type": "number" + }, + "row_offset": { + "description": "行偏移量(像素)", + "type": "number" + } + }, + "type": "object" + }, + "position": { + "additionalProperties": false, + "description": "必填。图表在表格中的单元格位置。注意:选择位置时应避免覆盖已有数据的单元格,并确保图表不超出当前表格的行列范围。", + "properties": { + "col": { + "description": "列索引,例如 \"A\"、\"B\"", + "type": "string" + }, + "row": { + "description": "行索引(0-based)", + "minimum": 0, + "type": "number" + } + }, + "required": [ + "row", + "col" + ], + "type": "object" + }, + "size": { + "additionalProperties": false, + "description": "必填。图表大小(像素)。注意:设定大小时应确保图表不超出当前表格的行列范围,并避免覆盖已有数据的单元格。", + "properties": { + "height": { + "description": "高度(像素)", + "minimum": 10, + "type": "number" + }, + "width": { + "description": "宽度(像素)", + "minimum": 10, + "type": "number" + } + }, + "required": [ + "width", + "height" + ], + "type": "object" + }, + "snapshot": { + "description": "图表快照配置。更新图表时必须传入完整的图表属性定义,不能只传修改的部分。应先通过 get_chart_objects 获取当前图表快照,修改需要变更的字段后,将完整快照传入。", + "properties": { + "data": { + "description": "图表数据配置", + "properties": { + "dim1": { + "description": "维度1配置(类别维度)", + "properties": { + "field": { + "description": "字段配置(静态数据时传此参数)", + "properties": { + "name": { + "description": "字段名称", + "type": "string" + }, + "text": { + "description": "字段文本数据", + "type": "string" + }, + "valueType": { + "description": "值类型", + "enum": [ + "number", + "string" + ], + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "serie": { + "description": "系列配置(非静态数据时传此参数)", + "properties": { + "aggregate": { + "description": "是否汇总相同的类别,默认为true,true 情况下 aggregateType 才生效", + "type": "boolean" + }, + "index": { + "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引", + "type": "number" + }, + "nameRef": { + "description": "可选。维度名称的单元格引用(A1 表示法,如 'Sheet1!A1')。当维度名称不直接来自 refs 首行/首列时,可通过此字段把维度名显式指向目标表头单元格,图表会以该单元格当前值作为类别维度名展示。", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "dim2": { + "description": "维度2配置(值维度)", + "properties": { + "fields": { + "description": "字段配置数组(静态数据时传此参数)", + "items": { + "properties": { + "name": { + "description": "字段名称", + "type": "string" + }, + "text": { + "description": "字段文本数据", + "type": "string" + }, + "valueType": { + "description": "值类型", + "enum": [ + "number" + ], + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "type": "array" + }, + "series": { + "description": "系列配置数组(非静态数据时传此参数)", + "items": { + "properties": { + "aggregateType": { + "description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效", + "enum": [ + "sum", + "average", + "count", + "min", + "max", + "median" + ], + "type": "string" + }, + "index": { + "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引", + "type": "number" + }, + "nameRef": { + "description": "可选。系列名称的单元格引用(A1 表示法,如 'Sheet1!C1')。当系列名称不直接来自 refs 首行/首列时,可通过此字段把系列名显式指向目标表头单元格,图表会以该单元格当前值作为系列名(图例/tooltip)展示。", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "direction": { + "description": "数据方向,可选值:row(行方向)或column(列方向),常用值为column", + "enum": [ + "row", + "column" + ], + "type": "string" + }, + "headerMode": { + "description": "表头模式。inline 表示 refs 首行/首列就是表头;detached 表示 refs 仅覆盖实际数据范围,维度名和系列名需通过 nameRef 显式指定。", + "enum": [ + "inline", + "detached" + ], + "type": "string" + }, + "includeHiddenOrFilter": { + "description": "是否包含隐藏或过滤的数据", + "type": "boolean" + }, + "isStaticData": { + "description": "是否为静态数据", + "type": "boolean" + }, + "refs": { + "description": "数据源引用范围数组", + "items": { + "properties": { + "value": { + "description": "图表数据源范围,格式为 'SheetName!A1:D100' 的连续区域", + "type": "string" + } + }, + "required": [ + "value" + ], + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "legend": { + "oneOf": [ + { + "description": "图例配置", + "properties": { + "bold": { + "description": "是否加粗", + "type": "boolean" + }, + "color": { + "description": "字体颜色,格式为 #RRGGBB", + "type": "string" + }, + "fontSize": { + "description": "字体大小", + "type": "number" + }, + "italic": { + "description": "是否斜体", + "type": "boolean" + }, + "position": { + "description": "图例位置", + "enum": [ + "top", + "bottom", + "left", + "right" + ], + "type": "string" + }, + "strikethrough": { + "description": "是否删除线", + "type": "boolean" + }, + "underline": { + "description": "是否下划线", + "type": "boolean" + } + }, + "type": "object" + }, + { + "description": "false 表示隐藏图例", + "type": "boolean" + } + ] + }, + "plotArea": { + "description": "绘图区域配置", + "properties": { + "axes": { + "description": "坐标轴配置数组", + "items": { + "description": "坐标轴配置", + "properties": { + "axisLine": { + "description": "是否显示轴线", + "type": "boolean" + }, + "gridLine": { + "oneOf": [ + { + "description": "网格线配置", + "properties": { + "color": { + "description": "网格线颜色", + "type": "string" + }, + "width": { + "description": "网格线宽度", + "type": "number" + } + }, + "type": "object" + }, + { + "description": "false 表示隐藏网格线", + "type": "boolean" + } + ] + }, + "label": { + "description": "坐标轴标签配置", + "properties": { + "angle": { + "description": "旋转角度,可选值:-90, -45, 0, 45, 90", + "type": "number" + }, + "bold": { + "description": "是否加粗", + "type": "boolean" + }, + "color": { + "description": "字体颜色", + "type": "string" + }, + "fontSize": { + "description": "字体大小", + "type": "number" + }, + "italic": { + "description": "是否斜体", + "type": "boolean" + }, + "strikethrough": { + "description": "是否删除线", + "type": "boolean" + }, + "underline": { + "description": "是否下划线", + "type": "boolean" + } + }, + "type": "object" + }, + "max": { + "description": "最大值", + "type": "number" + }, + "min": { + "description": "最小值", + "type": "number" + }, + "position": { + "description": "坐标轴位置", + "enum": [ + "left", + "right", + "bottom" + ], + "type": "string" + }, + "title": { + "description": "坐标轴标题配置", + "properties": { + "bold": { + "description": "是否加粗", + "type": "boolean" + }, + "color": { + "description": "字体颜色", + "type": "string" + }, + "fontSize": { + "description": "字体大小", + "type": "number" + }, + "italic": { + "description": "是否斜体", + "type": "boolean" + }, + "strikethrough": { + "description": "是否删除线", + "type": "boolean" + }, + "text": { + "description": "标题文本", + "type": "string" + }, + "underline": { + "description": "是否下划线", + "type": "boolean" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "type": { + "description": "坐标轴类型", + "enum": [ + "x", + "y", + "angle", + "radius" + ], + "type": "string" + }, + "valueType": { + "description": "是否将横轴数字视为文本,linear 表示将数字视为数值, ordinal 表示将数字视为文本", + "enum": [ + "ordinal", + "linear" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "type": "array" + }, + "plot": { + "description": "绘图配置", + "properties": { + "areas": { + "description": "全系列面积填充配置,面积图、雷达图、组合图生效。", + "properties": { + "color": { + "description": "区域填充颜色", + "type": "string" + } + }, + "type": "object" + }, + "bars": { + "description": "全系列柱状图、条形图、组合图生效。", + "properties": { + "backgroundColor": { + "description": "背景颜色", + "type": "string" + }, + "bar": { + "description": "单个柱子配置数组", + "items": { + "properties": { + "borderColor": { + "description": "边框颜色", + "type": "string" + }, + "borderStyle": { + "description": "边框样式", + "type": "string" + }, + "borderWidth": { + "description": "边框宽度", + "type": "number" + }, + "color": { + "description": "颜色", + "type": "string" + }, + "index": { + "description": "柱子索引", + "type": "number" + } + }, + "required": [ + "index" + ], + "type": "object" + }, + "type": "array" + }, + "borderColor": { + "description": "边框颜色", + "type": "string" + }, + "borderStyle": { + "description": "边框样式", + "enum": [ + "solid", + "dashed", + "dotted" + ], + "type": "string" + }, + "borderWidth": { + "description": "边框宽度", + "type": "number" + }, + "color": { + "description": "柱子颜色", + "type": "string" + }, + "gap": { + "description": "柱子间距比例,0-1之间", + "type": "number" + }, + "width": { + "description": "柱子宽度", + "type": "number" + } + }, + "type": "object" + }, + "comboType": { + "description": "组合图表默认类型", + "enum": [ + "column", + "line", + "area" + ], + "type": "string" + }, + "extra": { + "description": "额外配置", + "properties": { + "radar": { + "description": "雷达图配置", + "properties": { + "area": { + "description": "是否填充区域", + "type": "boolean" + }, + "shape": { + "description": "雷达图形状", + "enum": [ + "polygon", + "circle" + ], + "type": "string" + } + }, + "type": "object" + }, + "smooth": { + "description": "是否平滑曲线", + "type": "boolean" + }, + "stack": { + "description": "堆叠配置", + "properties": { + "percentage": { + "description": "是否百分比堆叠", + "type": "boolean" + } + }, + "type": "object" + }, + "step": { + "description": "是否阶梯图", + "type": "boolean" + } + }, + "type": "object" + }, + "labels": { + "description": "数据标签配置", + "properties": { + "bold": { + "description": "是否加粗", + "type": "boolean" + }, + "category": { + "description": "是否显示类别名", + "type": "boolean" + }, + "color": { + "description": "字体颜色", + "type": "string" + }, + "fontSize": { + "description": "字体大小", + "type": "number" + }, + "italic": { + "description": "是否斜体", + "type": "boolean" + }, + "percentage": { + "description": "是否显示百分比", + "type": "boolean" + }, + "position": { + "description": "标签位置", + "enum": [ + "auto", + "top", + "bottom", + "left", + "right", + "center", + "inside", + "outside" + ], + "type": "string" + }, + "series": { + "description": "是否显示系列名", + "type": "boolean" + }, + "strikethrough": { + "description": "是否删除线", + "type": "boolean" + }, + "underline": { + "description": "是否下划线", + "type": "boolean" + }, + "value": { + "description": "是否显示值", + "type": "boolean" + } + }, + "type": "object" + }, + "lines": { + "description": "全系列线条配置,折线图、面积图、雷达图、组合图生效。", + "properties": { + "color": { + "description": "线条颜色", + "type": "string" + }, + "invalidType": { + "description": "无效值处理方式", + "enum": [ + "break", + "zero", + "link" + ], + "type": "string" + }, + "style": { + "description": "线条样式", + "enum": [ + "solid", + "dashed", + "dotted" + ], + "type": "string" + }, + "width": { + "description": "线条宽度", + "type": "number" + } + }, + "type": "object" + }, + "points": { + "description": "全系列数据点配置,折线图、面积图、雷达图、散点图、组合图生效。", + "properties": { + "color": { + "description": "数据点颜色", + "type": "string" + }, + "point": { + "description": "单个数据点配置数组", + "items": { + "properties": { + "color": { + "description": "颜色", + "type": "string" + }, + "index": { + "description": "数据点索引", + "type": "number" + }, + "shape": { + "description": "形状", + "type": "string" + }, + "size": { + "description": "大小", + "type": "number" + } + }, + "required": [ + "index" + ], + "type": "object" + }, + "type": "array" + }, + "shape": { + "description": "数据点形状", + "enum": [ + "circle", + "triangle", + "rect", + "diamond", + "square" + ], + "type": "string" + }, + "size": { + "description": "数据点大小", + "type": "number" + } + }, + "type": "object" + }, + "series": { + "description": "单个系列配置数组", + "items": { + "description": "系列配置", + "properties": { + "area": { + "description": "区域填充配置,配置项同 plotArea.areas", + "type": "object" + }, + "bars": { + "description": "柱状图配置,配置项同 plotArea.bars", + "type": "object" + }, + "comboType": { + "description": "组合图下该系列的图表类型,仅在 type 为 combo 时生效", + "enum": [ + "column", + "line", + "area" + ], + "type": "string" + }, + "index": { + "description": "数据列索引,与 dim2.series[].index 值一致,从 1 开始(1 通常对应首列标签维度,2+ 对应后续数据维度列)", + "type": "number" + }, + "labels": { + "description": "数据标签配置", + "type": "object" + }, + "line": { + "description": "线条配置,配置项同 plotArea.lines", + "type": "object" + }, + "points": { + "description": "数据点配置,配置项同 plotArea.points", + "type": "object" + }, + "sectors": { + "description": "扇区配置(饼图)", + "properties": { + "borderColor": { + "description": "边框颜色", + "type": "string" + }, + "innerRadius": { + "description": "内半径比例,0-1之间", + "type": "number" + }, + "offsetRadius": { + "description": "偏移半径比例", + "type": "number" + }, + "sector": { + "description": "单个扇区配置数组", + "items": { + "properties": { + "borderColor": { + "description": "边框颜色", + "type": "string" + }, + "color": { + "description": "颜色", + "type": "string" + }, + "index": { + "description": "扇区索引", + "type": "number" + }, + "offsetRadius": { + "description": "偏移半径", + "type": "number" + } + }, + "required": [ + "index" + ], + "type": "object" + }, + "type": "array" + }, + "startAngle": { + "description": "起始角度,0-359", + "type": "number" + } + }, + "type": "object" + }, + "yAxisPosition": { + "description": "Y轴位置", + "enum": [ + "left", + "right" + ], + "type": "string" + } + }, + "required": [ + "index" + ], + "type": "object" + }, + "type": "array" + }, + "type": { + "description": "图表类型", + "enum": [ + "bar", + "column", + "line", + "area", + "combo", + "pie", + "radar", + "scatter" + ], + "type": "string" + }, + "yAxisPosition": { + "description": "Y轴位置", + "enum": [ + "left", + "right" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + }, + "type": "object" + }, + "style": { + "description": "图表样式配置", + "properties": { + "background": { + "description": "背景配置", + "properties": { + "color": { + "description": "背景颜色,格式为 #RRGGBB", + "type": "string" + } + }, + "type": "object" + }, + "border": { + "description": "边框配置", + "properties": { + "color": { + "description": "边框颜色,格式为 #RRGGBB", + "type": "string" + }, + "radius": { + "description": "边框圆角", + "type": "number" + }, + "style": { + "description": "边框样式", + "enum": [ + "solid", + "dashed", + "dotted" + ], + "type": "string" + }, + "width": { + "description": "边框宽度", + "type": "number" + } + }, + "type": "object" + }, + "colorGradient": { + "description": "是否启用颜色渐变", + "type": "boolean" + }, + "colorTheme": { + "description": "颜色主题配置。支持两种模式:1. 仅传一个预设主题名称(字符串数组,长度为1);2. 传多个自定义十六进制颜色字符串。预设主题:brandColorSeries@v2、rainbowColorSeries@v2、complementaryColorSeries@v2、converseColorSeries@v2、primaryColorSeries@v2、singleColorSeries-B-@v2、singleColorSeries-W-@v2、singleColorSeries-G-@v2、singleColorSeries-Y-@v2、singleColorSeries-O-@v2、singleColorSeries-R-@v2、singleColorSeries-D-@v2。十六进制格式:#RRGGBB", + "oneOf": [ + { + "items": { + "enum": [ + "brandColorSeries@v2", + "rainbowColorSeries@v2", + "complementaryColorSeries@v2", + "converseColorSeries@v2", + "primaryColorSeries@v2", + "singleColorSeries-B-@v2", + "singleColorSeries-W-@v2", + "singleColorSeries-G-@v2", + "singleColorSeries-Y-@v2", + "singleColorSeries-O-@v2", + "singleColorSeries-R-@v2", + "singleColorSeries-D-@v2" + ], + "type": "string" + }, + "maxItems": 1, + "minItems": 1 + }, + { + "items": { + "description": "颜色字符串,十六进制格式:#RRGGBB", + "type": "string" + }, + "minItems": 2 + } + ], + "type": "array" + }, + "font": { + "description": "字体配置", + "properties": { + "color": { + "description": "字体颜色,格式为 #RRGGBB", + "type": "string" + }, + "size": { + "description": "字体大小", + "type": "number" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "subTitle": { + "description": "图表副标题配置", + "properties": { + "bold": { + "description": "是否加粗", + "type": "boolean" + }, + "color": { + "description": "字体颜色,格式为 #RRGGBB", + "type": "string" + }, + "fontSize": { + "description": "字体大小", + "type": "number" + }, + "italic": { + "description": "是否斜体", + "type": "boolean" + }, + "strikethrough": { + "description": "是否删除线", + "type": "boolean" + }, + "text": { + "description": "副标题文本", + "type": "string" + }, + "textAlign": { + "description": "副标题对齐方式", + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "underline": { + "description": "是否下划线", + "type": "boolean" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "title": { + "description": "图表标题配置", + "properties": { + "bold": { + "description": "是否加粗", + "type": "boolean" + }, + "color": { + "description": "字体颜色,格式为 #RRGGBB", + "type": "string" + }, + "fontSize": { + "description": "字体大小", + "type": "number" + }, + "italic": { + "description": "是否斜体", + "type": "boolean" + }, + "strikethrough": { + "description": "是否删除线", + "type": "boolean" + }, + "text": { + "description": "标题文本", + "type": "string" + }, + "textAlign": { + "description": "标题对齐方式", + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "underline": { + "description": "是否下划线", + "type": "boolean" + } + }, + "required": [ + "text" + ], + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "+chart-update": { + "properties": { + "additionalProperties": {}, + "description": "创建/更新的图表属性。", + "properties": { + "offset": { + "additionalProperties": false, + "description": "可选。图表在位置基础上的偏移量(像素)。", + "properties": { + "col_offset": { + "description": "列偏移量(像素)", + "type": "number" + }, + "row_offset": { + "description": "行偏移量(像素)", + "type": "number" + } + }, + "type": "object" + }, + "position": { + "additionalProperties": false, + "description": "必填。图表在表格中的单元格位置。注意:选择位置时应避免覆盖已有数据的单元格,并确保图表不超出当前表格的行列范围。", + "properties": { + "col": { + "description": "列索引,例如 \"A\"、\"B\"", + "type": "string" + }, + "row": { + "description": "行索引(0-based)", + "minimum": 0, + "type": "number" + } + }, + "required": [ + "row", + "col" + ], + "type": "object" + }, + "size": { + "additionalProperties": false, + "description": "必填。图表大小(像素)。注意:设定大小时应确保图表不超出当前表格的行列范围,并避免覆盖已有数据的单元格。", + "properties": { + "height": { + "description": "高度(像素)", + "minimum": 10, + "type": "number" + }, + "width": { + "description": "宽度(像素)", + "minimum": 10, + "type": "number" + } + }, + "required": [ + "width", + "height" + ], + "type": "object" + }, + "snapshot": { + "description": "图表快照配置。更新图表时必须传入完整的图表属性定义,不能只传修改的部分。应先通过 get_chart_objects 获取当前图表快照,修改需要变更的字段后,将完整快照传入。", + "properties": { + "data": { + "description": "图表数据配置", + "properties": { + "dim1": { + "description": "维度1配置(类别维度)", + "properties": { + "field": { + "description": "字段配置(静态数据时传此参数)", + "properties": { + "name": { + "description": "字段名称", + "type": "string" + }, + "text": { + "description": "字段文本数据", + "type": "string" + }, + "valueType": { + "description": "值类型", + "enum": [ + "number", + "string" + ], + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "serie": { + "description": "系列配置(非静态数据时传此参数)", + "properties": { + "aggregate": { + "description": "是否汇总相同的类别,默认为true,true 情况下 aggregateType 才生效", + "type": "boolean" + }, + "index": { + "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引", + "type": "number" + }, + "nameRef": { + "description": "可选。维度名称的单元格引用(A1 表示法,如 'Sheet1!A1')。当维度名称不直接来自 refs 首行/首列时,可通过此字段把维度名显式指向目标表头单元格,图表会以该单元格当前值作为类别维度名展示。", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "dim2": { + "description": "维度2配置(值维度)", + "properties": { + "fields": { + "description": "字段配置数组(静态数据时传此参数)", + "items": { + "properties": { + "name": { + "description": "字段名称", + "type": "string" + }, + "text": { + "description": "字段文本数据", + "type": "string" + }, + "valueType": { + "description": "值类型", + "enum": [ + "number" + ], + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "type": "array" + }, + "series": { + "description": "系列配置数组(非静态数据时传此参数)", + "items": { + "properties": { + "aggregateType": { + "description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效", + "enum": [ + "sum", + "average", + "count", + "min", + "max", + "median" + ], + "type": "string" + }, + "index": { + "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引", + "type": "number" + }, + "nameRef": { + "description": "可选。系列名称的单元格引用(A1 表示法,如 'Sheet1!C1')。当系列名称不直接来自 refs 首行/首列时,可通过此字段把系列名显式指向目标表头单元格,图表会以该单元格当前值作为系列名(图例/tooltip)展示。", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "direction": { + "description": "数据方向,可选值:row(行方向)或column(列方向),常用值为column", + "enum": [ + "row", + "column" + ], + "type": "string" + }, + "headerMode": { + "description": "表头模式。inline 表示 refs 首行/首列就是表头;detached 表示 refs 仅覆盖实际数据范围,维度名和系列名需通过 nameRef 显式指定。", + "enum": [ + "inline", + "detached" + ], + "type": "string" + }, + "includeHiddenOrFilter": { + "description": "是否包含隐藏或过滤的数据", + "type": "boolean" + }, + "isStaticData": { + "description": "是否为静态数据", + "type": "boolean" + }, + "refs": { + "description": "数据源引用范围数组", + "items": { + "properties": { + "value": { + "description": "图表数据源范围,格式为 'SheetName!A1:D100' 的连续区域", + "type": "string" + } + }, + "required": [ + "value" + ], + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "legend": { + "oneOf": [ + { + "description": "图例配置", + "properties": { + "bold": { + "description": "是否加粗", + "type": "boolean" + }, + "color": { + "description": "字体颜色,格式为 #RRGGBB", + "type": "string" + }, + "fontSize": { + "description": "字体大小", + "type": "number" + }, + "italic": { + "description": "是否斜体", + "type": "boolean" + }, + "position": { + "description": "图例位置", + "enum": [ + "top", + "bottom", + "left", + "right" + ], + "type": "string" + }, + "strikethrough": { + "description": "是否删除线", + "type": "boolean" + }, + "underline": { + "description": "是否下划线", + "type": "boolean" + } + }, + "type": "object" + }, + { + "description": "false 表示隐藏图例", + "type": "boolean" + } + ] + }, + "plotArea": { + "description": "绘图区域配置", + "properties": { + "axes": { + "description": "坐标轴配置数组", + "items": { + "description": "坐标轴配置", + "properties": { + "axisLine": { + "description": "是否显示轴线", + "type": "boolean" + }, + "gridLine": { + "oneOf": [ + { + "description": "网格线配置", + "properties": { + "color": { + "description": "网格线颜色", + "type": "string" + }, + "width": { + "description": "网格线宽度", + "type": "number" + } + }, + "type": "object" + }, + { + "description": "false 表示隐藏网格线", + "type": "boolean" + } + ] + }, + "label": { + "description": "坐标轴标签配置", + "properties": { + "angle": { + "description": "旋转角度,可选值:-90, -45, 0, 45, 90", + "type": "number" + }, + "bold": { + "description": "是否加粗", + "type": "boolean" + }, + "color": { + "description": "字体颜色", + "type": "string" + }, + "fontSize": { + "description": "字体大小", + "type": "number" + }, + "italic": { + "description": "是否斜体", + "type": "boolean" + }, + "strikethrough": { + "description": "是否删除线", + "type": "boolean" + }, + "underline": { + "description": "是否下划线", + "type": "boolean" + } + }, + "type": "object" + }, + "max": { + "description": "最大值", + "type": "number" + }, + "min": { + "description": "最小值", + "type": "number" + }, + "position": { + "description": "坐标轴位置", + "enum": [ + "left", + "right", + "bottom" + ], + "type": "string" + }, + "title": { + "description": "坐标轴标题配置", + "properties": { + "bold": { + "description": "是否加粗", + "type": "boolean" + }, + "color": { + "description": "字体颜色", + "type": "string" + }, + "fontSize": { + "description": "字体大小", + "type": "number" + }, + "italic": { + "description": "是否斜体", + "type": "boolean" + }, + "strikethrough": { + "description": "是否删除线", + "type": "boolean" + }, + "text": { + "description": "标题文本", + "type": "string" + }, + "underline": { + "description": "是否下划线", + "type": "boolean" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "type": { + "description": "坐标轴类型", + "enum": [ + "x", + "y", + "angle", + "radius" + ], + "type": "string" + }, + "valueType": { + "description": "是否将横轴数字视为文本,linear 表示将数字视为数值, ordinal 表示将数字视为文本", + "enum": [ + "ordinal", + "linear" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "type": "array" + }, + "plot": { + "description": "绘图配置", + "properties": { + "areas": { + "description": "全系列面积填充配置,面积图、雷达图、组合图生效。", + "properties": { + "color": { + "description": "区域填充颜色", + "type": "string" + } + }, + "type": "object" + }, + "bars": { + "description": "全系列柱状图、条形图、组合图生效。", + "properties": { + "backgroundColor": { + "description": "背景颜色", + "type": "string" + }, + "bar": { + "description": "单个柱子配置数组", + "items": { + "properties": { + "borderColor": { + "description": "边框颜色", + "type": "string" + }, + "borderStyle": { + "description": "边框样式", + "type": "string" + }, + "borderWidth": { + "description": "边框宽度", + "type": "number" + }, + "color": { + "description": "颜色", + "type": "string" + }, + "index": { + "description": "柱子索引", + "type": "number" + } + }, + "required": [ + "index" + ], + "type": "object" + }, + "type": "array" + }, + "borderColor": { + "description": "边框颜色", + "type": "string" + }, + "borderStyle": { + "description": "边框样式", + "enum": [ + "solid", + "dashed", + "dotted" + ], + "type": "string" + }, + "borderWidth": { + "description": "边框宽度", + "type": "number" + }, + "color": { + "description": "柱子颜色", + "type": "string" + }, + "gap": { + "description": "柱子间距比例,0-1之间", + "type": "number" + }, + "width": { + "description": "柱子宽度", + "type": "number" + } + }, + "type": "object" + }, + "comboType": { + "description": "组合图表默认类型", + "enum": [ + "column", + "line", + "area" + ], + "type": "string" + }, + "extra": { + "description": "额外配置", + "properties": { + "radar": { + "description": "雷达图配置", + "properties": { + "area": { + "description": "是否填充区域", + "type": "boolean" + }, + "shape": { + "description": "雷达图形状", + "enum": [ + "polygon", + "circle" + ], + "type": "string" + } + }, + "type": "object" + }, + "smooth": { + "description": "是否平滑曲线", + "type": "boolean" + }, + "stack": { + "description": "堆叠配置", + "properties": { + "percentage": { + "description": "是否百分比堆叠", + "type": "boolean" + } + }, + "type": "object" + }, + "step": { + "description": "是否阶梯图", + "type": "boolean" + } + }, + "type": "object" + }, + "labels": { + "description": "数据标签配置", + "properties": { + "bold": { + "description": "是否加粗", + "type": "boolean" + }, + "category": { + "description": "是否显示类别名", + "type": "boolean" + }, + "color": { + "description": "字体颜色", + "type": "string" + }, + "fontSize": { + "description": "字体大小", + "type": "number" + }, + "italic": { + "description": "是否斜体", + "type": "boolean" + }, + "percentage": { + "description": "是否显示百分比", + "type": "boolean" + }, + "position": { + "description": "标签位置", + "enum": [ + "auto", + "top", + "bottom", + "left", + "right", + "center", + "inside", + "outside" + ], + "type": "string" + }, + "series": { + "description": "是否显示系列名", + "type": "boolean" + }, + "strikethrough": { + "description": "是否删除线", + "type": "boolean" + }, + "underline": { + "description": "是否下划线", + "type": "boolean" + }, + "value": { + "description": "是否显示值", + "type": "boolean" + } + }, + "type": "object" + }, + "lines": { + "description": "全系列线条配置,折线图、面积图、雷达图、组合图生效。", + "properties": { + "color": { + "description": "线条颜色", + "type": "string" + }, + "invalidType": { + "description": "无效值处理方式", + "enum": [ + "break", + "zero", + "link" + ], + "type": "string" + }, + "style": { + "description": "线条样式", + "enum": [ + "solid", + "dashed", + "dotted" + ], + "type": "string" + }, + "width": { + "description": "线条宽度", + "type": "number" + } + }, + "type": "object" + }, + "points": { + "description": "全系列数据点配置,折线图、面积图、雷达图、散点图、组合图生效。", + "properties": { + "color": { + "description": "数据点颜色", + "type": "string" + }, + "point": { + "description": "单个数据点配置数组", + "items": { + "properties": { + "color": { + "description": "颜色", + "type": "string" + }, + "index": { + "description": "数据点索引", + "type": "number" + }, + "shape": { + "description": "形状", + "type": "string" + }, + "size": { + "description": "大小", + "type": "number" + } + }, + "required": [ + "index" + ], + "type": "object" + }, + "type": "array" + }, + "shape": { + "description": "数据点形状", + "enum": [ + "circle", + "triangle", + "rect", + "diamond", + "square" + ], + "type": "string" + }, + "size": { + "description": "数据点大小", + "type": "number" + } + }, + "type": "object" + }, + "series": { + "description": "单个系列配置数组", + "items": { + "description": "系列配置", + "properties": { + "area": { + "description": "区域填充配置,配置项同 plotArea.areas", + "type": "object" + }, + "bars": { + "description": "柱状图配置,配置项同 plotArea.bars", + "type": "object" + }, + "comboType": { + "description": "组合图下该系列的图表类型,仅在 type 为 combo 时生效", + "enum": [ + "column", + "line", + "area" + ], + "type": "string" + }, + "index": { + "description": "数据列索引,与 dim2.series[].index 值一致,从 1 开始(1 通常对应首列标签维度,2+ 对应后续数据维度列)", + "type": "number" + }, + "labels": { + "description": "数据标签配置", + "type": "object" + }, + "line": { + "description": "线条配置,配置项同 plotArea.lines", + "type": "object" + }, + "points": { + "description": "数据点配置,配置项同 plotArea.points", + "type": "object" + }, + "sectors": { + "description": "扇区配置(饼图)", + "properties": { + "borderColor": { + "description": "边框颜色", + "type": "string" + }, + "innerRadius": { + "description": "内半径比例,0-1之间", + "type": "number" + }, + "offsetRadius": { + "description": "偏移半径比例", + "type": "number" + }, + "sector": { + "description": "单个扇区配置数组", + "items": { + "properties": { + "borderColor": { + "description": "边框颜色", + "type": "string" + }, + "color": { + "description": "颜色", + "type": "string" + }, + "index": { + "description": "扇区索引", + "type": "number" + }, + "offsetRadius": { + "description": "偏移半径", + "type": "number" + } + }, + "required": [ + "index" + ], + "type": "object" + }, + "type": "array" + }, + "startAngle": { + "description": "起始角度,0-359", + "type": "number" + } + }, + "type": "object" + }, + "yAxisPosition": { + "description": "Y轴位置", + "enum": [ + "left", + "right" + ], + "type": "string" + } + }, + "required": [ + "index" + ], + "type": "object" + }, + "type": "array" + }, + "type": { + "description": "图表类型", + "enum": [ + "bar", + "column", + "line", + "area", + "combo", + "pie", + "radar", + "scatter" + ], + "type": "string" + }, + "yAxisPosition": { + "description": "Y轴位置", + "enum": [ + "left", + "right" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + }, + "type": "object" + }, + "style": { + "description": "图表样式配置", + "properties": { + "background": { + "description": "背景配置", + "properties": { + "color": { + "description": "背景颜色,格式为 #RRGGBB", + "type": "string" + } + }, + "type": "object" + }, + "border": { + "description": "边框配置", + "properties": { + "color": { + "description": "边框颜色,格式为 #RRGGBB", + "type": "string" + }, + "radius": { + "description": "边框圆角", + "type": "number" + }, + "style": { + "description": "边框样式", + "enum": [ + "solid", + "dashed", + "dotted" + ], + "type": "string" + }, + "width": { + "description": "边框宽度", + "type": "number" + } + }, + "type": "object" + }, + "colorGradient": { + "description": "是否启用颜色渐变", + "type": "boolean" + }, + "colorTheme": { + "description": "颜色主题配置。支持两种模式:1. 仅传一个预设主题名称(字符串数组,长度为1);2. 传多个自定义十六进制颜色字符串。预设主题:brandColorSeries@v2、rainbowColorSeries@v2、complementaryColorSeries@v2、converseColorSeries@v2、primaryColorSeries@v2、singleColorSeries-B-@v2、singleColorSeries-W-@v2、singleColorSeries-G-@v2、singleColorSeries-Y-@v2、singleColorSeries-O-@v2、singleColorSeries-R-@v2、singleColorSeries-D-@v2。十六进制格式:#RRGGBB", + "oneOf": [ + { + "items": { + "enum": [ + "brandColorSeries@v2", + "rainbowColorSeries@v2", + "complementaryColorSeries@v2", + "converseColorSeries@v2", + "primaryColorSeries@v2", + "singleColorSeries-B-@v2", + "singleColorSeries-W-@v2", + "singleColorSeries-G-@v2", + "singleColorSeries-Y-@v2", + "singleColorSeries-O-@v2", + "singleColorSeries-R-@v2", + "singleColorSeries-D-@v2" + ], + "type": "string" + }, + "maxItems": 1, + "minItems": 1 + }, + { + "items": { + "description": "颜色字符串,十六进制格式:#RRGGBB", + "type": "string" + }, + "minItems": 2 + } + ], + "type": "array" + }, + "font": { + "description": "字体配置", + "properties": { + "color": { + "description": "字体颜色,格式为 #RRGGBB", + "type": "string" + }, + "size": { + "description": "字体大小", + "type": "number" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "subTitle": { + "description": "图表副标题配置", + "properties": { + "bold": { + "description": "是否加粗", + "type": "boolean" + }, + "color": { + "description": "字体颜色,格式为 #RRGGBB", + "type": "string" + }, + "fontSize": { + "description": "字体大小", + "type": "number" + }, + "italic": { + "description": "是否斜体", + "type": "boolean" + }, + "strikethrough": { + "description": "是否删除线", + "type": "boolean" + }, + "text": { + "description": "副标题文本", + "type": "string" + }, + "textAlign": { + "description": "副标题对齐方式", + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "underline": { + "description": "是否下划线", + "type": "boolean" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "title": { + "description": "图表标题配置", + "properties": { + "bold": { + "description": "是否加粗", + "type": "boolean" + }, + "color": { + "description": "字体颜色,格式为 #RRGGBB", + "type": "string" + }, + "fontSize": { + "description": "字体大小", + "type": "number" + }, + "italic": { + "description": "是否斜体", + "type": "boolean" + }, + "strikethrough": { + "description": "是否删除线", + "type": "boolean" + }, + "text": { + "description": "标题文本", + "type": "string" + }, + "textAlign": { + "description": "标题对齐方式", + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "underline": { + "description": "是否下划线", + "type": "boolean" + } + }, + "required": [ + "text" + ], + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "+cond-format-create": { + "properties": { + "additionalProperties": false, + "description": "创建/更新的条件格式属性。", + "properties": { + "attrs": { + "description": "规则参数列表。不同 rule_type 下取值结构不同。当 rule_type 为 dataBar 时,attrs 必须包含两个对象,分别定义正值和负值的数据条颜色。", + "items": { + "oneOf": [ + { + "additionalProperties": false, + "description": "数值比较类规则参数。", + "properties": { + "compare_type": { + "description": "比较运算符。", + "enum": [ + "equal", + "notEqual", + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual", + "between", + "notBetween" + ], + "type": "string" + }, + "value": { + "description": "用于比较的值,例如 \"100\"、\"hello\"。between/notBetween 时用逗号分隔两个值。", + "type": "string" + } + }, + "required": [ + "compare_type", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "文本包含类规则参数。", + "properties": { + "compare_type": { + "description": "文本匹配方式。", + "enum": [ + "beginsWith", + "endsWith", + "containsText", + "notContains", + "is" + ], + "type": "string" + }, + "text": { + "description": "用于匹配的文本内容。", + "type": "string" + } + }, + "required": [ + "compare_type", + "text" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "时间段类规则参数。", + "properties": { + "operator": { + "description": "与指定时间段的比较关系。", + "enum": [ + "before", + "is", + "after" + ], + "type": "string" + }, + "time_period": { + "description": "时间段类型。", + "enum": [ + "today", + "yesterday", + "tomorrow", + "last7Days", + "thisMonth", + "lastMonth", + "nextMonth", + "thisWeek", + "lastWeek", + "nextWeek" + ], + "type": "string" + } + }, + "required": [ + "operator", + "time_period" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "数据条规则参数。", + "properties": { + "color": { + "description": "主颜色,例如 \"#63BE7B\"。", + "type": "string" + }, + "gradient": { + "description": "是否使用渐变色数据条。", + "type": "boolean" + }, + "hide_value": { + "description": "是否隐藏单元格中的原始值,仅显示数据条。", + "type": "boolean" + }, + "value": { + "description": "阈值或比例值,含义由 value_type 决定。", + "type": "number" + }, + "value_type": { + "description": "阈值类型。", + "enum": [ + "minValue", + "maxValue", + "num", + "percentile", + "percent", + "formula", + "auto" + ], + "type": "string" + } + }, + "required": [ + "color", + "value_type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "色阶规则中的单个分段。ColorScaleAttrs 由 2 或 3 个该对象组成,分别表示最小值/中间值/最大值。", + "properties": { + "color": { + "description": "该分段对应的颜色。", + "type": "string" + }, + "value": { + "description": "阈值数值,例如百分位或具体数值。", + "type": "number" + }, + "value_type": { + "description": "阈值类型。", + "enum": [ + "minValue", + "maxValue", + "num", + "percentile", + "percent", + "formula", + "auto" + ], + "type": "string" + } + }, + "required": [ + "value_type", + "color" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "前 N/后 N 规则参数。", + "properties": { + "is_bottom": { + "description": "是否选取最后 N 个。true 表示最后 N 个,false 表示前 N 个。", + "type": "boolean" + }, + "value": { + "description": "N 或百分比数值。", + "type": "number" + }, + "value_type": { + "description": "排名方式:percent 表示百分比,sort 表示按条目数。", + "enum": [ + "percent", + "sort" + ], + "type": "string" + } + }, + "required": [ + "is_bottom", + "value_type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "平均值规则参数。", + "properties": { + "operator": { + "description": "与平均值的比较关系。", + "enum": [ + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual" + ], + "type": "string" + } + }, + "required": [ + "operator" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "自定义公式规则参数。", + "properties": { + "formula": { + "description": "条件公式列表,例如 [\"=A1>0\"]。", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "formula" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "图标集规则参数。", + "properties": { + "hide_value": { + "description": "是否隐藏单元格原始值,仅显示图标。", + "type": "boolean" + }, + "icon_type": { + "description": "图标集类型。其中 3Circles/4Circles/5Circles 为不同颜色的实心圆(如红、黄、绿),用于表示状态等级;5CirclesRatio 为不同填充比例的圆(从空心◯到半圆◑到满圆●),用于表示完成度、进度、比例。当涉及半圆、满圆、空心圆、填充比例、进度等场景时,应选择 5CirclesRatio。", + "enum": [ + "3Arrows", + "3ArrowsGray", + "3Triangles", + "4ArrowsGray", + "4Arrows", + "5ArrowsGray", + "5Arrows", + "3Circles", + "3MultiGraphics", + "4Circles", + "5Circles", + "2Status", + "3Status", + "2CommentStatus", + "3Flags", + "3Stars", + "3HeartShaped", + "3Mood", + "5CirclesRatio" + ], + "type": "string" + }, + "operator": { + "description": "与阈值的比较关系。", + "enum": [ + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual" + ], + "type": "string" + }, + "reverse_icons": { + "description": "是否反转图标顺序。", + "type": "boolean" + }, + "value": { + "description": "用于比较的数值,含义由 value_type 决定。", + "type": "number" + }, + "value_type": { + "description": "阈值类型。", + "enum": [ + "minValue", + "maxValue", + "num", + "percentile", + "percent", + "formula", + "auto" + ], + "type": "string" + } + }, + "required": [ + "icon_type", + "value_type", + "operator" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "has_ref": { + "description": "可选。是否存在引用,用于标记规则是否依赖其他对象。", + "type": "boolean" + }, + "ranges": { + "description": "应用条件格式的 A1 范围列表。每个元素支持:矩形范围 \"A1:A10\"、整行 \"3:6\"、整列 \"C:C\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。例如 [\"A1:A10\",\"C:C\"]。", + "items": { + "type": "string" + }, + "type": "array" + }, + "rule_type": { + "description": "条件格式规则类型。", + "enum": [ + "duplicateValues", + "uniqueValues", + "cellIs", + "containsText", + "timePeriod", + "containsBlanks", + "notContainsBlanks", + "dataBar", + "colorScale", + "rank", + "aboveAverage", + "expression", + "iconSet" + ], + "type": "string" + }, + "style": { + "additionalProperties": false, + "description": "命中规则时应用的单元格样式。", + "properties": { + "back_color": { + "description": "背景色,CSS 十六进制表示,例如 \"#FF0000\"。", + "type": "string" + }, + "font": { + "description": "字体样式:\"bold\"=加粗,\"italic\"=斜体,\"bold italic\"=加粗+斜体。", + "enum": [ + "bold", + "italic", + "bold italic" + ], + "type": "string" + }, + "fore_color": { + "description": "前景色/字体颜色。", + "type": "string" + }, + "text_decoration": { + "description": "文字装饰:none=无,underline=下划线,strikethrough=删除线,underline_strikethrough=下划线+删除线。", + "enum": [ + "none", + "underline", + "strikethrough", + "underline_strikethrough" + ], + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "rule_type", + "ranges", + "style" + ], + "type": "object" + } + }, + "+cond-format-update": { + "properties": { + "additionalProperties": false, + "description": "创建/更新的条件格式属性。", + "properties": { + "attrs": { + "description": "规则参数列表。不同 rule_type 下取值结构不同。当 rule_type 为 dataBar 时,attrs 必须包含两个对象,分别定义正值和负值的数据条颜色。", + "items": { + "oneOf": [ + { + "additionalProperties": false, + "description": "数值比较类规则参数。", + "properties": { + "compare_type": { + "description": "比较运算符。", + "enum": [ + "equal", + "notEqual", + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual", + "between", + "notBetween" + ], + "type": "string" + }, + "value": { + "description": "用于比较的值,例如 \"100\"、\"hello\"。between/notBetween 时用逗号分隔两个值。", + "type": "string" + } + }, + "required": [ + "compare_type", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "文本包含类规则参数。", + "properties": { + "compare_type": { + "description": "文本匹配方式。", + "enum": [ + "beginsWith", + "endsWith", + "containsText", + "notContains", + "is" + ], + "type": "string" + }, + "text": { + "description": "用于匹配的文本内容。", + "type": "string" + } + }, + "required": [ + "compare_type", + "text" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "时间段类规则参数。", + "properties": { + "operator": { + "description": "与指定时间段的比较关系。", + "enum": [ + "before", + "is", + "after" + ], + "type": "string" + }, + "time_period": { + "description": "时间段类型。", + "enum": [ + "today", + "yesterday", + "tomorrow", + "last7Days", + "thisMonth", + "lastMonth", + "nextMonth", + "thisWeek", + "lastWeek", + "nextWeek" + ], + "type": "string" + } + }, + "required": [ + "operator", + "time_period" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "数据条规则参数。", + "properties": { + "color": { + "description": "主颜色,例如 \"#63BE7B\"。", + "type": "string" + }, + "gradient": { + "description": "是否使用渐变色数据条。", + "type": "boolean" + }, + "hide_value": { + "description": "是否隐藏单元格中的原始值,仅显示数据条。", + "type": "boolean" + }, + "value": { + "description": "阈值或比例值,含义由 value_type 决定。", + "type": "number" + }, + "value_type": { + "description": "阈值类型。", + "enum": [ + "minValue", + "maxValue", + "num", + "percentile", + "percent", + "formula", + "auto" + ], + "type": "string" + } + }, + "required": [ + "color", + "value_type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "色阶规则中的单个分段。ColorScaleAttrs 由 2 或 3 个该对象组成,分别表示最小值/中间值/最大值。", + "properties": { + "color": { + "description": "该分段对应的颜色。", + "type": "string" + }, + "value": { + "description": "阈值数值,例如百分位或具体数值。", + "type": "number" + }, + "value_type": { + "description": "阈值类型。", + "enum": [ + "minValue", + "maxValue", + "num", + "percentile", + "percent", + "formula", + "auto" + ], + "type": "string" + } + }, + "required": [ + "value_type", + "color" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "前 N/后 N 规则参数。", + "properties": { + "is_bottom": { + "description": "是否选取最后 N 个。true 表示最后 N 个,false 表示前 N 个。", + "type": "boolean" + }, + "value": { + "description": "N 或百分比数值。", + "type": "number" + }, + "value_type": { + "description": "排名方式:percent 表示百分比,sort 表示按条目数。", + "enum": [ + "percent", + "sort" + ], + "type": "string" + } + }, + "required": [ + "is_bottom", + "value_type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "平均值规则参数。", + "properties": { + "operator": { + "description": "与平均值的比较关系。", + "enum": [ + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual" + ], + "type": "string" + } + }, + "required": [ + "operator" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "自定义公式规则参数。", + "properties": { + "formula": { + "description": "条件公式列表,例如 [\"=A1>0\"]。", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "formula" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "图标集规则参数。", + "properties": { + "hide_value": { + "description": "是否隐藏单元格原始值,仅显示图标。", + "type": "boolean" + }, + "icon_type": { + "description": "图标集类型。其中 3Circles/4Circles/5Circles 为不同颜色的实心圆(如红、黄、绿),用于表示状态等级;5CirclesRatio 为不同填充比例的圆(从空心◯到半圆◑到满圆●),用于表示完成度、进度、比例。当涉及半圆、满圆、空心圆、填充比例、进度等场景时,应选择 5CirclesRatio。", + "enum": [ + "3Arrows", + "3ArrowsGray", + "3Triangles", + "4ArrowsGray", + "4Arrows", + "5ArrowsGray", + "5Arrows", + "3Circles", + "3MultiGraphics", + "4Circles", + "5Circles", + "2Status", + "3Status", + "2CommentStatus", + "3Flags", + "3Stars", + "3HeartShaped", + "3Mood", + "5CirclesRatio" + ], + "type": "string" + }, + "operator": { + "description": "与阈值的比较关系。", + "enum": [ + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual" + ], + "type": "string" + }, + "reverse_icons": { + "description": "是否反转图标顺序。", + "type": "boolean" + }, + "value": { + "description": "用于比较的数值,含义由 value_type 决定。", + "type": "number" + }, + "value_type": { + "description": "阈值类型。", + "enum": [ + "minValue", + "maxValue", + "num", + "percentile", + "percent", + "formula", + "auto" + ], + "type": "string" + } + }, + "required": [ + "icon_type", + "value_type", + "operator" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "has_ref": { + "description": "可选。是否存在引用,用于标记规则是否依赖其他对象。", + "type": "boolean" + }, + "ranges": { + "description": "应用条件格式的 A1 范围列表。每个元素支持:矩形范围 \"A1:A10\"、整行 \"3:6\"、整列 \"C:C\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。例如 [\"A1:A10\",\"C:C\"]。", + "items": { + "type": "string" + }, + "type": "array" + }, + "rule_type": { + "description": "条件格式规则类型。", + "enum": [ + "duplicateValues", + "uniqueValues", + "cellIs", + "containsText", + "timePeriod", + "containsBlanks", + "notContainsBlanks", + "dataBar", + "colorScale", + "rank", + "aboveAverage", + "expression", + "iconSet" + ], + "type": "string" + }, + "style": { + "additionalProperties": false, + "description": "命中规则时应用的单元格样式。", + "properties": { + "back_color": { + "description": "背景色,CSS 十六进制表示,例如 \"#FF0000\"。", + "type": "string" + }, + "font": { + "description": "字体样式:\"bold\"=加粗,\"italic\"=斜体,\"bold italic\"=加粗+斜体。", + "enum": [ + "bold", + "italic", + "bold italic" + ], + "type": "string" + }, + "fore_color": { + "description": "前景色/字体颜色。", + "type": "string" + }, + "text_decoration": { + "description": "文字装饰:none=无,underline=下划线,strikethrough=删除线,underline_strikethrough=下划线+删除线。", + "enum": [ + "none", + "underline", + "strikethrough", + "underline_strikethrough" + ], + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "rule_type", + "ranges", + "style" + ], + "type": "object" + } + }, + "+dropdown-set": { + "options": { + "description": "数据验证配置。设为 null 可清除已有的数据验证。", + "properties": { + "help_text": { + "description": "验证失败时显示的提示文本", + "type": "string" + }, + "items": { + "description": "列表选项(type='list' 时必填)", + "items": { + "type": "string" + }, + "type": "array" + }, + "operator": { + "description": "比较运算符(type='number'/'date'/'textLength' 时必填)", + "enum": [ + "equal", + "notEqual", + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual", + "between", + "notBetween" + ], + "type": "string" + }, + "range": { + "description": "源数据区域(type='listFromRange' 时必填,格式:'SheetName!A1:A10')", + "type": "string" + }, + "support_multiple_values": { + "description": "列表验证是否支持多选(type='list'/'listFromRange' 时可选,默认 false)", + "type": "boolean" + }, + "type": { + "description": "数据验证类型:list(下拉列表)、listFromRange(引用范围下拉列表)、number(数字)、date(日期)、textLength(文本长度)、checkbox(复选框)", + "enum": [ + "list", + "listFromRange", + "number", + "date", + "textLength", + "checkbox" + ], + "type": "string" + }, + "values": { + "description": "比较值(operator 为 'between'/'notBetween' 时需要两个值,其它运算符需要一个值)", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "type": "array" + } + }, + "required": [ + "type" + ], + "type": "object" + } + }, + "+dropdown-update": { + "options": { + "description": "数据验证配置。设为 null 可清除已有的数据验证。", + "properties": { + "help_text": { + "description": "验证失败时显示的提示文本", + "type": "string" + }, + "items": { + "description": "列表选项(type='list' 时必填)", + "items": { + "type": "string" + }, + "type": "array" + }, + "operator": { + "description": "比较运算符(type='number'/'date'/'textLength' 时必填)", + "enum": [ + "equal", + "notEqual", + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual", + "between", + "notBetween" + ], + "type": "string" + }, + "range": { + "description": "源数据区域(type='listFromRange' 时必填,格式:'SheetName!A1:A10')", + "type": "string" + }, + "support_multiple_values": { + "description": "列表验证是否支持多选(type='list'/'listFromRange' 时可选,默认 false)", + "type": "boolean" + }, + "type": { + "description": "数据验证类型:list(下拉列表)、listFromRange(引用范围下拉列表)、number(数字)、date(日期)、textLength(文本长度)、checkbox(复选框)", + "enum": [ + "list", + "listFromRange", + "number", + "date", + "textLength", + "checkbox" + ], + "type": "string" + }, + "values": { + "description": "比较值(operator 为 'between'/'notBetween' 时需要两个值,其它运算符需要一个值)", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "type": "array" + } + }, + "required": [ + "type" + ], + "type": "object" + } + }, + "+filter-create": { + "properties": { + "additionalProperties": false, + "description": "创建/更新的筛选器属性。", + "properties": { + "filtered_columns": { + "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", + "items": { + "type": "string" + }, + "type": "array" + }, + "range": { + "description": "筛选对象作用的单元格范围(A1 表示法)。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。", + "type": "string" + }, + "rules": { + "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。update 时传空数组 [] 表示清空所有现有列的规则。", + "items": { + "additionalProperties": false, + "description": "单列筛选规则。", + "properties": { + "column_index": { + "description": "作用的列索引,例如 \"A\"、\"B\"。", + "type": "string" + }, + "conditions": { + "description": "该列上的条件列表。**目前每列仅支持 1 个 condition**(传入多个会被拒绝,请用 multiValue 或单条件表达),数组形式为未来扩展多条件组合预留。", + "items": { + "oneOf": [ + { + "additionalProperties": false, + "description": "文本条件筛选。", + "properties": { + "compare_type": { + "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。", + "enum": [ + "beginsWith", + "doesNotBeginWith", + "endsWith", + "doesNotEndWith", + "contains", + "doesNotContain", + "equals", + "notEquals" + ], + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"text\"。", + "enum": [ + "text" + ], + "type": "string" + }, + "values": { + "description": "可选。用于比较的文本值列表,只需要提供一个值,例如 [\"华东\"]。", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "type", + "compare_type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "数值条件筛选。", + "properties": { + "compare_type": { + "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。", + "enum": [ + "equal", + "notEqual", + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual", + "between", + "notBetween" + ], + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"number\"。", + "enum": [ + "number" + ], + "type": "string" + }, + "values": { + "description": "可选。用于比较的数值参数列表。大多数比较运算需要 1 个值(如 [5000]),between/notBetween 需要 2 个值(如 [1000, 5000])。", + "items": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "type": "array" + } + }, + "required": [ + "type", + "compare_type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "颜色条件筛选。", + "properties": { + "compare_type": { + "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。", + "enum": [ + "backgroundColor", + "foregroundColor" + ], + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"color\"。", + "enum": [ + "color" + ], + "type": "string" + }, + "value": { + "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。", + "type": "string" + } + }, + "required": [ + "type", + "compare_type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "多值条件筛选。", + "properties": { + "compare_type": { + "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。", + "enum": [ + "equal", + "notEqual" + ], + "type": "string" + }, + "date_groups": { + "description": "可选。年月日等聚合筛选信息。", + "items": { + "additionalProperties": false, + "properties": { + "date_time_grouping": { + "enum": [ + "year", + "month", + "day", + "hour", + "minute", + "second" + ], + "type": "string" + }, + "day": { + "type": "number" + }, + "hour": { + "type": "number" + }, + "minute": { + "type": "number" + }, + "month": { + "type": "number" + }, + "second": { + "type": "number" + }, + "year": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + }, + "type": { + "description": "条件类型固定为 \"multiValue\"。", + "enum": [ + "multiValue" + ], + "type": "string" + }, + "values": { + "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "type", + "compare_type" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "filtered_rows": { + "description": "可选。由该列条件直接导致被隐藏的行索引列表(0-based),用于调试或回显。", + "items": { + "type": "number" + }, + "type": "array" + } + }, + "required": [ + "column_index", + "conditions" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "range", + "rules" + ], + "type": "object" + } + }, + "+filter-update": { + "properties": { + "additionalProperties": false, + "description": "创建/更新的筛选器属性。", + "properties": { + "filtered_columns": { + "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", + "items": { + "type": "string" + }, + "type": "array" + }, + "range": { + "description": "筛选对象作用的单元格范围(A1 表示法)。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。", + "type": "string" + }, + "rules": { + "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。update 时传空数组 [] 表示清空所有现有列的规则。", + "items": { + "additionalProperties": false, + "description": "单列筛选规则。", + "properties": { + "column_index": { + "description": "作用的列索引,例如 \"A\"、\"B\"。", + "type": "string" + }, + "conditions": { + "description": "该列上的条件列表。**目前每列仅支持 1 个 condition**(传入多个会被拒绝,请用 multiValue 或单条件表达),数组形式为未来扩展多条件组合预留。", + "items": { + "oneOf": [ + { + "additionalProperties": false, + "description": "文本条件筛选。", + "properties": { + "compare_type": { + "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。", + "enum": [ + "beginsWith", + "doesNotBeginWith", + "endsWith", + "doesNotEndWith", + "contains", + "doesNotContain", + "equals", + "notEquals" + ], + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"text\"。", + "enum": [ + "text" + ], + "type": "string" + }, + "values": { + "description": "可选。用于比较的文本值列表,只需要提供一个值,例如 [\"华东\"]。", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "type", + "compare_type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "数值条件筛选。", + "properties": { + "compare_type": { + "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。", + "enum": [ + "equal", + "notEqual", + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual", + "between", + "notBetween" + ], + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"number\"。", + "enum": [ + "number" + ], + "type": "string" + }, + "values": { + "description": "可选。用于比较的数值参数列表。大多数比较运算需要 1 个值(如 [5000]),between/notBetween 需要 2 个值(如 [1000, 5000])。", + "items": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "type": "array" + } + }, + "required": [ + "type", + "compare_type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "颜色条件筛选。", + "properties": { + "compare_type": { + "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。", + "enum": [ + "backgroundColor", + "foregroundColor" + ], + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"color\"。", + "enum": [ + "color" + ], + "type": "string" + }, + "value": { + "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。", + "type": "string" + } + }, + "required": [ + "type", + "compare_type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "多值条件筛选。", + "properties": { + "compare_type": { + "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。", + "enum": [ + "equal", + "notEqual" + ], + "type": "string" + }, + "date_groups": { + "description": "可选。年月日等聚合筛选信息。", + "items": { + "additionalProperties": false, + "properties": { + "date_time_grouping": { + "enum": [ + "year", + "month", + "day", + "hour", + "minute", + "second" + ], + "type": "string" + }, + "day": { + "type": "number" + }, + "hour": { + "type": "number" + }, + "minute": { + "type": "number" + }, + "month": { + "type": "number" + }, + "second": { + "type": "number" + }, + "year": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + }, + "type": { + "description": "条件类型固定为 \"multiValue\"。", + "enum": [ + "multiValue" + ], + "type": "string" + }, + "values": { + "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "type", + "compare_type" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "filtered_rows": { + "description": "可选。由该列条件直接导致被隐藏的行索引列表(0-based),用于调试或回显。", + "items": { + "type": "number" + }, + "type": "array" + } + }, + "required": [ + "column_index", + "conditions" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "range", + "rules" + ], + "type": "object" + } + }, + "+filter-view-create": { + "properties": { + "additionalProperties": false, + "description": "create / update 的视图属性。create 必须传 range;update 至少传 view_name / range / rules 之一。delete 禁止传该字段。", + "properties": { + "filtered_columns": { + "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", + "items": { + "type": "string" + }, + "type": "array" + }, + "range": { + "description": "视图作用的单元格范围(A1 表示法)。create 必填;update 可选——未提供时沿用视图当前 range。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。", + "type": "string" + }, + "rules": { + "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。结构与 manage_filter_object.properties.rules 完全一致。", + "items": { + "additionalProperties": false, + "description": "单列筛选规则。", + "properties": { + "column_index": { + "description": "作用的列索引,例如 \"A\"、\"B\"。", + "type": "string" + }, + "conditions": { + "description": "该列上的条件列表。**目前每列仅支持 1 个 condition**(传入多个会被拒绝,请用 multiValue 或单条件表达),数组形式为未来扩展多条件组合预留。", + "items": { + "oneOf": [ + { + "additionalProperties": false, + "description": "文本条件筛选。", + "properties": { + "compare_type": { + "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。", + "enum": [ + "beginsWith", + "doesNotBeginWith", + "endsWith", + "doesNotEndWith", + "contains", + "doesNotContain", + "equals", + "notEquals" + ], + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"text\"。", + "enum": [ + "text" + ], + "type": "string" + }, + "values": { + "description": "可选。用于比较的文本值列表,只需要提供一个值,例如 [\"华东\"]。", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "type", + "compare_type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "数值条件筛选。", + "properties": { + "compare_type": { + "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。", + "enum": [ + "equal", + "notEqual", + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual", + "between", + "notBetween" + ], + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"number\"。", + "enum": [ + "number" + ], + "type": "string" + }, + "values": { + "description": "可选。用于比较的数值参数列表。大多数比较运算需要 1 个值(如 [5000]),between/notBetween 需要 2 个值(如 [1000, 5000])。", + "items": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "type": "array" + } + }, + "required": [ + "type", + "compare_type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "颜色条件筛选。", + "properties": { + "compare_type": { + "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。", + "enum": [ + "backgroundColor", + "foregroundColor" + ], + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"color\"。", + "enum": [ + "color" + ], + "type": "string" + }, + "value": { + "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。", + "type": "string" + } + }, + "required": [ + "type", + "compare_type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "多值条件筛选。", + "properties": { + "compare_type": { + "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。", + "enum": [ + "equal", + "notEqual" + ], + "type": "string" + }, + "date_groups": { + "description": "可选。年月日等聚合筛选信息。", + "items": { + "additionalProperties": false, + "properties": { + "date_time_grouping": { + "enum": [ + "year", + "month", + "day", + "hour", + "minute", + "second" + ], + "type": "string" + }, + "day": { + "type": "number" + }, + "hour": { + "type": "number" + }, + "minute": { + "type": "number" + }, + "month": { + "type": "number" + }, + "second": { + "type": "number" + }, + "year": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + }, + "type": { + "description": "条件类型固定为 \"multiValue\"。", + "enum": [ + "multiValue" + ], + "type": "string" + }, + "values": { + "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "type", + "compare_type" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "filtered_rows": { + "description": "可选。由该列条件直接导致被隐藏的行索引列表(0-based),用于调试或回显。", + "items": { + "type": "number" + }, + "type": "array" + } + }, + "required": [ + "column_index", + "conditions" + ], + "type": "object" + }, + "type": "array" + }, + "view_name": { + "description": "可选。视图名称。create 不传时系统自动分配;create / update 传入时若与现有视图重名,会自动以后缀避让,不会抛错。", + "type": "string" + } + }, + "type": "object" + } + }, + "+filter-view-update": { + "properties": { + "additionalProperties": false, + "description": "create / update 的视图属性。create 必须传 range;update 至少传 view_name / range / rules 之一。delete 禁止传该字段。", + "properties": { + "filtered_columns": { + "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", + "items": { + "type": "string" + }, + "type": "array" + }, + "range": { + "description": "视图作用的单元格范围(A1 表示法)。create 必填;update 可选——未提供时沿用视图当前 range。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。", + "type": "string" + }, + "rules": { + "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。结构与 manage_filter_object.properties.rules 完全一致。", + "items": { + "additionalProperties": false, + "description": "单列筛选规则。", + "properties": { + "column_index": { + "description": "作用的列索引,例如 \"A\"、\"B\"。", + "type": "string" + }, + "conditions": { + "description": "该列上的条件列表。**目前每列仅支持 1 个 condition**(传入多个会被拒绝,请用 multiValue 或单条件表达),数组形式为未来扩展多条件组合预留。", + "items": { + "oneOf": [ + { + "additionalProperties": false, + "description": "文本条件筛选。", + "properties": { + "compare_type": { + "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。", + "enum": [ + "beginsWith", + "doesNotBeginWith", + "endsWith", + "doesNotEndWith", + "contains", + "doesNotContain", + "equals", + "notEquals" + ], + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"text\"。", + "enum": [ + "text" + ], + "type": "string" + }, + "values": { + "description": "可选。用于比较的文本值列表,只需要提供一个值,例如 [\"华东\"]。", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "type", + "compare_type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "数值条件筛选。", + "properties": { + "compare_type": { + "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。", + "enum": [ + "equal", + "notEqual", + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual", + "between", + "notBetween" + ], + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"number\"。", + "enum": [ + "number" + ], + "type": "string" + }, + "values": { + "description": "可选。用于比较的数值参数列表。大多数比较运算需要 1 个值(如 [5000]),between/notBetween 需要 2 个值(如 [1000, 5000])。", + "items": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "type": "array" + } + }, + "required": [ + "type", + "compare_type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "颜色条件筛选。", + "properties": { + "compare_type": { + "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。", + "enum": [ + "backgroundColor", + "foregroundColor" + ], + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"color\"。", + "enum": [ + "color" + ], + "type": "string" + }, + "value": { + "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。", + "type": "string" + } + }, + "required": [ + "type", + "compare_type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "多值条件筛选。", + "properties": { + "compare_type": { + "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。", + "enum": [ + "equal", + "notEqual" + ], + "type": "string" + }, + "date_groups": { + "description": "可选。年月日等聚合筛选信息。", + "items": { + "additionalProperties": false, + "properties": { + "date_time_grouping": { + "enum": [ + "year", + "month", + "day", + "hour", + "minute", + "second" + ], + "type": "string" + }, + "day": { + "type": "number" + }, + "hour": { + "type": "number" + }, + "minute": { + "type": "number" + }, + "month": { + "type": "number" + }, + "second": { + "type": "number" + }, + "year": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + }, + "type": { + "description": "条件类型固定为 \"multiValue\"。", + "enum": [ + "multiValue" + ], + "type": "string" + }, + "values": { + "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "type", + "compare_type" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "filtered_rows": { + "description": "可选。由该列条件直接导致被隐藏的行索引列表(0-based),用于调试或回显。", + "items": { + "type": "number" + }, + "type": "array" + } + }, + "required": [ + "column_index", + "conditions" + ], + "type": "object" + }, + "type": "array" + }, + "view_name": { + "description": "可选。视图名称。create 不传时系统自动分配;create / update 传入时若与现有视图重名,会自动以后缀避让,不会抛错。", + "type": "string" + } + }, + "type": "object" + } + }, + "+pivot-create": { + "properties": { + "additionalProperties": {}, + "description": "创建/更新的透视表属性。", + "properties": { + "auto_fit_col": { + "description": "是否自动调整列宽以适应内容", + "type": "boolean" + }, + "calculated_fields": { + "description": "计算字段列表", + "items": { + "properties": { + "formula": { + "description": "公式表达式,引用其他字段名,例如 \"'Sales' + 'Tax'\"", + "type": "string" + }, + "name": { + "description": "计算字段的显示名称", + "type": "string" + }, + "summarize_by": { + "default": "sum", + "description": "计算字段汇总方式(默认 'sum',仅支持 sum/custom 两种):'sum' 表示先对 formula 中引用的各源字段分别求和后再代入公式(formula 是逐字段求和后的标量表达式,例如 \"'Sales'+'Tax'\" 等价于 SUM(Sales)+SUM(Tax));'custom' 表示数组直接传给 formula,要求 formula 自带聚合函数(例如 \"SUM('Sales')+SUM('Tax')\" 或 \"AVERAGE('Sales')\")。需要 count/average/max/min 等其他聚合方式时,请改用 values[] 字段对源字段直接聚合,calculated_fields 不适用。", + "enum": [ + "sum", + "custom" + ], + "type": "string" + } + }, + "required": [ + "name", + "formula" + ], + "type": "object" + }, + "type": "array" + }, + "collapse": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "description": "行字段展开/折叠状态:字段名 -> 要折叠的项目列表", + "type": "object" + }, + "columns": { + "description": "横向分组字段(列字段)", + "items": { + "properties": { + "condition_filter": { + "description": "条件筛选:按文本/数值/日期条件筛选", + "properties": { + "operator": { + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", + "type": "string" + }, + "type": { + "description": "条件类型", + "enum": [ + "text", + "number", + "date" + ], + "type": "string" + }, + "value": { + "description": "比较值" + }, + "value2": { + "description": "'between'/'notBetween' 的第二个边界值" + } + }, + "required": [ + "type", + "operator" + ], + "type": "object" + }, + "display_name": { + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。", + "type": "string" + }, + "field": { + "description": "源数据中的字段名(OOXML 字段引用)", + "type": "string" + }, + "filter": { + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "description": "要显示的项目列表(其余项目被隐藏)", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "group": { + "description": "分组配置", + "properties": { + "date_group_by": { + "description": "日期分组粒度(type='date' 时必填)", + "enum": [ + "year", + "yearMonth", + "yearQuarter", + "yearMonthDate", + "quarter", + "month", + "monthDate", + "date", + "hour", + "hourMinute", + "minute" + ], + "type": "string" + }, + "end": { + "description": "数值分组结束值", + "type": "number" + }, + "groups": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", + "type": "object" + }, + "interval": { + "description": "数值分组间隔(type='number' 时必填)", + "type": "number" + }, + "start": { + "description": "数值分组起始值", + "type": "number" + }, + "type": { + "description": "分组类型", + "enum": [ + "date", + "number", + "element" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "sort": { + "description": "排序配置", + "properties": { + "by": { + "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序", + "enum": [ + "label", + "value" + ], + "type": "string" + }, + "order": { + "description": "排序方向", + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + "value_field": { + "description": "by='value' 时必填,指定按哪个值字段排序", + "type": "string" + } + }, + "required": [ + "order" + ], + "type": "object" + } + }, + "required": [ + "field" + ], + "type": "object" + }, + "type": "array" + }, + "filters": { + "description": "筛选区域字段(页字段)", + "items": { + "properties": { + "condition_filter": { + "description": "条件筛选:按文本/数值/日期条件筛选", + "properties": { + "operator": { + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", + "type": "string" + }, + "type": { + "description": "条件类型", + "enum": [ + "text", + "number", + "date" + ], + "type": "string" + }, + "value": { + "description": "比较值" + }, + "value2": { + "description": "'between'/'notBetween' 的第二个边界值" + } + }, + "required": [ + "type", + "operator" + ], + "type": "object" + }, + "display_name": { + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。", + "type": "string" + }, + "field": { + "description": "源数据中的字段名(OOXML 字段引用)", + "type": "string" + }, + "filter": { + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "description": "要显示的项目列表(其余项目被隐藏)", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "group": { + "description": "分组配置", + "properties": { + "date_group_by": { + "description": "日期分组粒度(type='date' 时必填)", + "enum": [ + "year", + "yearMonth", + "yearQuarter", + "yearMonthDate", + "quarter", + "month", + "monthDate", + "date", + "hour", + "hourMinute", + "minute" + ], + "type": "string" + }, + "end": { + "description": "数值分组结束值", + "type": "number" + }, + "groups": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", + "type": "object" + }, + "interval": { + "description": "数值分组间隔(type='number' 时必填)", + "type": "number" + }, + "start": { + "description": "数值分组起始值", + "type": "number" + }, + "type": { + "description": "分组类型", + "enum": [ + "date", + "number", + "element" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + }, + "required": [ + "field" + ], + "type": "object" + }, + "type": "array" + }, + "range": { + "description": "放置透视表的左上角单元格 A1 地址(例如:'F1')(仅 create 时有效)。可省略——省略后透视表放在新建子表的 A1 位置。", + "type": "string" + }, + "repeat_row_labels": { + "description": "是否显示重复项标签", + "type": "boolean" + }, + "rows": { + "description": "纵向分组字段(行字段)", + "items": { + "properties": { + "condition_filter": { + "description": "条件筛选:按文本/数值/日期条件筛选", + "properties": { + "operator": { + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", + "type": "string" + }, + "type": { + "description": "条件类型", + "enum": [ + "text", + "number", + "date" + ], + "type": "string" + }, + "value": { + "description": "比较值" + }, + "value2": { + "description": "'between'/'notBetween' 的第二个边界值" + } + }, + "required": [ + "type", + "operator" + ], + "type": "object" + }, + "display_name": { + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用;否则保持源字段原名,透视表产物已足够清晰。", + "type": "string" + }, + "field": { + "description": "源数据中的字段名(OOXML 字段引用)", + "type": "string" + }, + "filter": { + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "description": "要显示的项目列表(其余项目被隐藏)", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "group": { + "description": "分组配置", + "properties": { + "date_group_by": { + "description": "日期分组粒度(type='date' 时必填)", + "enum": [ + "year", + "yearMonth", + "yearQuarter", + "yearMonthDate", + "quarter", + "month", + "monthDate", + "date", + "hour", + "hourMinute", + "minute" + ], + "type": "string" + }, + "end": { + "description": "数值分组结束值", + "type": "number" + }, + "groups": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", + "type": "object" + }, + "interval": { + "description": "数值分组间隔(type='number' 时必填)", + "type": "number" + }, + "start": { + "description": "数值分组起始值", + "type": "number" + }, + "type": { + "description": "分组类型", + "enum": [ + "date", + "number", + "element" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "sort": { + "description": "排序配置", + "properties": { + "by": { + "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序", + "enum": [ + "label", + "value" + ], + "type": "string" + }, + "order": { + "description": "排序方向", + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + "value_field": { + "description": "by='value' 时必填,指定按哪个值字段排序", + "type": "string" + } + }, + "required": [ + "order" + ], + "type": "object" + } + }, + "required": [ + "field" + ], + "type": "object" + }, + "type": "array" + }, + "show_col_grand_total": { + "description": "是否显示列总计(默认 true)", + "type": "boolean" + }, + "show_row_grand_total": { + "description": "是否显示行总计(默认 true)", + "type": "boolean" + }, + "show_subtotals": { + "description": "是否显示分类小计(默认 true,应用于所有字段)", + "type": "boolean" + }, + "source": { + "description": "源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100')。create 时必填;update 时提供则切换数据源。", + "type": "string" + }, + "values": { + "description": "要汇总的字段(至少需要 1 个)", + "items": { + "properties": { + "base_field": { + "description": "show_data_as 需要基准字段时的字段名", + "type": "string" + }, + "display_name": { + "description": "字段在透视表中的显示名(典型场景:把 count(销售额) 的值列头显示为 'GMV')。不传则使用源字段原名。仅在用户明确要求重命名时使用。", + "type": "string" + }, + "field": { + "description": "要汇总的源数据字段名", + "type": "string" + }, + "show_data_as": { + "description": "值显示方式(默认 'normal')", + "enum": [ + "normal", + "percentOfTotal", + "percentOfCol", + "percentOfRow", + "percentOfParentRow", + "percentOfParentCol", + "index" + ], + "type": "string" + }, + "summarize_by": { + "default": "sum", + "description": "汇总函数", + "enum": [ + "sum", + "count", + "average", + "max", + "min", + "product", + "countNums", + "stdDev", + "stdDevp", + "var", + "varp", + "distinct", + "median" + ], + "type": "string" + } + }, + "required": [ + "field" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + } + }, + "type": "object" + } + }, + "+pivot-update": { + "properties": { + "additionalProperties": {}, + "description": "创建/更新的透视表属性。", + "properties": { + "auto_fit_col": { + "description": "是否自动调整列宽以适应内容", + "type": "boolean" + }, + "calculated_fields": { + "description": "计算字段列表", + "items": { + "properties": { + "formula": { + "description": "公式表达式,引用其他字段名,例如 \"'Sales' + 'Tax'\"", + "type": "string" + }, + "name": { + "description": "计算字段的显示名称", + "type": "string" + }, + "summarize_by": { + "default": "sum", + "description": "计算字段汇总方式(默认 'sum',仅支持 sum/custom 两种):'sum' 表示先对 formula 中引用的各源字段分别求和后再代入公式(formula 是逐字段求和后的标量表达式,例如 \"'Sales'+'Tax'\" 等价于 SUM(Sales)+SUM(Tax));'custom' 表示数组直接传给 formula,要求 formula 自带聚合函数(例如 \"SUM('Sales')+SUM('Tax')\" 或 \"AVERAGE('Sales')\")。需要 count/average/max/min 等其他聚合方式时,请改用 values[] 字段对源字段直接聚合,calculated_fields 不适用。", + "enum": [ + "sum", + "custom" + ], + "type": "string" + } + }, + "required": [ + "name", + "formula" + ], + "type": "object" + }, + "type": "array" + }, + "collapse": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "description": "行字段展开/折叠状态:字段名 -> 要折叠的项目列表", + "type": "object" + }, + "columns": { + "description": "横向分组字段(列字段)", + "items": { + "properties": { + "condition_filter": { + "description": "条件筛选:按文本/数值/日期条件筛选", + "properties": { + "operator": { + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", + "type": "string" + }, + "type": { + "description": "条件类型", + "enum": [ + "text", + "number", + "date" + ], + "type": "string" + }, + "value": { + "description": "比较值" + }, + "value2": { + "description": "'between'/'notBetween' 的第二个边界值" + } + }, + "required": [ + "type", + "operator" + ], + "type": "object" + }, + "display_name": { + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。", + "type": "string" + }, + "field": { + "description": "源数据中的字段名(OOXML 字段引用)", + "type": "string" + }, + "filter": { + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "description": "要显示的项目列表(其余项目被隐藏)", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "group": { + "description": "分组配置", + "properties": { + "date_group_by": { + "description": "日期分组粒度(type='date' 时必填)", + "enum": [ + "year", + "yearMonth", + "yearQuarter", + "yearMonthDate", + "quarter", + "month", + "monthDate", + "date", + "hour", + "hourMinute", + "minute" + ], + "type": "string" + }, + "end": { + "description": "数值分组结束值", + "type": "number" + }, + "groups": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", + "type": "object" + }, + "interval": { + "description": "数值分组间隔(type='number' 时必填)", + "type": "number" + }, + "start": { + "description": "数值分组起始值", + "type": "number" + }, + "type": { + "description": "分组类型", + "enum": [ + "date", + "number", + "element" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "sort": { + "description": "排序配置", + "properties": { + "by": { + "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序", + "enum": [ + "label", + "value" + ], + "type": "string" + }, + "order": { + "description": "排序方向", + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + "value_field": { + "description": "by='value' 时必填,指定按哪个值字段排序", + "type": "string" + } + }, + "required": [ + "order" + ], + "type": "object" + } + }, + "required": [ + "field" + ], + "type": "object" + }, + "type": "array" + }, + "filters": { + "description": "筛选区域字段(页字段)", + "items": { + "properties": { + "condition_filter": { + "description": "条件筛选:按文本/数值/日期条件筛选", + "properties": { + "operator": { + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", + "type": "string" + }, + "type": { + "description": "条件类型", + "enum": [ + "text", + "number", + "date" + ], + "type": "string" + }, + "value": { + "description": "比较值" + }, + "value2": { + "description": "'between'/'notBetween' 的第二个边界值" + } + }, + "required": [ + "type", + "operator" + ], + "type": "object" + }, + "display_name": { + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。", + "type": "string" + }, + "field": { + "description": "源数据中的字段名(OOXML 字段引用)", + "type": "string" + }, + "filter": { + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "description": "要显示的项目列表(其余项目被隐藏)", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "group": { + "description": "分组配置", + "properties": { + "date_group_by": { + "description": "日期分组粒度(type='date' 时必填)", + "enum": [ + "year", + "yearMonth", + "yearQuarter", + "yearMonthDate", + "quarter", + "month", + "monthDate", + "date", + "hour", + "hourMinute", + "minute" + ], + "type": "string" + }, + "end": { + "description": "数值分组结束值", + "type": "number" + }, + "groups": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", + "type": "object" + }, + "interval": { + "description": "数值分组间隔(type='number' 时必填)", + "type": "number" + }, + "start": { + "description": "数值分组起始值", + "type": "number" + }, + "type": { + "description": "分组类型", + "enum": [ + "date", + "number", + "element" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + }, + "required": [ + "field" + ], + "type": "object" + }, + "type": "array" + }, + "range": { + "description": "放置透视表的左上角单元格 A1 地址(例如:'F1')(仅 create 时有效)。可省略——省略后透视表放在新建子表的 A1 位置。", + "type": "string" + }, + "repeat_row_labels": { + "description": "是否显示重复项标签", + "type": "boolean" + }, + "rows": { + "description": "纵向分组字段(行字段)", + "items": { + "properties": { + "condition_filter": { + "description": "条件筛选:按文本/数值/日期条件筛选", + "properties": { + "operator": { + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", + "type": "string" + }, + "type": { + "description": "条件类型", + "enum": [ + "text", + "number", + "date" + ], + "type": "string" + }, + "value": { + "description": "比较值" + }, + "value2": { + "description": "'between'/'notBetween' 的第二个边界值" + } + }, + "required": [ + "type", + "operator" + ], + "type": "object" + }, + "display_name": { + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用;否则保持源字段原名,透视表产物已足够清晰。", + "type": "string" + }, + "field": { + "description": "源数据中的字段名(OOXML 字段引用)", + "type": "string" + }, + "filter": { + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "description": "要显示的项目列表(其余项目被隐藏)", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "group": { + "description": "分组配置", + "properties": { + "date_group_by": { + "description": "日期分组粒度(type='date' 时必填)", + "enum": [ + "year", + "yearMonth", + "yearQuarter", + "yearMonthDate", + "quarter", + "month", + "monthDate", + "date", + "hour", + "hourMinute", + "minute" + ], + "type": "string" + }, + "end": { + "description": "数值分组结束值", + "type": "number" + }, + "groups": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", + "type": "object" + }, + "interval": { + "description": "数值分组间隔(type='number' 时必填)", + "type": "number" + }, + "start": { + "description": "数值分组起始值", + "type": "number" + }, + "type": { + "description": "分组类型", + "enum": [ + "date", + "number", + "element" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "sort": { + "description": "排序配置", + "properties": { + "by": { + "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序", + "enum": [ + "label", + "value" + ], + "type": "string" + }, + "order": { + "description": "排序方向", + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + "value_field": { + "description": "by='value' 时必填,指定按哪个值字段排序", + "type": "string" + } + }, + "required": [ + "order" + ], + "type": "object" + } + }, + "required": [ + "field" + ], + "type": "object" + }, + "type": "array" + }, + "show_col_grand_total": { + "description": "是否显示列总计(默认 true)", + "type": "boolean" + }, + "show_row_grand_total": { + "description": "是否显示行总计(默认 true)", + "type": "boolean" + }, + "show_subtotals": { + "description": "是否显示分类小计(默认 true,应用于所有字段)", + "type": "boolean" + }, + "source": { + "description": "源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100')。create 时必填;update 时提供则切换数据源。", + "type": "string" + }, + "values": { + "description": "要汇总的字段(至少需要 1 个)", + "items": { + "properties": { + "base_field": { + "description": "show_data_as 需要基准字段时的字段名", + "type": "string" + }, + "display_name": { + "description": "字段在透视表中的显示名(典型场景:把 count(销售额) 的值列头显示为 'GMV')。不传则使用源字段原名。仅在用户明确要求重命名时使用。", + "type": "string" + }, + "field": { + "description": "要汇总的源数据字段名", + "type": "string" + }, + "show_data_as": { + "description": "值显示方式(默认 'normal')", + "enum": [ + "normal", + "percentOfTotal", + "percentOfCol", + "percentOfRow", + "percentOfParentRow", + "percentOfParentCol", + "index" + ], + "type": "string" + }, + "summarize_by": { + "default": "sum", + "description": "汇总函数", + "enum": [ + "sum", + "count", + "average", + "max", + "min", + "product", + "countNums", + "stdDev", + "stdDevp", + "var", + "varp", + "distinct", + "median" + ], + "type": "string" + } + }, + "required": [ + "field" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + } + }, + "type": "object" + } + }, + "+range-sort": { + "sort-keys": { + "description": "排序条件列表(仅 sort 操作)。支持多级排序,靠前的条件优先级更高。", + "items": { + "properties": { + "ascending": { + "description": "是否升序排序", + "type": "boolean" + }, + "column": { + "description": "排序依据的列字母(如 \"C\"、\"D\"),必须在 range 范围内", + "type": "string" + } + }, + "required": [ + "column", + "ascending" + ], + "type": "object" + }, + "type": "array" + } + }, + "+sparkline-create": { + "properties": { + "additionalProperties": false, + "description": "创建/更新/部分删除的迷你图属性。delete 时不传 sparklines 即删整组,传则删指定项。", + "properties": { + "config": { + "additionalProperties": false, + "description": "迷你图样式配置, 相同 groupId 的迷你图共享相同的样式。", + "properties": { + "axis": { + "additionalProperties": false, + "description": "坐标轴配置,包含坐标轴颜色、是否翻转、是否显示坐标轴。", + "properties": { + "color": { + "description": "坐标轴颜色。", + "type": "string" + }, + "reverse": { + "description": "是否翻转坐标轴方向。", + "type": "boolean" + }, + "visible": { + "description": "是否显示坐标轴。", + "type": "boolean" + } + }, + "type": "object" + }, + "contain_hidden_cells": { + "description": "隐藏的单元格数据是否参与绘制。", + "type": "boolean" + }, + "empty_show_as": { + "description": "空单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。", + "enum": [ + "zero", + "gap", + "average" + ], + "type": "string" + }, + "extremum_max": { + "additionalProperties": false, + "description": "最大极值配置,包含极值类型、极值。", + "properties": { + "type": { + "description": "极值类型,可选值:individual=单个迷你图极值,group=同组内极值,custom=自定义。", + "enum": [ + "individual", + "group", + "custom" + ], + "type": "string" + }, + "value": { + "description": "当 type='custom' 时生效的具体数值。", + "type": "number" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "extremum_min": { + "additionalProperties": false, + "description": "最小极值配置,包含极值类型、极值。", + "properties": { + "type": { + "description": "极值类型:individual=单个迷你图极值,group=同组内极值,custom=自定义。", + "enum": [ + "individual", + "group", + "custom" + ], + "type": "string" + }, + "value": { + "description": "当 type='custom' 时生效的具体数值。", + "type": "number" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "line_width": { + "description": "折线图线宽,可选值:1=1px,2=2px,3=3px,4=4px。", + "enum": [ + 1, + 2, + 3, + 4 + ], + "type": "number" + }, + "non_num_show_as": { + "description": "非数字单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。", + "enum": [ + "zero", + "gap", + "average" + ], + "type": "string" + }, + "points": { + "additionalProperties": false, + "description": "特殊点样式配置,包含高点、低点、标记点、首点、尾点、负点。", + "properties": { + "first_point": { + "additionalProperties": false, + "description": "首点配置,第一个数据点的样式。", + "properties": { + "color": { + "description": "点的颜色。", + "type": "string" + }, + "visible": { + "description": "是否显示该点。", + "type": "boolean" + } + }, + "type": "object" + }, + "high_point": { + "additionalProperties": false, + "description": "高点配置,最高点的样式。", + "properties": { + "color": { + "description": "点的颜色。", + "type": "string" + }, + "visible": { + "description": "是否显示该点。", + "type": "boolean" + } + }, + "type": "object" + }, + "last_point": { + "additionalProperties": false, + "description": "尾点配置,最后一个数据点的样式。", + "properties": { + "color": { + "description": "点的颜色。", + "type": "string" + }, + "visible": { + "description": "是否显示该点。", + "type": "boolean" + } + }, + "type": "object" + }, + "low_point": { + "additionalProperties": false, + "description": "低点配置,最低点的样式。", + "properties": { + "color": { + "description": "点的颜色。", + "type": "string" + }, + "visible": { + "description": "是否显示该点。", + "type": "boolean" + } + }, + "type": "object" + }, + "markers_point": { + "additionalProperties": false, + "description": "标记点配置,所有标记点的样式。", + "properties": { + "color": { + "description": "点的颜色。", + "type": "string" + }, + "visible": { + "description": "是否显示该点。", + "type": "boolean" + } + }, + "type": "object" + }, + "negative_point": { + "additionalProperties": false, + "description": "负点配置,负数点的样式。", + "properties": { + "color": { + "description": "点的颜色。", + "type": "string" + }, + "visible": { + "description": "是否显示该点。", + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "series_color": { + "description": "主系列颜色,例如 \"#4472C4\"。", + "type": "string" + }, + "show_gradient": { + "description": "是否显示渐变效果。", + "type": "boolean" + }, + "show_radius": { + "description": "是否显示圆角,仅对柱形图和盈亏图生效。", + "type": "boolean" + }, + "theme_type": { + "description": "主题类型:pro、light、soft、brand、fresh。", + "enum": [ + "pro", + "light", + "soft", + "brand", + "fresh" + ], + "type": "string" + }, + "type": { + "description": "迷你图类型,可选值:line=折线图,column=柱形图,win_loss=盈亏图。", + "enum": [ + "line", + "column", + "win_loss" + ], + "type": "string" + } + }, + "type": "object" + }, + "sparklines": { + "description": "迷你图项列表。create 时为待创建项(每项需 position + source/source_range);update 时为待变更/新增项(每项需 sparkline_id;upsert=true 新增时需 position + source);delete 时为待删除项(每项需 sparkline_id)。", + "items": { + "additionalProperties": false, + "description": "单个迷你图项。", + "properties": { + "position": { + "additionalProperties": false, + "description": "迷你图位置。create / update 时必填;delete 时省略。", + "properties": { + "col": { + "description": "列索引,例如 \"A\"、\"B\"", + "type": "string" + }, + "row": { + "description": "行索引(0-based)", + "minimum": 0, + "type": "number" + } + }, + "required": [ + "row", + "col" + ], + "type": "object" + }, + "source": { + "description": "A1 范围字符串,表示数据来源,例如 \"Sheet1!A2:A10\"。create 必填(与 source_range 二选一);update 仅在改源时传;delete 时省略。", + "type": "string" + }, + "source_range": { + "additionalProperties": false, + "description": "结构化数据源范围(与 source 等价)。", + "properties": { + "range": { + "description": "数据源的 A1 引用区域", + "type": "string" + } + }, + "required": [ + "range" + ], + "type": "object" + }, + "sparkline_id": { + "description": "迷你图 reference_id。create 时可选(不传系统生成);update / delete 时必填。", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + }, + "+sparkline-update": { + "properties": { + "additionalProperties": false, + "description": "创建/更新/部分删除的迷你图属性。delete 时不传 sparklines 即删整组,传则删指定项。", + "properties": { + "config": { + "additionalProperties": false, + "description": "迷你图样式配置, 相同 groupId 的迷你图共享相同的样式。", + "properties": { + "axis": { + "additionalProperties": false, + "description": "坐标轴配置,包含坐标轴颜色、是否翻转、是否显示坐标轴。", + "properties": { + "color": { + "description": "坐标轴颜色。", + "type": "string" + }, + "reverse": { + "description": "是否翻转坐标轴方向。", + "type": "boolean" + }, + "visible": { + "description": "是否显示坐标轴。", + "type": "boolean" + } + }, + "type": "object" + }, + "contain_hidden_cells": { + "description": "隐藏的单元格数据是否参与绘制。", + "type": "boolean" + }, + "empty_show_as": { + "description": "空单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。", + "enum": [ + "zero", + "gap", + "average" + ], + "type": "string" + }, + "extremum_max": { + "additionalProperties": false, + "description": "最大极值配置,包含极值类型、极值。", + "properties": { + "type": { + "description": "极值类型,可选值:individual=单个迷你图极值,group=同组内极值,custom=自定义。", + "enum": [ + "individual", + "group", + "custom" + ], + "type": "string" + }, + "value": { + "description": "当 type='custom' 时生效的具体数值。", + "type": "number" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "extremum_min": { + "additionalProperties": false, + "description": "最小极值配置,包含极值类型、极值。", + "properties": { + "type": { + "description": "极值类型:individual=单个迷你图极值,group=同组内极值,custom=自定义。", + "enum": [ + "individual", + "group", + "custom" + ], + "type": "string" + }, + "value": { + "description": "当 type='custom' 时生效的具体数值。", + "type": "number" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "line_width": { + "description": "折线图线宽,可选值:1=1px,2=2px,3=3px,4=4px。", + "enum": [ + 1, + 2, + 3, + 4 + ], + "type": "number" + }, + "non_num_show_as": { + "description": "非数字单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。", + "enum": [ + "zero", + "gap", + "average" + ], + "type": "string" + }, + "points": { + "additionalProperties": false, + "description": "特殊点样式配置,包含高点、低点、标记点、首点、尾点、负点。", + "properties": { + "first_point": { + "additionalProperties": false, + "description": "首点配置,第一个数据点的样式。", + "properties": { + "color": { + "description": "点的颜色。", + "type": "string" + }, + "visible": { + "description": "是否显示该点。", + "type": "boolean" + } + }, + "type": "object" + }, + "high_point": { + "additionalProperties": false, + "description": "高点配置,最高点的样式。", + "properties": { + "color": { + "description": "点的颜色。", + "type": "string" + }, + "visible": { + "description": "是否显示该点。", + "type": "boolean" + } + }, + "type": "object" + }, + "last_point": { + "additionalProperties": false, + "description": "尾点配置,最后一个数据点的样式。", + "properties": { + "color": { + "description": "点的颜色。", + "type": "string" + }, + "visible": { + "description": "是否显示该点。", + "type": "boolean" + } + }, + "type": "object" + }, + "low_point": { + "additionalProperties": false, + "description": "低点配置,最低点的样式。", + "properties": { + "color": { + "description": "点的颜色。", + "type": "string" + }, + "visible": { + "description": "是否显示该点。", + "type": "boolean" + } + }, + "type": "object" + }, + "markers_point": { + "additionalProperties": false, + "description": "标记点配置,所有标记点的样式。", + "properties": { + "color": { + "description": "点的颜色。", + "type": "string" + }, + "visible": { + "description": "是否显示该点。", + "type": "boolean" + } + }, + "type": "object" + }, + "negative_point": { + "additionalProperties": false, + "description": "负点配置,负数点的样式。", + "properties": { + "color": { + "description": "点的颜色。", + "type": "string" + }, + "visible": { + "description": "是否显示该点。", + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "series_color": { + "description": "主系列颜色,例如 \"#4472C4\"。", + "type": "string" + }, + "show_gradient": { + "description": "是否显示渐变效果。", + "type": "boolean" + }, + "show_radius": { + "description": "是否显示圆角,仅对柱形图和盈亏图生效。", + "type": "boolean" + }, + "theme_type": { + "description": "主题类型:pro、light、soft、brand、fresh。", + "enum": [ + "pro", + "light", + "soft", + "brand", + "fresh" + ], + "type": "string" + }, + "type": { + "description": "迷你图类型,可选值:line=折线图,column=柱形图,win_loss=盈亏图。", + "enum": [ + "line", + "column", + "win_loss" + ], + "type": "string" + } + }, + "type": "object" + }, + "sparklines": { + "description": "迷你图项列表。create 时为待创建项(每项需 position + source/source_range);update 时为待变更/新增项(每项需 sparkline_id;upsert=true 新增时需 position + source);delete 时为待删除项(每项需 sparkline_id)。", + "items": { + "additionalProperties": false, + "description": "单个迷你图项。", + "properties": { + "position": { + "additionalProperties": false, + "description": "迷你图位置。create / update 时必填;delete 时省略。", + "properties": { + "col": { + "description": "列索引,例如 \"A\"、\"B\"", + "type": "string" + }, + "row": { + "description": "行索引(0-based)", + "minimum": 0, + "type": "number" + } + }, + "required": [ + "row", + "col" + ], + "type": "object" + }, + "source": { + "description": "A1 范围字符串,表示数据来源,例如 \"Sheet1!A2:A10\"。create 必填(与 source_range 二选一);update 仅在改源时传;delete 时省略。", + "type": "string" + }, + "source_range": { + "additionalProperties": false, + "description": "结构化数据源范围(与 source 等价)。", + "properties": { + "range": { + "description": "数据源的 A1 引用区域", + "type": "string" + } + }, + "required": [ + "range" + ], + "type": "object" + }, + "sparkline_id": { + "description": "迷你图 reference_id。create 时可选(不传系统生成);update / delete 时必填。", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + } + } +} diff --git a/shortcuts/sheets/flag_schema.go b/shortcuts/sheets/flag_schema.go new file mode 100644 index 000000000..b61abd8f7 --- /dev/null +++ b/shortcuts/sheets/flag_schema.go @@ -0,0 +1,120 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + _ "embed" + "encoding/json" + "fmt" + "sort" +) + +// ─── --print-schema runtime introspection ───────────────────────────── +// +// Composite JSON flags (--cells, --properties, --operations, --border-styles, +// --sort-keys) carry non-trivial structured payloads. Reference docs cover +// the top-level fields but agents often need the full JSON Schema to +// generate valid input. +// +// To serve that need without forcing every caller to fetch external docs, +// the spec repo ships a compact `flag-schemas.json` that extracts just the +// schema subtree corresponding to each (shortcut, flag) pair. We embed +// that artifact at compile time so `lark-cli sheets +// --print-schema --flag-name ` runs entirely locally. +// +// The artifact is generated by sheet-skill-spec's +// scripts/sync_to_consumers.mjs from canonical-spec/cli-flag-schema-map.json +// + tool-schemas/mcp-tools.json. Do not hand-edit data/flag-schemas.json; +// regenerate via the sync script. + +//go:embed data/flag-schemas.json +var flagSchemasJSON []byte + +// flagSchemaIndex parses lazily on first access; failures are surfaced +// as errors from the lookup helper rather than panicking at init time. +type flagSchemaIndex struct { + SchemaVersion string `json:"schema_version"` + Flags map[string]map[string]json.RawMessage `json:"flags"` +} + +var ( + parsedFlagSchemas *flagSchemaIndex + parseFlagErr error +) + +func loadFlagSchemas() (*flagSchemaIndex, error) { + if parsedFlagSchemas != nil || parseFlagErr != nil { + return parsedFlagSchemas, parseFlagErr + } + var idx flagSchemaIndex + if err := json.Unmarshal(flagSchemasJSON, &idx); err != nil { + parseFlagErr = fmt.Errorf("flag-schemas.json: %w", err) + return nil, parseFlagErr + } + if idx.Flags == nil { + idx.Flags = map[string]map[string]json.RawMessage{} + } + parsedFlagSchemas = &idx + return parsedFlagSchemas, nil +} + +// commandsWithFlagSchema returns the set of shortcut commands that have +// at least one introspectable flag. Used by Shortcuts() to decide which +// shortcuts to wire PrintFlagSchema into. +func commandsWithFlagSchema() map[string]struct{} { + idx, err := loadFlagSchemas() + if err != nil || idx == nil { + return nil + } + out := make(map[string]struct{}, len(idx.Flags)) + for cmd := range idx.Flags { + out[cmd] = struct{}{} + } + return out +} + +// printFlagSchemaFor returns a PrintFlagSchema closure bound to the given +// shortcut command. When flagName == "" the closure returns a JSON +// listing of introspectable flags; otherwise it returns the schema +// subtree JSON for the named flag, or an error if the flag is not +// registered. +func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) { + return func(flagName string) ([]byte, error) { + idx, err := loadFlagSchemas() + if err != nil { + return nil, err + } + entry, ok := idx.Flags[command] + if !ok || len(entry) == 0 { + return nil, fmt.Errorf("no JSON Schema registered for %s", command) + } + if flagName == "" { + flags := make([]string, 0, len(entry)) + for f := range entry { + flags = append(flags, f) + } + sort.Strings(flags) + return json.MarshalIndent(map[string]interface{}{ + "shortcut": command, + "introspectable_flags": flags, + "hint": "run again with --flag-name to dump the JSON Schema for that flag", + }, "", " ") + } + schema, ok := entry[flagName] + if !ok { + flags := make([]string, 0, len(entry)) + for f := range entry { + flags = append(flags, f) + } + sort.Strings(flags) + return nil, fmt.Errorf("no JSON Schema registered for %s --%s; available: %v", command, flagName, flags) + } + // Reformat for readability — schema files store compact JSON. + var pretty interface{} + if err := json.Unmarshal(schema, &pretty); err != nil { + return schema, nil + } + return json.MarshalIndent(pretty, "", " ") + } +} diff --git a/shortcuts/sheets/flag_schema_test.go b/shortcuts/sheets/flag_schema_test.go new file mode 100644 index 000000000..6a541ea96 --- /dev/null +++ b/shortcuts/sheets/flag_schema_test.go @@ -0,0 +1,180 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestFlagSchemas_EmbedParses asserts the synced flag-schemas.json +// embedded blob is valid JSON and has at least one shortcut/flag entry. +// If sync_to_consumers.mjs ever ships an empty or broken artifact, this +// catches it at build time of the test binary. +func TestFlagSchemas_EmbedParses(t *testing.T) { + t.Parallel() + idx, err := loadFlagSchemas() + if err != nil { + t.Fatalf("loadFlagSchemas error: %v", err) + } + if idx == nil || len(idx.Flags) == 0 { + t.Fatalf("flag-schemas.json has no entries") + } + if idx.SchemaVersion == "" { + t.Errorf("schema_version missing") + } + // Spot-check a couple of canonical entries we know upstream guarantees. + for _, want := range []string{"+cells-set", "+chart-create", "+batch-update"} { + if _, ok := idx.Flags[want]; !ok { + t.Errorf("missing shortcut entry %q (regenerate via sheet-skill-spec/scripts/sync_to_consumers.mjs)", want) + } + } +} + +// TestPrintFlagSchema_ListIntrospectable verifies that calling the +// closure with an empty flag name returns the JSON listing of +// introspectable flags for the shortcut. +func TestPrintFlagSchema_ListIntrospectable(t *testing.T) { + t.Parallel() + out, err := printFlagSchemaFor("+cells-set")("") + if err != nil { + t.Fatalf("err: %v", err) + } + var got map[string]interface{} + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("output not JSON: %v\n%s", err, out) + } + if got["shortcut"] != "+cells-set" { + t.Errorf("shortcut = %v, want +cells-set", got["shortcut"]) + } + flags, _ := got["introspectable_flags"].([]interface{}) + if len(flags) == 0 || flags[0] != "cells" { + t.Errorf("introspectable_flags = %v, want [cells]", flags) + } +} + +// TestPrintFlagSchema_NamedFlagReturnsSchemaSubtree verifies a hit on +// (+chart-create, properties) yields a JSON Schema object with the +// expected top-level fields. +func TestPrintFlagSchema_NamedFlagReturnsSchemaSubtree(t *testing.T) { + t.Parallel() + out, err := printFlagSchemaFor("+chart-create")("properties") + if err != nil { + t.Fatalf("err: %v", err) + } + var schema map[string]interface{} + if err := json.Unmarshal(out, &schema); err != nil { + t.Fatalf("output not JSON: %v\n%s", err, out) + } + if schema["type"] != "object" { + t.Errorf("schema.type = %v, want object", schema["type"]) + } + if _, ok := schema["properties"]; !ok { + t.Errorf("schema missing nested .properties: keys=%v", keysOf(schema)) + } +} + +// TestPrintFlagSchema_UnknownFlagListsAvailable confirms the error +// message tells the caller which flags exist for the shortcut. +func TestPrintFlagSchema_UnknownFlagListsAvailable(t *testing.T) { + t.Parallel() + _, err := printFlagSchemaFor("+chart-create")("does-not-exist") + if err == nil { + t.Fatal("expected error for unknown flag, got nil") + } + msg := err.Error() + if !strings.Contains(msg, "+chart-create") || !strings.Contains(msg, "properties") { + t.Errorf("error should mention shortcut + available flags; got %q", msg) + } +} + +// TestPrintFlagSchema_UnknownShortcut surfaces a missing shortcut entry. +func TestPrintFlagSchema_UnknownShortcut(t *testing.T) { + t.Parallel() + _, err := printFlagSchemaFor("+not-a-real-shortcut")("") + if err == nil { + t.Fatal("expected error for unknown shortcut") + } +} + +// TestShortcuts_AttachesPrintFlagSchema confirms the registration loop +// in Shortcuts() wires PrintFlagSchema onto each shortcut whose command +// has a schema entry, and leaves it nil for shortcuts that don't. +func TestShortcuts_AttachesPrintFlagSchema(t *testing.T) { + t.Parallel() + all := Shortcuts() + withSchema := commandsWithFlagSchema() + for _, s := range all { + _, expected := withSchema[s.Command] + got := s.PrintFlagSchema != nil + if got != expected { + t.Errorf("%s: PrintFlagSchema attached=%v, expected=%v", s.Command, got, expected) + } + } +} + +// TestPrintSchema_SystemFlagShortCircuit verifies the framework's +// --print-schema interception: required flags are relaxed, Validate / +// Execute are skipped, and the schema JSON appears on stdout. +func TestPrintSchema_SystemFlagShortCircuit(t *testing.T) { + t.Parallel() + // +cells-set has required --range / --cells / --sheet-id; without + // --print-schema, cobra would reject the call. With --print-schema, + // it should print the schema and exit cleanly. The PrintFlagSchema + // closure is normally attached by Shortcuts(), so we attach it here + // to mirror that registration path. + sc := CellsSet + sc.PrintFlagSchema = printFlagSchemaFor(sc.Command) + stdout, err := runShortcut(t, sc, []string{"--print-schema", "--flag-name", "cells"}) + if err != nil { + t.Fatalf("err: %v\nstdout=%s", err, stdout) + } + if !strings.Contains(stdout, "\"type\"") { + t.Errorf("expected JSON Schema with \"type\" key; got=%s", stdout) + } +} + +// TestPrintSchema_ListingWhenNoFlagNameGiven exercises the discovery +// path: `--print-schema` without `--flag-name` should list the +// shortcut's introspectable flags as JSON on stdout. +func TestPrintSchema_ListingWhenNoFlagNameGiven(t *testing.T) { + t.Parallel() + sc := CellsSet + sc.PrintFlagSchema = printFlagSchemaFor(sc.Command) + stdout, err := runShortcut(t, sc, []string{"--print-schema"}) + if err != nil { + t.Fatalf("err: %v\nstdout=%s", err, stdout) + } + var got map[string]interface{} + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("stdout not JSON: %v\n%s", err, stdout) + } + flags, _ := got["introspectable_flags"].([]interface{}) + if len(flags) == 0 { + t.Errorf("introspectable_flags empty: %#v", got) + } +} + +// TestPrintSchema_SystemFlagAbsentForReadOnlyShortcut ensures we don't +// inject --print-schema onto shortcuts that have no composite flags. +// +workbook-info is read-only and not in the schema map. +func TestPrintSchema_SystemFlagAbsentForReadOnlyShortcut(t *testing.T) { + t.Parallel() + _, _, err := runShortcutCapturingErr(t, WorkbookInfo, []string{"--url", testURL, "--print-schema"}) + if err == nil { + t.Fatal("expected unknown flag error") + } + if !strings.Contains(err.Error(), "unknown flag") { + t.Errorf("expected 'unknown flag'; got %v", err) + } +} + +func keysOf(m map[string]interface{}) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index 7dc00836c..c71ef5572 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -8,7 +8,22 @@ import "github.com/larksuite/cli/shortcuts/common" // Shortcuts returns all lark-sheets shortcuts. The list is grouped by // canonical skill to mirror the sheet-skill-spec layout // (lark_sheet_workbook → lark_sheet_float_image). +// +// Any shortcut whose command is registered in data/flag-schemas.json gets a +// PrintFlagSchema closure attached, so the framework can serve +// `--print-schema --flag-name ` locally. func Shortcuts() []common.Shortcut { + all := shortcutList() + withSchema := commandsWithFlagSchema() + for i := range all { + if _, ok := withSchema[all[i].Command]; ok { + all[i].PrintFlagSchema = printFlagSchemaFor(all[i].Command) + } + } + return all +} + +func shortcutList() []common.Shortcut { return []common.Shortcut{ // lark_sheet_workbook WorkbookInfo, diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index bcddab005..b4153bde0 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -38,3 +38,34 @@ 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_sheet_write_cells Skill。仅针对飞书表格。 | + +## 公共 flag 速查 + +各 reference 的每个 shortcut 标题下用一行徽章标注该 shortcut 支持的公共 / 系统 flag,例如: + +- `_公共四件套 · 系统:--dry-run_` — URL/token + sheet 定位全 4 个公共 flag,加 `--dry-run` +- `_公共:URL/token(无 sheet 定位) · 系统:--yes、--dry-run_` — 只接 URL/token,常见于 `+batch-update` 等不强制 sheet 定位的 shortcut + +徽章里只列名字。type / 必填 / 描述都在本段统一声明: + +### 公共 flag(定位资源) + +**公共四件套** = `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`。前两者 XOR 互斥(spreadsheet 定位),后两者 XOR 互斥(sheet 定位)。 + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--url` | string | XOR | spreadsheet URL;与 `--spreadsheet-token` 二选一 | +| `--spreadsheet-token` | string | XOR | spreadsheet token;与 `--url` 二选一 | +| `--sheet-id` | string | XOR | 工作表 reference_id;与 `--sheet-name` 二选一 | +| `--sheet-name` | string | XOR | 工作表名称;与 `--sheet-id` 二选一 | + +### 系统 flag + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--dry-run` | bool | 否 | 零副作用:仅打印请求路径与参数模板,不发起调用;多步操作会输出每个子操作的请求模板 | +| `--yes` | bool | 是(仅 `high-risk-write`) | 二次确认;不带时退出码 10。详见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 高风险审批协议 | +| `--print-schema` | bool | 否 | 本地打印复合 JSON flag 的 JSON Schema 并退出,不发起任何调用、不需要其它 required flag。与 `--flag-name ` 搭配指定要查哪个 flag;省略 `--flag-name` 时列出该 shortcut 所有可查询的 flag。仅在 shortcut 含复合 JSON flag 时有效。 | +| `--flag-name` | string | 否 | 配合 `--print-schema` 使用,指定要打印 JSON Schema 的 flag 名(不带 `--` 前缀,如 `cells` / `properties` / `operations`)。 | + +**Agent 使用提示**:写复合 JSON flag(`--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys` 等)时,如果对结构不确定,先跑 `lark-cli sheets --print-schema --flag-name ` 把完整 JSON Schema 读出来再构造 payload,比靠 reference 的速查表更精确,也避免因为字段拼写或缺失被服务端拒绝。reference 的 `## Schemas` 段只给一层结构,深层只能靠 `--print-schema` 或 `## Examples` 的真实示例。 diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 53fee6a40..837b37880 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -33,60 +33,54 @@ ### `+batch-update` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet 定位(与子操作的 sheet 定位独立) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet 定位(与子操作的 sheet 定位独立) | -| `--operations` | 专有 | string + File + Stdin(复合 JSON) | 是 | JSON:`{"operations":[{"tool":"set_cell_range","params":{...}}, ...]}`;按数组顺序串行执行 | -| `--yes` | 系统 | bool | 是 | `high-risk-write`,必须二次确认(不带时退出码 10) | -| `--dry-run` | 系统 | bool | 否 | 输出每个子操作的请求模板,零网络副作用 | +_公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--operations` | string + File + Stdin(复合 JSON) | 是 | JSON:`{"operations":[{"tool":"set_cell_range","params":{...}}, ...]}`;按数组顺序串行执行 | ### `+cells-batch-set-style` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--ranges` | 专有 | string + File + Stdin(简单 JSON) | 是 | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["sheet1!A1:B2","sheet1!D1:D10"]`);所有 range 应用同一组 style | -| `--background-color` | 专有 | string | 否 | 背景颜色(十六进制,如 `#ffffff`) | -| `--font-color` | 专有 | string | 否 | 字体颜色(十六进制,如 `#000000`) | -| `--font-size` | 专有 | number | 否 | 字体大小(px,例:10、12、14) | -| `--font-style` | 专有 | string + Enum | 否 | 字体样式 enum:`normal` / `italic` | -| `--font-weight` | 专有 | string + Enum | 否 | 字重 enum:`normal` / `bold` | -| `--font-line` | 专有 | string + Enum | 否 | 字体线条样式 enum:`none` / `underline` / `line-through` | -| `--horizontal-alignment` | 专有 | string + Enum | 否 | 水平对齐 enum:`left` / `center` / `right` | -| `--vertical-alignment` | 专有 | string + Enum | 否 | 垂直对齐 enum:`top` / `middle` / `bottom` | -| `--word-wrap` | 专有 | string + Enum | 否 | 换行策略 enum:`overflow` / `auto-wrap` / `word-clip`(默认 `overflow`) | -| `--number-format` | 专有 | string | 否 | 数字格式(例:文本 `@`、数字 `0.00`、货币 `$#,##0.00`、日期 `mm/dd/yyyy`) | -| `--border-styles` | 专有 | string + File + Stdin(复合 JSON) | 否 | 边框配置 JSON(结构同 +cells-set-style) | -| `--dry-run` | 系统 | bool | 否 | | +_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--ranges` | string + File + Stdin(简单 JSON) | 是 | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["sheet1!A1:B2","sheet1!D1:D10"]`);所有 range 应用同一组 style | +| `--background-color` | string | 否 | 背景颜色(十六进制,如 `#ffffff`) | +| `--font-color` | string | 否 | 字体颜色(十六进制,如 `#000000`) | +| `--font-size` | number | 否 | 字体大小(px,例:10、12、14) | +| `--font-style` | string + Enum | 否 | 字体样式 enum:`normal` / `italic` | +| `--font-weight` | string + Enum | 否 | 字重 enum:`normal` / `bold` | +| `--font-line` | string + Enum | 否 | 字体线条样式 enum:`none` / `underline` / `line-through` | +| `--horizontal-alignment` | string + Enum | 否 | 水平对齐 enum:`left` / `center` / `right` | +| `--vertical-alignment` | string + Enum | 否 | 垂直对齐 enum:`top` / `middle` / `bottom` | +| `--word-wrap` | string + Enum | 否 | 换行策略 enum:`overflow` / `auto-wrap` / `word-clip`(默认 `overflow`) | +| `--number-format` | string | 否 | 数字格式(例:文本 `@`、数字 `0.00`、货币 `$#,##0.00`、日期 `mm/dd/yyyy`) | +| `--border-styles` | string + File + Stdin(复合 JSON) | 否 | 边框配置 JSON(结构同 +cells-set-style) | ### `+dropdown-update` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--ranges` | 专有 | string + File + Stdin(简单 JSON) | 是 | 目标范围 JSON 数组(如 `["sheet1!A2:A100"]`),每项必须带 sheet 前缀 | -| `--options` | 专有 | string + File + Stdin(复合 JSON) | 是 | 选项 JSON 数组 | -| `--colors` | 专有 | string + File + Stdin(简单 JSON) | 否 | 颜色数组(与 `--options` 等长) | -| `--multiple` | 专有 | bool | 否 | 启用多选 | -| `--highlight` | 专有 | bool | 否 | 选项配色 | -| `--dry-run` | 系统 | bool | 否 | | +_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--ranges` | string + File + Stdin(简单 JSON) | 是 | 目标范围 JSON 数组(如 `["sheet1!A2:A100"]`),每项必须带 sheet 前缀 | +| `--options` | string + File + Stdin(复合 JSON) | 是 | 选项 JSON 数组 | +| `--colors` | string + File + Stdin(简单 JSON) | 否 | 颜色数组(与 `--options` 等长) | +| `--multiple` | bool | 否 | 启用多选 | +| `--highlight` | bool | 否 | 选项配色 | ### `+dropdown-delete` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--ranges` | 专有 | string + File + Stdin(简单 JSON) | 是 | 目标范围 JSON 数组(最多 100 个,每项带 sheet 前缀) | -| `--yes` | 系统 | bool | 是 | `high-risk-write`,必须二次确认(不带时退出码 10) | -| `--dry-run` | 系统 | bool | 否 | | +_公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--ranges` | string + File + Stdin(简单 JSON) | 是 | 目标范围 JSON 数组(最多 100 个,每项带 sheet 前缀) | ## Schemas -> 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 +> 复合 JSON flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag-name `。先 `--print-schema`(不带 `--flag-name`)会列出该 shortcut 所有可查询的 flag。 ### `+batch-update` `--operations` @@ -138,6 +132,19 @@ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" -- # ] ``` +> **常见组合:插列 + 写表头 + 整列回填**——一次原子提交,不要拆成 N 次独立调用。批量回填同一列 **只需一次** `set_cell_range`(range 写整列范围、cells 写 N×1 矩阵),不需要逐行循环。 +> +> ```jsonc +> // 在 C 列前插入新列 → 写表头 C1 → 回填 C2:C100 共 99 行 +> [ +> {"tool_name": "modify_sheet_structure", +> "input": {"sheet_id": "...", "operation": "insert", "dimension": "column", "start": 3, "end": 4}}, +> {"tool_name": "set_cell_range", +> "input": {"sheet_id": "...", "range": "C1:C100", +> "cells": [[{"value":"score"}], [{"value":95}], [{"value":87}], /* ... 97 more rows ... */ ]}} +> ] +> ``` + ### `+cells-batch-set-style` 多 range 应用同一组 style(服务端走 `batch_update` 原子事务): diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md index f880c40ad..51692473b 100644 --- a/skills/lark-sheets/references/lark-sheets-chart.md +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -107,53 +107,40 @@ ### `+chart-list` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--chart-id` | 专有 | string | 否 | 指定单个图表 reference_id 过滤 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--chart-id` | string | 否 | 指定单个图表 reference_id 过滤 | ### `+chart-create` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 是 | 图表完整配置 JSON(`position` / `data` / `properties` 等),结构嵌套深,统一走 JSON 注入 | -| `--dry-run` | 系统 | bool | 否 | 零副作用,输出请求模板 | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--properties` | string + File + Stdin(复合 JSON) | 是 | 图表完整配置 JSON(`position` / `data` / `properties` 等),结构嵌套深,统一走 JSON 注入 | ### `+chart-update` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--chart-id` | 专有 | string | 是 | 目标图表 reference_id | -| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 是 | 完整或足够完整的图表配置 JSON(先 `+chart-list` 回读再 patch) | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--chart-id` | string | 是 | 目标图表 reference_id | +| `--properties` | string + File + Stdin(复合 JSON) | 是 | 完整或足够完整的图表配置 JSON(先 `+chart-list` 回读再 patch) | ### `+chart-delete` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--chart-id` | 专有 | string | 是 | 目标图表 reference_id | -| `--yes` | 系统 | bool | 是 | 二次确认(不带时退出码 10) | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--yes`、`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--chart-id` | string | 是 | 目标图表 reference_id | ## Schemas -> 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 +> 复合 JSON flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag-name `。先 `--print-schema`(不带 `--flag-name`)会列出该 shortcut 所有可查询的 flag。 ### `+chart-create` `--properties` / `+chart-update` `--properties` diff --git a/skills/lark-sheets/references/lark-sheets-conditional-format.md b/skills/lark-sheets/references/lark-sheets-conditional-format.md index 5115dd2e8..7db9a123b 100644 --- a/skills/lark-sheets/references/lark-sheets-conditional-format.md +++ b/skills/lark-sheets/references/lark-sheets-conditional-format.md @@ -83,57 +83,44 @@ manage_conditional_format_object create ### `+cond-format-list` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--rule-id` | 专有 | string | 否 | 按规则 id 过滤 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--rule-id` | string | 否 | 按规则 id 过滤 | ### `+cond-format-create` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 是 | +cond-format-create / --data: 规则配置 JSON,含 `style`(命中样式,必填)和 `attrs?`(规则参数列表,因 rule_type 不同结构而异)/ `has_ref?`。`rule_type` 和 `ranges` 已拎为独立 flag | -| `--rule-type` | 专有 | string + Enum | 是 | 条件格式规则类型 enum:`cellValue` / `formula` / `duplicate` / `unique` / `topBottom` / `aboveBelowAverage` / `dataBar` / `colorScale` / `iconSet` / `textContains` / `dateOccurring` / `blankCell` / `errorCell`(共 13 项);优先级高于 `--data` | -| `--ranges` | 专有 | string + File + Stdin(简单 JSON) | 是 | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--data` | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--properties` | string + File + Stdin(复合 JSON) | 是 | +cond-format-create / --data: 规则配置 JSON,含 `style`(命中样式,必填)和 `attrs?`(规则参数列表,因 rule_type 不同结构而异)/ `has_ref?`。`rule_type` 和 `ranges` 已拎为独立 flag | +| `--rule-type` | string + Enum | 是 | 条件格式规则类型 enum:`cellValue` / `formula` / `duplicate` / `unique` / `topBottom` / `aboveBelowAverage` / `dataBar` / `colorScale` / `iconSet` / `textContains` / `dateOccurring` / `blankCell` / `errorCell`(共 13 项);优先级高于 `--data` | +| `--ranges` | string + File + Stdin(简单 JSON) | 是 | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--data` | ### `+cond-format-update` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--rule-id` | 专有 | string | 是 | 目标规则 id | -| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 是 | +cond-format-update / --data: 同 +cond-format-create;update 是整组覆盖式 | -| `--rule-type` | 专有 | string + Enum | 是 | 条件格式规则类型 enum:`cellValue` / `formula` / `duplicate` / `unique` / `topBottom` / `aboveBelowAverage` / `dataBar` / `colorScale` / `iconSet` / `textContains` / `dateOccurring` / `blankCell` / `errorCell`(共 13 项);优先级高于 `--data` | -| `--ranges` | 专有 | string + File + Stdin(简单 JSON) | 是 | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--data` | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--rule-id` | string | 是 | 目标规则 id | +| `--properties` | string + File + Stdin(复合 JSON) | 是 | +cond-format-update / --data: 同 +cond-format-create;update 是整组覆盖式 | +| `--rule-type` | string + Enum | 是 | 条件格式规则类型 enum:`cellValue` / `formula` / `duplicate` / `unique` / `topBottom` / `aboveBelowAverage` / `dataBar` / `colorScale` / `iconSet` / `textContains` / `dateOccurring` / `blankCell` / `errorCell`(共 13 项);优先级高于 `--data` | +| `--ranges` | string + File + Stdin(简单 JSON) | 是 | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--data` | ### `+cond-format-delete` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--rule-id` | 专有 | string | 是 | 目标规则 id | -| `--yes` | 系统 | bool | 是 | `high-risk-write`,删除不可逆 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--yes`、`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--rule-id` | string | 是 | 目标规则 id | ## Schemas -> 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 +> 复合 JSON flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag-name `。先 `--print-schema`(不带 `--flag-name`)会列出该 shortcut 所有可查询的 flag。 ### `+cond-format-create` `--properties` / `+cond-format-update` `--properties` diff --git a/skills/lark-sheets/references/lark-sheets-filter-view.md b/skills/lark-sheets/references/lark-sheets-filter-view.md index 1ad54201f..4f3e17b54 100644 --- a/skills/lark-sheets/references/lark-sheets-filter-view.md +++ b/skills/lark-sheets/references/lark-sheets-filter-view.md @@ -36,57 +36,44 @@ ### `+filter-view-list` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--view-id` | 专有 | string | 否 | 按筛选视图 reference_id 过滤(命中即只返回单个视图) | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--view-id` | string | 否 | 按筛选视图 reference_id 过滤(命中即只返回单个视图) | ### `+filter-view-create` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 是 | +filter-view-create / --data: 视图规则 JSON,含 `rules?`(列级筛选规则数组)和 `filtered_columns?`。`range` 和 `view_name` 已拎为独立 flag | -| `--range` | 专有 | string | 是 | 筛选作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--data` 中同名字段(create 必填,必须覆盖表头行) | -| `--view-name` | 专有 | string | 否 | 视图名称;create 不传时系统自动分配,update 不传时保留原名。优先级高于 `--data` 中同名字段 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--properties` | string + File + Stdin(复合 JSON) | 是 | +filter-view-create / --data: 视图规则 JSON,含 `rules?`(列级筛选规则数组)和 `filtered_columns?`。`range` 和 `view_name` 已拎为独立 flag | +| `--range` | string | 是 | 筛选作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--data` 中同名字段(create 必填,必须覆盖表头行) | +| `--view-name` | string | 否 | 视图名称;create 不传时系统自动分配,update 不传时保留原名。优先级高于 `--data` 中同名字段 | ### `+filter-view-update` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--view-id` | 专有 | string | 是 | 目标视图 reference_id | -| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 是 | +filter-view-update / --data: 视图规则 JSON,含 `rules?` 和 `filtered_columns?`。`range` 和 `view_name` 已拎为独立 flag;至少传 `--data.rules` / `--range` / `--view-name` 之一 | -| `--range` | 专有 | string | 否 | 筛选作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--data` 中同名字段(update 时省略表示保留当前 range) | -| `--view-name` | 专有 | string | 否 | 视图名称;create 不传时系统自动分配,update 不传时保留原名。优先级高于 `--data` 中同名字段 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--view-id` | string | 是 | 目标视图 reference_id | +| `--properties` | string + File + Stdin(复合 JSON) | 是 | +filter-view-update / --data: 视图规则 JSON,含 `rules?` 和 `filtered_columns?`。`range` 和 `view_name` 已拎为独立 flag;至少传 `--data.rules` / `--range` / `--view-name` 之一 | +| `--range` | string | 否 | 筛选作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--data` 中同名字段(update 时省略表示保留当前 range) | +| `--view-name` | string | 否 | 视图名称;create 不传时系统自动分配,update 不传时保留原名。优先级高于 `--data` 中同名字段 | ### `+filter-view-delete` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--view-id` | 专有 | string | 是 | 目标视图 reference_id | -| `--yes` | 系统 | bool | 是 | `high-risk-write`,必须二次确认(不带时退出码 10) | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--yes`、`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--view-id` | string | 是 | 目标视图 reference_id | ## Schemas -> 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 +> 复合 JSON flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag-name `。先 `--print-schema`(不带 `--flag-name`)会列出该 shortcut 所有可查询的 flag。 ### `+filter-view-create` `--properties` / `+filter-view-update` `--properties` diff --git a/skills/lark-sheets/references/lark-sheets-filter.md b/skills/lark-sheets/references/lark-sheets-filter.md index f10fe22b2..1679f4a5c 100644 --- a/skills/lark-sheets/references/lark-sheets-filter.md +++ b/skills/lark-sheets/references/lark-sheets-filter.md @@ -40,52 +40,37 @@ ### `+filter-list` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +_仅含公共 / 系统 flag。_ ### `+filter-create` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--range` | 专有 | string | 是 | 筛选范围(A1 表示法,含表头行,如 `A1:F1000`);不要重复写入 --data 中的 range 字段 | -| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 否 | +filter-create / --data: 筛选规则 JSON,含 `rules`(列级筛选规则数组,必填)和 `filtered_columns?`(激活列索引提示)。`range` 是独立 flag(不要再放此 JSON 里) | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | 是 | 筛选范围(A1 表示法,含表头行,如 `A1:F1000`);不要重复写入 --data 中的 range 字段 | +| `--properties` | string + File + Stdin(复合 JSON) | 否 | +filter-create / --data: 筛选规则 JSON,含 `rules`(列级筛选规则数组,必填)和 `filtered_columns?`(激活列索引提示)。`range` 是独立 flag(不要再放此 JSON 里) | ### `+filter-update` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 是 | +filter-update / --data: 筛选规则 JSON,含 `rules` 和 `filtered_columns?`;update 是整组覆盖式(传空 `rules: []` 清空)。`range` 已拎为独立 flag | -| `--range` | 专有 | string | 是 | 筛选作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--data` 中同名字段 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--properties` | string + File + Stdin(复合 JSON) | 是 | +filter-update / --data: 筛选规则 JSON,含 `rules` 和 `filtered_columns?`;update 是整组覆盖式(传空 `rules: []` 清空)。`range` 已拎为独立 flag | +| `--range` | string | 是 | 筛选作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--data` 中同名字段 | ### `+filter-delete` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--yes` | 系统 | bool | 是 | `high-risk-write`,删除不可逆 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--yes`、`--dry-run`_ + +_仅含公共 / 系统 flag。_ ## Schemas -> 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 +> 复合 JSON flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag-name `。先 `--print-schema`(不带 `--flag-name`)会列出该 shortcut 所有可查询的 flag。 ### `+filter-create` `--properties` / `+filter-update` `--properties` diff --git a/skills/lark-sheets/references/lark-sheets-float-image.md b/skills/lark-sheets/references/lark-sheets-float-image.md index 857b84c04..85e427b2b 100644 --- a/skills/lark-sheets/references/lark-sheets-float-image.md +++ b/skills/lark-sheets/references/lark-sheets-float-image.md @@ -47,67 +47,54 @@ reference_id 的映射规则: ### `+float-image-list` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--float-image-id` | 专有 | string | 否 | 按 id 过滤;省略时列工作表全部 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--float-image-id` | string | 否 | 按 id 过滤;省略时列工作表全部 | ### `+float-image-create` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--image-name` | 专有 | string | 是 | 图片名称,含拓展名(如 `logo.png`) | -| `--image-token` | 专有 | string | XOR | 图片 file_token(与 `--image-uri` 二选一)。常见来源:`+float-image-list` 返回的 `image_token` | -| `--image-uri` | 专有 | string | XOR | 图片 reference_id(与 `--image-token` 二选一);形如 `<\|image\|>:abcdef` 这种带前缀的字符串,从上游 SKILL.md 的素材引用约定取 | -| `--position-row` | 专有 | int | 是 | 图片左上角所在行(0-based) | -| `--position-col` | 专有 | string | 是 | 图片左上角所在列(列字母,如 `A` / `B`) | -| `--size-width` | 专有 | int | 是 | 图片宽度(像素) | -| `--size-height` | 专有 | int | 是 | 图片高度(像素) | -| `--offset-row` | 专有 | int | 否 | 在 position 基础上的行内偏移(像素) | -| `--offset-col` | 专有 | int | 否 | 在 position 基础上的列内偏移(像素) | -| `--z-index` | 专有 | int | 否 | 图片 Z 轴层级,控制重叠顺序 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--image-name` | string | 是 | 图片名称,含拓展名(如 `logo.png`) | +| `--image-token` | string | XOR | 图片 file_token(与 `--image-uri` 二选一)。常见来源:`+float-image-list` 返回的 `image_token` | +| `--image-uri` | string | XOR | 图片 reference_id(与 `--image-token` 二选一);形如 `<\|image\|>:abcdef` 这种带前缀的字符串,从上游 SKILL.md 的素材引用约定取 | +| `--position-row` | int | 是 | 图片左上角所在行(0-based) | +| `--position-col` | string | 是 | 图片左上角所在列(列字母,如 `A` / `B`) | +| `--size-width` | int | 是 | 图片宽度(像素) | +| `--size-height` | int | 是 | 图片高度(像素) | +| `--offset-row` | int | 否 | 在 position 基础上的行内偏移(像素) | +| `--offset-col` | int | 否 | 在 position 基础上的列内偏移(像素) | +| `--z-index` | int | 否 | 图片 Z 轴层级,控制重叠顺序 | ### `+float-image-update` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--float-image-id` | 专有 | string | 是 | 目标图片 id | -| `--image-name` | 专有 | string | 是 | 图片名称,含拓展名(如 `logo.png`) | -| `--image-token` | 专有 | string | XOR | 图片 file_token(与 `--image-uri` 二选一)。常见来源:`+float-image-list` 返回的 `image_token` | -| `--image-uri` | 专有 | string | XOR | 图片 reference_id(与 `--image-token` 二选一);形如 `<\|image\|>:abcdef` 这种带前缀的字符串,从上游 SKILL.md 的素材引用约定取 | -| `--position-row` | 专有 | int | 是 | 图片左上角所在行(0-based) | -| `--position-col` | 专有 | string | 是 | 图片左上角所在列(列字母,如 `A` / `B`) | -| `--size-width` | 专有 | int | 是 | 图片宽度(像素) | -| `--size-height` | 专有 | int | 是 | 图片高度(像素) | -| `--offset-row` | 专有 | int | 否 | 在 position 基础上的行内偏移(像素) | -| `--offset-col` | 专有 | int | 否 | 在 position 基础上的列内偏移(像素) | -| `--z-index` | 专有 | int | 否 | 图片 Z 轴层级,控制重叠顺序 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--float-image-id` | string | 是 | 目标图片 id | +| `--image-name` | string | 是 | 图片名称,含拓展名(如 `logo.png`) | +| `--image-token` | string | XOR | 图片 file_token(与 `--image-uri` 二选一)。常见来源:`+float-image-list` 返回的 `image_token` | +| `--image-uri` | string | XOR | 图片 reference_id(与 `--image-token` 二选一);形如 `<\|image\|>:abcdef` 这种带前缀的字符串,从上游 SKILL.md 的素材引用约定取 | +| `--position-row` | int | 是 | 图片左上角所在行(0-based) | +| `--position-col` | string | 是 | 图片左上角所在列(列字母,如 `A` / `B`) | +| `--size-width` | int | 是 | 图片宽度(像素) | +| `--size-height` | int | 是 | 图片高度(像素) | +| `--offset-row` | int | 否 | 在 position 基础上的行内偏移(像素) | +| `--offset-col` | int | 否 | 在 position 基础上的列内偏移(像素) | +| `--z-index` | int | 否 | 图片 Z 轴层级,控制重叠顺序 | ### `+float-image-delete` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--float-image-id` | 专有 | string | 是 | 目标图片 id | -| `--yes` | 系统 | bool | 是 | `high-risk-write`,删除不可逆 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--yes`、`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--float-image-id` | string | 是 | 目标图片 id | ## Examples diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md index 2e13fb7ed..359dceda0 100644 --- a/skills/lark-sheets/references/lark-sheets-pivot-table.md +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -49,57 +49,44 @@ ### `+pivot-list` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--pivot-table-id` | 专有 | string | 否 | 按 id 过滤 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--pivot-table-id` | string | 否 | 按 id 过滤 | ### `+pivot-create` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 是 | JSON:`{"data_range":"Sheet1!A1:F1000","rows":[...],"columns":[...],"values":[...],"filters":[...],"show_row_grand_total":true,"show_col_grand_total":true}` | -| `--target-sheet-id` | 专有 | string | 否 | 透视表落点子表 id;省略时自动新建子表(推荐) | -| `--target-position` | 专有 | string | 否 | 落点起始 cell(如 `A1`),默认 `A1` | -| `--source` | 专有 | string | 是 | 透视表源数据区域(A1 表示法,格式 `SheetName!StartCell:EndCell`,如 `Sheet1!A1:D100`) | -| `--range` | 专有 | string | 否 | 透视表放置位置(左上角 A1 单值,如 `F1`);省略时放在新建子表的左上角 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--properties` | string + File + Stdin(复合 JSON) | 是 | JSON:`{"data_range":"Sheet1!A1:F1000","rows":[...],"columns":[...],"values":[...],"filters":[...],"show_row_grand_total":true,"show_col_grand_total":true}` | +| `--target-sheet-id` | string | 否 | 透视表落点子表 id;省略时自动新建子表(推荐) | +| `--target-position` | string | 否 | 落点起始 cell(如 `A1`),默认 `A1` | +| `--source` | string | 是 | 透视表源数据区域(A1 表示法,格式 `SheetName!StartCell:EndCell`,如 `Sheet1!A1:D100`) | +| `--range` | string | 否 | 透视表放置位置(左上角 A1 单值,如 `F1`);省略时放在新建子表的左上角 | ### `+pivot-update` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--pivot-table-id` | 专有 | string | 是 | 目标透视表 id | -| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 是 | 完整或足够完整的配置(先 `+pivot-list --pivot-table-id ` 回读再 patch) | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--pivot-table-id` | string | 是 | 目标透视表 id | +| `--properties` | string + File + Stdin(复合 JSON) | 是 | 完整或足够完整的配置(先 `+pivot-list --pivot-table-id ` 回读再 patch) | ### `+pivot-delete` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--pivot-table-id` | 专有 | string | 是 | 目标透视表 id | -| `--yes` | 系统 | bool | 是 | `high-risk-write`,删除不可逆 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--yes`、`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--pivot-table-id` | string | 是 | 目标透视表 id | ## Schemas -> 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 +> 复合 JSON flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag-name `。先 `--print-schema`(不带 `--flag-name`)会列出该 shortcut 所有可查询的 flag。 ### `+pivot-create` `--properties` / `+pivot-update` `--properties` diff --git a/skills/lark-sheets/references/lark-sheets-range-operations.md b/skills/lark-sheets/references/lark-sheets-range-operations.md index 35108a86b..f1869d0e1 100644 --- a/skills/lark-sheets/references/lark-sheets-range-operations.md +++ b/skills/lark-sheets/references/lark-sheets-range-operations.md @@ -122,126 +122,98 @@ ### `+cells-clear` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--range` | 专有 | string | 是 | 清除范围 A1 格式 | -| `--scope` | 专有 | string + Enum | 否 | `content` / `formats` / `all`,默认 `content`(仅清内容) | -| `--yes` | 系统 | bool | 是 | `high-risk-write`,清除不可逆 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--yes`、`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | 是 | 清除范围 A1 格式 | +| `--scope` | string + Enum | 否 | `content` / `formats` / `all`,默认 `content`(仅清内容) | ### `+cells-merge` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--range` | 专有 | string | 是 | 待合并 / 取消合并的范围 | -| `--merge-type` | 专有 | string + Enum | 否 | (仅 `+cells-merge`)`all` / `rows` / `columns`,默认 `all` | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | 是 | 待合并 / 取消合并的范围 | +| `--merge-type` | string + Enum | 否 | (仅 `+cells-merge`)`all` / `rows` / `columns`,默认 `all` | ### `+cells-unmerge` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--range` | 专有 | string | 是 | 待合并 / 取消合并的范围 | -| `--merge-type` | 专有 | string + Enum | 否 | (仅 `+cells-merge`)`all` / `rows` / `columns`,默认 `all` | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | 是 | 待合并 / 取消合并的范围 | +| `--merge-type` | string + Enum | 否 | (仅 `+cells-merge`)`all` / `rows` / `columns`,默认 `all` | ### `+rows-resize` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--start` | 专有 | int | 是 | 起始行(0-based,inclusive) | -| `--end` | 专有 | int | 是 | 结束行(0-based,inclusive) | -| `--type` | 专有 | string + Enum | 是 | 尺寸方式 enum:`pixel`(指定 px 像素值,需配 `--size`)/ `standard`(重置为默认标准行高)/ `auto`(自动适应内容) | -| `--size` | 专有 | int | 否 | 行高(像素,例:30 / 40 / 60);`--type pixel` 时必填,其它 type 忽略 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--start` | int | 是 | 起始行(0-based,inclusive) | +| `--end` | int | 是 | 结束行(0-based,inclusive) | +| `--type` | string + Enum | 是 | 尺寸方式 enum:`pixel`(指定 px 像素值,需配 `--size`)/ `standard`(重置为默认标准行高)/ `auto`(自动适应内容) | +| `--size` | int | 否 | 行高(像素,例:30 / 40 / 60);`--type pixel` 时必填,其它 type 忽略 | ### `+cols-resize` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--start` | 专有 | int | 是 | 起始列(0-based,inclusive) | -| `--end` | 专有 | int | 是 | 结束列(0-based,inclusive) | -| `--type` | 专有 | string + Enum | 是 | 尺寸方式 enum:`pixel`(指定 px 像素值,需配 `--size`)/ `standard`(重置为默认标准列宽) | -| `--size` | 专有 | int | 否 | 列宽(像素,例:80 / 120 / 200);`--type pixel` 时必填,其它 type 忽略 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--start` | int | 是 | 起始列(0-based,inclusive) | +| `--end` | int | 是 | 结束列(0-based,inclusive) | +| `--type` | string + Enum | 是 | 尺寸方式 enum:`pixel`(指定 px 像素值,需配 `--size`)/ `standard`(重置为默认标准列宽) | +| `--size` | int | 否 | 列宽(像素,例:80 / 120 / 200);`--type pixel` 时必填,其它 type 忽略 | ### `+range-move` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--source-range` | 专有 | string | 是 | 源 A1 范围 | -| `--target-sheet-id` | 专有 | string | 否 | 目标子表;省略时同 sheet | -| `--target-range` | 专有 | string | 是 | 目标 A1 范围(起点 cell 即可,按源尺寸自动推断) | -| `--paste-type` | 专有 | string + Enum | 否 | (仅 `+range-copy`)`values` / `formulas` / `formats` / `all`,默认 `all` | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--source-range` | string | 是 | 源 A1 范围 | +| `--target-sheet-id` | string | 否 | 目标子表;省略时同 sheet | +| `--target-range` | string | 是 | 目标 A1 范围(起点 cell 即可,按源尺寸自动推断) | +| `--paste-type` | string + Enum | 否 | (仅 `+range-copy`)`values` / `formulas` / `formats` / `all`,默认 `all` | ### `+range-copy` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--source-range` | 专有 | string | 是 | 源 A1 范围 | -| `--target-sheet-id` | 专有 | string | 否 | 目标子表;省略时同 sheet | -| `--target-range` | 专有 | string | 是 | 目标 A1 范围(起点 cell 即可,按源尺寸自动推断) | -| `--paste-type` | 专有 | string + Enum | 否 | (仅 `+range-copy`)`values` / `formulas` / `formats` / `all`,默认 `all` | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--source-range` | string | 是 | 源 A1 范围 | +| `--target-sheet-id` | string | 否 | 目标子表;省略时同 sheet | +| `--target-range` | string | 是 | 目标 A1 范围(起点 cell 即可,按源尺寸自动推断) | +| `--paste-type` | string + Enum | 否 | (仅 `+range-copy`)`values` / `formulas` / `formats` / `all`,默认 `all` | ### `+range-fill` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--source-range` | 专有 | string | 是 | 填充模板范围(系列起始 cells) | -| `--target-range` | 专有 | string | 是 | 目标填充范围 | -| `--series-type` | 专有 | string + Enum | 否 | `auto` / `linear` / `growth` / `date` / `copy`,默认 `auto` | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--source-range` | string | 是 | 填充模板范围(系列起始 cells) | +| `--target-range` | string | 是 | 目标填充范围 | +| `--series-type` | string + Enum | 否 | `auto` / `linear` / `growth` / `date` / `copy`,默认 `auto` | ### `+range-sort` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--range` | 专有 | string | 是 | 排序范围(含或不含表头由 `--has-header` 决定) | -| `--sort-keys` | 专有 | string + File + Stdin(复合 JSON) | 是 | JSON:`[{"col":"B","order":"asc"},{"col":"D","order":"desc"}]` | -| `--has-header` | 专有 | bool | 否 | 第一行是表头不参与排序,默认 false | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | 是 | 排序范围(含或不含表头由 `--has-header` 决定) | +| `--sort-keys` | string + File + Stdin(复合 JSON) | 是 | JSON:`[{"col":"B","order":"asc"},{"col":"D","order":"desc"}]` | +| `--has-header` | bool | 否 | 第一行是表头不参与排序,默认 false | ## Schemas -> 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 +> 复合 JSON flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag-name `。先 `--print-schema`(不带 `--flag-name`)会列出该 shortcut 所有可查询的 flag。 ### `+range-sort` `--sort-keys` @@ -300,4 +272,4 @@ lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --start 0 --end 5 --t - `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`(只行高支持自适应)。 - `DryRun`:所有写操作输出"将要 PATCH 的 range + 受影响 cell 数估算"。 -- `Execute`:写后调用 `+cells-get --ranges <影响范围>` 抽样回读对比,envelope.meta.verification 沉淀对比结果。 +- `Execute`:写后调用 `+cells-get --range <影响范围>` 抽样回读对比,envelope.meta.verification 沉淀对比结果。 diff --git a/skills/lark-sheets/references/lark-sheets-read-data.md b/skills/lark-sheets/references/lark-sheets-read-data.md index 65a3ff0e3..b67e590c2 100644 --- a/skills/lark-sheets/references/lark-sheets-read-data.md +++ b/skills/lark-sheets/references/lark-sheets-read-data.md @@ -87,43 +87,36 @@ ### `+cells-get` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--ranges` | 专有 | string_array | 是 | 多 range,可重复:`--ranges A1:B2 --ranges D1:E5` | -| `--include` | 专有 | string_slice + Enum | 否 | `value` / `formula` / `style` / `comment` / `data_validation`,逗号拆分 | -| `--cell-limit` | 专有 | int + Hidden | 否 | 防爆,默认 5000 | -| `--max-chars` | 专有 | int + Hidden | 否 | 防爆,默认 200000 | -| `--skip-hidden` | 专有 | bool | 否 | 同上 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | 是 | A1 范围,如 `Sheet1!A1:F10`;与 `+cells-set` 等写入 shortcut 保持一致 | +| `--include` | string_slice + Enum | 否 | `value` / `formula` / `style` / `comment` / `data_validation`,逗号拆分 | +| `--cell-limit` | int + Hidden | 否 | 防爆,默认 5000 | +| `--max-chars` | int + Hidden | 否 | 防爆,默认 200000 | +| `--skip-hidden` | bool | 否 | 同上 | ### `+dropdown-get` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--range` | 专有 | string | 是 | 目标范围 A1 格式(含 sheet 前缀,如 `sheet1!A2:A100`) | -| `--dry-run` | 系统 | bool | 否 | | +_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | 是 | 目标范围 A1 格式(含 sheet 前缀,如 `sheet1!A2:A100`) | ### `+csv-get` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称 | -| `--range` | 专有 | string | 否 | A1 格式范围;省略时读整表的 `current_region` | -| `--value-render-option` | 专有 | string + Enum | 否 | `ToString` / `FormattedValue` / `Formula` / `UnformattedValue` | -| `--max-rows` | 专有 | int + Hidden | 否 | 防爆,默认 100000 | -| `--max-chars` | 专有 | int + Hidden | 否 | 防爆,默认 200000 | -| `--include-row-prefix` | 专有 | bool | 否 | 是否在每行前加 `[row=N]` 前缀,默认 `true` | -| `--skip-hidden` | 专有 | bool | 否 | 跳过隐藏行列,默认 `false` | -| `--dry-run` | 系统 | bool | 否 | 仅打印请求路径与参数,不执行 | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | 否 | A1 格式范围;省略时读整表的 `current_region` | +| `--value-render-option` | string + Enum | 否 | `ToString` / `FormattedValue` / `Formula` / `UnformattedValue` | +| `--max-rows` | int + Hidden | 否 | 防爆,默认 100000 | +| `--max-chars` | int + Hidden | 否 | 防爆,默认 200000 | +| `--include-row-prefix` | bool | 否 | 是否在每行前加 `[row=N]` 前缀,默认 `true` | +| `--skip-hidden` | bool | 否 | 跳过隐藏行列,默认 `false` | ## Examples @@ -155,7 +148,7 @@ lark-cli sheets +csv-get --spreadsheet-token shtXXX --sheet-name "销售明细" ```bash # 读 A1:F10 的公式 + 样式 lark-cli sheets +cells-get --url "https://example.feishu.cn/sheets/shtXXX" \ - --ranges "Sheet1!A1:F10" --include formula,style + --range "Sheet1!A1:F10" --include formula,style ``` > ⚠️ 调用方在 `cells[i][j]` 中**不能**用下标推真实行列:必须读 `ranges[n].row_indices[i]` / `ranges[n].col_indices[j]`。 diff --git a/skills/lark-sheets/references/lark-sheets-search-replace.md b/skills/lark-sheets/references/lark-sheets-search-replace.md index 30a8eab10..fe70ea90a 100644 --- a/skills/lark-sheets/references/lark-sheets-search-replace.md +++ b/skills/lark-sheets/references/lark-sheets-search-replace.md @@ -33,37 +33,31 @@ ### `+cells-search` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--find` | 专有 | string | 是 | 待查找文本(与 `--regex` 配合时是正则) | -| `--range` | 专有 | string | 否 | A1 格式查找范围;省略时整表 | -| `--match-case` | 专有 | bool | 否 | 大小写敏感 | -| `--match-entire-cell` | 专有 | bool | 否 | 完全匹配整个单元格 | -| `--regex` | 专有 | bool | 否 | `--find` 当正则解释 | -| `--include-formulas` | 专有 | bool | 否 | 也在公式文本中搜索 | -| `--max-matches` | 专有 | int + Hidden | 否 | 防爆,默认 5000 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--find` | string | 是 | 待查找文本(与 `--regex` 配合时是正则) | +| `--range` | string | 否 | A1 格式查找范围;省略时整表 | +| `--match-case` | bool | 否 | 大小写敏感 | +| `--match-entire-cell` | bool | 否 | 完全匹配整个单元格 | +| `--regex` | bool | 否 | `--find` 当正则解释 | +| `--include-formulas` | bool | 否 | 也在公式文本中搜索 | +| `--max-matches` | int + Hidden | 否 | 防爆,默认 5000 | ### `+cells-replace` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--find` | 专有 | string | 是 | 待替换文本 | -| `--replacement` | 专有 | string | 是 | 替换为;传 `""` 等价于"删除内容" | -| `--range` | 专有 | string | 否 | 范围;省略时整表 | -| `--match-case` | 专有 | bool | 否 | 同 `+cells-search` | -| `--match-entire-cell` | 专有 | bool | 否 | 同 `+cells-search` | -| `--regex` | 专有 | bool | 否 | 同 `+cells-search` | -| `--include-formulas` | 专有 | bool | 否 | 同 `+cells-search` | -| `--dry-run` | 系统 | bool | 否 | 必跑:输出 `would_replace_count` 让用户先确认 | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--find` | string | 是 | 待替换文本 | +| `--replacement` | string | 是 | 替换为;传 `""` 等价于"删除内容" | +| `--range` | string | 否 | 范围;省略时整表 | +| `--match-case` | bool | 否 | 同 `+cells-search` | +| `--match-entire-cell` | bool | 否 | 同 `+cells-search` | +| `--regex` | bool | 否 | 同 `+cells-search` | +| `--include-formulas` | bool | 否 | 同 `+cells-search` | ## Examples diff --git a/skills/lark-sheets/references/lark-sheets-sheet-structure.md b/skills/lark-sheets/references/lark-sheets-sheet-structure.md index c88b013d1..78b655dce 100644 --- a/skills/lark-sheets/references/lark-sheets-sheet-structure.md +++ b/skills/lark-sheets/references/lark-sheets-sheet-structure.md @@ -53,122 +53,94 @@ ### `+sheet-info` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--include` | 专有 | string_slice + Enum | 否 | `merges` / `row_heights` / `col_widths` / `hidden_rows` / `hidden_cols` / `groups` / `frozen`,逗号拆分 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--include` | string_slice + Enum | 否 | `merges` / `row_heights` / `col_widths` / `hidden_rows` / `hidden_cols` / `groups` / `frozen`,逗号拆分 | ### `+dim-insert` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--dimension` | 专有 | string + Enum | 是 | `row` / `column` | -| `--start` | 专有 | int | 是 | 插入起始位置(0-based) | -| `--end` | 专有 | int | 是 | 插入结束位置(exclusive) | -| `--inherit-style` | 专有 | string + Enum | 否 | `before` / `after` / `none`;默认 `none` | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--dimension` | string + Enum | 是 | `row` / `column` | +| `--start` | int | 是 | 插入起始位置(0-based) | +| `--end` | int | 是 | 插入结束位置(exclusive) | +| `--inherit-style` | string + Enum | 否 | `before` / `after` / `none`;默认 `none` | ### `+dim-delete` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--dimension` | 专有 | string + Enum | 是 | `row` / `column` | -| `--start` | 专有 | int | 是 | 起始(0-based) | -| `--end` | 专有 | int | 是 | 结束(exclusive) | -| `--yes` | 系统 | bool | 是 | `high-risk-write`,删除行/列不可逆 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--yes`、`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--dimension` | string + Enum | 是 | `row` / `column` | +| `--start` | int | 是 | 起始(0-based) | +| `--end` | int | 是 | 结束(exclusive) | ### `+dim-hide` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--dimension` | 专有 | string + Enum | 是 | `row` / `column` | -| `--start` | 专有 | int | 是 | 范围 | -| `--end` | 专有 | int | 是 | 范围 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--dimension` | string + Enum | 是 | `row` / `column` | +| `--start` | int | 是 | 范围 | +| `--end` | int | 是 | 范围 | ### `+dim-unhide` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--dimension` | 专有 | string + Enum | 是 | `row` / `column` | -| `--start` | 专有 | int | 是 | 范围 | -| `--end` | 专有 | int | 是 | 范围 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--dimension` | string + Enum | 是 | `row` / `column` | +| `--start` | int | 是 | 范围 | +| `--end` | int | 是 | 范围 | ### `+dim-freeze` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--dimension` | 专有 | string + Enum | 是 | `row` / `column` | -| `--count` | 专有 | int | 是 | 冻结前 N 行/列;传 0 解除冻结 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--dimension` | string + Enum | 是 | `row` / `column` | +| `--count` | int | 是 | 冻结前 N 行/列;传 0 解除冻结 | ### `+dim-group` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--dimension` | 专有 | string + Enum | 是 | `row` / `column` | -| `--start` | 专有 | int | 是 | 范围 | -| `--end` | 专有 | int | 是 | 范围 | -| `--depth` | 专有 | int | 否 | 嵌套层级(`+dim-group` 用),默认 1 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--dimension` | string + Enum | 是 | `row` / `column` | +| `--start` | int | 是 | 范围 | +| `--end` | int | 是 | 范围 | +| `--depth` | int | 否 | 嵌套层级(`+dim-group` 用),默认 1 | ### `+dim-ungroup` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--dimension` | 专有 | string + Enum | 是 | `row` / `column` | -| `--start` | 专有 | int | 是 | 范围 | -| `--end` | 专有 | int | 是 | 范围 | -| `--depth` | 专有 | int | 否 | 嵌套层级(`+dim-group` 用),默认 1 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--dimension` | string + Enum | 是 | `row` / `column` | +| `--start` | int | 是 | 范围 | +| `--end` | int | 是 | 范围 | +| `--depth` | int | 否 | 嵌套层级(`+dim-group` 用),默认 1 | ### `+dim-move` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--dimension` | 专有 | string + Enum | 是 | `row` / `column` | -| `--start` | 专有 | int | 是 | 源起始位置(0-indexed,inclusive) | -| `--end` | 专有 | int | 是 | 源结束位置(0-indexed,inclusive) | -| `--target` | 专有 | int | 是 | 目标位置(move 到该 index 前面,0-indexed) | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--dimension` | string + Enum | 是 | `row` / `column` | +| `--start` | int | 是 | 源起始位置(0-indexed,inclusive) | +| `--end` | int | 是 | 源结束位置(0-indexed,inclusive) | +| `--target` | int | 是 | 目标位置(move 到该 index 前面,0-indexed) | ## Examples diff --git a/skills/lark-sheets/references/lark-sheets-sparkline.md b/skills/lark-sheets/references/lark-sheets-sparkline.md index 81d0767bd..0801a1015 100644 --- a/skills/lark-sheets/references/lark-sheets-sparkline.md +++ b/skills/lark-sheets/references/lark-sheets-sparkline.md @@ -33,53 +33,40 @@ ### `+sparkline-list` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--group-id` | 专有 | string | 否 | 按 group_id 过滤 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--group-id` | string | 否 | 按 group_id 过滤 | ### `+sparkline-create` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 是 | JSON:`{"type":"line\|column\|winLoss","data_range":"A2:F10","target_range":"G2:G10","style":{...},"special_points":{...}}`;type 三种 enum;data_range 与 target_range 行/列数需对齐 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--properties` | string + File + Stdin(复合 JSON) | 是 | JSON:`{"type":"line\|column\|winLoss","data_range":"A2:F10","target_range":"G2:G10","style":{...},"special_points":{...}}`;type 三种 enum;data_range 与 target_range 行/列数需对齐 | ### `+sparkline-update` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--group-id` | 专有 | string | 是 | 目标组 id | -| `--properties` | 专有 | string + File + Stdin(复合 JSON) | 是 | 完整或足够完整的配置(先 `+sparkline-list --group-id ` 回读再 patch);可改 type / data_range / target_range / style / special_points 等字段 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--group-id` | string | 是 | 目标组 id | +| `--properties` | string + File + Stdin(复合 JSON) | 是 | 完整或足够完整的配置(先 `+sparkline-list --group-id ` 回读再 patch);可改 type / data_range / target_range / style / special_points 等字段 | ### `+sparkline-delete` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--group-id` | 专有 | string | 是 | 目标组 id | -| `--yes` | 系统 | bool | 是 | `high-risk-write`,删除不可逆 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--yes`、`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--group-id` | string | 是 | 目标组 id | ## Schemas -> 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 +> 复合 JSON flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag-name `。先 `--print-schema`(不带 `--flag-name`)会列出该 shortcut 所有可查询的 flag。 ### `+sparkline-create` `--properties` / `+sparkline-update` `--properties` diff --git a/skills/lark-sheets/references/lark-sheets-workbook.md b/skills/lark-sheets/references/lark-sheets-workbook.md index a8b518949..cbb869fa9 100644 --- a/skills/lark-sheets/references/lark-sheets-workbook.md +++ b/skills/lark-sheets/references/lark-sheets-workbook.md @@ -48,122 +48,95 @@ ### `+workbook-info` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet 定位 | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet 定位 | -| `--include-properties` | 专有 | bool | 否 | 是否返回每个 sheet 的扩展属性(默认 true) | -| `--dry-run` | 系统 | bool | 否 | | +_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--include-properties` | bool | 否 | 是否返回每个 sheet 的扩展属性(默认 true) | ### `+sheet-create` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--title` | 专有 | string | 是 | 新工作表名称 | -| `--index` | 专有 | int | 否 | 插入位置;省略时附加到末尾 | -| `--row-count` | 专有 | int | 否 | 初始行数,默认 100 | -| `--col-count` | 专有 | int | 否 | 初始列数,默认 26 | -| `--dry-run` | 系统 | bool | 否 | | +_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--title` | string | 是 | 新工作表名称 | +| `--index` | int | 否 | 插入位置;省略时附加到末尾 | +| `--row-count` | int | 否 | 初始行数,默认 100 | +| `--col-count` | int | 否 | 初始列数,默认 26 | ### `+sheet-delete` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--yes` | 系统 | bool | 是 | `high-risk-write`,必须二次确认(不带时退出码 10) | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--yes`、`--dry-run`_ + +_仅含公共 / 系统 flag。_ ### `+sheet-rename` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--title` | 专有 | string | 是 | 新名称 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--title` | string | 是 | 新名称 | ### `+sheet-move` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--index` | 专有 | int | 是 | 目标位置(0-based) | -| `--source-index` | 专有 | int | 否 | 源位置(0-based);可选,未传时由 CLI runtime 根据 --sheet-id / --sheet-name 当前在工作簿中的 index 自动派生 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--index` | int | 是 | 目标位置(0-based) | +| `--source-index` | int | 否 | 源位置(0-based);可选,未传时由 CLI runtime 根据 --sheet-id / --sheet-name 当前在工作簿中的 index 自动派生 | ### `+sheet-copy` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--title` | 专有 | string | 否 | 副本名称;省略时由服务端生成 | -| `--index` | 专有 | int | 否 | 副本插入位置 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--title` | string | 否 | 副本名称;省略时由服务端生成 | +| `--index` | int | 否 | 副本插入位置 | ### `+sheet-hide` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +_仅含公共 / 系统 flag。_ ### `+sheet-unhide` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +_仅含公共 / 系统 flag。_ ### `+sheet-set-tab-color` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--color` | 专有 | string | 是 | Hex 色值如 `#FF0000`,传空 `""` 清除 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--color` | string | 是 | Hex 色值如 `#FF0000`,传空 `""` 清除 | ### `+workbook-create` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--title` | 专有 | string | 是 | 新 spreadsheet 标题 | -| `--folder-token` | 专有 | string | 否 | 目标文件夹 token;省略放根目录 | -| `--headers` | 专有 | string + File + Stdin(简单 JSON) | 否 | 表头行 JSON 数组:`["列A","列B"]` | -| `--values` | 专有 | string + File + Stdin(简单 JSON) | 否 | 初始数据 JSON 二维数组:`[["alice",95]]` | -| `--dry-run` | 系统 | bool | 否 | | +_系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--title` | string | 是 | 新 spreadsheet 标题 | +| `--folder-token` | string | 否 | 目标文件夹 token;省略放根目录 | +| `--headers` | string + File + Stdin(简单 JSON) | 否 | 表头行 JSON 数组:`["列A","列B"]` | +| `--values` | string + File + Stdin(简单 JSON) | 否 | 初始数据 JSON 二维数组:`[["alice",95]]` | ### `+workbook-export` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--file-extension` | 专有 | string + Enum | 否 | `xlsx` / `csv`,默认 `xlsx`;csv 模式必须配 `--sheet-id` | -| `--sheet-id` | 专有 | string | 否 | 仅 csv 模式必填:指定要导出的 sheet reference_id | -| `--output-path` | 专有 | string | 否 | 本地保存路径;省略只触发导出不下载 | -| `--dry-run` | 系统 | bool | 否 | | +_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--file-extension` | string + Enum | 否 | `xlsx` / `csv`,默认 `xlsx`;csv 模式必须配 `--sheet-id` | +| `--sheet-id` | string | 否 | 仅 csv 模式必填:指定要导出的 sheet reference_id | +| `--output-path` | string | 否 | 本地保存路径;省略只触发导出不下载 | ## Examples diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 1de922143..5f87d0d26 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -156,14 +156,14 @@ set_cell_range — range="A11:H11", cells=[[ | 场景 | 工具 | 原因 | |------|------|------| -| 写入含公式、样式、批注、图片、数据校验等任意富写入 | `+cells-set` | 唯一支持完整字段的工具 | -| 数据已经在沙箱里(Python 清洗/聚合/筛选/合并的大块纯值) | `import_sandbox_to_sheet` | 只传 `file_uri`,CSV 不进对话上下文,最省 token | | 模型手里已经有 CSV 文本(小规模手动构造、从 `+csv-get` 取到后简单加工) | `+csv-put` | 直接传 CSV 文本 + start_cell,不用自己拼二维 cells 数组;必要时自动扩容行列 | -| 大量纯值 + 需要表头样式/边框 | 先用 `import_sandbox_to_sheet` 或 `+csv-put` 写值,再用 `+cells-set` 补样式 | 分工配合,入参最短 | +| 数据已经在沙箱里(Python 清洗/聚合/筛选/合并的大块纯值) | `import_sandbox_to_sheet` | 只传 `file_uri`,CSV 不进对话上下文,最省 token | +| 写入含公式、样式、批注、图片、数据校验等任意富写入 | `+cells-set` | 唯一支持完整字段的工具 | +| 大量纯值 + 需要表头样式/边框 | 先用 `+csv-put` 或 `import_sandbox_to_sheet` 写值,再用 `+cells-set` 补样式 | 分工配合,入参最短 | -**优先级**:沙箱路径(`import_sandbox_to_sheet`)> CSV 文本(`+csv-put`)> 逐格写(`+cells-set`)。能把数据保留在沙箱里的就别让 CSV 进上下文。 +**优先级**:常规规模优先 `+csv-put`(最短入参,直接传 CSV 文本);数据已在沙箱或大数据回写场景切到 `import_sandbox_to_sheet`(CSV 不进上下文,更省 token);含公式/样式/批注/图片才用 `+cells-set`。 -⚠️ `import_sandbox_to_sheet` 和 `+csv-put` 都只写纯值,**不会**携带公式/样式/批注/图片;公式字符串以 `=` 开头会被当作字面量文本落地。如果数据里需要公式或样式,**必须**用 `+cells-set`(或"写值 + 补样式"两步法)。 +⚠️ `+csv-put` 和 `import_sandbox_to_sheet` 都只写纯值,**不会**携带公式/样式/批注/图片;公式字符串以 `=` 开头会被当作字面量文本落地。如果数据里需要公式或样式,**必须**用 `+cells-set`(或"写值 + 补样式"两步法)。 ## Shortcuts @@ -180,84 +180,69 @@ set_cell_range — range="A11:H11", cells=[[ ### `+cells-set` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--range` | 专有 | string | 是 | 写入区域 A1 格式 | -| `--cells` | 专有 | string + File + Stdin(复合 JSON) | 是 | JSON:`{"values": [[...], ...]}`;可含 `formula` / `cell_styles` / `comments` / `embed_image` 富信息 | -| `--allow-overwrite` | 专有 | bool | 否 | 允许覆盖非空 cell;默认 false 时遇非空 cell 报错 | -| `--max-cells` | 专有 | int + Hidden | 否 | 防爆,默认 50000 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | 是 | 写入区域 A1 格式 | +| `--cells` | string + File + Stdin(复合 JSON) | 是 | JSON:`{"values": [[...], ...]}`;可含 `formula` / `cell_styles` / `comments` / `embed_image` 富信息 | +| `--allow-overwrite` | bool | 否 | 允许覆盖非空 cell;默认 false 时遇非空 cell 报错 | +| `--max-cells` | int + Hidden | 否 | 防爆,默认 50000 | ### `+cells-set-style` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--range` | 专有 | string | 是 | 目标范围 A1 格式(如 `A1:B2`) | -| `--background-color` | 专有 | string | 否 | 背景颜色(十六进制,如 `#ffffff`) | -| `--font-color` | 专有 | string | 否 | 字体颜色(十六进制,如 `#000000`) | -| `--font-size` | 专有 | number | 否 | 字体大小(px,例:10、12、14) | -| `--font-style` | 专有 | string + Enum | 否 | 字体样式 enum:`normal` / `italic` | -| `--font-weight` | 专有 | string + Enum | 否 | 字重 enum:`normal` / `bold` | -| `--font-line` | 专有 | string + Enum | 否 | 字体线条样式 enum:`none` / `underline` / `line-through` | -| `--horizontal-alignment` | 专有 | string + Enum | 否 | 水平对齐 enum:`left` / `center` / `right` | -| `--vertical-alignment` | 专有 | string + Enum | 否 | 垂直对齐 enum:`top` / `middle` / `bottom` | -| `--word-wrap` | 专有 | string + Enum | 否 | 换行策略 enum:`overflow` / `auto-wrap` / `word-clip`(默认 `overflow`) | -| `--number-format` | 专有 | string | 否 | 数字格式(例:文本 `@`、数字 `0.00`、货币 `$#,##0.00`、日期 `mm/dd/yyyy`) | -| `--border-styles` | 专有 | string + File + Stdin(复合 JSON) | 否 | 边框配置 JSON:`{ top: {style,color,weight}, bottom: ..., left: ..., right: ... }`;4 方向结构相同 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | 是 | 目标范围 A1 格式(如 `A1:B2`) | +| `--background-color` | string | 否 | 背景颜色(十六进制,如 `#ffffff`) | +| `--font-color` | string | 否 | 字体颜色(十六进制,如 `#000000`) | +| `--font-size` | number | 否 | 字体大小(px,例:10、12、14) | +| `--font-style` | string + Enum | 否 | 字体样式 enum:`normal` / `italic` | +| `--font-weight` | string + Enum | 否 | 字重 enum:`normal` / `bold` | +| `--font-line` | string + Enum | 否 | 字体线条样式 enum:`none` / `underline` / `line-through` | +| `--horizontal-alignment` | string + Enum | 否 | 水平对齐 enum:`left` / `center` / `right` | +| `--vertical-alignment` | string + Enum | 否 | 垂直对齐 enum:`top` / `middle` / `bottom` | +| `--word-wrap` | string + Enum | 否 | 换行策略 enum:`overflow` / `auto-wrap` / `word-clip`(默认 `overflow`) | +| `--number-format` | string | 否 | 数字格式(例:文本 `@`、数字 `0.00`、货币 `$#,##0.00`、日期 `mm/dd/yyyy`) | +| `--border-styles` | string + File + Stdin(复合 JSON) | 否 | 边框配置 JSON:`{ top: {style,color,weight}, bottom: ..., left: ..., right: ... }`;4 方向结构相同 | ### `+cells-set-image` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--range` | 专有 | string | 是 | 目标 cell A1(必须单 cell,如 `A1`;起止 cell 须相同) | -| `--image` | 专有 | string | 是 | 本地图片路径(支持 PNG / JPEG / JPG / GIF / BMP / JFIF / EXIF / TIFF / BPG / HEIC) | -| `--name` | 专有 | string | 否 | 图片文件名(含扩展名);省略时取 `--image` 的 basename | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | 是 | 目标 cell A1(必须单 cell,如 `A1`;起止 cell 须相同) | +| `--image` | string | 是 | 本地图片路径(支持 PNG / JPEG / JPG / GIF / BMP / JFIF / EXIF / TIFF / BPG / HEIC) | +| `--name` | string | 否 | 图片文件名(含扩展名);省略时取 `--image` 的 basename | ### `+dropdown-set` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--range` | 专有 | string | 是 | 目标范围 A1 格式(如 `A2:A100`) | -| `--options` | 专有 | string + File + Stdin(复合 JSON) | 是 | 选项 JSON 数组 `["opt1","opt2"]`;最多 500 项,每项 ≤100 字符,不含逗号 | -| `--colors` | 专有 | string + File + Stdin(简单 JSON) | 否 | RGB hex 颜色数组(如 `["#1FB6C1","#F006C2"]`),长度必须与 `--options` 一致 | -| `--multiple` | 专有 | bool | 否 | 启用多选;默认 `false` | -| `--highlight` | 专有 | bool | 否 | 选项配色显示;默认 `false` | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | 是 | 目标范围 A1 格式(如 `A2:A100`) | +| `--options` | string + File + Stdin(复合 JSON) | 是 | 选项 JSON 数组 `["opt1","opt2"]`;最多 500 项,每项 ≤100 字符,不含逗号 | +| `--colors` | string + File + Stdin(简单 JSON) | 否 | RGB hex 颜色数组(如 `["#1FB6C1","#F006C2"]`),长度必须与 `--options` 一致 | +| `--multiple` | bool | 否 | 启用多选;默认 `false` | +| `--highlight` | bool | 否 | 选项配色显示;默认 `false` | ### `+csv-put` -| Flag | 分类 | Type | 必填 | 说明 | -| --- | --- | --- | --- | --- | -| `--url` | 公共 | string | XOR | spreadsheet URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 公共 | string | XOR | spreadsheet token(与 `--url` 二选一) | -| `--sheet-id` | 公共 | string | XOR | 工作表 reference_id(与 `--sheet-name` 二选一) | -| `--sheet-name` | 公共 | string | XOR | 工作表名称(与 `--sheet-id` 二选一) | -| `--range` | 专有 | string | 是 | 目标区域起点 A1(如 `Sheet1!A1`);自动按 CSV 行列数推断终点 | -| `--csv` | 专有 | string + File + Stdin(非 JSON 文本) | 是 | RFC 4180 CSV 文本;只写纯值,不带公式/样式/批注 | -| `--allow-overwrite` | 专有 | bool | 否 | 允许覆盖;默认 false 时若目标非空报错 | -| `--dry-run` | 系统 | bool | 否 | | +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | 是 | 目标区域起点 A1(如 `Sheet1!A1`);自动按 CSV 行列数推断终点 | +| `--csv` | string + File + Stdin(非 JSON 文本) | 是 | RFC 4180 CSV 文本;只写纯值,不带公式/样式/批注 | +| `--allow-overwrite` | bool | 否 | 允许覆盖;默认 false 时若目标非空报错 | ## Schemas -> 复合 JSON flag(`--data` / `--style` / `--options` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag `(runtime introspection,待落地)。 +> 复合 JSON flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag-name `。先 `--print-schema`(不带 `--flag-name`)会列出该 shortcut 所有可查询的 flag。 ### `+cells-set` `--cells` @@ -299,6 +284,17 @@ _数据验证配置_ 公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。 +### `+cells-set` 的拆分与转介绍 + +"工具选择"段已讲清纯值(`+csv-put`)vs 富写入(`+cells-set`)。下表补 CLI 侧的 `+cells-set` **兄弟拆分**,以及不属于本 skill 的**跨 skill 转介绍**——避免 agent 用 `+cells-set` 硬扛所有写入场景。 + +| 写入场景 | 用这个 | 不要用 | +|---------|--------|--------| +| 只改**已有 cell 的样式**,不动 value/formula | `+cells-set-style` | `+cells-set`(会触发不必要的值写入) | +| 把**单张图片嵌入**到某个 cell | `+cells-set-image` | `+cells-set`(参数更繁琐) | +| **插行/列 + 写入** 这种多步组合,且要原子 | `+batch-update`(跨 skill) | 多次独立 `+cells-set`(非原子;插入会扰动后续 range) | +| 在**多个不连续 range** 上应用同一组样式 | `+cells-batch-set-style`(跨 skill) | 多次 `+cells-set-style`(非原子) | + ### `+cells-set` 示例: @@ -316,6 +312,10 @@ lark-cli sheets +cells-set --spreadsheet-token shtXXX --sheet-id "$SID" \ `--cells` 富格式见 `## Schemas` 段(cells 元素含 value / formula / cell_styles / border_styles / data_validation / multiple_values / note / rich_text);值 / 公式 / 样式 / 批注 / 嵌入图片可同一次写入混合提交。 +> 中间想跳过的 cell 用空对象 `{}` 占位(底层语义为"保留原值不变"),`--cells` 维度仍须与 `--range` 完全一致。例:`--range A1:A5 --cells '[[{"value":1}],[{}],[{}],[{}],[{"value":5}]]'` 只写 A1 和 A5。 +> +> 跨多个不连续区域散点写入(如 `D2` + `F7` + `J15`)不属于 `+cells-set` 的能力范围——请用 `+batch-update` 把多次 `set_cell_range` 打包成单次原子请求。 + ### `+cells-set-style` 只改样式,不动 value / formula。10 个 cell_styles 字段拍平为独立 flag,边框走 `--border-styles` JSON。 @@ -361,4 +361,4 @@ lark-cli sheets +csv-put --spreadsheet-token shtXXX --sheet-id "$SID" \ - `Validate`:XOR 公共四件套;`+cells-set` 的 `--cells` 必须能解析为 JSON 二维矩阵且行列数与 `--range` 完全一致;`+cells-set-style` 的样式 flag 至少一个非空(或带 `--border-styles`);`+cells-set-image` 的 `--range` 必须是单 cell(起止 cell 相同);`+csv-put` 的 `--csv` 必须能按 RFC 4180 解析;防爆参数上限校验。 - `DryRun`:输出目标 range + 推断尺寸 + 是否覆盖非空 cell 警告,零网络副作用。 -- `Execute`:写后调用 `+cells-get --ranges <写入区域> --include value,formula` 抽样回读,envelope.meta.verification 给出"预期 vs 实际"对比。 +- `Execute`:写后调用 `+cells-get --range <写入区域> --include value,formula` 抽样回读,envelope.meta.verification 给出"预期 vs 实际"对比。 From 54914e6082fb253b137f9ee10578bb00d2a2558c Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Tue, 19 May 2026 21:01:58 +0800 Subject: [PATCH 018/114] docs(sheets): remove sandbox references and normalize tool names to CLI shortcuts Replace export_sheet_to_sandbox / import_sandbox_to_sheet / doubao_code_interpreter with local-script + batch csv-get/csv-put workflows; unify legacy MCP tool names (set_cell_range, get_range_as_csv, etc.) to CLI shortcut format (+cells-set, +csv-get). --- skills/lark-sheets/SKILL.md | 8 +- .../references/lark-sheets-batch-update.md | 32 ++++---- .../references/lark-sheets-chart.md | 20 ++--- .../lark-sheets-conditional-format.md | 22 +++--- .../references/lark-sheets-core-operations.md | 77 ++++++++++--------- .../references/lark-sheets-filter-view.md | 12 +-- .../references/lark-sheets-filter.md | 14 ++-- .../references/lark-sheets-float-image.md | 12 +-- .../lark-sheets-formula-translation.md | 2 +- .../references/lark-sheets-pivot-table.md | 14 ++-- .../lark-sheets-range-operations.md | 53 ++++--------- .../references/lark-sheets-read-data.md | 37 ++++----- .../references/lark-sheets-search-replace.md | 8 +- .../references/lark-sheets-sheet-structure.md | 24 +++--- .../references/lark-sheets-sparkline.md | 12 +-- .../lark-sheets-visual-standards.md | 14 ++-- .../references/lark-sheets-workbook.md | 26 +++---- .../references/lark-sheets-write-cells.md | 60 +++++++-------- 18 files changed, 209 insertions(+), 238 deletions(-) diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index b4153bde0..ee323e850 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-sheets version: 2.0.0-draft -description: "飞书电子表格:分析、编辑与可视化飞书在线表格。每个能力子域(read / write / chart / pivot / filter ...)有独立 reference 文档,内容与 sheet-ai-skills 对应 skill 完全一致;CLI 实现按子域提供对应 shortcut,详见各 reference。" +description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)等任务时使用。若用户是想按名称或关键词搜索云空间里的表格文件,请改用 lark-doc 的 `docs +search` 先定位资源。仅针对飞书在线电子表格,不适用于本地 Excel 文件。" metadata: requires: bins: ["lark-cli"] @@ -13,11 +13,9 @@ metadata: **CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理。** -飞书电子表格:分析、编辑与可视化飞书在线表格。每个能力子域(read / write / chart / pivot / filter ...)有独立 reference 文档,内容与 sheet-ai-skills 对应 skill 完全一致;CLI 实现按子域提供对应 shortcut,详见各 reference。 - ## References -每个 reference 内容与 `sheet-ai-skills` 中对应 skill 完全一致,按能力子域组织。CLI shortcut / API 路由的实现按这些子域提供,并在对应 reference 中描述。 +本 skill 按能力子域组织,每个子域有独立 reference。先按下表索引定位到目标子域,再进入对应 reference 查 shortcut / 调用细节。 | Reference | 描述 | | --- | --- | @@ -28,7 +26,7 @@ metadata: | [Lark Sheet Sheet Structure](references/lark-sheets-sheet-structure.md) | 管理飞书表格的子表结构与布局。适用场景:查看行高、列宽、隐藏行列、合并单元格等布局信息,以及"插入一行"、"删除这列"、"隐藏行"、"冻结表头"、行列分组(大纲折叠/展开)等操作。行列大纲仅在用户明确提到"行分组"、"列分组"、"大纲"、"outline"时才触发,"按XXX分组"等数据分组场景请使用 lark_sheet_pivot_table。如需在表尾追加数据,应先通过此 skill 插入行,再通过 lark_sheet_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_sheet_float_image);若只需把一块 CSV 纯值批量铺到表格上(不带公式/样式),直接使用 set_range_from_csv 更短更快。追加数据需先通过 lark_sheet_sheet_structure 插入行列。仅针对飞书表格。 | +| [Lark Sheet Write Cells](references/lark-sheets-write-cells.md) | 向飞书表格的指定区域批量写入值、公式、样式、批注或单元格图片。适用场景:填写数据、设置公式、修改格式、添加批注、嵌入单元格图片(如需操作浮动图片,请使用 lark_sheet_float_image);若只需把一块 CSV 纯值批量铺到表格上(不带公式/样式),直接使用 `+csv-put` 更短更快。追加数据需先通过 lark_sheet_sheet_structure 插入行列。仅针对飞书表格。 | | [Lark Sheet Range Operations](references/lark-sheets-range-operations.md) | 对飞书表格中指定区域执行结构性操作(不涉及写入单元格数据值)。适用场景:清除内容或格式("清空"、"删除内容"、"去掉格式")、合并/取消合并单元格、调整行高列宽("加宽列"、"自适应列宽")、移动/复制/填充/排序数据("移动数据"、"复制到"、"自动填充"、"按某列排序")。写入单元格数据请使用 lark_sheet_write_cells。仅针对飞书表格。 | | [Lark Sheet Batch Update](references/lark-sheets-batch-update.md) | 将多个飞书表格写入操作合并为一次批量执行,按顺序依次完成。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。仅针对飞书表格。 | | [Lark Sheet Chart](references/lark-sheets-chart.md) | 管理飞书表格中的图表(柱形图、折线图、饼图、条形图、面积图、散点图、组合图、雷达图等)。当用户需要创建图表、修改图表样式或数据源、查看已有图表配置、删除图表时使用。也适用于用户提到"数据可视化"、"画个图"、"趋势分析"、"对比图"、"占比分析"、"做个图表"等数据可视化相关场景。仅针对飞书表格。 | diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 837b37880..00e6f5c87 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -5,12 +5,12 @@ `+batch-update` 把多次写入打包成单次请求,但每个子操作仍受编辑类任务硬性默认规则约束: 1. **目标 range 必须落在用户授权范围内**:除用户明示要修改的区域外,子操作禁止扩张到无关单元格 / 列 / Sheet。规划 range 时先确认每个子操作的边界。 -2. **批次完成后必须回读校验**:整个 `+batch-update` 执行成功后,用 `+csv-get` 或 `+cells-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末),与 `doubao_code_interpreter` 预先计算的预期值对照。 +2. **批次完成后必须回读校验**:整个 `+batch-update` 执行成功后,用 `+csv-get` 或 `+cells-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末),与 本地脚本 预先计算的预期值对照。 3. **预期条数前置断言**:涉及"批量填充 N 行"或"对 M 个区域分别写入"时,先把 N、M 硬编码进代码,回读后断言实际等于预期;不一致就再发一轮 `+batch-update` 补齐,禁止交付半成品。 ## 使用场景 -写入。批量执行多个写入工具操作。将多个工具调用合并为一次请求,按顺序依次执行。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。注意:不支持嵌套 batch_update。 +写入。批量执行多个写入工具操作。将多个工具调用合并为一次请求,按顺序依次执行。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。注意:不支持嵌套 `+batch-update`。 **⚠️ 何时必须使用 `+batch-update`(硬性要求)**: - 需要对**多个**不同区域执行 `+cells-{merge|unmerge}` 时(如按分组合并多列相同内容) @@ -22,12 +22,12 @@ ## Shortcuts -| MCP tool | CLI shortcut | Risk | 分组 | -| --- | --- | --- | --- | -| `batch_update` | `+batch-update` | high-risk-write | 批量 | -| | `+cells-batch-set-style` | write | 批量 | -| | `+dropdown-update` | write | 对象 | -| | `+dropdown-delete` | high-risk-write | 对象 | +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+batch-update` | high-risk-write | 批量 | +| `+cells-batch-set-style` | write | 批量 | +| `+dropdown-update` | write | 对象 | +| `+dropdown-delete` | high-risk-write | 对象 | ## Flags @@ -37,7 +37,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--operations` | string + File + Stdin(复合 JSON) | 是 | JSON:`{"operations":[{"tool":"set_cell_range","params":{...}}, ...]}`;按数组顺序串行执行 | +| `--operations` | string + File + Stdin(复合 JSON) | 是 | JSON:`{"operations":[{"tool":"`+cells-set`","params":{...}}, ...]}`;按数组顺序串行执行 | ### `+cells-batch-set-style` @@ -88,7 +88,7 @@ _要批量执行的操作列表,按顺序依次执行_ **数组项**(类型 object): - `input` (object) — 对应工具的入参,结构与单独调用该工具时完全一致 -- `tool_name` (string) — 要执行的工具名称,如 "set_cell_range"、"clear_cell_range"、"modify_sheet_structure" 等 +- `tool_name` (string) — 要执行的工具名称,如 "`+cells-set`"、"`+cells-clear`"、"`+dim-{insert|delete|hide|unhide|freeze|group|ungroup}`" 等 ### `+cells-batch-set-style` `--border-styles` @@ -132,14 +132,14 @@ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" -- # ] ``` -> **常见组合:插列 + 写表头 + 整列回填**——一次原子提交,不要拆成 N 次独立调用。批量回填同一列 **只需一次** `set_cell_range`(range 写整列范围、cells 写 N×1 矩阵),不需要逐行循环。 +> **常见组合:插列 + 写表头 + 整列回填**——一次原子提交,不要拆成 N 次独立调用。批量回填同一列 **只需一次** `+cells-set`(range 写整列范围、cells 写 N×1 矩阵),不需要逐行循环。 > > ```jsonc > // 在 C 列前插入新列 → 写表头 C1 → 回填 C2:C100 共 99 行 > [ -> {"tool_name": "modify_sheet_structure", +> {"tool_name": "`+dim-{insert|delete|hide|unhide|freeze|group|ungroup}`", > "input": {"sheet_id": "...", "operation": "insert", "dimension": "column", "start": 3, "end": 4}}, -> {"tool_name": "set_cell_range", +> {"tool_name": "`+cells-set`", > "input": {"sheet_id": "...", "range": "C1:C100", > "cells": [[{"value":"score"}], [{"value":95}], [{"value":87}], /* ... 97 more rows ... */ ]}} > ] @@ -147,7 +147,7 @@ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" -- ### `+cells-batch-set-style` -多 range 应用同一组 style(服务端走 `batch_update` 原子事务): +多 range 应用同一组 style(服务端走 `+batch-update` 原子事务): ```bash # 表头行 + 汇总行同时刷成蓝底白字 @@ -158,6 +158,6 @@ lark-cli sheets +cells-batch-set-style --url "..." \ ### Validate / DryRun / Execute 约束 -- `Validate`:`+batch-update` 的 `--operations` 必须合法 JSON,且为非空数组;逐个子操作 `tool_name` / `input` 字段必填校验;**禁止嵌套 batch_update**。`+cells-batch-set-style` 的 `--ranges` 必须 JSON 数组、每项带 sheet 前缀;样式 flag 至少一个非空(或带 `--border-styles`)。 +- `Validate`:`+batch-update` 的 `--operations` 必须合法 JSON,且为非空数组;逐个子操作 `tool_name` / `input` 字段必填校验;**禁止嵌套 `+batch-update`**。`+cells-batch-set-style` 的 `--ranges` 必须 JSON 数组、每项带 sheet 前缀;样式 flag 至少一个非空(或带 `--border-styles`)。 - `DryRun`:按顺序输出每个子操作的目标 API + 请求 body 模板;首个失败则整批 fail-fast(不实际执行任何后续)。 -- `Execute`:按声明顺序串行执行;任一子操作失败立即中断并回滚到该子操作前状态(具体回滚能力取决于子操作类型,沿用 MCP `batch_update` 的语义)。 +- `Execute`:按声明顺序串行执行;任一子操作失败立即中断并回滚到该子操作前状态(具体回滚能力取决于子操作类型,沿用 MCP `+batch-update` 的语义)。 diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md index 51692473b..ee3234234 100644 --- a/skills/lark-sheets/references/lark-sheets-chart.md +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -2,7 +2,7 @@ ## 真对象硬约束 -当用户要求"画个图 / 数据可视化 / 趋势图 / 对比图 / 占比图"时,**必须**通过 `+chart-{create|update|delete}` 创建真实的图表对象。**禁止**用 doubao_code_interpreter 调 matplotlib / seaborn 生成图片再插入到表格代替——静态图片无法随源数据更新,且失去交互能力。判断标准:交付后 `+chart-list` 必须能返回该对象。 +当用户要求"画个图 / 数据可视化 / 趋势图 / 对比图 / 占比图"时,**必须**通过 `+chart-{create|update|delete}` 创建真实的图表对象。**禁止**用 本地脚本 调 matplotlib / seaborn 生成图片再插入到表格代替——静态图片无法随源数据更新,且失去交互能力。判断标准:交付后 `+chart-list` 必须能返回该对象。 ## 使用场景 @@ -34,7 +34,7 @@ - **默认情况(inline 模式)**:`refs` 范围**应包含表头行**(首行/首列即系列名),且范围要精确覆盖目标数据,不要多选或少选。 - **合并标题行要跳过**:如果表格在表头上方存在合并的标题行(如"员工统计表"横跨多列的大标题),`refs` 必须跳过标题行、从真正的列标题行开始。例如表头在第 3 行、数据在第 4-20 行,则 `refs` 应为 `A3:G20` 而非 `A1:G20`。包含合并标题行会导致列名识别错误、表头被当作数据参与聚合计算。 - **数据与表头分离时必须用 detached 模式**:当 `refs` 只覆盖完整数据的一个子集(按筛选/分组只画其中一段),而真正的语义表头在该子集之外时,**必须**设置 `data.headerMode='detached'`:refs 仅传纯数据范围,维度名/系列名通过 `dim1.serie.nameRef` / `dim2.series[].nameRef` 指向真正的表头单元格。详见下文"硬性规则:数据与表头分离场景必须使用 detached 模式"。 -- **axes[].label 不接受 `format` / `number_format` 字段**:想给坐标轴数值加千分位、百分号等格式化时,不要在 `axes[i].label` 里传 `format` 或 `number_format`(schema 未定义,会报 `unexpected property "format" is not defined in schema`)。数值格式化统一在源数据单元格的 `cell_styles.number_format` 里设置(写 set_cell_range 时),图表会沿用单元格格式。 +- **axes[].label 不接受 `format` / `number_format` 字段**:想给坐标轴数值加千分位、百分号等格式化时,不要在 `axes[i].label` 里传 `format` 或 `number_format`(schema 未定义,会报 `unexpected property "format" is not defined in schema`)。数值格式化统一在源数据单元格的 `cell_styles.number_format` 里设置(写 `+cells-set` 时),图表会沿用单元格格式。 - **创建后必须验证**:图表创建后必须调用 `+chart-list` 验证配置是否正确 > **⚠️ 硬性规则:当用户通过列标题名称(而非列索引)指定横轴/纵轴系列时,必须先读取表格首行(表头)来确定列名与列索引的对应关系,再设置 `dim1`/`dim2` 的 `index`。** @@ -71,10 +71,10 @@ - **饼图**会多一个"总计"扇区占 33%+,真实类别的比例完全失真 **正确流程**: -1. `+pivot-create` 返回 `sheet_id` + `pivot_table_id` +1. `+pivot-create create` 返回 `sheet_id` + `pivot_table_id` 2. 调 `+csv-get(sheet_id, 'A1:E30')` 或 `+pivot-list` 读 pivot 产物的**实际数据范围** 3. 识别并排除"总计"/"小计"行(通常最后一行;嵌套 pivot 还要排除中间层小计) -4. `+chart-create` 时 `data.refs` 精确到数据行(如 pivot 占 A1:D9、总计在 row9 → chart 用 `A1:D8`) +4. `+chart-create create` 时 `data.refs` 精确到数据行(如 pivot 占 A1:D9、总计在 row9 → chart 用 `A1:D8`) 详细规则见 `lark-sheets-pivot-table` skill 第 5 节"pivot → chart 组合场景"。 @@ -96,12 +96,12 @@ ## Shortcuts -| MCP tool | CLI shortcut | Risk | 分组 | -| --- | --- | --- | --- | -| `get_chart_objects` | `+chart-list` | read | 对象 | -| `manage_chart_object` | `+chart-create` | write | 对象 | -| | `+chart-update` | write | 对象 | -| | `+chart-delete` | high-risk-write | 对象 | +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+chart-list` | read | 对象 | +| `+chart-create` | write | 对象 | +| `+chart-update` | write | 对象 | +| `+chart-delete` | high-risk-write | 对象 | ## Flags diff --git a/skills/lark-sheets/references/lark-sheets-conditional-format.md b/skills/lark-sheets/references/lark-sheets-conditional-format.md index 7db9a123b..ea7e44c60 100644 --- a/skills/lark-sheets/references/lark-sheets-conditional-format.md +++ b/skills/lark-sheets/references/lark-sheets-conditional-format.md @@ -14,7 +14,7 @@ **判断标准**:交付后 `+cond-format-list` 必须能返回该规则;否则视为违规。 -**大数据量加分项**:当数据量 > 1000 行时,条件格式是首选——它由飞书自身渲染,不会触发 doubao_code_interpreter 50 秒超时(同 R8)。 +**大数据量加分项**:当数据量 > 1000 行时,条件格式是首选——它由飞书自身渲染,不会触发 本地脚本 50 秒超时(同 R8)。 ## 使用场景 @@ -44,11 +44,11 @@ **正确做法(两步走)**: ``` -Step 1: set_cell_range 在新列写判断公式(形成"是/否"或布尔辅助列) +Step 1: `+cells-set` 在新列写判断公式(形成"是/否"或布尔辅助列) range="H2", cells=[[{formula: "=IF(A2>B2, \"是\", \"否\")"}]], copy_to_range="H2:H100" Step 2: 基于辅助列值做条件格式(用 cellIs 或引用辅助列的 expression) - manage_conditional_format_object create + `+cond-{format-create|format-update|format-delete}` create rule_type: "expression" ranges: ["A2:H100"] // 整行高亮 attrs: [{formula: ["=$H2=\"是\""]}] // 引用辅助列 @@ -58,7 +58,7 @@ Step 2: 基于辅助列值做条件格式(用 cellIs 或引用辅助列的 exp **错误做法(一步走绕过辅助列)**: ``` -manage_conditional_format_object create +`+cond-{format-create|format-update|format-delete}` create rule_type: "expression" ranges: ["2:145"] attrs: [{formula: ["=$O2>$H2"]}] ← 虽然逻辑等价,但产物里缺辅助列 → 扣配置需求分 @@ -68,16 +68,16 @@ manage_conditional_format_object create `expression` 单独使用的场景是:用户**没有**明确要求辅助列、只要"标红符合条件的行"时。 -⚠️ **创建条件格式前必须读数据行确认列对应**:仅读首行表头(`get_range_as_csv range="A1:Z1"`)不够——如果表头语义含糊(比如"时间"、"日期"这种多列同义词),formula 里引用的列字母可能张冠李戴。必须再读 3-5 行**数据样本**(如 `range="A2:Z6"`)确认:①列名对应的实际值;②字段含义匹配用户描述;③数据类型是日期/数字/文本。特别是比较类条件格式(`=$A2>$B2` 这种),列字母选错整条规则就废了。 +⚠️ **创建条件格式前必须读数据行确认列对应**:仅读首行表头(`+csv-get range="A1:Z1"`)不够——如果表头语义含糊(比如"时间"、"日期"这种多列同义词),formula 里引用的列字母可能张冠李戴。必须再读 3-5 行**数据样本**(如 `range="A2:Z6"`)确认:①列名对应的实际值;②字段含义匹配用户描述;③数据类型是日期/数字/文本。特别是比较类条件格式(`=$A2>$B2` 这种),列字母选错整条规则就废了。 ## Shortcuts -| MCP tool | CLI shortcut | Risk | 分组 | -| --- | --- | --- | --- | -| `get_conditional_format_objects` | `+cond-format-list` | read | 对象 | -| `manage_conditional_format_object` | `+cond-format-create` | write | 对象 | -| | `+cond-format-update` | write | 对象 | -| | `+cond-format-delete` | high-risk-write | 对象 | +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+cond-format-list` | read | 对象 | +| `+cond-format-create` | write | 对象 | +| `+cond-format-update` | write | 对象 | +| `+cond-format-delete` | high-risk-write | 对象 | ## Flags diff --git a/skills/lark-sheets/references/lark-sheets-core-operations.md b/skills/lark-sheets/references/lark-sheets-core-operations.md index e0fca5442..188887342 100644 --- a/skills/lark-sheets/references/lark-sheets-core-operations.md +++ b/skills/lark-sheets/references/lark-sheets-core-operations.md @@ -10,7 +10,7 @@ 1. **R1 最小改动**:除用户明示要修改的单元格 / 列外,原表其它单元格、行列结构、Sheet 名称、合并区域、格式必须 1:1 保持。中间结果 / 标注列优先放在原数据列**右侧**;当中间结果会与原数据混淆,或需要承载结构化对象(透视表 / 图表)时可**新建空白 Sheet**。**禁止**擅自删除 / 重命名 / 隐藏 / 移动**已存在**的原 Sheet(新建是允许的,节制使用即可)。 2. **R2 真实写回**:编辑任务的最终交付必须是对在线表格的真实写入并回读校验。**严禁**只在文本里描述"已完成 X" 没有任何写入;**严禁**用普通公式 / 文本汇总假装"透视表 / 筛选 / 图表 / 条件格式 / 迷你图"等结构化对象;**严禁**只输出 `{"type": "LarkExcelCard", "refs": [...]}` 形式的引用作为交付——LarkExcelCard 引用 ≠ 真实写入,必须有对应的 `+cells-set` / `manage_*_object` 工具调用并能用 `get_*` 工具回读到改动结果。 -3. **R3 计算复现**:涉及计算、排序、筛选、聚合、批量数据提取的任务,必须用 `doubao_code_interpreter` 独立复现一份预期值,与回读结果对照通过后再交付。设计公式 / 筛选条件前先 sample 至少 50 行识别该列所有值类型变体(纯数值 / 公式文本 / 多种日期格式 / 空值),不能只看前 10 行。 +3. **R3 计算复现**:涉及计算、排序、筛选、聚合、批量数据提取的任务,必须用 本地脚本 独立复现一份预期值,与回读结果对照通过后再交付。设计公式 / 筛选条件前先 sample 至少 50 行识别该列所有值类型变体(纯数值 / 公式文本 / 多种日期格式 / 空值),不能只看前 10 行。 4. **R4 处理完整性**:全量逐条处理类任务(翻译 / 打标 / 删除指定行 / 批量公式落地 / 按条件保留),落地前先把"预期处理条数"硬编码进代码,处理完后 `assert actual == expected`。**严禁**输出"已完成前 N 条,剩余将继续"这类半成品文案。 5. **R5 指令语义还原**:"按 X 排序" / "筛选 X" / "把 X 删除" 落地前先回答:① X 在哪一列?该列实际值类型是什么?② 期望结果集大小是多少?答完再动手;禁止直接对混合文本列做字符串排序 / 筛选。 6. **R6 任务拆解为可验证 checklist**:用户的指令落地前,**必须**先拆成所有"独立可验证子要点",每点对应一个 `assert`,全部通过才交付: @@ -21,12 +21,12 @@ - **单一指令隐含多个失败模式**(如"用公式算面积"隐含"提取数值 / 乘以数量 / 单位转换 / 公式而非硬编码 / 不超时"等多个验收点)→ 每点独立 assert 只完成第一个要点就交付(典型如:按部门只排一级、删 3 行只删 1 行、兼容日期只兼容 YYYYMM)属于违规。 7. **R7 公式模式延续**:扩展 / 续写 / 新增行列时,**必须**先用 `+cells-get` 读原数据区域的 `formula` 字段,识别公式模式,新行新列必须延续相同模式。**禁止**把原表 `=C3/B3` / `=SUM(B3:B9)` 模式在新行替换为硬编码常数(如 `0.85` / `50`)这种破坏数据驱动性的写法。**用户口头操作("分列 / 排序 / 提取 / 求和")也必须落地为公式或原生工具**(SORT 公式 / 分列 / `TEXTBEFORE` / `MID` / `+filter-{create|update|delete}` / `+pivot-{create|update|delete}` 等),不能只写静态结果——否则用户改源数据时结果不再联动,等同破坏了表格的数据驱动性。 -8. **R8 大数据量超时降级走原生工具**:当任务涉及 **> 1000 行**数据 / 预估 doubao_code_interpreter 超 50 秒时,**禁止**继续用代码逐行处理,**必须**改走原生工具: +8. **R8 大数据量超时降级走原生工具**:当任务涉及 **> 1000 行**数据 / 预估 本地脚本 超 50 秒时,**禁止**继续用代码逐行处理,**必须**改走原生工具: | 任务类型 | 必须使用 | 禁止 | |---|---|---| - | 重复检测 / 条件高亮 / 颜色标记 | `+cond-{format-create|format-update|format-delete}` | doubao_code_interpreter 逐行 + set_cell_range 静态背景色 | - | 大批量行公式填充 | 模板公式 + `copy_to_range "X:X"` | doubao_code_interpreter 计算每行 + 静态写入 | + | 重复检测 / 条件高亮 / 颜色标记 | `+cond-{format-create|format-update|format-delete}` | 本地脚本 逐行 + `+cells-set` 静态背景色 | + | 大批量行公式填充 | 模板公式 + `copy_to_range "X:X"` | 本地脚本 计算每行 + 静态写入 | | 大数据筛选 | `+filter-{create|update|delete}` 或 `+pivot-{create|update|delete}` | 复制到新 sheet 后覆盖 | | 大批量数据 set | 分批 `+cells-set`,每批 ≤ 100 行 | 一次写 1000+ 行 | | 大批量翻译 / NLP | 分 30 行/批,每批后立即写回 | 一次性处理全表后才写回 | @@ -41,8 +41,8 @@ - 本 skill(`lark-sheets-core-operations`)+ `lark-sheets-workbook` 是几乎每次都需要的基础 skill,读完后应立即进入操作,不要在读取阶段停留过久 2. **先了解结构再操作**:飞书表格的行列数、冻结位置、合并区域等信息不可猜测,猜错会导致写入越界或覆盖数据。操作前先调用 `+workbook-info` 获取子表概览;然后根据任务类型选择读取方式(三个读取工具均在 `lark-sheets-read-data` skill 中): - - **批量填充/补齐/完善/修正多行**类任务 → **默认走 `export_sheet_to_sandbox` + Python**(路径 A),用 `df.info()` 的 non-null 数和 `len(df)` 确定真实数据行数。**禁止**对这类任务直接用 `+csv-get` 探 10 行就进入写入——实测会漏写表尾多行(高频致命错误)。 - - 数据分析/清洗/可视化/大数据集 → 同上 `export_sheet_to_sandbox` +- **批量填充/补齐/完善/修正多行**类任务 → **必须先用 `+csv-get` 翻页读全(关注 `has_more` / `current_region`),或导出到本地用 `pandas` 等本地脚本确定真实数据行数**(路径 A)。**禁止**对这类任务直接用 `+csv-get` 探 10 行就进入写入——实测会漏写表尾多行(高频致命错误)。 + - 数据分析/清洗/可视化/大数据集 → 同上路径 A - 快速查看少量数据或简单问答(只读、不回写) → `+csv-get` 读取到对话上下文 - 需要公式/样式/批注 → `+cells-get` - **续写/扩展已有内容** → 必须用 `+cells-get` 读取源区块样式 + `+sheet-info` 读取行高和合并信息(见硬性规则 12) @@ -59,7 +59,7 @@ - `current_region`:连续数据矩形(Excel Ctrl+Shift+\*);若末行 > 请求 range 末行说明数据还没读全 - `has_more`:因 `max_bytes` 被截断时为 `true`,按 `actual_range` 末行+1 分页续读,或改走路径 A - **`current_region` 远大于可灌入上下文的量(如几百行)时**:切换到路径 A(`export_sheet_to_sandbox` + Python),不要用路径 B 翻页硬塞。 +**`current_region` 远大于可灌入上下文的量(如几百行)时**:切换到路径 A(分批 `+csv-get` 导出到本地 + 本地脚本处理),不要用路径 B 翻页硬塞到上下文。 **禁止仅凭首次探查范围就进入批量写入**——不管什么形态,都要先由形态判断决定是否补读纵向表头,再用 `current_region` / `has_more` 兜底确认没漏行。 @@ -77,7 +77,7 @@ - 需要先插入行列再写入数据 → 将 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` + `+cells-set` 放进一个 `+batch-update` - 当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求 -8. **结构操作用专用工具**:插入/删除行列用 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}`(一次操作可处理数千行),不要用 `+cells-set` 逐行搬数据——后者既慢又容易出错。用户给多步骤请求时,每个步骤都要执行,执行后重新验证。**`+dim-insert` 只继承相邻行的值/公式/边框,不继承 `row_height`**:插入后新行行高会回落到默认值(约 22px),若原行是高行高(容纳多行自动换行文本),新行长文本会被裁切(显示不全,末尾字符被截断)。**在中间插入行并填入文本前,必须先用 `+sheet-info` 读取相邻原行的 `row_height`,再用 `+batch-update` 合并 `+rows-resize / +cols-resize` 把同一高度应用到新行**——"在中间插入行填数据"本质等价于"续写已有内容",必须走硬性规则 12 的同一套样式继承流程(行高、边框、合并、对齐)。 +8. **结构操作用专用工具**:插入/删除行列用 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}`(一次操作可处理数千行),不要用 `+cells-set` 逐行搬数据——后者既慢又容易出错。用户给多步骤请求时,每个步骤都要执行,执行后重新验证。**`+dim-insert insert` 只继承相邻行的值/公式/边框,不继承 `row_height`**:插入后新行行高会回落到默认值(约 22px),若原行是高行高(容纳多行自动换行文本),新行长文本会被裁切(显示不全,末尾字符被截断)。**在中间插入行并填入文本前,必须先用 `+sheet-info` 读取相邻原行的 `row_height`,再用 `+batch-update` 合并 `+rows-resize / +cols-resize` 把同一高度应用到新行**——"在中间插入行填数据"本质等价于"续写已有内容",必须走硬性规则 12 的同一套样式继承流程(行高、边框、合并、对齐)。 9. **写入前精确定位表头和数据区域**:在执行任何写操作之前,必须先通过读取数据确认: - 表头在哪一行(不要假设表头一定在第 1 行,可能存在标题行、空行等) @@ -89,7 +89,7 @@ 10. **公式填充必须用 `copy_to_range`,禁止逐行写入**:当同一公式需要向下填充到多行时,必须先用 `+cells-set` 在第一行写入模板公式,再用 `copy_to_range` 填充整列(如 `"copy_to_range": "H2:H100"` 或 `"copy_to_range": "H:H"`)。**禁止**对每一行单独调用 `+cells-set` 写入相同结构的公式——这会浪费大量调用轮次。 -11. **分组汇总必须用透视表**:当用户说"按XX统计YY"、"分组汇总"、"各部门/地区的数量/金额"、"汇总每个XX的YY"时,必须使用 `+pivot-{create|update|delete}` 创建透视表(推荐省略 sheet_id 自动新建子表)。禁止用 SUMIF/COUNTIF 公式或 doubao_code_interpreter 代码替代——后者会导致统计结果覆盖原表数据。 +11. **分组汇总必须用透视表**:当用户说"按XX统计YY"、"分组汇总"、"各部门/地区的数量/金额"、"汇总每个XX的YY"时,必须使用 `+pivot-{create|update|delete}` 创建透视表(推荐省略 sheet_id 自动新建子表)。禁止用 SUMIF/COUNTIF 公式或 本地脚本 代码替代——后者会导致统计结果覆盖原表数据。 12. **续写/扩展已有内容时必须继承样式(高频致命错误)**:当用户要求"按已有格式续写"、"继续填充"、"复制区块"、"增加到第N周/月"等扩展任务时,**禁止只用 `+csv-get` 读值后只写值**。必须按以下顺序执行: - 用 `+cells-get` 读取源区块的 `cell_styles` 和 `border_styles` @@ -103,7 +103,7 @@ - **Step 2 — 读表头和原数据行的完整样式**:用 `+cells-get` 读原表头行(如 row2/row3)和数据行(row4)的 `cell_styles` **和 `border_styles`**,记录字体/加粗/对齐/**边框**/数字格式等。 - **Step 3 — 新列 cells 必须同时带 `cell_styles` + `border_styles`**:写新列时 cells 里的每个对象都要完整复制原表样式(包括边框),不能只传 font_size / alignment 就算"样式一致"。 - **Step 4 — 列宽对齐**:用 `+batch-update` 合并 `+rows-resize / +cols-resize` 把新列列宽与原数据列保持一致。 - - 典型反模式:AI 只在 batch_update 里放 3 个 `+cells-set`(表头 + 空列样式 + 公式列),完全跳过 Step 1 的合并扩展 和 Step 2-3 的边框复制 → row1 不跟着变宽、新列无边框,用户打开产物感受"新列被孤立在原表外"。 + - 典型反模式:AI 只在 `+batch-update` 里放 3 个 `+cells-set`(表头 + 空列样式 + 公式列),完全跳过 Step 1 的合并扩展 和 Step 2-3 的边框复制 → row1 不跟着变宽、新列无边框,用户打开产物感受"新列被孤立在原表外"。 ## 推荐工作流程 @@ -113,12 +113,12 @@ | 用户需求语义 | 强制路径 | 写入范围默认值 | |------------|---------|-------------| -| **"完善 / 补齐 / 填空 / 修正所有 XX"** | **路径 A(export_sheet_to_sandbox + Python)** | **覆盖所有对应类别的完整数据行**——不以用户 `` 圈选为准(圈选通常只是光标位置) | +| **"完善 / 补齐 / 填空 / 修正所有 XX"** | **路径 A(导出 + 本地处理 + 分批回写)** | **覆盖所有对应类别的完整数据行**——不以用户 `` 圈选为准(圈选通常只是光标位置) | | "加一列 / 加 N 行 / 扩展到第 X 周" 等**扩展**类 | 路径 D(参见硬性规则 12/13) | 按用户指定或选区末行 | | "查一下 / 看看 / 统计 / 汇总" 等**只读**类 | 路径 B (`+csv-get`) | n/a | | 其他复杂任务 | 按任务类型在下方 A/B/C/D 路径中选 | — | -**【高频致命错误 绝对禁止】** 对"完善 / 补齐 / 填空"类任务用路径 B 探 10 行就进入写入——`get_range_as_csv A1:Z10` → `set_cell_range ?3:?10` 的模式实测会漏写表尾多行。 +**【高频致命错误 绝对禁止】** 对"完善 / 补齐 / 填空"类任务用路径 B 探 10 行就进入写入——`+csv-get A1:Z10` → `+cells-set ?3:?10` 的模式实测会漏写表尾多行。 1. 选择相关 skill 根据任务组合相关 skill。涉及样式、美化、补齐边框和格式时,参考 `lark-sheets-visual-standards`。不要只读完本 skill 就直接调用具体工具;应继续读取对应的工具 skill,包括 `lark-sheets-workbook`、`lark-sheets-write-cells`、`lark-sheets-filter`、`lark-sheets-pivot-table` 等,再执行工具调用。 @@ -128,10 +128,14 @@ 3. 读取数据(按第 0 步锁定的路径) -**路径 A:数据分析/清洗/可视化/大数据集/"完善 / 补齐 / 填空 / 修正所有 XX"** → 用 `export_sheet_to_sandbox` 导出到沙箱,再写 Python 代码: +**路径 A:数据分析/清洗/可视化/大数据集/"完善 / 补齐 / 填空 / 修正所有 XX"** → 分批 `+csv-get` 把数据导出到本地文件,再用本地脚本(如 pandas)处理: +```bash +# 分批导出(按 has_more 翻页拼接到本地 data.csv,直到读完) +lark-cli sheets +csv-get --url "$URL" --range "A:Z" --max-rows 500 > data.csv +``` ```python import pandas as pd -df = pd.read_csv(file_path) # file_path 从 export_sheet_to_sandbox 返回值获取 +df = pd.read_csv('data.csv') print(df.info()) # 必做:获得「实际数据行数」(non-null count) 和列类型 print(df.head(10)) # 必做:横向——确认表头行 + 前 10 行数据样貌 @@ -179,8 +183,8 @@ print(df.head(10)) # 必做:横向——确认表头行 + | 读取路径 | 真实数据末行的字段 | 触发再读的条件 | |---------|-------------------|--------------| | 路径 A(Python/export,强制路径) | `df.info()` 的 non-null 最大值 **或** `len(df)`;交叉表场景还要看 `df.iloc[:, :2].to_string()` 的行数 | non-null > head 行数 → 执行 `print(df.to_string())` 或 `print(df.iloc[N:].to_string())` | -| 路径 B(get_range_as_csv,仅只读场景) | 响应里的 `current_region` 末行;交叉表还要看纵向表头列 `A:A` / `A:C` 读到底的最后一行 | `current_region` 末行 > 首次请求 range 的末行 → 再调一次 `+csv-get` 覆盖全区 | -| 路径 C(get_cell_ranges) | 响应里的 `cell_range.end_row` | `end_row` > 首次读 range 的末行 → 再读一次 | +| 路径 B(`+csv-get`,仅只读场景) | 响应里的 `current_region` 末行;交叉表还要看纵向表头列 `A:A` / `A:C` 读到底的最后一行 | `current_region` 末行 > 首次请求 range 的末行 → 再调一次 `+csv-get` 覆盖全区 | +| 路径 C(`+cells-get`) | 响应里的 `cell_range.end_row` | `end_row` > 首次读 range 的末行 → 再读一次 | **共同规则(强制)**: - **禁止**在首次读 range 写死 `10` / `20` / `100` 等经验数字就直接进入写入。 @@ -206,7 +210,7 @@ print(df.head(10)) # 必做:横向——确认表头行 + | 用户需求 | 必须用的原生工具 | 禁止用代码替代 | |---------|----------------|--------------| -| 按XX统计YY、分组汇总 | `+pivot-{create|update|delete}` | pandas groupby → set_cell_range | +| 按XX统计YY、分组汇总 | `+pivot-{create|update|delete}` | pandas groupby → `+cells-set` | | 求和/计数/平均/占比 | 公式(SUM/COUNT/AVERAGE) | Python 计算 → 写静态值 | | 画图表、可视化 | `+chart-{create|update|delete}` | matplotlib/seaborn 画图 | | 条件高亮、色阶 | `+cond-{format-create|format-update|format-delete}` | 逐单元格设样式 | @@ -215,16 +219,15 @@ print(df.head(10)) # 必做:横向——确认表头行 + | 查找匹配 | 公式(VLOOKUP/INDEX+MATCH) | pandas merge → 写静态值 | **只有以下场景才用代码**:多步数据清洗(正则+拆分+合并流水线)、统计建模、公式试错 3 次仍失败时的降级方案。代码计算结果写回表格时: - - **数据已经在沙箱里(由 Python 清洗/聚合/筛选得到的大块纯值)** → 优先 `import_sandbox_to_sheet`(只传 file_uri,CSV 不进对话上下文,最省 token) - - **模型手里就有 CSV 文本(小规模手动构造,或从 `+csv-get` 取到后简单加工)** → `+csv-put`(直接传 CSV 文本 + start_cell) +- **批量 CSV 纯值回写**(本地脚本清洗 / 聚合 / 筛选 / 合并的结果)→ `+csv-put`(直接传 CSV 文本 + start_cell,必要时自动扩容行列) - **少量数据或需要公式/样式** → `+cells-set` - **能用飞书公式表达的** → 写飞书公式(源数据变化时自动重算) 7. 写入与修改 -- 范围写入使用 `lark-sheets-write-cells`。`+cells-set` 的 `range` 必须落在当前已有行列范围内,`cells` 二维数组必须与 `range` 严格同维度;若是大块 CSV 纯值回写,沙箱路径优先用 `import_sandbox_to_sheet`,已有 CSV 文本用 `+csv-put`(两者必要时自动扩容)。 +- 范围写入使用 `lark-sheets-write-cells`。`+cells-set` 的 `range` 必须落在当前已有行列范围内,`cells` 二维数组必须与 `range` 严格同维度;若是大块 CSV 纯值回写,优先用 `+csv-put`(直接传 CSV 文本,必要时自动扩容)。 - 如需在表尾追加数据,先插入行或列,再执行写入。 -- **多步写入优先用 `+batch-update`**(见硬性规则 7):将多个写入操作合并为一次 `+batch-update` 调用,减少调用轮次。尤其是多次 merge_cells、多次 resize_range、多次 set_cell_range 场景,必须合并。 -- **公式填充必须用 `copy_to_range`(见硬性规则 10)**:先写一行模板公式,再用 `copy_to_range` 一次填充整列或整区域。示例:在 H2 写 `=SUM(B2:G2)` 后,设 `copy_to_range: "H2:H100"` 即可填充 99 行。**禁止逐行调用 set_cell_range 写入相同结构的公式。** +- **多步写入优先用 `+batch-update`**(见硬性规则 7):将多个写入操作合并为一次 `+batch-update` 调用,减少调用轮次。尤其是多次 `+cells-{merge|unmerge}`、多次 `+rows-resize / +cols-resize`、多次 `+cells-set` 场景,必须合并。 +- **公式填充必须用 `copy_to_range`(见硬性规则 10)**:先写一行模板公式,再用 `copy_to_range` 一次填充整列或整区域。示例:在 H2 写 `=SUM(B2:G2)` 后,设 `copy_to_range: "H2:H100"` 即可填充 99 行。**禁止逐行调用 `+cells-set` 写入相同结构的公式。** - 对整行/整列统一设置值、公式、格式或批注时,优先写一个模板单元格,再用 `copy_to_range` 扩展到 `1:1`、`J:J`、`A:A` 等目标范围。 - 当用户请求“宽一点 / 高一点 / 和其他行一样高 / 和其他列一样宽”时,先读取相邻可见行列的当前尺寸,再决定使用精确尺寸、`standard` 或 `auto`,不要随意猜测数值。 - 对图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象,按各自 skill 的“先读后改后验证”工作流执行。 @@ -291,7 +294,7 @@ print(df.head(10)) # 必做:横向——确认表头行 + - 多步骤请求漏做:若用户要求”先重命名,再新建”,两个动作都必须执行 - **表头定位不精确导致写入全偏(高频致命错误)**:不要假设表头在第 1 行。很多表格有标题行、说明行或空行,实际表头可能在第 2、3 行甚至更后。写入公式或数据前,必须先读取前几行确认表头行号和各列实际含义,再基于确认后的行列号构造写入 range - **参数冗余**:只需修改 10 个单元格时,不要把全表重写一遍;`+cells-set` 的 range 和 cells 应精确覆盖变更区域 -- **表头理解路径不对**:要了解表格结构和字段含义时,优先通过 `export_sheet_to_sandbox` 导出到沙箱后用 `df.info()` + `df.head()` 查看;简单场景也可用 `+csv-get` 读取前 5-10 行。不要一行行用 `+cells-get` 逐行读取,也不要依赖 `+cells-search` 去”猜”字段名 +- **表头理解路径不对**:要了解表格结构和字段含义时,先用 `+csv-get` 读取前 5-10 行查看表头与字段格式;大表需要全量统计才考虑分批导出后用本地脚本(如 `df.info()` + `df.head()`)分析。不要一行行用 `+cells-get` 逐行读取,也不要依赖 `+cells-search` 去”猜”字段名 - **隐藏行列导致定位偏移**:`+csv-get` 默认 `skip_hidden=false`(返回完整数据含隐藏行列)。如需只看可见数据,显式设 `skip_hidden=true`,但注意跳过隐藏行后返回数据的行序号与实际行号不对应 - **写入前读取范围不充分**:涉及批量写入或修改时,必须先读取足够的数据范围。如果表格有 100 行而只读了 20 行,后续操作会漏掉剩余数据。使用 `+workbook-info` 获取行数后,根据实际行数决定读取范围。注意:了解表头和数据结构只需前几行,但批量操作前需要掌握完整数据 - **`+cells-search` 不是万能的**:用户说”汇总金额”是一个操作动作(求和),不是要搜索”汇总金额”这个文本。只有当确实需要定位某个文本值的位置时才用 `+cells-search` @@ -303,23 +306,23 @@ print(df.head(10)) # 必做:横向——确认表头行 + ## Skill Set -通过 `read_skill` 读取对应 Skill 获取详细用法。涉及样式/美化时,同时参考 `lark-sheets-visual-standards`。 +通过 Read 工具读取对应 reference 获取详细用法。涉及样式/美化时,同时参考 `lark-sheets-visual-standards`。 -| 类别 | skill | 一句话用途 | 包含工具 | -|------|------|-----------|---------| -| 读写 | `lark-sheets-workbook` | 获取工作簿全局结构(首步必调);增删/重命名/移动/复制子表 | `+workbook-info`、`+sheet-{create|delete|rename|move|copy|hide|unhide|set-tab-color}` | -| 读取 | `lark-sheets-read-data` | 读取单元格数据:导出到沙箱供 Python 分析、CSV 格式快速查看、含公式/样式/批注的详细信息 | `export_sheet_to_sandbox`(数据分析首选)、`+csv-get`(快速查看)、`+cells-get`(公式/样式/批注) | -| 读写 | `lark-sheets-sheet-structure` | 获取子表行列布局;增删/隐藏/冻结/分组行列 | `+sheet-info`、`+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` | +| 类别 | reference | 一句话用途 | 主要 shortcut | +|------|-----------|-----------|--------------| +| 读写 | `lark-sheets-workbook` | 获取工作簿全局结构(首步必调);增删/重命名/移动/复制子表 | `+workbook-info`、`+sheet-{create\|delete\|rename\|move\|copy}` | +| 读取 | `lark-sheets-read-data` | 读取单元格数据:CSV 快速查看、含公式/样式/批注的完整信息 | `+csv-get`(快速查看与分批导出)、`+cells-get`(公式/样式/批注) | +| 读写 | `lark-sheets-sheet-structure` | 获取子表行列布局;增删/隐藏/冻结/分组行列 | `+sheet-info`、`+dim-{insert\|delete\|hide\|unhide\|freeze\|group\|ungroup}` | | 读写 | `lark-sheets-search-replace` | 按关键字搜索定位单元格;查找并替换文本 | `+cells-search`、`+cells-replace` | -| 写入 | `lark-sheets-write-cells` | 向指定区域写入值/公式/样式/批注,或批量导入 CSV 纯值(沙箱路径 / 已有 CSV 文本两条路) | `+cells-set`(精确控制)、`import_sandbox_to_sheet`(沙箱结果回写)、`+csv-put`(已有 CSV 文本直接铺) | -| 写入 | `lark-sheets-range-operations` | 清除内容、合并单元格、调整行列尺寸、排序、移动/复制区域(支持格式刷:仅复制值/公式/格式) | `+cells-clear`、`+cells-{merge|unmerge}`、`+rows-resize / +cols-resize`、`+range-{move|copy|fill|sort}` | +| 写入 | `lark-sheets-write-cells` | 向指定区域写入值/公式/样式/批注,或批量灌入 CSV 纯值 | `+csv-put`(CSV 文本直接铺)、`+cells-set`(精确控制)、`+cells-set-style` / `+cells-set-image`(兄弟拆分) | +| 写入 | `lark-sheets-range-operations` | 清除内容、合并单元格、调整行列尺寸、排序、移动/复制区域 | `+cells-clear`、`+cells-{merge\|unmerge}`、`+rows-resize` / `+cols-resize`、`+range-{move\|copy\|fill\|sort}` | | 写入 | `lark-sheets-batch-update` | 将多个写入操作合并为单次请求,减少调用次数 | `+batch-update` | -| 对象 | `lark-sheets-chart` | 查询、创建、更新或删除图表 | `+chart-list`、`+chart-{create|update|delete}` | -| 对象 | `lark-sheets-pivot-table` | 查询、创建、更新或删除数据透视表 | `+pivot-list`、`+pivot-{create|update|delete}` | -| 对象 | `lark-sheets-conditional-format` | 查询、创建、更新或删除条件格式规则 | `+cond-format-list`、`+cond-{format-create|format-update|format-delete}` | -| 对象 | `lark-sheets-filter` | 查询、创建、更新或删除筛选器 | `+filter-list`、`+filter-{create|update|delete}` | -| 对象 | `lark-sheets-sparkline` | 查询、创建、更新或删除迷你图 | `+sparkline-list`、`+sparkline-{create|update|delete}` | -| 对象 | `lark-sheets-float-image` | 查询、创建、更新或删除浮动图片 | `+float-image-list`、`+float-{image-create|image-update|image-delete}` | +| 对象 | `lark-sheets-chart` | 查询、创建、更新或删除图表 | `+chart-{create\|update\|delete}` | +| 对象 | `lark-sheets-pivot-table` | 查询、创建、更新或删除数据透视表 | `+pivot-{create\|update\|delete}` | +| 对象 | `lark-sheets-conditional-format` | 查询、创建、更新或删除条件格式规则 | `+cond-{format-create\|format-update\|format-delete}` | +| 对象 | `lark-sheets-filter` | 查询、创建、更新或删除筛选器 | `+filter-{create\|update\|delete}` | +| 对象 | `lark-sheets-sparkline` | 查询、创建、更新或删除迷你图 | `+sparkline-{create\|update\|delete}` | +| 对象 | `lark-sheets-float-image` | 查询、创建、更新或删除浮动图片 | `+float-image-{create\|update\|delete}` | ## 特殊场景 diff --git a/skills/lark-sheets/references/lark-sheets-filter-view.md b/skills/lark-sheets/references/lark-sheets-filter-view.md index 4f3e17b54..bd0d5f3da 100644 --- a/skills/lark-sheets/references/lark-sheets-filter-view.md +++ b/skills/lark-sheets/references/lark-sheets-filter-view.md @@ -25,12 +25,12 @@ ## Shortcuts -| MCP tool | CLI shortcut | Risk | 分组 | -| --- | --- | --- | --- | -| `get_filter_view_objects` | `+filter-view-list` | read | 对象 | -| `manage_filter_view_object` | `+filter-view-create` | write | 对象 | -| | `+filter-view-update` | write | 对象 | -| | `+filter-view-delete` | high-risk-write | 对象 | +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+filter-view-list` | read | 对象 | +| `+filter-view-create` | write | 对象 | +| `+filter-view-update` | write | 对象 | +| `+filter-view-delete` | high-risk-write | 对象 | ## Flags diff --git a/skills/lark-sheets/references/lark-sheets-filter.md b/skills/lark-sheets/references/lark-sheets-filter.md index 1679f4a5c..6522ed5f7 100644 --- a/skills/lark-sheets/references/lark-sheets-filter.md +++ b/skills/lark-sheets/references/lark-sheets-filter.md @@ -3,7 +3,7 @@ ## 真对象硬约束 + 数量校验 1. **真对象**:当用户要求"筛选 / 只看 / 仅保留 X"时,**必须**通过 `+filter-{create|update|delete}` 创建真实的筛选器对象。**禁止**用"删除不符合条件的行" / "新建子表只放符合条件的行" / 用 `+cells-set` 覆盖原表来代替——这些做法会让原数据丢失或不可恢复。 -2. **筛选数量必校**:执行筛选后**必须**回读,断言 `len(visible_rows) == expected_count`。`expected_count` 来自先用 `doubao_code_interpreter` 在源数据上独立复现该筛选条件得到的结果数。两者不一致时禁止交付,需排查筛选条件 / 数据列类型问题。 +2. **筛选数量必校**:执行筛选后**必须**回读,断言 `len(visible_rows) == expected_count`。`expected_count` 来自先用 本地脚本 在源数据上独立复现该筛选条件得到的结果数。两者不一致时禁止交付,需排查筛选条件 / 数据列类型问题。 3. **混合文本列禁止字面比较**:筛选 key 是公式文本(如 `1000+200=1200`)或带单位的混合文本时,先在辅助列里抽出纯数值再筛选;不能直接用文本比较。 ## 使用场景 @@ -29,12 +29,12 @@ ## Shortcuts -| MCP tool | CLI shortcut | Risk | 分组 | -| --- | --- | --- | --- | -| `get_filter_objects` | `+filter-list` | read | 对象 | -| `manage_filter_object` | `+filter-create` | write | 对象 | -| | `+filter-update` | write | 对象 | -| | `+filter-delete` | high-risk-write | 对象 | +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+filter-list` | read | 对象 | +| `+filter-create` | write | 对象 | +| `+filter-update` | write | 对象 | +| `+filter-delete` | high-risk-write | 对象 | ## Flags diff --git a/skills/lark-sheets/references/lark-sheets-float-image.md b/skills/lark-sheets/references/lark-sheets-float-image.md index 85e427b2b..776ea365d 100644 --- a/skills/lark-sheets/references/lark-sheets-float-image.md +++ b/skills/lark-sheets/references/lark-sheets-float-image.md @@ -36,12 +36,12 @@ reference_id 的映射规则: ## Shortcuts -| MCP tool | CLI shortcut | Risk | 分组 | -| --- | --- | --- | --- | -| `get_float_image_objects` | `+float-image-list` | read | 对象 | -| `manage_float_image_object` | `+float-image-create` | write | 对象 | -| | `+float-image-update` | write | 对象 | -| | `+float-image-delete` | high-risk-write | 对象 | +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+float-image-list` | read | 对象 | +| `+float-image-create` | write | 对象 | +| `+float-image-update` | write | 对象 | +| `+float-image-delete` | high-risk-write | 对象 | ## Flags diff --git a/skills/lark-sheets/references/lark-sheets-formula-translation.md b/skills/lark-sheets/references/lark-sheets-formula-translation.md index 82884915f..03ceef07c 100644 --- a/skills/lark-sheets/references/lark-sheets-formula-translation.md +++ b/skills/lark-sheets/references/lark-sheets-formula-translation.md @@ -4,7 +4,7 @@ ## 翻译后必做:代码复现校验 -公式语法翻译完之后,**必须**用 `doubao_code_interpreter` 在源数据上独立复现一份"等价计算结果"再写入。流程: +公式语法翻译完之后,**必须**用 本地脚本 在源数据上独立复现一份"等价计算结果"再写入。流程: 1. **挑 3-5 个代表性输入行**(首行 / 中段 / 末行 / 含空值 / 含异常格式各一) 2. **用 Python 复现 Excel 原公式的语义**(不是飞书译文的语义,而是用户原本想要的结果) diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md index 359dceda0..886ff7789 100644 --- a/skills/lark-sheets/references/lark-sheets-pivot-table.md +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -38,12 +38,12 @@ ## Shortcuts -| MCP tool | CLI shortcut | Risk | 分组 | -| --- | --- | --- | --- | -| `get_pivot_table_objects` | `+pivot-list` | read | 对象 | -| `manage_pivot_table_object` | `+pivot-create` | write | 对象 | -| | `+pivot-update` | write | 对象 | -| | `+pivot-delete` | high-risk-write | 对象 | +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+pivot-list` | read | 对象 | +| `+pivot-create` | write | 对象 | +| `+pivot-update` | write | 对象 | +| `+pivot-delete` | high-risk-write | 对象 | ## Flags @@ -134,4 +134,4 @@ lark-cli sheets +pivot-create --url "..." --sheet-id "$SRC_SID" \ - `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 pivot 请求模板"+ 预估输出尺寸(行数 × 列数)。 - `Execute`:写后调用 `+pivot-list --pivot-table-id ` 回读 + `+csv-get` 抽样读透视产物,envelope.meta.verification 给出实际输出尺寸 + 总计行位置。 -> ⚠️ pivot 输出包含总计 / 小计行;后续 chart 引用 pivot 时,`data_range` 必须排除这些行(见 `lark_sheet_chart` 决策段)。 +> ⚠️ pivot 输出包含总计 / 小计行;后续 chart 引用 pivot 时,`data_range` 必须排除这些行(见 `lark-sheets-chart` 决策段)。 diff --git a/skills/lark-sheets/references/lark-sheets-range-operations.md b/skills/lark-sheets/references/lark-sheets-range-operations.md index f1869d0e1..89289826c 100644 --- a/skills/lark-sheets/references/lark-sheets-range-operations.md +++ b/skills/lark-sheets/references/lark-sheets-range-operations.md @@ -32,7 +32,7 @@ 写入文本 / 数值后**必须**主动检查列宽是否适配,否则会出现"内容被截断 / 长数字显示为科学计数法 / 文本溢出被相邻列遮挡"等用户感知问题: 1. **写入后回读最长内容字符数**:用 `+csv-get` 读目标列的实际写入内容,统计最长单元格的字符数(`max(len(cell) for cell in col)`)。汉字按 2 字符宽度估算,半角字母数字按 1 字符。 -2. **判定阈值**:当前列宽(用 `get_sheet_structure --info_type=row_heights_column_widths` 拿)≥ 最长字符数 × 字体宽度系数 + buffer 才算适配。默认列宽 11 通常只够 11 个半角字符或 5-6 个汉字,写长文本前必扩宽。 +2. **判定阈值**:当前列宽(用 `+sheet-info --info_type=row_heights_column_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` 调高对应行的行高 @@ -55,32 +55,7 @@ **例外**:`+cells-{merge|unmerge}(operation: unmerge)` 原生支持对覆盖多个合并区域的大 range 一次性取消,应直接单次调用,**不要**拆进 `+batch-update`。 -示例:需要合并 A1:A3、B1:B3、C1:C3 三个区域时,应使用: -```json -{ - "excel_id": "${excel_id}", - "operations": [ - {"tool_name": "merge_cells", "input": {"sheet_id": "${sheet_id}", "range": "A1:A3", "operation": "merge"}}, - {"tool_name": "merge_cells", "input": {"sheet_id": "${sheet_id}", "range": "B1:B3", "operation": "merge"}}, - {"tool_name": "merge_cells", "input": {"sheet_id": "${sheet_id}", "range": "C1:C3", "operation": "merge"}} - ] -} -``` -而不是分三次单独调用 `+cells-{merge|unmerge}`。 - -示例:需要将 A、B、C 三列列宽设为 120px,同时将第 1-3 行行高设为 40px 时,应使用: -```json -{ - "excel_id": "${excel_id}", - "operations": [ - {"tool_name": "resize_range", "input": {"sheet_id": "${sheet_id}", "range": "A:A", "resize_width": {"type": "pixel", "value": 120}}}, - {"tool_name": "resize_range", "input": {"sheet_id": "${sheet_id}", "range": "B:B", "resize_width": {"type": "pixel", "value": 120}}}, - {"tool_name": "resize_range", "input": {"sheet_id": "${sheet_id}", "range": "C:C", "resize_width": {"type": "pixel", "value": 120}}}, - {"tool_name": "resize_range", "input": {"sheet_id": "${sheet_id}", "range": "1:3", "resize_height": {"type": "pixel", "value": 40}}} - ] -} -``` -而不是分四次单独调用 `+rows-resize / +cols-resize`。 +> 多操作组合示例(合并多区域、批量调整列宽行高的 `+batch-update --operations` JSON 入参格式)见 `lark-sheets-batch-update` 文档。 **⚠️ sort 操作前必读:确认目标列的数据类型** @@ -102,21 +77,21 @@ 2. 若是纯数字或日期 → 直接 sort。 3. 若是带符号 / 表达式 / 单位的文本 → **不要直接排**: - 简单场景(货币、千分位、单位前缀):新增辅助列,用公式提取数值(如 `=VALUE(SUBSTITUTE(SUBSTITUTE(A2,"¥",""),",",""))`),按辅助列排序,排完可按需清除辅助列。 - - 复杂场景(多段表达式、中文单位、混合格式):`export_sheet_to_sandbox` + `doubao_code_interpreter` 在沙箱里按数值排序后 `+cells-set` 回写。 +- 复杂场景(多段表达式、中文单位、混合格式):分批 `+csv-get` 读到本地,按数值排序后用 `+csv-put` / `+cells-set` 分批回写。 ## Shortcuts -| MCP tool | CLI shortcut | Risk | 分组 | -| --- | --- | --- | --- | -| `clear_cell_range` | `+cells-clear` | high-risk-write | 单元格 | -| `merge_cells` | `+cells-merge` | write | 单元格 | -| | `+cells-unmerge` | write | 单元格 | -| `resize_range` | `+rows-resize` | write | 工作表 | -| | `+cols-resize` | write | 工作表 | -| `transform_range` | `+range-move` | write | 区域 | -| | `+range-copy` | write | 区域 | -| | `+range-fill` | write | 区域 | -| | `+range-sort` | write | 区域 | +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+cells-clear` | high-risk-write | 单元格 | +| `+cells-merge` | write | 单元格 | +| `+cells-unmerge` | write | 单元格 | +| `+rows-resize` | write | 工作表 | +| `+cols-resize` | write | 工作表 | +| `+range-move` | write | 区域 | +| `+range-copy` | write | 区域 | +| `+range-fill` | write | 区域 | +| `+range-sort` | write | 区域 | ## Flags diff --git a/skills/lark-sheets/references/lark-sheets-read-data.md b/skills/lark-sheets/references/lark-sheets-read-data.md index b67e590c2..54629932d 100644 --- a/skills/lark-sheets/references/lark-sheets-read-data.md +++ b/skills/lark-sheets/references/lark-sheets-read-data.md @@ -1,7 +1,5 @@ # Lark Sheet Read Data -> ⚠️ **沙箱类工具在 CLI 中不存在**:`export_sheet_to_sandbox` 是 sheet-ai-skills 侧(AI/MCP 消费方)的沙箱 IO 工具,本 reference 中提到它们的段落对 CLI 不适用。CLI 处理大数据请走 `+csv-get --max-rows N` 分页读取 + 本地 Python 处理;写回用 `+csv-put` 或 `+cells-set`。 - ## 列格式多样性预探(写公式 / 排序 / 筛选前必做) > 对应 `lark-sheets-core-operations` 的 **R3 计算复现**——本节是 R3 在 read_data 工具层的具体落地。 @@ -17,21 +15,21 @@ ## 使用场景 -读取。从飞书表格中读取单元格数据。本 skill 包含三个工具,根据读取目的选择: +读取。从飞书表格中读取单元格数据。本 skill 提供两个 CLI shortcut,按读取目的选择: -| 读取目的 | 使用工具 | 数据去向 | 说明 | -|---------|---------|---------|------| -| 数据分析、清洗、可视化、大数据集 | `export_sheet_to_sandbox` | 沙箱文件系统(不进 context) | **数据分析首选**。导出为 CSV 到沙箱,模型写 Python 代码读取分析。仅文件路径和元信息占用 token | -| 快速查看少量数据、简单问答 | `+csv-get` | 对话上下文 | 返回 CSV 文本,适合几十行以内的快速查看 | +| 读取目的 | 用这个 shortcut | 数据去向 | 说明 | +|---------|----------------|---------|------| +| 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本;大表请分批读(控制 `--max-rows` / `--max-chars`) | | 查看公式、样式、批注、数据验证 | `+cells-get` | 对话上下文 | 返回单元格完整信息,token 开销较大 | **选择原则**: -- 数据分析场景(统计、聚合、清洗、画图)→ 优先使用 `export_sheet_to_sandbox`,数据不进 context,在沙箱中用 pandas 处理 -- 快速查看少量数据 → 使用 `+csv-get` -- 需要公式/样式/批注 → 使用 `+cells-get` +- 只看值或做数据处理 → `+csv-get`;大表分批读取,避免一次拉全表撑爆上下文 +- 需要公式/样式/批注 → `+cells-get` + +⚠️ 超大数据请走"`+csv-get --max-rows N` 分批读到本地文件 + 本地脚本处理 + `+csv-put` 分批回写"。 **`+csv-get` 返回值核心设计**: -- `annotated_csv` — **CSV 数据唯一入口**。每一逻辑行前加 `[row=N] ` 前缀(N = 真实表格行号)。任何需要行号的下游操作(合并、写入、清空、格式化、插入/删除、条件格式、筛选、图表/透视表范围、搜索替换等),**行号一律直接从 `[row=N]` 读取**。若需要纯 CSV(如喂给 pandas 做 context 内解析),去前缀即可:`line.replace(/^\[row=\d+\] /, '')`——但大数据集走 `export_sheet_to_sandbox`,不要用本工具。 +- `annotated_csv` — **CSV 数据唯一入口**。每一逻辑行前加 `[row=N] ` 前缀(N = 真实表格行号)。任何需要行号的下游操作(合并、写入、清空、格式化、插入/删除、条件格式、筛选、图表/透视表范围、搜索替换等),**行号一律直接从 `[row=N]` 读取**。若需要纯 CSV(如喂给本地脚本做解析),去前缀即可:`line.replace(/^\[row=\d+\] /, '')`。 - `col_indices` — **定位列字母唯一入口**。在表头中找到目标字段是第 j 个(0-based),用 `col_indices[j]` 取列字母。**禁止手数逗号**——列数超过 10 时极易 off-by-one(例如把 W 误判为 X)。 - `row_indices` — 程序化引用的备用数组。LLM 推理请用 `annotated_csv` 的前缀,不要查这个数组里的 index(把行号当数值用容易心算出错)。 - `current_region` — 从请求范围扩展到被空行空列包围的连续数据区域(等价于 Excel Ctrl+Shift+*),适合先读少量行探表头、同时获知整表实际范围。 @@ -42,11 +40,11 @@ - 隐藏行列默认包含在返回结果中(`skip_hidden=false`),如需只看可见数据设为 `true` **常见配置错误(必须注意)**: -- **全量读取导致上下文溢出(高频致命错误)**:不要对大表(数百行以上)直接用 `+csv-get` 或 `+cells-get` 读取全部数据到上下文。大表场景必须使用 `export_sheet_to_sandbox` 导出到沙箱后用 Python 处理,或分批读取:`+csv-get` 控制 `max_rows` / `max_chars`,`+cells-get` 控制 `ranges` / `cell_limit` / `max_chars` +- **全量读取导致上下文溢出(高频致命错误)**:不要对大表(数百行以上)直接用 `+csv-get` 或 `+cells-get` 读取全部数据到上下文。大表场景必须分批读取:`+csv-get` 控制 `--max-rows` / `--max-chars`,`+cells-get` 控制 `--range` / `--cell-limit` / `--max-chars`;过大时考虑导出到本地文件后用脚本处理再分批回写 - **了解结构 ≠ 读取全量数据**:探表不用读全表,但必须同时探两个方向的表头: - **横向(列头)**:先读前几行,且**列范围必须覆盖所有列**——用 `+workbook-info` 拿总列数,`range` 末列填到最后一列(例如总列数是 N,则 `range: "A1:[列N]10"`)。列范围截短会遗漏右侧字段、后续写入列定位错误。 - **纵向(行标)**:若左侧 1-2 列是行标签(日期/类别/编号枚举每行含义,典型交叉表/透视布局),**必须再读 `A:A` 或 `A:B` 把行标列读到底**,拿全部行标。只读前几行会看不全表尾的行,导致批量写入漏改——这是"只改前 N 行、其余未更新"的主要成因。扁平列表(每行独立记录、列是字段)可跳过这一步,但仍要靠 `current_region` 兜底。 - - 数据量大或会进入上下文上限时,直接走 `export_sheet_to_sandbox`,不要用 `+csv-get` 翻页硬塞。 + - 数据量大或会进入上下文上限时,分批读 + 本地处理 + 分批回写,不要一口气拉全表到上下文。 - **`+cells-get` 滥用**:当只需要数据值时,使用 `+csv-get`(token 开销约为 `+cells-get` 的 1/5)。只有确实需要公式、样式或批注时才用 `+cells-get` - **忽略分页标志**:读取返回 `has_more=true` 时,说明还有更多数据。如果任务需要完整数据,必须继续分页读取,不能只处理第一页就开始写入 - **直接按 `+cells-get` 返回二维数组下标推导真实位置(高频错误)**:`ranges[n].cells[i][j]` 里的 `i/j` 只是返回数组下标,不等于真实表格行列。定位真实行号必须用 `ranges[n].row_indices[i]`,定位真实列字母必须用 `ranges[n].col_indices[j]`;若 `skip_hidden=true`、请求范围越界被裁剪,或最后一行是部分返回,错误地自己数下标会立刻错位 @@ -76,12 +74,11 @@ ## Shortcuts -| MCP tool | CLI shortcut | Risk | 分组 | -| --- | --- | --- | --- | -| `export_sheet_to_sandbox` | _Sheet Tool 独有,CLI 不实现_ | — | — | -| `get_cell_ranges` | `+cells-get` | read | 单元格 | -| | `+dropdown-get` | read | 对象 | -| `get_range_as_csv` | `+csv-get` | read | 单元格 | +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+cells-get` | read | 单元格 | +| `+dropdown-get` | read | 对象 | +| `+csv-get` | read | 单元格 | ## Flags @@ -158,5 +155,3 @@ lark-cli sheets +cells-get --url "https://example.feishu.cn/sheets/shtXXX" \ - `Validate` 阶段只做 XOR 检查、Enum 合法性、防爆参数上限校验;**禁止**联网(如不能用 `--sheet-name` 提前去查 `sheet-id`)。 - `DryRun` 输出请求模板:`--sheet-name` 在 dry-run 输出里生成为 `` 占位符,不实际解析。 - `Execute` 阶段才进行 sheet-name → sheet-id 解析与 API 调用。 - -> `export_sheet_to_sandbox` 是 Sheet Tool 独有的沙箱导出工具,CLI 不提供等价 shortcut(见 `## Shortcuts` 段标注)。 diff --git a/skills/lark-sheets/references/lark-sheets-search-replace.md b/skills/lark-sheets/references/lark-sheets-search-replace.md index fe70ea90a..a459769f6 100644 --- a/skills/lark-sheets/references/lark-sheets-search-replace.md +++ b/skills/lark-sheets/references/lark-sheets-search-replace.md @@ -24,10 +24,10 @@ ## Shortcuts -| MCP tool | CLI shortcut | Risk | 分组 | -| --- | --- | --- | --- | -| `search_data` | `+cells-search` | read | 单元格 | -| `replace_data` | `+cells-replace` | write | 单元格 | +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+cells-search` | read | 单元格 | +| `+cells-replace` | write | 单元格 | ## Flags diff --git a/skills/lark-sheets/references/lark-sheets-sheet-structure.md b/skills/lark-sheets/references/lark-sheets-sheet-structure.md index 78b655dce..658759e56 100644 --- a/skills/lark-sheets/references/lark-sheets-sheet-structure.md +++ b/skills/lark-sheets/references/lark-sheets-sheet-structure.md @@ -37,17 +37,17 @@ ## Shortcuts -| MCP tool | CLI shortcut | Risk | 分组 | -| --- | --- | --- | --- | -| `get_sheet_structure` | `+sheet-info` | read | 工作表 | -| `modify_sheet_structure` | `+dim-insert` | write | 工作表 | -| | `+dim-delete` | high-risk-write | 工作表 | -| | `+dim-hide` | write | 工作表 | -| | `+dim-unhide` | write | 工作表 | -| | `+dim-freeze` | write | 工作表 | -| | `+dim-group` | write | 工作表 | -| | `+dim-ungroup` | write | 工作表 | -| `move_dimension` | `+dim-move` | write | 工作表 | +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+sheet-info` | read | 工作表 | +| `+dim-insert` | write | 工作表 | +| `+dim-delete` | high-risk-write | 工作表 | +| `+dim-hide` | write | 工作表 | +| `+dim-unhide` | write | 工作表 | +| `+dim-freeze` | write | 工作表 | +| `+dim-group` | write | 工作表 | +| `+dim-ungroup` | write | 工作表 | +| `+dim-move` | write | 工作表 | ## Flags @@ -166,7 +166,7 @@ lark-cli sheets +dim-insert --url "https://example.feishu.cn/sheets/shtXXX" \ ### `+rows-resize` / `+cols-resize` -> ⚠️ 这两条 shortcut 来自 `lark_sheet_range_operations` 的 `resize_range` tool(分组在"工作表"是为了发现性)。详细参数和示例在 `lark_sheet_range_operations/cli-shortcuts.md`。 +> ⚠️ 这两条 shortcut 来自 `lark-sheets-range-operations` 的 `+rows-resize / +cols-resize` tool(分组在"工作表"是为了发现性)。详细参数和示例在 `lark_sheet_range_operations/cli-shortcuts.md`。 > > 行 vs 列底层 schema 有差异:`+rows-resize.--type` 支持 `pixel` / `standard` / `auto`,`+cols-resize.--type` 只支持 `pixel` / `standard`(列宽不支持自动适应)。 diff --git a/skills/lark-sheets/references/lark-sheets-sparkline.md b/skills/lark-sheets/references/lark-sheets-sparkline.md index 0801a1015..5cb826f13 100644 --- a/skills/lark-sheets/references/lark-sheets-sparkline.md +++ b/skills/lark-sheets/references/lark-sheets-sparkline.md @@ -22,12 +22,12 @@ ## Shortcuts -| MCP tool | CLI shortcut | Risk | 分组 | -| --- | --- | --- | --- | -| `get_sparkline_objects` | `+sparkline-list` | read | 对象 | -| `manage_sparkline_object` | `+sparkline-create` | write | 对象 | -| | `+sparkline-update` | write | 对象 | -| | `+sparkline-delete` | high-risk-write | 对象 | +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+sparkline-list` | read | 对象 | +| `+sparkline-create` | write | 对象 | +| `+sparkline-update` | write | 对象 | +| `+sparkline-delete` | high-risk-write | 对象 | ## Flags diff --git a/skills/lark-sheets/references/lark-sheets-visual-standards.md b/skills/lark-sheets/references/lark-sheets-visual-standards.md index a094db08f..8581b8c09 100644 --- a/skills/lark-sheets/references/lark-sheets-visual-standards.md +++ b/skills/lark-sheets/references/lark-sheets-visual-standards.md @@ -142,15 +142,15 @@ **核心思路:三步分层法** ``` -Step 1 — 格式铺开:batch_update + transform_range(copy/fill) +Step 1 — 格式铺开:`+batch-update` + `+range-{move|copy|fill|sort}`(copy/fill) └── 将模板行/区域的 **全部格式**(样式、边框、数字格式、数据验证等)复制到目标区域 └── 推荐传 paste_type: "format_only"(仅复制格式,目标值/公式保留),即"格式刷" └── 若需连带公式平移填充(如公式列结构一致),改用 fill(copyCells) 或 copy 默认的 paste_type: "all" -Step 2 — 内容覆写:batch_update + set_cell_range(仅传 value/formula,不传任何样式) +Step 2 — 内容覆写:`+batch-update` + `+cells-set`(仅传 value/formula,不传任何样式) └── 将每行的实际数据写入,cell_styles 全部省略,因为格式已在 Step 1 中就位 -Step 3 — 微调收尾:batch_update + resize_range / merge_cells 等 +Step 3 — 微调收尾:`+batch-update` + `+rows-resize / +cols-resize` / `+cells-{merge|unmerge}` 等 └── 调整行高列宽、处理合并单元格、扩展条件格式范围等边缘情况 ``` @@ -172,15 +172,15 @@ Step 3 — 微调收尾:batch_update + resize_range / merge_cells 等 ``` 1. 探查阶段 - ├── get_workbook_structure → 获取子表列表、行列数、冻结位置 - ├── get_sheet_structure(info_type: merged_cells_infos)→ 获取合并区域 - ├── get_cell_ranges(前几行 + 末尾几行,include_styles: true)→ 采样表头/数据区/汇总行样式 + ├── `+workbook-info` → 获取子表列表、行列数、冻结位置 + ├── `+sheet-info`(info_type: merged_cells_infos)→ 获取合并区域 + ├── `+cells-get`(前几行 + 末尾几行,include_styles: true)→ 采样表头/数据区/汇总行样式 └── 分析结果 → 建立区域地图(表头行号、数据起止行号、汇总行号、合并区域列表) 2. 规划阶段 ├── 判断表头行:通常第 1 行或前 2 行,特征为加粗/背景色/合并/居中 ├── 判断汇总行:通常最后 1~2 行,特征为加粗/SUM/AVERAGE 公式/更深背景色 - ├── 判断合并区域:从 get_cell_ranges 返回中识别(多个单元格同值且样式相同通常暗示合并) + ├── 判断合并区域:从 `+cells-get` 返回中识别(多个单元格同值且样式相同通常暗示合并) └── 制定美化方案:按区域分别设置样式 3. 执行阶段(按顺序) diff --git a/skills/lark-sheets/references/lark-sheets-workbook.md b/skills/lark-sheets/references/lark-sheets-workbook.md index cbb869fa9..937cf5c93 100644 --- a/skills/lark-sheets/references/lark-sheets-workbook.md +++ b/skills/lark-sheets/references/lark-sheets-workbook.md @@ -30,19 +30,19 @@ ## Shortcuts -| MCP tool | CLI shortcut | Risk | 分组 | -| --- | --- | --- | --- | -| `get_workbook_structure` | `+workbook-info` | read | 工作簿 | -| `modify_workbook_structure` | `+sheet-create` | write | 工作簿 | -| | `+sheet-delete` | high-risk-write | 工作簿 | -| | `+sheet-rename` | write | 工作簿 | -| | `+sheet-move` | write | 工作簿 | -| | `+sheet-copy` | write | 工作簿 | -| | `+sheet-hide` | write | 工作簿 | -| | `+sheet-unhide` | write | 工作簿 | -| | `+sheet-set-tab-color` | write | 工作簿 | -| `create_workbook` | `+workbook-create` | write | 工作簿 | -| `export_workbook` | `+workbook-export` | read | 工作簿 | +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+workbook-info` | read | 工作簿 | +| `+sheet-create` | write | 工作簿 | +| `+sheet-delete` | high-risk-write | 工作簿 | +| `+sheet-rename` | write | 工作簿 | +| `+sheet-move` | write | 工作簿 | +| `+sheet-copy` | write | 工作簿 | +| `+sheet-hide` | write | 工作簿 | +| `+sheet-unhide` | write | 工作簿 | +| `+sheet-set-tab-color` | write | 工作簿 | +| `+workbook-create` | write | 工作簿 | +| `+workbook-export` | read | 工作簿 | ## Flags diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 5f87d0d26..bc8e6e623 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -1,12 +1,10 @@ # Lark Sheet Write Cells -> ⚠️ **沙箱类工具在 CLI 中不存在**:`import_sandbox_to_sheet` 是 sheet-ai-skills 侧(AI/MCP 消费方)的沙箱 IO 工具,本 reference 中提到它们的段落对 CLI 不适用。CLI 处理大数据请走 `+csv-get --max-rows N` 分页读取 + 本地 Python 处理;写回用 `+csv-put` 或 `+cells-set`。 - ## 写入边界 + 回读校验(编辑类任务必做) 1. **明确写入边界**:写入前必须能回答"目标 range 的起止行列号是多少?是否落在用户授权范围内?"。除用户明示要修改的区域外,禁止扩张到原数据列以外或新建 Sheet。 2. **完整性断言**:批量写入前先把"预期写入条数"硬编码到代码里(如要填 106 条翻译 → `expected = 106`),写完后回读断言 `actual == expected`。少于预期就继续写,禁止交付半成品。 -3. **回读抽样校验**:写完关键值 / 公式后,用 `+csv-get` 或 `+cells-get` 重新读取写入区域,至少抽样 3-5 个代表性单元格(首 / 中 / 末),核对值与预期一致(与 `doubao_code_interpreter` 计算的预期值对照)。公式特定的"先验证模板再 copy_to_range / 修完再读回"细则见下方相关章节。 +3. **回读抽样校验**:写完关键值 / 公式后,用 `+csv-get` 或 `+cells-get` 重新读取写入区域,至少抽样 3-5 个代表性单元格(首 / 中 / 末),核对值与预期一致(与 本地脚本 计算的预期值对照)。公式特定的"先验证模板再 copy_to_range / 修完再读回"细则见下方相关章节。 ## 新增列 / 新增行的样式继承(防止视觉风格不一致) @@ -19,7 +17,7 @@ 3. `cell_styles.number_format`(小数位 / 千分位 / 百分比 / 日期格式)—— 漏继承会导致同列数值格式混乱 4. `cell_styles.background_color`(背景色) 5. `border_styles`(四边框) -6. **`merged_cells`(合并范围)**——续写场景必查(高频致命错误):用 `get_sheet_structure --info_type=merged_cells_infos` 读原数据区域的合并信息。**原行有跨列合并**(如标题行 `A1:G1` 合并)时,新行**必须**用 `+cells-{merge|unmerge}` 工具复制相同合并模式到新行(如续写第 3 个周报块的标题行 `A23:G23` 必须合并)。仅传 cells 数组的 5 类样式不够——合并范围要单独靠 `+cells-{merge|unmerge}` 工具落地(典型反例:续写多周记录表时,新增周次的标题行未合并,视觉上与原前几周风格不一致) +6. **`merged_cells`(合并范围)**——续写场景必查(高频致命错误):用 `+sheet-info --info_type=merged_cells_infos` 读原数据区域的合并信息。**原行有跨列合并**(如标题行 `A1:G1` 合并)时,新行**必须**用 `+cells-{merge|unmerge}` 工具复制相同合并模式到新行(如续写第 3 个周报块的标题行 `A23:G23` 必须合并)。仅传 cells 数组的 5 类样式不够——合并范围要单独靠 `+cells-{merge|unmerge}` 工具落地(典型反例:续写多周记录表时,新增周次的标题行未合并,视觉上与原前几周风格不一致) **采样模板的正确做法**: - 表头新列 → 读相邻表头单元格(如新加 D1 → 读 A1/B1/C1 任一) @@ -54,7 +52,7 @@ 高频模式(**必须遵守,禁止逐行写入替代**): -- 整列公式:先在 `H2` 写一个公式,再用 `copy_to_range: "H2:H100"` 或 `copy_to_range: "H:H"` 向下填充。**禁止对每一行单独调用 set_cell_range 写入相同结构的公式** +- 整列公式:先在 `H2` 写一个公式,再用 `copy_to_range: "H2:H100"` 或 `copy_to_range: "H:H"` 向下填充。**禁止对每一行单独调用 `+cells-set` 写入相同结构的公式** - 整列格式:先在 `J1` 写一个带样式的模板单元格,再用 `copy_to_range: "J:J"` - 首行样式:先在 `A1` 写一个模板单元格,再用 `copy_to_range: "1:1"` - 用户说”这列 / 整列 / 这行 / 首行 / 向下复制”时,**必须**使用模板单元格 + `copy_to_range` @@ -70,8 +68,8 @@ 示例:要对 A2:A100 写入数据并统一设置蓝色背景 + 边框: ``` -Step 1: set_cell_range — range="A2:A100", cells 只含 value(无样式,入参短) -Step 2: set_cell_range — range="A2", cells 含 value + cell_styles + border_styles(单个模板), copy_to_range="A2:A100" +Step 1: `+cells-set` — range="A2:A100", cells 只含 value(无样式,入参短) +Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styles(单个模板), copy_to_range="A2:A100" ``` 这比在 99 个单元格中都重复写样式 JSON 高效得多。 @@ -95,7 +93,7 @@ Step 2: set_cell_range — range="A2", cells 含 value + cell_styles + border_st 3. **`copy_to_range` 扩展前先验证模板**:模板单元格公式自己都算错,`copy_to_range` 复制到 100 行就是 100 个错误 4. **飞书不支持的函数**:`UNIQUE` / `DISTINCT` / `FILTER`(部分)—— 对应"去重"场景改用透视表(`+pivot-{create|update|delete}`,值字段聚合方式选 count) 5. **循环引用预检(高频致命错误)**:写聚合公式(SUM / AVERAGE / COUNT 等)前必须明确**引用范围不包含目标单元格自身或其传递依赖**。典型反例:在 C3 写 `=SUMIF(B:B,LEFT(B3,9)&"*",C:C)`,B 列匹配 B3 前 9 位时 C3 自己也命中,导致 C3 自引用 → `~CIRCULAR~REF~`。修法:用辅助列 / 显式排除自身(`SUMIFS(C:C, B:B, ..., A:A, "<>"&A3)`)/ 缩小范围避开自己 -6. **REGEX 模式覆盖率验证**:公式里的 `REGEXEXTRACT` / `REGEXMATCH` / `REGEXREPLACE` 等正则模式落地前必须用 `doubao_code_interpreter` 在源列上跑一遍命中率统计(`df[col].str.contains(pattern).mean()`);命中率 < 100% 时必须扩展 pattern 或加多分支(IFS / 多个 IFERROR 串联)兜底,**禁止**只覆盖样本前 N 行就交付(典型反例:用 `REGEXEXTRACT(D5,"长(\d+)")` 只匹配带"长"前缀的尺寸文本,对"宽×高"、"×"、"*"等其它分隔符直接漏匹配) +6. **REGEX 模式覆盖率验证**:公式里的 `REGEXEXTRACT` / `REGEXMATCH` / `REGEXREPLACE` 等正则模式落地前必须用 本地脚本 在源列上跑一遍命中率统计(`df[col].str.contains(pattern).mean()`);命中率 < 100% 时必须扩展 pattern 或加多分支(IFS / 多个 IFERROR 串联)兜底,**禁止**只覆盖样本前 N 行就交付(典型反例:用 `REGEXEXTRACT(D5,"长(\d+)")` 只匹配带"长"前缀的尺寸文本,对"宽×高"、"×"、"*"等其它分隔符直接漏匹配) 7. **公式范围与用户指令字面对齐**:用户说"对 F 至 L 列求和"就必须写 `SUM(F2:L2)` 或 `F2+G2+H2+I2+J2+K2+L2`,**不能漏列、多列、错列**。写完用 `+cells-get` 拿回 `formula` 字符串,与用户原话逐字对照(参与求和的列名一致 / 起止列号一致 / 运算符一致),不一致就是违规 ⚠️ **收到 `formula_errors` 反馈后不要只打补丁(高频致命错误)**:`+cells-set` 返回值里若出现 `formula_errors: [{cell, formula, error_type, detail}]`,说明某些 cell 公式编译失败(`error_type=compile_failed` 通常是函数语法错如 `SPLIT(x)[1]` 飞书不支持;`non_formula` 是 `=` 开头但解析不通过)。此时**禁止只聚焦修报错点的局部语法**(如仅把 `[1]` 换成 `INDEX(..,1)`),必须: @@ -124,14 +122,14 @@ Step 2: set_cell_range — range="A2", cells 含 value + cell_styles + border_st ``` Step 1: 用模板单元格 + copy_to_range 铺"完整样式"(不是只铺 border)到新区域 - set_cell_range — range="A11", cells=[[{ + `+cells-set` — range="A11", cells=[[{ border_styles: {...}, cell_styles: { /* 按行性质填充:数据行继承数据区样式;汇总行见 lark_sheet_visual_standards */ } }]], copy_to_range="A11:H11" -Step 2: 再用 set_cell_range 单独写具体 value/formula(不再传样式,避免覆盖) - set_cell_range — range="A11", cells=[[{value: "平均分"}]] - set_cell_range — range="C11:F11", cells=[[{formula: "=AVERAGE(C2:C10)"}, {formula: "=AVERAGE(D2:D10)"}, ...]] +Step 2: 再用 `+cells-set` 单独写具体 value/formula(不再传样式,避免覆盖) + `+cells-set` — range="A11", cells=[[{value: "平均分"}]] + `+cells-set` — range="C11:F11", cells=[[{formula: "=AVERAGE(C2:C10)"}, {formula: "=AVERAGE(D2:D10)"}, ...]] ``` ⚠️ **Step 1 `cell_styles` 禁止留空**:只铺 border、不铺 `cell_styles`,等于新行从格式上"裸奔"——没字体、没对齐、没背景色。如果新行是汇总行,这意味着 bold 丢失,用户感受"没做样式"。Step 1 的 `cell_styles` 要么继承源区块(`+cells-get` 读相邻已有行样式后复用),要么按汇总行规范(见 `lark-sheets-visual-standards`)配齐。 @@ -139,7 +137,7 @@ Step 2: 再用 set_cell_range 单独写具体 value/formula(不再传样式, **做法 B:一次写入但每个 cell 都显式带样式** ``` -set_cell_range — range="A11:H11", cells=[[ +`+cells-set` — range="A11:H11", cells=[[ {value: "平均分", cell_styles: {...}, border_styles: {...}}, {value: "", cell_styles: {...}, border_styles: {...}}, ← B11 不能是 {},要显式带 border {formula: "=AVERAGE(C2:C10)", cell_styles: {...}, border_styles: {...}}, @@ -152,29 +150,31 @@ set_cell_range — range="A11:H11", cells=[[ ## 工具选择 -本 skill 提供三个写入工具,按数据来源 + 内容形态选: +本 skill 提供以下 CLI shortcut,按数据来源 + 内容形态选: -| 场景 | 工具 | 原因 | -|------|------|------| +| 场景 | 用这个 shortcut | 原因 | +|------|----------------|------| | 模型手里已经有 CSV 文本(小规模手动构造、从 `+csv-get` 取到后简单加工) | `+csv-put` | 直接传 CSV 文本 + start_cell,不用自己拼二维 cells 数组;必要时自动扩容行列 | -| 数据已经在沙箱里(Python 清洗/聚合/筛选/合并的大块纯值) | `import_sandbox_to_sheet` | 只传 `file_uri`,CSV 不进对话上下文,最省 token | -| 写入含公式、样式、批注、图片、数据校验等任意富写入 | `+cells-set` | 唯一支持完整字段的工具 | -| 大量纯值 + 需要表头样式/边框 | 先用 `+csv-put` 或 `import_sandbox_to_sheet` 写值,再用 `+cells-set` 补样式 | 分工配合,入参最短 | +| 写入含公式、样式、批注、图片、数据校验等任意富写入 | `+cells-set` | 唯一支持完整字段的 shortcut | +| 只改已有 cell 的样式,不动 value/formula | `+cells-set-style` | 拍平 10 个样式字段为独立 flag;不触发不必要的值写入 | +| 单 cell 嵌入图片 | `+cells-set-image` | 比 `+cells-set` 参数更简短 | +| 大量纯值 + 需要表头样式/边框 | 先用 `+csv-put` 写值,再用 `+cells-set-style` 补样式 | 分工配合,入参最短 | + +**优先级**:常规纯值写入优先 `+csv-put`(最短入参,直接传 CSV 文本);含公式/样式/批注/图片才用 `+cells-set`。 -**优先级**:常规规模优先 `+csv-put`(最短入参,直接传 CSV 文本);数据已在沙箱或大数据回写场景切到 `import_sandbox_to_sheet`(CSV 不进上下文,更省 token);含公式/样式/批注/图片才用 `+cells-set`。 +⚠️ `+csv-put` 只写纯值,**不会**携带公式/样式/批注/图片;公式字符串以 `=` 开头会被当作字面量文本落地。如果数据里需要公式或样式,**必须**用 `+cells-set`(或"写值 + 补样式"两步法)。 -⚠️ `+csv-put` 和 `import_sandbox_to_sheet` 都只写纯值,**不会**携带公式/样式/批注/图片;公式字符串以 `=` 开头会被当作字面量文本落地。如果数据里需要公式或样式,**必须**用 `+cells-set`(或"写值 + 补样式"两步法)。 +⚠️ 大数据回写走"`+csv-get --max-rows N` 分批读到本地 + 本地脚本处理 + `+csv-put` 分批回写"。 ## Shortcuts -| MCP tool | CLI shortcut | Risk | 分组 | -| --- | --- | --- | --- | -| `set_cell_range` | `+cells-set` | write | 单元格 | -| | `+cells-set-style` | write | 单元格 | -| | `+cells-set-image` | write | 单元格 | -| | `+dropdown-set` | write | 对象 | -| `set_range_from_csv` | `+csv-put` | write | 单元格 | -| `import_sandbox_to_sheet` | _Sheet Tool 独有,CLI 不实现_ | — | — | +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+cells-set` | write | 单元格 | +| `+cells-set-style` | write | 单元格 | +| `+cells-set-image` | write | 单元格 | +| `+dropdown-set` | write | 对象 | +| `+csv-put` | write | 单元格 | ## Flags @@ -314,7 +314,7 @@ lark-cli sheets +cells-set --spreadsheet-token shtXXX --sheet-id "$SID" \ > 中间想跳过的 cell 用空对象 `{}` 占位(底层语义为"保留原值不变"),`--cells` 维度仍须与 `--range` 完全一致。例:`--range A1:A5 --cells '[[{"value":1}],[{}],[{}],[{}],[{"value":5}]]'` 只写 A1 和 A5。 > -> 跨多个不连续区域散点写入(如 `D2` + `F7` + `J15`)不属于 `+cells-set` 的能力范围——请用 `+batch-update` 把多次 `set_cell_range` 打包成单次原子请求。 +> 跨多个不连续区域散点写入(如 `D2` + `F7` + `J15`)不属于 `+cells-set` 的能力范围——请用 `+batch-update` 把多次 `+cells-set` 打包成单次原子请求。 ### `+cells-set-style` From 460c794f28b1a503026408b3c44336322ac4d861 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Wed, 20 May 2026 11:28:35 +0800 Subject: [PATCH 019/114] feat(sheets): add flag-descriptions.en.json and wire applyFlagDescs into Shortcuts() Embed data/flag-descriptions.en.json (synced from upstream spec) and apply it at shortcut assembly time so every Flag.Desc is sourced from the canonical JSON rather than hardcoded Go strings. Existing hardcoded Desc values serve as fallback for flags not yet in the JSON. Also sync reference doc updates from upstream. --- .../sheets/data/flag-descriptions.en.json | 634 ++++++++++++++++++ shortcuts/sheets/flag_desc.go | 71 ++ shortcuts/sheets/flag_desc_test.go | 112 ++++ shortcuts/sheets/shortcuts.go | 1 + .../references/lark-sheets-batch-update.md | 6 +- .../references/lark-sheets-chart.md | 2 +- .../lark-sheets-conditional-format.md | 12 +- .../references/lark-sheets-filter-view.md | 16 +- .../references/lark-sheets-filter.md | 8 +- .../references/lark-sheets-float-image.md | 16 +- .../references/lark-sheets-pivot-table.md | 2 +- .../lark-sheets-range-operations.md | 38 +- .../references/lark-sheets-read-data.md | 12 +- .../references/lark-sheets-search-replace.md | 18 +- .../references/lark-sheets-sheet-structure.md | 30 +- .../references/lark-sheets-sparkline.md | 2 +- .../references/lark-sheets-workbook.md | 8 +- .../references/lark-sheets-write-cells.md | 12 +- 18 files changed, 909 insertions(+), 91 deletions(-) create mode 100644 shortcuts/sheets/data/flag-descriptions.en.json create mode 100644 shortcuts/sheets/flag_desc.go create mode 100644 shortcuts/sheets/flag_desc_test.go diff --git a/shortcuts/sheets/data/flag-descriptions.en.json b/shortcuts/sheets/data/flag-descriptions.en.json new file mode 100644 index 000000000..22e25ce10 --- /dev/null +++ b/shortcuts/sheets/data/flag-descriptions.en.json @@ -0,0 +1,634 @@ +{ + "+workbook-info": { + "--url": "Spreadsheet locator", + "--spreadsheet-token": "Spreadsheet locator", + "--include-properties": "Whether to include each sheet's extended properties; default `true`" + }, + "+sheet-create": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--title": "New sheet title", + "--index": "Insert position; appended to the end when omitted", + "--row-count": "Initial row count; default 100", + "--col-count": "Initial column count; default 26" + }, + "+sheet-delete": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--yes": "Confirm high-risk write (exit code 10 without this flag)" + }, + "+sheet-rename": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--title": "New title" + }, + "+sheet-move": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--index": "Target position (0-based)", + "--source-index": "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`" + }, + "+sheet-copy": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--title": "Copy title; auto-generated by the server when omitted", + "--index": "Insert position for the copy (0-based); appended to the end when omitted" + }, + "+sheet-hide": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)" + }, + "+sheet-unhide": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)" + }, + "+sheet-set-tab-color": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--color": "Hex color like `#FF0000`; pass empty string `\"\"` to clear" + }, + "+workbook-create": { + "--title": "Spreadsheet title", + "--folder-token": "Target folder token; placed at the drive root when omitted", + "--headers": "Header row as a JSON array: `[\"Col A\",\"Col B\"]`", + "--values": "Initial data as a 2D JSON array: `[[\"alice\",95]]`" + }, + "+workbook-export": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--file-extension": "`xlsx` / `csv`; default `xlsx`. `csv` mode requires `--sheet-id`", + "--sheet-id": "Required only in csv mode: the sheet reference_id to export", + "--output-path": "Local save path; export is triggered but not downloaded when omitted" + }, + "+sheet-info": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--include": "Comma-separated subset of `merges` / `row_heights` / `col_widths` / `hidden_rows` / `hidden_cols` / `groups` / `frozen`" + }, + "+dim-insert": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--dimension": "`row` / `column`", + "--start": "Insert start position (0-based)", + "--end": "Insert end position (exclusive)", + "--inherit-style": "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)" + }, + "+dim-delete": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--dimension": "`row` / `column`", + "--start": "Start position (0-based)", + "--end": "End position (exclusive)", + "--yes": "Confirm destructive write (exit code 10 without this flag); row/column deletion is irreversible" + }, + "+dim-hide": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--dimension": "`row` / `column`", + "--start": "End position (0-based, inclusive)", + "--end": "End position (0-based, inclusive)" + }, + "+dim-unhide": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--dimension": "`row` / `column`", + "--start": "End position (0-based, inclusive)", + "--end": "End position (0-based, inclusive)" + }, + "+dim-freeze": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--dimension": "`row` / `column`", + "--count": "Freeze the first N rows/columns; pass 0 to unfreeze" + }, + "+dim-group": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--dimension": "`row` / `column`", + "--start": "End position (0-based, inclusive)", + "--end": "End position (0-based, inclusive)", + "--depth": "Nesting level (used by `+dim-group`); default 1" + }, + "+dim-ungroup": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--dimension": "`row` / `column`", + "--start": "End position (0-based, inclusive)", + "--end": "End position (0-based, inclusive)", + "--depth": "Nesting level (used by `+dim-group`); default 1" + }, + "+dim-move": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--dimension": "`row` / `column`", + "--start": "Source range start position (0-based, inclusive)", + "--end": "Source range end position (0-based, inclusive)", + "--target": "Destination position (move target inserts before this index; 0-based)" + }, + "+cells-get": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--range": "A1 range, e.g. `Sheet1!A1:F10`", + "--include": "Comma-separated subset of `value` / `formula` / `style` / `comment` / `data_validation`", + "--cell-limit": "Safety cap; default 5000", + "--max-chars": "Safety cap; default 200000", + "--skip-hidden": "Skip hidden rows and columns; default `false`" + }, + "+dropdown-get": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--range": "Target range (A1 notation; must include the sheet prefix, e.g. `sheet1!A2:A100`)" + }, + "+csv-get": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--range": "A1 range; reads the whole sheet's `current_region` when omitted", + "--value-render-option": "Cell value render mode: `ToString` / `FormattedValue` / `Formula` / `UnformattedValue`", + "--max-rows": "Safety cap; default 100000", + "--max-chars": "Safety cap; default 200000", + "--include-row-prefix": "Whether to prefix each row with `[row=N]`; default `true`", + "--skip-hidden": "Skip hidden rows and columns; default `false`", + "--dry-run": "Print the request path and parameters without executing" + }, + "+cells-search": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--find": "Text to find (interpreted as regex when `--regex` is set)", + "--range": "Search range (A1 notation); whole sheet when omitted", + "--match-case": "Case-sensitive match", + "--match-entire-cell": "Match the entire cell content", + "--regex": "Interpret `--find` as a regex pattern", + "--include-formulas": "Also search within formula text", + "--max-matches": "Safety cap; default 5000" + }, + "+cells-replace": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--find": "Text to find for replacement", + "--replacement": "Replacement text; pass empty string `\"\"` to delete matched content", + "--range": "Replace range (A1 notation); whole sheet when omitted", + "--match-case": "Also replace within formula text", + "--match-entire-cell": "Also replace within formula text", + "--regex": "Also replace within formula text", + "--include-formulas": "Also replace within formula text", + "--dry-run": "Required preflight: outputs `would_replace_count` for user confirmation before the actual replace" + }, + "+cells-set": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--range": "Write range (A1 notation)", + "--cells": "JSON: `{\"values\": [[...], ...]}`; may include `formula` / `cell_styles` / `comments` / `embed_image` rich-cell fields", + "--allow-overwrite": "Allow overwriting non-empty cells; with default `false`, error if any target cell is non-empty", + "--max-cells": "Safety cap; default 50000" + }, + "+cells-set-style": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--range": "Target range (A1 notation, e.g. `A1:B2`)", + "--background-color": "Background color (hex, e.g. `#ffffff`)", + "--font-color": "Font color (hex, e.g. `#000000`)", + "--font-size": "Font size in px (e.g. 10, 12, 14)", + "--font-style": "Font style enum: `normal` / `italic`", + "--font-weight": "Font weight enum: `normal` / `bold`", + "--font-line": "Font line style enum: `none` / `underline` / `line-through`", + "--horizontal-alignment": "Horizontal alignment enum: `left` / `center` / `right`", + "--vertical-alignment": "Vertical alignment enum: `top` / `middle` / `bottom`", + "--word-wrap": "Word-wrap strategy: `overflow` / `auto-wrap` / `word-clip`; default `overflow`", + "--number-format": "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)", + "--border-styles": "Border config JSON: `{ top: {style,color,weight}, bottom: ..., left: ..., right: ... }`; same shape for all 4 sides" + }, + "+cells-set-image": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--range": "Target cell (A1 notation; must be a single cell, e.g. `A1`; start and end must be identical)", + "--image": "Local image path (PNG / JPEG / JPG / GIF / BMP / JFIF / EXIF / TIFF / BPG / HEIC)", + "--name": "Image file name (with extension); defaults to the basename of `--image`" + }, + "+dropdown-set": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--range": "Target range (A1 notation, e.g. `A2:A100`)", + "--options": "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`; up to 500 items, each ≤100 chars, no commas", + "--colors": "RGB hex color array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`); length must equal `--options`", + "--multiple": "Enable multi-select; default `false`", + "--highlight": "Color-highlight options; default `false`" + }, + "+csv-put": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--range": "Top-left A1 anchor (e.g. `Sheet1!A1`); the bottom-right is inferred from CSV row/column counts", + "--csv": "RFC 4180 CSV text; plain values only (no formulas / styles / comments)", + "--allow-overwrite": "Allow overwriting; with default `false`, error if any target cell is non-empty" + }, + "+cells-clear": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--range": "Range to clear (A1 notation)", + "--scope": "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", + "--yes": "Confirm destructive write (exit code 10 without this flag); clear is irreversible" + }, + "+cells-merge": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--range": "Range to merge / unmerge (A1 notation)", + "--merge-type": "Merge direction (`+cells-merge` only): `all` / `rows` / `columns`; default `all`" + }, + "+cells-unmerge": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--range": "Range to merge / unmerge (A1 notation)", + "--merge-type": "Merge direction (`+cells-merge` only): `all` / `rows` / `columns`; default `all`" + }, + "+rows-resize": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--start": "Start row (0-based, inclusive)", + "--end": "End row (0-based, inclusive)", + "--type": "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default row height) / `auto` (fit content)", + "--size": "Row height in pixels (e.g. 30 / 40 / 60); required when `--type pixel`, ignored otherwise" + }, + "+cols-resize": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--start": "Start column (0-based, inclusive)", + "--end": "End column (0-based, inclusive)", + "--type": "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default column width)", + "--size": "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise" + }, + "+range-move": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--source-range": "Source A1 range", + "--target-sheet-id": "Destination sub-sheet id; defaults to the same sheet as the source", + "--target-range": "Destination A1 range (anchor cell is enough; size inferred from the source)", + "--paste-type": "Paste content type (`+range-copy` only): `values` / `formulas` / `formats` / `all`; default `all`" + }, + "+range-copy": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--source-range": "Source A1 range", + "--target-sheet-id": "Destination sub-sheet id; defaults to the same sheet as the source", + "--target-range": "Destination A1 range (anchor cell is enough; size inferred from the source)", + "--paste-type": "Paste content type (`+range-copy` only): `values` / `formulas` / `formats` / `all`; default `all`" + }, + "+range-fill": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--source-range": "Fill template range (seed cells for the series)", + "--target-range": "Destination fill range (A1 notation)", + "--series-type": "Fill series type: `auto` / `linear` / `growth` / `date` / `copy`; default `auto`" + }, + "+range-sort": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--range": "Sort range (A1 notation; whether the header is included depends on `--has-header`)", + "--sort-keys": "Sort keys JSON: `[{\"col\":\"B\",\"order\":\"asc\"},{\"col\":\"D\",\"order\":\"desc\"}]`", + "--has-header": "Treat the first row as a header and exclude from sort; default `false`" + }, + "+batch-update": { + "--url": "Spreadsheet locator (independent from per-operation sheet locator)", + "--spreadsheet-token": "Spreadsheet locator (independent from per-operation sheet locator)", + "--operations": "JSON: `{\"operations\":[{\"tool\":\"set_cell_range\",\"params\":{...}}, ...]}`; executed serially in array order", + "--yes": "Confirm high-risk write (exit code 10 without this flag)", + "--dry-run": "Print the request template for each sub-operation; no network side effects" + }, + "+cells-batch-set-style": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--ranges": "Target ranges as a JSON array (e.g. `[\"sheet1!A1:B2\",\"sheet1!D1:D10\"]`); each item must include the sheet prefix; the same style is applied to all ranges", + "--background-color": "Background color (hex, e.g. `#ffffff`)", + "--font-color": "Font color (hex, e.g. `#000000`)", + "--font-size": "Font size in px (e.g. 10, 12, 14)", + "--font-style": "Font style enum: `normal` / `italic`", + "--font-weight": "Font weight enum: `normal` / `bold`", + "--font-line": "Font line style enum: `none` / `underline` / `line-through`", + "--horizontal-alignment": "Horizontal alignment enum: `left` / `center` / `right`", + "--vertical-alignment": "Vertical alignment enum: `top` / `middle` / `bottom`", + "--word-wrap": "Word-wrap strategy: `overflow` / `auto-wrap` / `word-clip`; default `overflow`", + "--number-format": "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)", + "--border-styles": "Border config JSON (same shape as in +cells-set-style)" + }, + "+dropdown-update": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--ranges": "Target ranges as a JSON array (e.g. `[\"sheet1!A2:A100\"]`); each item must include the sheet prefix", + "--options": "Options as a JSON array (e.g. `[\"opt1\",\"opt2\"]`)", + "--colors": "Color array (same length as `--options`)", + "--multiple": "Enable multi-select", + "--highlight": "Color-highlight options" + }, + "+dropdown-delete": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--ranges": "Target ranges as a JSON array (up to 100 items; each must include the sheet prefix)", + "--yes": "Confirm high-risk write (exit code 10 without this flag)" + }, + "+chart-list": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--chart-id": "Filter to a single chart reference_id" + }, + "+chart-create": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--properties": "Full chart config JSON (`position` / `data` / `properties` etc.); deeply nested, must be passed as JSON", + "--dry-run": "Print the request template; no side effects" + }, + "+chart-update": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--chart-id": "Target chart reference_id", + "--properties": "Full or sufficiently complete chart config JSON (read back with `+chart-list` first, then patch)" + }, + "+chart-delete": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--chart-id": "Target chart reference_id", + "--yes": "Confirm destructive write (exit code 10 without this flag)" + }, + "+pivot-list": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--pivot-table-id": "Filter by id" + }, + "+pivot-create": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--properties": "JSON: `{\"data_range\":\"Sheet1!A1:F1000\",\"rows\":[...],\"columns\":[...],\"values\":[...],\"filters\":[...],\"show_row_grand_total\":true,\"show_col_grand_total\":true}`", + "--target-sheet-id": "Destination sub-sheet id for the pivot table; auto-creates a new sub-sheet when omitted (recommended)", + "--target-position": "Destination anchor cell (A1 notation, e.g. `A1`); default `A1`", + "--source": "Pivot table source range (A1 notation; format `SheetName!StartCell:EndCell`, e.g. `Sheet1!A1:D100`)", + "--range": "Pivot table placement (single A1 anchor for the top-left, e.g. `F1`); placed at the top-left of a newly created sub-sheet when omitted" + }, + "+pivot-update": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--pivot-table-id": "Target pivot table id", + "--properties": "Full or sufficiently complete pivot config (read back with `+pivot-list --pivot-table-id ` first, then patch)" + }, + "+pivot-delete": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--pivot-table-id": "Target pivot table id", + "--yes": "Confirm destructive write (exit code 10 without this flag); delete is irreversible" + }, + "+cond-format-list": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--rule-id": "Filter by rule id" + }, + "+cond-format-create": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--properties": "Rule config JSON: `style` (required, applied on match), `attrs?` (rule-type-dependent params), `has_ref?`. `rule_type` and `ranges` are separate flags", + "--rule-type": "Conditional format rule type (13 values): `cellValue` / `formula` / `duplicate` / `unique` / `topBottom` / `aboveBelowAverage` / `dataBar` / `colorScale` / `iconSet` / `textContains` / `dateOccurring` / `blankCell` / `errorCell`; takes precedence over the same-named field inside `--properties`", + "--ranges": "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`" + }, + "+cond-format-update": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--rule-id": "Target rule id", + "--properties": "Rule config JSON, same shape as `+cond-format-create --properties`; update overwrites the entire rule", + "--rule-type": "Conditional format rule type (13 values): `cellValue` / `formula` / `duplicate` / `unique` / `topBottom` / `aboveBelowAverage` / `dataBar` / `colorScale` / `iconSet` / `textContains` / `dateOccurring` / `blankCell` / `errorCell`; takes precedence over the same-named field inside `--properties`", + "--ranges": "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`" + }, + "+cond-format-delete": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--rule-id": "Target rule id", + "--yes": "Confirm destructive write (exit code 10 without this flag); delete is irreversible" + }, + "+filter-list": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)" + }, + "+filter-create": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--range": "Filter range (A1 notation, including header row, e.g. `A1:F1000`); do not duplicate the range field inside `--properties`", + "--properties": "Filter rule JSON: `rules` (required, per-column rule array), `filtered_columns?` (active column index hint). `range` is a separate flag (do not duplicate inside this JSON)" + }, + "+filter-update": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--properties": "Filter rule JSON: `rules` and `filtered_columns?`; update overwrites the entire rule set (pass `rules: []` to clear). `range` is a separate flag", + "--range": "Range the filter applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`" + }, + "+filter-delete": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--yes": "Confirm destructive write (exit code 10 without this flag); delete is irreversible" + }, + "+filter-view-list": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--view-id": "Filter by filter-view reference_id (returns the matching single view)" + }, + "+filter-view-create": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--properties": "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", + "--range": "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", + "--view-name": "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`" + }, + "+filter-view-update": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--view-id": "Target filter-view reference_id", + "--properties": "Filter-view rule JSON: `rules?`, `filtered_columns?`. `range` and `view_name` are separate flags; pass at least one of `--properties.rules` / `--range` / `--view-name`", + "--range": "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; omit to keep the current range on update", + "--view-name": "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`" + }, + "+filter-view-delete": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--view-id": "Target filter-view reference_id", + "--yes": "Confirm high-risk write (exit code 10 without this flag)" + }, + "+sparkline-list": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--group-id": "Filter by group_id" + }, + "+sparkline-create": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--properties": "JSON: `{\"type\":\"line|column|winLoss\",\"data_range\":\"A2:F10\",\"target_range\":\"G2:G10\",\"style\":{...},\"special_points\":{...}}`; `type` is a 3-value enum; row/column counts of `data_range` must match `target_range`" + }, + "+sparkline-update": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--group-id": "Target group id", + "--properties": "Full or sufficiently complete sparkline config (read back with `+sparkline-list --group-id ` first, then patch); supports updating `type` / `data_range` / `target_range` / `style` / `special_points` etc." + }, + "+sparkline-delete": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--group-id": "Target group id", + "--yes": "Confirm destructive write (exit code 10 without this flag); delete is irreversible" + }, + "+float-image-list": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--float-image-id": "Filter by id; lists all float images on the sheet when omitted" + }, + "+float-image-create": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--image-name": "Image name, including extension (e.g. `logo.png`)", + "--image-token": "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`", + "--image-uri": "Image reference_id (XOR with `--image-token`); a prefixed string like `<|image|>:abcdef`", + "--position-row": "Row anchor of the image's top-left corner (0-based)", + "--position-col": "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)", + "--size-width": "Image width in pixels", + "--size-height": "Image height in pixels", + "--offset-row": "Pixel offset within the anchor row, on top of `--position-row`", + "--offset-col": "Pixel offset within the anchor column, on top of `--position-col`", + "--z-index": "Image z-index controlling stacking order" + }, + "+float-image-update": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--float-image-id": "Target float image id", + "--image-name": "Image name, including extension (e.g. `logo.png`)", + "--image-token": "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`", + "--image-uri": "Image reference_id (XOR with `--image-token`); a prefixed string like `<|image|>:abcdef`", + "--position-row": "Row anchor of the image's top-left corner (0-based)", + "--position-col": "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)", + "--size-width": "Image width in pixels", + "--size-height": "Image height in pixels", + "--offset-row": "Pixel offset within the anchor row, on top of `--position-row`", + "--offset-col": "Pixel offset within the anchor column, on top of `--position-col`", + "--z-index": "Image z-index controlling stacking order" + }, + "+float-image-delete": { + "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", + "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", + "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", + "--sheet-name": "Sheet name (XOR with `--sheet-id`)", + "--float-image-id": "Target float image id", + "--yes": "Confirm destructive write (exit code 10 without this flag); delete is irreversible" + } +} diff --git a/shortcuts/sheets/flag_desc.go b/shortcuts/sheets/flag_desc.go new file mode 100644 index 000000000..863af7dae --- /dev/null +++ b/shortcuts/sheets/flag_desc.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + _ "embed" + "encoding/json" + "fmt" + "sync" + + "github.com/larksuite/cli/shortcuts/common" +) + +//go:embed data/flag-descriptions.en.json +var flagDescsJSON []byte + +var ( + flagDescsOnce sync.Once + flagDescs map[string]map[string]string + flagDescsErr error +) + +func loadFlagDescs() (map[string]map[string]string, error) { + flagDescsOnce.Do(func() { + flagDescs = make(map[string]map[string]string) + flagDescsErr = json.Unmarshal(flagDescsJSON, &flagDescs) + if flagDescsErr != nil { + flagDescsErr = fmt.Errorf("flag-descriptions.en.json: %w", flagDescsErr) + } + }) + return flagDescs, flagDescsErr +} + +// flagDesc returns the description for a flag from the embedded +// flag-descriptions.en.json. command is e.g. "+workbook-info", +// flagName is e.g. "url" (without "--" prefix). Returns "" when +// no entry exists. +func flagDesc(command, flagName string) string { + descs, err := loadFlagDescs() + if err != nil || descs == nil { + return "" + } + cmd, ok := descs[command] + if !ok { + return "" + } + return cmd["--"+flagName] +} + +// applyFlagDescs patches all Flag.Desc fields in the given shortcut +// slice with values from flag-descriptions.en.json. Flags without a +// JSON entry keep their existing Desc unchanged. +func applyFlagDescs(shortcuts []common.Shortcut) { + descs, err := loadFlagDescs() + if err != nil || descs == nil { + return + } + for i := range shortcuts { + cmd, ok := descs[shortcuts[i].Command] + if !ok { + continue + } + for j := range shortcuts[i].Flags { + key := "--" + shortcuts[i].Flags[j].Name + if desc, found := cmd[key]; found { + shortcuts[i].Flags[j].Desc = desc + } + } + } +} diff --git a/shortcuts/sheets/flag_desc_test.go b/shortcuts/sheets/flag_desc_test.go new file mode 100644 index 000000000..e30d02bc6 --- /dev/null +++ b/shortcuts/sheets/flag_desc_test.go @@ -0,0 +1,112 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "testing" +) + +func TestFlagDescs_EmbedParses(t *testing.T) { + t.Parallel() + descs, err := loadFlagDescs() + if err != nil { + t.Fatalf("loadFlagDescs error: %v", err) + } + if len(descs) == 0 { + t.Fatal("flag-descriptions.en.json has no entries") + } +} + +func TestFlagDescs_SpotCheck(t *testing.T) { + t.Parallel() + cases := []struct { + command string + flagName string + }{ + {"+workbook-info", "url"}, + {"+cells-set", "range"}, + {"+csv-get", "range"}, + {"+batch-update", "operations"}, + {"+chart-create", "properties"}, + } + for _, tc := range cases { + desc := flagDesc(tc.command, tc.flagName) + if desc == "" { + t.Errorf("flagDesc(%q, %q) = empty; want a description", tc.command, tc.flagName) + } + } +} + +func TestFlagDescs_UnknownReturnsEmpty(t *testing.T) { + t.Parallel() + if got := flagDesc("+no-such-cmd", "no-flag"); got != "" { + t.Errorf("expected empty for unknown command; got %q", got) + } +} + +func TestApplyFlagDescs_OverridesHardcodedDesc(t *testing.T) { + t.Parallel() + all := Shortcuts() + descs, err := loadFlagDescs() + if err != nil { + t.Fatalf("loadFlagDescs: %v", err) + } + for _, s := range all { + cmd, ok := descs[s.Command] + if !ok { + continue + } + for _, f := range s.Flags { + key := "--" + f.Name + want, exists := cmd[key] + if !exists { + continue + } + if f.Desc != want { + t.Errorf("%s %s: Desc=%q, want=%q", s.Command, key, f.Desc, want) + } + } + } +} + +func TestApplyFlagDescs_Coverage(t *testing.T) { + t.Parallel() + all := Shortcuts() + descs, err := loadFlagDescs() + if err != nil { + t.Fatalf("loadFlagDescs: %v", err) + } + + // Framework-injected flags are not in the Flags slice but may + // appear in the JSON as documentation. Skip them. + frameworkFlags := map[string]bool{ + "--yes": true, + "--dry-run": true, + } + + // Every non-framework flag in the JSON should appear in the shortcut list. + for cmd, flags := range descs { + for flagKey := range flags { + if frameworkFlags[flagKey] { + continue + } + found := false + for _, s := range all { + if s.Command != cmd { + continue + } + for _, f := range s.Flags { + if "--"+f.Name == flagKey { + found = true + break + } + } + break + } + if !found { + t.Logf("JSON has %s %s but no matching flag in shortcut list (naming mismatch or not yet implemented)", cmd, flagKey) + } + } + } +} diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index c71ef5572..8c7a5c708 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -14,6 +14,7 @@ import "github.com/larksuite/cli/shortcuts/common" // `--print-schema --flag-name ` locally. func Shortcuts() []common.Shortcut { all := shortcutList() + applyFlagDescs(all) withSchema := commandsWithFlagSchema() for i := range all { if _, ok := withSchema[all[i].Command]; ok { diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 00e6f5c87..0590be694 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -54,7 +54,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | `--font-line` | string + Enum | 否 | 字体线条样式 enum:`none` / `underline` / `line-through` | | `--horizontal-alignment` | string + Enum | 否 | 水平对齐 enum:`left` / `center` / `right` | | `--vertical-alignment` | string + Enum | 否 | 垂直对齐 enum:`top` / `middle` / `bottom` | -| `--word-wrap` | string + Enum | 否 | 换行策略 enum:`overflow` / `auto-wrap` / `word-clip`(默认 `overflow`) | +| `--word-wrap` | string + Enum | 否 | 换行策略 enum:`overflow` / `auto-wrap` / `word-clip`,默认 `overflow` | | `--number-format` | string | 否 | 数字格式(例:文本 `@`、数字 `0.00`、货币 `$#,##0.00`、日期 `mm/dd/yyyy`) | | `--border-styles` | string + File + Stdin(复合 JSON) | 否 | 边框配置 JSON(结构同 +cells-set-style) | @@ -65,7 +65,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--ranges` | string + File + Stdin(简单 JSON) | 是 | 目标范围 JSON 数组(如 `["sheet1!A2:A100"]`),每项必须带 sheet 前缀 | -| `--options` | string + File + Stdin(复合 JSON) | 是 | 选项 JSON 数组 | +| `--options` | string + File + Stdin(复合 JSON) | 是 | 选项 JSON 数组(如 `["opt1","opt2"]`) | | `--colors` | string + File + Stdin(简单 JSON) | 否 | 颜色数组(与 `--options` 等长) | | `--multiple` | bool | 否 | 启用多选 | | `--highlight` | bool | 否 | 选项配色 | @@ -76,7 +76,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--ranges` | string + File + Stdin(简单 JSON) | 是 | 目标范围 JSON 数组(最多 100 个,每项带 sheet 前缀) | +| `--ranges` | string + File + Stdin(简单 JSON) | 是 | 目标范围 JSON 数组(最多 100 个,每项必须带 sheet 前缀) | ## Schemas diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md index ee3234234..3b7012a7c 100644 --- a/skills/lark-sheets/references/lark-sheets-chart.md +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -119,7 +119,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--properties` | string + File + Stdin(复合 JSON) | 是 | 图表完整配置 JSON(`position` / `data` / `properties` 等),结构嵌套深,统一走 JSON 注入 | +| `--properties` | string + File + Stdin(复合 JSON) | 是 | 图表完整配置 JSON(`position` / `data` / `properties` 等);结构嵌套深,统一走 JSON 注入 | ### `+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 ea7e44c60..d305f5564 100644 --- a/skills/lark-sheets/references/lark-sheets-conditional-format.md +++ b/skills/lark-sheets/references/lark-sheets-conditional-format.md @@ -95,9 +95,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--properties` | string + File + Stdin(复合 JSON) | 是 | +cond-format-create / --data: 规则配置 JSON,含 `style`(命中样式,必填)和 `attrs?`(规则参数列表,因 rule_type 不同结构而异)/ `has_ref?`。`rule_type` 和 `ranges` 已拎为独立 flag | -| `--rule-type` | string + Enum | 是 | 条件格式规则类型 enum:`cellValue` / `formula` / `duplicate` / `unique` / `topBottom` / `aboveBelowAverage` / `dataBar` / `colorScale` / `iconSet` / `textContains` / `dateOccurring` / `blankCell` / `errorCell`(共 13 项);优先级高于 `--data` | -| `--ranges` | string + File + Stdin(简单 JSON) | 是 | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--data` | +| `--properties` | string + File + Stdin(复合 JSON) | 是 | 规则配置 JSON,含 `style`(命中样式,必填)和 `attrs?`(规则参数列表,因 `rule_type` 不同结构而异)/ `has_ref?`。`rule_type` 和 `ranges` 已拎为独立 flag | +| `--rule-type` | string + Enum | 是 | 条件格式规则类型 enum:`cellValue` / `formula` / `duplicate` / `unique` / `topBottom` / `aboveBelowAverage` / `dataBar` / `colorScale` / `iconSet` / `textContains` / `dateOccurring` / `blankCell` / `errorCell`(共 13 项);优先级高于 `--properties` 中同名字段 | +| `--ranges` | string + File + Stdin(简单 JSON) | 是 | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--properties` 中同名字段 | ### `+cond-format-update` @@ -106,9 +106,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--rule-id` | string | 是 | 目标规则 id | -| `--properties` | string + File + Stdin(复合 JSON) | 是 | +cond-format-update / --data: 同 +cond-format-create;update 是整组覆盖式 | -| `--rule-type` | string + Enum | 是 | 条件格式规则类型 enum:`cellValue` / `formula` / `duplicate` / `unique` / `topBottom` / `aboveBelowAverage` / `dataBar` / `colorScale` / `iconSet` / `textContains` / `dateOccurring` / `blankCell` / `errorCell`(共 13 项);优先级高于 `--data` | -| `--ranges` | string + File + Stdin(简单 JSON) | 是 | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--data` | +| `--properties` | string + File + Stdin(复合 JSON) | 是 | 规则配置 JSON,结构同 `+cond-format-create` 的 `--properties`;update 是整组覆盖式 | +| `--rule-type` | string + Enum | 是 | 条件格式规则类型 enum:`cellValue` / `formula` / `duplicate` / `unique` / `topBottom` / `aboveBelowAverage` / `dataBar` / `colorScale` / `iconSet` / `textContains` / `dateOccurring` / `blankCell` / `errorCell`(共 13 项);优先级高于 `--properties` 中同名字段 | +| `--ranges` | string + File + Stdin(简单 JSON) | 是 | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--properties` 中同名字段 | ### `+cond-format-delete` diff --git a/skills/lark-sheets/references/lark-sheets-filter-view.md b/skills/lark-sheets/references/lark-sheets-filter-view.md index bd0d5f3da..d35ed4cee 100644 --- a/skills/lark-sheets/references/lark-sheets-filter-view.md +++ b/skills/lark-sheets/references/lark-sheets-filter-view.md @@ -48,9 +48,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--properties` | string + File + Stdin(复合 JSON) | 是 | +filter-view-create / --data: 视图规则 JSON,含 `rules?`(列级筛选规则数组)和 `filtered_columns?`。`range` 和 `view_name` 已拎为独立 flag | -| `--range` | string | 是 | 筛选作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--data` 中同名字段(create 必填,必须覆盖表头行) | -| `--view-name` | string | 否 | 视图名称;create 不传时系统自动分配,update 不传时保留原名。优先级高于 `--data` 中同名字段 | +| `--properties` | string + File + Stdin(复合 JSON) | 是 | 筛选视图规则 JSON,含 `rules?`(列级筛选规则数组)和 `filtered_columns?`。`range` 和 `view_name` 是独立 flag | +| `--range` | string | 是 | 筛选视图作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段;create 必填,必须覆盖表头行 | +| `--view-name` | string | 否 | 筛选视图名称;create 不传时系统自动分配,update 不传时保留原名;优先级高于 `--properties` 中同名字段 | ### `+filter-view-update` @@ -58,10 +58,10 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--view-id` | string | 是 | 目标视图 reference_id | -| `--properties` | string + File + Stdin(复合 JSON) | 是 | +filter-view-update / --data: 视图规则 JSON,含 `rules?` 和 `filtered_columns?`。`range` 和 `view_name` 已拎为独立 flag;至少传 `--data.rules` / `--range` / `--view-name` 之一 | -| `--range` | string | 否 | 筛选作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--data` 中同名字段(update 时省略表示保留当前 range) | -| `--view-name` | string | 否 | 视图名称;create 不传时系统自动分配,update 不传时保留原名。优先级高于 `--data` 中同名字段 | +| `--view-id` | string | 是 | 目标筛选视图 reference_id | +| `--properties` | string + File + Stdin(复合 JSON) | 是 | 筛选视图规则 JSON,含 `rules?` 和 `filtered_columns?`。`range` 和 `view_name` 是独立 flag;至少传 `--properties.rules` / `--range` / `--view-name` 之一 | +| `--range` | string | 否 | 筛选视图作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段;update 时省略表示保留当前 range | +| `--view-name` | string | 否 | 筛选视图名称;create 不传时系统自动分配,update 不传时保留原名;优先级高于 `--properties` 中同名字段 | ### `+filter-view-delete` @@ -69,7 +69,7 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--view-id` | string | 是 | 目标视图 reference_id | +| `--view-id` | string | 是 | 目标筛选视图 reference_id | ## Schemas diff --git a/skills/lark-sheets/references/lark-sheets-filter.md b/skills/lark-sheets/references/lark-sheets-filter.md index 6522ed5f7..055e42d17 100644 --- a/skills/lark-sheets/references/lark-sheets-filter.md +++ b/skills/lark-sheets/references/lark-sheets-filter.md @@ -50,8 +50,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | 筛选范围(A1 表示法,含表头行,如 `A1:F1000`);不要重复写入 --data 中的 range 字段 | -| `--properties` | string + File + Stdin(复合 JSON) | 否 | +filter-create / --data: 筛选规则 JSON,含 `rules`(列级筛选规则数组,必填)和 `filtered_columns?`(激活列索引提示)。`range` 是独立 flag(不要再放此 JSON 里) | +| `--range` | string | 是 | 筛选范围(A1 表示法,含表头行,如 `A1:F1000`);不要重复写入 `--properties` 中的 range 字段 | +| `--properties` | string + File + Stdin(复合 JSON) | 否 | 筛选规则 JSON,含 `rules`(列级筛选规则数组,必填)和 `filtered_columns?`(激活列索引提示)。`range` 是独立 flag(不要再放此 JSON 里) | ### `+filter-update` @@ -59,8 +59,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--properties` | string + File + Stdin(复合 JSON) | 是 | +filter-update / --data: 筛选规则 JSON,含 `rules` 和 `filtered_columns?`;update 是整组覆盖式(传空 `rules: []` 清空)。`range` 已拎为独立 flag | -| `--range` | string | 是 | 筛选作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--data` 中同名字段 | +| `--properties` | string + File + Stdin(复合 JSON) | 是 | 筛选规则 JSON,含 `rules` 和 `filtered_columns?`;update 是整组覆盖式(传空 `rules: []` 清空)。`range` 已拎为独立 flag | +| `--range` | string | 是 | 筛选作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段 | ### `+filter-delete` diff --git a/skills/lark-sheets/references/lark-sheets-float-image.md b/skills/lark-sheets/references/lark-sheets-float-image.md index 776ea365d..25d23b4cc 100644 --- a/skills/lark-sheets/references/lark-sheets-float-image.md +++ b/skills/lark-sheets/references/lark-sheets-float-image.md @@ -59,15 +59,15 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--image-name` | string | 是 | 图片名称,含拓展名(如 `logo.png`) | +| `--image-name` | string | 是 | 图片名称,含扩展名(如 `logo.png`) | | `--image-token` | string | XOR | 图片 file_token(与 `--image-uri` 二选一)。常见来源:`+float-image-list` 返回的 `image_token` | -| `--image-uri` | string | XOR | 图片 reference_id(与 `--image-token` 二选一);形如 `<\|image\|>:abcdef` 这种带前缀的字符串,从上游 SKILL.md 的素材引用约定取 | +| `--image-uri` | string | XOR | 图片 reference_id(与 `--image-token` 二选一);形如 `<\|image\|>:abcdef` 这种带前缀的字符串 | | `--position-row` | int | 是 | 图片左上角所在行(0-based) | | `--position-col` | string | 是 | 图片左上角所在列(列字母,如 `A` / `B`) | | `--size-width` | int | 是 | 图片宽度(像素) | | `--size-height` | int | 是 | 图片高度(像素) | -| `--offset-row` | int | 否 | 在 position 基础上的行内偏移(像素) | -| `--offset-col` | int | 否 | 在 position 基础上的列内偏移(像素) | +| `--offset-row` | int | 否 | 在 `--position-row` 基础上的行内偏移(像素) | +| `--offset-col` | int | 否 | 在 `--position-col` 基础上的列内偏移(像素) | | `--z-index` | int | 否 | 图片 Z 轴层级,控制重叠顺序 | ### `+float-image-update` @@ -77,15 +77,15 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--float-image-id` | string | 是 | 目标图片 id | -| `--image-name` | string | 是 | 图片名称,含拓展名(如 `logo.png`) | +| `--image-name` | string | 是 | 图片名称,含扩展名(如 `logo.png`) | | `--image-token` | string | XOR | 图片 file_token(与 `--image-uri` 二选一)。常见来源:`+float-image-list` 返回的 `image_token` | -| `--image-uri` | string | XOR | 图片 reference_id(与 `--image-token` 二选一);形如 `<\|image\|>:abcdef` 这种带前缀的字符串,从上游 SKILL.md 的素材引用约定取 | +| `--image-uri` | string | XOR | 图片 reference_id(与 `--image-token` 二选一);形如 `<\|image\|>:abcdef` 这种带前缀的字符串 | | `--position-row` | int | 是 | 图片左上角所在行(0-based) | | `--position-col` | string | 是 | 图片左上角所在列(列字母,如 `A` / `B`) | | `--size-width` | int | 是 | 图片宽度(像素) | | `--size-height` | int | 是 | 图片高度(像素) | -| `--offset-row` | int | 否 | 在 position 基础上的行内偏移(像素) | -| `--offset-col` | int | 否 | 在 position 基础上的列内偏移(像素) | +| `--offset-row` | int | 否 | 在 `--position-row` 基础上的行内偏移(像素) | +| `--offset-col` | int | 否 | 在 `--position-col` 基础上的列内偏移(像素) | | `--z-index` | int | 否 | 图片 Z 轴层级,控制重叠顺序 | ### `+float-image-delete` diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md index 886ff7789..531a0de53 100644 --- a/skills/lark-sheets/references/lark-sheets-pivot-table.md +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -63,7 +63,7 @@ _公共四件套 · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--properties` | string + File + Stdin(复合 JSON) | 是 | JSON:`{"data_range":"Sheet1!A1:F1000","rows":[...],"columns":[...],"values":[...],"filters":[...],"show_row_grand_total":true,"show_col_grand_total":true}` | | `--target-sheet-id` | string | 否 | 透视表落点子表 id;省略时自动新建子表(推荐) | -| `--target-position` | string | 否 | 落点起始 cell(如 `A1`),默认 `A1` | +| `--target-position` | string | 否 | 落点起始 cell(A1 格式,如 `A1`),默认 `A1` | | `--source` | string | 是 | 透视表源数据区域(A1 表示法,格式 `SheetName!StartCell:EndCell`,如 `Sheet1!A1:D100`) | | `--range` | string | 否 | 透视表放置位置(左上角 A1 单值,如 `F1`);省略时放在新建子表的左上角 | diff --git a/skills/lark-sheets/references/lark-sheets-range-operations.md b/skills/lark-sheets/references/lark-sheets-range-operations.md index 89289826c..0d3918c13 100644 --- a/skills/lark-sheets/references/lark-sheets-range-operations.md +++ b/skills/lark-sheets/references/lark-sheets-range-operations.md @@ -101,8 +101,8 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | 清除范围 A1 格式 | -| `--scope` | string + Enum | 否 | `content` / `formats` / `all`,默认 `content`(仅清内容) | +| `--range` | string | 是 | 清除范围(A1 格式) | +| `--scope` | string + Enum | 否 | 清除范围 enum:`content`(默认,仅清内容)/ `formats`(仅清格式)/ `all`(清内容 + 格式) | ### `+cells-merge` @@ -110,8 +110,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | 待合并 / 取消合并的范围 | -| `--merge-type` | string + Enum | 否 | (仅 `+cells-merge`)`all` / `rows` / `columns`,默认 `all` | +| `--range` | string | 是 | 待合并 / 取消合并的范围(A1 格式) | +| `--merge-type` | string + Enum | 否 | 合并方向 enum(仅 `+cells-merge`):`all` / `rows` / `columns`,默认 `all` | ### `+cells-unmerge` @@ -119,8 +119,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | 待合并 / 取消合并的范围 | -| `--merge-type` | string + Enum | 否 | (仅 `+cells-merge`)`all` / `rows` / `columns`,默认 `all` | +| `--range` | string | 是 | 待合并 / 取消合并的范围(A1 格式) | +| `--merge-type` | string + Enum | 否 | 合并方向 enum(仅 `+cells-merge`):`all` / `rows` / `columns`,默认 `all` | ### `+rows-resize` @@ -128,8 +128,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--start` | int | 是 | 起始行(0-based,inclusive) | -| `--end` | int | 是 | 结束行(0-based,inclusive) | +| `--start` | int | 是 | 起始行(0-based, inclusive) | +| `--end` | int | 是 | 结束行(0-based, inclusive) | | `--type` | string + Enum | 是 | 尺寸方式 enum:`pixel`(指定 px 像素值,需配 `--size`)/ `standard`(重置为默认标准行高)/ `auto`(自动适应内容) | | `--size` | int | 否 | 行高(像素,例:30 / 40 / 60);`--type pixel` 时必填,其它 type 忽略 | @@ -139,8 +139,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--start` | int | 是 | 起始列(0-based,inclusive) | -| `--end` | int | 是 | 结束列(0-based,inclusive) | +| `--start` | int | 是 | 起始列(0-based, inclusive) | +| `--end` | int | 是 | 结束列(0-based, inclusive) | | `--type` | string + Enum | 是 | 尺寸方式 enum:`pixel`(指定 px 像素值,需配 `--size`)/ `standard`(重置为默认标准列宽) | | `--size` | int | 否 | 列宽(像素,例:80 / 120 / 200);`--type pixel` 时必填,其它 type 忽略 | @@ -151,9 +151,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--source-range` | string | 是 | 源 A1 范围 | -| `--target-sheet-id` | string | 否 | 目标子表;省略时同 sheet | -| `--target-range` | string | 是 | 目标 A1 范围(起点 cell 即可,按源尺寸自动推断) | -| `--paste-type` | string + Enum | 否 | (仅 `+range-copy`)`values` / `formulas` / `formats` / `all`,默认 `all` | +| `--target-sheet-id` | string | 否 | 目标子表 id;省略时同源 sheet | +| `--target-range` | string | 是 | 目标 A1 范围(传起点 cell 即可,按源尺寸自动推断) | +| `--paste-type` | string + Enum | 否 | 粘贴内容 enum(仅 `+range-copy`):`values` / `formulas` / `formats` / `all`,默认 `all` | ### `+range-copy` @@ -162,9 +162,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--source-range` | string | 是 | 源 A1 范围 | -| `--target-sheet-id` | string | 否 | 目标子表;省略时同 sheet | -| `--target-range` | string | 是 | 目标 A1 范围(起点 cell 即可,按源尺寸自动推断) | -| `--paste-type` | string + Enum | 否 | (仅 `+range-copy`)`values` / `formulas` / `formats` / `all`,默认 `all` | +| `--target-sheet-id` | string | 否 | 目标子表 id;省略时同源 sheet | +| `--target-range` | string | 是 | 目标 A1 范围(传起点 cell 即可,按源尺寸自动推断) | +| `--paste-type` | string + Enum | 否 | 粘贴内容 enum(仅 `+range-copy`):`values` / `formulas` / `formats` / `all`,默认 `all` | ### `+range-fill` @@ -173,8 +173,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--source-range` | string | 是 | 填充模板范围(系列起始 cells) | -| `--target-range` | string | 是 | 目标填充范围 | -| `--series-type` | string + Enum | 否 | `auto` / `linear` / `growth` / `date` / `copy`,默认 `auto` | +| `--target-range` | string | 是 | 目标填充范围(A1 格式) | +| `--series-type` | string + Enum | 否 | 填充序列类型 enum:`auto` / `linear` / `growth` / `date` / `copy`,默认 `auto` | ### `+range-sort` @@ -182,7 +182,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | 排序范围(含或不含表头由 `--has-header` 决定) | +| `--range` | string | 是 | 排序范围(A1 格式;含或不含表头由 `--has-header` 决定) | | `--sort-keys` | string + File + Stdin(复合 JSON) | 是 | JSON:`[{"col":"B","order":"asc"},{"col":"D","order":"desc"}]` | | `--has-header` | bool | 否 | 第一行是表头不参与排序,默认 false | diff --git a/skills/lark-sheets/references/lark-sheets-read-data.md b/skills/lark-sheets/references/lark-sheets-read-data.md index 54629932d..11315f579 100644 --- a/skills/lark-sheets/references/lark-sheets-read-data.md +++ b/skills/lark-sheets/references/lark-sheets-read-data.md @@ -88,11 +88,11 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | A1 范围,如 `Sheet1!A1:F10`;与 `+cells-set` 等写入 shortcut 保持一致 | -| `--include` | string_slice + Enum | 否 | `value` / `formula` / `style` / `comment` / `data_validation`,逗号拆分 | +| `--range` | string | 是 | A1 范围,如 `Sheet1!A1:F10` | +| `--include` | string_slice + Enum | 否 | `value` / `formula` / `style` / `comment` / `data_validation`,逗号分隔 | | `--cell-limit` | int + Hidden | 否 | 防爆,默认 5000 | | `--max-chars` | int + Hidden | 否 | 防爆,默认 200000 | -| `--skip-hidden` | bool | 否 | 同上 | +| `--skip-hidden` | bool | 否 | 跳过隐藏行列,默认 `false` | ### `+dropdown-get` @@ -100,7 +100,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | 目标范围 A1 格式(含 sheet 前缀,如 `sheet1!A2:A100`) | +| `--range` | string | 是 | 目标范围(A1 格式,必须带 sheet 前缀,如 `sheet1!A2:A100`) | ### `+csv-get` @@ -108,8 +108,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 否 | A1 格式范围;省略时读整表的 `current_region` | -| `--value-render-option` | string + Enum | 否 | `ToString` / `FormattedValue` / `Formula` / `UnformattedValue` | +| `--range` | string | 否 | A1 范围;省略时读整表的 `current_region` | +| `--value-render-option` | string + Enum | 否 | 单元格取值模式 enum:`ToString` / `FormattedValue` / `Formula` / `UnformattedValue` | | `--max-rows` | int + Hidden | 否 | 防爆,默认 100000 | | `--max-chars` | int + Hidden | 否 | 防爆,默认 200000 | | `--include-row-prefix` | bool | 否 | 是否在每行前加 `[row=N]` 前缀,默认 `true` | diff --git a/skills/lark-sheets/references/lark-sheets-search-replace.md b/skills/lark-sheets/references/lark-sheets-search-replace.md index a459769f6..7bc07bd2d 100644 --- a/skills/lark-sheets/references/lark-sheets-search-replace.md +++ b/skills/lark-sheets/references/lark-sheets-search-replace.md @@ -37,11 +37,11 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--find` | string | 是 | 待查找文本(与 `--regex` 配合时是正则) | -| `--range` | string | 否 | A1 格式查找范围;省略时整表 | +| `--find` | string | 是 | 待查找文本(与 `--regex` 配合时按正则解释) | +| `--range` | string | 否 | 查找范围(A1 格式);省略时整表 | | `--match-case` | bool | 否 | 大小写敏感 | | `--match-entire-cell` | bool | 否 | 完全匹配整个单元格 | -| `--regex` | bool | 否 | `--find` 当正则解释 | +| `--regex` | bool | 否 | 把 `--find` 按正则解释 | | `--include-formulas` | bool | 否 | 也在公式文本中搜索 | | `--max-matches` | int + Hidden | 否 | 防爆,默认 5000 | @@ -52,12 +52,12 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--find` | string | 是 | 待替换文本 | -| `--replacement` | string | 是 | 替换为;传 `""` 等价于"删除内容" | -| `--range` | string | 否 | 范围;省略时整表 | -| `--match-case` | bool | 否 | 同 `+cells-search` | -| `--match-entire-cell` | bool | 否 | 同 `+cells-search` | -| `--regex` | bool | 否 | 同 `+cells-search` | -| `--include-formulas` | bool | 否 | 同 `+cells-search` | +| `--replacement` | string | 是 | 替换为;传空字符串 `""` 等价于「删除内容」 | +| `--range` | string | 否 | 替换范围(A1 格式);省略时整表 | +| `--match-case` | bool | 否 | 也在公式文本中替换 | +| `--match-entire-cell` | bool | 否 | 也在公式文本中替换 | +| `--regex` | bool | 否 | 也在公式文本中替换 | +| `--include-formulas` | bool | 否 | 也在公式文本中替换 | ## Examples diff --git a/skills/lark-sheets/references/lark-sheets-sheet-structure.md b/skills/lark-sheets/references/lark-sheets-sheet-structure.md index 658759e56..b35440e46 100644 --- a/skills/lark-sheets/references/lark-sheets-sheet-structure.md +++ b/skills/lark-sheets/references/lark-sheets-sheet-structure.md @@ -57,7 +57,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--include` | string_slice + Enum | 否 | `merges` / `row_heights` / `col_widths` / `hidden_rows` / `hidden_cols` / `groups` / `frozen`,逗号拆分 | +| `--include` | string_slice + Enum | 否 | `merges` / `row_heights` / `col_widths` / `hidden_rows` / `hidden_cols` / `groups` / `frozen`,逗号分隔 | ### `+dim-insert` @@ -68,7 +68,7 @@ _公共四件套 · 系统:`--dry-run`_ | `--dimension` | string + Enum | 是 | `row` / `column` | | `--start` | int | 是 | 插入起始位置(0-based) | | `--end` | int | 是 | 插入结束位置(exclusive) | -| `--inherit-style` | string + Enum | 否 | `before` / `after` / `none`;默认 `none` | +| `--inherit-style` | string + Enum | 否 | 新行/列样式继承策略 enum:`before`(继承前一行/列)/ `after`(继承后一行/列)/ `none`(默认) | ### `+dim-delete` @@ -77,8 +77,8 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--dimension` | string + Enum | 是 | `row` / `column` | -| `--start` | int | 是 | 起始(0-based) | -| `--end` | int | 是 | 结束(exclusive) | +| `--start` | int | 是 | 起始位置(0-based) | +| `--end` | int | 是 | 结束位置(exclusive) | ### `+dim-hide` @@ -87,8 +87,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--dimension` | string + Enum | 是 | `row` / `column` | -| `--start` | int | 是 | 范围 | -| `--end` | int | 是 | 范围 | +| `--start` | int | 是 | 结束位置(0-based, inclusive) | +| `--end` | int | 是 | 结束位置(0-based, inclusive) | ### `+dim-unhide` @@ -97,8 +97,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--dimension` | string + Enum | 是 | `row` / `column` | -| `--start` | int | 是 | 范围 | -| `--end` | int | 是 | 范围 | +| `--start` | int | 是 | 结束位置(0-based, inclusive) | +| `--end` | int | 是 | 结束位置(0-based, inclusive) | ### `+dim-freeze` @@ -116,8 +116,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--dimension` | string + Enum | 是 | `row` / `column` | -| `--start` | int | 是 | 范围 | -| `--end` | int | 是 | 范围 | +| `--start` | int | 是 | 结束位置(0-based, inclusive) | +| `--end` | int | 是 | 结束位置(0-based, inclusive) | | `--depth` | int | 否 | 嵌套层级(`+dim-group` 用),默认 1 | ### `+dim-ungroup` @@ -127,8 +127,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--dimension` | string + Enum | 是 | `row` / `column` | -| `--start` | int | 是 | 范围 | -| `--end` | int | 是 | 范围 | +| `--start` | int | 是 | 结束位置(0-based, inclusive) | +| `--end` | int | 是 | 结束位置(0-based, inclusive) | | `--depth` | int | 否 | 嵌套层级(`+dim-group` 用),默认 1 | ### `+dim-move` @@ -138,9 +138,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--dimension` | string + Enum | 是 | `row` / `column` | -| `--start` | int | 是 | 源起始位置(0-indexed,inclusive) | -| `--end` | int | 是 | 源结束位置(0-indexed,inclusive) | -| `--target` | int | 是 | 目标位置(move 到该 index 前面,0-indexed) | +| `--start` | int | 是 | 源起止区间的起始位置(0-based, inclusive) | +| `--end` | int | 是 | 源起止区间的结束位置(0-based, inclusive) | +| `--target` | int | 是 | 目标位置(move 到该 index 之前;0-based) | ## Examples diff --git a/skills/lark-sheets/references/lark-sheets-sparkline.md b/skills/lark-sheets/references/lark-sheets-sparkline.md index 5cb826f13..a416715c4 100644 --- a/skills/lark-sheets/references/lark-sheets-sparkline.md +++ b/skills/lark-sheets/references/lark-sheets-sparkline.md @@ -54,7 +54,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--group-id` | string | 是 | 目标组 id | -| `--properties` | string + File + Stdin(复合 JSON) | 是 | 完整或足够完整的配置(先 `+sparkline-list --group-id ` 回读再 patch);可改 type / data_range / target_range / style / special_points 等字段 | +| `--properties` | string + File + Stdin(复合 JSON) | 是 | 完整或足够完整的配置(先 `+sparkline-list --group-id ` 回读再 patch);可改 `type` / `data_range` / `target_range` / `style` / `special_points` 等字段 | ### `+sparkline-delete` diff --git a/skills/lark-sheets/references/lark-sheets-workbook.md b/skills/lark-sheets/references/lark-sheets-workbook.md index 937cf5c93..4f9f8928d 100644 --- a/skills/lark-sheets/references/lark-sheets-workbook.md +++ b/skills/lark-sheets/references/lark-sheets-workbook.md @@ -86,7 +86,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--index` | int | 是 | 目标位置(0-based) | -| `--source-index` | int | 否 | 源位置(0-based);可选,未传时由 CLI runtime 根据 --sheet-id / --sheet-name 当前在工作簿中的 index 自动派生 | +| `--source-index` | int | 否 | 源位置(0-based);可选,未传时由 CLI runtime 根据 `--sheet-id` / `--sheet-name` 当前在工作簿中的 index 自动派生 | ### `+sheet-copy` @@ -95,7 +95,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--title` | string | 否 | 副本名称;省略时由服务端生成 | -| `--index` | int | 否 | 副本插入位置 | +| `--index` | int | 否 | 副本插入位置(0-based);省略时附加到末尾 | ### `+sheet-hide` @@ -124,7 +124,7 @@ _系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--title` | string | 是 | 新 spreadsheet 标题 | -| `--folder-token` | string | 否 | 目标文件夹 token;省略放根目录 | +| `--folder-token` | string | 否 | 目标文件夹 token;省略时放在云空间根目录 | | `--headers` | string + File + Stdin(简单 JSON) | 否 | 表头行 JSON 数组:`["列A","列B"]` | | `--values` | string + File + Stdin(简单 JSON) | 否 | 初始数据 JSON 二维数组:`[["alice",95]]` | @@ -136,7 +136,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--file-extension` | string + Enum | 否 | `xlsx` / `csv`,默认 `xlsx`;csv 模式必须配 `--sheet-id` | | `--sheet-id` | string | 否 | 仅 csv 模式必填:指定要导出的 sheet reference_id | -| `--output-path` | string | 否 | 本地保存路径;省略只触发导出不下载 | +| `--output-path` | string | 否 | 本地保存路径;省略时只触发导出不下载 | ## Examples diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index bc8e6e623..331fc4526 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -184,7 +184,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | 写入区域 A1 格式 | +| `--range` | string | 是 | 写入区域(A1 格式) | | `--cells` | string + File + Stdin(复合 JSON) | 是 | JSON:`{"values": [[...], ...]}`;可含 `formula` / `cell_styles` / `comments` / `embed_image` 富信息 | | `--allow-overwrite` | bool | 否 | 允许覆盖非空 cell;默认 false 时遇非空 cell 报错 | | `--max-cells` | int + Hidden | 否 | 防爆,默认 50000 | @@ -195,7 +195,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | 目标范围 A1 格式(如 `A1:B2`) | +| `--range` | string | 是 | 目标范围(A1 格式,如 `A1:B2`) | | `--background-color` | string | 否 | 背景颜色(十六进制,如 `#ffffff`) | | `--font-color` | string | 否 | 字体颜色(十六进制,如 `#000000`) | | `--font-size` | number | 否 | 字体大小(px,例:10、12、14) | @@ -204,7 +204,7 @@ _公共四件套 · 系统:`--dry-run`_ | `--font-line` | string + Enum | 否 | 字体线条样式 enum:`none` / `underline` / `line-through` | | `--horizontal-alignment` | string + Enum | 否 | 水平对齐 enum:`left` / `center` / `right` | | `--vertical-alignment` | string + Enum | 否 | 垂直对齐 enum:`top` / `middle` / `bottom` | -| `--word-wrap` | string + Enum | 否 | 换行策略 enum:`overflow` / `auto-wrap` / `word-clip`(默认 `overflow`) | +| `--word-wrap` | string + Enum | 否 | 换行策略 enum:`overflow` / `auto-wrap` / `word-clip`,默认 `overflow` | | `--number-format` | string | 否 | 数字格式(例:文本 `@`、数字 `0.00`、货币 `$#,##0.00`、日期 `mm/dd/yyyy`) | | `--border-styles` | string + File + Stdin(复合 JSON) | 否 | 边框配置 JSON:`{ top: {style,color,weight}, bottom: ..., left: ..., right: ... }`;4 方向结构相同 | @@ -214,7 +214,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | 目标 cell A1(必须单 cell,如 `A1`;起止 cell 须相同) | +| `--range` | string | 是 | 目标单元格(A1 格式,必须单 cell,如 `A1`;起止 cell 须相同) | | `--image` | string | 是 | 本地图片路径(支持 PNG / JPEG / JPG / GIF / BMP / JFIF / EXIF / TIFF / BPG / HEIC) | | `--name` | string | 否 | 图片文件名(含扩展名);省略时取 `--image` 的 basename | @@ -224,7 +224,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | 目标范围 A1 格式(如 `A2:A100`) | +| `--range` | string | 是 | 目标范围(A1 格式,如 `A2:A100`) | | `--options` | string + File + Stdin(复合 JSON) | 是 | 选项 JSON 数组 `["opt1","opt2"]`;最多 500 项,每项 ≤100 字符,不含逗号 | | `--colors` | string + File + Stdin(简单 JSON) | 否 | RGB hex 颜色数组(如 `["#1FB6C1","#F006C2"]`),长度必须与 `--options` 一致 | | `--multiple` | bool | 否 | 启用多选;默认 `false` | @@ -236,7 +236,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | 目标区域起点 A1(如 `Sheet1!A1`);自动按 CSV 行列数推断终点 | +| `--range` | string | 是 | 目标区域起点 A1(如 `Sheet1!A1`);终点按 CSV 实际行列数自动推断 | | `--csv` | string + File + Stdin(非 JSON 文本) | 是 | RFC 4180 CSV 文本;只写纯值,不带公式/样式/批注 | | `--allow-overwrite` | bool | 否 | 允许覆盖;默认 false 时若目标非空报错 | From ce852e26d80eec1d8456e47bb30661e0310f5005 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Wed, 20 May 2026 12:35:04 +0800 Subject: [PATCH 020/114] feat(shortcuts): support int64 and float64 flag types Flag.Type previously could not express non-integer numbers. Add int64 and float64 cases to flag registration plus Int64/Float64 runtime accessors. --- shortcuts/common/runner.go | 20 ++++++++++++++++++++ shortcuts/common/types.go | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index a97b6b46d..a88dd326e 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -191,6 +191,18 @@ func (ctx *RuntimeContext) Int(name string) int { return v } +// Int64 returns an int64 flag value. +func (ctx *RuntimeContext) Int64(name string) int64 { + v, _ := ctx.Cmd.Flags().GetInt64(name) + return v +} + +// Float64 returns a float64 flag value (non-integer numbers). +func (ctx *RuntimeContext) Float64(name string) float64 { + v, _ := ctx.Cmd.Flags().GetFloat64(name) + return v +} + // StrArray returns a string-array flag value (repeated flag, no CSV splitting). func (ctx *RuntimeContext) StrArray(name string) []string { v, _ := ctx.Cmd.Flags().GetStringArray(name) @@ -1021,6 +1033,14 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f var d int fmt.Sscanf(fl.Default, "%d", &d) cmd.Flags().Int(fl.Name, d, desc) + case "int64": + var d int64 + fmt.Sscanf(fl.Default, "%d", &d) + cmd.Flags().Int64(fl.Name, d, desc) + case "float64": + var d float64 + fmt.Sscanf(fl.Default, "%g", &d) + cmd.Flags().Float64(fl.Name, d, desc) case "string_array": cmd.Flags().StringArray(fl.Name, nil, desc) case "string_slice": diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 51457b260..879689137 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -18,7 +18,7 @@ const ( // Flag describes a CLI flag for a shortcut. type Flag struct { Name string // flag name (e.g. "calendar-id") - Type string // "string" (default) | "bool" | "int" | "string_array" | "string_slice" + Type string // "string" (default) | "bool" | "int" | "int64" | "float64" | "string_array" | "string_slice" Default string // default value as string Desc string // help text Hidden bool // hidden from --help, still readable at runtime From 3d3e2c7f10ca0a2ef020cd7eceacada9e437b4c5 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Wed, 20 May 2026 17:46:49 +0800 Subject: [PATCH 021/114] refactor(sheets): build shortcut flags generically from flag-defs.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace flag-descriptions.en.json with the richer flag-defs.json (full flag definitions: type / default / enum / input / hidden / required / kind) synced from sheet-skill-spec. Add flagsFor(command) to materialize each shortcut's []common.Flag straight from the JSON, skipping system-kind flags the framework injects. Migrate every sheets shortcut (including the CRUD/list/dim/merge/ visibility factories) to Flags: flagsFor("+command"), dropping all hand-written flag literals plus the now-dead publicTokenFlags / publicSheetFlags / styleFlatFlags helpers and enum vars. A coverage test locks the Go-flags-match-JSON contract. Align Go with the new spec where they diverged: +cells-get --ranges → --range, font-size int → float64, +filter-view-create --range now required, +sheet-create row/col-count defaults 200/20. --- shortcuts/sheets/data/flag-defs.json | 4598 +++++++++++++++++ .../sheets/data/flag-descriptions.en.json | 634 --- shortcuts/sheets/execute_paths_test.go | 2 +- shortcuts/sheets/flag_defs.go | 94 + shortcuts/sheets/flag_defs_test.go | 142 + shortcuts/sheets/flag_desc.go | 71 - shortcuts/sheets/flag_desc_test.go | 112 - shortcuts/sheets/flag_schema.go | 6 +- shortcuts/sheets/helpers.go | 53 +- shortcuts/sheets/lark_sheet_batch_update.go | 29 +- shortcuts/sheets/lark_sheet_object_crud.go | 130 +- .../sheets/lark_sheet_object_crud_test.go | 2 +- shortcuts/sheets/lark_sheet_object_list.go | 15 +- .../sheets/lark_sheet_range_operations.go | 75 +- shortcuts/sheets/lark_sheet_read_data.go | 32 +- shortcuts/sheets/lark_sheet_read_data_test.go | 2 +- shortcuts/sheets/lark_sheet_search_replace.go | 21 +- .../sheets/lark_sheet_sheet_structure.go | 62 +- shortcuts/sheets/lark_sheet_workbook.go | 44 +- shortcuts/sheets/lark_sheet_workbook_test.go | 8 +- shortcuts/sheets/lark_sheet_write_cells.go | 38 +- shortcuts/sheets/shortcuts.go | 1 - .../references/lark-sheets-batch-update.md | 63 +- .../references/lark-sheets-chart.md | 10 +- .../lark-sheets-conditional-format.md | 20 +- .../references/lark-sheets-filter-view.md | 20 +- .../references/lark-sheets-filter.md | 10 +- .../references/lark-sheets-float-image.md | 62 +- .../references/lark-sheets-pivot-table.md | 18 +- .../lark-sheets-range-operations.md | 54 +- .../references/lark-sheets-read-data.md | 24 +- .../references/lark-sheets-search-replace.md | 29 +- .../references/lark-sheets-sheet-structure.md | 58 +- .../references/lark-sheets-sparkline.md | 10 +- .../references/lark-sheets-workbook.md | 38 +- .../references/lark-sheets-write-cells.md | 66 +- 36 files changed, 5162 insertions(+), 1491 deletions(-) create mode 100644 shortcuts/sheets/data/flag-defs.json delete mode 100644 shortcuts/sheets/data/flag-descriptions.en.json create mode 100644 shortcuts/sheets/flag_defs.go create mode 100644 shortcuts/sheets/flag_defs_test.go delete mode 100644 shortcuts/sheets/flag_desc.go delete mode 100644 shortcuts/sheets/flag_desc_test.go diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json new file mode 100644 index 000000000..7e8a0d2f1 --- /dev/null +++ b/shortcuts/sheets/data/flag-defs.json @@ -0,0 +1,4598 @@ +{ + "+workbook-info": { + "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": [ + { + "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": "title", + "kind": "own", + "type": "string", + "required": "required", + "desc": "New sheet title" + }, + { + "name": "index", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Insert position; 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": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sheet-delete": { + "risk": "high-risk-write", + "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", + "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": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm high-risk write (exit code 10 without this flag)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sheet-rename": { + "risk": "write", + "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", + "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": "title", + "kind": "own", + "type": "string", + "required": "required", + "desc": "New title" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sheet-move": { + "risk": "write", + "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", + "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": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sheet-copy": { + "risk": "write", + "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", + "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": "title", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Copy title; auto-generated by the server when omitted" + }, + { + "name": "index", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Insert position for the copy (0-based); appended to the end when omitted", + "default": "-1" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sheet-hide": { + "risk": "write", + "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", + "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": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sheet-unhide": { + "risk": "write", + "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", + "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": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sheet-set-tab-color": { + "risk": "write", + "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", + "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": "color", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Hex color like `#FF0000`; pass empty string `\"\"` to clear" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+workbook-create": { + "risk": "write", + "flags": [ + { + "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": "headers", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Header row as a JSON array: `[\"Col A\",\"Col B\"]`", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "values", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Initial data as a 2D JSON array: `[[\"alice\",95]]`", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+workbook-export": { + "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": "file-extension", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Export file format; `csv` mode requires `--sheet-id`", + "default": "xlsx", + "enum": [ + "xlsx", + "csv" + ] + }, + { + "name": "sheet-id", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Required only in csv mode: the sheet reference_id to export" + }, + { + "name": "output-path", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Local save path; export is triggered but not downloaded when omitted" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sheet-info": { + "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", + "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": "include", + "kind": "own", + "type": "string_slice", + "required": "optional", + "desc": "Comma-separated structure info categories to return", + "enum": [ + "merges", + "row_heights", + "col_widths", + "hidden_rows", + "hidden_cols", + "groups", + "frozen" + ] + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Limit structure info to this A1 range; whole sheet when omitted" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dim-insert": { + "risk": "write", + "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", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "dimension", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Dimension (row or column)", + "enum": [ + "row", + "column" + ] + }, + { + "name": "start", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Insert start position (0-based)" + }, + { + "name": "end", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Insert end position (exclusive)" + }, + { + "name": "inherit-style", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)", + "default": "none", + "enum": [ + "before", + "after", + "none" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dim-delete": { + "risk": "high-risk-write", + "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", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "dimension", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Dimension (row or column)", + "enum": [ + "row", + "column" + ] + }, + { + "name": "start", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Start position (0-based)" + }, + { + "name": "end", + "kind": "own", + "type": "int", + "required": "required", + "desc": "End position (exclusive)" + }, + { + "name": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm destructive write (exit code 10 without this flag); row/column deletion is irreversible" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dim-hide": { + "risk": "write", + "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", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "dimension", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Dimension (row or column)", + "enum": [ + "row", + "column" + ] + }, + { + "name": "start", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Start position (0-based, inclusive)" + }, + { + "name": "end", + "kind": "own", + "type": "int", + "required": "required", + "desc": "End position (0-based, inclusive)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dim-unhide": { + "risk": "write", + "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", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "dimension", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Dimension (row or column)", + "enum": [ + "row", + "column" + ] + }, + { + "name": "start", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Start position (0-based, inclusive)" + }, + { + "name": "end", + "kind": "own", + "type": "int", + "required": "required", + "desc": "End position (0-based, inclusive)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dim-freeze": { + "risk": "write", + "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", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "dimension", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Dimension (row or column)", + "enum": [ + "row", + "column" + ] + }, + { + "name": "count", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Freeze the first N rows/columns; pass 0 to unfreeze" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dim-group": { + "risk": "write", + "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", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "dimension", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Dimension (row or column)", + "enum": [ + "row", + "column" + ] + }, + { + "name": "start", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Start position (0-based, inclusive)" + }, + { + "name": "end", + "kind": "own", + "type": "int", + "required": "required", + "desc": "End position (0-based, inclusive)" + }, + { + "name": "depth", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Nesting level (used by `+dim-group`); default 1", + "default": "1" + }, + { + "name": "group-state", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Initial group expand state", + "default": "expand", + "enum": [ + "expand", + "fold" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dim-ungroup": { + "risk": "write", + "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", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "dimension", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Dimension (row or column)", + "enum": [ + "row", + "column" + ] + }, + { + "name": "start", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Start position (0-based, inclusive)" + }, + { + "name": "end", + "kind": "own", + "type": "int", + "required": "required", + "desc": "End position (0-based, inclusive)" + }, + { + "name": "depth", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Nesting level (used by `+dim-group`); default 1", + "default": "1" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dim-move": { + "risk": "write", + "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", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "dimension", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Dimension (row or column)", + "enum": [ + "row", + "column" + ] + }, + { + "name": "start", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Source range start position (0-based, inclusive)" + }, + { + "name": "end", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Source range end position (0-based, inclusive)" + }, + { + "name": "target", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Destination position (move target inserts before this index; 0-based)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cells-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": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string_array", + "required": "required", + "desc": "A1 range, e.g. `Sheet1!A1:F10`" + }, + { + "name": "include", + "kind": "own", + "type": "string_slice", + "required": "optional", + "desc": "Comma-separated info categories to include", + "enum": [ + "value", + "formula", + "style", + "comment", + "data_validation" + ] + }, + { + "name": "cell-limit", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Safety cap; default 5000", + "default": "5000", + "hidden": true + }, + { + "name": "max-chars", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Safety cap; default 200000", + "default": "200000", + "hidden": true + }, + { + "name": "skip-hidden", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Skip hidden rows and columns; default `false`" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dropdown-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": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target range (A1 notation; must include the sheet prefix, e.g. `sheet1!A2:A100`)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+csv-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": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "A1 range; reads the whole sheet's `current_region` when omitted" + }, + { + "name": "value-render-option", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Cell value render mode", + "default": "formatted_value", + "enum": [ + "formatted_value", + "raw_value", + "formula" + ] + }, + { + "name": "max-rows", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Safety cap; default 100000", + "default": "100000", + "hidden": true + }, + { + "name": "max-chars", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Safety cap; default 200000", + "default": "200000", + "hidden": true + }, + { + "name": "include-row-prefix", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Whether to prefix each row with `[row=N]`; default `true`", + "default": "true" + }, + { + "name": "skip-hidden", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Skip hidden rows and columns; default `false`" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "Print the request path and parameters without executing" + } + ] + }, + "+cells-search": { + "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", + "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": "find", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Text to find (interpreted as regex when `--regex` is set)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Search range (A1 notation); whole sheet when omitted" + }, + { + "name": "match-case", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Case-sensitive match" + }, + { + "name": "match-entire-cell", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Match the entire cell content" + }, + { + "name": "regex", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Interpret `--find` as a regex pattern" + }, + { + "name": "include-formulas", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Also search within formula text" + }, + { + "name": "max-matches", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Safety cap; default 5000", + "default": "5000", + "hidden": true + }, + { + "name": "offset", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Skip the first N matches (for pagination); default 0", + "default": "0" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cells-replace": { + "risk": "write", + "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", + "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": "find", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Text to find for replacement" + }, + { + "name": "replacement", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Replacement text; pass empty string `\"\"` to delete matched content" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Replace range (A1 notation); whole sheet when omitted" + }, + { + "name": "match-case", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Case-sensitive match" + }, + { + "name": "match-entire-cell", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Match the entire cell content" + }, + { + "name": "regex", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Interpret `--find` as a regex pattern" + }, + { + "name": "include-formulas", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Also replace within formula text" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "Required preflight: outputs `would_replace_count` for user confirmation before the actual replace" + } + ] + }, + "+cells-set": { + "risk": "write", + "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", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Write range (A1 notation)" + }, + { + "name": "cells", + "kind": "own", + "type": "string", + "required": "required", + "desc": "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "allow-overwrite", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Allow overwriting non-empty cells (default true); set false to error if any target cell is non-empty", + "default": "true" + }, + { + "name": "max-cells", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Safety cap; default 50000", + "default": "50000", + "hidden": true + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cells-set-style": { + "risk": "write", + "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", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "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-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": [ + "normal", + "italic" + ] + }, + { + "name": "font-weight", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Font weight", + "enum": [ + "normal", + "bold" + ] + }, + { + "name": "font-line", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Font line style", + "enum": [ + "none", + "underline", + "line-through" + ] + }, + { + "name": "horizontal-alignment", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Horizontal alignment", + "enum": [ + "left", + "center", + "right" + ] + }, + { + "name": "vertical-alignment", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Vertical alignment", + "enum": [ + "top", + "middle", + "bottom" + ] + }, + { + "name": "word-wrap", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Word-wrap strategy", + "enum": [ + "overflow", + "auto-wrap", + "word-clip" + ] + }, + { + "name": "number-format", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)" + }, + { + "name": "border-styles", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Border config JSON: `{ top: {style,color,weight}, bottom: ..., left: ..., right: ... }`; same shape for all 4 sides", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cells-set-image": { + "risk": "write", + "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", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target cell (A1 notation; must be a single cell, e.g. `A1`; start and end must be identical)" + }, + { + "name": "image", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Local image path (PNG / JPEG / JPG / GIF / BMP / JFIF / EXIF / TIFF / BPG / HEIC)" + }, + { + "name": "name", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Image file name (with extension); defaults to the basename of `--image`" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dropdown-set": { + "risk": "write", + "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", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target range (A1 notation, e.g. `A2:A100`)" + }, + { + "name": "options", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`; up to 500 items, each ≤100 chars, no commas", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "colors", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "RGB hex color array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`); length must equal `--options`", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "multiple", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Enable multi-select; default `false`" + }, + { + "name": "highlight", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Color-highlight options; default `false`" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+csv-put": { + "risk": "write", + "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", + "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": "start-cell", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Top-left A1 anchor (e.g. `Sheet1!A1`); the bottom-right is inferred from CSV row/column counts", + "default": "A1" + }, + { + "name": "csv", + "kind": "own", + "type": "string", + "required": "required", + "desc": "RFC 4180 CSV text; plain values only (no formulas / styles / comments)", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "allow-overwrite", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Allow overwriting (default true); set false to error if any target cell is non-empty", + "default": "true" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cells-clear": { + "risk": "high-risk-write", + "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", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Range to clear (A1 notation)" + }, + { + "name": "scope", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", + "default": "content", + "enum": [ + "content", + "formats", + "all" + ] + }, + { + "name": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm destructive write (exit code 10 without this flag); clear is irreversible" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cells-merge": { + "risk": "write", + "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", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Range to merge / unmerge (A1 notation)" + }, + { + "name": "merge-type", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Merge direction (`+cells-merge` only)", + "default": "all", + "enum": [ + "all", + "rows", + "columns" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cells-unmerge": { + "risk": "write", + "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", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Range to merge / unmerge (A1 notation)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+rows-resize": { + "risk": "write", + "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", + "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": "start", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Start row (0-based, inclusive)" + }, + { + "name": "end", + "kind": "own", + "type": "int", + "required": "required", + "desc": "End row (0-based, inclusive)" + }, + { + "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": [ + "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": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cols-resize": { + "risk": "write", + "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", + "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": "start", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Start column (0-based, inclusive)" + }, + { + "name": "end", + "kind": "own", + "type": "int", + "required": "required", + "desc": "End column (0-based, inclusive)" + }, + { + "name": "type", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default column width)", + "enum": [ + "pixel", + "standard" + ] + }, + { + "name": "size", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise", + "default": "0" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+range-move": { + "risk": "write", + "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", + "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": "source-range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Source A1 range" + }, + { + "name": "target-sheet-id", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Destination sub-sheet id; defaults to the same sheet as the source" + }, + { + "name": "target-range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Destination A1 range (anchor cell is enough; size inferred from the source)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+range-copy": { + "risk": "write", + "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", + "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": "source-range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Source A1 range" + }, + { + "name": "target-sheet-id", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Destination sub-sheet id; defaults to the same sheet as the source" + }, + { + "name": "target-range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Destination A1 range (anchor cell is enough; size inferred from the source)" + }, + { + "name": "paste-type", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Paste content type (`+range-copy` only)", + "default": "all", + "enum": [ + "values", + "formulas", + "formats", + "all" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+range-fill": { + "risk": "write", + "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", + "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": "source-range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Fill template range (seed cells for the series)" + }, + { + "name": "target-range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Destination fill range (A1 notation)" + }, + { + "name": "series-type", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Fill series type", + "default": "auto", + "enum": [ + "auto", + "linear", + "growth", + "date", + "copy" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+range-sort": { + "risk": "write", + "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", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Sort range (A1 notation; whether the header is included depends on `--has-header`)" + }, + { + "name": "sort-keys", + "kind": "own", + "type": "string", + "required": "required", + "desc": "JSON array: `[{\"column\":\"\",\"ascending\":}, ...]`", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "has-header", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Treat the first row as a header and exclude from sort; default `false`" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+batch-update": { + "risk": "high-risk-write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet locator (independent from per-operation sheet locator)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet locator (independent from per-operation sheet locator)" + }, + { + "name": "operations", + "kind": "own", + "type": "string", + "required": "required", + "desc": "JSON array: `[{\"tool_name\":\"set_cell_range\",\"input\":{...}}, ...]`, executed serially; `tool_name` is the underlying tool name (e.g. `set_cell_range` / `clear_cell_range` / `modify_sheet_structure`)", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "continue-on-error", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Continue with remaining operations when a sub-operation fails; default false (abort on first failure)" + }, + { + "name": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm high-risk write (exit code 10 without this flag)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "Print the request template for each sub-operation; no network side effects" + } + ] + }, + "+cells-batch-set-style": { + "risk": "write", + "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": "ranges", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target ranges as a JSON array (e.g. `[\"sheet1!A1:B2\",\"sheet1!D1:D10\"]`); each item must include the sheet prefix; the same style is applied to all ranges", + "input": [ + "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-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": [ + "normal", + "italic" + ] + }, + { + "name": "font-weight", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Font weight", + "enum": [ + "normal", + "bold" + ] + }, + { + "name": "font-line", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Font line style", + "enum": [ + "none", + "underline", + "line-through" + ] + }, + { + "name": "horizontal-alignment", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Horizontal alignment", + "enum": [ + "left", + "center", + "right" + ] + }, + { + "name": "vertical-alignment", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Vertical alignment", + "enum": [ + "top", + "middle", + "bottom" + ] + }, + { + "name": "word-wrap", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Word-wrap strategy", + "enum": [ + "overflow", + "auto-wrap", + "word-clip" + ] + }, + { + "name": "number-format", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)" + }, + { + "name": "border-styles", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Border config JSON (same shape as in +cells-set-style)", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dropdown-update": { + "risk": "write", + "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": "ranges", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target ranges as a JSON array (e.g. `[\"sheet1!A2:A100\"]`); each item must include the sheet prefix", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "options", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Options as a JSON array (e.g. `[\"opt1\",\"opt2\"]`)", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "colors", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Color array (same length as `--options`)", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "multiple", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Enable multi-select" + }, + { + "name": "highlight", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Color-highlight options" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dropdown-delete": { + "risk": "high-risk-write", + "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": "ranges", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target ranges as a JSON array (up to 100 items; each must include the sheet prefix)", + "input": [ + "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", + "desc": "" + } + ] + }, + "+chart-list": { + "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", + "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": "chart-id", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Filter to a single chart reference_id" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+chart-create": { + "risk": "write", + "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", + "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 (`position` / `data` / `properties` etc.); deeply nested, must be passed as JSON", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "Print the request template; no side effects" + } + ] + }, + "+chart-update": { + "risk": "write", + "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", + "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": "chart-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target chart reference_id" + }, + { + "name": "properties", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Full or sufficiently complete chart config JSON (read back with `+chart-list` first, then patch)", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+chart-delete": { + "risk": "high-risk-write", + "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", + "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": "chart-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target chart reference_id" + }, + { + "name": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm destructive write (exit code 10 without this flag)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+pivot-list": { + "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", + "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": "pivot-table-id", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Filter by id" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+pivot-create": { + "risk": "write", + "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", + "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": "JSON: `{\"data_range\":\"Sheet1!A1:F1000\",\"rows\":[...],\"columns\":[...],\"values\":[...],\"filters\":[...],\"show_row_grand_total\":true,\"show_col_grand_total\":true}`", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "target-sheet-id", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Destination sub-sheet id for the pivot table; auto-creates a new sub-sheet when omitted (recommended)" + }, + { + "name": "target-position", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Destination anchor cell (A1 notation, e.g. `A1`); default `A1`", + "default": "A1" + }, + { + "name": "source", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Pivot table source range (A1 notation; format `SheetName!StartCell:EndCell`, e.g. `Sheet1!A1:D100`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Pivot table placement (single A1 anchor for the top-left, e.g. `F1`); placed at the top-left of a newly created sub-sheet when omitted" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+pivot-update": { + "risk": "write", + "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", + "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": "pivot-table-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target pivot table id" + }, + { + "name": "properties", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Full or sufficiently complete pivot config (read back with `+pivot-list --pivot-table-id ` first, then patch)", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+pivot-delete": { + "risk": "high-risk-write", + "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", + "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": "pivot-table-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target pivot table id" + }, + { + "name": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm destructive write (exit code 10 without this flag); delete is irreversible" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cond-format-list": { + "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", + "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": "rule-id", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Filter by rule id" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cond-format-create": { + "risk": "write", + "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", + "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": "Rule config JSON: `style` (required, applied on match), `attrs?` (rule-type-dependent params), `has_ref?`. `rule_type` and `ranges` are separate flags", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "rule-type", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Conditional format rule type; takes precedence over the same-named field inside `--properties`", + "enum": [ + "cellValue", + "formula", + "duplicate", + "unique", + "topBottom", + "aboveBelowAverage", + "dataBar", + "colorScale", + "iconSet", + "textContains", + "dateOccurring", + "blankCell", + "errorCell" + ] + }, + { + "name": "ranges", + "kind": "own", + "type": "string", + "required": "required", + "desc": "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cond-format-update": { + "risk": "write", + "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", + "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": "rule-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target rule id" + }, + { + "name": "properties", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Rule config JSON, same shape as `+cond-format-create --properties`; update overwrites the entire rule", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "rule-type", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Conditional format rule type; takes precedence over the same-named field inside `--properties`", + "enum": [ + "cellValue", + "formula", + "duplicate", + "unique", + "topBottom", + "aboveBelowAverage", + "dataBar", + "colorScale", + "iconSet", + "textContains", + "dateOccurring", + "blankCell", + "errorCell" + ] + }, + { + "name": "ranges", + "kind": "own", + "type": "string", + "required": "required", + "desc": "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cond-format-delete": { + "risk": "high-risk-write", + "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", + "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": "rule-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target rule id" + }, + { + "name": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm destructive write (exit code 10 without this flag); delete is irreversible" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+filter-list": { + "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", + "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": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+filter-create": { + "risk": "write", + "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", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Filter range (A1 notation, including header row, e.g. `A1:F1000`); do not duplicate the range field inside `--properties`" + }, + { + "name": "properties", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Filter rule JSON: `rules` (required, per-column rule array), `filtered_columns?` (active column index hint). `range` is a separate flag (do not duplicate inside this JSON)", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+filter-update": { + "risk": "write", + "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", + "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": "Filter rule JSON: `rules` and `filtered_columns?`; update overwrites the entire rule set (pass `rules: []` to clear). `range` is a separate flag", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Range the filter applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+filter-delete": { + "risk": "high-risk-write", + "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", + "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": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm destructive write (exit code 10 without this flag); delete is irreversible" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+filter-view-list": { + "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", + "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": "view-id", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Filter by filter-view reference_id (returns the matching single view)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+filter-view-create": { + "risk": "write", + "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", + "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": "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", + "input": [ + "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": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+filter-view-update": { + "risk": "write", + "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", + "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": "view-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target filter-view reference_id" + }, + { + "name": "properties", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Filter-view rule JSON: `rules?`, `filtered_columns?`. `range` and `view_name` are separate flags; pass at least one of `--properties.rules` / `--range` / `--view-name`", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; omit to keep the current range on update" + }, + { + "name": "view-name", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+filter-view-delete": { + "risk": "high-risk-write", + "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", + "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": "view-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target filter-view reference_id" + }, + { + "name": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm high-risk write (exit code 10 without this flag)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sparkline-list": { + "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", + "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": "group-id", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Filter by group_id" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sparkline-create": { + "risk": "write", + "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", + "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": "JSON: `{config (shared style), sparklines (array of mini-charts)}`; run `--print-schema` for the full structure", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sparkline-update": { + "risk": "write", + "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", + "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": "group-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target group id" + }, + { + "name": "properties", + "kind": "own", + "type": "string", + "required": "required", + "desc": "JSON: `{config, sparklines}`; read back with `+sparkline-list --group-id ` first, then patch; run `--print-schema` for the full structure", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sparkline-delete": { + "risk": "high-risk-write", + "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", + "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": "group-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target group id" + }, + { + "name": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm destructive write (exit code 10 without this flag); delete is irreversible" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+float-image-list": { + "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", + "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": "float-image-id", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Filter by id; lists all float images on the sheet when omitted" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+float-image-create": { + "risk": "write", + "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", + "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": "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`); a prefixed string like `<|image|>:abcdef`" + }, + { + "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" + }, + { + "name": "size-height", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Image height in pixels" + }, + { + "name": "offset-row", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Pixel offset within the anchor row, on top of `--position-row`" + }, + { + "name": "offset-col", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Pixel offset within the anchor column, on top of `--position-col`" + }, + { + "name": "z-index", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Image z-index controlling stacking order" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+float-image-update": { + "risk": "write", + "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", + "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": "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`); a prefixed string like `<|image|>:abcdef`" + }, + { + "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" + }, + { + "name": "size-height", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Image height in pixels" + }, + { + "name": "offset-row", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Pixel offset within the anchor row, on top of `--position-row`" + }, + { + "name": "offset-col", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Pixel offset within the anchor column, on top of `--position-col`" + }, + { + "name": "z-index", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Image z-index controlling stacking order" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+float-image-delete": { + "risk": "high-risk-write", + "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", + "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": "float-image-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target float image id" + }, + { + "name": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm destructive write (exit code 10 without this flag); delete is irreversible" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + } +} diff --git a/shortcuts/sheets/data/flag-descriptions.en.json b/shortcuts/sheets/data/flag-descriptions.en.json deleted file mode 100644 index 22e25ce10..000000000 --- a/shortcuts/sheets/data/flag-descriptions.en.json +++ /dev/null @@ -1,634 +0,0 @@ -{ - "+workbook-info": { - "--url": "Spreadsheet locator", - "--spreadsheet-token": "Spreadsheet locator", - "--include-properties": "Whether to include each sheet's extended properties; default `true`" - }, - "+sheet-create": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--title": "New sheet title", - "--index": "Insert position; appended to the end when omitted", - "--row-count": "Initial row count; default 100", - "--col-count": "Initial column count; default 26" - }, - "+sheet-delete": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--yes": "Confirm high-risk write (exit code 10 without this flag)" - }, - "+sheet-rename": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--title": "New title" - }, - "+sheet-move": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--index": "Target position (0-based)", - "--source-index": "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`" - }, - "+sheet-copy": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--title": "Copy title; auto-generated by the server when omitted", - "--index": "Insert position for the copy (0-based); appended to the end when omitted" - }, - "+sheet-hide": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)" - }, - "+sheet-unhide": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)" - }, - "+sheet-set-tab-color": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--color": "Hex color like `#FF0000`; pass empty string `\"\"` to clear" - }, - "+workbook-create": { - "--title": "Spreadsheet title", - "--folder-token": "Target folder token; placed at the drive root when omitted", - "--headers": "Header row as a JSON array: `[\"Col A\",\"Col B\"]`", - "--values": "Initial data as a 2D JSON array: `[[\"alice\",95]]`" - }, - "+workbook-export": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--file-extension": "`xlsx` / `csv`; default `xlsx`. `csv` mode requires `--sheet-id`", - "--sheet-id": "Required only in csv mode: the sheet reference_id to export", - "--output-path": "Local save path; export is triggered but not downloaded when omitted" - }, - "+sheet-info": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--include": "Comma-separated subset of `merges` / `row_heights` / `col_widths` / `hidden_rows` / `hidden_cols` / `groups` / `frozen`" - }, - "+dim-insert": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--dimension": "`row` / `column`", - "--start": "Insert start position (0-based)", - "--end": "Insert end position (exclusive)", - "--inherit-style": "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)" - }, - "+dim-delete": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--dimension": "`row` / `column`", - "--start": "Start position (0-based)", - "--end": "End position (exclusive)", - "--yes": "Confirm destructive write (exit code 10 without this flag); row/column deletion is irreversible" - }, - "+dim-hide": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--dimension": "`row` / `column`", - "--start": "End position (0-based, inclusive)", - "--end": "End position (0-based, inclusive)" - }, - "+dim-unhide": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--dimension": "`row` / `column`", - "--start": "End position (0-based, inclusive)", - "--end": "End position (0-based, inclusive)" - }, - "+dim-freeze": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--dimension": "`row` / `column`", - "--count": "Freeze the first N rows/columns; pass 0 to unfreeze" - }, - "+dim-group": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--dimension": "`row` / `column`", - "--start": "End position (0-based, inclusive)", - "--end": "End position (0-based, inclusive)", - "--depth": "Nesting level (used by `+dim-group`); default 1" - }, - "+dim-ungroup": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--dimension": "`row` / `column`", - "--start": "End position (0-based, inclusive)", - "--end": "End position (0-based, inclusive)", - "--depth": "Nesting level (used by `+dim-group`); default 1" - }, - "+dim-move": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--dimension": "`row` / `column`", - "--start": "Source range start position (0-based, inclusive)", - "--end": "Source range end position (0-based, inclusive)", - "--target": "Destination position (move target inserts before this index; 0-based)" - }, - "+cells-get": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--range": "A1 range, e.g. `Sheet1!A1:F10`", - "--include": "Comma-separated subset of `value` / `formula` / `style` / `comment` / `data_validation`", - "--cell-limit": "Safety cap; default 5000", - "--max-chars": "Safety cap; default 200000", - "--skip-hidden": "Skip hidden rows and columns; default `false`" - }, - "+dropdown-get": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--range": "Target range (A1 notation; must include the sheet prefix, e.g. `sheet1!A2:A100`)" - }, - "+csv-get": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--range": "A1 range; reads the whole sheet's `current_region` when omitted", - "--value-render-option": "Cell value render mode: `ToString` / `FormattedValue` / `Formula` / `UnformattedValue`", - "--max-rows": "Safety cap; default 100000", - "--max-chars": "Safety cap; default 200000", - "--include-row-prefix": "Whether to prefix each row with `[row=N]`; default `true`", - "--skip-hidden": "Skip hidden rows and columns; default `false`", - "--dry-run": "Print the request path and parameters without executing" - }, - "+cells-search": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--find": "Text to find (interpreted as regex when `--regex` is set)", - "--range": "Search range (A1 notation); whole sheet when omitted", - "--match-case": "Case-sensitive match", - "--match-entire-cell": "Match the entire cell content", - "--regex": "Interpret `--find` as a regex pattern", - "--include-formulas": "Also search within formula text", - "--max-matches": "Safety cap; default 5000" - }, - "+cells-replace": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--find": "Text to find for replacement", - "--replacement": "Replacement text; pass empty string `\"\"` to delete matched content", - "--range": "Replace range (A1 notation); whole sheet when omitted", - "--match-case": "Also replace within formula text", - "--match-entire-cell": "Also replace within formula text", - "--regex": "Also replace within formula text", - "--include-formulas": "Also replace within formula text", - "--dry-run": "Required preflight: outputs `would_replace_count` for user confirmation before the actual replace" - }, - "+cells-set": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--range": "Write range (A1 notation)", - "--cells": "JSON: `{\"values\": [[...], ...]}`; may include `formula` / `cell_styles` / `comments` / `embed_image` rich-cell fields", - "--allow-overwrite": "Allow overwriting non-empty cells; with default `false`, error if any target cell is non-empty", - "--max-cells": "Safety cap; default 50000" - }, - "+cells-set-style": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--range": "Target range (A1 notation, e.g. `A1:B2`)", - "--background-color": "Background color (hex, e.g. `#ffffff`)", - "--font-color": "Font color (hex, e.g. `#000000`)", - "--font-size": "Font size in px (e.g. 10, 12, 14)", - "--font-style": "Font style enum: `normal` / `italic`", - "--font-weight": "Font weight enum: `normal` / `bold`", - "--font-line": "Font line style enum: `none` / `underline` / `line-through`", - "--horizontal-alignment": "Horizontal alignment enum: `left` / `center` / `right`", - "--vertical-alignment": "Vertical alignment enum: `top` / `middle` / `bottom`", - "--word-wrap": "Word-wrap strategy: `overflow` / `auto-wrap` / `word-clip`; default `overflow`", - "--number-format": "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)", - "--border-styles": "Border config JSON: `{ top: {style,color,weight}, bottom: ..., left: ..., right: ... }`; same shape for all 4 sides" - }, - "+cells-set-image": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--range": "Target cell (A1 notation; must be a single cell, e.g. `A1`; start and end must be identical)", - "--image": "Local image path (PNG / JPEG / JPG / GIF / BMP / JFIF / EXIF / TIFF / BPG / HEIC)", - "--name": "Image file name (with extension); defaults to the basename of `--image`" - }, - "+dropdown-set": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--range": "Target range (A1 notation, e.g. `A2:A100`)", - "--options": "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`; up to 500 items, each ≤100 chars, no commas", - "--colors": "RGB hex color array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`); length must equal `--options`", - "--multiple": "Enable multi-select; default `false`", - "--highlight": "Color-highlight options; default `false`" - }, - "+csv-put": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--range": "Top-left A1 anchor (e.g. `Sheet1!A1`); the bottom-right is inferred from CSV row/column counts", - "--csv": "RFC 4180 CSV text; plain values only (no formulas / styles / comments)", - "--allow-overwrite": "Allow overwriting; with default `false`, error if any target cell is non-empty" - }, - "+cells-clear": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--range": "Range to clear (A1 notation)", - "--scope": "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", - "--yes": "Confirm destructive write (exit code 10 without this flag); clear is irreversible" - }, - "+cells-merge": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--range": "Range to merge / unmerge (A1 notation)", - "--merge-type": "Merge direction (`+cells-merge` only): `all` / `rows` / `columns`; default `all`" - }, - "+cells-unmerge": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--range": "Range to merge / unmerge (A1 notation)", - "--merge-type": "Merge direction (`+cells-merge` only): `all` / `rows` / `columns`; default `all`" - }, - "+rows-resize": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--start": "Start row (0-based, inclusive)", - "--end": "End row (0-based, inclusive)", - "--type": "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default row height) / `auto` (fit content)", - "--size": "Row height in pixels (e.g. 30 / 40 / 60); required when `--type pixel`, ignored otherwise" - }, - "+cols-resize": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--start": "Start column (0-based, inclusive)", - "--end": "End column (0-based, inclusive)", - "--type": "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default column width)", - "--size": "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise" - }, - "+range-move": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--source-range": "Source A1 range", - "--target-sheet-id": "Destination sub-sheet id; defaults to the same sheet as the source", - "--target-range": "Destination A1 range (anchor cell is enough; size inferred from the source)", - "--paste-type": "Paste content type (`+range-copy` only): `values` / `formulas` / `formats` / `all`; default `all`" - }, - "+range-copy": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--source-range": "Source A1 range", - "--target-sheet-id": "Destination sub-sheet id; defaults to the same sheet as the source", - "--target-range": "Destination A1 range (anchor cell is enough; size inferred from the source)", - "--paste-type": "Paste content type (`+range-copy` only): `values` / `formulas` / `formats` / `all`; default `all`" - }, - "+range-fill": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--source-range": "Fill template range (seed cells for the series)", - "--target-range": "Destination fill range (A1 notation)", - "--series-type": "Fill series type: `auto` / `linear` / `growth` / `date` / `copy`; default `auto`" - }, - "+range-sort": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--range": "Sort range (A1 notation; whether the header is included depends on `--has-header`)", - "--sort-keys": "Sort keys JSON: `[{\"col\":\"B\",\"order\":\"asc\"},{\"col\":\"D\",\"order\":\"desc\"}]`", - "--has-header": "Treat the first row as a header and exclude from sort; default `false`" - }, - "+batch-update": { - "--url": "Spreadsheet locator (independent from per-operation sheet locator)", - "--spreadsheet-token": "Spreadsheet locator (independent from per-operation sheet locator)", - "--operations": "JSON: `{\"operations\":[{\"tool\":\"set_cell_range\",\"params\":{...}}, ...]}`; executed serially in array order", - "--yes": "Confirm high-risk write (exit code 10 without this flag)", - "--dry-run": "Print the request template for each sub-operation; no network side effects" - }, - "+cells-batch-set-style": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--ranges": "Target ranges as a JSON array (e.g. `[\"sheet1!A1:B2\",\"sheet1!D1:D10\"]`); each item must include the sheet prefix; the same style is applied to all ranges", - "--background-color": "Background color (hex, e.g. `#ffffff`)", - "--font-color": "Font color (hex, e.g. `#000000`)", - "--font-size": "Font size in px (e.g. 10, 12, 14)", - "--font-style": "Font style enum: `normal` / `italic`", - "--font-weight": "Font weight enum: `normal` / `bold`", - "--font-line": "Font line style enum: `none` / `underline` / `line-through`", - "--horizontal-alignment": "Horizontal alignment enum: `left` / `center` / `right`", - "--vertical-alignment": "Vertical alignment enum: `top` / `middle` / `bottom`", - "--word-wrap": "Word-wrap strategy: `overflow` / `auto-wrap` / `word-clip`; default `overflow`", - "--number-format": "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)", - "--border-styles": "Border config JSON (same shape as in +cells-set-style)" - }, - "+dropdown-update": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--ranges": "Target ranges as a JSON array (e.g. `[\"sheet1!A2:A100\"]`); each item must include the sheet prefix", - "--options": "Options as a JSON array (e.g. `[\"opt1\",\"opt2\"]`)", - "--colors": "Color array (same length as `--options`)", - "--multiple": "Enable multi-select", - "--highlight": "Color-highlight options" - }, - "+dropdown-delete": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--ranges": "Target ranges as a JSON array (up to 100 items; each must include the sheet prefix)", - "--yes": "Confirm high-risk write (exit code 10 without this flag)" - }, - "+chart-list": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--chart-id": "Filter to a single chart reference_id" - }, - "+chart-create": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--properties": "Full chart config JSON (`position` / `data` / `properties` etc.); deeply nested, must be passed as JSON", - "--dry-run": "Print the request template; no side effects" - }, - "+chart-update": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--chart-id": "Target chart reference_id", - "--properties": "Full or sufficiently complete chart config JSON (read back with `+chart-list` first, then patch)" - }, - "+chart-delete": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--chart-id": "Target chart reference_id", - "--yes": "Confirm destructive write (exit code 10 without this flag)" - }, - "+pivot-list": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--pivot-table-id": "Filter by id" - }, - "+pivot-create": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--properties": "JSON: `{\"data_range\":\"Sheet1!A1:F1000\",\"rows\":[...],\"columns\":[...],\"values\":[...],\"filters\":[...],\"show_row_grand_total\":true,\"show_col_grand_total\":true}`", - "--target-sheet-id": "Destination sub-sheet id for the pivot table; auto-creates a new sub-sheet when omitted (recommended)", - "--target-position": "Destination anchor cell (A1 notation, e.g. `A1`); default `A1`", - "--source": "Pivot table source range (A1 notation; format `SheetName!StartCell:EndCell`, e.g. `Sheet1!A1:D100`)", - "--range": "Pivot table placement (single A1 anchor for the top-left, e.g. `F1`); placed at the top-left of a newly created sub-sheet when omitted" - }, - "+pivot-update": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--pivot-table-id": "Target pivot table id", - "--properties": "Full or sufficiently complete pivot config (read back with `+pivot-list --pivot-table-id ` first, then patch)" - }, - "+pivot-delete": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--pivot-table-id": "Target pivot table id", - "--yes": "Confirm destructive write (exit code 10 without this flag); delete is irreversible" - }, - "+cond-format-list": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--rule-id": "Filter by rule id" - }, - "+cond-format-create": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--properties": "Rule config JSON: `style` (required, applied on match), `attrs?` (rule-type-dependent params), `has_ref?`. `rule_type` and `ranges` are separate flags", - "--rule-type": "Conditional format rule type (13 values): `cellValue` / `formula` / `duplicate` / `unique` / `topBottom` / `aboveBelowAverage` / `dataBar` / `colorScale` / `iconSet` / `textContains` / `dateOccurring` / `blankCell` / `errorCell`; takes precedence over the same-named field inside `--properties`", - "--ranges": "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`" - }, - "+cond-format-update": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--rule-id": "Target rule id", - "--properties": "Rule config JSON, same shape as `+cond-format-create --properties`; update overwrites the entire rule", - "--rule-type": "Conditional format rule type (13 values): `cellValue` / `formula` / `duplicate` / `unique` / `topBottom` / `aboveBelowAverage` / `dataBar` / `colorScale` / `iconSet` / `textContains` / `dateOccurring` / `blankCell` / `errorCell`; takes precedence over the same-named field inside `--properties`", - "--ranges": "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`" - }, - "+cond-format-delete": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--rule-id": "Target rule id", - "--yes": "Confirm destructive write (exit code 10 without this flag); delete is irreversible" - }, - "+filter-list": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)" - }, - "+filter-create": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--range": "Filter range (A1 notation, including header row, e.g. `A1:F1000`); do not duplicate the range field inside `--properties`", - "--properties": "Filter rule JSON: `rules` (required, per-column rule array), `filtered_columns?` (active column index hint). `range` is a separate flag (do not duplicate inside this JSON)" - }, - "+filter-update": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--properties": "Filter rule JSON: `rules` and `filtered_columns?`; update overwrites the entire rule set (pass `rules: []` to clear). `range` is a separate flag", - "--range": "Range the filter applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`" - }, - "+filter-delete": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--yes": "Confirm destructive write (exit code 10 without this flag); delete is irreversible" - }, - "+filter-view-list": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--view-id": "Filter by filter-view reference_id (returns the matching single view)" - }, - "+filter-view-create": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--properties": "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", - "--range": "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", - "--view-name": "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`" - }, - "+filter-view-update": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--view-id": "Target filter-view reference_id", - "--properties": "Filter-view rule JSON: `rules?`, `filtered_columns?`. `range` and `view_name` are separate flags; pass at least one of `--properties.rules` / `--range` / `--view-name`", - "--range": "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; omit to keep the current range on update", - "--view-name": "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`" - }, - "+filter-view-delete": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--view-id": "Target filter-view reference_id", - "--yes": "Confirm high-risk write (exit code 10 without this flag)" - }, - "+sparkline-list": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--group-id": "Filter by group_id" - }, - "+sparkline-create": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--properties": "JSON: `{\"type\":\"line|column|winLoss\",\"data_range\":\"A2:F10\",\"target_range\":\"G2:G10\",\"style\":{...},\"special_points\":{...}}`; `type` is a 3-value enum; row/column counts of `data_range` must match `target_range`" - }, - "+sparkline-update": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--group-id": "Target group id", - "--properties": "Full or sufficiently complete sparkline config (read back with `+sparkline-list --group-id ` first, then patch); supports updating `type` / `data_range` / `target_range` / `style` / `special_points` etc." - }, - "+sparkline-delete": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--group-id": "Target group id", - "--yes": "Confirm destructive write (exit code 10 without this flag); delete is irreversible" - }, - "+float-image-list": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--float-image-id": "Filter by id; lists all float images on the sheet when omitted" - }, - "+float-image-create": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--image-name": "Image name, including extension (e.g. `logo.png`)", - "--image-token": "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`", - "--image-uri": "Image reference_id (XOR with `--image-token`); a prefixed string like `<|image|>:abcdef`", - "--position-row": "Row anchor of the image's top-left corner (0-based)", - "--position-col": "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)", - "--size-width": "Image width in pixels", - "--size-height": "Image height in pixels", - "--offset-row": "Pixel offset within the anchor row, on top of `--position-row`", - "--offset-col": "Pixel offset within the anchor column, on top of `--position-col`", - "--z-index": "Image z-index controlling stacking order" - }, - "+float-image-update": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--float-image-id": "Target float image id", - "--image-name": "Image name, including extension (e.g. `logo.png`)", - "--image-token": "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`", - "--image-uri": "Image reference_id (XOR with `--image-token`); a prefixed string like `<|image|>:abcdef`", - "--position-row": "Row anchor of the image's top-left corner (0-based)", - "--position-col": "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)", - "--size-width": "Image width in pixels", - "--size-height": "Image height in pixels", - "--offset-row": "Pixel offset within the anchor row, on top of `--position-row`", - "--offset-col": "Pixel offset within the anchor column, on top of `--position-col`", - "--z-index": "Image z-index controlling stacking order" - }, - "+float-image-delete": { - "--url": "Spreadsheet URL (XOR with `--spreadsheet-token`)", - "--spreadsheet-token": "Spreadsheet token (XOR with `--url`)", - "--sheet-id": "Sheet reference_id (XOR with `--sheet-name`)", - "--sheet-name": "Sheet name (XOR with `--sheet-id`)", - "--float-image-id": "Target float image id", - "--yes": "Confirm destructive write (exit code 10 without this flag); delete is irreversible" - } -} diff --git a/shortcuts/sheets/execute_paths_test.go b/shortcuts/sheets/execute_paths_test.go index b484d1b79..4de3b2997 100644 --- a/shortcuts/sheets/execute_paths_test.go +++ b/shortcuts/sheets/execute_paths_test.go @@ -99,7 +99,7 @@ func TestExecute_CellsGet(t *testing.T) { t.Parallel() stub := toolOutputStub(testToken, "read", `{"ranges":[{"range":"A1:B2","cells":[[{"value":1}]]}]}`) out, err := runShortcutWithStubs(t, CellsGet, - []string{"--url", testURL, "--sheet-id", testSheetID, "--ranges", "A1:B2"}, stub) + []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2"}, stub) if err != nil { t.Fatalf("execute failed: %v\nout=%s", err, out) } diff --git a/shortcuts/sheets/flag_defs.go b/shortcuts/sheets/flag_defs.go new file mode 100644 index 000000000..f16f19eb8 --- /dev/null +++ b/shortcuts/sheets/flag_defs.go @@ -0,0 +1,94 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + _ "embed" + "encoding/json" + "fmt" + "sync" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── flag definitions, sourced from sheet-skill-spec ─────────────────── +// +// data/flag-defs.json is the canonical, full definition of every CLI flag +// (name, type, default, desc, enum, input, hidden, required, kind), +// generated by sheet-skill-spec's sync script. We embed it and build each +// shortcut's []common.Flag from it at assembly time, so flag metadata never +// has to be hand-written in Go. +// +// Flags with kind == "system" (--dry-run, --yes, ...) are NOT materialized +// here: the framework auto-injects them based on Risk / DryRun / HasFormat. +// Do not hand-edit data/flag-defs.json; regenerate via the sync script. + +//go:embed data/flag-defs.json +var flagDefsJSON []byte + +type flagDef struct { + Name string `json:"name"` + Kind string `json:"kind"` // "public" | "own" | "system" + Type string `json:"type"` // string | bool | int | int64 | float64 | string_array | string_slice + Required string `json:"required"` // "required" | "optional" | "xor" + Desc string `json:"desc"` + Default string `json:"default"` + Hidden bool `json:"hidden"` + Enum []string `json:"enum"` + Input []string `json:"input"` +} + +type commandDef struct { + Risk string `json:"risk"` + Flags []flagDef `json:"flags"` +} + +var ( + flagDefsOnce sync.Once + flagDefs map[string]commandDef + flagDefsErr error +) + +func loadFlagDefs() (map[string]commandDef, error) { + flagDefsOnce.Do(func() { + flagDefs = make(map[string]commandDef) + if err := json.Unmarshal(flagDefsJSON, &flagDefs); err != nil { + flagDefsErr = fmt.Errorf("flag-defs.json: %w", err) + } + }) + return flagDefs, flagDefsErr +} + +// flagsFor builds the []common.Flag for a shortcut command directly from +// flag-defs.json. System-kind flags are skipped (the framework injects +// them). Panics if the command is absent or the JSON is malformed — this +// is a build-time data contract, so a missing entry is a programming error +// surfaced loudly at startup rather than a silent empty flag set. +func flagsFor(command string) []common.Flag { + defs, err := loadFlagDefs() + if err != nil { + panic(fmt.Sprintf("sheets: %v", err)) + } + spec, ok := defs[command] + if !ok { + panic(fmt.Sprintf("sheets: no flag-defs.json entry for %q", command)) + } + out := make([]common.Flag, 0, len(spec.Flags)) + for _, df := range spec.Flags { + if df.Kind == "system" { + continue + } + out = append(out, common.Flag{ + Name: df.Name, + Type: df.Type, + Default: df.Default, + Desc: df.Desc, + Hidden: df.Hidden, + Required: df.Required == "required", + Enum: df.Enum, + Input: df.Input, + }) + } + return out +} diff --git a/shortcuts/sheets/flag_defs_test.go b/shortcuts/sheets/flag_defs_test.go new file mode 100644 index 000000000..a4c47fdf3 --- /dev/null +++ b/shortcuts/sheets/flag_defs_test.go @@ -0,0 +1,142 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +// TestFlagDefs_EmbedParses asserts the embedded flag-defs.json blob is valid +// JSON with at least one command entry. +func TestFlagDefs_EmbedParses(t *testing.T) { + t.Parallel() + defs, err := loadFlagDefs() + if err != nil { + t.Fatalf("loadFlagDefs error: %v", err) + } + if len(defs) == 0 { + t.Fatal("flag-defs.json has no command entries") + } +} + +// TestFlagsFor_SkipsSystemFlags verifies system-kind flags (--dry-run, --yes) +// are never materialized into a shortcut's Flags slice — the framework injects +// those based on Risk / DryRun. +func TestFlagsFor_SkipsSystemFlags(t *testing.T) { + t.Parallel() + for _, cmd := range []string{"+sheet-delete", "+batch-update", "+csv-get"} { + for _, f := range flagsFor(cmd) { + if f.Name == "dry-run" || f.Name == "yes" { + t.Errorf("%s: system flag --%s leaked into Flags", cmd, f.Name) + } + } + } +} + +// TestFlagsFor_MapsAllFields spot-checks that name/type/default/enum/input/ +// required/hidden are carried over from the JSON correctly. +func TestFlagsFor_MapsAllFields(t *testing.T) { + t.Parallel() + byName := func(cmd, name string) *common.Flag { + flags := flagsFor(cmd) + for i := range flags { + if flags[i].Name == name { + return &flags[i] + } + } + return nil + } + + // enum + default + rt := byName("+dim-insert", "inherit-style") + if rt == nil || len(rt.Enum) != 3 || rt.Default != "none" { + t.Errorf("+dim-insert --inherit-style not mapped: %+v", rt) + } + // required + title := byName("+sheet-create", "title") + if title == nil || !title.Required { + t.Errorf("+sheet-create --title should be required: %+v", title) + } + // xor is NOT cobra-required (enforced by Validate hooks) + url := byName("+sheet-create", "url") + if url == nil || url.Required { + t.Errorf("+sheet-create --url should not be cobra-required: %+v", url) + } + // hidden + int default + cap := byName("+cells-get", "cell-limit") + if cap == nil || !cap.Hidden || cap.Default != "5000" { + t.Errorf("+cells-get --cell-limit not mapped: %+v", cap) + } + // input sources + cells := byName("+cells-set", "cells") + if cells == nil || len(cells.Input) != 2 { + t.Errorf("+cells-set --cells should support file+stdin: %+v", cells) + } + // float64 type + fs := byName("+cells-set-style", "font-size") + if fs == nil || fs.Type != "float64" { + t.Errorf("+cells-set-style --font-size should be float64: %+v", fs) + } +} + +// TestFlagsFor_EveryRegisteredCommandHasDefs ensures every shortcut returned by +// Shortcuts() has a flag-defs.json entry and that its flags match the JSON's +// non-system flags exactly (name + type + required + default + hidden). This is +// the contract that lets shortcuts drop hand-written flag literals. +func TestFlagsFor_EveryRegisteredCommandHasDefs(t *testing.T) { + t.Parallel() + defs, err := loadFlagDefs() + if err != nil { + t.Fatal(err) + } + for _, s := range Shortcuts() { + spec, ok := defs[s.Command] + if !ok { + t.Errorf("%s has no flag-defs.json entry", s.Command) + continue + } + want := map[string]flagDef{} + for _, df := range spec.Flags { + if df.Kind != "system" { + want[df.Name] = df + } + } + got := map[string]bool{} + for _, f := range s.Flags { + got[f.Name] = true + df, ok := want[f.Name] + if !ok { + t.Errorf("%s --%s present in Go but not in JSON (non-system)", s.Command, f.Name) + continue + } + ft := f.Type + if ft == "" { + ft = "string" + } + jt := df.Type + if jt == "" { + jt = "string" + } + if ft != jt { + t.Errorf("%s --%s type: go=%s json=%s", s.Command, f.Name, ft, jt) + } + if f.Required != (df.Required == "required") { + t.Errorf("%s --%s required: go=%v json=%s", s.Command, f.Name, f.Required, df.Required) + } + if f.Default != df.Default { + t.Errorf("%s --%s default: go=%q json=%q", s.Command, f.Name, f.Default, df.Default) + } + if f.Hidden != df.Hidden { + t.Errorf("%s --%s hidden: go=%v json=%v", s.Command, f.Name, f.Hidden, df.Hidden) + } + } + for name := range want { + if !got[name] { + t.Errorf("%s --%s in JSON but missing from Go Flags", s.Command, name) + } + } + } +} diff --git a/shortcuts/sheets/flag_desc.go b/shortcuts/sheets/flag_desc.go deleted file mode 100644 index 863af7dae..000000000 --- a/shortcuts/sheets/flag_desc.go +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - _ "embed" - "encoding/json" - "fmt" - "sync" - - "github.com/larksuite/cli/shortcuts/common" -) - -//go:embed data/flag-descriptions.en.json -var flagDescsJSON []byte - -var ( - flagDescsOnce sync.Once - flagDescs map[string]map[string]string - flagDescsErr error -) - -func loadFlagDescs() (map[string]map[string]string, error) { - flagDescsOnce.Do(func() { - flagDescs = make(map[string]map[string]string) - flagDescsErr = json.Unmarshal(flagDescsJSON, &flagDescs) - if flagDescsErr != nil { - flagDescsErr = fmt.Errorf("flag-descriptions.en.json: %w", flagDescsErr) - } - }) - return flagDescs, flagDescsErr -} - -// flagDesc returns the description for a flag from the embedded -// flag-descriptions.en.json. command is e.g. "+workbook-info", -// flagName is e.g. "url" (without "--" prefix). Returns "" when -// no entry exists. -func flagDesc(command, flagName string) string { - descs, err := loadFlagDescs() - if err != nil || descs == nil { - return "" - } - cmd, ok := descs[command] - if !ok { - return "" - } - return cmd["--"+flagName] -} - -// applyFlagDescs patches all Flag.Desc fields in the given shortcut -// slice with values from flag-descriptions.en.json. Flags without a -// JSON entry keep their existing Desc unchanged. -func applyFlagDescs(shortcuts []common.Shortcut) { - descs, err := loadFlagDescs() - if err != nil || descs == nil { - return - } - for i := range shortcuts { - cmd, ok := descs[shortcuts[i].Command] - if !ok { - continue - } - for j := range shortcuts[i].Flags { - key := "--" + shortcuts[i].Flags[j].Name - if desc, found := cmd[key]; found { - shortcuts[i].Flags[j].Desc = desc - } - } - } -} diff --git a/shortcuts/sheets/flag_desc_test.go b/shortcuts/sheets/flag_desc_test.go deleted file mode 100644 index e30d02bc6..000000000 --- a/shortcuts/sheets/flag_desc_test.go +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package sheets - -import ( - "testing" -) - -func TestFlagDescs_EmbedParses(t *testing.T) { - t.Parallel() - descs, err := loadFlagDescs() - if err != nil { - t.Fatalf("loadFlagDescs error: %v", err) - } - if len(descs) == 0 { - t.Fatal("flag-descriptions.en.json has no entries") - } -} - -func TestFlagDescs_SpotCheck(t *testing.T) { - t.Parallel() - cases := []struct { - command string - flagName string - }{ - {"+workbook-info", "url"}, - {"+cells-set", "range"}, - {"+csv-get", "range"}, - {"+batch-update", "operations"}, - {"+chart-create", "properties"}, - } - for _, tc := range cases { - desc := flagDesc(tc.command, tc.flagName) - if desc == "" { - t.Errorf("flagDesc(%q, %q) = empty; want a description", tc.command, tc.flagName) - } - } -} - -func TestFlagDescs_UnknownReturnsEmpty(t *testing.T) { - t.Parallel() - if got := flagDesc("+no-such-cmd", "no-flag"); got != "" { - t.Errorf("expected empty for unknown command; got %q", got) - } -} - -func TestApplyFlagDescs_OverridesHardcodedDesc(t *testing.T) { - t.Parallel() - all := Shortcuts() - descs, err := loadFlagDescs() - if err != nil { - t.Fatalf("loadFlagDescs: %v", err) - } - for _, s := range all { - cmd, ok := descs[s.Command] - if !ok { - continue - } - for _, f := range s.Flags { - key := "--" + f.Name - want, exists := cmd[key] - if !exists { - continue - } - if f.Desc != want { - t.Errorf("%s %s: Desc=%q, want=%q", s.Command, key, f.Desc, want) - } - } - } -} - -func TestApplyFlagDescs_Coverage(t *testing.T) { - t.Parallel() - all := Shortcuts() - descs, err := loadFlagDescs() - if err != nil { - t.Fatalf("loadFlagDescs: %v", err) - } - - // Framework-injected flags are not in the Flags slice but may - // appear in the JSON as documentation. Skip them. - frameworkFlags := map[string]bool{ - "--yes": true, - "--dry-run": true, - } - - // Every non-framework flag in the JSON should appear in the shortcut list. - for cmd, flags := range descs { - for flagKey := range flags { - if frameworkFlags[flagKey] { - continue - } - found := false - for _, s := range all { - if s.Command != cmd { - continue - } - for _, f := range s.Flags { - if "--"+f.Name == flagKey { - found = true - break - } - } - break - } - if !found { - t.Logf("JSON has %s %s but no matching flag in shortcut list (naming mismatch or not yet implemented)", cmd, flagKey) - } - } - } -} diff --git a/shortcuts/sheets/flag_schema.go b/shortcuts/sheets/flag_schema.go index b61abd8f7..03bf0b65d 100644 --- a/shortcuts/sheets/flag_schema.go +++ b/shortcuts/sheets/flag_schema.go @@ -96,9 +96,9 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) { } sort.Strings(flags) return json.MarshalIndent(map[string]interface{}{ - "shortcut": command, - "introspectable_flags": flags, - "hint": "run again with --flag-name to dump the JSON Schema for that flag", + "shortcut": command, + "introspectable_flags": flags, + "hint": "run again with --flag-name to dump the JSON Schema for that flag", }, "", " ") } schema, ok := entry[flagName] diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go index 931f3eb5c..53e53be95 100644 --- a/shortcuts/sheets/helpers.go +++ b/shortcuts/sheets/helpers.go @@ -57,26 +57,6 @@ func extractSpreadsheetToken(input string) string { return input } -// publicTokenFlags is the leading pair of every canonical sheets shortcut. -// Shortcuts targeting a single sheet append the public sheet-id / sheet-name -// XOR pair on top of this; workbook-level shortcuts use this pair only. -func publicTokenFlags() []common.Flag { - return []common.Flag{ - {Name: "url", Desc: "spreadsheet URL (XOR --spreadsheet-token)"}, - {Name: "spreadsheet-token", Desc: "spreadsheet token (XOR --url)"}, - } -} - -// publicSheetFlags extends publicTokenFlags with the sheet selector pair. -// Use for any +sheet-* / +cells-* / +dim-* / object shortcut that operates -// on an existing single sub-sheet. -func publicSheetFlags() []common.Flag { - return append(publicTokenFlags(), - common.Flag{Name: "sheet-id", Desc: "sheet reference_id (XOR --sheet-name)"}, - common.Flag{Name: "sheet-name", Desc: "sheet title (XOR --sheet-id)"}, - ) -} - // resolveSheetSelector validates the --sheet-id / --sheet-name XOR and // returns whichever was supplied. Network-free. // @@ -169,35 +149,6 @@ func requireJSONArray(runtime *common.RuntimeContext, name string) ([]interface{ // ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─ -var ( - fontStyleEnum = []string{"normal", "italic"} - fontWeightEnum = []string{"normal", "bold"} - fontLineEnum = []string{"none", "underline", "line-through"} - hAlignEnum = []string{"left", "center", "right"} - vAlignEnum = []string{"top", "middle", "bottom"} - wordWrapEnum = []string{"overflow", "auto-wrap", "word-clip"} -) - -// styleFlatFlags returns the 11 flat style flags + --border-styles that both -// +cells-set-style and +cells-batch-set-style expose. Keeping them in one -// place stops the two shortcuts from drifting apart. -func styleFlatFlags() []common.Flag { - return []common.Flag{ - {Name: "background-color", Desc: "hex background color (e.g. #ffffff)"}, - {Name: "font-color", Desc: "hex font color (e.g. #000000)"}, - {Name: "font-size", Type: "int", Desc: "font size in pixels (e.g. 10, 12, 14)"}, - {Name: "font-style", Enum: fontStyleEnum, Desc: "normal / italic"}, - {Name: "font-weight", Enum: fontWeightEnum, Desc: "normal / bold"}, - {Name: "font-line", Enum: fontLineEnum, Desc: "none / underline / line-through"}, - {Name: "horizontal-alignment", Enum: hAlignEnum, Desc: "left / center / right"}, - {Name: "vertical-alignment", Enum: vAlignEnum, Desc: "top / middle / bottom"}, - {Name: "word-wrap", Enum: wordWrapEnum, Desc: "overflow (default) / auto-wrap / word-clip"}, - {Name: "number-format", Desc: "number format string (e.g. @, 0.00, $#,##0.00, mm/dd/yyyy)"}, - {Name: "border-styles", Input: []string{common.File, common.Stdin}, - Desc: "border JSON: { top, bottom, left, right } each = { color, style, weight }"}, - } -} - // buildCellStyleFromFlags reads the 11 flat style flags and returns the // cell_styles map expected by set_cell_range. Skips any flag the user // didn't set so partial styles work. @@ -209,8 +160,8 @@ func buildCellStyleFromFlags(runtime *common.RuntimeContext) map[string]interfac if v := runtime.Str("font-color"); v != "" { style["font_color"] = v } - if runtime.Changed("font-size") && runtime.Int("font-size") > 0 { - style["font_size"] = runtime.Int("font-size") + if runtime.Changed("font-size") && runtime.Float64("font-size") > 0 { + style["font_size"] = runtime.Float64("font-size") } if v := runtime.Str("font-style"); v != "" { style["font_style"] = v diff --git a/shortcuts/sheets/lark_sheet_batch_update.go b/shortcuts/sheets/lark_sheet_batch_update.go index d91d34cee..364a11ddd 100644 --- a/shortcuts/sheets/lark_sheet_batch_update.go +++ b/shortcuts/sheets/lark_sheet_batch_update.go @@ -40,11 +40,7 @@ var BatchUpdate = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicTokenFlags(), - common.Flag{Name: "operations", Input: []string{common.File, common.Stdin}, Required: true, - Desc: "operations JSON array: [{tool_name, input}, ...] (or an envelope object with operations / continue_on_error)"}, - common.Flag{Name: "continue-on-error", Type: "bool", Desc: "flip the default strict transaction off; partial success is kept on disk"}, - ), + Flags: flagsFor("+batch-update"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -142,12 +138,7 @@ var CellsBatchSetStyle = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append( - append(publicTokenFlags(), - common.Flag{Name: "ranges", Input: []string{common.File, common.Stdin}, Required: true, - Desc: "JSON array of sheet-prefixed A1 ranges (e.g. [\"sheet1!A1:B2\", \"sheet1!D1:E2\"])"}), - styleFlatFlags()..., - ), + Flags: flagsFor("+cells-batch-set-style"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -240,16 +231,7 @@ var DropdownUpdate = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicTokenFlags(), - common.Flag{Name: "ranges", Input: []string{common.File, common.Stdin}, Required: true, - Desc: "JSON array of sheet-prefixed A1 ranges (e.g. [\"sheet1!A2:A100\"])"}, - common.Flag{Name: "options", Input: []string{common.File, common.Stdin}, Required: true, - Desc: "options JSON array (e.g. [\"alpha\",\"beta\"])"}, - common.Flag{Name: "colors", Input: []string{common.File, common.Stdin}, - Desc: "optional RGB hex color array (must equal --options length)"}, - common.Flag{Name: "multiple", Type: "bool", Desc: "enable multi-select"}, - common.Flag{Name: "highlight", Type: "bool", Desc: "color-highlight options"}, - ), + Flags: flagsFor("+dropdown-update"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -294,10 +276,7 @@ var DropdownDelete = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicTokenFlags(), - common.Flag{Name: "ranges", Input: []string{common.File, common.Stdin}, Required: true, - Desc: "JSON array of sheet-prefixed A1 ranges (max 100)"}, - ), + Flags: flagsFor("+dropdown-delete"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err diff --git a/shortcuts/sheets/lark_sheet_object_crud.go b/shortcuts/sheets/lark_sheet_object_crud.go index 96910fa59..4457188b2 100644 --- a/shortcuts/sheets/lark_sheet_object_crud.go +++ b/shortcuts/sheets/lark_sheet_object_crud.go @@ -33,14 +33,10 @@ import ( // idFlag / idField empty → no per-object id flag (only filter uses that // today, and it has its own bespoke shortcuts further down). type objectCRUDSpec struct { - commandPrefix string // e.g. "+chart" → +chart-create / -update / -delete - toolName string // e.g. "manage_chart_object" - idFlag string // e.g. "chart-id" - idField string // e.g. "chart_id" - createDataDesc string // help text for --properties on create - updateDataDesc string // help text for --properties on update - createExtraFlags []common.Flag - updateExtraFlags []common.Flag + commandPrefix string // e.g. "+chart" → +chart-create / -update / -delete + toolName string // e.g. "manage_chart_object" + idFlag string // e.g. "chart-id" + idField string // e.g. "chart_id" // enhanceCreateInput / enhanceUpdateInput, when set, mutate the tool // input after the standard fields are written. Used to inject // shortcut-specific flat flags into the input (typically into the @@ -51,10 +47,7 @@ type objectCRUDSpec struct { } func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { - flags := append(publicSheetFlags(), - common.Flag{Name: "properties", Input: []string{common.File, common.Stdin}, Required: true, Desc: spec.createDataDesc}, - ) - flags = append(flags, spec.createExtraFlags...) + flags := flagsFor(spec.commandPrefix + "-create") return common.Shortcut{ Service: "sheets", Command: spec.commandPrefix + "-create", @@ -121,18 +114,7 @@ func objectCreateInput(runtime *common.RuntimeContext, token, sheetID, sheetName } func newObjectUpdateShortcut(spec objectCRUDSpec) common.Shortcut { - flags := publicSheetFlags() - if spec.idFlag != "" { - flags = append(flags, common.Flag{ - Name: spec.idFlag, Required: true, - Desc: "target object reference_id (maps to " + spec.idField + " on the wire)", - }) - } - flags = append(flags, common.Flag{ - Name: "properties", Input: []string{common.File, common.Stdin}, Required: true, - Desc: spec.updateDataDesc, - }) - flags = append(flags, spec.updateExtraFlags...) + flags := flagsFor(spec.commandPrefix + "-update") return common.Shortcut{ Service: "sheets", Command: spec.commandPrefix + "-update", @@ -205,13 +187,7 @@ func objectUpdateInput(runtime *common.RuntimeContext, token, sheetID, sheetName } func newObjectDeleteShortcut(spec objectCRUDSpec) common.Shortcut { - flags := publicSheetFlags() - if spec.idFlag != "" { - flags = append(flags, common.Flag{ - Name: spec.idFlag, Required: true, - Desc: "target object reference_id (maps to " + spec.idField + " on the wire)", - }) - } + flags := flagsFor(spec.commandPrefix + "-delete") return common.Shortcut{ Service: "sheets", Command: spec.commandPrefix + "-delete", @@ -273,12 +249,10 @@ func objectDeleteInput(runtime *common.RuntimeContext, token, sheetID, sheetName // chart var chartSpec = objectCRUDSpec{ - commandPrefix: "+chart", - toolName: "manage_chart_object", - idFlag: "chart-id", - idField: "chart_id", - createDataDesc: "chart properties JSON (position / data / properties etc.); see lark-sheets-chart.md for the shape", - updateDataDesc: "full or partial chart properties JSON (`+chart-list --chart-id ` first, then patch)", + commandPrefix: "+chart", + toolName: "manage_chart_object", + idFlag: "chart-id", + idField: "chart_id", } var ChartCreate = newObjectCreateShortcut(chartSpec) var ChartUpdate = newObjectUpdateShortcut(chartSpec) @@ -287,18 +261,10 @@ var ChartDelete = newObjectDeleteShortcut(chartSpec) // pivot — create exposes --target-sheet-id / --target-position (top-level // of the tool input) plus --source / --range hoisted from properties. var pivotSpec = objectCRUDSpec{ - commandPrefix: "+pivot", - toolName: "manage_pivot_table_object", - idFlag: "pivot-table-id", - idField: "pivot_table_id", - createDataDesc: "pivot table properties JSON: { rows, columns, values, filters, show_row_grand_total, ... }; --source / --range cover the common scalar fields", - updateDataDesc: "full or partial pivot properties JSON (`+pivot-list --pivot-table-id ` first, then patch)", - createExtraFlags: []common.Flag{ - {Name: "target-sheet-id", Desc: "destination sheet id for the pivot table; omit to auto-create a fresh sheet (recommended)"}, - {Name: "target-position", Default: "A1", Desc: "destination start cell, default A1"}, - {Name: "source", Required: true, Desc: "pivot source range, e.g. Sheet1!A1:D100 (--source overrides any properties.source)"}, - {Name: "range", Desc: "destination top-left A1 cell, e.g. F1 (--range overrides any properties.range)"}, - }, + commandPrefix: "+pivot", + toolName: "manage_pivot_table_object", + idFlag: "pivot-table-id", + idField: "pivot_table_id", enhanceCreateInput: func(rt *common.RuntimeContext, input map[string]interface{}) { if v := strings.TrimSpace(rt.Str("target-sheet-id")); v != "" { input["target_sheet_id"] = v @@ -325,17 +291,6 @@ var PivotDelete = newObjectDeleteShortcut(pivotSpec) // conditional format — CLI surface uses --rule-id (short), wired to the // tool's conditional_format_id on the wire. --rule-type and --ranges are // hoisted out of properties (both required, set on every CRUD write). -var condFormatRuleTypeEnum = []string{ - "cellValue", "formula", "duplicate", "unique", - "topBottom", "aboveBelowAverage", "dataBar", "colorScale", - "iconSet", "textContains", "dateOccurring", "blankCell", "errorCell", -} -var condFormatExtraFlags = []common.Flag{ - {Name: "rule-type", Required: true, Enum: condFormatRuleTypeEnum, - Desc: "rule type enum (cellValue / formula / duplicate / ...); merged into properties.rule.type"}, - {Name: "ranges", Input: []string{common.File, common.Stdin}, Required: true, - Desc: "A1 ranges JSON array (e.g. [\"A1:A100\",\"C2:C50\"]); merged into properties.ranges"}, -} var condFormatEnhance = func(rt *common.RuntimeContext, input map[string]interface{}) { props, _ := input["properties"].(map[string]interface{}) if props == nil { @@ -361,10 +316,6 @@ var condFormatSpec = objectCRUDSpec{ toolName: "manage_conditional_format_object", idFlag: "rule-id", idField: "conditional_format_id", - createDataDesc: "rule JSON: { rule: { operator, value, style, ... }, ... }; --rule-type and --ranges cover the common scalar fields", - updateDataDesc: "full or partial rule JSON (`+cond-format-list --rule-id ` first, then patch); --rule-type and --ranges still required", - createExtraFlags: condFormatExtraFlags, - updateExtraFlags: condFormatExtraFlags, enhanceCreateInput: condFormatEnhance, enhanceUpdateInput: condFormatEnhance, } @@ -374,12 +325,10 @@ var CondFormatDelete = newObjectDeleteShortcut(condFormatSpec) // sparkline — CLI uses --group-id (higher level) as the object selector. var sparklineSpec = objectCRUDSpec{ - commandPrefix: "+sparkline", - toolName: "manage_sparkline_object", - idFlag: "group-id", - idField: "group_id", - createDataDesc: "sparkline group JSON: { type: line|column|winLoss, source_range, target_range, ... }", - updateDataDesc: "full or partial sparkline group JSON (`+sparkline-list --group-id ` first, then patch)", + commandPrefix: "+sparkline", + toolName: "manage_sparkline_object", + idFlag: "group-id", + idField: "group_id", } var SparklineCreate = newObjectCreateShortcut(sparklineSpec) var SparklineUpdate = newObjectUpdateShortcut(sparklineSpec) @@ -389,19 +338,6 @@ var SparklineDelete = newObjectDeleteShortcut(sparklineSpec) // the tool's properties is composed entirely from the position / size / // offset / image_token / image_uri / z_index flat flags. -var floatImageFlatFlags = []common.Flag{ - {Name: "image-name", Required: true, Desc: "image file name with extension (e.g. logo.png)"}, - {Name: "image-token", Desc: "image file_token (XOR --image-uri); commonly returned by +float-image-list"}, - {Name: "image-uri", Desc: "image reference_id (XOR --image-token); upstream-supplied like \"<|image|>:abcdef\""}, - {Name: "position-row", Type: "int", Required: true, Desc: "top-left row index (0-based)"}, - {Name: "position-col", Required: true, Desc: "top-left column letter (e.g. A, B)"}, - {Name: "size-width", Type: "int", Required: true, Desc: "image width in pixels"}, - {Name: "size-height", Type: "int", Required: true, Desc: "image height in pixels"}, - {Name: "offset-row", Type: "int", Desc: "in-cell row offset in pixels (optional)"}, - {Name: "offset-col", Type: "int", Desc: "in-cell column offset in pixels (optional)"}, - {Name: "z-index", Type: "int", Desc: "z-order layer for overlapping images (optional)"}, -} - // floatImageProperties assembles the tool's properties object from the // 10 flat flags. Caller is responsible for marking required flags via // cobra Required:true; this function only enforces the image_token XOR @@ -452,11 +388,7 @@ func newFloatImageWriteShortcut(command, description, op string, withIDFlag, isH if isHighRisk { risk = "high-risk-write" } - flags := publicSheetFlags() - if withIDFlag { - flags = append(flags, common.Flag{Name: "float-image-id", Required: true, Desc: "target image reference_id"}) - } - flags = append(flags, floatImageFlatFlags...) + flags := flagsFor(command) return common.Shortcut{ Service: "sheets", Command: command, @@ -550,10 +482,6 @@ var FloatImageDelete = newObjectDeleteShortcut(floatImageDeleteSpec) // it dispatches via the same One-OpenAPI endpoint as every other shortcut. // --view-name and --range are hoisted out of properties (optional on both // create and update; they always win over properties.{view_name, range}). -var filterViewExtraFlags = []common.Flag{ - {Name: "range", Desc: "filter view range (A1 covering the header, e.g. A1:F1000); overrides properties.range"}, - {Name: "view-name", Desc: "view title; create omits → server-generated, update omits → keep current. Overrides properties.view_name"}, -} var filterViewEnhance = func(rt *common.RuntimeContext, input map[string]interface{}) { props, _ := input["properties"].(map[string]interface{}) if props == nil { @@ -572,10 +500,6 @@ var filterViewSpec = objectCRUDSpec{ toolName: "manage_filter_view_object", idFlag: "view-id", idField: "view_id", - createDataDesc: "filter view JSON: { rules?: [...] , filtered_columns?: [...] }; --range / --view-name cover the scalar fields", - updateDataDesc: "partial update JSON: any of { rules, filtered_columns }; `+filter-view-list --view-id ` first", - createExtraFlags: filterViewExtraFlags, - updateExtraFlags: filterViewExtraFlags, enhanceCreateInput: filterViewEnhance, enhanceUpdateInput: filterViewEnhance, } @@ -600,11 +524,7 @@ var FilterCreate = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "range", Required: true, Desc: "filter range including the header row (e.g. A1:F1000)"}, - common.Flag{Name: "properties", Input: []string{common.File, common.Stdin}, - Desc: "optional rules JSON: { rules: [...], filtered_columns?: [...] }; empty filter when omitted"}, - ), + Flags: flagsFor("+filter-create"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -686,11 +606,7 @@ var FilterUpdate = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "properties", Input: []string{common.File, common.Stdin}, Required: true, - Desc: "patch JSON: { rules: [...], filtered_columns?: [...] } — read with +filter-list first"}, - common.Flag{Name: "range", Required: true, Desc: "filter range A1 (e.g. A1:F1000); overrides properties.range"}, - ), + Flags: flagsFor("+filter-update"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -757,7 +673,7 @@ var FilterDelete = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: publicSheetFlags(), + Flags: flagsFor("+filter-delete"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err diff --git a/shortcuts/sheets/lark_sheet_object_crud_test.go b/shortcuts/sheets/lark_sheet_object_crud_test.go index 933da4a38..88c610159 100644 --- a/shortcuts/sheets/lark_sheet_object_crud_test.go +++ b/shortcuts/sheets/lark_sheet_object_crud_test.go @@ -152,7 +152,7 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { { name: "+filter-view-create", sc: FilterViewCreate, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--properties", `{"view_name":"v1","range":"A1:Z100"}`}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:Z100", "--properties", `{"view_name":"v1"}`}, toolName: "manage_filter_view_object", wantInput: map[string]interface{}{ "excel_id": testToken, diff --git a/shortcuts/sheets/lark_sheet_object_list.go b/shortcuts/sheets/lark_sheet_object_list.go index cb2bc0d82..7c3b442ca 100644 --- a/shortcuts/sheets/lark_sheet_object_list.go +++ b/shortcuts/sheets/lark_sheet_object_list.go @@ -31,17 +31,10 @@ type objectListSpec struct { // Optional id filter. Empty filterFlag → no filter flag exposed. filterFlag string // CLI flag name (without leading --), e.g. "chart-id" filterField string // tool input key, e.g. "chart_id" - filterDesc string // flag help text } func newObjectListShortcut(spec objectListSpec) common.Shortcut { - flags := publicSheetFlags() - if spec.filterFlag != "" { - flags = append(flags, common.Flag{ - Name: spec.filterFlag, - Desc: spec.filterDesc, - }) - } + flags := flagsFor(spec.command) return common.Shortcut{ Service: "sheets", Command: spec.command, @@ -102,7 +95,6 @@ var ChartList = newObjectListShortcut(objectListSpec{ toolName: "get_chart_objects", filterFlag: "chart-id", filterField: "chart_id", - filterDesc: "optional chart reference_id; returns just that chart when set", }) // PivotList — list pivot tables on a sheet. @@ -112,7 +104,6 @@ var PivotList = newObjectListShortcut(objectListSpec{ toolName: "get_pivot_table_objects", filterFlag: "pivot-table-id", filterField: "pivot_table_id", - filterDesc: "optional pivot table reference_id; returns just that pivot when set", }) // CondFormatList — list conditional format rules. CLI's --rule-id maps to @@ -123,7 +114,6 @@ var CondFormatList = newObjectListShortcut(objectListSpec{ toolName: "get_conditional_format_objects", filterFlag: "rule-id", filterField: "conditional_format_id", - filterDesc: "optional rule reference_id (maps to conditional_format_id server-side)", }) // FilterList — list active sheet-level filters. No id filter because each @@ -143,7 +133,6 @@ var FilterViewList = newObjectListShortcut(objectListSpec{ toolName: "get_filter_view_objects", filterFlag: "view-id", filterField: "view_id", - filterDesc: "optional filter-view reference_id; returns just that view when set", }) // SparklineList — list sparkline groups on a sheet. The tool also accepts @@ -155,7 +144,6 @@ var SparklineList = newObjectListShortcut(objectListSpec{ toolName: "get_sparkline_objects", filterFlag: "group-id", filterField: "group_id", - filterDesc: "optional sparkline group reference_id; returns all sparklines in that group", }) // FloatImageList — list floating images on a sheet (vs. embedded @@ -166,5 +154,4 @@ var FloatImageList = newObjectListShortcut(objectListSpec{ toolName: "get_float_image_objects", filterFlag: "float-image-id", filterField: "float_image_id", - filterDesc: "optional floating-image reference_id; returns just that image when set", }) diff --git a/shortcuts/sheets/lark_sheet_range_operations.go b/shortcuts/sheets/lark_sheet_range_operations.go index f249f317a..2910712a1 100644 --- a/shortcuts/sheets/lark_sheet_range_operations.go +++ b/shortcuts/sheets/lark_sheet_range_operations.go @@ -35,11 +35,7 @@ var CellsClear = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "range", Required: true, Desc: "A1 range to clear (e.g. A1:C10 / D3:D / 3:3)"}, - common.Flag{Name: "scope", Enum: []string{"content", "formats", "all"}, Default: "content", - Desc: "what to clear: content (values+formulas only, default) / formats / all"}, - ), + Flags: flagsFor("+cells-clear"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -107,15 +103,7 @@ var CellsUnmerge = newMergeShortcut( ) func newMergeShortcut(command, desc, op string, withMergeType bool) common.Shortcut { - flags := append(publicSheetFlags(), - common.Flag{Name: "range", Required: true, Desc: "A1 range to merge / unmerge (e.g. A1:C3)"}, - ) - if withMergeType { - flags = append(flags, common.Flag{ - Name: "merge-type", Enum: []string{"all", "rows", "columns"}, Default: "all", - Desc: "merge strategy: all (one cell) / rows / columns", - }) - } + flags := flagsFor(command) return common.Shortcut{ Service: "sheets", Command: command, @@ -191,9 +179,6 @@ func mergeInput(runtime *common.RuntimeContext, token, sheetID, sheetName, op st // Both shortcuts share the underlying resize_range tool; --end is inclusive // in the new CLI surface (was exclusive in the legacy +dim-resize). -var rowsResizeTypeEnum = []string{"pixel", "standard", "auto"} -var colsResizeTypeEnum = []string{"pixel", "standard"} - // RowsResize wraps resize_range for row heights. --type auto enables // auto-fit (rows only); --type pixel requires --size. var RowsResize = common.Shortcut{ @@ -204,14 +189,8 @@ var RowsResize = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "start", Type: "int", Required: true, Desc: "0-based start row (inclusive)"}, - common.Flag{Name: "end", Type: "int", Required: true, Desc: "0-based end row (inclusive)"}, - common.Flag{Name: "type", Required: true, Enum: rowsResizeTypeEnum, - Desc: "sizing mode: `pixel` (needs --size) / `standard` (reset to default) / `auto` (auto-fit row height)"}, - common.Flag{Name: "size", Type: "int", Default: "0", Desc: "row height in pixels (e.g. 30); required with --type pixel, ignored otherwise"}, - ), - Validate: validateResize("row"), + Flags: flagsFor("+rows-resize"), + Validate: validateResize("row"), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) @@ -245,14 +224,8 @@ var ColsResize = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "start", Type: "int", Required: true, Desc: "0-based start column (inclusive)"}, - common.Flag{Name: "end", Type: "int", Required: true, Desc: "0-based end column (inclusive)"}, - common.Flag{Name: "type", Required: true, Enum: colsResizeTypeEnum, - Desc: "sizing mode: `pixel` (needs --size) / `standard` (reset to default); `auto` is rows-only"}, - common.Flag{Name: "size", Type: "int", Default: "0", Desc: "column width in pixels (e.g. 120); required with --type pixel, ignored otherwise"}, - ), - Validate: validateResize("column"), + Flags: flagsFor("+cols-resize"), + Validate: validateResize("column"), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) @@ -358,11 +331,7 @@ var RangeMove = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "source-range", Required: true, Desc: "source A1 range (e.g. A1:C5)"}, - common.Flag{Name: "target-range", Required: true, Desc: "target A1 starting cell (size derived from source)"}, - common.Flag{Name: "target-sheet-id", Desc: "destination sheet id (cross-sheet); omit for same sheet"}, - ), + Flags: flagsFor("+range-move"), Validate: validateRangeMoveOrCopy, DryRun: transformDryRunFn("move", false, false), Execute: transformExecuteFn("move", false, false), @@ -378,13 +347,7 @@ var RangeCopy = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "source-range", Required: true, Desc: "source A1 range"}, - common.Flag{Name: "target-range", Required: true, Desc: "target A1 starting cell"}, - common.Flag{Name: "target-sheet-id", Desc: "destination sheet id (cross-sheet); omit for same sheet"}, - common.Flag{Name: "paste-type", Enum: []string{"values", "formulas", "formats", "all"}, Default: "all", - Desc: "what to copy: values / formulas / formats / all (default)"}, - ), + Flags: flagsFor("+range-copy"), Validate: validateRangeMoveOrCopy, DryRun: transformDryRunFn("copy", true, false), Execute: transformExecuteFn("copy", true, false), @@ -402,12 +365,7 @@ var RangeFill = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "source-range", Required: true, Desc: "template A1 range with seed cells"}, - common.Flag{Name: "target-range", Required: true, Desc: "target fill range (must be disjoint from source)"}, - common.Flag{Name: "series-type", Enum: []string{"auto", "linear", "growth", "date", "copy"}, Default: "auto", - Desc: "auto / linear / growth / date → tool fillSeries; copy → tool copyCells"}, - ), + Flags: flagsFor("+range-fill"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -455,12 +413,7 @@ var RangeSort = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "range", Required: true, Desc: "A1 range to sort"}, - common.Flag{Name: "sort-keys", Input: []string{common.File, common.Stdin}, Required: true, - Desc: "sort keys JSON, e.g. [{\"col\":\"B\",\"order\":\"asc\"},{\"col\":\"D\",\"order\":\"desc\"}]"}, - common.Flag{Name: "has-header", Type: "bool", Desc: "treat first row as header (excluded from sort); default false"}, - ), + Flags: flagsFor("+range-sort"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -613,10 +566,10 @@ func rangeSortInput(runtime *common.RuntimeContext, token, sheetID, sheetName st return nil, err } input := map[string]interface{}{ - "excel_id": token, - "operation": "sort", - "range": strings.TrimSpace(runtime.Str("range")), - "sort_conditions": keys, + "excel_id": token, + "operation": "sort", + "range": strings.TrimSpace(runtime.Str("range")), + "sort_conditions": keys, } sheetSelectorForToolInput(input, sheetID, sheetName) if runtime.Bool("has-header") { diff --git a/shortcuts/sheets/lark_sheet_read_data.go b/shortcuts/sheets/lark_sheet_read_data.go index 428c209ab..4c87c5f1b 100644 --- a/shortcuts/sheets/lark_sheet_read_data.go +++ b/shortcuts/sheets/lark_sheet_read_data.go @@ -19,8 +19,6 @@ import ( // The sandbox tool (export_sheet_to_sandbox) is Sheet-Tool-only and has no // CLI surface here. -var cellsGetIncludeEnum = []string{"value", "formula", "style", "comment", "data_validation"} - // CellsGet wraps get_cell_ranges: read multiple A1 ranges and return per-cell // values, formulas, styles, and other metadata as requested via --include. var CellsGet = common.Shortcut{ @@ -31,13 +29,7 @@ var CellsGet = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:read"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "ranges", Type: "string_array", Required: true, Desc: "A1 ranges (repeat: --ranges A1:B2 --ranges D1:E5)"}, - common.Flag{Name: "include", Type: "string_slice", Enum: cellsGetIncludeEnum, Desc: "categories to include (default: value+style). value|formula|style|comment|data_validation"}, - common.Flag{Name: "skip-hidden", Type: "bool", Desc: "skip hidden rows/cols"}, - common.Flag{Name: "cell-limit", Type: "int", Default: "5000", Hidden: true, Desc: "anti-burst cell scan cap"}, - common.Flag{Name: "max-chars", Type: "int", Default: "200000", Hidden: true, Desc: "anti-burst response char cap"}, - ), + Flags: flagsFor("+cells-get"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -45,8 +37,8 @@ var CellsGet = common.Shortcut{ if _, _, err := resolveSheetSelector(runtime); err != nil { return err } - if len(runtime.StrArray("ranges")) == 0 { - return common.FlagErrorf("--ranges is required") + if len(runtime.StrArray("range")) == 0 { + return common.FlagErrorf("--range is required") } return nil }, @@ -76,7 +68,7 @@ var CellsGet = common.Shortcut{ func cellsGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { input := map[string]interface{}{ "excel_id": token, - "ranges": runtime.StrArray("ranges"), + "ranges": runtime.StrArray("range"), } sheetSelectorForToolInput(input, sheetID, sheetName) applyIncludeToCellsGet(input, runtime.StrSlice("include")) @@ -129,17 +121,7 @@ var CsvGet = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:read"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "range", Desc: "A1 range; omit for the sheet's current_region"}, - common.Flag{ - Name: "value-render-option", Enum: []string{"formatted_value", "raw_value", "formula"}, - Desc: "value rendering: formatted_value (default) / raw_value / formula", - }, - common.Flag{Name: "include-row-prefix", Type: "bool", Default: "true", Desc: "keep [row=N] line prefix; pass --include-row-prefix=false to strip"}, - common.Flag{Name: "skip-hidden", Type: "bool", Desc: "skip hidden rows/cols"}, - common.Flag{Name: "max-rows", Type: "int", Default: "100000", Hidden: true, Desc: "anti-burst row cap"}, - common.Flag{Name: "max-chars", Type: "int", Default: "200000", Hidden: true, Desc: "anti-burst char cap"}, - ), + Flags: flagsFor("+csv-get"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -228,9 +210,7 @@ var DropdownGet = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:read"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicTokenFlags(), - common.Flag{Name: "range", Required: true, Desc: "A1 range with sheet prefix (e.g. sheet1!A2:A100)"}, - ), + Flags: flagsFor("+dropdown-get"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err diff --git a/shortcuts/sheets/lark_sheet_read_data_test.go b/shortcuts/sheets/lark_sheet_read_data_test.go index c59441417..ea57d1762 100644 --- a/shortcuts/sheets/lark_sheet_read_data_test.go +++ b/shortcuts/sheets/lark_sheet_read_data_test.go @@ -23,7 +23,7 @@ func TestReadDataShortcuts_DryRun(t *testing.T) { { name: "+cells-get multi-range + include=style,formula", sc: CellsGet, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--ranges", "A1:B2", "--ranges", "D1:E5", "--include", "style,formula"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--range", "D1:E5", "--include", "style,formula"}, toolName: "get_cell_ranges", wantInput: map[string]interface{}{ "excel_id": testToken, diff --git a/shortcuts/sheets/lark_sheet_search_replace.go b/shortcuts/sheets/lark_sheet_search_replace.go index a6d94db98..3c6f79ebd 100644 --- a/shortcuts/sheets/lark_sheet_search_replace.go +++ b/shortcuts/sheets/lark_sheet_search_replace.go @@ -27,16 +27,7 @@ var CellsSearch = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:read"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "find", Required: true, Desc: "text to search for (regex when --regex is set)"}, - common.Flag{Name: "range", Desc: "optional A1 range to scope the search"}, - common.Flag{Name: "match-case", Type: "bool", Desc: "case-sensitive match"}, - common.Flag{Name: "match-entire-cell", Type: "bool", Desc: "match the entire cell content only"}, - common.Flag{Name: "regex", Type: "bool", Desc: "treat --find as a regular expression"}, - common.Flag{Name: "include-formulas", Type: "bool", Desc: "also search inside formula text"}, - common.Flag{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset (use next_offset from previous page)"}, - common.Flag{Name: "max-matches", Type: "int", Default: "5000", Hidden: true, Desc: "anti-burst match cap"}, - ), + Flags: flagsFor("+cells-search"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -122,15 +113,7 @@ var CellsReplace = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "find", Required: true, Desc: "text to find (regex when --regex is set)"}, - common.Flag{Name: "replacement", Required: true, Desc: "replacement text (empty string deletes the match)"}, - common.Flag{Name: "range", Desc: "optional A1 range to scope the replace"}, - common.Flag{Name: "match-case", Type: "bool", Desc: "case-sensitive match"}, - common.Flag{Name: "match-entire-cell", Type: "bool", Desc: "match the entire cell content only"}, - common.Flag{Name: "regex", Type: "bool", Desc: "treat --find as a regular expression"}, - common.Flag{Name: "include-formulas", Type: "bool", Desc: "also replace inside formula text"}, - ), + Flags: flagsFor("+cells-replace"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err diff --git a/shortcuts/sheets/lark_sheet_sheet_structure.go b/shortcuts/sheets/lark_sheet_sheet_structure.go index acfe1feeb..2b10f0819 100644 --- a/shortcuts/sheets/lark_sheet_sheet_structure.go +++ b/shortcuts/sheets/lark_sheet_sheet_structure.go @@ -33,15 +33,7 @@ var SheetInfo = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:read"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "range", Desc: "optional A1-style range to scope the query (e.g. A1:C20 / 3:6 / C:E); omit for whole sheet"}, - common.Flag{ - Name: "include", - Type: "string_slice", - Enum: []string{"merges", "row_heights", "col_widths", "hidden_rows", "hidden_cols", "groups", "frozen"}, - Desc: "filter returned categories (comma-separated). Omit for all.", - }, - ), + Flags: flagsFor("+sheet-info"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -121,9 +113,6 @@ func infoTypeFromInclude(include []string) string { // ─── +dim-* (modify_sheet_structure) ────────────────────────────────── -// dimEnum bounds the allowed values for --dimension across every +dim-* shortcut. -var dimEnum = []string{"row", "column"} - // DimInsert inserts blank rows / columns and optionally inherits style from // the adjacent dimension. var DimInsert = common.Shortcut{ @@ -134,13 +123,8 @@ var DimInsert = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "dimension", Required: true, Enum: dimEnum, Desc: "`row` or `column`"}, - common.Flag{Name: "start", Type: "int", Required: true, Desc: "0-based start position (inclusive)"}, - common.Flag{Name: "end", Type: "int", Required: true, Desc: "0-based end position (exclusive)"}, - common.Flag{Name: "inherit-style", Enum: []string{"before", "after", "none"}, Default: "none", Desc: "inherit cell style from the row/column before, after, or neither"}, - ), - Validate: validateDimRange, + Flags: flagsFor("+dim-insert"), + Validate: validateDimRange, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) @@ -193,12 +177,8 @@ var DimDelete = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "dimension", Required: true, Enum: dimEnum, Desc: "`row` or `column`"}, - common.Flag{Name: "start", Type: "int", Required: true, Desc: "0-based start position (inclusive)"}, - common.Flag{Name: "end", Type: "int", Required: true, Desc: "0-based end position (exclusive)"}, - ), - Validate: validateDimRange, + Flags: flagsFor("+dim-delete"), + Validate: validateDimRange, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) @@ -251,10 +231,7 @@ var DimFreeze = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "dimension", Required: true, Enum: dimEnum, Desc: "`row` or `column`"}, - common.Flag{Name: "count", Type: "int", Required: true, Desc: "number of leading rows/columns to freeze; 0 unfreezes the chosen dimension"}, - ), + Flags: flagsFor("+dim-freeze"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -363,12 +340,8 @@ func newDimRangeOpShortcut(command, desc, op, risk string) common.Shortcut { Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "dimension", Required: true, Enum: dimEnum, Desc: "`row` or `column`"}, - common.Flag{Name: "start", Type: "int", Required: true, Desc: "0-based start position (inclusive)"}, - common.Flag{Name: "end", Type: "int", Required: true, Desc: "0-based end position (exclusive)"}, - ), - Validate: validateDimRange, + Flags: flagsFor(command), + Validate: validateDimRange, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) @@ -397,17 +370,7 @@ func newDimRangeOpShortcut(command, desc, op, risk string) common.Shortcut { // --depth (currently unused server-side — accepted for forward-compat per // the canonical spec) and --group-state (group only, defaults to expand). func newDimGroupShortcut(command, desc, op string) common.Shortcut { - flags := append(publicSheetFlags(), - common.Flag{Name: "dimension", Required: true, Enum: dimEnum, Desc: "`row` or `column`"}, - common.Flag{Name: "start", Type: "int", Required: true, Desc: "0-based start position (inclusive)"}, - common.Flag{Name: "end", Type: "int", Required: true, Desc: "0-based end position (exclusive)"}, - common.Flag{Name: "depth", Type: "int", Default: "1", Desc: "nesting level (currently honored only when the server-side endpoint supports it)"}, - ) - if op == "group" { - flags = append(flags, - common.Flag{Name: "group-state", Enum: []string{"expand", "fold"}, Default: "expand", Desc: "initial state of the new group"}, - ) - } + flags := flagsFor(command) return common.Shortcut{ Service: "sheets", Command: command, @@ -512,12 +475,7 @@ var DimMove = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "dimension", Required: true, Enum: dimEnum, Desc: "`row` or `column`"}, - common.Flag{Name: "start", Type: "int", Required: true, Desc: "source start (0-indexed, inclusive)"}, - common.Flag{Name: "end", Type: "int", Required: true, Desc: "source end (0-indexed, inclusive)"}, - common.Flag{Name: "target", Type: "int", Required: true, Desc: "destination index (0-indexed); rows/cols move to land BEFORE this index"}, - ), + Flags: flagsFor("+dim-move"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err diff --git a/shortcuts/sheets/lark_sheet_workbook.go b/shortcuts/sheets/lark_sheet_workbook.go index cdfe6bcd6..17fd39e72 100644 --- a/shortcuts/sheets/lark_sheet_workbook.go +++ b/shortcuts/sheets/lark_sheet_workbook.go @@ -46,7 +46,7 @@ var WorkbookInfo = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:read"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: publicTokenFlags(), + Flags: flagsFor("+workbook-info"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { _, err := resolveSpreadsheetToken(runtime) return err @@ -88,12 +88,7 @@ var SheetCreate = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicTokenFlags(), - common.Flag{Name: "title", Desc: "new sheet title", Required: true}, - common.Flag{Name: "index", Type: "int", Default: "-1", Desc: "insertion position (0-based); omit to append"}, - common.Flag{Name: "row-count", Type: "int", Default: "0", Desc: "initial row count; omit for tool default (200)"}, - common.Flag{Name: "col-count", Type: "int", Default: "0", Desc: "initial column count; omit for tool default (20)"}, - ), + Flags: flagsFor("+sheet-create"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -155,7 +150,7 @@ var SheetDelete = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: publicSheetFlags(), + Flags: flagsFor("+sheet-delete"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -202,9 +197,7 @@ var SheetRename = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "title", Desc: "new sheet title", Required: true}, - ), + Flags: flagsFor("+sheet-rename"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -269,10 +262,7 @@ var SheetMove = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:read", "sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "index", Type: "int", Required: true, Desc: "target position (0-based)"}, - common.Flag{Name: "source-index", Type: "int", Default: "-1", Desc: "source position (0-based); omitted → auto-derived from --sheet-id/--sheet-name's current workbook position"}, - ), + Flags: flagsFor("+sheet-move"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -367,10 +357,7 @@ var SheetCopy = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "title", Desc: "title for the duplicated sheet (server-generated when omitted)"}, - common.Flag{Name: "index", Type: "int", Default: "-1", Desc: "insertion position for the copy (0-based); omit to append"}, - ), + Flags: flagsFor("+sheet-copy"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -432,7 +419,7 @@ func newSheetVisibilityShortcut(command, desc, op string) common.Shortcut { Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: publicSheetFlags(), + Flags: flagsFor(command), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -477,9 +464,7 @@ var SheetSetTabColor = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "color", Desc: "hex color like #FF0000; pass empty string to clear"}, - ), + Flags: flagsFor("+sheet-set-tab-color"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -545,12 +530,7 @@ var WorkbookCreate = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:create", "sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: []common.Flag{ - {Name: "title", Required: true, Desc: "spreadsheet title"}, - {Name: "folder-token", Desc: "destination folder token; omit to land at the drive root"}, - {Name: "headers", Input: []string{common.File, common.Stdin}, Desc: "header row JSON array, e.g. [\"列A\",\"列B\"]"}, - {Name: "values", Input: []string{common.File, common.Stdin}, Desc: "initial data JSON 2D array, e.g. [[\"alice\",95]]"}, - }, + Flags: flagsFor("+workbook-create"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("title")) == "" { return common.FlagErrorf("--title is required") @@ -707,11 +687,7 @@ var WorkbookExport = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:read", "docs:document:export", "drive:drive.metadata:readonly"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicTokenFlags(), - common.Flag{Name: "file-extension", Enum: []string{"xlsx", "csv"}, Default: "xlsx", Desc: "xlsx (whole workbook) or csv (one sheet via --sheet-id)"}, - common.Flag{Name: "sheet-id", Desc: "csv mode only: target sheet reference_id to export"}, - common.Flag{Name: "output-path", Desc: "local file path to save into; omit to just trigger and report the file_token"}, - ), + Flags: flagsFor("+workbook-export"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err diff --git a/shortcuts/sheets/lark_sheet_workbook_test.go b/shortcuts/sheets/lark_sheet_workbook_test.go index eaa49d16c..f7b257fc2 100644 --- a/shortcuts/sheets/lark_sheet_workbook_test.go +++ b/shortcuts/sheets/lark_sheet_workbook_test.go @@ -159,10 +159,10 @@ func TestSheetMove_DryRunResolvePlaceholders(t *testing.T) { t.Parallel() cases := []struct { - name string - args []string - wantSheetID string - wantSourceIdx interface{} + name string + args []string + wantSheetID string + wantSourceIdx interface{} }{ { name: "id only, no source-index → both literal + placeholder", diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index 7d61f5a95..527d6d5f8 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -42,13 +42,7 @@ var CellsSet = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "range", Required: true, Desc: "target A1 range (e.g. A1:C10); cells dimensions must match"}, - common.Flag{Name: "cells", Input: []string{common.File, common.Stdin}, Required: true, - Desc: "JSON 2D matrix of cell objects, e.g. [[{\"value\":\"a\"},{\"value\":\"b\"}],[{\"value\":1},{\"value\":2}]]; dimensions must match --range"}, - common.Flag{Name: "allow-overwrite", Type: "bool", Default: "true", Desc: "allow overwriting non-empty cells (default true)"}, - common.Flag{Name: "max-cells", Type: "int", Default: "50000", Hidden: true, Desc: "anti-burst cells write cap"}, - ), + Flags: flagsFor("+cells-set"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -123,11 +117,7 @@ var CellsSetStyle = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append( - append(publicSheetFlags(), - common.Flag{Name: "range", Required: true, Desc: "target A1 range (e.g. A1:B2)"}), - styleFlatFlags()..., - ), + Flags: flagsFor("+cells-set-style"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -223,13 +213,7 @@ var CsvPut = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "csv", Input: []string{common.File, common.Stdin}, Required: true, - Desc: "CSV text (RFC 4180); supports @file or stdin via -"}, - common.Flag{Name: "start-cell", Default: "A1", Required: true, Desc: "single A1 anchor cell, e.g. A1 / B5"}, - common.Flag{Name: "allow-overwrite", Type: "bool", Default: "true", - Desc: "allow overwriting non-empty cells (default true); false errors if any target cell is non-empty"}, - ), + Flags: flagsFor("+csv-put"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -300,15 +284,7 @@ var DropdownSet = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "range", Required: true, Desc: "target A1 range (e.g. A2:A100)"}, - common.Flag{Name: "options", Input: []string{common.File, common.Stdin}, Required: true, - Desc: "options JSON array (e.g. [\"opt1\",\"opt2\"]); ≤500 items, ≤100 chars each, no commas"}, - common.Flag{Name: "colors", Input: []string{common.File, common.Stdin}, - Desc: "optional RGB hex array (e.g. [\"#1FB6C1\",\"#F006C2\"]); length must equal --options"}, - common.Flag{Name: "multiple", Type: "bool", Desc: "enable multi-select; default false"}, - common.Flag{Name: "highlight", Type: "bool", Desc: "color-highlight options; default false"}, - ), + Flags: flagsFor("+dropdown-set"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err @@ -551,11 +527,7 @@ var CellsSetImage = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only", "drive:file:upload"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: append(publicSheetFlags(), - common.Flag{Name: "range", Required: true, Desc: "single target cell (e.g. A1; start/end must equal)"}, - common.Flag{Name: "image", Required: true, Desc: "local image path (PNG/JPEG/JPG/GIF/BMP/JFIF/EXIF/TIFF/BPG/HEIC)"}, - common.Flag{Name: "name", Desc: "uploaded file name (with extension); defaults to basename(--image)"}, - ), + Flags: flagsFor("+cells-set-image"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index 8c7a5c708..c71ef5572 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -14,7 +14,6 @@ import "github.com/larksuite/cli/shortcuts/common" // `--print-schema --flag-name ` locally. func Shortcuts() []common.Shortcut { all := shortcutList() - applyFlagDescs(all) withSchema := commandsWithFlagSchema() for i := range all { if _, ok := withSchema[all[i].Command]; ok { diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 0590be694..1fdd28a73 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -37,7 +37,8 @@ _公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--operations` | string + File + Stdin(复合 JSON) | 是 | JSON:`{"operations":[{"tool":"`+cells-set`","params":{...}}, ...]}`;按数组顺序串行执行 | +| `--operations` | string + File + Stdin(复合 JSON) | required | JSON 数组:`[{"tool_name":"`+cells-set`","input":{...}}, ...]`,按声明顺序串行执行;`tool_name` 为底层工具名(如 `+cells-set` / `+cells-clear` / `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}`),`input` 结构与单独调用该工具一致 | +| `--continue-on-error` | bool | optional | 遇子操作失败时继续执行剩余操作;默认 false(首个失败即整批中断) | ### `+cells-batch-set-style` @@ -45,18 +46,18 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--ranges` | string + File + Stdin(简单 JSON) | 是 | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["sheet1!A1:B2","sheet1!D1:D10"]`);所有 range 应用同一组 style | -| `--background-color` | string | 否 | 背景颜色(十六进制,如 `#ffffff`) | -| `--font-color` | string | 否 | 字体颜色(十六进制,如 `#000000`) | -| `--font-size` | number | 否 | 字体大小(px,例:10、12、14) | -| `--font-style` | string + Enum | 否 | 字体样式 enum:`normal` / `italic` | -| `--font-weight` | string + Enum | 否 | 字重 enum:`normal` / `bold` | -| `--font-line` | string + Enum | 否 | 字体线条样式 enum:`none` / `underline` / `line-through` | -| `--horizontal-alignment` | string + Enum | 否 | 水平对齐 enum:`left` / `center` / `right` | -| `--vertical-alignment` | string + Enum | 否 | 垂直对齐 enum:`top` / `middle` / `bottom` | -| `--word-wrap` | string + Enum | 否 | 换行策略 enum:`overflow` / `auto-wrap` / `word-clip`,默认 `overflow` | -| `--number-format` | string | 否 | 数字格式(例:文本 `@`、数字 `0.00`、货币 `$#,##0.00`、日期 `mm/dd/yyyy`) | -| `--border-styles` | string + File + Stdin(复合 JSON) | 否 | 边框配置 JSON(结构同 +cells-set-style) | +| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["sheet1!A1:B2","sheet1!D1:D10"]`);所有 range 应用同一组 style | +| `--background-color` | string | optional | 背景颜色(十六进制,如 `#ffffff`) | +| `--font-color` | string | optional | 字体颜色(十六进制,如 `#000000`) | +| `--font-size` | float64 | optional | 字体大小(px,例:10、12、14) | +| `--font-style` | string | optional | 字体样式(可选值:`normal` / `italic`) | +| `--font-weight` | string | optional | 字重(可选值:`normal` / `bold`) | +| `--font-line` | string | optional | 字体线条样式(可选值:`none` / `underline` / `line-through`) | +| `--horizontal-alignment` | string | optional | 水平对齐(可选值:`left` / `center` / `right`) | +| `--vertical-alignment` | string | optional | 垂直对齐(可选值:`top` / `middle` / `bottom`) | +| `--word-wrap` | string | optional | 换行策略(可选值:`overflow` / `auto-wrap` / `word-clip`) | +| `--number-format` | string | optional | 数字格式(例:文本 `@`、数字 `0.00`、货币 `$#,##0.00`、日期 `mm/dd/yyyy`) | +| `--border-styles` | string + File + Stdin(复合 JSON) | optional | 边框配置 JSON(结构同 +cells-set-style) | ### `+dropdown-update` @@ -64,11 +65,11 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--ranges` | string + File + Stdin(简单 JSON) | 是 | 目标范围 JSON 数组(如 `["sheet1!A2:A100"]`),每项必须带 sheet 前缀 | -| `--options` | string + File + Stdin(复合 JSON) | 是 | 选项 JSON 数组(如 `["opt1","opt2"]`) | -| `--colors` | string + File + Stdin(简单 JSON) | 否 | 颜色数组(与 `--options` 等长) | -| `--multiple` | bool | 否 | 启用多选 | -| `--highlight` | bool | 否 | 选项配色 | +| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(如 `["sheet1!A2:A100"]`),每项必须带 sheet 前缀 | +| `--options` | string + File + Stdin(复合 JSON) | required | 选项 JSON 数组(如 `["opt1","opt2"]`) | +| `--colors` | string + File + Stdin(简单 JSON) | optional | 颜色数组(与 `--options` 等长) | +| `--multiple` | bool | optional | 启用多选 | +| `--highlight` | bool | optional | 选项配色 | ### `+dropdown-delete` @@ -76,7 +77,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--ranges` | string + File + Stdin(简单 JSON) | 是 | 目标范围 JSON 数组(最多 100 个,每项必须带 sheet 前缀) | +| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(最多 100 个,每项必须带 sheet 前缀) | ## Schemas @@ -102,16 +103,10 @@ _单元格边框配置,含 top/bottom/left/right 四个方向,每个方向 ### `+dropdown-update` `--options` -_数据验证配置_ +_列表选项(type='list' 时必填)_ -**顶层字段**: -- `help_text` (string?) — 验证失败时显示的提示文本 -- `items` (array?) — 列表选项(type='list' 时必填) -- `operator` (enum?) — 比较运算符(type='number'/'date'/'textLength' 时必填) [equal / notEqual / greaterThan / greaterThanOrEqual / lessThan / lessThanOrEqual / between / notBetween] -- `range` (string?) — 源数据区域(type='listFromRange' 时必填,格式:'SheetName!A1:A10') -- `support_multiple_values` (boolean?) — 列表验证是否支持多选(type='list'/'listFromRange' 时可选,默认 false) -- `type` (enum) — 数据验证类型:list(下拉列表)、listFromRange(引用范围下拉列表)、number(数字)、date(日期)、textLength(文本长度)、… [list / listFromRange / number / date / textLength / checkbox] -- `values` (array?) — 比较值(operator 为 'between'/'notBetween' 时需要两个值,其它运算符需要一个值) +**数组项**(类型 string): +- 标量:string ## Examples @@ -125,10 +120,10 @@ _数据验证配置_ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" --yes \ --operations @ops.json -# ops.json (array<{tool_name, input}>): +# ops.json (array<{tool_name, input}>,tool_name 是 CLI shortcut 名): # [ -# {"tool_name": "modify_sheet_structure", "input": {"sheet_id":"...","operation":"insert","dimension":"row","start":10,"end":12}}, -# {"tool_name": "set_cell_range", "input": {"sheet_id":"...","range":"A11:B12","cells":[[{"value":"a"},{"value":"b"}],[{"value":"c"},{"value":"d"}]]}} +# {"tool_name": "+dim-insert", "input": {"sheet_id":"...","dimension":"row","start":10,"end":12}}, +# {"tool_name": "+cells-set", "input": {"sheet_id":"...","range":"A11:B12","cells":[[{"value":"a"},{"value":"b"}],[{"value":"c"},{"value":"d"}]]}} # ] ``` @@ -137,9 +132,9 @@ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" -- > ```jsonc > // 在 C 列前插入新列 → 写表头 C1 → 回填 C2:C100 共 99 行 > [ -> {"tool_name": "`+dim-{insert|delete|hide|unhide|freeze|group|ungroup}`", -> "input": {"sheet_id": "...", "operation": "insert", "dimension": "column", "start": 3, "end": 4}}, -> {"tool_name": "`+cells-set`", +> {"tool_name": "+dim-insert", +> "input": {"sheet_id": "...", "dimension": "column", "start": 3, "end": 4}}, +> {"tool_name": "+cells-set", > "input": {"sheet_id": "...", "range": "C1:C100", > "cells": [[{"value":"score"}], [{"value":95}], [{"value":87}], /* ... 97 more rows ... */ ]}} > ] diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md index 3b7012a7c..4ea612bed 100644 --- a/skills/lark-sheets/references/lark-sheets-chart.md +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -111,7 +111,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--chart-id` | string | 否 | 指定单个图表 reference_id 过滤 | +| `--chart-id` | string | optional | 指定单个图表 reference_id 过滤 | ### `+chart-create` @@ -119,7 +119,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--properties` | string + File + Stdin(复合 JSON) | 是 | 图表完整配置 JSON(`position` / `data` / `properties` 等);结构嵌套深,统一走 JSON 注入 | +| `--properties` | string + File + Stdin(复合 JSON) | required | 图表完整配置 JSON(`position` / `data` / `properties` 等);结构嵌套深,统一走 JSON 注入 | ### `+chart-update` @@ -127,8 +127,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--chart-id` | string | 是 | 目标图表 reference_id | -| `--properties` | string + File + Stdin(复合 JSON) | 是 | 完整或足够完整的图表配置 JSON(先 `+chart-list` 回读再 patch) | +| `--chart-id` | string | required | 目标图表 reference_id | +| `--properties` | string + File + Stdin(复合 JSON) | required | 完整或足够完整的图表配置 JSON(先 `+chart-list` 回读再 patch) | ### `+chart-delete` @@ -136,7 +136,7 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--chart-id` | string | 是 | 目标图表 reference_id | +| `--chart-id` | string | required | 目标图表 reference_id | ## Schemas diff --git a/skills/lark-sheets/references/lark-sheets-conditional-format.md b/skills/lark-sheets/references/lark-sheets-conditional-format.md index d305f5564..b8c69eb1b 100644 --- a/skills/lark-sheets/references/lark-sheets-conditional-format.md +++ b/skills/lark-sheets/references/lark-sheets-conditional-format.md @@ -87,7 +87,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--rule-id` | string | 否 | 按规则 id 过滤 | +| `--rule-id` | string | optional | 按规则 id 过滤 | ### `+cond-format-create` @@ -95,9 +95,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--properties` | string + File + Stdin(复合 JSON) | 是 | 规则配置 JSON,含 `style`(命中样式,必填)和 `attrs?`(规则参数列表,因 `rule_type` 不同结构而异)/ `has_ref?`。`rule_type` 和 `ranges` 已拎为独立 flag | -| `--rule-type` | string + Enum | 是 | 条件格式规则类型 enum:`cellValue` / `formula` / `duplicate` / `unique` / `topBottom` / `aboveBelowAverage` / `dataBar` / `colorScale` / `iconSet` / `textContains` / `dateOccurring` / `blankCell` / `errorCell`(共 13 项);优先级高于 `--properties` 中同名字段 | -| `--ranges` | string + File + Stdin(简单 JSON) | 是 | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--properties` 中同名字段 | +| `--properties` | string + File + Stdin(复合 JSON) | required | 规则配置 JSON,含 `style`(命中样式,必填)和 `attrs?`(规则参数列表,因 `rule_type` 不同结构而异)/ `has_ref?`。`rule_type` 和 `ranges` 已拎为独立 flag | +| `--rule-type` | string | required | 条件格式规则类型;优先级高于 `--properties` 中同名字段(可选值:`cellValue` / `formula` / `duplicate` / `unique` / `topBottom` / `aboveBelowAverage` / `dataBar` / `colorScale` / `iconSet` / `textContains` / `dateOccurring` / `blankCell` / `errorCell`) | +| `--ranges` | string + File + Stdin(简单 JSON) | required | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--properties` 中同名字段 | ### `+cond-format-update` @@ -105,10 +105,10 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--rule-id` | string | 是 | 目标规则 id | -| `--properties` | string + File + Stdin(复合 JSON) | 是 | 规则配置 JSON,结构同 `+cond-format-create` 的 `--properties`;update 是整组覆盖式 | -| `--rule-type` | string + Enum | 是 | 条件格式规则类型 enum:`cellValue` / `formula` / `duplicate` / `unique` / `topBottom` / `aboveBelowAverage` / `dataBar` / `colorScale` / `iconSet` / `textContains` / `dateOccurring` / `blankCell` / `errorCell`(共 13 项);优先级高于 `--properties` 中同名字段 | -| `--ranges` | string + File + Stdin(简单 JSON) | 是 | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--properties` 中同名字段 | +| `--rule-id` | string | required | 目标规则 id | +| `--properties` | string + File + Stdin(复合 JSON) | required | 规则配置 JSON,结构同 `+cond-format-create` 的 `--properties`;update 是整组覆盖式 | +| `--rule-type` | string | required | 条件格式规则类型;优先级高于 `--properties` 中同名字段(可选值:`cellValue` / `formula` / `duplicate` / `unique` / `topBottom` / `aboveBelowAverage` / `dataBar` / `colorScale` / `iconSet` / `textContains` / `dateOccurring` / `blankCell` / `errorCell`) | +| `--ranges` | string + File + Stdin(简单 JSON) | required | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--properties` 中同名字段 | ### `+cond-format-delete` @@ -116,7 +116,7 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--rule-id` | string | 是 | 目标规则 id | +| `--rule-id` | string | required | 目标规则 id | ## Schemas @@ -147,7 +147,7 @@ _创建/更新的条件格式属性_ # 重复值高亮 lark-cli sheets +cond-format-create --url "..." --sheet-id "$SID" \ --rule-type duplicate --ranges '["A1:A100"]' \ - --properties '{"style":{"background_color":"#FFD7D7"}}' + --properties '{"style":{"back_color":"#FFD7D7"}}' # 数据条 lark-cli sheets +cond-format-create --url "..." --sheet-id "$SID" \ diff --git a/skills/lark-sheets/references/lark-sheets-filter-view.md b/skills/lark-sheets/references/lark-sheets-filter-view.md index d35ed4cee..6da48d78a 100644 --- a/skills/lark-sheets/references/lark-sheets-filter-view.md +++ b/skills/lark-sheets/references/lark-sheets-filter-view.md @@ -40,7 +40,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--view-id` | string | 否 | 按筛选视图 reference_id 过滤(命中即只返回单个视图) | +| `--view-id` | string | optional | 按筛选视图 reference_id 过滤(命中即只返回单个视图) | ### `+filter-view-create` @@ -48,9 +48,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--properties` | string + File + Stdin(复合 JSON) | 是 | 筛选视图规则 JSON,含 `rules?`(列级筛选规则数组)和 `filtered_columns?`。`range` 和 `view_name` 是独立 flag | -| `--range` | string | 是 | 筛选视图作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段;create 必填,必须覆盖表头行 | -| `--view-name` | string | 否 | 筛选视图名称;create 不传时系统自动分配,update 不传时保留原名;优先级高于 `--properties` 中同名字段 | +| `--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` 中同名字段 | ### `+filter-view-update` @@ -58,10 +58,10 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--view-id` | string | 是 | 目标筛选视图 reference_id | -| `--properties` | string + File + Stdin(复合 JSON) | 是 | 筛选视图规则 JSON,含 `rules?` 和 `filtered_columns?`。`range` 和 `view_name` 是独立 flag;至少传 `--properties.rules` / `--range` / `--view-name` 之一 | -| `--range` | string | 否 | 筛选视图作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段;update 时省略表示保留当前 range | -| `--view-name` | string | 否 | 筛选视图名称;create 不传时系统自动分配,update 不传时保留原名;优先级高于 `--properties` 中同名字段 | +| `--view-id` | string | required | 目标筛选视图 reference_id | +| `--properties` | string + File + Stdin(复合 JSON) | required | 筛选视图规则 JSON,含 `rules?` 和 `filtered_columns?`。`range` 和 `view_name` 是独立 flag;至少传 `--properties.rules` / `--range` / `--view-name` 之一 | +| `--range` | string | optional | 筛选视图作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段;update 时省略表示保留当前 range | +| `--view-name` | string | optional | 筛选视图名称;create 不传时系统自动分配,update 不传时保留原名;优先级高于 `--properties` 中同名字段 | ### `+filter-view-delete` @@ -69,7 +69,7 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--view-id` | string | 是 | 目标筛选视图 reference_id | +| `--view-id` | string | required | 目标筛选视图 reference_id | ## Schemas @@ -108,7 +108,7 @@ lark-cli sheets +filter-view-list --url "..." --sheet-id "$SID" --view-id vAbcde ```bash lark-cli sheets +filter-view-create --url "..." --sheet-id "$SID" \ --view-name "活跃用户" --range "A1:F1000" \ - --properties '{"rules":[{"col":"C","filter_type":"number","compare":"greater","expected":[100]}]}' + --properties '{"rules":[{"column_index":"C","conditions":[{"type":"number","compare_type":"greaterThan","values":[100]}]}]}' ``` > `--range` **必须覆盖表头行**(如 `A1:F1000`),不能只包含数据行;`--view-name` 重名时服务端自动改名。 diff --git a/skills/lark-sheets/references/lark-sheets-filter.md b/skills/lark-sheets/references/lark-sheets-filter.md index 055e42d17..04bef892e 100644 --- a/skills/lark-sheets/references/lark-sheets-filter.md +++ b/skills/lark-sheets/references/lark-sheets-filter.md @@ -50,8 +50,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | 筛选范围(A1 表示法,含表头行,如 `A1:F1000`);不要重复写入 `--properties` 中的 range 字段 | -| `--properties` | string + File + Stdin(复合 JSON) | 否 | 筛选规则 JSON,含 `rules`(列级筛选规则数组,必填)和 `filtered_columns?`(激活列索引提示)。`range` 是独立 flag(不要再放此 JSON 里) | +| `--range` | string | required | 筛选范围(A1 表示法,含表头行,如 `A1:F1000`);不要重复写入 `--properties` 中的 range 字段 | +| `--properties` | string + File + Stdin(复合 JSON) | optional | 筛选规则 JSON,含 `rules`(列级筛选规则数组,必填)和 `filtered_columns?`(激活列索引提示)。`range` 是独立 flag(不要再放此 JSON 里) | ### `+filter-update` @@ -59,8 +59,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--properties` | string + File + Stdin(复合 JSON) | 是 | 筛选规则 JSON,含 `rules` 和 `filtered_columns?`;update 是整组覆盖式(传空 `rules: []` 清空)。`range` 已拎为独立 flag | -| `--range` | string | 是 | 筛选作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段 | +| `--properties` | string + File + Stdin(复合 JSON) | required | 筛选规则 JSON,含 `rules` 和 `filtered_columns?`;update 是整组覆盖式(传空 `rules: []` 清空)。`range` 已拎为独立 flag | +| `--range` | string | required | 筛选作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段 | ### `+filter-delete` @@ -94,7 +94,7 @@ _创建/更新的筛选器属性_ ```bash lark-cli sheets +filter-create --url "..." --sheet-id "$SID" \ --range "A1:F1000" \ - --properties '{"rules":[{"col":"B","filter_type":"multiValue","expected":["北京","上海"]}]}' + --properties '{"rules":[{"column_index":"B","conditions":[{"type":"multiValue","compare_type":"equal","values":["北京","上海"]}]}]}' ``` ### `+filter-update` diff --git a/skills/lark-sheets/references/lark-sheets-float-image.md b/skills/lark-sheets/references/lark-sheets-float-image.md index 25d23b4cc..83cb373fd 100644 --- a/skills/lark-sheets/references/lark-sheets-float-image.md +++ b/skills/lark-sheets/references/lark-sheets-float-image.md @@ -51,7 +51,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--float-image-id` | string | 否 | 按 id 过滤;省略时列工作表全部 | +| `--float-image-id` | string | optional | 按 id 过滤;省略时列工作表全部 | ### `+float-image-create` @@ -59,16 +59,16 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--image-name` | string | 是 | 图片名称,含扩展名(如 `logo.png`) | -| `--image-token` | string | XOR | 图片 file_token(与 `--image-uri` 二选一)。常见来源:`+float-image-list` 返回的 `image_token` | -| `--image-uri` | string | XOR | 图片 reference_id(与 `--image-token` 二选一);形如 `<\|image\|>:abcdef` 这种带前缀的字符串 | -| `--position-row` | int | 是 | 图片左上角所在行(0-based) | -| `--position-col` | string | 是 | 图片左上角所在列(列字母,如 `A` / `B`) | -| `--size-width` | int | 是 | 图片宽度(像素) | -| `--size-height` | int | 是 | 图片高度(像素) | -| `--offset-row` | int | 否 | 在 `--position-row` 基础上的行内偏移(像素) | -| `--offset-col` | int | 否 | 在 `--position-col` 基础上的列内偏移(像素) | -| `--z-index` | int | 否 | 图片 Z 轴层级,控制重叠顺序 | +| `--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` 二选一);形如 `<\|image\|>:abcdef` 这种带前缀的字符串 | +| `--position-row` | int | required | 图片左上角所在行(0-based) | +| `--position-col` | string | required | 图片左上角所在列(列字母,如 `A` / `B`) | +| `--size-width` | int | required | 图片宽度(像素) | +| `--size-height` | int | required | 图片高度(像素) | +| `--offset-row` | int | optional | 在 `--position-row` 基础上的行内偏移(像素) | +| `--offset-col` | int | optional | 在 `--position-col` 基础上的列内偏移(像素) | +| `--z-index` | int | optional | 图片 Z 轴层级,控制重叠顺序 | ### `+float-image-update` @@ -76,17 +76,17 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--float-image-id` | string | 是 | 目标图片 id | -| `--image-name` | string | 是 | 图片名称,含扩展名(如 `logo.png`) | -| `--image-token` | string | XOR | 图片 file_token(与 `--image-uri` 二选一)。常见来源:`+float-image-list` 返回的 `image_token` | -| `--image-uri` | string | XOR | 图片 reference_id(与 `--image-token` 二选一);形如 `<\|image\|>:abcdef` 这种带前缀的字符串 | -| `--position-row` | int | 是 | 图片左上角所在行(0-based) | -| `--position-col` | string | 是 | 图片左上角所在列(列字母,如 `A` / `B`) | -| `--size-width` | int | 是 | 图片宽度(像素) | -| `--size-height` | int | 是 | 图片高度(像素) | -| `--offset-row` | int | 否 | 在 `--position-row` 基础上的行内偏移(像素) | -| `--offset-col` | int | 否 | 在 `--position-col` 基础上的列内偏移(像素) | -| `--z-index` | int | 否 | 图片 Z 轴层级,控制重叠顺序 | +| `--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` 二选一);形如 `<\|image\|>:abcdef` 这种带前缀的字符串 | +| `--position-row` | int | required | 图片左上角所在行(0-based) | +| `--position-col` | string | required | 图片左上角所在列(列字母,如 `A` / `B`) | +| `--size-width` | int | required | 图片宽度(像素) | +| `--size-height` | int | required | 图片高度(像素) | +| `--offset-row` | int | optional | 在 `--position-row` 基础上的行内偏移(像素) | +| `--offset-col` | int | optional | 在 `--position-col` 基础上的列内偏移(像素) | +| `--z-index` | int | optional | 图片 Z 轴层级,控制重叠顺序 | ### `+float-image-delete` @@ -94,7 +94,7 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--float-image-id` | string | 是 | 目标图片 id | +| `--float-image-id` | string | required | 目标图片 id | ## Examples @@ -120,12 +120,24 @@ lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \ ### `+float-image-update` -> 必须先 `+float-image-list --float-image-id ` 回读当前完整属性,再通过 `--image-name` / `--position-*` / `--size-*` 等独立 flag 改对应字段。 +> **patch 模式**:除了 `--float-image-id`(必填,定位目标图片)外,其它字段都可选——只传你需要改的那几个,未传的字段保持原值不变。至少传一个改动字段。 +> +> 推荐流程:先 `+float-image-list --float-image-id ` 回读当前完整属性,再针对要改的字段调一次 `+float-image-update`。 + +```bash +# 只改位置,保留其它属性 +lark-cli sheets +float-image-update --url "..." --sheet-id "$SID" \ + --float-image-id "$IMG_ID" --position-row 5 --position-col C + +# 只换图,位置/尺寸不变 +lark-cli sheets +float-image-update --url "..." --sheet-id "$SID" \ + --float-image-id "$IMG_ID" --image-name "new-logo.png" --image-token "$NEW_TOKEN" +``` ### `+float-image-delete` ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`+float-image-create` 校验 `--image-name` 非空,`--image-token` 与 `--image-uri` 互斥且至少一个非空,`--position-row/col` 与 `--size-width/height` 必填且为合法整数;`+float-image-update` 必须 `--float-image-id`;`+float-image-delete` 强制 `--yes` 或 `--dry-run`。 +- `Validate`:XOR 公共四件套;`+float-image-create` 校验 `--image-name` 非空,`--image-token` 与 `--image-uri` 互斥且至少一个非空,`--position-row/col` 与 `--size-width/height` 必填且为合法整数;`+float-image-update` 必须 `--float-image-id`,其余 `--image-name` / `--image-token` / `--image-uri` / `--position-*` / `--size-*` / `--offset-*` / `--z-index` 至少传 1 个(patch 模式:未传字段保持原值);`+float-image-delete` 强制 `--yes` 或 `--dry-run`。 - `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 float_image 请求模板"。 - `Execute`:写后调用 `+float-image-list --float-image-id ` 回读,envelope.meta.verification 给出新位置 / 尺寸对比。 diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md index 531a0de53..c30b3a8df 100644 --- a/skills/lark-sheets/references/lark-sheets-pivot-table.md +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -53,7 +53,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--pivot-table-id` | string | 否 | 按 id 过滤 | +| `--pivot-table-id` | string | optional | 按 id 过滤 | ### `+pivot-create` @@ -61,11 +61,11 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--properties` | string + File + Stdin(复合 JSON) | 是 | JSON:`{"data_range":"Sheet1!A1:F1000","rows":[...],"columns":[...],"values":[...],"filters":[...],"show_row_grand_total":true,"show_col_grand_total":true}` | -| `--target-sheet-id` | string | 否 | 透视表落点子表 id;省略时自动新建子表(推荐) | -| `--target-position` | string | 否 | 落点起始 cell(A1 格式,如 `A1`),默认 `A1` | -| `--source` | string | 是 | 透视表源数据区域(A1 表示法,格式 `SheetName!StartCell:EndCell`,如 `Sheet1!A1:D100`) | -| `--range` | string | 否 | 透视表放置位置(左上角 A1 单值,如 `F1`);省略时放在新建子表的左上角 | +| `--properties` | string + File + Stdin(复合 JSON) | required | JSON:`{"data_range":"Sheet1!A1:F1000","rows":[...],"columns":[...],"values":[...],"filters":[...],"show_row_grand_total":true,"show_col_grand_total":true}` | +| `--target-sheet-id` | string | optional | 透视表落点子表 id;省略时自动新建子表(推荐) | +| `--target-position` | string | optional | 落点起始 cell(A1 格式,如 `A1`),默认 `A1` | +| `--source` | string | required | 透视表源数据区域(A1 表示法,格式 `SheetName!StartCell:EndCell`,如 `Sheet1!A1:D100`) | +| `--range` | string | optional | 透视表放置位置(左上角 A1 单值,如 `F1`);省略时放在新建子表的左上角 | ### `+pivot-update` @@ -73,8 +73,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--pivot-table-id` | string | 是 | 目标透视表 id | -| `--properties` | string + File + Stdin(复合 JSON) | 是 | 完整或足够完整的配置(先 `+pivot-list --pivot-table-id ` 回读再 patch) | +| `--pivot-table-id` | string | required | 目标透视表 id | +| `--properties` | string + File + Stdin(复合 JSON) | required | 完整或足够完整的配置(先 `+pivot-list --pivot-table-id ` 回读再 patch) | ### `+pivot-delete` @@ -82,7 +82,7 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--pivot-table-id` | string | 是 | 目标透视表 id | +| `--pivot-table-id` | string | required | 目标透视表 id | ## Schemas diff --git a/skills/lark-sheets/references/lark-sheets-range-operations.md b/skills/lark-sheets/references/lark-sheets-range-operations.md index 0d3918c13..b6733b18d 100644 --- a/skills/lark-sheets/references/lark-sheets-range-operations.md +++ b/skills/lark-sheets/references/lark-sheets-range-operations.md @@ -101,8 +101,8 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | 清除范围(A1 格式) | -| `--scope` | string + Enum | 否 | 清除范围 enum:`content`(默认,仅清内容)/ `formats`(仅清格式)/ `all`(清内容 + 格式) | +| `--range` | string | required | 清除范围(A1 格式) | +| `--scope` | string | optional | 清除范围 enum:`content`(默认,仅清内容)/ `formats`(仅清格式)/ `all`(清内容 + 格式)(可选值:`content` / `formats` / `all`) | ### `+cells-merge` @@ -110,8 +110,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | 待合并 / 取消合并的范围(A1 格式) | -| `--merge-type` | string + Enum | 否 | 合并方向 enum(仅 `+cells-merge`):`all` / `rows` / `columns`,默认 `all` | +| `--range` | string | required | 待合并 / 取消合并的范围(A1 格式) | +| `--merge-type` | string | optional | 合并方向(仅 `+cells-merge`)(可选值:`all` / `rows` / `columns`) | ### `+cells-unmerge` @@ -119,8 +119,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | 待合并 / 取消合并的范围(A1 格式) | -| `--merge-type` | string + Enum | 否 | 合并方向 enum(仅 `+cells-merge`):`all` / `rows` / `columns`,默认 `all` | +| `--range` | string | required | 待合并 / 取消合并的范围(A1 格式) | ### `+rows-resize` @@ -128,10 +127,10 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--start` | int | 是 | 起始行(0-based, inclusive) | -| `--end` | int | 是 | 结束行(0-based, inclusive) | -| `--type` | string + Enum | 是 | 尺寸方式 enum:`pixel`(指定 px 像素值,需配 `--size`)/ `standard`(重置为默认标准行高)/ `auto`(自动适应内容) | -| `--size` | int | 否 | 行高(像素,例:30 / 40 / 60);`--type pixel` 时必填,其它 type 忽略 | +| `--start` | int | required | 起始行(0-based, inclusive) | +| `--end` | int | required | 结束行(0-based, inclusive) | +| `--type` | string | required | 尺寸方式 enum:`pixel`(指定 px 像素值,需配 `--size`)/ `standard`(重置为默认标准行高)/ `auto`(自动适应内容)(可选值:`pixel` / `standard` / `auto`) | +| `--size` | int | optional | 行高(像素,例:30 / 40 / 60);`--type pixel` 时必填,其它 type 忽略 | ### `+cols-resize` @@ -139,10 +138,10 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--start` | int | 是 | 起始列(0-based, inclusive) | -| `--end` | int | 是 | 结束列(0-based, inclusive) | -| `--type` | string + Enum | 是 | 尺寸方式 enum:`pixel`(指定 px 像素值,需配 `--size`)/ `standard`(重置为默认标准列宽) | -| `--size` | int | 否 | 列宽(像素,例:80 / 120 / 200);`--type pixel` 时必填,其它 type 忽略 | +| `--start` | int | required | 起始列(0-based, inclusive) | +| `--end` | int | required | 结束列(0-based, inclusive) | +| `--type` | string | required | 尺寸方式 enum:`pixel`(指定 px 像素值,需配 `--size`)/ `standard`(重置为默认标准列宽)(可选值:`pixel` / `standard`) | +| `--size` | int | optional | 列宽(像素,例:80 / 120 / 200);`--type pixel` 时必填,其它 type 忽略 | ### `+range-move` @@ -150,10 +149,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--source-range` | string | 是 | 源 A1 范围 | -| `--target-sheet-id` | string | 否 | 目标子表 id;省略时同源 sheet | -| `--target-range` | string | 是 | 目标 A1 范围(传起点 cell 即可,按源尺寸自动推断) | -| `--paste-type` | string + Enum | 否 | 粘贴内容 enum(仅 `+range-copy`):`values` / `formulas` / `formats` / `all`,默认 `all` | +| `--source-range` | string | required | 源 A1 范围 | +| `--target-sheet-id` | string | optional | 目标子表 id;省略时同源 sheet | +| `--target-range` | string | required | 目标 A1 范围(传起点 cell 即可,按源尺寸自动推断) | ### `+range-copy` @@ -161,10 +159,10 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--source-range` | string | 是 | 源 A1 范围 | -| `--target-sheet-id` | string | 否 | 目标子表 id;省略时同源 sheet | -| `--target-range` | string | 是 | 目标 A1 范围(传起点 cell 即可,按源尺寸自动推断) | -| `--paste-type` | string + Enum | 否 | 粘贴内容 enum(仅 `+range-copy`):`values` / `formulas` / `formats` / `all`,默认 `all` | +| `--source-range` | string | required | 源 A1 范围 | +| `--target-sheet-id` | string | optional | 目标子表 id;省略时同源 sheet | +| `--target-range` | string | required | 目标 A1 范围(传起点 cell 即可,按源尺寸自动推断) | +| `--paste-type` | string | optional | 粘贴内容(仅 `+range-copy`)(可选值:`values` / `formulas` / `formats` / `all`) | ### `+range-fill` @@ -172,9 +170,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--source-range` | string | 是 | 填充模板范围(系列起始 cells) | -| `--target-range` | string | 是 | 目标填充范围(A1 格式) | -| `--series-type` | string + Enum | 否 | 填充序列类型 enum:`auto` / `linear` / `growth` / `date` / `copy`,默认 `auto` | +| `--source-range` | string | required | 填充模板范围(系列起始 cells) | +| `--target-range` | string | required | 目标填充范围(A1 格式) | +| `--series-type` | string | optional | 填充序列类型(可选值:`auto` / `linear` / `growth` / `date` / `copy`) | ### `+range-sort` @@ -182,9 +180,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | 排序范围(A1 格式;含或不含表头由 `--has-header` 决定) | -| `--sort-keys` | string + File + Stdin(复合 JSON) | 是 | JSON:`[{"col":"B","order":"asc"},{"col":"D","order":"desc"}]` | -| `--has-header` | bool | 否 | 第一行是表头不参与排序,默认 false | +| `--range` | string | required | 排序范围(A1 格式;含或不含表头由 `--has-header` 决定) | +| `--sort-keys` | string + File + Stdin(复合 JSON) | required | JSON 数组:`[{"column":"<列字母>","ascending":}, ...]` | +| `--has-header` | bool | optional | 第一行是表头不参与排序,默认 false | ## Schemas diff --git a/skills/lark-sheets/references/lark-sheets-read-data.md b/skills/lark-sheets/references/lark-sheets-read-data.md index 11315f579..4471d62c1 100644 --- a/skills/lark-sheets/references/lark-sheets-read-data.md +++ b/skills/lark-sheets/references/lark-sheets-read-data.md @@ -88,11 +88,11 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | A1 范围,如 `Sheet1!A1:F10` | -| `--include` | string_slice + Enum | 否 | `value` / `formula` / `style` / `comment` / `data_validation`,逗号分隔 | -| `--cell-limit` | int + Hidden | 否 | 防爆,默认 5000 | -| `--max-chars` | int + Hidden | 否 | 防爆,默认 200000 | -| `--skip-hidden` | bool | 否 | 跳过隐藏行列,默认 `false` | +| `--range` | string_array | required | A1 范围,如 `Sheet1!A1:F10` | +| `--include` | string_slice | optional | 要返回的信息类别,逗号分隔多个(可选值:`value` / `formula` / `style` / `comment` / `data_validation`) | +| `--cell-limit` | int | optional | 防爆,默认 5000 | +| `--max-chars` | int | optional | 防爆,默认 200000 | +| `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` | ### `+dropdown-get` @@ -100,7 +100,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | 目标范围(A1 格式,必须带 sheet 前缀,如 `sheet1!A2:A100`) | +| `--range` | string | required | 目标范围(A1 格式,必须带 sheet 前缀,如 `sheet1!A2:A100`) | ### `+csv-get` @@ -108,12 +108,12 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 否 | A1 范围;省略时读整表的 `current_region` | -| `--value-render-option` | string + Enum | 否 | 单元格取值模式 enum:`ToString` / `FormattedValue` / `Formula` / `UnformattedValue` | -| `--max-rows` | int + Hidden | 否 | 防爆,默认 100000 | -| `--max-chars` | int + Hidden | 否 | 防爆,默认 200000 | -| `--include-row-prefix` | bool | 否 | 是否在每行前加 `[row=N]` 前缀,默认 `true` | -| `--skip-hidden` | bool | 否 | 跳过隐藏行列,默认 `false` | +| `--range` | string | optional | A1 范围;省略时读整表的 `current_region` | +| `--value-render-option` | string | optional | 单元格取值模式(可选值:`formatted_value` / `raw_value` / `formula`) | +| `--max-rows` | int | optional | 防爆,默认 100000 | +| `--max-chars` | int | optional | 防爆,默认 200000 | +| `--include-row-prefix` | bool | optional | 是否在每行前加 `[row=N]` 前缀,默认 `true` | +| `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` | ## Examples diff --git a/skills/lark-sheets/references/lark-sheets-search-replace.md b/skills/lark-sheets/references/lark-sheets-search-replace.md index 7bc07bd2d..f0813bb9a 100644 --- a/skills/lark-sheets/references/lark-sheets-search-replace.md +++ b/skills/lark-sheets/references/lark-sheets-search-replace.md @@ -37,13 +37,14 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--find` | string | 是 | 待查找文本(与 `--regex` 配合时按正则解释) | -| `--range` | string | 否 | 查找范围(A1 格式);省略时整表 | -| `--match-case` | bool | 否 | 大小写敏感 | -| `--match-entire-cell` | bool | 否 | 完全匹配整个单元格 | -| `--regex` | bool | 否 | 把 `--find` 按正则解释 | -| `--include-formulas` | bool | 否 | 也在公式文本中搜索 | -| `--max-matches` | int + Hidden | 否 | 防爆,默认 5000 | +| `--find` | string | required | 待查找文本(与 `--regex` 配合时按正则解释) | +| `--range` | string | optional | 查找范围(A1 格式);省略时整表 | +| `--match-case` | bool | optional | 大小写敏感 | +| `--match-entire-cell` | bool | optional | 完全匹配整个单元格 | +| `--regex` | bool | optional | 把 `--find` 按正则解释 | +| `--include-formulas` | bool | optional | 也在公式文本中搜索 | +| `--max-matches` | int | optional | 防爆,默认 5000 | +| `--offset` | int | optional | 跳过前 N 个匹配(分页用),默认 0 | ### `+cells-replace` @@ -51,13 +52,13 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--find` | string | 是 | 待替换文本 | -| `--replacement` | string | 是 | 替换为;传空字符串 `""` 等价于「删除内容」 | -| `--range` | string | 否 | 替换范围(A1 格式);省略时整表 | -| `--match-case` | bool | 否 | 也在公式文本中替换 | -| `--match-entire-cell` | bool | 否 | 也在公式文本中替换 | -| `--regex` | bool | 否 | 也在公式文本中替换 | -| `--include-formulas` | bool | 否 | 也在公式文本中替换 | +| `--find` | string | required | 待替换文本 | +| `--replacement` | string | required | 替换为;传空字符串 `""` 等价于「删除内容」 | +| `--range` | string | optional | 替换范围(A1 格式);省略时整表 | +| `--match-case` | bool | optional | 大小写敏感 | +| `--match-entire-cell` | bool | optional | 完全匹配整个单元格 | +| `--regex` | bool | optional | 把 `--find` 按正则解释 | +| `--include-formulas` | bool | optional | 也在公式文本中替换 | ## Examples diff --git a/skills/lark-sheets/references/lark-sheets-sheet-structure.md b/skills/lark-sheets/references/lark-sheets-sheet-structure.md index b35440e46..2567e37d3 100644 --- a/skills/lark-sheets/references/lark-sheets-sheet-structure.md +++ b/skills/lark-sheets/references/lark-sheets-sheet-structure.md @@ -57,7 +57,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--include` | string_slice + Enum | 否 | `merges` / `row_heights` / `col_widths` / `hidden_rows` / `hidden_cols` / `groups` / `frozen`,逗号分隔 | +| `--include` | string_slice | optional | 要返回的结构信息类别,逗号分隔多个(可选值:`merges` / `row_heights` / `col_widths` / `hidden_rows` / `hidden_cols` / `groups` / `frozen`) | +| `--range` | string | optional | 限定只返回该 A1 范围的结构信息;省略时返回整表 | ### `+dim-insert` @@ -65,10 +66,10 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--dimension` | string + Enum | 是 | `row` / `column` | -| `--start` | int | 是 | 插入起始位置(0-based) | -| `--end` | int | 是 | 插入结束位置(exclusive) | -| `--inherit-style` | string + Enum | 否 | 新行/列样式继承策略 enum:`before`(继承前一行/列)/ `after`(继承后一行/列)/ `none`(默认) | +| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | +| `--start` | int | required | 插入起始位置(0-based) | +| `--end` | int | required | 插入结束位置(exclusive) | +| `--inherit-style` | string | optional | 新行/列样式继承策略 enum:`before`(继承前一行/列)/ `after`(继承后一行/列)/ `none`(默认)(可选值:`before` / `after` / `none`) | ### `+dim-delete` @@ -76,9 +77,9 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--dimension` | string + Enum | 是 | `row` / `column` | -| `--start` | int | 是 | 起始位置(0-based) | -| `--end` | int | 是 | 结束位置(exclusive) | +| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | +| `--start` | int | required | 起始位置(0-based) | +| `--end` | int | required | 结束位置(exclusive) | ### `+dim-hide` @@ -86,9 +87,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--dimension` | string + Enum | 是 | `row` / `column` | -| `--start` | int | 是 | 结束位置(0-based, inclusive) | -| `--end` | int | 是 | 结束位置(0-based, inclusive) | +| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | +| `--start` | int | required | 起始位置(0-based, inclusive) | +| `--end` | int | required | 结束位置(0-based, inclusive) | ### `+dim-unhide` @@ -96,9 +97,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--dimension` | string + Enum | 是 | `row` / `column` | -| `--start` | int | 是 | 结束位置(0-based, inclusive) | -| `--end` | int | 是 | 结束位置(0-based, inclusive) | +| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | +| `--start` | int | required | 起始位置(0-based, inclusive) | +| `--end` | int | required | 结束位置(0-based, inclusive) | ### `+dim-freeze` @@ -106,8 +107,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--dimension` | string + Enum | 是 | `row` / `column` | -| `--count` | int | 是 | 冻结前 N 行/列;传 0 解除冻结 | +| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | +| `--count` | int | required | 冻结前 N 行/列;传 0 解除冻结 | ### `+dim-group` @@ -115,10 +116,11 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--dimension` | string + Enum | 是 | `row` / `column` | -| `--start` | int | 是 | 结束位置(0-based, inclusive) | -| `--end` | int | 是 | 结束位置(0-based, inclusive) | -| `--depth` | int | 否 | 嵌套层级(`+dim-group` 用),默认 1 | +| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | +| `--start` | int | required | 起始位置(0-based, inclusive) | +| `--end` | int | required | 结束位置(0-based, inclusive) | +| `--depth` | int | optional | 嵌套层级(`+dim-group` 用),默认 1 | +| `--group-state` | string | optional | 分组初始展开状态(可选值:`expand` / `fold`) | ### `+dim-ungroup` @@ -126,10 +128,10 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--dimension` | string + Enum | 是 | `row` / `column` | -| `--start` | int | 是 | 结束位置(0-based, inclusive) | -| `--end` | int | 是 | 结束位置(0-based, inclusive) | -| `--depth` | int | 否 | 嵌套层级(`+dim-group` 用),默认 1 | +| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | +| `--start` | int | required | 起始位置(0-based, inclusive) | +| `--end` | int | required | 结束位置(0-based, inclusive) | +| `--depth` | int | optional | 嵌套层级(`+dim-group` 用),默认 1 | ### `+dim-move` @@ -137,10 +139,10 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--dimension` | string + Enum | 是 | `row` / `column` | -| `--start` | int | 是 | 源起止区间的起始位置(0-based, inclusive) | -| `--end` | int | 是 | 源起止区间的结束位置(0-based, inclusive) | -| `--target` | int | 是 | 目标位置(move 到该 index 之前;0-based) | +| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | +| `--start` | int | required | 源起止区间的起始位置(0-based, inclusive) | +| `--end` | int | required | 源起止区间的结束位置(0-based, inclusive) | +| `--target` | int | required | 目标位置(move 到该 index 之前;0-based) | ## Examples diff --git a/skills/lark-sheets/references/lark-sheets-sparkline.md b/skills/lark-sheets/references/lark-sheets-sparkline.md index a416715c4..0d12b0ffe 100644 --- a/skills/lark-sheets/references/lark-sheets-sparkline.md +++ b/skills/lark-sheets/references/lark-sheets-sparkline.md @@ -37,7 +37,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--group-id` | string | 否 | 按 group_id 过滤 | +| `--group-id` | string | optional | 按 group_id 过滤 | ### `+sparkline-create` @@ -45,7 +45,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--properties` | string + File + Stdin(复合 JSON) | 是 | JSON:`{"type":"line\|column\|winLoss","data_range":"A2:F10","target_range":"G2:G10","style":{...},"special_points":{...}}`;type 三种 enum;data_range 与 target_range 行/列数需对齐 | +| `--properties` | string + File + Stdin(复合 JSON) | required | JSON:`{config(共享样式配置), sparklines(迷你图数组)}`;完整字段结构跑 `--print-schema` | ### `+sparkline-update` @@ -53,8 +53,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--group-id` | string | 是 | 目标组 id | -| `--properties` | string + File + Stdin(复合 JSON) | 是 | 完整或足够完整的配置(先 `+sparkline-list --group-id ` 回读再 patch);可改 `type` / `data_range` / `target_range` / `style` / `special_points` 等字段 | +| `--group-id` | string | required | 目标组 id | +| `--properties` | string + File + Stdin(复合 JSON) | required | JSON:`{config, sparklines}`;先 `+sparkline-list --group-id ` 回读再 patch;完整字段结构跑 `--print-schema` | ### `+sparkline-delete` @@ -62,7 +62,7 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--group-id` | string | 是 | 目标组 id | +| `--group-id` | string | required | 目标组 id | ## Schemas diff --git a/skills/lark-sheets/references/lark-sheets-workbook.md b/skills/lark-sheets/references/lark-sheets-workbook.md index 4f9f8928d..eda7e525e 100644 --- a/skills/lark-sheets/references/lark-sheets-workbook.md +++ b/skills/lark-sheets/references/lark-sheets-workbook.md @@ -50,9 +50,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ -| Flag | Type | 必填 | 说明 | -| --- | --- | --- | --- | -| `--include-properties` | bool | 否 | 是否返回每个 sheet 的扩展属性(默认 true) | +_仅含公共 / 系统 flag。_ ### `+sheet-create` @@ -60,10 +58,10 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--title` | string | 是 | 新工作表名称 | -| `--index` | int | 否 | 插入位置;省略时附加到末尾 | -| `--row-count` | int | 否 | 初始行数,默认 100 | -| `--col-count` | int | 否 | 初始列数,默认 26 | +| `--title` | string | required | 新工作表名称 | +| `--index` | int | optional | 插入位置;省略时附加到末尾 | +| `--row-count` | int | optional | 初始行数(默认 200,上限 50000) | +| `--col-count` | int | optional | 初始列数(默认 20,上限 200) | ### `+sheet-delete` @@ -77,7 +75,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--title` | string | 是 | 新名称 | +| `--title` | string | required | 新名称 | ### `+sheet-move` @@ -85,8 +83,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--index` | int | 是 | 目标位置(0-based) | -| `--source-index` | int | 否 | 源位置(0-based);可选,未传时由 CLI runtime 根据 `--sheet-id` / `--sheet-name` 当前在工作簿中的 index 自动派生 | +| `--index` | int | required | 目标位置(0-based) | +| `--source-index` | int | optional | 源位置(0-based);可选,未传时由 CLI runtime 根据 `--sheet-id` / `--sheet-name` 当前在工作簿中的 index 自动派生 | ### `+sheet-copy` @@ -94,8 +92,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--title` | string | 否 | 副本名称;省略时由服务端生成 | -| `--index` | int | 否 | 副本插入位置(0-based);省略时附加到末尾 | +| `--title` | string | optional | 副本名称;省略时由服务端生成 | +| `--index` | int | optional | 副本插入位置(0-based);省略时附加到末尾 | ### `+sheet-hide` @@ -115,7 +113,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--color` | string | 是 | Hex 色值如 `#FF0000`,传空 `""` 清除 | +| `--color` | string | required | Hex 色值如 `#FF0000`,传空 `""` 清除 | ### `+workbook-create` @@ -123,10 +121,10 @@ _系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--title` | string | 是 | 新 spreadsheet 标题 | -| `--folder-token` | string | 否 | 目标文件夹 token;省略时放在云空间根目录 | -| `--headers` | string + File + Stdin(简单 JSON) | 否 | 表头行 JSON 数组:`["列A","列B"]` | -| `--values` | string + File + Stdin(简单 JSON) | 否 | 初始数据 JSON 二维数组:`[["alice",95]]` | +| `--title` | string | required | 新 spreadsheet 标题 | +| `--folder-token` | string | optional | 目标文件夹 token;省略时放在云空间根目录 | +| `--headers` | string + File + Stdin(简单 JSON) | optional | 表头行 JSON 数组:`["列A","列B"]` | +| `--values` | string + File + Stdin(简单 JSON) | optional | 初始数据 JSON 二维数组:`[["alice",95]]` | ### `+workbook-export` @@ -134,9 +132,9 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--file-extension` | string + Enum | 否 | `xlsx` / `csv`,默认 `xlsx`;csv 模式必须配 `--sheet-id` | -| `--sheet-id` | string | 否 | 仅 csv 模式必填:指定要导出的 sheet reference_id | -| `--output-path` | string | 否 | 本地保存路径;省略时只触发导出不下载 | +| `--file-extension` | string | optional | 导出文件格式;`csv` 模式必须配 `--sheet-id`(可选值:`xlsx` / `csv`) | +| `--sheet-id` | string | optional | 仅 csv 模式必填:指定要导出的 sheet reference_id | +| `--output-path` | string | optional | 本地保存路径;省略时只触发导出不下载 | ## Examples diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 331fc4526..7b40657b8 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -184,10 +184,10 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | 写入区域(A1 格式) | -| `--cells` | string + File + Stdin(复合 JSON) | 是 | JSON:`{"values": [[...], ...]}`;可含 `formula` / `cell_styles` / `comments` / `embed_image` 富信息 | -| `--allow-overwrite` | bool | 否 | 允许覆盖非空 cell;默认 false 时遇非空 cell 报错 | -| `--max-cells` | int + Hidden | 否 | 防爆,默认 50000 | +| `--range` | string | required | 写入区域(A1 格式) | +| `--cells` | string + File + Stdin(复合 JSON) | required | JSON:2D 数组 `[[{cell},...],...]`,维度与 `--range` 完全一致;每个 cell 可含 `value` / `formula` / `cell_styles` / `note` / `rich_text`(含 `type="embed-image"` 单元格嵌图)等,完整字段跑 `--print-schema` | +| `--allow-overwrite` | bool | optional | 允许覆盖非空 cell(默认 true);设为 false 时遇非空 cell 报错 | +| `--max-cells` | int | optional | 防爆,默认 50000 | ### `+cells-set-style` @@ -195,18 +195,18 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | 目标范围(A1 格式,如 `A1:B2`) | -| `--background-color` | string | 否 | 背景颜色(十六进制,如 `#ffffff`) | -| `--font-color` | string | 否 | 字体颜色(十六进制,如 `#000000`) | -| `--font-size` | number | 否 | 字体大小(px,例:10、12、14) | -| `--font-style` | string + Enum | 否 | 字体样式 enum:`normal` / `italic` | -| `--font-weight` | string + Enum | 否 | 字重 enum:`normal` / `bold` | -| `--font-line` | string + Enum | 否 | 字体线条样式 enum:`none` / `underline` / `line-through` | -| `--horizontal-alignment` | string + Enum | 否 | 水平对齐 enum:`left` / `center` / `right` | -| `--vertical-alignment` | string + Enum | 否 | 垂直对齐 enum:`top` / `middle` / `bottom` | -| `--word-wrap` | string + Enum | 否 | 换行策略 enum:`overflow` / `auto-wrap` / `word-clip`,默认 `overflow` | -| `--number-format` | string | 否 | 数字格式(例:文本 `@`、数字 `0.00`、货币 `$#,##0.00`、日期 `mm/dd/yyyy`) | -| `--border-styles` | string + File + Stdin(复合 JSON) | 否 | 边框配置 JSON:`{ top: {style,color,weight}, bottom: ..., left: ..., right: ... }`;4 方向结构相同 | +| `--range` | string | required | 目标范围(A1 格式,如 `A1:B2`) | +| `--background-color` | string | optional | 背景颜色(十六进制,如 `#ffffff`) | +| `--font-color` | string | optional | 字体颜色(十六进制,如 `#000000`) | +| `--font-size` | float64 | optional | 字体大小(px,例:10、12、14) | +| `--font-style` | string | optional | 字体样式(可选值:`normal` / `italic`) | +| `--font-weight` | string | optional | 字重(可选值:`normal` / `bold`) | +| `--font-line` | string | optional | 字体线条样式(可选值:`none` / `underline` / `line-through`) | +| `--horizontal-alignment` | string | optional | 水平对齐(可选值:`left` / `center` / `right`) | +| `--vertical-alignment` | string | optional | 垂直对齐(可选值:`top` / `middle` / `bottom`) | +| `--word-wrap` | string | optional | 换行策略(可选值:`overflow` / `auto-wrap` / `word-clip`) | +| `--number-format` | string | optional | 数字格式(例:文本 `@`、数字 `0.00`、货币 `$#,##0.00`、日期 `mm/dd/yyyy`) | +| `--border-styles` | string + File + Stdin(复合 JSON) | optional | 边框配置 JSON:`{ top: {style,color,weight}, bottom: ..., left: ..., right: ... }`;4 方向结构相同 | ### `+cells-set-image` @@ -214,9 +214,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | 目标单元格(A1 格式,必须单 cell,如 `A1`;起止 cell 须相同) | -| `--image` | string | 是 | 本地图片路径(支持 PNG / JPEG / JPG / GIF / BMP / JFIF / EXIF / TIFF / BPG / HEIC) | -| `--name` | string | 否 | 图片文件名(含扩展名);省略时取 `--image` 的 basename | +| `--range` | string | required | 目标单元格(A1 格式,必须单 cell,如 `A1`;起止 cell 须相同) | +| `--image` | string | required | 本地图片路径(支持 PNG / JPEG / JPG / GIF / BMP / JFIF / EXIF / TIFF / BPG / HEIC) | +| `--name` | string | optional | 图片文件名(含扩展名);省略时取 `--image` 的 basename | ### `+dropdown-set` @@ -224,11 +224,11 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | 目标范围(A1 格式,如 `A2:A100`) | -| `--options` | string + File + Stdin(复合 JSON) | 是 | 选项 JSON 数组 `["opt1","opt2"]`;最多 500 项,每项 ≤100 字符,不含逗号 | -| `--colors` | string + File + Stdin(简单 JSON) | 否 | RGB hex 颜色数组(如 `["#1FB6C1","#F006C2"]`),长度必须与 `--options` 一致 | -| `--multiple` | bool | 否 | 启用多选;默认 `false` | -| `--highlight` | bool | 否 | 选项配色显示;默认 `false` | +| `--range` | string | required | 目标范围(A1 格式,如 `A2:A100`) | +| `--options` | string + File + Stdin(复合 JSON) | required | 选项 JSON 数组 `["opt1","opt2"]`;最多 500 项,每项 ≤100 字符,不含逗号 | +| `--colors` | string + File + Stdin(简单 JSON) | optional | RGB hex 颜色数组(如 `["#1FB6C1","#F006C2"]`),长度必须与 `--options` 一致 | +| `--multiple` | bool | optional | 启用多选;默认 `false` | +| `--highlight` | bool | optional | 选项配色显示;默认 `false` | ### `+csv-put` @@ -236,9 +236,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | 是 | 目标区域起点 A1(如 `Sheet1!A1`);终点按 CSV 实际行列数自动推断 | -| `--csv` | string + File + Stdin(非 JSON 文本) | 是 | RFC 4180 CSV 文本;只写纯值,不带公式/样式/批注 | -| `--allow-overwrite` | bool | 否 | 允许覆盖;默认 false 时若目标非空报错 | +| `--start-cell` | string | required | 目标区域起点 A1(如 `Sheet1!A1`);终点按 CSV 实际行列数自动推断 | +| `--csv` | string + File + Stdin(非 JSON 文本) | required | RFC 4180 CSV 文本;只写纯值,不带公式/样式/批注 | +| `--allow-overwrite` | bool | optional | 允许覆盖(默认 true);设为 false 时若目标非空报错 | ## Schemas @@ -269,16 +269,10 @@ _单元格边框配置,含 top/bottom/left/right 四个方向,每个方向 ### `+dropdown-set` `--options` -_数据验证配置_ +_列表选项(type='list' 时必填)_ -**顶层字段**: -- `help_text` (string?) — 验证失败时显示的提示文本 -- `items` (array?) — 列表选项(type='list' 时必填) -- `operator` (enum?) — 比较运算符(type='number'/'date'/'textLength' 时必填) [equal / notEqual / greaterThan / greaterThanOrEqual / lessThan / lessThanOrEqual / between / notBetween] -- `range` (string?) — 源数据区域(type='listFromRange' 时必填,格式:'SheetName!A1:A10') -- `support_multiple_values` (boolean?) — 列表验证是否支持多选(type='list'/'listFromRange' 时可选,默认 false) -- `type` (enum) — 数据验证类型:list(下拉列表)、listFromRange(引用范围下拉列表)、number(数字)、date(日期)、textLength(文本长度)、… [list / listFromRange / number / date / textLength / checkbox] -- `values` (array?) — 比较值(operator 为 'between'/'notBetween' 时需要两个值,其它运算符需要一个值) +**数组项**(类型 string): +- 标量:string ## Examples From 9048c7097fe8bc8e48d3a1dc0524a4d365f5ad9f Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Wed, 20 May 2026 20:54:02 +0800 Subject: [PATCH 022/114] docs(sheets): sync +batch-update CLI override schema (shortcut/input form) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulled from sheet-skill-spec: - skills/lark-sheets/references/lark-sheets-batch-update.md: --operations now documents the {shortcut, input} form; tool_name references gone - shortcuts/sheets/data/flag-schemas.json: --operations resolves to the CLI-side array<{shortcut(enum), input}> schema, sourced from spec's canonical-spec/tool-schemas/cli-schemas.json (cli: prefix). +dropdown --options also drilled one level deeper NOTE: the binary still raw-passes --operations to MCP batch_update which expects {tool_name, input}. A follow-up will add a shortcut→tool_name translation layer (with per-shortcut operation field) before the docs become actionable. --- shortcuts/sheets/data/flag-defs.json | 2 +- shortcuts/sheets/data/flag-schemas.json | 220 ++++++------------ .../references/lark-sheets-batch-update.md | 20 +- 3 files changed, 86 insertions(+), 156 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index 7e8a0d2f1..13988e967 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -2599,7 +2599,7 @@ "kind": "own", "type": "string", "required": "required", - "desc": "JSON array: `[{\"tool_name\":\"set_cell_range\",\"input\":{...}}, ...]`, executed serially; `tool_name` is the underlying tool name (e.g. `set_cell_range` / `clear_cell_range` / `modify_sheet_structure`)", + "desc": "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is the shortcut's flag set minus the spreadsheet locator. For basic flags use lark-cli sheets --help; for composite JSON flags use --print-schema --flag-name . Do not pass an explicit operation field. Strict transaction by default, pass --continue-on-error for soft batch; no nesting; executed serially.", "input": [ "file", "stdin" diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json index 21e920800..5807e648c 100644 --- a/shortcuts/sheets/data/flag-schemas.json +++ b/shortcuts/sheets/data/flag-schemas.json @@ -3,25 +3,77 @@ "flags": { "+batch-update": { "operations": { - "description": "要批量执行的操作列表,按顺序依次执行。每个操作包含工具名称和对应的入参。", + "type": "array", + "description": "要批量执行的 CLI shortcut 操作列表,按声明顺序串行执行;任一失败立即中断。", "items": { - "properties": { - "input": { - "description": "对应工具的入参,结构与单独调用该工具时完全一致", - "type": "object" - }, - "tool_name": { - "description": "要执行的工具名称,如 \"set_cell_range\"、\"clear_cell_range\"、\"modify_sheet_structure\" 等。不支持 \"batch_update\" 嵌套。", - "type": "string" - } - }, + "type": "object", + "additionalProperties": false, "required": [ - "tool_name", + "shortcut", "input" ], - "type": "object" - }, - "type": "array" + "properties": { + "shortcut": { + "type": "string", + "enum": [ + "+cells-set", + "+cells-clear", + "+cells-merge", + "+cells-unmerge", + "+cells-replace", + "+csv-put", + "+dim-insert", + "+dim-delete", + "+dim-hide", + "+dim-unhide", + "+dim-freeze", + "+dim-group", + "+dim-ungroup", + "+dim-move", + "+rows-resize", + "+cols-resize", + "+range-move", + "+range-copy", + "+range-fill", + "+range-sort", + "+sheet-create", + "+sheet-delete", + "+sheet-rename", + "+sheet-move", + "+sheet-copy", + "+sheet-hide", + "+sheet-unhide", + "+sheet-set-tab-color", + "+chart-create", + "+chart-update", + "+chart-delete", + "+pivot-create", + "+pivot-update", + "+pivot-delete", + "+cond-format-create", + "+cond-format-update", + "+cond-format-delete", + "+filter-create", + "+filter-update", + "+filter-delete", + "+filter-view-create", + "+filter-view-update", + "+filter-view-delete", + "+sparkline-create", + "+sparkline-update", + "+sparkline-delete", + "+float-image-create", + "+float-image-update", + "+float-image-delete" + ], + "description": "CLI shortcut 名(不是底层 MCP tool 名)" + }, + "input": { + "type": "object", + "description": "该 shortcut 的入参集(不含 spreadsheet 定位);基础 flag 跑 `lark-cli sheets --help`,复合 JSON flag 跑 `--print-schema --flag-name `。不要手填 `operation`(动作由 shortcut 名表达)。" + } + } + } } }, "+cells-batch-set-style": { @@ -3495,142 +3547,20 @@ }, "+dropdown-set": { "options": { - "description": "数据验证配置。设为 null 可清除已有的数据验证。", - "properties": { - "help_text": { - "description": "验证失败时显示的提示文本", - "type": "string" - }, - "items": { - "description": "列表选项(type='list' 时必填)", - "items": { - "type": "string" - }, - "type": "array" - }, - "operator": { - "description": "比较运算符(type='number'/'date'/'textLength' 时必填)", - "enum": [ - "equal", - "notEqual", - "greaterThan", - "greaterThanOrEqual", - "lessThan", - "lessThanOrEqual", - "between", - "notBetween" - ], - "type": "string" - }, - "range": { - "description": "源数据区域(type='listFromRange' 时必填,格式:'SheetName!A1:A10')", - "type": "string" - }, - "support_multiple_values": { - "description": "列表验证是否支持多选(type='list'/'listFromRange' 时可选,默认 false)", - "type": "boolean" - }, - "type": { - "description": "数据验证类型:list(下拉列表)、listFromRange(引用范围下拉列表)、number(数字)、date(日期)、textLength(文本长度)、checkbox(复选框)", - "enum": [ - "list", - "listFromRange", - "number", - "date", - "textLength", - "checkbox" - ], - "type": "string" - }, - "values": { - "description": "比较值(operator 为 'between'/'notBetween' 时需要两个值,其它运算符需要一个值)", - "items": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "type": "array" - } + "description": "列表选项(type='list' 时必填)", + "items": { + "type": "string" }, - "required": [ - "type" - ], - "type": "object" + "type": "array" } }, "+dropdown-update": { "options": { - "description": "数据验证配置。设为 null 可清除已有的数据验证。", - "properties": { - "help_text": { - "description": "验证失败时显示的提示文本", - "type": "string" - }, - "items": { - "description": "列表选项(type='list' 时必填)", - "items": { - "type": "string" - }, - "type": "array" - }, - "operator": { - "description": "比较运算符(type='number'/'date'/'textLength' 时必填)", - "enum": [ - "equal", - "notEqual", - "greaterThan", - "greaterThanOrEqual", - "lessThan", - "lessThanOrEqual", - "between", - "notBetween" - ], - "type": "string" - }, - "range": { - "description": "源数据区域(type='listFromRange' 时必填,格式:'SheetName!A1:A10')", - "type": "string" - }, - "support_multiple_values": { - "description": "列表验证是否支持多选(type='list'/'listFromRange' 时可选,默认 false)", - "type": "boolean" - }, - "type": { - "description": "数据验证类型:list(下拉列表)、listFromRange(引用范围下拉列表)、number(数字)、date(日期)、textLength(文本长度)、checkbox(复选框)", - "enum": [ - "list", - "listFromRange", - "number", - "date", - "textLength", - "checkbox" - ], - "type": "string" - }, - "values": { - "description": "比较值(operator 为 'between'/'notBetween' 时需要两个值,其它运算符需要一个值)", - "items": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "type": "array" - } + "description": "列表选项(type='list' 时必填)", + "items": { + "type": "string" }, - "required": [ - "type" - ], - "type": "object" + "type": "array" } }, "+filter-create": { diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 1fdd28a73..d837cdd32 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -37,7 +37,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--operations` | string + File + Stdin(复合 JSON) | required | JSON 数组:`[{"tool_name":"`+cells-set`","input":{...}}, ...]`,按声明顺序串行执行;`tool_name` 为底层工具名(如 `+cells-set` / `+cells-clear` / `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}`),`input` 结构与单独调用该工具一致 | +| `--operations` | string + File + Stdin(复合 JSON) | required | JSON 数组:[{"shortcut":"+xxx-yyy","input":{...}}, ...]。shortcut 用 CLI 名;input 是该 shortcut 的入参集(不含 spreadsheet 定位),基础 flag 查 --help,复合 JSON flag 查 --print-schema --flag-name ;禁手填 operation。默认严格事务,传 --continue-on-error 翻软批;不支持嵌套;按数组顺序串行执行 | | `--continue-on-error` | bool | optional | 遇子操作失败时继续执行剩余操作;默认 false(首个失败即整批中断) | ### `+cells-batch-set-style` @@ -85,11 +85,11 @@ _公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_ ### `+batch-update` `--operations` -_要批量执行的操作列表,按顺序依次执行_ +_要批量执行的 CLI shortcut 操作列表,按声明顺序串行执行;任一失败立即中断_ **数组项**(类型 object): -- `input` (object) — 对应工具的入参,结构与单独调用该工具时完全一致 -- `tool_name` (string) — 要执行的工具名称,如 "`+cells-set`"、"`+cells-clear`"、"`+dim-{insert|delete|hide|unhide|freeze|group|ungroup}`" 等 +- `shortcut` (enum) — CLI shortcut 名(不是底层 MCP tool 名) [+cells-set / +cells-clear / +cells-merge / +cells-unmerge / +cells-replace / +csv-put / +dim-insert / +dim-delete / …共 49 项] +- `input` (object) — 该 shortcut 的入参集(不含 spreadsheet 定位);基础 flag 跑 `lark-cli sheets --help… ### `+cells-batch-set-style` `--border-styles` @@ -120,10 +120,10 @@ _列表选项(type='list' 时必填)_ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" --yes \ --operations @ops.json -# ops.json (array<{tool_name, input}>,tool_name 是 CLI shortcut 名): +# ops.json (array<{shortcut, input}>,shortcut 用 CLI 名): # [ -# {"tool_name": "+dim-insert", "input": {"sheet_id":"...","dimension":"row","start":10,"end":12}}, -# {"tool_name": "+cells-set", "input": {"sheet_id":"...","range":"A11:B12","cells":[[{"value":"a"},{"value":"b"}],[{"value":"c"},{"value":"d"}]]}} +# {"shortcut": "+dim-insert", "input": {"sheet_id":"...","dimension":"row","start":10,"end":12}}, +# {"shortcut": "+cells-set", "input": {"sheet_id":"...","range":"A11:B12","cells":[[{"value":"a"},{"value":"b"}],[{"value":"c"},{"value":"d"}]]}} # ] ``` @@ -132,9 +132,9 @@ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" -- > ```jsonc > // 在 C 列前插入新列 → 写表头 C1 → 回填 C2:C100 共 99 行 > [ -> {"tool_name": "+dim-insert", +> {"shortcut": "+dim-insert", > "input": {"sheet_id": "...", "dimension": "column", "start": 3, "end": 4}}, -> {"tool_name": "+cells-set", +> {"shortcut": "+cells-set", > "input": {"sheet_id": "...", "range": "C1:C100", > "cells": [[{"value":"score"}], [{"value":95}], [{"value":87}], /* ... 97 more rows ... */ ]}} > ] @@ -153,6 +153,6 @@ lark-cli sheets +cells-batch-set-style --url "..." \ ### Validate / DryRun / Execute 约束 -- `Validate`:`+batch-update` 的 `--operations` 必须合法 JSON,且为非空数组;逐个子操作 `tool_name` / `input` 字段必填校验;**禁止嵌套 `+batch-update`**。`+cells-batch-set-style` 的 `--ranges` 必须 JSON 数组、每项带 sheet 前缀;样式 flag 至少一个非空(或带 `--border-styles`)。 +- `Validate`:`+batch-update` 的 `--operations` 必须合法 JSON,且为非空数组;逐个子操作 `shortcut` / `input` 字段必填校验;**禁止嵌套 `+batch-update`**。`+cells-batch-set-style` 的 `--ranges` 必须 JSON 数组、每项带 sheet 前缀;样式 flag 至少一个非空(或带 `--border-styles`)。 - `DryRun`:按顺序输出每个子操作的目标 API + 请求 body 模板;首个失败则整批 fail-fast(不实际执行任何后续)。 - `Execute`:按声明顺序串行执行;任一子操作失败立即中断并回滚到该子操作前状态(具体回滚能力取决于子操作类型,沿用 MCP `+batch-update` 的语义)。 From 0c2e5f5e5c33c339cf501d9e90d448c5e99c8a11 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Wed, 20 May 2026 21:17:57 +0800 Subject: [PATCH 023/114] =?UTF-8?q?feat(sheets):=20translate=20+batch-upda?= =?UTF-8?q?te=20sub-ops=20{shortcut,input}=20=E2=86=92=20MCP=20shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users now hand +batch-update --operations a CLI-shape array ([{shortcut, input}, ...]) and the binary translates each sub-op to the underlying MCP batch_update shape ({tool_name, input(+operation)}) via a new dispatch table in shortcuts/sheets/batch_op_dispatch.go. Dispatch table covers 50 batchable write shortcuts. Excluded by design: - all read ops - fan-out wrappers (+batch-update self, +cells-batch-set-style, +dropdown-update, +dropdown-delete) — nesting these = nested batch - +dim-move — single shortcut uses legacy v2 /dimension_range endpoint, not MCP, can't be batched - +cells-set-image — multi-step image upload, not atomic-batch friendly - +workbook-create — new workbook, not batch-on-existing semantics Translator also rejects sub-ops that hand-fill input.operation (implied by shortcut name) or input.excel_id / spreadsheet_token / url (set once at +batch-update top level). +dim-freeze always injects operation=freeze; the count==0 unfreeze path of the single shortcut is intentionally not supported in batch — callers should use the single shortcut for unfreeze. Tests cover: end-to-end translation, --continue-on-error propagation, 13 rejection cases (banned shortcuts, malformed shapes, reserved keys). Sync'd from sheet-skill-spec: skills/lark-sheets/references/ lark-sheets-batch-update.md + shortcuts/sheets/data/flag-schemas.json pick up the corrected enum (+cells-set-style / +dropdown-set added, +dim-move removed). --- shortcuts/sheets/batch_op_dispatch.go | 226 ++++++++++++++++++ shortcuts/sheets/data/flag-schemas.json | 5 +- shortcuts/sheets/execute_paths_test.go | 25 +- shortcuts/sheets/lark_sheet_batch_update.go | 49 ++-- .../sheets/lark_sheet_batch_update_test.go | 188 +++++++++++++-- .../references/lark-sheets-batch-update.md | 2 +- 6 files changed, 456 insertions(+), 39 deletions(-) create mode 100644 shortcuts/sheets/batch_op_dispatch.go diff --git a/shortcuts/sheets/batch_op_dispatch.go b/shortcuts/sheets/batch_op_dispatch.go new file mode 100644 index 000000000..295053c6d --- /dev/null +++ b/shortcuts/sheets/batch_op_dispatch.go @@ -0,0 +1,226 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── +batch-update sub-op dispatch ───────────────────────────────────── +// +// 用户传给 +batch-update --operations 的形态是 CLI 视角的 {shortcut, input}: +// +// [{"shortcut": "+dim-insert", "input": {...}}, ...] +// +// 而底层 MCP batch_update tool 的契约是 {tool_name, input} —— input 里某些 +// MCP tool 还需要 operation 字段区分动作(如 modify_sheet_structure +// 的 insert / delete / hide / unhide / freeze / group / ungroup)。 +// +// translateBatchOp 做这层翻译:查表 shortcut → mcpToolName + 可选 operation, +// 然后把 operation 注入 input.operation。dispatch 表只列**可纳入 atomic +// batch 的 write shortcut**——读操作、fan-out wrapper(包括 +batch-update +// 自身)、走 legacy v2 endpoint 的 shortcut(如 +dim-move)、需要多步副作用 +// 的 shortcut(如 +cells-set-image / +workbook-create)一律不放进表里, +// 用户传到 +batch-update 里会被 translator 拒绝。 + +type batchOpMapping struct { + // mcpToolName 是底层 MCP batch_update 接受的 tool_name。 + mcpToolName string + // operationField 注入到 input.operation 的值;空 = 不注入(MCP tool 没有 + // operation 字段,如 set_cell_range / clear_cell_range / replace_data / + // set_range_from_csv / resize_range)。 + operationField string +} + +// batchOpDispatch 全表 41 项,覆盖 sheet skill 下所有可 batch 的 write shortcut。 +// 增删请同步 canonical-spec/tool-schemas/cli-schemas.json 的 shortcut enum。 +var batchOpDispatch = map[string]batchOpMapping{ + // ─── 单元格内容 ────────────────────────────────────────────────── + "+cells-set": {mcpToolName: "set_cell_range"}, + "+cells-set-style": {mcpToolName: "set_cell_range"}, + "+cells-clear": {mcpToolName: "clear_cell_range"}, + "+cells-replace": {mcpToolName: "replace_data"}, + "+csv-put": {mcpToolName: "set_range_from_csv"}, + "+dropdown-set": {mcpToolName: "set_cell_range"}, + + // ─── 单元格合并 (merge_cells, operation 区分) ──────────────────── + "+cells-merge": {mcpToolName: "merge_cells", operationField: "merge"}, + "+cells-unmerge": {mcpToolName: "merge_cells", operationField: "unmerge"}, + + // ─── 行列结构 (modify_sheet_structure, operation 区分) ────────── + // 注意:+dim-move 不在此 — 单 shortcut 走 legacy v2 dimension_range + // endpoint,不经 MCP,无法 batch。 + // +dim-freeze 静态注入 operation="freeze",单 shortcut 里基于 count==0 + // 切换 unfreeze 的路径在 batch 里不支持(用户要 unfreeze 用单 shortcut)。 + "+dim-insert": {mcpToolName: "modify_sheet_structure", operationField: "insert"}, + "+dim-delete": {mcpToolName: "modify_sheet_structure", operationField: "delete"}, + "+dim-hide": {mcpToolName: "modify_sheet_structure", operationField: "hide"}, + "+dim-unhide": {mcpToolName: "modify_sheet_structure", operationField: "unhide"}, + "+dim-freeze": {mcpToolName: "modify_sheet_structure", operationField: "freeze"}, + "+dim-group": {mcpToolName: "modify_sheet_structure", operationField: "group"}, + "+dim-ungroup": {mcpToolName: "modify_sheet_structure", operationField: "ungroup"}, + + // ─── 行高列宽 (resize_range, 无 operation 字段) ───────────────── + // row/column 通过 input.resize_height vs input.resize_width 顶层 key 表达。 + "+rows-resize": {mcpToolName: "resize_range"}, + "+cols-resize": {mcpToolName: "resize_range"}, + + // ─── 区域操作 (transform_range, operation 区分) ───────────────── + "+range-move": {mcpToolName: "transform_range", operationField: "move"}, + "+range-copy": {mcpToolName: "transform_range", operationField: "copy"}, + "+range-fill": {mcpToolName: "transform_range", operationField: "fill"}, + "+range-sort": {mcpToolName: "transform_range", operationField: "sort"}, + + // ─── 工作簿 / 子表 (modify_workbook_structure, operation 区分) ── + "+sheet-create": {mcpToolName: "modify_workbook_structure", operationField: "create"}, + "+sheet-delete": {mcpToolName: "modify_workbook_structure", operationField: "delete"}, + "+sheet-rename": {mcpToolName: "modify_workbook_structure", operationField: "rename"}, + "+sheet-move": {mcpToolName: "modify_workbook_structure", operationField: "move"}, + "+sheet-copy": {mcpToolName: "modify_workbook_structure", operationField: "copy"}, + "+sheet-hide": {mcpToolName: "modify_workbook_structure", operationField: "hide"}, + "+sheet-unhide": {mcpToolName: "modify_workbook_structure", operationField: "unhide"}, + "+sheet-set-tab-color": {mcpToolName: "modify_workbook_structure", operationField: "set_tab_color"}, + + // ─── 对象族 CRUD (manage_*_object, operation 区分) ───────────── + "+chart-create": {mcpToolName: "manage_chart_object", operationField: "create"}, + "+chart-update": {mcpToolName: "manage_chart_object", operationField: "update"}, + "+chart-delete": {mcpToolName: "manage_chart_object", operationField: "delete"}, + + "+pivot-create": {mcpToolName: "manage_pivot_table_object", operationField: "create"}, + "+pivot-update": {mcpToolName: "manage_pivot_table_object", operationField: "update"}, + "+pivot-delete": {mcpToolName: "manage_pivot_table_object", operationField: "delete"}, + + "+cond-format-create": {mcpToolName: "manage_conditional_format_object", operationField: "create"}, + "+cond-format-update": {mcpToolName: "manage_conditional_format_object", operationField: "update"}, + "+cond-format-delete": {mcpToolName: "manage_conditional_format_object", operationField: "delete"}, + + "+filter-create": {mcpToolName: "manage_filter_object", operationField: "create"}, + "+filter-update": {mcpToolName: "manage_filter_object", operationField: "update"}, + "+filter-delete": {mcpToolName: "manage_filter_object", operationField: "delete"}, + + "+filter-view-create": {mcpToolName: "manage_filter_view_object", operationField: "create"}, + "+filter-view-update": {mcpToolName: "manage_filter_view_object", operationField: "update"}, + "+filter-view-delete": {mcpToolName: "manage_filter_view_object", operationField: "delete"}, + + "+sparkline-create": {mcpToolName: "manage_sparkline_object", operationField: "create"}, + "+sparkline-update": {mcpToolName: "manage_sparkline_object", operationField: "update"}, + "+sparkline-delete": {mcpToolName: "manage_sparkline_object", operationField: "delete"}, + + "+float-image-create": {mcpToolName: "manage_float_image_object", operationField: "create"}, + "+float-image-update": {mcpToolName: "manage_float_image_object", operationField: "update"}, + "+float-image-delete": {mcpToolName: "manage_float_image_object", operationField: "delete"}, +} + +// reservedSubOpKeys 是禁止用户在 sub-op input 里手填的 key —— 它们要么由 +// shortcut 名隐含(operation),要么由 +batch-update 顶层 --url/--token +// 统一提供(excel_id / spreadsheet_token / url)。 +var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"} + +// translateBatchOp 把一个 CLI 视角的 {shortcut, input} 翻成底层 MCP +// batch_update 的 {tool_name, input(+operation)}。`index` 用于错误信息定位。 +// +// 失败场景: +// - shortcut 字段缺失 / 非 string +// - shortcut 不在 dispatch 表(典型:拼写错;用户传了 read 操作; +// 用户嵌套 +batch-update / +cells-batch-set-style 之类的 fan-out wrapper) +// - input 不是 object +// - input 里手填了 operation(由 shortcut 名隐含,禁手填以防 mismatch) +// - input 里手填了 excel_id / spreadsheet_token / url +func translateBatchOp(raw interface{}, index int) (map[string]interface{}, error) { + op, ok := raw.(map[string]interface{}) + if !ok { + return nil, common.FlagErrorf("operations[%d] must be a JSON object", index) + } + scRaw, present := op["shortcut"] + if !present { + return nil, common.FlagErrorf("operations[%d]: 'shortcut' field is required", index) + } + sc, ok := scRaw.(string) + if !ok || sc == "" { + return nil, common.FlagErrorf("operations[%d]: 'shortcut' must be a non-empty string (got %T)", index, scRaw) + } + mapping, ok := batchOpDispatch[sc] + if !ok { + return nil, common.FlagErrorf( + "operations[%d]: shortcut %q not allowed in +batch-update "+ + "(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +dropdown-{update,delete} / +dim-move are excluded; "+ + "run `lark-cli sheets +batch-update --print-schema --flag-name operations` to see the full enum)", + index, sc, + ) + } + inputRaw, hasInput := op["input"] + var input map[string]interface{} + if !hasInput || inputRaw == nil { + input = map[string]interface{}{} + } else { + input, ok = inputRaw.(map[string]interface{}) + if !ok { + return nil, common.FlagErrorf("operations[%d] (%s): 'input' must be a JSON object (got %T)", index, sc, inputRaw) + } + } + // 禁手填 operation —— 由 shortcut 名表达,手填易与 shortcut 不一致。 + if _, has := input["operation"]; has { + return nil, common.FlagErrorf( + "operations[%d] (%s): do not pass input.operation manually — it is implied by the shortcut name", + index, sc, + ) + } + // 禁在 sub-op 重复填 spreadsheet 定位 —— 由 +batch-update 顶层 --url/--token 统一提供。 + for _, k := range reservedSubOpKeys { + if _, has := input[k]; has { + return nil, common.FlagErrorf( + "operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token", + index, sc, k, + ) + } + } + // 拒绝任何额外的 sub-op 顶层 key(防御未来 schema drift / 用户笔误)。 + for k := range op { + if k != "shortcut" && k != "input" { + return nil, common.FlagErrorf("operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k) + } + } + // 浅拷贝 input,注入 operation(如有),再补 excel_id 由调用方统一注入到顶层后, + // translator 也把 excel_id 写进 sub-op input(MCP tool 要求每个 sub-tool 都带)。 + out := make(map[string]interface{}, len(input)+1) + for k, v := range input { + out[k] = v + } + if mapping.operationField != "" { + out["operation"] = mapping.operationField + } + return map[string]interface{}{ + "tool_name": mapping.mcpToolName, + "input": out, + }, nil +} + +// translateBatchOperations 翻译整个 ops 数组;fail-fast,遇错立即返回。 +// 翻译后会把 excel_id 注入每个 sub-op 的 input(MCP 契约要求)。 +func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) { + if len(rawOps) == 0 { + return nil, common.FlagErrorf("--operations must be a non-empty JSON array") + } + out := make([]interface{}, 0, len(rawOps)) + for i, raw := range rawOps { + translated, err := translateBatchOp(raw, i) + if err != nil { + return nil, err + } + // MCP batch_update 每个 sub-tool 的 input 都需要 excel_id(与单调用一致)。 + input := translated["input"].(map[string]interface{}) + input["excel_id"] = token + out = append(out, translated) + } + return out, nil +} + +// 仅供测试 / 调试:暴露已知 shortcut 列表,便于做 enum 漂移对账。 +func batchOpDispatchKeys() []string { + keys := make([]string, 0, len(batchOpDispatch)) + for k := range batchOpDispatch { + keys = append(keys, k) + } + return keys +} diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json index 5807e648c..c8c1a711b 100644 --- a/shortcuts/sheets/data/flag-schemas.json +++ b/shortcuts/sheets/data/flag-schemas.json @@ -17,11 +17,13 @@ "type": "string", "enum": [ "+cells-set", + "+cells-set-style", "+cells-clear", "+cells-merge", "+cells-unmerge", "+cells-replace", "+csv-put", + "+dropdown-set", "+dim-insert", "+dim-delete", "+dim-hide", @@ -29,7 +31,6 @@ "+dim-freeze", "+dim-group", "+dim-ungroup", - "+dim-move", "+rows-resize", "+cols-resize", "+range-move", @@ -66,7 +67,7 @@ "+float-image-update", "+float-image-delete" ], - "description": "CLI shortcut 名(不是底层 MCP tool 名)" + "description": "CLI shortcut 名(不是底层 MCP tool 名)。+dim-move 不在表中——它走 legacy v2 endpoint,无法批;+cells-set-image / +workbook-create 也不在——前者含多步图片上传,后者是新建工作簿,都不属于 atomic batch 范畴;所有读操作、fan-out wrapper(+batch-update 自身 / +cells-batch-set-style / +dropdown-{update,delete})一律禁。" }, "input": { "type": "object", diff --git a/shortcuts/sheets/execute_paths_test.go b/shortcuts/sheets/execute_paths_test.go index 4de3b2997..60d672a3a 100644 --- a/shortcuts/sheets/execute_paths_test.go +++ b/shortcuts/sheets/execute_paths_test.go @@ -233,14 +233,16 @@ func TestExecute_FilterCreate(t *testing.T) { } } -// TestExecute_BatchUpdate_Raw covers the raw passthrough including -// continue_on_error. -func TestExecute_BatchUpdate_Raw(t *testing.T) { +// TestExecute_BatchUpdate_Translated covers the CLI-shape → MCP-shape +// translation: user passes {shortcut, input}, batchOpDispatch maps it to +// {tool_name, input(+operation, +excel_id)} before the tool call. Also +// verifies --continue-on-error. +func TestExecute_BatchUpdate_Translated(t *testing.T) { t.Parallel() stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`) _, err := runShortcutWithStubs(t, BatchUpdate, []string{ "--url", testURL, - "--operations", `[{"tool_name":"set_cell_range","input":{"excel_id":"shtcnTestTOK","range":"A1","cells":[[{"value":1}]]}}]`, + "--operations", `[{"shortcut":"+cells-set","input":{"range":"A1","cells":[[{"value":1}]]}}]`, "--continue-on-error", "--yes", }, stub) @@ -252,6 +254,21 @@ func TestExecute_BatchUpdate_Raw(t *testing.T) { if input["continue_on_error"] != true { t.Errorf("continue_on_error not propagated: %#v", input) } + ops, _ := input["operations"].([]interface{}) + if len(ops) != 1 { + t.Fatalf("operations length = %d, want 1", len(ops)) + } + op := ops[0].(map[string]interface{}) + if op["tool_name"] != "set_cell_range" { + t.Errorf("op.tool_name = %v, want set_cell_range (translated from +cells-set)", op["tool_name"]) + } + subInput, _ := op["input"].(map[string]interface{}) + if subInput["excel_id"] != testToken { + t.Errorf("op.input.excel_id = %v, want %s (translator should inject)", subInput["excel_id"], testToken) + } + if _, has := subInput["operation"]; has { + t.Errorf("op.input.operation present but +cells-set should not inject one: %#v", subInput) + } } // TestExecute_WorkbookCreate covers the legacy POST + optional diff --git a/shortcuts/sheets/lark_sheet_batch_update.go b/shortcuts/sheets/lark_sheet_batch_update.go index 364a11ddd..34f51045d 100644 --- a/shortcuts/sheets/lark_sheet_batch_update.go +++ b/shortcuts/sheets/lark_sheet_batch_update.go @@ -14,15 +14,19 @@ import ( // // One tool (batch_update), four shortcuts: // -// - +batch-update raw passthrough of an operations array -// (high-risk-write — anything can be inside) +// - +batch-update user supplies a CLI-shape operations array +// [{shortcut, input}, ...]; CLI translates to +// MCP shape {tool_name, input(+operation)} via +// batchOpDispatch before invoking the tool +// (high-risk-write — anything in batchOpDispatch +// can be inside) // - +cells-batch-set-style fan a single style across many ranges // - +dropdown-update install/replace the same dropdown across // many ranges in one atomic batch // - +dropdown-delete clear data_validation across many ranges // (high-risk-write) // -// The tool's contract: +// The tool's contract (post-translation): // { excel_id, operations: [{tool_name, input}, ...], continue_on_error? } // // continue_on_error defaults to false (strict transaction): any failure @@ -30,33 +34,36 @@ import ( // three "fan-out" shortcuts since they're meant to be all-or-nothing; // only +batch-update lets callers flip it via --continue-on-error. -// BatchUpdate is the raw passthrough — caller hands in the operations -// array as --data. high-risk-write because it can wrap anything. +// BatchUpdate accepts a CLI-shape operations array (each item +// {shortcut, input}); on Validate / DryRun / Execute we translate each +// sub-op via batchOpDispatch (see batch_op_dispatch.go) into the MCP +// {tool_name, input(+operation)} form before calling the underlying +// batch_update tool. var BatchUpdate = common.Shortcut{ Service: "sheets", Command: "+batch-update", - Description: "Execute a batch of write tools as a single atomic request (rolls back on failure by default).", + Description: "Execute a batch of write shortcuts as a single atomic request (rolls back on failure by default).", Risk: "high-risk-write", Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+batch-update"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - ops, err := parseBatchOperationsFlag(runtime) + token, err := resolveSpreadsheetToken(runtime) if err != nil { return err } - if len(ops) == 0 { - return common.FlagErrorf("--operations must be a non-empty JSON array") + // Run the full translation in Validate so shape errors surface before + // DryRun / Execute. Translator is pure (no network), so re-running it + // in DryRun / Execute below is fine. + if _, err := batchUpdateInput(runtime, token); err != nil { + return err } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) - input, _ := batchUpdateRawInput(runtime, token) + input, _ := batchUpdateInput(runtime, token) return invokeToolDryRun(token, ToolKindWrite, "batch_update", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { @@ -64,7 +71,7 @@ var BatchUpdate = common.Shortcut{ if err != nil { return err } - input, err := batchUpdateRawInput(runtime, token) + input, err := batchUpdateInput(runtime, token) if err != nil { return err } @@ -77,17 +84,25 @@ var BatchUpdate = common.Shortcut{ }, Tips: []string{ "Default is strict transaction — any sub-tool failure rolls the whole batch back. Pass --continue-on-error to keep partial successes.", + "Each sub-op is {shortcut, input}. Do NOT pass input.operation (implied by shortcut name) or input.excel_id / input.url (set at the +batch-update top level).", }, } -func batchUpdateRawInput(runtime *common.RuntimeContext, token string) (map[string]interface{}, error) { - ops, err := parseBatchOperationsFlag(runtime) +// batchUpdateInput translates the user-supplied CLI-shape operations array +// into the MCP batch_update payload. Returns FlagErrorf-typed errors on +// any per-op shape problem (translator validates each entry). +func batchUpdateInput(runtime *common.RuntimeContext, token string) (map[string]interface{}, error) { + rawOps, err := parseBatchOperationsFlag(runtime) + if err != nil { + return nil, err + } + translated, err := translateBatchOperations(rawOps, token) if err != nil { return nil, err } input := map[string]interface{}{ "excel_id": token, - "operations": ops, + "operations": translated, } if runtime.Bool("continue-on-error") { input["continue_on_error"] = true diff --git a/shortcuts/sheets/lark_sheet_batch_update_test.go b/shortcuts/sheets/lark_sheet_batch_update_test.go index 8dcf23014..dbc19200d 100644 --- a/shortcuts/sheets/lark_sheet_batch_update_test.go +++ b/shortcuts/sheets/lark_sheet_batch_update_test.go @@ -9,33 +9,60 @@ import ( "testing" ) -// TestBatchUpdate_RawPassthrough verifies +batch-update threads -// --data.operations into the tool input as-is and honors -// --continue-on-error. -func TestBatchUpdate_RawPassthrough(t *testing.T) { +// TestBatchUpdate_TranslatesShortcutToToolName verifies +batch-update +// translates each CLI-shape sub-op ({shortcut, input}) to the MCP-shape +// ({tool_name, input(+operation, +excel_id)}) before threading into +// the underlying batch_update tool. Covers continue_on_error too. +func TestBatchUpdate_TranslatesShortcutToToolName(t *testing.T) { t.Parallel() body := parseDryRunBody(t, BatchUpdate, []string{ "--url", testURL, - "--operations", `[{"tool_name":"set_cell_range","input":{"excel_id":"shtcnTOK","sheet_id":"sh1","range":"A1","cells":[[{"value":42}]]}}]`, + "--operations", `[ + {"shortcut":"+cells-set","input":{"sheet_id":"sh1","range":"A1","cells":[[{"value":42}]]}}, + {"shortcut":"+dim-insert","input":{"sheet_id":"sh1","range":"1:3"}} + ]`, "--continue-on-error", "--yes", }) input := decodeToolInput(t, body, "batch_update") ops, _ := input["operations"].([]interface{}) - if len(ops) != 1 { - t.Fatalf("operations length = %d, want 1", len(ops)) + if len(ops) != 2 { + t.Fatalf("operations length = %d, want 2", len(ops)) } if input["continue_on_error"] != true { t.Errorf("continue_on_error = %v, want true", input["continue_on_error"]) } + + // op[0]: +cells-set → set_cell_range, no operation field + op0 := ops[0].(map[string]interface{}) + if op0["tool_name"] != "set_cell_range" { + t.Errorf("op[0].tool_name = %v, want set_cell_range", op0["tool_name"]) + } + in0, _ := op0["input"].(map[string]interface{}) + if in0["excel_id"] == nil { + t.Errorf("op[0].input.excel_id missing (translator should inject)") + } + if _, has := in0["operation"]; has { + t.Errorf("op[0].input.operation present, +cells-set should not inject one: %#v", in0) + } + + // op[1]: +dim-insert → modify_sheet_structure + operation:"insert" + op1 := ops[1].(map[string]interface{}) + if op1["tool_name"] != "modify_sheet_structure" { + t.Errorf("op[1].tool_name = %v, want modify_sheet_structure", op1["tool_name"]) + } + in1, _ := op1["input"].(map[string]interface{}) + if in1["operation"] != "insert" { + t.Errorf("op[1].input.operation = %v, want \"insert\"", in1["operation"]) + } } func TestBatchUpdate_HighRiskWriteRequiresYes(t *testing.T) { t.Parallel() stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{ "--url", testURL, - "--operations", `[{"tool_name":"set_cell_range","input":{}}]`, + "--operations", `[{"shortcut":"+cells-set","input":{}}]`, }) if err == nil { t.Fatalf("expected confirmation_required; stdout=%s stderr=%s", stdout, stderr) @@ -143,13 +170,6 @@ func TestDropdownDelete_BatchClearsValidation(t *testing.T) { func TestBatchUpdate_ValidationGuards(t *testing.T) { t.Parallel() - cases := []struct { - name string - sc interface{ shortcut() } - args []string - want string - }{} - _ = cases // dropdown-update with sheetless range stdout, stderr, err := runShortcutCapturingErr(t, DropdownUpdate, []string{ @@ -185,6 +205,144 @@ func TestBatchUpdate_ValidationGuards(t *testing.T) { } } +// TestBatchUpdate_TranslatorRejects covers per-op shape errors caught by +// translateBatchOp: unknown shortcut, missing shortcut, banned (read / +// fan-out / legacy v2) shortcuts, hand-filled reserved keys, etc. +func TestBatchUpdate_TranslatorRejects(t *testing.T) { + t.Parallel() + cases := []struct { + name string + opsJSON string + wantMatch string + }{ + { + name: "missing shortcut field", + opsJSON: `[{"input":{"range":"A1"}}]`, + wantMatch: "'shortcut' field is required", + }, + { + name: "empty shortcut string", + opsJSON: `[{"shortcut":"","input":{}}]`, + wantMatch: "'shortcut' must be a non-empty string", + }, + { + name: "unknown shortcut", + opsJSON: `[{"shortcut":"+cells-set-magic","input":{}}]`, + wantMatch: "not allowed in +batch-update", + }, + { + name: "read op rejected", + opsJSON: `[{"shortcut":"+cells-get","input":{}}]`, + wantMatch: "not allowed in +batch-update", + }, + { + name: "nested batch-update rejected", + opsJSON: `[{"shortcut":"+batch-update","input":{}}]`, + wantMatch: "not allowed in +batch-update", + }, + { + name: "fan-out wrapper rejected", + opsJSON: `[{"shortcut":"+cells-batch-set-style","input":{}}]`, + wantMatch: "not allowed in +batch-update", + }, + { + name: "legacy v2 +dim-move rejected", + opsJSON: `[{"shortcut":"+dim-move","input":{}}]`, + wantMatch: "not allowed in +batch-update", + }, + { + name: "user filled operation manually", + opsJSON: `[{"shortcut":"+dim-insert","input":{"operation":"delete","range":"1:1"}}]`, + wantMatch: "do not pass input.operation", + }, + { + name: "user filled excel_id", + opsJSON: `[{"shortcut":"+cells-set","input":{"excel_id":"shtcnX","range":"A1"}}]`, + wantMatch: "do not pass input.excel_id", + }, + { + name: "user filled url", + opsJSON: `[{"shortcut":"+cells-set","input":{"url":"https://x.feishu.cn/sheets/sh","range":"A1"}}]`, + wantMatch: "do not pass input.url", + }, + { + name: "extra top-level key", + opsJSON: `[{"shortcut":"+cells-set","input":{"range":"A1"},"tool_name":"oops"}]`, + wantMatch: "unknown top-level key", + }, + { + name: "sub-op not an object", + opsJSON: `["not-an-object"]`, + wantMatch: "must be a JSON object", + }, + { + name: "input not an object", + opsJSON: `[{"shortcut":"+cells-set","input":"not-an-object"}]`, + wantMatch: "'input' must be a JSON object", + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{ + "--url", testURL, + "--operations", tc.opsJSON, + "--yes", + "--dry-run", + }) + if err == nil { + t.Fatalf("expected error containing %q; got stdout=%s stderr=%s", tc.wantMatch, stdout, stderr) + } + if !strings.Contains(stdout+stderr+err.Error(), tc.wantMatch) { + t.Errorf("expected error containing %q; got: %s | %s | %v", tc.wantMatch, stdout, stderr, err) + } + }) + } +} + +// 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). +func TestBatchUpdate_DimFreezeInjectsFreeze(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, BatchUpdate, []string{ + "--url", testURL, + "--operations", `[{"shortcut":"+dim-freeze","input":{"sheet_id":"sh1","freeze_rows":2}}]`, + "--yes", + }) + input := decodeToolInput(t, body, "batch_update") + ops, _ := input["operations"].([]interface{}) + op := ops[0].(map[string]interface{}) + if op["tool_name"] != "modify_sheet_structure" { + t.Errorf("tool_name = %v, want modify_sheet_structure", op["tool_name"]) + } + in, _ := op["input"].(map[string]interface{}) + if in["operation"] != "freeze" { + t.Errorf("operation = %v, want \"freeze\"", in["operation"]) + } +} + +// TestBatchUpdate_ResizeNoOperationField covers the resize_range dispatch: +// mapping has no operationField, so input.operation must NOT be injected. +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","resize_height":{"type":"pixel","value":30}}}]`, + "--yes", + }) + input := decodeToolInput(t, body, "batch_update") + op := input["operations"].([]interface{})[0].(map[string]interface{}) + if op["tool_name"] != "resize_range" { + t.Errorf("tool_name = %v, want resize_range", op["tool_name"]) + } + in, _ := op["input"].(map[string]interface{}) + if _, has := in["operation"]; has { + t.Errorf("operation should NOT be injected for resize_range; got %#v", in) + } +} + // TestSplitSheetPrefixedRange exercises the helper directly. func TestSplitSheetPrefixedRange(t *testing.T) { t.Parallel() diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index d837cdd32..890215f74 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -88,7 +88,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_ _要批量执行的 CLI shortcut 操作列表,按声明顺序串行执行;任一失败立即中断_ **数组项**(类型 object): -- `shortcut` (enum) — CLI shortcut 名(不是底层 MCP tool 名) [+cells-set / +cells-clear / +cells-merge / +cells-unmerge / +cells-replace / +csv-put / +dim-insert / +dim-delete / …共 49 项] +- `shortcut` (enum) — CLI shortcut 名(不是底层 MCP tool 名) [+cells-set / +cells-set-style / +cells-clear / +cells-merge / +cells-unmerge / +cells-replace / +csv-put / +dropdown-set / …共 50 项] - `input` (object) — 该 shortcut 的入参集(不含 spreadsheet 定位);基础 flag 跑 `lark-cli sheets --help… ### `+cells-batch-set-style` `--border-styles` From 0ea7c14e4a9ceae460a58dfce7ef71528c662b72 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Thu, 21 May 2026 10:41:42 +0800 Subject: [PATCH 024/114] =?UTF-8?q?fix(sheets):=20make=20+batch-update=20s?= =?UTF-8?q?ub-ops=20reuse=20standalone=20flag=E2=86=92body=20translators?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-ops previously near-passed-through their input, so any shortcut whose standalone translator renames fields broke inside a batch: +range-copy lost range/destination_range (transform_range errored "range missing") and +rows-resize lost range/resize_height ("No resize operation specified"). Introduce a flagView interface (satisfied by *common.RuntimeContext) and a map-backed mapFlagView, then route every batchable sub-op through the SAME *Input builder the standalone shortcut uses. mapFlagView seeds flag-defs.json defaults for value reads while keeping Changed() user-driven, so a sub-op body is byte-identical to the standalone body — locked by a batch-vs-standalone contract test over all ~40 batchable shortcuts. Also fix single-row/column resize: start==end now formats as "23:23" / "C:C" (resize_range rejects a bare "23"); dimRangeFull keeps both sides while dimRange's collapse stays for modify_sheet_structure consumers. --- shortcuts/sheets/batch_op_contract_test.go | 355 ++++++++++++++++++ shortcuts/sheets/batch_op_dispatch.go | 265 ++++++++----- shortcuts/sheets/flag_view.go | 247 ++++++++++++ shortcuts/sheets/helpers.go | 10 +- .../sheets/lark_sheet_batch_update_test.go | 6 +- shortcuts/sheets/lark_sheet_object_crud.go | 24 +- .../sheets/lark_sheet_range_operations.go | 20 +- .../lark_sheet_range_operations_test.go | 4 +- shortcuts/sheets/lark_sheet_search_replace.go | 4 +- .../sheets/lark_sheet_sheet_structure.go | 19 +- shortcuts/sheets/lark_sheet_workbook.go | 88 ++--- shortcuts/sheets/lark_sheet_write_cells.go | 10 +- .../references/lark-sheets-batch-update.md | 16 + 13 files changed, 892 insertions(+), 176 deletions(-) create mode 100644 shortcuts/sheets/batch_op_contract_test.go create mode 100644 shortcuts/sheets/flag_view.go diff --git a/shortcuts/sheets/batch_op_contract_test.go b/shortcuts/sheets/batch_op_contract_test.go new file mode 100644 index 000000000..7f420deb5 --- /dev/null +++ b/shortcuts/sheets/batch_op_contract_test.go @@ -0,0 +1,355 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +// TestBatchOp_BodyMatchesStandalone is the core contract: for every batchable +// shortcut, the MCP body produced inside +batch-update must be byte-for-byte +// identical to the body the same shortcut produces when invoked standalone +// (both observed via --dry-run, comparing tool_name + decoded input). This is +// what guarantees "a sub-op behaves exactly like the standalone command", and +// it is the regression guard for the whole flag→body translator reuse. +// +// Each case provides the standalone CLI args and the equivalent sub-op input +// object (same CLI flag names, minus the spreadsheet locator which the batch +// supplies at the top level). +func TestBatchOp_BodyMatchesStandalone(t *testing.T) { + t.Parallel() + + cases := []struct { + shortcut string + sc common.Shortcut + // standalone args (excluding --url, which every case shares) + args []string + // sub-op input object as JSON (CLI flag names; no excel_id/url) + subInput string + }{ + { + shortcut: "+cells-set", + sc: CellsSet, + args: []string{"--sheet-id", "sh1", "--range", "A1:B1", "--cells", `[[{"value":"x"},{"value":"y"}]]`}, + subInput: `{"sheet-id":"sh1","range":"A1:B1","cells":[[{"value":"x"},{"value":"y"}]]}`, + }, + { + shortcut: "+cells-clear", + sc: CellsClear, + args: []string{"--sheet-id", "sh1", "--range", "A1:C3", "--scope", "formats"}, + subInput: `{"sheet-id":"sh1","range":"A1:C3","scope":"formats"}`, + }, + { + shortcut: "+cells-replace", + sc: CellsReplace, + args: []string{"--sheet-id", "sh1", "--find", "foo", "--replacement", "bar", "--match-case"}, + subInput: `{"sheet-id":"sh1","find":"foo","replacement":"bar","match-case":true}`, + }, + { + shortcut: "+csv-put", + sc: CsvPut, + args: []string{"--sheet-id", "sh1", "--csv", "a,b\n1,2", "--start-cell", "B2"}, + subInput: `{"sheet-id":"sh1","csv":"a,b\n1,2","start-cell":"B2"}`, + }, + { + shortcut: "+cells-merge", + sc: CellsMerge, + args: []string{"--sheet-id", "sh1", "--range", "A1:C1", "--merge-type", "rows"}, + subInput: `{"sheet-id":"sh1","range":"A1:C1","merge-type":"rows"}`, + }, + { + shortcut: "+cells-unmerge", + sc: CellsUnmerge, + args: []string{"--sheet-id", "sh1", "--range", "A1:C1"}, + subInput: `{"sheet-id":"sh1","range":"A1:C1"}`, + }, + { + shortcut: "+dim-insert", + sc: DimInsert, + args: []string{"--sheet-id", "sh1", "--dimension", "row", "--start", "10", "--end", "12", "--inherit-style", "before"}, + subInput: `{"sheet-id":"sh1","dimension":"row","start":10,"end":12,"inherit-style":"before"}`, + }, + { + shortcut: "+dim-delete", + sc: DimDelete, + args: []string{"--sheet-id", "sh1", "--dimension", "column", "--start", "2", "--end", "4"}, + subInput: `{"sheet-id":"sh1","dimension":"column","start":2,"end":4}`, + }, + { + shortcut: "+dim-hide", + sc: DimHide, + args: []string{"--sheet-id", "sh1", "--dimension", "row", "--start", "1", "--end", "3"}, + subInput: `{"sheet-id":"sh1","dimension":"row","start":1,"end":3}`, + }, + { + shortcut: "+dim-freeze", + sc: DimFreeze, + args: []string{"--sheet-id", "sh1", "--dimension", "row", "--count", "2"}, + subInput: `{"sheet-id":"sh1","dimension":"row","count":2}`, + }, + { + shortcut: "+dim-group", + sc: DimGroup, + args: []string{"--sheet-id", "sh1", "--dimension", "row", "--start", "1", "--end", "5", "--group-state", "fold"}, + subInput: `{"sheet-id":"sh1","dimension":"row","start":1,"end":5,"group-state":"fold"}`, + }, + { + shortcut: "+rows-resize", + sc: RowsResize, + args: []string{"--sheet-id", "sh1", "--start", "0", "--end", "0", "--type", "pixel", "--size", "30"}, + subInput: `{"sheet-id":"sh1","start":0,"end":0,"type":"pixel","size":30}`, + }, + { + shortcut: "+cols-resize", + sc: ColsResize, + args: []string{"--sheet-id", "sh1", "--start", "1", "--end", "3", "--type", "standard"}, + subInput: `{"sheet-id":"sh1","start":1,"end":3,"type":"standard"}`, + }, + { + shortcut: "+range-move", + sc: RangeMove, + args: []string{"--sheet-id", "sh1", "--source-range", "A1:C5", "--target-range", "D1"}, + subInput: `{"sheet-id":"sh1","source-range":"A1:C5","target-range":"D1"}`, + }, + { + shortcut: "+range-copy", + sc: RangeCopy, + args: []string{"--sheet-id", "sh1", "--source-range", "A1:B2", "--target-range", "A10", "--paste-type", "values"}, + subInput: `{"sheet-id":"sh1","source-range":"A1:B2","target-range":"A10","paste-type":"values"}`, + }, + { + shortcut: "+range-fill", + sc: RangeFill, + args: []string{"--sheet-id", "sh1", "--source-range", "A1:A2", "--target-range", "A1:A10", "--series-type", "linear"}, + subInput: `{"sheet-id":"sh1","source-range":"A1:A2","target-range":"A1:A10","series-type":"linear"}`, + }, + { + shortcut: "+range-sort", + sc: RangeSort, + args: []string{"--sheet-id", "sh1", "--range", "A1:D10", "--sort-keys", `[{"col":"B","order":"asc"}]`, "--has-header"}, + subInput: `{"sheet-id":"sh1","range":"A1:D10","sort-keys":[{"col":"B","order":"asc"}],"has-header":true}`, + }, + { + shortcut: "+sheet-create", + sc: SheetCreate, + args: []string{"--title", "New", "--index", "2"}, + subInput: `{"title":"New","index":2}`, + }, + { + shortcut: "+sheet-delete", + sc: SheetDelete, + args: []string{"--sheet-id", "sh1"}, + subInput: `{"sheet-id":"sh1"}`, + }, + { + shortcut: "+sheet-rename", + sc: SheetRename, + args: []string{"--sheet-id", "sh1", "--title", "Renamed"}, + subInput: `{"sheet-id":"sh1","title":"Renamed"}`, + }, + { + shortcut: "+sheet-copy", + sc: SheetCopy, + args: []string{"--sheet-id", "sh1", "--title", "Copy"}, + subInput: `{"sheet-id":"sh1","title":"Copy"}`, + }, + { + shortcut: "+sheet-hide", + sc: SheetHide, + args: []string{"--sheet-id", "sh1"}, + subInput: `{"sheet-id":"sh1"}`, + }, + { + shortcut: "+sheet-unhide", + sc: SheetUnhide, + args: []string{"--sheet-id", "sh1"}, + subInput: `{"sheet-id":"sh1"}`, + }, + { + shortcut: "+sheet-set-tab-color", + sc: SheetSetTabColor, + args: []string{"--sheet-id", "sh1", "--color", "#FF0000"}, + subInput: `{"sheet-id":"sh1","color":"#FF0000"}`, + }, + { + shortcut: "+dropdown-set", + sc: DropdownSet, + args: []string{"--sheet-id", "sh1", "--range", "A2:A4", "--options", `["x","y"]`, "--multiple"}, + subInput: `{"sheet-id":"sh1","range":"A2:A4","options":["x","y"],"multiple":true}`, + }, + { + shortcut: "+chart-create", + sc: ChartCreate, + args: []string{"--sheet-id", "sh1", "--properties", `{"position":{"start":"A1"}}`}, + subInput: `{"sheet-id":"sh1","properties":{"position":{"start":"A1"}}}`, + }, + { + shortcut: "+chart-update", + sc: ChartUpdate, + args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--properties", `{"title":"T"}`}, + subInput: `{"sheet-id":"sh1","chart-id":"c1","properties":{"title":"T"}}`, + }, + { + shortcut: "+chart-delete", + sc: ChartDelete, + args: []string{"--sheet-id", "sh1", "--chart-id", "c1"}, + subInput: `{"sheet-id":"sh1","chart-id":"c1"}`, + }, + { + shortcut: "+pivot-create", + sc: PivotCreate, + args: []string{"--sheet-id", "sh1", "--properties", `{"rows":[]}`, "--source", "Sheet1!A1:D100"}, + subInput: `{"sheet-id":"sh1","properties":{"rows":[]},"source":"Sheet1!A1:D100"}`, + }, + { + shortcut: "+cond-format-create", + sc: CondFormatCreate, + args: []string{"--sheet-id", "sh1", "--properties", `{"style":{}}`, "--rule-type", "duplicate", "--ranges", `["A1:A100"]`}, + subInput: `{"sheet-id":"sh1","properties":{"style":{}},"rule-type":"duplicate","ranges":["A1:A100"]}`, + }, + { + shortcut: "+filter-create", + sc: FilterCreate, + args: []string{"--sheet-id", "sh1", "--range", "A1:F1000", "--properties", `{"rules":[]}`}, + subInput: `{"sheet-id":"sh1","range":"A1:F1000","properties":{"rules":[]}}`, + }, + { + shortcut: "+filter-update", + sc: FilterUpdate, + args: []string{"--sheet-id", "sh1", "--range", "A1:F1000", "--properties", `{"rules":[]}`}, + subInput: `{"sheet-id":"sh1","range":"A1:F1000","properties":{"rules":[]}}`, + }, + { + shortcut: "+filter-delete", + sc: FilterDelete, + args: []string{"--sheet-id", "sh1"}, + subInput: `{"sheet-id":"sh1"}`, + }, + { + shortcut: "+filter-view-create", + sc: FilterViewCreate, + args: []string{"--sheet-id", "sh1", "--range", "A1:Z100", "--view-name", "v1", "--properties", `{"rules":[]}`}, + subInput: `{"sheet-id":"sh1","range":"A1:Z100","view-name":"v1","properties":{"rules":[]}}`, + }, + { + shortcut: "+sparkline-create", + sc: SparklineCreate, + args: []string{"--sheet-id", "sh1", "--properties", `{"type":"line","data_range":"A2:F2","target_range":"G2"}`}, + subInput: `{"sheet-id":"sh1","properties":{"type":"line","data_range":"A2:F2","target_range":"G2"}}`, + }, + { + shortcut: "+sparkline-delete", + sc: SparklineDelete, + args: []string{"--sheet-id", "sh1", "--group-id", "g1"}, + subInput: `{"sheet-id":"sh1","group-id":"g1"}`, + }, + { + shortcut: "+float-image-create", + sc: FloatImageCreate, + args: []string{"--sheet-id", "sh1", "--image-name", "logo.png", "--image-token", "tok", "--position-row", "0", "--position-col", "A", "--size-width", "100", "--size-height", "50"}, + subInput: `{"sheet-id":"sh1","image-name":"logo.png","image-token":"tok","position-row":0,"position-col":"A","size-width":100,"size-height":50}`, + }, + { + shortcut: "+float-image-delete", + sc: FloatImageDelete, + args: []string{"--sheet-id", "sh1", "--float-image-id", "fi1"}, + subInput: `{"sheet-id":"sh1","float-image-id":"fi1"}`, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.shortcut, func(t *testing.T) { + t.Parallel() + + mapping, ok := batchOpDispatch[tc.shortcut] + if !ok { + t.Fatalf("%s not in batchOpDispatch", tc.shortcut) + } + + // Standalone body via the shortcut's own dry-run. + standaloneBody := decodeToolInput(t, parseDryRunBody(t, tc.sc, append([]string{"--url", testURL}, tc.args...)), mapping.mcpToolName) + + // Batch body via the +batch-update translator. + var subInput map[string]interface{} + if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil { + t.Fatalf("bad subInput JSON: %v", err) + } + fv := newMapFlagViewForCommand(tc.shortcut, subInput) + sid := subInput["sheet-id"] + sname := subInput["sheet-name"] + sidStr, _ := sid.(string) + snameStr, _ := sname.(string) + batchBody, err := mapping.translate(fv, testToken, sidStr, snameStr) + if err != nil { + t.Fatalf("batch translate failed: %v", err) + } + + // Round-trip the batch body through JSON so number types match the + // standalone path (which is decoded from a JSON string). + batchBody = jsonRoundTrip(t, batchBody) + + if !reflect.DeepEqual(standaloneBody, batchBody) { + t.Errorf("%s: batch body != standalone body\n standalone=%#v\n batch =%#v", tc.shortcut, standaloneBody, batchBody) + } + }) + } +} + +func jsonRoundTrip(t *testing.T, m map[string]interface{}) map[string]interface{} { + t.Helper() + b, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out map[string]interface{} + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return out +} + +// TestBatchOp_DispatchCoversReportedBugs is a focused guard for the two +// originally reported failures: +range-copy and +rows-resize sub-ops must +// translate to the correct MCP body (not a near-passthrough that drops +// required fields). +func TestBatchOp_DispatchCoversReportedBugs(t *testing.T) { + t.Parallel() + + // +range-copy → transform_range with range / destination_range (not the + // raw source_range / target_range that used to leak through). + body := parseDryRunBody(t, BatchUpdate, []string{ + "--url", testURL, + "--operations", `[{"shortcut":"+range-copy","input":{"sheet-id":"sh1","source-range":"A1:B2","target-range":"A10","paste-type":"all"}}]`, + "--yes", + }) + ops := decodeToolInput(t, body, "batch_update")["operations"].([]interface{}) + copyIn := ops[0].(map[string]interface{})["input"].(map[string]interface{}) + if copyIn["range"] != "A1:B2" || copyIn["destination_range"] != "A10" { + t.Errorf("+range-copy sub-op body wrong: %#v", copyIn) + } + if copyIn["operation"] != "copy" { + t.Errorf("+range-copy operation = %v, want copy", copyIn["operation"]) + } + + // +rows-resize → resize_range with range + resize_height (not raw start/end). + body = parseDryRunBody(t, BatchUpdate, []string{ + "--url", testURL, + "--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","start":22,"end":22,"type":"pixel","size":40}}]`, + "--yes", + }) + ops = decodeToolInput(t, body, "batch_update")["operations"].([]interface{}) + resizeIn := ops[0].(map[string]interface{})["input"].(map[string]interface{}) + if resizeIn["range"] != "23:23" { + t.Errorf("+rows-resize single-row range = %v, want 23:23", resizeIn["range"]) + } + rh, _ := resizeIn["resize_height"].(map[string]interface{}) + if rh == nil || rh["type"] != "pixel" { + t.Errorf("+rows-resize resize_height wrong: %#v", resizeIn) + } +} diff --git a/shortcuts/sheets/batch_op_dispatch.go b/shortcuts/sheets/batch_op_dispatch.go index 295053c6d..4c2ae3861 100644 --- a/shortcuts/sheets/batch_op_dispatch.go +++ b/shortcuts/sheets/batch_op_dispatch.go @@ -4,6 +4,8 @@ package sheets import ( + "strings" + "github.com/larksuite/cli/shortcuts/common" ) @@ -11,123 +13,208 @@ import ( // // 用户传给 +batch-update --operations 的形态是 CLI 视角的 {shortcut, input}: // -// [{"shortcut": "+dim-insert", "input": {...}}, ...] +// [{"shortcut": "+range-copy", "input": {"sheet_id":"...","source-range":"A1:B2","target-range":"A10"}}, ...] // -// 而底层 MCP batch_update tool 的契约是 {tool_name, input} —— input 里某些 -// MCP tool 还需要 operation 字段区分动作(如 modify_sheet_structure -// 的 insert / delete / hide / unhide / freeze / group / ungroup)。 +// input 里用的是该 shortcut 的 **CLI flag 名**(与 standalone 调用一致;连字符 / +// 下划线两种写法都接受)。底层 MCP batch_update tool 要的是 +// {tool_name, input(MCP body)} —— body 的字段名往往与 CLI flag 名不同 +// (如 +range-copy 的 source-range/target-range 要翻成 range/destination_range)。 // -// translateBatchOp 做这层翻译:查表 shortcut → mcpToolName + 可选 operation, -// 然后把 operation 注入 input.operation。dispatch 表只列**可纳入 atomic -// batch 的 write shortcut**——读操作、fan-out wrapper(包括 +batch-update -// 自身)、走 legacy v2 endpoint 的 shortcut(如 +dim-move)、需要多步副作用 -// 的 shortcut(如 +cells-set-image / +workbook-create)一律不放进表里, +// 关键:每个子操作复用 **standalone shortcut 同一套 flag→body translator** +// (那些 *Input 构建函数,现在统一接收 flagView 接口)。这样 batch 子操作 +// 产出的 MCP body 与该 shortcut 单独调用产出的 body 完全一致(由 +// batch-vs-standalone 契约测试保证)。dispatch 表只列**可纳入 atomic batch +// 的 write shortcut**——读操作、fan-out wrapper(+batch-update 自身、 +// +cells-batch-set-style、+dropdown-{update,delete})一律不放进表里, // 用户传到 +batch-update 里会被 translator 拒绝。 +// batchTranslateFn turns a sub-op's CLI-shape input (via flagView) into the MCP +// tool body for the underlying batch_update sub-tool. token is the +// +batch-update top-level spreadsheet token; sheetID/sheetName are the resolved +// sheet selector for this sub-op. The returned body already carries excel_id +// and (where the tool needs one) the operation discriminator — exactly as the +// standalone shortcut would emit. +type batchTranslateFn func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) + type batchOpMapping struct { // mcpToolName 是底层 MCP batch_update 接受的 tool_name。 mcpToolName string - // operationField 注入到 input.operation 的值;空 = 不注入(MCP tool 没有 - // operation 字段,如 set_cell_range / clear_cell_range / replace_data / - // set_range_from_csv / resize_range)。 - operationField string + // translate 复用 standalone 的 *Input 构建逻辑,产出 MCP body。 + translate batchTranslateFn +} + +// noErrTranslate adapts a builder that cannot fail into a batchTranslateFn. +func noErrTranslate(f func(fv flagView, token, sheetID, sheetName string) map[string]interface{}) batchTranslateFn { + return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + return f(fv, token, sheetID, sheetName), nil + } +} + +// objCreateTranslate / objUpdateTranslate / objDeleteTranslate bind an object +// CRUD spec to the shared object_crud builders. +func objCreateTranslate(spec objectCRUDSpec) batchTranslateFn { + return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + return objectCreateInput(fv, token, sheetID, sheetName, spec) + } +} + +func objUpdateTranslate(spec objectCRUDSpec) batchTranslateFn { + return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + return objectUpdateInput(fv, token, sheetID, sheetName, spec) + } } -// batchOpDispatch 全表 41 项,覆盖 sheet skill 下所有可 batch 的 write shortcut。 -// 增删请同步 canonical-spec/tool-schemas/cli-schemas.json 的 shortcut enum。 +func objDeleteTranslate(spec objectCRUDSpec) batchTranslateFn { + return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + return objectDeleteInput(fv, token, sheetID, sheetName, spec), nil + } +} + +// batchOpDispatch covers every write shortcut that can join an atomic batch. var batchOpDispatch = map[string]batchOpMapping{ // ─── 单元格内容 ────────────────────────────────────────────────── - "+cells-set": {mcpToolName: "set_cell_range"}, - "+cells-set-style": {mcpToolName: "set_cell_range"}, - "+cells-clear": {mcpToolName: "clear_cell_range"}, - "+cells-replace": {mcpToolName: "replace_data"}, - "+csv-put": {mcpToolName: "set_range_from_csv"}, - "+dropdown-set": {mcpToolName: "set_cell_range"}, + "+cells-set": {"set_cell_range", cellsSetInput}, + "+cells-set-style": {"set_cell_range", cellsSetStyleInput}, + "+cells-clear": {"clear_cell_range", noErrTranslate(cellsClearInput)}, + "+cells-replace": {"replace_data", noErrTranslate(replaceInput)}, + "+csv-put": {"set_range_from_csv", noErrTranslate(csvPutInput)}, + "+dropdown-set": {"set_cell_range", dropdownSetInput}, // ─── 单元格合并 (merge_cells, operation 区分) ──────────────────── - "+cells-merge": {mcpToolName: "merge_cells", operationField: "merge"}, - "+cells-unmerge": {mcpToolName: "merge_cells", operationField: "unmerge"}, + "+cells-merge": {"merge_cells", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return mergeInput(fv, token, sid, sname, "merge", true), nil + }}, + "+cells-unmerge": {"merge_cells", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return mergeInput(fv, token, sid, sname, "unmerge", false), nil + }}, // ─── 行列结构 (modify_sheet_structure, operation 区分) ────────── - // 注意:+dim-move 不在此 — 单 shortcut 走 legacy v2 dimension_range - // endpoint,不经 MCP,无法 batch。 - // +dim-freeze 静态注入 operation="freeze",单 shortcut 里基于 count==0 - // 切换 unfreeze 的路径在 batch 里不支持(用户要 unfreeze 用单 shortcut)。 - "+dim-insert": {mcpToolName: "modify_sheet_structure", operationField: "insert"}, - "+dim-delete": {mcpToolName: "modify_sheet_structure", operationField: "delete"}, - "+dim-hide": {mcpToolName: "modify_sheet_structure", operationField: "hide"}, - "+dim-unhide": {mcpToolName: "modify_sheet_structure", operationField: "unhide"}, - "+dim-freeze": {mcpToolName: "modify_sheet_structure", operationField: "freeze"}, - "+dim-group": {mcpToolName: "modify_sheet_structure", operationField: "group"}, - "+dim-ungroup": {mcpToolName: "modify_sheet_structure", operationField: "ungroup"}, + "+dim-insert": {"modify_sheet_structure", noErrTranslate(dimInsertInput)}, + "+dim-delete": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return dimRangeOpInput(fv, token, sid, sname, "delete"), nil + }}, + "+dim-hide": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return dimRangeOpInput(fv, token, sid, sname, "hide"), nil + }}, + "+dim-unhide": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return dimRangeOpInput(fv, token, sid, sname, "unhide"), nil + }}, + "+dim-freeze": {"modify_sheet_structure", noErrTranslate(dimFreezeInput)}, + "+dim-group": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return dimGroupInput(fv, token, sid, sname, "group"), nil + }}, + "+dim-ungroup": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return dimGroupInput(fv, token, sid, sname, "ungroup"), nil + }}, // ─── 行高列宽 (resize_range, 无 operation 字段) ───────────────── - // row/column 通过 input.resize_height vs input.resize_width 顶层 key 表达。 - "+rows-resize": {mcpToolName: "resize_range"}, - "+cols-resize": {mcpToolName: "resize_range"}, + "+rows-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return resizeInput(fv, token, sid, sname, "row"), nil + }}, + "+cols-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return resizeInput(fv, token, sid, sname, "column"), nil + }}, // ─── 区域操作 (transform_range, operation 区分) ───────────────── - "+range-move": {mcpToolName: "transform_range", operationField: "move"}, - "+range-copy": {mcpToolName: "transform_range", operationField: "copy"}, - "+range-fill": {mcpToolName: "transform_range", operationField: "fill"}, - "+range-sort": {mcpToolName: "transform_range", operationField: "sort"}, + "+range-move": {"transform_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return transformMoveCopyInput(fv, token, sid, sname, "move", false), nil + }}, + "+range-copy": {"transform_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return transformMoveCopyInput(fv, token, sid, sname, "copy", true), nil + }}, + "+range-fill": {"transform_range", noErrTranslate(rangeFillInput)}, + "+range-sort": {"transform_range", rangeSortInput}, // ─── 工作簿 / 子表 (modify_workbook_structure, operation 区分) ── - "+sheet-create": {mcpToolName: "modify_workbook_structure", operationField: "create"}, - "+sheet-delete": {mcpToolName: "modify_workbook_structure", operationField: "delete"}, - "+sheet-rename": {mcpToolName: "modify_workbook_structure", operationField: "rename"}, - "+sheet-move": {mcpToolName: "modify_workbook_structure", operationField: "move"}, - "+sheet-copy": {mcpToolName: "modify_workbook_structure", operationField: "copy"}, - "+sheet-hide": {mcpToolName: "modify_workbook_structure", operationField: "hide"}, - "+sheet-unhide": {mcpToolName: "modify_workbook_structure", operationField: "unhide"}, - "+sheet-set-tab-color": {mcpToolName: "modify_workbook_structure", operationField: "set_tab_color"}, + "+sheet-create": {"modify_workbook_structure", func(fv flagView, token, _, _ string) (map[string]interface{}, error) { + return sheetCreateInput(fv, token), nil + }}, + "+sheet-delete": {"modify_workbook_structure", noErrTranslate(sheetDeleteInput)}, + "+sheet-rename": {"modify_workbook_structure", noErrTranslate(sheetRenameInput)}, + "+sheet-move": {"modify_workbook_structure", sheetMoveBatchInput}, + "+sheet-copy": {"modify_workbook_structure", noErrTranslate(sheetCopyInput)}, + "+sheet-hide": {"modify_workbook_structure", noErrTranslate(func(fv flagView, t, sid, sn string) map[string]interface{} { + return sheetVisibilityInput(fv, t, sid, sn, "hide") + })}, + "+sheet-unhide": {"modify_workbook_structure", noErrTranslate(func(fv flagView, t, sid, sn string) map[string]interface{} { + return sheetVisibilityInput(fv, t, sid, sn, "unhide") + })}, + "+sheet-set-tab-color": {"modify_workbook_structure", noErrTranslate(sheetSetTabColorInput)}, // ─── 对象族 CRUD (manage_*_object, operation 区分) ───────────── - "+chart-create": {mcpToolName: "manage_chart_object", operationField: "create"}, - "+chart-update": {mcpToolName: "manage_chart_object", operationField: "update"}, - "+chart-delete": {mcpToolName: "manage_chart_object", operationField: "delete"}, + "+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)}, + "+chart-update": {"manage_chart_object", objUpdateTranslate(chartSpec)}, + "+chart-delete": {"manage_chart_object", objDeleteTranslate(chartSpec)}, - "+pivot-create": {mcpToolName: "manage_pivot_table_object", operationField: "create"}, - "+pivot-update": {mcpToolName: "manage_pivot_table_object", operationField: "update"}, - "+pivot-delete": {mcpToolName: "manage_pivot_table_object", operationField: "delete"}, + "+pivot-create": {"manage_pivot_table_object", objCreateTranslate(pivotSpec)}, + "+pivot-update": {"manage_pivot_table_object", objUpdateTranslate(pivotSpec)}, + "+pivot-delete": {"manage_pivot_table_object", objDeleteTranslate(pivotSpec)}, - "+cond-format-create": {mcpToolName: "manage_conditional_format_object", operationField: "create"}, - "+cond-format-update": {mcpToolName: "manage_conditional_format_object", operationField: "update"}, - "+cond-format-delete": {mcpToolName: "manage_conditional_format_object", operationField: "delete"}, + "+cond-format-create": {"manage_conditional_format_object", objCreateTranslate(condFormatSpec)}, + "+cond-format-update": {"manage_conditional_format_object", objUpdateTranslate(condFormatSpec)}, + "+cond-format-delete": {"manage_conditional_format_object", objDeleteTranslate(condFormatSpec)}, - "+filter-create": {mcpToolName: "manage_filter_object", operationField: "create"}, - "+filter-update": {mcpToolName: "manage_filter_object", operationField: "update"}, - "+filter-delete": {mcpToolName: "manage_filter_object", operationField: "delete"}, + "+filter-create": {"manage_filter_object", filterCreateInput}, + "+filter-update": {"manage_filter_object", filterUpdateInput}, + "+filter-delete": {"manage_filter_object", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + input := map[string]interface{}{"excel_id": token, "operation": "delete"} + sheetSelectorForToolInput(input, sid, sname) + return input, nil + }}, - "+filter-view-create": {mcpToolName: "manage_filter_view_object", operationField: "create"}, - "+filter-view-update": {mcpToolName: "manage_filter_view_object", operationField: "update"}, - "+filter-view-delete": {mcpToolName: "manage_filter_view_object", operationField: "delete"}, + "+filter-view-create": {"manage_filter_view_object", objCreateTranslate(filterViewSpec)}, + "+filter-view-update": {"manage_filter_view_object", objUpdateTranslate(filterViewSpec)}, + "+filter-view-delete": {"manage_filter_view_object", objDeleteTranslate(filterViewSpec)}, - "+sparkline-create": {mcpToolName: "manage_sparkline_object", operationField: "create"}, - "+sparkline-update": {mcpToolName: "manage_sparkline_object", operationField: "update"}, - "+sparkline-delete": {mcpToolName: "manage_sparkline_object", operationField: "delete"}, + "+sparkline-create": {"manage_sparkline_object", objCreateTranslate(sparklineSpec)}, + "+sparkline-update": {"manage_sparkline_object", objUpdateTranslate(sparklineSpec)}, + "+sparkline-delete": {"manage_sparkline_object", objDeleteTranslate(sparklineSpec)}, + + "+float-image-create": {"manage_float_image_object", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return floatImageWriteInput(fv, token, sid, sname, "create", false) + }}, + "+float-image-update": {"manage_float_image_object", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return floatImageWriteInput(fv, token, sid, sname, "update", true) + }}, + "+float-image-delete": {"manage_float_image_object", objDeleteTranslate(floatImageDeleteSpec)}, +} - "+float-image-create": {mcpToolName: "manage_float_image_object", operationField: "create"}, - "+float-image-update": {mcpToolName: "manage_float_image_object", operationField: "update"}, - "+float-image-delete": {mcpToolName: "manage_float_image_object", operationField: "delete"}, +// sheetMoveBatchInput translates +sheet-move inside a batch. Unlike the +// standalone shortcut it cannot issue the get_workbook_structure read that +// auto-derives sheet_id / source_index, so both must be supplied explicitly. +func sheetMoveBatchInput(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if sheetID == "" { + return nil, common.FlagErrorf("+sheet-move in +batch-update requires sheet_id (sheet_name needs a network lookup unavailable mid-batch)") + } + if !fv.Changed("source-index") { + return nil, common.FlagErrorf("+sheet-move in +batch-update requires source_index (auto-derive needs a network lookup unavailable mid-batch)") + } + return map[string]interface{}{ + "excel_id": token, + "operation": "move", + "sheet_id": sheetID, + "source_index": fv.Int("source-index"), + "target_index": fv.Int("index"), + }, nil } -// reservedSubOpKeys 是禁止用户在 sub-op input 里手填的 key —— 它们要么由 -// shortcut 名隐含(operation),要么由 +batch-update 顶层 --url/--token -// 统一提供(excel_id / spreadsheet_token / url)。 +// reservedSubOpKeys 是禁止用户在 sub-op input 里手填的 key —— 它们由 +// +batch-update 顶层 --url/--token 统一提供(excel_id / spreadsheet_token / url)。 var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"} // translateBatchOp 把一个 CLI 视角的 {shortcut, input} 翻成底层 MCP -// batch_update 的 {tool_name, input(+operation)}。`index` 用于错误信息定位。 +// batch_update 的 {tool_name, input}。`index` 用于错误信息定位。input 用 +// shortcut 的 CLI flag 名(连字符/下划线均可),经该 shortcut 的 standalone +// translator 翻成 MCP body。 // // 失败场景: // - shortcut 字段缺失 / 非 string -// - shortcut 不在 dispatch 表(典型:拼写错;用户传了 read 操作; -// 用户嵌套 +batch-update / +cells-batch-set-style 之类的 fan-out wrapper) +// - shortcut 不在 dispatch 表(拼写错;read 操作;嵌套 fan-out wrapper) // - input 不是 object // - input 里手填了 operation(由 shortcut 名隐含,禁手填以防 mismatch) // - input 里手填了 excel_id / spreadsheet_token / url -func translateBatchOp(raw interface{}, index int) (map[string]interface{}, error) { +// - 子操作的 translator 报错(如缺必填字段) +func translateBatchOp(raw interface{}, token string, index int) (map[string]interface{}, error) { op, ok := raw.(map[string]interface{}) if !ok { return nil, common.FlagErrorf("operations[%d] must be a JSON object", index) @@ -144,7 +231,7 @@ func translateBatchOp(raw interface{}, index int) (map[string]interface{}, error if !ok { return nil, common.FlagErrorf( "operations[%d]: shortcut %q not allowed in +batch-update "+ - "(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +dropdown-{update,delete} / +dim-move are excluded; "+ + "(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +dropdown-{update,delete} are excluded; "+ "run `lark-cli sheets +batch-update --print-schema --flag-name operations` to see the full enum)", index, sc, ) @@ -181,36 +268,30 @@ func translateBatchOp(raw interface{}, index int) (map[string]interface{}, error return nil, common.FlagErrorf("operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k) } } - // 浅拷贝 input,注入 operation(如有),再补 excel_id 由调用方统一注入到顶层后, - // translator 也把 excel_id 写进 sub-op input(MCP tool 要求每个 sub-tool 都带)。 - out := make(map[string]interface{}, len(input)+1) - for k, v := range input { - out[k] = v - } - if mapping.operationField != "" { - out["operation"] = mapping.operationField + fv := newMapFlagViewForCommand(sc, input) + sheetID := strings.TrimSpace(fv.Str("sheet-id")) + sheetName := strings.TrimSpace(fv.Str("sheet-name")) + body, err := mapping.translate(fv, token, sheetID, sheetName) + if err != nil { + return nil, common.FlagErrorf("operations[%d] (%s): %v", index, sc, err) } return map[string]interface{}{ "tool_name": mapping.mcpToolName, - "input": out, + "input": body, }, nil } // translateBatchOperations 翻译整个 ops 数组;fail-fast,遇错立即返回。 -// 翻译后会把 excel_id 注入每个 sub-op 的 input(MCP 契约要求)。 func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) { if len(rawOps) == 0 { return nil, common.FlagErrorf("--operations must be a non-empty JSON array") } out := make([]interface{}, 0, len(rawOps)) for i, raw := range rawOps { - translated, err := translateBatchOp(raw, i) + translated, err := translateBatchOp(raw, token, i) if err != nil { return nil, err } - // MCP batch_update 每个 sub-tool 的 input 都需要 excel_id(与单调用一致)。 - input := translated["input"].(map[string]interface{}) - input["excel_id"] = token out = append(out, translated) } return out, nil diff --git a/shortcuts/sheets/flag_view.go b/shortcuts/sheets/flag_view.go new file mode 100644 index 000000000..d99f65304 --- /dev/null +++ b/shortcuts/sheets/flag_view.go @@ -0,0 +1,247 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "encoding/json" + "fmt" + "strings" +) + +// flagView is the read-only flag-accessor surface that every CLI-shape → +// MCP-tool-body translator (the *Input builders) depends on. It is satisfied +// as-is by *common.RuntimeContext (cobra-backed, used by standalone shortcut +// execution) and by mapFlagView (map-backed, used by +batch-update sub-ops). +// +// Routing both paths through the same interface lets a sub-op inside +// +batch-update reuse the exact same translator the standalone shortcut runs, +// so the generated MCP body is identical either way (enforced by the +// batch-vs-standalone contract test). +type flagView interface { + Str(name string) string + Int(name string) int + Int64(name string) int64 + Float64(name string) float64 + Bool(name string) bool + StrArray(name string) []string + StrSlice(name string) []string + Changed(name string) bool +} + +// mapFlagView adapts a +batch-update sub-op input object (decoded JSON) to the +// flagView interface so the standalone *Input translators can consume it. +// +// Keys are matched leniently against the CLI flag name: a translator asking for +// "source-range" finds either "source-range" or "source_range" in the map (the +// reference docs use CLI flag names; users frequently send the underscore +// form). Composite values (arrays / objects for flags like cells / properties / +// sort-keys) are re-encoded to a JSON string on Str() so the downstream +// parseJSONFlag round-trips them exactly as it would a CLI string argument. +// +// To mirror the standalone cobra layer exactly, value reads fall back to the +// flag's declared default (seeded from flag-defs.json), while Changed() reflects +// only what the user actually provided. This split matters because some +// translators branch on Changed() (e.g. omit target_index unless --index was +// set) and others read defaulted values (e.g. row-count defaults to 200). +type mapFlagView struct { + raw map[string]interface{} // user-supplied sub-op input (drives Changed) + defaults map[string]interface{} // flag defaults (value fallback only) +} + +// newMapFlagViewForCommand wraps a sub-op input and seeds the value-fallback +// defaults declared for `command` in flag-defs.json, so an absent flag resolves +// to the same value the standalone cobra command would carry. +func newMapFlagViewForCommand(command string, input map[string]interface{}) mapFlagView { + fv := mapFlagView{raw: input, defaults: map[string]interface{}{}} + defs, err := loadFlagDefs() + if err != nil { + return fv + } + spec, ok := defs[command] + if !ok { + return fv + } + for _, df := range spec.Flags { + if df.Kind == "system" || df.Default == "" { + continue + } + fv.defaults[df.Name] = typedDefault(df) + } + return fv +} + +// typedDefault converts a flag's string default to the Go type matching its +// declared kind, so Int()/Bool()/Float64() see the right type. +func typedDefault(df flagDef) interface{} { + switch df.Type { + case "bool": + return df.Default == "true" + case "int": + var n int + fmt.Sscanf(df.Default, "%d", &n) + return n + case "int64": + var n int64 + fmt.Sscanf(df.Default, "%d", &n) + return n + case "float64": + var f float64 + fmt.Sscanf(df.Default, "%g", &f) + return f + default: + return df.Default + } +} + +// lookup resolves a flag name for a VALUE read: user input first (hyphen↔ +// underscore tolerant), then the seeded default. Returns the value and whether +// it was found in either source. +func (m mapFlagView) lookup(name string) (interface{}, bool) { + if v, ok := m.lookupRaw(name); ok { + return v, true + } + if m.defaults != nil { + if v, ok := m.defaults[name]; ok { + return v, true + } + } + return nil, false +} + +// 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 + } + } + if alt := strings.ReplaceAll(name, "_", "-"); alt != name { + if v, ok := m.raw[alt]; ok { + return v, true + } + } + return nil, false +} + +func (m mapFlagView) Str(name string) string { + v, ok := m.lookup(name) + if !ok || v == nil { + return "" + } + switch t := v.(type) { + case string: + return t + case bool, float64, int, int64: + b, _ := json.Marshal(t) + return string(b) + default: + // Arrays / objects (cells, properties, sort-keys, options, ...) are + // re-encoded so the translator's parseJSONFlag re-parses them. + b, err := json.Marshal(t) + if err != nil { + return "" + } + return string(b) + } +} + +func (m mapFlagView) Int(name string) int { + v, ok := m.lookup(name) + if !ok { + return 0 + } + switch t := v.(type) { + case float64: + return int(t) + case int: + return t + case int64: + return int(t) + } + return 0 +} + +func (m mapFlagView) Int64(name string) int64 { + v, ok := m.lookup(name) + if !ok { + return 0 + } + switch t := v.(type) { + case float64: + return int64(t) + case int: + return int64(t) + case int64: + return t + } + return 0 +} + +func (m mapFlagView) Float64(name string) float64 { + v, ok := m.lookup(name) + if !ok { + return 0 + } + switch t := v.(type) { + case float64: + return t + case int: + return float64(t) + case int64: + return float64(t) + } + return 0 +} + +func (m mapFlagView) Bool(name string) bool { + v, ok := m.lookup(name) + if !ok { + return false + } + b, _ := v.(bool) + return b +} + +func (m mapFlagView) StrArray(name string) []string { + return m.strSliceLike(name) +} + +func (m mapFlagView) StrSlice(name string) []string { + return m.strSliceLike(name) +} + +func (m mapFlagView) strSliceLike(name string) []string { + v, ok := m.lookup(name) + if !ok || v == nil { + return nil + } + switch t := v.(type) { + case []string: + return t + case []interface{}: + out := make([]string, 0, len(t)) + for _, e := range t { + if s, ok := e.(string); ok { + out = append(out, s) + } + } + return out + case string: + // CSV / comma-separated (matches cobra StringSlice behavior). + if t == "" { + return nil + } + return strings.Split(t, ",") + } + return nil +} + +func (m mapFlagView) Changed(name string) bool { + _, ok := m.lookupRaw(name) + return ok +} diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go index 53e53be95..e52a2b0f5 100644 --- a/shortcuts/sheets/helpers.go +++ b/shortcuts/sheets/helpers.go @@ -103,7 +103,7 @@ func sheetSelectorPlaceholder(sheetID, sheetName string) string { // parseJSONFlag parses a JSON string from a flag value. Returns nil when the // flag is empty (caller decides if that's acceptable). Used by --data / // --style / --options / --ranges / --colors and friends. -func parseJSONFlag(runtime *common.RuntimeContext, name string) (interface{}, error) { +func parseJSONFlag(runtime flagView, name string) (interface{}, error) { raw := strings.TrimSpace(runtime.Str(name)) if raw == "" { return nil, nil @@ -116,7 +116,7 @@ func parseJSONFlag(runtime *common.RuntimeContext, name string) (interface{}, er } // requireJSONObject is parseJSONFlag + a type assertion to map[string]interface{}. -func requireJSONObject(runtime *common.RuntimeContext, name string) (map[string]interface{}, error) { +func requireJSONObject(runtime flagView, name string) (map[string]interface{}, error) { v, err := parseJSONFlag(runtime, name) if err != nil { return nil, err @@ -132,7 +132,7 @@ func requireJSONObject(runtime *common.RuntimeContext, name string) (map[string] } // requireJSONArray is parseJSONFlag + a type assertion to []interface{}. -func requireJSONArray(runtime *common.RuntimeContext, name string) ([]interface{}, error) { +func requireJSONArray(runtime flagView, name string) ([]interface{}, error) { v, err := parseJSONFlag(runtime, name) if err != nil { return nil, err @@ -152,7 +152,7 @@ func requireJSONArray(runtime *common.RuntimeContext, name string) ([]interface{ // buildCellStyleFromFlags reads the 11 flat style flags and returns the // cell_styles map expected by set_cell_range. Skips any flag the user // didn't set so partial styles work. -func buildCellStyleFromFlags(runtime *common.RuntimeContext) map[string]interface{} { +func buildCellStyleFromFlags(runtime flagView) map[string]interface{} { style := map[string]interface{}{} if v := runtime.Str("background-color"); v != "" { style["background_color"] = v @@ -189,7 +189,7 @@ func buildCellStyleFromFlags(runtime *common.RuntimeContext) map[string]interfac // borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/ // left/right with style sub-objects). Returns nil when the flag is empty. -func borderStylesFromFlag(runtime *common.RuntimeContext) (map[string]interface{}, error) { +func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) { if runtime.Str("border-styles") == "" { return nil, nil } diff --git a/shortcuts/sheets/lark_sheet_batch_update_test.go b/shortcuts/sheets/lark_sheet_batch_update_test.go index dbc19200d..23937f428 100644 --- a/shortcuts/sheets/lark_sheet_batch_update_test.go +++ b/shortcuts/sheets/lark_sheet_batch_update_test.go @@ -20,7 +20,7 @@ func TestBatchUpdate_TranslatesShortcutToToolName(t *testing.T) { "--url", testURL, "--operations", `[ {"shortcut":"+cells-set","input":{"sheet_id":"sh1","range":"A1","cells":[[{"value":42}]]}}, - {"shortcut":"+dim-insert","input":{"sheet_id":"sh1","range":"1:3"}} + {"shortcut":"+dim-insert","input":{"sheet_id":"sh1","dimension":"row","start":0,"end":3}} ]`, "--continue-on-error", "--yes", @@ -308,7 +308,7 @@ func TestBatchUpdate_DimFreezeInjectsFreeze(t *testing.T) { t.Parallel() body := parseDryRunBody(t, BatchUpdate, []string{ "--url", testURL, - "--operations", `[{"shortcut":"+dim-freeze","input":{"sheet_id":"sh1","freeze_rows":2}}]`, + "--operations", `[{"shortcut":"+dim-freeze","input":{"sheet_id":"sh1","dimension":"row","count":2}}]`, "--yes", }) input := decodeToolInput(t, body, "batch_update") @@ -329,7 +329,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","resize_height":{"type":"pixel","value":30}}}]`, + "--operations", `[{"shortcut":"+rows-resize","input":{"sheet_id":"sh1","start":0,"end":2,"type":"pixel","size":30}}]`, "--yes", }) input := decodeToolInput(t, body, "batch_update") diff --git a/shortcuts/sheets/lark_sheet_object_crud.go b/shortcuts/sheets/lark_sheet_object_crud.go index 4457188b2..2ff7c41be 100644 --- a/shortcuts/sheets/lark_sheet_object_crud.go +++ b/shortcuts/sheets/lark_sheet_object_crud.go @@ -42,8 +42,8 @@ type objectCRUDSpec struct { // shortcut-specific flat flags into the input (typically into the // properties map). The callback is responsible for navigating to the // right nesting level. - enhanceCreateInput func(rt *common.RuntimeContext, input map[string]interface{}) - enhanceUpdateInput func(rt *common.RuntimeContext, input map[string]interface{}) + enhanceCreateInput func(rt flagView, input map[string]interface{}) + enhanceUpdateInput func(rt flagView, input map[string]interface{}) } func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { @@ -96,7 +96,7 @@ func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { } } -func objectCreateInput(runtime *common.RuntimeContext, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) { +func objectCreateInput(runtime flagView, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) { props, err := requireJSONObject(runtime, "properties") if err != nil { return nil, err @@ -166,7 +166,7 @@ func newObjectUpdateShortcut(spec objectCRUDSpec) common.Shortcut { } } -func objectUpdateInput(runtime *common.RuntimeContext, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) { +func objectUpdateInput(runtime flagView, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) { props, err := requireJSONObject(runtime, "properties") if err != nil { return nil, err @@ -233,7 +233,7 @@ func newObjectDeleteShortcut(spec objectCRUDSpec) common.Shortcut { } } -func objectDeleteInput(runtime *common.RuntimeContext, token, sheetID, sheetName string, spec objectCRUDSpec) map[string]interface{} { +func objectDeleteInput(runtime flagView, token, sheetID, sheetName string, spec objectCRUDSpec) map[string]interface{} { input := map[string]interface{}{ "excel_id": token, "operation": "delete", @@ -265,7 +265,7 @@ var pivotSpec = objectCRUDSpec{ toolName: "manage_pivot_table_object", idFlag: "pivot-table-id", idField: "pivot_table_id", - enhanceCreateInput: func(rt *common.RuntimeContext, input map[string]interface{}) { + enhanceCreateInput: func(rt flagView, input map[string]interface{}) { if v := strings.TrimSpace(rt.Str("target-sheet-id")); v != "" { input["target_sheet_id"] = v } @@ -291,7 +291,7 @@ var PivotDelete = newObjectDeleteShortcut(pivotSpec) // conditional format — CLI surface uses --rule-id (short), wired to the // tool's conditional_format_id on the wire. --rule-type and --ranges are // hoisted out of properties (both required, set on every CRUD write). -var condFormatEnhance = func(rt *common.RuntimeContext, input map[string]interface{}) { +var condFormatEnhance = func(rt flagView, input map[string]interface{}) { props, _ := input["properties"].(map[string]interface{}) if props == nil { return @@ -342,7 +342,7 @@ var SparklineDelete = newObjectDeleteShortcut(sparklineSpec) // 10 flat flags. Caller is responsible for marking required flags via // cobra Required:true; this function only enforces the image_token XOR // image_uri pair (one must be set). -func floatImageProperties(runtime *common.RuntimeContext) (map[string]interface{}, error) { +func floatImageProperties(runtime flagView) (map[string]interface{}, error) { token := strings.TrimSpace(runtime.Str("image-token")) uri := strings.TrimSpace(runtime.Str("image-uri")) if token == "" && uri == "" { @@ -440,7 +440,7 @@ func newFloatImageWriteShortcut(command, description, op string, withIDFlag, isH } } -func floatImageWriteInput(runtime *common.RuntimeContext, token, sheetID, sheetName, op string, withIDFlag bool) (map[string]interface{}, error) { +func floatImageWriteInput(runtime flagView, token, sheetID, sheetName, op string, withIDFlag bool) (map[string]interface{}, error) { props, err := floatImageProperties(runtime) if err != nil { return nil, err @@ -482,7 +482,7 @@ var FloatImageDelete = newObjectDeleteShortcut(floatImageDeleteSpec) // it dispatches via the same One-OpenAPI endpoint as every other shortcut. // --view-name and --range are hoisted out of properties (optional on both // create and update; they always win over properties.{view_name, range}). -var filterViewEnhance = func(rt *common.RuntimeContext, input map[string]interface{}) { +var filterViewEnhance = func(rt flagView, input map[string]interface{}) { props, _ := input["properties"].(map[string]interface{}) if props == nil { return @@ -570,7 +570,7 @@ var FilterCreate = common.Shortcut{ }, } -func filterCreateInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) (map[string]interface{}, error) { +func filterCreateInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { props := map[string]interface{}{ "range": strings.TrimSpace(runtime.Str("range")), } @@ -648,7 +648,7 @@ var FilterUpdate = common.Shortcut{ }, } -func filterUpdateInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) (map[string]interface{}, error) { +func filterUpdateInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { props, err := requireJSONObject(runtime, "properties") if err != nil { return nil, err diff --git a/shortcuts/sheets/lark_sheet_range_operations.go b/shortcuts/sheets/lark_sheet_range_operations.go index 2910712a1..a4738e942 100644 --- a/shortcuts/sheets/lark_sheet_range_operations.go +++ b/shortcuts/sheets/lark_sheet_range_operations.go @@ -74,7 +74,7 @@ var CellsClear = common.Shortcut{ }, } -func cellsClearInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { +func cellsClearInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { scope := runtime.Str("scope") clearType := "contents" switch scope { @@ -149,7 +149,7 @@ func newMergeShortcut(command, desc, op string, withMergeType bool) common.Short } } -func mergeInput(runtime *common.RuntimeContext, token, sheetID, sheetName, op string, withMergeType bool) map[string]interface{} { +func mergeInput(runtime flagView, token, sheetID, sheetName, op string, withMergeType bool) map[string]interface{} { input := map[string]interface{}{ "excel_id": token, "range": strings.TrimSpace(runtime.Str("range")), @@ -293,10 +293,12 @@ func autoSuffix(dimension string) string { } // resizeInput builds the resize_range tool input. dimension is "row" / -// "column"; --end is inclusive on the CLI surface, dimRange wants -// exclusive end, so it is bumped by one here. -func resizeInput(runtime *common.RuntimeContext, token, sheetID, sheetName, dimension string) map[string]interface{} { - rangeStr := dimRange(dimension, runtime.Int("start"), runtime.Int("end")+1) +// "column"; --end is inclusive on the CLI surface, dimRangeFull wants +// exclusive end, so it is bumped by one here. dimRangeFull (not dimRange) is +// used so a single row/column still emits "N:N" — resize_range rejects a bare +// "N". +func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string) map[string]interface{} { + rangeStr := dimRangeFull(dimension, runtime.Int("start"), runtime.Int("end")+1) input := map[string]interface{}{ "excel_id": token, "range": rangeStr, @@ -504,7 +506,7 @@ func transformExecuteFn(op string, withPasteType, _ bool) func(context.Context, } } -func transformMoveCopyInput(runtime *common.RuntimeContext, token, sheetID, sheetName, op string, withPasteType bool) map[string]interface{} { +func transformMoveCopyInput(runtime flagView, token, sheetID, sheetName, op string, withPasteType bool) map[string]interface{} { input := map[string]interface{}{ "excel_id": token, "operation": op, @@ -537,7 +539,7 @@ func pasteTypeToTool(pt string) string { return "all" } -func rangeFillInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { +func rangeFillInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { input := map[string]interface{}{ "excel_id": token, "operation": "fill", @@ -560,7 +562,7 @@ func fillSeriesToToolType(seriesType string) string { return "fillSeries" } -func rangeSortInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) (map[string]interface{}, error) { +func rangeSortInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { keys, err := requireJSONArray(runtime, "sort-keys") if err != nil { return nil, err diff --git a/shortcuts/sheets/lark_sheet_range_operations_test.go b/shortcuts/sheets/lark_sheet_range_operations_test.go index 11bdb1c7e..73930f697 100644 --- a/shortcuts/sheets/lark_sheet_range_operations_test.go +++ b/shortcuts/sheets/lark_sheet_range_operations_test.go @@ -82,12 +82,12 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) { }, }, { - name: "+rows-resize --type auto omits --size", + name: "+rows-resize single row (start==end) keeps N:N range", sc: RowsResize, args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "0", "--end", "0", "--type", "auto"}, toolName: "resize_range", wantInput: map[string]interface{}{ - "range": "1", + "range": "1:1", "resize_height": map[string]interface{}{"type": "auto"}, }, }, diff --git a/shortcuts/sheets/lark_sheet_search_replace.go b/shortcuts/sheets/lark_sheet_search_replace.go index 3c6f79ebd..680cdc770 100644 --- a/shortcuts/sheets/lark_sheet_search_replace.go +++ b/shortcuts/sheets/lark_sheet_search_replace.go @@ -86,7 +86,7 @@ func searchInput(runtime *common.RuntimeContext, token, sheetID, sheetName strin // searchReplaceOptions packs the four shared boolean flags into the tool's // `options` sub-object. Empty result → caller should omit the field. -func searchReplaceOptions(runtime *common.RuntimeContext) map[string]interface{} { +func searchReplaceOptions(runtime flagView) map[string]interface{} { opts := map[string]interface{}{} if runtime.Bool("match-case") { opts["match_case"] = true @@ -155,7 +155,7 @@ var CellsReplace = common.Shortcut{ }, } -func replaceInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { +func replaceInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { input := map[string]interface{}{ "excel_id": token, "search_term": runtime.Str("find"), diff --git a/shortcuts/sheets/lark_sheet_sheet_structure.go b/shortcuts/sheets/lark_sheet_sheet_structure.go index 2b10f0819..14a4e9acb 100644 --- a/shortcuts/sheets/lark_sheet_sheet_structure.go +++ b/shortcuts/sheets/lark_sheet_sheet_structure.go @@ -148,7 +148,7 @@ var DimInsert = common.Shortcut{ }, } -func dimInsertInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { +func dimInsertInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { dim := runtime.Str("dimension") start := runtime.Int("start") end := runtime.Int("end") @@ -273,7 +273,7 @@ var DimFreeze = common.Shortcut{ }, } -func dimFreezeInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { +func dimFreezeInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { dim := runtime.Str("dimension") count := runtime.Int("count") op := "freeze" @@ -320,7 +320,7 @@ func validateDimRange(ctx context.Context, runtime *common.RuntimeContext) error // dimRangeOpInput builds the tool input for delete/hide/unhide which all // take a `range` field. dimRange handles 0-based exclusive → 1-based inclusive. -func dimRangeOpInput(runtime *common.RuntimeContext, token, sheetID, sheetName, op string) map[string]interface{} { +func dimRangeOpInput(runtime flagView, token, sheetID, sheetName, op string) map[string]interface{} { input := map[string]interface{}{ "excel_id": token, "operation": op, @@ -405,7 +405,7 @@ func newDimGroupShortcut(command, desc, op string) common.Shortcut { } } -func dimGroupInput(runtime *common.RuntimeContext, token, sheetID, sheetName, op string) map[string]interface{} { +func dimGroupInput(runtime flagView, token, sheetID, sheetName, op string) map[string]interface{} { input := dimRangeOpInput(runtime, token, sheetID, sheetName, op) if op == "group" { if gs := runtime.Str("group-state"); gs != "" { @@ -435,6 +435,17 @@ func dimRange(dimension string, start, end int) string { return fmt.Sprintf("%d:%d", start+1, end) } +// dimRangeFull is like dimRange but never collapses a single-element range to +// a bare index — it always emits the two-sided "N:N" / "C:C" form. resize_range +// rejects a bare index ("23" → Invalid range), so single-row/column resizes +// must keep both sides. +func dimRangeFull(dimension string, start, end int) string { + if dimension == "column" { + return columnIndexToLetter(start) + ":" + columnIndexToLetter(end-1) + } + return fmt.Sprintf("%d:%d", start+1, end) +} + // dimPosition formats a single CLI 0-based index as the tool's 1-based row // number string or column letter. func dimPosition(dimension string, idx int) string { diff --git a/shortcuts/sheets/lark_sheet_workbook.go b/shortcuts/sheets/lark_sheet_workbook.go index 17fd39e72..633096749 100644 --- a/shortcuts/sheets/lark_sheet_workbook.go +++ b/shortcuts/sheets/lark_sheet_workbook.go @@ -122,7 +122,7 @@ var SheetCreate = common.Shortcut{ }, } -func sheetCreateInput(runtime *common.RuntimeContext, token string) map[string]interface{} { +func sheetCreateInput(runtime flagView, token string) map[string]interface{} { input := map[string]interface{}{ "excel_id": token, "operation": "create", @@ -140,6 +140,42 @@ func sheetCreateInput(runtime *common.RuntimeContext, token string) map[string]i return input } +// sheetDeleteInput / sheetRenameInput / sheetVisibilityInput / +// sheetSetTabColorInput build the modify_workbook_structure body for the +// matching shortcut. Shared by standalone DryRun/Execute and by the +// +batch-update sub-op dispatch so both paths emit an identical body. +func sheetDeleteInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { + input := map[string]interface{}{"excel_id": token, "operation": "delete"} + sheetSelectorForToolInput(input, sheetID, sheetName) + return input +} + +func sheetRenameInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { + input := map[string]interface{}{ + "excel_id": token, + "operation": "rename", + "new_name": strings.TrimSpace(runtime.Str("title")), + } + sheetSelectorForToolInput(input, sheetID, sheetName) + return input +} + +func sheetVisibilityInput(runtime flagView, token, sheetID, sheetName, op string) map[string]interface{} { + input := map[string]interface{}{"excel_id": token, "operation": op} + sheetSelectorForToolInput(input, sheetID, sheetName) + return input +} + +func sheetSetTabColorInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { + input := map[string]interface{}{ + "excel_id": token, + "operation": "set_tab_color", + "tab_color": runtime.Str("color"), + } + sheetSelectorForToolInput(input, sheetID, sheetName) + return input +} + // SheetDelete deletes a sub-sheet. high-risk-write — framework rejects // without --yes. Always preview with --dry-run first to confirm the target. var SheetDelete = common.Shortcut{ @@ -161,9 +197,7 @@ var SheetDelete = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - input := map[string]interface{}{"excel_id": token, "operation": "delete"} - sheetSelectorForToolInput(input, sheetID, sheetName) - return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", sheetDeleteInput(runtime, token, sheetID, sheetName)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -174,9 +208,7 @@ var SheetDelete = common.Shortcut{ if err != nil { return err } - input := map[string]interface{}{"excel_id": token, "operation": "delete"} - sheetSelectorForToolInput(input, sheetID, sheetName) - out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", sheetDeleteInput(runtime, token, sheetID, sheetName)) if err != nil { return err } @@ -213,13 +245,7 @@ var SheetRename = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - input := map[string]interface{}{ - "excel_id": token, - "operation": "rename", - "new_name": strings.TrimSpace(runtime.Str("title")), - } - sheetSelectorForToolInput(input, sheetID, sheetName) - return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", sheetRenameInput(runtime, token, sheetID, sheetName)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -230,13 +256,7 @@ var SheetRename = common.Shortcut{ if err != nil { return err } - input := map[string]interface{}{ - "excel_id": token, - "operation": "rename", - "new_name": strings.TrimSpace(runtime.Str("title")), - } - sheetSelectorForToolInput(input, sheetID, sheetName) - out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", sheetRenameInput(runtime, token, sheetID, sheetName)) if err != nil { return err } @@ -388,7 +408,7 @@ var SheetCopy = common.Shortcut{ }, } -func sheetCopyInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { +func sheetCopyInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { input := map[string]interface{}{"excel_id": token, "operation": "duplicate"} sheetSelectorForToolInput(input, sheetID, sheetName) if t := strings.TrimSpace(runtime.Str("title")); t != "" { @@ -430,9 +450,7 @@ func newSheetVisibilityShortcut(command, desc, op string) common.Shortcut { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - input := map[string]interface{}{"excel_id": token, "operation": op} - sheetSelectorForToolInput(input, sheetID, sheetName) - return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", sheetVisibilityInput(runtime, token, sheetID, sheetName, op)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -443,9 +461,7 @@ func newSheetVisibilityShortcut(command, desc, op string) common.Shortcut { if err != nil { return err } - input := map[string]interface{}{"excel_id": token, "operation": op} - sheetSelectorForToolInput(input, sheetID, sheetName) - out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", sheetVisibilityInput(runtime, token, sheetID, sheetName, op)) if err != nil { return err } @@ -480,13 +496,7 @@ var SheetSetTabColor = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - input := map[string]interface{}{ - "excel_id": token, - "operation": "set_tab_color", - "tab_color": runtime.Str("color"), - } - sheetSelectorForToolInput(input, sheetID, sheetName) - return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", sheetSetTabColorInput(runtime, token, sheetID, sheetName)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -497,13 +507,7 @@ var SheetSetTabColor = common.Shortcut{ if err != nil { return err } - input := map[string]interface{}{ - "excel_id": token, - "operation": "set_tab_color", - "tab_color": runtime.Str("color"), - } - sheetSelectorForToolInput(input, sheetID, sheetName) - out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", sheetSetTabColorInput(runtime, token, sheetID, sheetName)) if err != nil { return err } diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index 527d6d5f8..e2478480b 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -86,7 +86,7 @@ var CellsSet = common.Shortcut{ }, } -func cellsSetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) (map[string]interface{}, error) { +func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { cells, err := requireJSONArray(runtime, "cells") if err != nil { return nil, err @@ -168,7 +168,7 @@ var CellsSetStyle = common.Shortcut{ }, } -func cellsSetStyleInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) (map[string]interface{}, error) { +func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { rangeStr := strings.TrimSpace(runtime.Str("range")) rows, cols, err := rangeDimensions(rangeStr) if err != nil { @@ -256,7 +256,7 @@ var CsvPut = common.Shortcut{ }, } -func csvPutInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { +func csvPutInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { input := map[string]interface{}{ "excel_id": token, "csv": runtime.Str("csv"), @@ -332,7 +332,7 @@ var DropdownSet = common.Shortcut{ }, } -func dropdownSetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) (map[string]interface{}, error) { +func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { validation, err := buildDropdownValidation(runtime) if err != nil { return nil, err @@ -361,7 +361,7 @@ func dropdownSetInput(runtime *common.RuntimeContext, token, sheetID, sheetName // buildDropdownValidation packs --options / --colors / --multiple / --highlight // into the data_validation block expected by set_cell_range. -func buildDropdownValidation(runtime *common.RuntimeContext) (map[string]interface{}, error) { +func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) { options, err := requireJSONArray(runtime, "options") if err != nil { return nil, err diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 890215f74..64d4ce936 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -127,6 +127,22 @@ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" -- # ] ``` +> **子操作 `input` 用该 shortcut 的 CLI flag 名**(连字符 / 下划线均可),与单独调用完全一致;CLI 会用同一套翻译逻辑生成底层 tool body。不要传底层 MCP 字段名。例如: +> - `+range-copy` / `+range-move` 用 `source-range` / `target-range`(不是 `range` / `destination_range`) +> - `+rows-resize` / `+cols-resize` 用 `start` / `end` / `type` / `size`(不是 `range` / `resize_height`) +> - `+dim-{insert|delete|hide|unhide|group|ungroup}` 用 `dimension` / `start` / `end` +> +> ```jsonc +> // 复制 A1:B2 到 A10,并把第 23 行行高设为 40px +> [ +> {"shortcut": "+range-copy", +> "input": {"sheet_id": "...", "source-range": "A1:B2", "target-range": "A10", "paste-type": "all"}}, +> {"shortcut": "+rows-resize", +> "input": {"sheet_id": "...", "start": 22, "end": 22, "type": "pixel", "size": 40}} +> ] +> ``` +> 注:`+sheet-move` 在批量内需显式提供 `sheet-id` 与 `source-index`(批量中途无法发起结构查询自动推导)。 + > **常见组合:插列 + 写表头 + 整列回填**——一次原子提交,不要拆成 N 次独立调用。批量回填同一列 **只需一次** `+cells-set`(range 写整列范围、cells 写 N×1 矩阵),不需要逐行循环。 > > ```jsonc From 1e05e7b3ad9f1234db7614456af5695cf130f9eb Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Thu, 21 May 2026 11:38:09 +0800 Subject: [PATCH 025/114] fix(sheets): align +cells-get/+csv-get range flags with synced spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sheet-skill-spec now declares +cells-get --range as a single string (was string_array) and +csv-get --range as required. Match the flag→body translators: - +cells-get wraps the single --range into the tool's `ranges` array and validates with Str() instead of StrArray(), which silently returned nil against the now-String flag and broke the command. - +csv-get gains a trim-based required-range guard. Update read-data dry-run tests to single-range form and add a guard test for the empty --range path. --- shortcuts/sheets/data/flag-defs.json | 6 ++-- shortcuts/sheets/lark_sheet_read_data.go | 13 ++++--- shortcuts/sheets/lark_sheet_read_data_test.go | 35 +++++++++++++++++-- skills/lark-shared/SKILL.md | 6 ++++ .../references/lark-sheets-batch-update.md | 16 --------- .../references/lark-sheets-read-data.md | 4 +-- 6 files changed, 52 insertions(+), 28 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index 13988e967..128b710d0 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -1178,7 +1178,7 @@ { "name": "range", "kind": "own", - "type": "string_array", + "type": "string", "required": "required", "desc": "A1 range, e.g. `Sheet1!A1:F10`" }, @@ -1298,8 +1298,8 @@ "name": "range", "kind": "own", "type": "string", - "required": "optional", - "desc": "A1 range; reads the whole sheet's `current_region` when omitted" + "required": "required", + "desc": "A1 range, e.g. `Sheet1!A1:F30`" }, { "name": "value-render-option", diff --git a/shortcuts/sheets/lark_sheet_read_data.go b/shortcuts/sheets/lark_sheet_read_data.go index 4c87c5f1b..511413899 100644 --- a/shortcuts/sheets/lark_sheet_read_data.go +++ b/shortcuts/sheets/lark_sheet_read_data.go @@ -37,7 +37,7 @@ var CellsGet = common.Shortcut{ if _, _, err := resolveSheetSelector(runtime); err != nil { return err } - if len(runtime.StrArray("range")) == 0 { + if strings.TrimSpace(runtime.Str("range")) == "" { return common.FlagErrorf("--range is required") } return nil @@ -68,7 +68,7 @@ var CellsGet = common.Shortcut{ func cellsGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { input := map[string]interface{}{ "excel_id": token, - "ranges": runtime.StrArray("range"), + "ranges": []string{strings.TrimSpace(runtime.Str("range"))}, } sheetSelectorForToolInput(input, sheetID, sheetName) applyIncludeToCellsGet(input, runtime.StrSlice("include")) @@ -126,8 +126,13 @@ var CsvGet = common.Shortcut{ if _, err := resolveSpreadsheetToken(runtime); err != nil { return err } - _, _, err := resolveSheetSelector(runtime) - return err + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return common.FlagErrorf("--range is required") + } + return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) diff --git a/shortcuts/sheets/lark_sheet_read_data_test.go b/shortcuts/sheets/lark_sheet_read_data_test.go index ea57d1762..d63d4214a 100644 --- a/shortcuts/sheets/lark_sheet_read_data_test.go +++ b/shortcuts/sheets/lark_sheet_read_data_test.go @@ -21,14 +21,14 @@ func TestReadDataShortcuts_DryRun(t *testing.T) { wantInput map[string]interface{} }{ { - name: "+cells-get multi-range + include=style,formula", + name: "+cells-get single range + include=style,formula", sc: CellsGet, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--range", "D1:E5", "--include", "style,formula"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--include", "style,formula"}, toolName: "get_cell_ranges", wantInput: map[string]interface{}{ "excel_id": testToken, "sheet_id": testSheetID, - "ranges": []interface{}{"A1:B2", "D1:E5"}, + "ranges": []interface{}{"A1:B2"}, "include_styles": true, "value_render_option": "formula", }, @@ -82,6 +82,35 @@ func TestDropdownGet_RequiresSheetPrefix(t *testing.T) { } } +// TestReadData_RequiresRange covers the trim-based --range guard on the +// single-range readers (--range "" slips past cobra's MarkFlagRequired but +// must still be rejected by Validate). +func TestReadData_RequiresRange(t *testing.T) { + t.Parallel() + cases := []struct { + name string + sc common.Shortcut + }{ + {"+cells-get", CellsGet}, + {"+csv-get", CsvGet}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, c.sc, []string{ + "--url", testURL, "--sheet-id", testSheetID, "--range", " ", "--dry-run", + }) + if err == nil { + t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) + } + if !strings.Contains(stdout+stderr+err.Error(), "--range is required") { + t.Errorf("expected --range guard; got=%s|%s|%v", stdout, stderr, err) + } + }) + } +} + // TestInfoTypeFromInclude exercises the fine-grained → coarse mapping // directly (white-box). func TestInfoTypeFromInclude(t *testing.T) { diff --git a/skills/lark-shared/SKILL.md b/skills/lark-shared/SKILL.md index c1ed2bd77..007ed46e9 100644 --- a/skills/lark-shared/SKILL.md +++ b/skills/lark-shared/SKILL.md @@ -104,6 +104,12 @@ lark-cli 命令执行后,如果检测到新版本,JSON 输出中会包含 `_ - **写入/删除操作前必须确认用户意图**。 - 用 `--dry-run` 预览危险请求。 +## 复合 JSON / 大入参:优先 stdin + +flag 帮助里标注支持 **Stdin** 的入参,当 payload 较大或含换行 / 引号等特殊字符时,优先用 stdin(`-`)传入,避免命令行超长与 shell 转义问题。 + +`@file` 出于安全只接受 cwd 下的相对路径,传绝对路径会被拒;遇到此限制改用 stdin,不要为它切换目录或把临时文件写进用户项目目录。 + ## 高风险操作的审批协议(exit 10) lark-cli 对高风险写操作(`risk: "high-risk-write"`)有强制确认门禁。当你不带 `--yes` 调用这类命令时,CLI 会退出码 `10`、并在 stderr 返回如下结构化 envelope: diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 64d4ce936..890215f74 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -127,22 +127,6 @@ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" -- # ] ``` -> **子操作 `input` 用该 shortcut 的 CLI flag 名**(连字符 / 下划线均可),与单独调用完全一致;CLI 会用同一套翻译逻辑生成底层 tool body。不要传底层 MCP 字段名。例如: -> - `+range-copy` / `+range-move` 用 `source-range` / `target-range`(不是 `range` / `destination_range`) -> - `+rows-resize` / `+cols-resize` 用 `start` / `end` / `type` / `size`(不是 `range` / `resize_height`) -> - `+dim-{insert|delete|hide|unhide|group|ungroup}` 用 `dimension` / `start` / `end` -> -> ```jsonc -> // 复制 A1:B2 到 A10,并把第 23 行行高设为 40px -> [ -> {"shortcut": "+range-copy", -> "input": {"sheet_id": "...", "source-range": "A1:B2", "target-range": "A10", "paste-type": "all"}}, -> {"shortcut": "+rows-resize", -> "input": {"sheet_id": "...", "start": 22, "end": 22, "type": "pixel", "size": 40}} -> ] -> ``` -> 注:`+sheet-move` 在批量内需显式提供 `sheet-id` 与 `source-index`(批量中途无法发起结构查询自动推导)。 - > **常见组合:插列 + 写表头 + 整列回填**——一次原子提交,不要拆成 N 次独立调用。批量回填同一列 **只需一次** `+cells-set`(range 写整列范围、cells 写 N×1 矩阵),不需要逐行循环。 > > ```jsonc diff --git a/skills/lark-sheets/references/lark-sheets-read-data.md b/skills/lark-sheets/references/lark-sheets-read-data.md index 4471d62c1..43246151e 100644 --- a/skills/lark-sheets/references/lark-sheets-read-data.md +++ b/skills/lark-sheets/references/lark-sheets-read-data.md @@ -88,7 +88,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string_array | required | A1 范围,如 `Sheet1!A1:F10` | +| `--range` | string | required | A1 范围,如 `Sheet1!A1:F10` | | `--include` | string_slice | optional | 要返回的信息类别,逗号分隔多个(可选值:`value` / `formula` / `style` / `comment` / `data_validation`) | | `--cell-limit` | int | optional | 防爆,默认 5000 | | `--max-chars` | int | optional | 防爆,默认 200000 | @@ -108,7 +108,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | optional | A1 范围;省略时读整表的 `current_region` | +| `--range` | string | required | A1 范围,如 `Sheet1!A1:F30` | | `--value-render-option` | string | optional | 单元格取值模式(可选值:`formatted_value` / `raw_value` / `formula`) | | `--max-rows` | int | optional | 防爆,默认 100000 | | `--max-chars` | int | optional | 防爆,默认 200000 | From 8d0fefd9e0995dc9e6a84ce692ba209a9d699b7b Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Thu, 21 May 2026 15:18:05 +0800 Subject: [PATCH 026/114] fix(sheets): push +batch-update sub-op validation down into xxxInput builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-ops that omit --sheet-id (or any other required flag) used to slip past CLI validation — Validate ran only against the standalone shortcut path, and batchOpDispatch's translators built bodies from whatever flagView returned, so a structurally broken sub-op surfaced as an opaque server "sheet undefined not found" after a network round-trip. Push each batchable shortcut's check trio down into its xxxInput builder: 1. resolveSpreadsheetToken — stays in Validate (batch already does it once at the top level; sub-ops don't repeat). 2. requireSheetSelector(sheetID, sheetName) — new helper; flagView- agnostic XOR + control-char check, called at the top of every xxxInput. 3. shortcut-specific required / range / enum checks (--dimension, --range, --start <= --end, --type pixel needs --size, --float-image-id, image-token XOR image-uri, ...) — moved out of Validate into the builder body. All ~30 batchable xxxInput builders now return (map, error). Standalone Validate shrinks to validateViaInput(xxxInput); DryRun / Execute propagate the error. batch_op_dispatch entries drop the noErrTranslate wrapper and pass the builder directly — its error bubbles up wrapped with "operations[N] (+shortcut):" context. Tests: - TestBatchOp_ErrorEquivalence (7 cases): XOR / logical-constraint errors fire identically from standalone and batch sub-op paths. - TestBatchOp_RejectsBadSubOpInput (8 cases): cobra-required flags that standalone catches via MarkFlagRequired now also get rejected CLI-side on the batch path (where cobra is not in the loop). - TestBatchOp_BodyMatchesStandalone (~40 cases) and TestBatchOp_DispatchCoversReportedBugs continue to pass — bodies stay byte-identical. - BOE smoke (spreadsheet ICFwstkUGheyfptGWS2bB7RgcDf, sheet 51991c): +batch-update with a sub-op missing --sheet-id now returns "operations[0] (+dim-insert): specify at least one of --sheet-id or --sheet-name" before any network call. sheetMoveBatchInput (xiongyuanwen's batch-only explicit-source-index requirement) is preserved — it's an orthogonal batch-specific constraint not affected by this push-down. --- shortcuts/sheets/batch_op_contract_test.go | 227 +++++++++++++++ shortcuts/sheets/batch_op_dispatch.go | 71 +++-- shortcuts/sheets/execute_paths_test.go | 2 +- shortcuts/sheets/helpers.go | 56 +++- shortcuts/sheets/lark_sheet_object_crud.go | 156 +++++----- .../sheets/lark_sheet_range_operations.go | 266 ++++++++++-------- shortcuts/sheets/lark_sheet_search_replace.go | 38 +-- .../sheets/lark_sheet_sheet_structure.go | 160 +++++++---- shortcuts/sheets/lark_sheet_workbook.go | 176 +++++++----- shortcuts/sheets/lark_sheet_write_cells.go | 136 ++++----- 10 files changed, 824 insertions(+), 464 deletions(-) diff --git a/shortcuts/sheets/batch_op_contract_test.go b/shortcuts/sheets/batch_op_contract_test.go index 7f420deb5..aa3c5b897 100644 --- a/shortcuts/sheets/batch_op_contract_test.go +++ b/shortcuts/sheets/batch_op_contract_test.go @@ -6,6 +6,7 @@ package sheets import ( "encoding/json" "reflect" + "strings" "testing" "github.com/larksuite/cli/shortcuts/common" @@ -314,6 +315,232 @@ func jsonRoundTrip(t *testing.T, m map[string]interface{}) map[string]interface{ return out } +// TestBatchOp_ErrorEquivalence is the second half of the contract: for the +// same bad input, the standalone shortcut Validate and the +batch-update +// sub-op translator must emit the same friendly CLI error. Previously a +// sub-op that omitted --sheet-id (or another required flag) slipped through +// to the server and surfaced as "sheet undefined not found"; with the +// validation pushed down into the xxxInput builders both paths now stop the +// request before the API call. +// +// Scope: this test covers checks that cobra cannot enforce — XOR pairs +// (sheet selector, image token/uri), range relationships, enum-bound rules, +// pixel/size cross-flag coupling. cobra's own MarkFlagRequired catches the +// single-required cases on the standalone path with its own +// "required flag(s) \"X\" not set" wording; the batch path now catches the +// same situations with our friendlier "--X is required" wording — those are +// asserted by TestBatchOp_RejectsBadSubOpInput below. +func TestBatchOp_ErrorEquivalence(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + // shortcut & standalone args. --url is supplied by the runner. Args + // satisfy every cobra-required flag so cobra doesn't short-circuit + // before our shared validator runs. + shortcut common.Shortcut + args []string + // matching sub-op input; reach the same failing check. + subShortcut string + subInput string + // substring expected in both errors. We assert *contains* rather than + // equality because the batch path wraps the inner error with + // "operations[i] (): " context — the inner message must match. + wantContains string + }{ + { + name: "+cells-set missing sheet selector", + shortcut: CellsSet, + args: []string{"--range", "A1", "--cells", `[[{"value":"x"}]]`}, + subShortcut: "+cells-set", + subInput: `{"range":"A1","cells":[[{"value":"x"}]]}`, + wantContains: "specify at least one of --sheet-id or --sheet-name", + }, + { + name: "+cells-set both sheet-id and sheet-name", + shortcut: CellsSet, + args: []string{"--sheet-id", "sh1", "--sheet-name", "Sheet1", "--range", "A1", "--cells", `[[{"value":"x"}]]`}, + subShortcut: "+cells-set", + subInput: `{"sheet-id":"sh1","sheet-name":"Sheet1","range":"A1","cells":[[{"value":"x"}]]}`, + wantContains: "mutually exclusive", + }, + { + name: "+dim-insert missing sheet selector", + shortcut: DimInsert, + args: []string{"--dimension", "row", "--start", "0", "--end", "1"}, + subShortcut: "+dim-insert", + subInput: `{"dimension":"row","start":0,"end":1}`, + wantContains: "specify at least one of --sheet-id or --sheet-name", + }, + { + name: "+dim-insert --end <= --start", + shortcut: DimInsert, + args: []string{"--sheet-id", "sh1", "--dimension", "row", "--start", "5", "--end", "3"}, + subShortcut: "+dim-insert", + subInput: `{"sheet-id":"sh1","dimension":"row","start":5,"end":3}`, + wantContains: "must be greater than --start", + }, + { + name: "+rows-resize --type pixel without --size", + shortcut: RowsResize, + args: []string{"--sheet-id", "sh1", "--start", "0", "--end", "1", "--type", "pixel"}, + subShortcut: "+rows-resize", + subInput: `{"sheet-id":"sh1","start":0,"end":1,"type":"pixel"}`, + wantContains: "--type pixel requires --size", + }, + { + name: "+sheet-delete missing sheet selector", + shortcut: SheetDelete, + args: []string{}, + subShortcut: "+sheet-delete", + subInput: `{}`, + wantContains: "specify at least one of --sheet-id or --sheet-name", + }, + { + name: "+float-image-create both image-token and image-uri", + shortcut: FloatImageCreate, + args: []string{"--sheet-id", "sh1", "--image-name", "x.png", "--image-token", "t", "--image-uri", "u", "--position-row", "0", "--position-col", "A", "--size-width", "100", "--size-height", "50"}, + subShortcut: "+float-image-create", + subInput: `{"sheet-id":"sh1","image-name":"x.png","image-token":"t","image-uri":"u","position-row":0,"position-col":"A","size-width":100,"size-height":50}`, + wantContains: "mutually exclusive", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Standalone path: run the shortcut with --dry-run + bad args. + // Validate runs before DryRun, so we expect it to fail there. + _, _, standaloneErr := runShortcutCapturingErr( + t, tc.shortcut, + append([]string{"--url", testURL, "--dry-run"}, tc.args...), + ) + if standaloneErr == nil { + t.Fatalf("standalone Validate accepted bad input — expected error containing %q", tc.wantContains) + } + if !strings.Contains(standaloneErr.Error(), tc.wantContains) { + t.Errorf("standalone error = %q, want substring %q", standaloneErr.Error(), tc.wantContains) + } + + // Batch path: translate the matching sub-op. The translator wraps + // the inner error with "operations[i] (): " — assert the + // inner message survives the wrap. + var subInput map[string]interface{} + if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil { + t.Fatalf("bad subInput JSON: %v", err) + } + rawOp := map[string]interface{}{ + "shortcut": tc.subShortcut, + "input": subInput, + } + _, batchErr := translateBatchOp(rawOp, testToken, 0) + if batchErr == nil { + t.Fatalf("batch translator accepted bad input — expected error containing %q", tc.wantContains) + } + if !strings.Contains(batchErr.Error(), tc.wantContains) { + t.Errorf("batch error = %q, want substring %q (operations[i] prefix is fine)", batchErr.Error(), tc.wantContains) + } + // And the wrap context must include the sub-op index + shortcut + // name so error reports stay actionable in multi-op batches. + wrapHint := "operations[0] (" + tc.subShortcut + "):" + if !strings.Contains(batchErr.Error(), wrapHint) { + t.Errorf("batch error %q missing context prefix %q", batchErr.Error(), wrapHint) + } + }) + } +} + +// TestBatchOp_RejectsBadSubOpInput pins down the secondary guard: for +// inputs that cobra's MarkFlagRequired catches on the standalone path, +// the +batch-update sub-op (which has no cobra layer) must still reject +// CLI-side with its own friendly error before issuing any API call. This +// closes the original bug — a sub-op missing --sheet-id used to slip +// through and surface as "sheet undefined not found" only after a +// network round-trip. +func TestBatchOp_RejectsBadSubOpInput(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + subShortcut string + subInput string + wantContains string + }{ + { + "+cells-set missing --range", + "+cells-set", + `{"sheet-id":"sh1","cells":[[{"value":"x"}]]}`, + "--range is required", + }, + { + "+dim-insert missing --dimension", + "+dim-insert", + `{"sheet-id":"sh1","start":0,"end":1}`, + "--dimension is required", + }, + { + "+rows-resize missing --type", + "+rows-resize", + `{"sheet-id":"sh1","start":0,"end":0}`, + "--type is required", + }, + { + "+range-copy missing --target-range", + "+range-copy", + `{"sheet-id":"sh1","source-range":"A1:B2"}`, + "--target-range is required", + }, + { + "+sheet-rename missing --title", + "+sheet-rename", + `{"sheet-id":"sh1"}`, + "--title is required", + }, + { + "+chart-update missing --chart-id", + "+chart-update", + `{"sheet-id":"sh1","properties":{"title":"T"}}`, + "--chart-id is required", + }, + { + "+filter-create missing --range", + "+filter-create", + `{"sheet-id":"sh1"}`, + "--range is required", + }, + { + "+float-image-update missing --float-image-id", + "+float-image-update", + `{"sheet-id":"sh1","image-name":"x.png","image-token":"t","position-row":0,"position-col":"A","size-width":100,"size-height":50}`, + "--float-image-id is required", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var subInput map[string]interface{} + if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil { + t.Fatalf("bad subInput JSON: %v", err) + } + rawOp := map[string]interface{}{ + "shortcut": tc.subShortcut, + "input": subInput, + } + _, err := translateBatchOp(rawOp, testToken, 0) + if err == nil { + t.Fatalf("translator accepted bad input — expected error containing %q", tc.wantContains) + } + if !strings.Contains(err.Error(), tc.wantContains) { + t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains) + } + }) + } +} + // TestBatchOp_DispatchCoversReportedBugs is a focused guard for the two // originally reported failures: +range-copy and +rows-resize sub-ops must // translate to the correct MCP body (not a near-passthrough that drops diff --git a/shortcuts/sheets/batch_op_dispatch.go b/shortcuts/sheets/batch_op_dispatch.go index 4c2ae3861..9620d1a1f 100644 --- a/shortcuts/sheets/batch_op_dispatch.go +++ b/shortcuts/sheets/batch_op_dispatch.go @@ -43,13 +43,6 @@ type batchOpMapping struct { translate batchTranslateFn } -// noErrTranslate adapts a builder that cannot fail into a batchTranslateFn. -func noErrTranslate(f func(fv flagView, token, sheetID, sheetName string) map[string]interface{}) batchTranslateFn { - return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { - return f(fv, token, sheetID, sheetName), nil - } -} - // objCreateTranslate / objUpdateTranslate / objDeleteTranslate bind an object // CRUD spec to the shared object_crud builders. func objCreateTranslate(spec objectCRUDSpec) batchTranslateFn { @@ -66,80 +59,84 @@ func objUpdateTranslate(spec objectCRUDSpec) batchTranslateFn { func objDeleteTranslate(spec objectCRUDSpec) batchTranslateFn { return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { - return objectDeleteInput(fv, token, sheetID, sheetName, spec), nil + return objectDeleteInput(fv, token, sheetID, sheetName, spec) } } // batchOpDispatch covers every write shortcut that can join an atomic batch. +// Each entry plugs the shortcut's standalone xxxInput builder into the +// batch translator path — so the body is byte-identical to the standalone +// invocation (locked by TestBatchOp_BodyMatchesStandalone) and the missing- +// flag error is identical too (locked by TestBatchOp_ErrorEquivalence). var batchOpDispatch = map[string]batchOpMapping{ // ─── 单元格内容 ────────────────────────────────────────────────── "+cells-set": {"set_cell_range", cellsSetInput}, "+cells-set-style": {"set_cell_range", cellsSetStyleInput}, - "+cells-clear": {"clear_cell_range", noErrTranslate(cellsClearInput)}, - "+cells-replace": {"replace_data", noErrTranslate(replaceInput)}, - "+csv-put": {"set_range_from_csv", noErrTranslate(csvPutInput)}, + "+cells-clear": {"clear_cell_range", cellsClearInput}, + "+cells-replace": {"replace_data", replaceInput}, + "+csv-put": {"set_range_from_csv", csvPutInput}, "+dropdown-set": {"set_cell_range", dropdownSetInput}, // ─── 单元格合并 (merge_cells, operation 区分) ──────────────────── "+cells-merge": {"merge_cells", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { - return mergeInput(fv, token, sid, sname, "merge", true), nil + return mergeInput(fv, token, sid, sname, "merge", true) }}, "+cells-unmerge": {"merge_cells", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { - return mergeInput(fv, token, sid, sname, "unmerge", false), nil + return mergeInput(fv, token, sid, sname, "unmerge", false) }}, // ─── 行列结构 (modify_sheet_structure, operation 区分) ────────── - "+dim-insert": {"modify_sheet_structure", noErrTranslate(dimInsertInput)}, + "+dim-insert": {"modify_sheet_structure", dimInsertInput}, "+dim-delete": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { - return dimRangeOpInput(fv, token, sid, sname, "delete"), nil + return dimRangeOpInput(fv, token, sid, sname, "delete") }}, "+dim-hide": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { - return dimRangeOpInput(fv, token, sid, sname, "hide"), nil + return dimRangeOpInput(fv, token, sid, sname, "hide") }}, "+dim-unhide": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { - return dimRangeOpInput(fv, token, sid, sname, "unhide"), nil + return dimRangeOpInput(fv, token, sid, sname, "unhide") }}, - "+dim-freeze": {"modify_sheet_structure", noErrTranslate(dimFreezeInput)}, + "+dim-freeze": {"modify_sheet_structure", dimFreezeInput}, "+dim-group": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { - return dimGroupInput(fv, token, sid, sname, "group"), nil + return dimGroupInput(fv, token, sid, sname, "group") }}, "+dim-ungroup": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { - return dimGroupInput(fv, token, sid, sname, "ungroup"), nil + return dimGroupInput(fv, token, sid, sname, "ungroup") }}, // ─── 行高列宽 (resize_range, 无 operation 字段) ───────────────── "+rows-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { - return resizeInput(fv, token, sid, sname, "row"), nil + return resizeInput(fv, token, sid, sname, "row") }}, "+cols-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { - return resizeInput(fv, token, sid, sname, "column"), nil + return resizeInput(fv, token, sid, sname, "column") }}, // ─── 区域操作 (transform_range, operation 区分) ───────────────── "+range-move": {"transform_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { - return transformMoveCopyInput(fv, token, sid, sname, "move", false), nil + return transformMoveCopyInput(fv, token, sid, sname, "move", false) }}, "+range-copy": {"transform_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { - return transformMoveCopyInput(fv, token, sid, sname, "copy", true), nil + return transformMoveCopyInput(fv, token, sid, sname, "copy", true) }}, - "+range-fill": {"transform_range", noErrTranslate(rangeFillInput)}, + "+range-fill": {"transform_range", rangeFillInput}, "+range-sort": {"transform_range", rangeSortInput}, // ─── 工作簿 / 子表 (modify_workbook_structure, operation 区分) ── "+sheet-create": {"modify_workbook_structure", func(fv flagView, token, _, _ string) (map[string]interface{}, error) { - return sheetCreateInput(fv, token), nil + return sheetCreateInput(fv, token) }}, - "+sheet-delete": {"modify_workbook_structure", noErrTranslate(sheetDeleteInput)}, - "+sheet-rename": {"modify_workbook_structure", noErrTranslate(sheetRenameInput)}, + "+sheet-delete": {"modify_workbook_structure", sheetDeleteInput}, + "+sheet-rename": {"modify_workbook_structure", sheetRenameInput}, "+sheet-move": {"modify_workbook_structure", sheetMoveBatchInput}, - "+sheet-copy": {"modify_workbook_structure", noErrTranslate(sheetCopyInput)}, - "+sheet-hide": {"modify_workbook_structure", noErrTranslate(func(fv flagView, t, sid, sn string) map[string]interface{} { + "+sheet-copy": {"modify_workbook_structure", sheetCopyInput}, + "+sheet-hide": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) { return sheetVisibilityInput(fv, t, sid, sn, "hide") - })}, - "+sheet-unhide": {"modify_workbook_structure", noErrTranslate(func(fv flagView, t, sid, sn string) map[string]interface{} { + }}, + "+sheet-unhide": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) { return sheetVisibilityInput(fv, t, sid, sn, "unhide") - })}, - "+sheet-set-tab-color": {"modify_workbook_structure", noErrTranslate(sheetSetTabColorInput)}, + }}, + "+sheet-set-tab-color": {"modify_workbook_structure", sheetSetTabColorInput}, // ─── 对象族 CRUD (manage_*_object, operation 区分) ───────────── "+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)}, @@ -156,11 +153,7 @@ var batchOpDispatch = map[string]batchOpMapping{ "+filter-create": {"manage_filter_object", filterCreateInput}, "+filter-update": {"manage_filter_object", filterUpdateInput}, - "+filter-delete": {"manage_filter_object", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { - input := map[string]interface{}{"excel_id": token, "operation": "delete"} - sheetSelectorForToolInput(input, sid, sname) - return input, nil - }}, + "+filter-delete": {"manage_filter_object", filterDeleteInput}, "+filter-view-create": {"manage_filter_view_object", objCreateTranslate(filterViewSpec)}, "+filter-view-update": {"manage_filter_view_object", objUpdateTranslate(filterViewSpec)}, diff --git a/shortcuts/sheets/execute_paths_test.go b/shortcuts/sheets/execute_paths_test.go index 60d672a3a..770ad7bd0 100644 --- a/shortcuts/sheets/execute_paths_test.go +++ b/shortcuts/sheets/execute_paths_test.go @@ -242,7 +242,7 @@ func TestExecute_BatchUpdate_Translated(t *testing.T) { stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`) _, err := runShortcutWithStubs(t, BatchUpdate, []string{ "--url", testURL, - "--operations", `[{"shortcut":"+cells-set","input":{"range":"A1","cells":[[{"value":1}]]}}]`, + "--operations", `[{"shortcut":"+cells-set","input":{"sheet-id":"sh1","range":"A1","cells":[[{"value":1}]]}}]`, "--continue-on-error", "--yes", }, stub) diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go index e52a2b0f5..64206b9e2 100644 --- a/shortcuts/sheets/helpers.go +++ b/shortcuts/sheets/helpers.go @@ -8,6 +8,7 @@ package sheets import ( + "context" "encoding/json" "strings" @@ -79,6 +80,59 @@ func resolveSheetSelector(runtime *common.RuntimeContext) (sheetID, sheetName st return "", name, nil } +// validateViaInput shrinks a shortcut's Validate to the minimal +// "token + ask the xxxInput builder if everything else is OK" pattern. +// The builder owns the sheet selector and shortcut-specific checks +// (--range required, --start >= 0, ...), so Validate no longer duplicates +// them — the same error fires whether the shortcut runs standalone or as a +// +batch-update sub-op. Use the inline form when the builder needs extra +// arguments (operation enum, withMergeType bool, ...). +func validateViaInput( + build func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error), +) func(ctx context.Context, runtime *common.RuntimeContext) error { + return func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID := strings.TrimSpace(runtime.Str("sheet-id")) + sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + _, err = build(runtime, token, sheetID, sheetName) + return err + } +} + +// requireSheetSelector is the flagView-agnostic counterpart of +// resolveSheetSelector: given the already-extracted (sheetID, sheetName) pair, +// it enforces the same XOR and control-char rules. +// +// Every batchable xxxInput builder calls this at the top so the same friendly +// error fires whether the shortcut runs standalone (Validate sees the error +// through the builder) or as a +batch-update sub-op (translator sees it +// directly, prefixed by operations[i]). Without this, batch sub-ops +// missing --sheet-id would slip through CLI validation and only fail on the +// server with an opaque "sheet undefined not found". +func requireSheetSelector(sheetID, sheetName string) error { + sheetID = strings.TrimSpace(sheetID) + sheetName = strings.TrimSpace(sheetName) + if sheetID == "" && sheetName == "" { + return common.FlagErrorf("specify at least one of --sheet-id or --sheet-name") + } + if sheetID != "" && sheetName != "" { + return common.FlagErrorf("--sheet-id and --sheet-name are mutually exclusive") + } + if sheetID != "" { + if err := validate.RejectControlChars(sheetID, "sheet-id"); err != nil { + return common.FlagErrorf("%v", err) + } + } else { + if err := validate.RejectControlChars(sheetName, "sheet-name"); err != nil { + return common.FlagErrorf("%v", err) + } + } + return nil +} + // sheetSelectorForToolInput packs --sheet-id / --sheet-name into the tool // input map, omitting empty fields. Use after resolveSheetSelector returns. func sheetSelectorForToolInput(input map[string]interface{}, sheetID, sheetName string) { @@ -206,7 +260,7 @@ func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) { // requireAnyStyleFlag ensures at least one style-defining flag (style or // border) is set — otherwise the request would do nothing. -func requireAnyStyleFlag(runtime *common.RuntimeContext) error { +func requireAnyStyleFlag(runtime flagView) error { if len(buildCellStyleFromFlags(runtime)) > 0 { return nil } diff --git a/shortcuts/sheets/lark_sheet_object_crud.go b/shortcuts/sheets/lark_sheet_object_crud.go index 2ff7c41be..25dba6a9e 100644 --- a/shortcuts/sheets/lark_sheet_object_crud.go +++ b/shortcuts/sheets/lark_sheet_object_crud.go @@ -58,13 +58,13 @@ func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { HasFormat: true, Flags: flags, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, _, err := resolveSheetSelector(runtime); err != nil { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { return err } - _, err := requireJSONObject(runtime, "properties") + sheetID := strings.TrimSpace(runtime.Str("sheet-id")) + sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + _, err = objectCreateInput(runtime, token, sheetID, sheetName, spec) return err }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -97,6 +97,9 @@ func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { } func objectCreateInput(runtime flagView, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } props, err := requireJSONObject(runtime, "properties") if err != nil { return nil, err @@ -125,16 +128,13 @@ func newObjectUpdateShortcut(spec objectCRUDSpec) common.Shortcut { HasFormat: true, Flags: flags, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, _, err := resolveSheetSelector(runtime); err != nil { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { return err } - if spec.idFlag != "" && strings.TrimSpace(runtime.Str(spec.idFlag)) == "" { - return common.FlagErrorf("--%s is required", spec.idFlag) - } - _, err := requireJSONObject(runtime, "properties") + sheetID := strings.TrimSpace(runtime.Str("sheet-id")) + sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + _, err = objectUpdateInput(runtime, token, sheetID, sheetName, spec) return err }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -167,6 +167,12 @@ func newObjectUpdateShortcut(spec objectCRUDSpec) common.Shortcut { } func objectUpdateInput(runtime flagView, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if spec.idFlag != "" && strings.TrimSpace(runtime.Str(spec.idFlag)) == "" { + return nil, common.FlagErrorf("--%s is required", spec.idFlag) + } props, err := requireJSONObject(runtime, "properties") if err != nil { return nil, err @@ -198,21 +204,20 @@ func newObjectDeleteShortcut(spec objectCRUDSpec) common.Shortcut { HasFormat: true, Flags: flags, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, _, err := resolveSheetSelector(runtime); err != nil { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { return err } - if spec.idFlag != "" && strings.TrimSpace(runtime.Str(spec.idFlag)) == "" { - return common.FlagErrorf("--%s is required", spec.idFlag) - } - return nil + sheetID := strings.TrimSpace(runtime.Str("sheet-id")) + sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + _, err = objectDeleteInput(runtime, token, sheetID, sheetName, spec) + return err }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - return invokeToolDryRun(token, ToolKindWrite, spec.toolName, objectDeleteInput(runtime, token, sheetID, sheetName, spec)) + input, _ := objectDeleteInput(runtime, token, sheetID, sheetName, spec) + return invokeToolDryRun(token, ToolKindWrite, spec.toolName, input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -223,7 +228,11 @@ func newObjectDeleteShortcut(spec objectCRUDSpec) common.Shortcut { if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindWrite, spec.toolName, objectDeleteInput(runtime, token, sheetID, sheetName, spec)) + input, err := objectDeleteInput(runtime, token, sheetID, sheetName, spec) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, spec.toolName, input) if err != nil { return err } @@ -233,7 +242,13 @@ func newObjectDeleteShortcut(spec objectCRUDSpec) common.Shortcut { } } -func objectDeleteInput(runtime flagView, token, sheetID, sheetName string, spec objectCRUDSpec) map[string]interface{} { +func objectDeleteInput(runtime flagView, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if spec.idFlag != "" && strings.TrimSpace(runtime.Str(spec.idFlag)) == "" { + return nil, common.FlagErrorf("--%s is required", spec.idFlag) + } input := map[string]interface{}{ "excel_id": token, "operation": "delete", @@ -242,7 +257,7 @@ func objectDeleteInput(runtime flagView, token, sheetID, sheetName string, spec if spec.idFlag != "" { input[spec.idField] = strings.TrimSpace(runtime.Str(spec.idFlag)) } - return input + return input, nil } // ─── per-object instantiations ──────────────────────────────────────── @@ -399,16 +414,13 @@ func newFloatImageWriteShortcut(command, description, op string, withIDFlag, isH HasFormat: true, Flags: flags, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, _, err := resolveSheetSelector(runtime); err != nil { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { return err } - if withIDFlag && strings.TrimSpace(runtime.Str("float-image-id")) == "" { - return common.FlagErrorf("--float-image-id is required") - } - _, err := floatImageProperties(runtime) + sheetID := strings.TrimSpace(runtime.Str("sheet-id")) + sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + _, err = floatImageWriteInput(runtime, token, sheetID, sheetName, op, withIDFlag) return err }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -441,6 +453,12 @@ func newFloatImageWriteShortcut(command, description, op string, withIDFlag, isH } func floatImageWriteInput(runtime flagView, token, sheetID, sheetName, op string, withIDFlag bool) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if withIDFlag && strings.TrimSpace(runtime.Str("float-image-id")) == "" { + return nil, common.FlagErrorf("--float-image-id is required") + } props, err := floatImageProperties(runtime) if err != nil { return nil, err @@ -525,23 +543,7 @@ var FilterCreate = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+filter-create"), - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, _, err := resolveSheetSelector(runtime); err != nil { - return err - } - if strings.TrimSpace(runtime.Str("range")) == "" { - return common.FlagErrorf("--range is required") - } - if runtime.Str("properties") != "" { - if _, err := requireJSONObject(runtime, "properties"); err != nil { - return err - } - } - return nil - }, + Validate: validateViaInput(filterCreateInput), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) @@ -571,6 +573,12 @@ var FilterCreate = common.Shortcut{ } func filterCreateInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return nil, common.FlagErrorf("--range is required") + } props := map[string]interface{}{ "range": strings.TrimSpace(runtime.Str("range")), } @@ -607,19 +615,7 @@ var FilterUpdate = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+filter-update"), - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, _, err := resolveSheetSelector(runtime); err != nil { - return err - } - if strings.TrimSpace(runtime.Str("range")) == "" { - return common.FlagErrorf("--range is required") - } - _, err := requireJSONObject(runtime, "properties") - return err - }, + Validate: validateViaInput(filterUpdateInput), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) @@ -649,6 +645,12 @@ var FilterUpdate = common.Shortcut{ } func filterUpdateInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return nil, common.FlagErrorf("--range is required") + } props, err := requireJSONObject(runtime, "properties") if err != nil { return nil, err @@ -674,18 +676,11 @@ var FilterDelete = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+filter-delete"), - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - _, _, err := resolveSheetSelector(runtime) - return err - }, + Validate: validateViaInput(filterDeleteInput), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - input := map[string]interface{}{"excel_id": token, "operation": "delete"} - sheetSelectorForToolInput(input, sheetID, sheetName) + input, _ := filterDeleteInput(runtime, token, sheetID, sheetName) return invokeToolDryRun(token, ToolKindWrite, "manage_filter_object", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { @@ -697,8 +692,10 @@ var FilterDelete = common.Shortcut{ if err != nil { return err } - input := map[string]interface{}{"excel_id": token, "operation": "delete"} - sheetSelectorForToolInput(input, sheetID, sheetName) + input, err := filterDeleteInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_filter_object", input) if err != nil { return err @@ -707,3 +704,14 @@ var FilterDelete = common.Shortcut{ return nil }, } + +// filterDeleteInput mirrors the standalone +filter-delete body for batch +// sub-op reuse. filter_id is implicit (sheet-scoped), so no extra id flag. +func filterDeleteInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + input := map[string]interface{}{"excel_id": token, "operation": "delete"} + sheetSelectorForToolInput(input, sheetID, sheetName) + return input, nil +} diff --git a/shortcuts/sheets/lark_sheet_range_operations.go b/shortcuts/sheets/lark_sheet_range_operations.go index a4738e942..71dc04ff8 100644 --- a/shortcuts/sheets/lark_sheet_range_operations.go +++ b/shortcuts/sheets/lark_sheet_range_operations.go @@ -36,22 +36,12 @@ var CellsClear = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+cells-clear"), - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, _, err := resolveSheetSelector(runtime); err != nil { - return err - } - if strings.TrimSpace(runtime.Str("range")) == "" { - return common.FlagErrorf("--range is required") - } - return nil - }, + Validate: validateViaInput(cellsClearInput), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - return invokeToolDryRun(token, ToolKindWrite, "clear_cell_range", cellsClearInput(runtime, token, sheetID, sheetName)) + input, _ := cellsClearInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "clear_cell_range", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -62,7 +52,11 @@ var CellsClear = common.Shortcut{ if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "clear_cell_range", cellsClearInput(runtime, token, sheetID, sheetName)) + input, err := cellsClearInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "clear_cell_range", input) if err != nil { return err } @@ -74,7 +68,13 @@ var CellsClear = common.Shortcut{ }, } -func cellsClearInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { +func cellsClearInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return nil, common.FlagErrorf("--range is required") + } scope := runtime.Str("scope") clearType := "contents" switch scope { @@ -89,7 +89,7 @@ func cellsClearInput(runtime flagView, token, sheetID, sheetName string) map[str "clear_type": clearType, } sheetSelectorForToolInput(input, sheetID, sheetName) - return input + return input, nil } // CellsMerge / CellsUnmerge share the merge_cells tool, dispatched by the @@ -114,21 +114,20 @@ func newMergeShortcut(command, desc, op string, withMergeType bool) common.Short HasFormat: true, Flags: flags, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, _, err := resolveSheetSelector(runtime); err != nil { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { return err } - if strings.TrimSpace(runtime.Str("range")) == "" { - return common.FlagErrorf("--range is required") - } - return nil + sheetID := strings.TrimSpace(runtime.Str("sheet-id")) + sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + _, err = mergeInput(runtime, token, sheetID, sheetName, op, withMergeType) + return err }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - return invokeToolDryRun(token, ToolKindWrite, "merge_cells", mergeInput(runtime, token, sheetID, sheetName, op, withMergeType)) + input, _ := mergeInput(runtime, token, sheetID, sheetName, op, withMergeType) + return invokeToolDryRun(token, ToolKindWrite, "merge_cells", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -139,7 +138,11 @@ func newMergeShortcut(command, desc, op string, withMergeType bool) common.Short if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "merge_cells", mergeInput(runtime, token, sheetID, sheetName, op, withMergeType)) + input, err := mergeInput(runtime, token, sheetID, sheetName, op, withMergeType) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "merge_cells", input) if err != nil { return err } @@ -149,7 +152,13 @@ func newMergeShortcut(command, desc, op string, withMergeType bool) common.Short } } -func mergeInput(runtime flagView, token, sheetID, sheetName, op string, withMergeType bool) map[string]interface{} { +func mergeInput(runtime flagView, token, sheetID, sheetName, op string, withMergeType bool) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return nil, common.FlagErrorf("--range is required") + } input := map[string]interface{}{ "excel_id": token, "range": strings.TrimSpace(runtime.Str("range")), @@ -163,7 +172,7 @@ func mergeInput(runtime flagView, token, sheetID, sheetName, op string, withMerg input["merge_type"] = "all" } } - return input + return input, nil } // resize_range now exposes two CLI shortcuts: @@ -190,11 +199,12 @@ var RowsResize = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+rows-resize"), - Validate: validateResize("row"), + Validate: validateViaResize("row"), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - return invokeToolDryRun(token, ToolKindWrite, "resize_range", resizeInput(runtime, token, sheetID, sheetName, "row")) + 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 := resolveSpreadsheetToken(runtime) @@ -205,7 +215,11 @@ var RowsResize = common.Shortcut{ if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "resize_range", resizeInput(runtime, token, sheetID, sheetName, "row")) + 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 } @@ -225,11 +239,12 @@ var ColsResize = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+cols-resize"), - Validate: validateResize("column"), + Validate: validateViaResize("column"), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - return invokeToolDryRun(token, ToolKindWrite, "resize_range", resizeInput(runtime, token, sheetID, sheetName, "column")) + input, _ := resizeInput(runtime, token, sheetID, sheetName, "column") + return invokeToolDryRun(token, ToolKindWrite, "resize_range", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -240,7 +255,11 @@ var ColsResize = common.Shortcut{ if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "resize_range", resizeInput(runtime, token, sheetID, sheetName, "column")) + input, err := resizeInput(runtime, token, sheetID, sheetName, "column") + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "resize_range", input) if err != nil { return err } @@ -249,38 +268,19 @@ var ColsResize = common.Shortcut{ }, } -// validateResize returns a Validate closure shared by both rows/cols shortcuts. -// dimension is either "row" or "column"; the closure rejects --type auto on -// columns (column widths do not support auto-fit). -func validateResize(dimension string) func(ctx context.Context, runtime *common.RuntimeContext) error { +// validateViaResize wires the standalone Validate to resizeInput so both +// paths (standalone + batch sub-op) emit the same error for missing --type, +// out-of-range --start/--end, or --type auto on columns. +func validateViaResize(dimension string) func(ctx context.Context, runtime *common.RuntimeContext) error { return func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, _, err := resolveSheetSelector(runtime); err != nil { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { return err } - if !runtime.Changed("start") || !runtime.Changed("end") { - return common.FlagErrorf("--start and --end are required") - } - if runtime.Int("start") < 0 || runtime.Int("end") < runtime.Int("start") { - return common.FlagErrorf("invalid range: --start (%d) must be >= 0 and --end (%d) must be >= --start", runtime.Int("start"), runtime.Int("end")) - } - typ := strings.TrimSpace(runtime.Str("type")) - if typ == "" { - return common.FlagErrorf("--type is required (pixel / standard%s)", autoSuffix(dimension)) - } - if dimension == "column" && typ == "auto" { - return common.FlagErrorf("--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 common.FlagErrorf("--type pixel requires --size ") - } - if typ != "pixel" && hasSize { - return common.FlagErrorf("--size is only valid with --type pixel") - } - return nil + sheetID := strings.TrimSpace(runtime.Str("sheet-id")) + sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + _, err = resizeInput(runtime, token, sheetID, sheetName, dimension) + return err } } @@ -297,14 +297,36 @@ func autoSuffix(dimension string) string { // exclusive end, so it is bumped by one here. dimRangeFull (not dimRange) is // used so a single row/column still emits "N:N" — resize_range rejects a bare // "N". -func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string) map[string]interface{} { +func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if !runtime.Changed("start") || !runtime.Changed("end") { + return nil, common.FlagErrorf("--start and --end are required") + } + if runtime.Int("start") < 0 || runtime.Int("end") < runtime.Int("start") { + return nil, common.FlagErrorf("invalid range: --start (%d) must be >= 0 and --end (%d) must be >= --start", runtime.Int("start"), runtime.Int("end")) + } + typ := strings.TrimSpace(runtime.Str("type")) + if typ == "" { + return nil, common.FlagErrorf("--type is required (pixel / standard%s)", autoSuffix(dimension)) + } + if dimension == "column" && typ == "auto" { + return nil, common.FlagErrorf("--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.FlagErrorf("--type pixel requires --size ") + } + if typ != "pixel" && hasSize { + return nil, common.FlagErrorf("--size is only valid with --type pixel") + } rangeStr := dimRangeFull(dimension, runtime.Int("start"), runtime.Int("end")+1) input := map[string]interface{}{ "excel_id": token, "range": rangeStr, } sheetSelectorForToolInput(input, sheetID, sheetName) - typ := strings.TrimSpace(runtime.Str("type")) sizeBlock := map[string]interface{}{"type": typ} if typ == "pixel" { sizeBlock["value"] = runtime.Int("size") @@ -314,7 +336,7 @@ func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string) } else { input["resize_width"] = sizeBlock } - return input + return input, nil } // ─── transform_range (4 shortcuts) ──────────────────────────────────── @@ -334,7 +356,7 @@ var RangeMove = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+range-move"), - Validate: validateRangeMoveOrCopy, + Validate: validateRangeMoveOrCopy("move", false), DryRun: transformDryRunFn("move", false, false), Execute: transformExecuteFn("move", false, false), } @@ -350,7 +372,7 @@ var RangeCopy = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+range-copy"), - Validate: validateRangeMoveOrCopy, + Validate: validateRangeMoveOrCopy("copy", true), DryRun: transformDryRunFn("copy", true, false), Execute: transformExecuteFn("copy", true, false), } @@ -368,25 +390,12 @@ var RangeFill = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+range-fill"), - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, _, err := resolveSheetSelector(runtime); err != nil { - return err - } - if strings.TrimSpace(runtime.Str("source-range")) == "" { - return common.FlagErrorf("--source-range is required") - } - if strings.TrimSpace(runtime.Str("target-range")) == "" { - return common.FlagErrorf("--target-range is required") - } - return nil - }, + Validate: validateViaInput(rangeFillInput), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - return invokeToolDryRun(token, ToolKindWrite, "transform_range", rangeFillInput(runtime, token, sheetID, sheetName)) + input, _ := rangeFillInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "transform_range", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -397,7 +406,11 @@ var RangeFill = common.Shortcut{ if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "transform_range", rangeFillInput(runtime, token, sheetID, sheetName)) + input, err := rangeFillInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "transform_range", input) if err != nil { return err } @@ -416,21 +429,7 @@ var RangeSort = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+range-sort"), - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, _, err := resolveSheetSelector(runtime); err != nil { - return err - } - if strings.TrimSpace(runtime.Str("range")) == "" { - return common.FlagErrorf("--range is required") - } - if _, err := requireJSONArray(runtime, "sort-keys"); err != nil { - return err - } - return nil - }, + Validate: validateViaInput(rangeSortInput), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) @@ -461,28 +460,28 @@ var RangeSort = common.Shortcut{ // ─── transform_range helpers ────────────────────────────────────────── -func validateRangeMoveOrCopy(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, _, err := resolveSheetSelector(runtime); err != nil { +// validateRangeMoveOrCopy wires the standalone Validate to transformMoveCopyInput +// so missing --source-range / --target-range fire the same friendly error on +// the batch sub-op path. +func validateRangeMoveOrCopy(op string, withPasteType bool) func(ctx context.Context, runtime *common.RuntimeContext) error { + return func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID := strings.TrimSpace(runtime.Str("sheet-id")) + sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + _, err = transformMoveCopyInput(runtime, token, sheetID, sheetName, op, withPasteType) return err } - if strings.TrimSpace(runtime.Str("source-range")) == "" { - return common.FlagErrorf("--source-range is required") - } - if strings.TrimSpace(runtime.Str("target-range")) == "" { - return common.FlagErrorf("--target-range is required") - } - return nil } func transformDryRunFn(op string, withPasteType, _ bool) func(context.Context, *common.RuntimeContext) *common.DryRunAPI { return func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - return invokeToolDryRun(token, ToolKindWrite, "transform_range", - transformMoveCopyInput(runtime, token, sheetID, sheetName, op, withPasteType)) + input, _ := transformMoveCopyInput(runtime, token, sheetID, sheetName, op, withPasteType) + return invokeToolDryRun(token, ToolKindWrite, "transform_range", input) } } @@ -496,8 +495,11 @@ func transformExecuteFn(op string, withPasteType, _ bool) func(context.Context, if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "transform_range", - transformMoveCopyInput(runtime, token, sheetID, sheetName, op, withPasteType)) + input, err := transformMoveCopyInput(runtime, token, sheetID, sheetName, op, withPasteType) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "transform_range", input) if err != nil { return err } @@ -506,7 +508,16 @@ func transformExecuteFn(op string, withPasteType, _ bool) func(context.Context, } } -func transformMoveCopyInput(runtime flagView, token, sheetID, sheetName, op string, withPasteType bool) map[string]interface{} { +func transformMoveCopyInput(runtime flagView, token, sheetID, sheetName, op string, withPasteType bool) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if strings.TrimSpace(runtime.Str("source-range")) == "" { + return nil, common.FlagErrorf("--source-range is required") + } + if strings.TrimSpace(runtime.Str("target-range")) == "" { + return nil, common.FlagErrorf("--target-range is required") + } input := map[string]interface{}{ "excel_id": token, "operation": op, @@ -522,7 +533,7 @@ func transformMoveCopyInput(runtime flagView, token, sheetID, sheetName, op stri input["paste_type"] = pasteTypeToTool(pt) } } - return input + return input, nil } // pasteTypeToTool maps the CLI vocabulary (values / formulas / formats / all) @@ -539,7 +550,16 @@ func pasteTypeToTool(pt string) string { return "all" } -func rangeFillInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { +func rangeFillInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if strings.TrimSpace(runtime.Str("source-range")) == "" { + return nil, common.FlagErrorf("--source-range is required") + } + if strings.TrimSpace(runtime.Str("target-range")) == "" { + return nil, common.FlagErrorf("--target-range is required") + } input := map[string]interface{}{ "excel_id": token, "operation": "fill", @@ -548,7 +568,7 @@ func rangeFillInput(runtime flagView, token, sheetID, sheetName string) map[stri "fill_type": fillSeriesToToolType(runtime.Str("series-type")), } sheetSelectorForToolInput(input, sheetID, sheetName) - return input + return input, nil } // fillSeriesToToolType maps the CLI series vocabulary to the tool's fill_type. @@ -563,6 +583,12 @@ func fillSeriesToToolType(seriesType string) string { } func rangeSortInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return nil, common.FlagErrorf("--range is required") + } keys, err := requireJSONArray(runtime, "sort-keys") if err != nil { return nil, err diff --git a/shortcuts/sheets/lark_sheet_search_replace.go b/shortcuts/sheets/lark_sheet_search_replace.go index 680cdc770..d5a5579e6 100644 --- a/shortcuts/sheets/lark_sheet_search_replace.go +++ b/shortcuts/sheets/lark_sheet_search_replace.go @@ -114,25 +114,12 @@ var CellsReplace = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+cells-replace"), - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, _, err := resolveSheetSelector(runtime); err != nil { - return err - } - if strings.TrimSpace(runtime.Str("find")) == "" { - return common.FlagErrorf("--find is required") - } - if !runtime.Changed("replacement") { - return common.FlagErrorf("--replacement is required (pass an empty string to delete matches)") - } - return nil - }, + Validate: validateViaInput(replaceInput), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - return invokeToolDryRun(token, ToolKindWrite, "replace_data", replaceInput(runtime, token, sheetID, sheetName)) + input, _ := replaceInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "replace_data", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -143,7 +130,11 @@ var CellsReplace = common.Shortcut{ if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "replace_data", replaceInput(runtime, token, sheetID, sheetName)) + input, err := replaceInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "replace_data", input) if err != nil { return err } @@ -155,7 +146,16 @@ var CellsReplace = common.Shortcut{ }, } -func replaceInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { +func replaceInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if strings.TrimSpace(runtime.Str("find")) == "" { + return nil, common.FlagErrorf("--find is required") + } + if !runtime.Changed("replacement") { + return nil, common.FlagErrorf("--replacement is required (pass an empty string to delete matches)") + } input := map[string]interface{}{ "excel_id": token, "search_term": runtime.Str("find"), @@ -168,5 +168,5 @@ func replaceInput(runtime flagView, token, sheetID, sheetName string) map[string if opts := searchReplaceOptions(runtime); len(opts) > 0 { input["options"] = opts } - return input + return input, nil } diff --git a/shortcuts/sheets/lark_sheet_sheet_structure.go b/shortcuts/sheets/lark_sheet_sheet_structure.go index 14a4e9acb..934f6d889 100644 --- a/shortcuts/sheets/lark_sheet_sheet_structure.go +++ b/shortcuts/sheets/lark_sheet_sheet_structure.go @@ -124,11 +124,12 @@ var DimInsert = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+dim-insert"), - Validate: validateDimRange, + Validate: validateViaInput(dimInsertInput), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", dimInsertInput(runtime, token, sheetID, sheetName)) + input, _ := dimInsertInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -139,7 +140,11 @@ var DimInsert = common.Shortcut{ if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", dimInsertInput(runtime, token, sheetID, sheetName)) + input, err := dimInsertInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input) if err != nil { return err } @@ -148,7 +153,13 @@ var DimInsert = common.Shortcut{ }, } -func dimInsertInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { +func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if err := requireDimRange(runtime); err != nil { + return nil, err + } dim := runtime.Str("dimension") start := runtime.Int("start") end := runtime.Int("end") @@ -165,7 +176,7 @@ func dimInsertInput(runtime flagView, token, sheetID, sheetName string) map[stri case "after": input["side"] = "after" } - return input + return input, nil } // DimDelete deletes rows / columns — irreversible, high-risk-write. @@ -178,11 +189,12 @@ var DimDelete = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+dim-delete"), - Validate: validateDimRange, + Validate: validateDimRangeOp("delete"), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", dimRangeOpInput(runtime, token, sheetID, sheetName, "delete")) + input, _ := dimRangeOpInput(runtime, token, sheetID, sheetName, "delete") + return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -193,7 +205,11 @@ var DimDelete = common.Shortcut{ if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", dimRangeOpInput(runtime, token, sheetID, sheetName, "delete")) + input, err := dimRangeOpInput(runtime, token, sheetID, sheetName, "delete") + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input) if err != nil { return err } @@ -205,6 +221,36 @@ var DimDelete = common.Shortcut{ }, } +// validateDimRangeOp returns a Validate closure that delegates to +// dimRangeOpInput for shortcuts (delete/hide/unhide) whose builder takes an +// extra `op` argument. Token check happens here; the rest is the builder. +func validateDimRangeOp(op string) func(ctx context.Context, runtime *common.RuntimeContext) error { + return func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID := strings.TrimSpace(runtime.Str("sheet-id")) + sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + _, err = dimRangeOpInput(runtime, token, sheetID, sheetName, op) + return err + } +} + +// validateDimGroupOp is the group/ungroup counterpart of validateDimRangeOp. +func validateDimGroupOp(op string) func(ctx context.Context, runtime *common.RuntimeContext) error { + return func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID := strings.TrimSpace(runtime.Str("sheet-id")) + sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + _, err = dimGroupInput(runtime, token, sheetID, sheetName, op) + return err + } +} + // DimHide / DimUnhide toggle visibility on a row/column range. var DimHide = newDimRangeOpShortcut( "+dim-hide", "Hide rows or columns within a range.", "hide", "write", @@ -232,28 +278,12 @@ var DimFreeze = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+dim-freeze"), - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, _, err := resolveSheetSelector(runtime); err != nil { - return err - } - if !runtime.Changed("dimension") { - return common.FlagErrorf("--dimension is required") - } - if !runtime.Changed("count") { - return common.FlagErrorf("--count is required (0 unfreezes)") - } - if runtime.Int("count") < 0 { - return common.FlagErrorf("--count must be >= 0") - } - return nil - }, + Validate: validateViaInput(dimFreezeInput), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", dimFreezeInput(runtime, token, sheetID, sheetName)) + input, _ := dimFreezeInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -264,7 +294,11 @@ var DimFreeze = common.Shortcut{ if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", dimFreezeInput(runtime, token, sheetID, sheetName)) + input, err := dimFreezeInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input) if err != nil { return err } @@ -273,7 +307,19 @@ var DimFreeze = common.Shortcut{ }, } -func dimFreezeInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { +func dimFreezeInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if !runtime.Changed("dimension") { + return nil, common.FlagErrorf("--dimension is required") + } + if !runtime.Changed("count") { + return nil, common.FlagErrorf("--count is required (0 unfreezes)") + } + if runtime.Int("count") < 0 { + return nil, common.FlagErrorf("--count must be >= 0") + } dim := runtime.Str("dimension") count := runtime.Int("count") op := "freeze" @@ -289,18 +335,13 @@ func dimFreezeInput(runtime flagView, token, sheetID, sheetName string) map[stri input["freeze_columns"] = count } } - return input + return input, nil } -// validateDimRange validates the public XOR pair and dimension/start/end -// triple shared by insert/delete/hide/unhide/group/ungroup. -func validateDimRange(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, _, err := resolveSheetSelector(runtime); err != nil { - return err - } +// requireDimRange validates the dimension/start/end triple shared by +// insert/delete/hide/unhide/group/ungroup. Pure flag-level checks — the sheet +// selector and token live in their own helpers. +func requireDimRange(runtime flagView) error { if !runtime.Changed("dimension") { return common.FlagErrorf("--dimension is required") } @@ -320,14 +361,20 @@ func validateDimRange(ctx context.Context, runtime *common.RuntimeContext) error // dimRangeOpInput builds the tool input for delete/hide/unhide which all // take a `range` field. dimRange handles 0-based exclusive → 1-based inclusive. -func dimRangeOpInput(runtime flagView, token, sheetID, sheetName, op string) map[string]interface{} { +func dimRangeOpInput(runtime flagView, token, sheetID, sheetName, op string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if err := requireDimRange(runtime); err != nil { + return nil, err + } input := map[string]interface{}{ "excel_id": token, "operation": op, "range": dimRange(runtime.Str("dimension"), runtime.Int("start"), runtime.Int("end")), } sheetSelectorForToolInput(input, sheetID, sheetName) - return input + return input, nil } // newDimRangeOpShortcut builds the shared shape for hide / unhide. @@ -341,11 +388,12 @@ func newDimRangeOpShortcut(command, desc, op, risk string) common.Shortcut { AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor(command), - Validate: validateDimRange, + Validate: validateDimRangeOp(op), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", dimRangeOpInput(runtime, token, sheetID, sheetName, op)) + input, _ := dimRangeOpInput(runtime, token, sheetID, sheetName, op) + return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -356,7 +404,11 @@ func newDimRangeOpShortcut(command, desc, op, risk string) common.Shortcut { if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", dimRangeOpInput(runtime, token, sheetID, sheetName, op)) + input, err := dimRangeOpInput(runtime, token, sheetID, sheetName, op) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input) if err != nil { return err } @@ -380,11 +432,12 @@ func newDimGroupShortcut(command, desc, op string) common.Shortcut { AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flags, - Validate: validateDimRange, + Validate: validateDimGroupOp(op), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", dimGroupInput(runtime, token, sheetID, sheetName, op)) + input, _ := dimGroupInput(runtime, token, sheetID, sheetName, op) + return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -395,7 +448,11 @@ func newDimGroupShortcut(command, desc, op string) common.Shortcut { if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", dimGroupInput(runtime, token, sheetID, sheetName, op)) + input, err := dimGroupInput(runtime, token, sheetID, sheetName, op) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input) if err != nil { return err } @@ -405,14 +462,17 @@ func newDimGroupShortcut(command, desc, op string) common.Shortcut { } } -func dimGroupInput(runtime flagView, token, sheetID, sheetName, op string) map[string]interface{} { - input := dimRangeOpInput(runtime, token, sheetID, sheetName, op) +func dimGroupInput(runtime flagView, token, sheetID, sheetName, op string) (map[string]interface{}, error) { + input, err := dimRangeOpInput(runtime, token, sheetID, sheetName, op) + if err != nil { + return nil, err + } if op == "group" { if gs := runtime.Str("group-state"); gs != "" { input["group_state"] = gs } } - return input + return input, nil } // ─── dimension formatting helpers ───────────────────────────────────── diff --git a/shortcuts/sheets/lark_sheet_workbook.go b/shortcuts/sheets/lark_sheet_workbook.go index 633096749..a062214bd 100644 --- a/shortcuts/sheets/lark_sheet_workbook.go +++ b/shortcuts/sheets/lark_sheet_workbook.go @@ -90,30 +90,28 @@ var SheetCreate = common.Shortcut{ HasFormat: true, Flags: flagsFor("+sheet-create"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { return err } - if strings.TrimSpace(runtime.Str("title")) == "" { - return common.FlagErrorf("--title is required") - } - if n := runtime.Int("row-count"); n < 0 || n > 50000 { - return common.FlagErrorf("--row-count must be between 0 and 50000") - } - if n := runtime.Int("col-count"); n < 0 || n > 200 { - return common.FlagErrorf("--col-count must be between 0 and 200") - } - return nil + _, err = sheetCreateInput(runtime, token) + return err }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) - return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", sheetCreateInput(runtime, token)) + input, _ := sheetCreateInput(runtime, token) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", sheetCreateInput(runtime, token)) + input, err := sheetCreateInput(runtime, token) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) if err != nil { return err } @@ -122,7 +120,16 @@ var SheetCreate = common.Shortcut{ }, } -func sheetCreateInput(runtime flagView, token string) map[string]interface{} { +func sheetCreateInput(runtime flagView, token string) (map[string]interface{}, error) { + if strings.TrimSpace(runtime.Str("title")) == "" { + return nil, common.FlagErrorf("--title is required") + } + if n := runtime.Int("row-count"); n < 0 || n > 50000 { + return nil, common.FlagErrorf("--row-count must be between 0 and 50000") + } + if n := runtime.Int("col-count"); n < 0 || n > 200 { + return nil, common.FlagErrorf("--col-count must be between 0 and 200") + } input := map[string]interface{}{ "excel_id": token, "operation": "create", @@ -137,43 +144,63 @@ func sheetCreateInput(runtime flagView, token string) map[string]interface{} { if n := runtime.Int("col-count"); n > 0 { input["columns"] = n } - return input + return input, nil } // sheetDeleteInput / sheetRenameInput / sheetVisibilityInput / // sheetSetTabColorInput build the modify_workbook_structure body for the // matching shortcut. Shared by standalone DryRun/Execute and by the -// +batch-update sub-op dispatch so both paths emit an identical body. -func sheetDeleteInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { +// +batch-update sub-op dispatch so both paths emit an identical body and the +// same friendly error when --sheet-id/--sheet-name (or the shortcut's own +// required flags) are missing. +func sheetDeleteInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } input := map[string]interface{}{"excel_id": token, "operation": "delete"} sheetSelectorForToolInput(input, sheetID, sheetName) - return input + return input, nil } -func sheetRenameInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { +func sheetRenameInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if strings.TrimSpace(runtime.Str("title")) == "" { + return nil, common.FlagErrorf("--title is required") + } input := map[string]interface{}{ "excel_id": token, "operation": "rename", "new_name": strings.TrimSpace(runtime.Str("title")), } sheetSelectorForToolInput(input, sheetID, sheetName) - return input + return input, nil } -func sheetVisibilityInput(runtime flagView, token, sheetID, sheetName, op string) map[string]interface{} { +func sheetVisibilityInput(runtime flagView, token, sheetID, sheetName, op string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } input := map[string]interface{}{"excel_id": token, "operation": op} sheetSelectorForToolInput(input, sheetID, sheetName) - return input + return input, nil } -func sheetSetTabColorInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { +func sheetSetTabColorInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if !runtime.Changed("color") { + return nil, common.FlagErrorf("--color is required (empty string clears)") + } input := map[string]interface{}{ "excel_id": token, "operation": "set_tab_color", "tab_color": runtime.Str("color"), } sheetSelectorForToolInput(input, sheetID, sheetName) - return input + return input, nil } // SheetDelete deletes a sub-sheet. high-risk-write — framework rejects @@ -187,17 +214,12 @@ var SheetDelete = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+sheet-delete"), - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - _, _, err := resolveSheetSelector(runtime) - return err - }, + Validate: validateViaInput(sheetDeleteInput), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", sheetDeleteInput(runtime, token, sheetID, sheetName)) + input, _ := sheetDeleteInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -208,7 +230,11 @@ var SheetDelete = common.Shortcut{ if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", sheetDeleteInput(runtime, token, sheetID, sheetName)) + input, err := sheetDeleteInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) if err != nil { return err } @@ -230,22 +256,12 @@ var SheetRename = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+sheet-rename"), - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, _, err := resolveSheetSelector(runtime); err != nil { - return err - } - if strings.TrimSpace(runtime.Str("title")) == "" { - return common.FlagErrorf("--title is required") - } - return nil - }, + Validate: validateViaInput(sheetRenameInput), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", sheetRenameInput(runtime, token, sheetID, sheetName)) + input, _ := sheetRenameInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -256,7 +272,11 @@ var SheetRename = common.Shortcut{ if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", sheetRenameInput(runtime, token, sheetID, sheetName)) + input, err := sheetRenameInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) if err != nil { return err } @@ -378,17 +398,12 @@ var SheetCopy = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+sheet-copy"), - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - _, _, err := resolveSheetSelector(runtime) - return err - }, + Validate: validateViaInput(sheetCopyInput), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", sheetCopyInput(runtime, token, sheetID, sheetName)) + input, _ := sheetCopyInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -399,7 +414,11 @@ var SheetCopy = common.Shortcut{ if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", sheetCopyInput(runtime, token, sheetID, sheetName)) + input, err := sheetCopyInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) if err != nil { return err } @@ -408,7 +427,10 @@ var SheetCopy = common.Shortcut{ }, } -func sheetCopyInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { +func sheetCopyInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } input := map[string]interface{}{"excel_id": token, "operation": "duplicate"} sheetSelectorForToolInput(input, sheetID, sheetName) if t := strings.TrimSpace(runtime.Str("title")); t != "" { @@ -417,7 +439,7 @@ func sheetCopyInput(runtime flagView, token, sheetID, sheetName string) map[stri if runtime.Changed("index") { input["target_index"] = runtime.Int("index") } - return input + return input, nil } // SheetHide / SheetUnhide toggle visibility. Visible bool semantics live in @@ -441,16 +463,20 @@ func newSheetVisibilityShortcut(command, desc, op string) common.Shortcut { HasFormat: true, Flags: flagsFor(command), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { return err } - _, _, err := resolveSheetSelector(runtime) + sheetID := strings.TrimSpace(runtime.Str("sheet-id")) + sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + _, err = sheetVisibilityInput(runtime, token, sheetID, sheetName, op) return err }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", sheetVisibilityInput(runtime, token, sheetID, sheetName, op)) + input, _ := sheetVisibilityInput(runtime, token, sheetID, sheetName, op) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -461,7 +487,11 @@ func newSheetVisibilityShortcut(command, desc, op string) common.Shortcut { if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", sheetVisibilityInput(runtime, token, sheetID, sheetName, op)) + input, err := sheetVisibilityInput(runtime, token, sheetID, sheetName, op) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) if err != nil { return err } @@ -481,22 +511,12 @@ var SheetSetTabColor = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+sheet-set-tab-color"), - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, _, err := resolveSheetSelector(runtime); err != nil { - return err - } - if !runtime.Changed("color") { - return common.FlagErrorf("--color is required (empty string clears)") - } - return nil - }, + Validate: validateViaInput(sheetSetTabColorInput), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", sheetSetTabColorInput(runtime, token, sheetID, sheetName)) + input, _ := sheetSetTabColorInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -507,7 +527,11 @@ var SheetSetTabColor = common.Shortcut{ if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", sheetSetTabColorInput(runtime, token, sheetID, sheetName)) + input, err := sheetSetTabColorInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) if err != nil { return err } diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index e2478480b..0c606fa6e 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -43,21 +43,7 @@ var CellsSet = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+cells-set"), - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, _, err := resolveSheetSelector(runtime); err != nil { - return err - } - if strings.TrimSpace(runtime.Str("range")) == "" { - return common.FlagErrorf("--range is required") - } - if _, err := requireJSONArray(runtime, "cells"); err != nil { - return err - } - return nil - }, + Validate: validateViaInput(cellsSetInput), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) @@ -87,6 +73,12 @@ var CellsSet = common.Shortcut{ } func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return nil, common.FlagErrorf("--range is required") + } cells, err := requireJSONArray(runtime, "cells") if err != nil { return nil, err @@ -118,28 +110,7 @@ var CellsSetStyle = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+cells-set-style"), - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, _, err := resolveSheetSelector(runtime); err != nil { - return err - } - r := strings.TrimSpace(runtime.Str("range")) - if r == "" { - return common.FlagErrorf("--range is required") - } - if _, _, err := rangeDimensions(r); err != nil { - return common.FlagErrorf("--range %q: %v", r, err) - } - if err := requireAnyStyleFlag(runtime); err != nil { - return err - } - if _, err := borderStylesFromFlag(runtime); err != nil { - return err - } - return nil - }, + Validate: validateViaInput(cellsSetStyleInput), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) @@ -169,11 +140,20 @@ var CellsSetStyle = common.Shortcut{ } func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } rangeStr := strings.TrimSpace(runtime.Str("range")) + if rangeStr == "" { + return nil, common.FlagErrorf("--range is required") + } rows, cols, err := rangeDimensions(rangeStr) if err != nil { return nil, common.FlagErrorf("--range %q: %v", rangeStr, err) } + if err := requireAnyStyleFlag(runtime); err != nil { + return nil, err + } cellStyle := buildCellStyleFromFlags(runtime) borderStyles, err := borderStylesFromFlag(runtime) if err != nil { @@ -214,29 +194,12 @@ var CsvPut = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+csv-put"), - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, _, err := resolveSheetSelector(runtime); err != nil { - return err - } - if strings.TrimSpace(runtime.Str("csv")) == "" { - return common.FlagErrorf("--csv is required") - } - anchor := strings.TrimSpace(runtime.Str("start-cell")) - if anchor == "" { - return common.FlagErrorf("--start-cell is required") - } - if _, _, ok := splitCellRef(anchor); !ok { - return common.FlagErrorf("--start-cell %q must be a single cell ref (e.g. A1)", anchor) - } - return nil - }, + Validate: validateViaInput(csvPutInput), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - return invokeToolDryRun(token, ToolKindWrite, "set_range_from_csv", csvPutInput(runtime, token, sheetID, sheetName)) + input, _ := csvPutInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "set_range_from_csv", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -247,7 +210,11 @@ var CsvPut = common.Shortcut{ if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_range_from_csv", csvPutInput(runtime, token, sheetID, sheetName)) + input, err := csvPutInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_range_from_csv", input) if err != nil { return err } @@ -256,17 +223,30 @@ var CsvPut = common.Shortcut{ }, } -func csvPutInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { +func csvPutInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if strings.TrimSpace(runtime.Str("csv")) == "" { + return nil, common.FlagErrorf("--csv is required") + } + anchor := strings.TrimSpace(runtime.Str("start-cell")) + if anchor == "" { + return nil, common.FlagErrorf("--start-cell is required") + } + if _, _, ok := splitCellRef(anchor); !ok { + return nil, common.FlagErrorf("--start-cell %q must be a single cell ref (e.g. A1)", anchor) + } input := map[string]interface{}{ "excel_id": token, "csv": runtime.Str("csv"), - "start_cell": strings.TrimSpace(runtime.Str("start-cell")), + "start_cell": anchor, } sheetSelectorForToolInput(input, sheetID, sheetName) if !runtime.Bool("allow-overwrite") { input["allow_overwrite"] = false } - return input + return input, nil } // ─── +dropdown-* (set_cell_range via data_validation) ───────────────── @@ -285,25 +265,7 @@ var DropdownSet = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+dropdown-set"), - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := resolveSpreadsheetToken(runtime); err != nil { - return err - } - if _, _, err := resolveSheetSelector(runtime); err != nil { - return err - } - r := strings.TrimSpace(runtime.Str("range")) - if r == "" { - return common.FlagErrorf("--range is required") - } - if _, _, err := rangeDimensions(r); err != nil { - return common.FlagErrorf("--range %q: %v", r, err) - } - if _, err := validateDropdownOptionsColors(runtime); err != nil { - return err - } - return nil - }, + Validate: validateViaInput(dropdownSetInput), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) @@ -333,15 +295,21 @@ var DropdownSet = common.Shortcut{ } func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { - validation, err := buildDropdownValidation(runtime) - if err != nil { + if err := requireSheetSelector(sheetID, sheetName); err != nil { return nil, err } rangeStr := strings.TrimSpace(runtime.Str("range")) + if rangeStr == "" { + return nil, common.FlagErrorf("--range is required") + } rows, cols, err := rangeDimensions(rangeStr) if err != nil { return nil, common.FlagErrorf("--range %q: %v", rangeStr, err) } + validation, err := buildDropdownValidation(runtime) + if err != nil { + return nil, err + } cells := fillCellsMatrix(rows, cols, map[string]interface{}{"data_validation": validation}) input := map[string]interface{}{ "excel_id": token, @@ -390,8 +358,8 @@ func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) { } // validateDropdownOptionsColors validates --options is a JSON array and that -// --colors (when set) has matching length. Used by +dropdown-set Validate. -func validateDropdownOptionsColors(runtime *common.RuntimeContext) (int, error) { +// --colors (when set) has matching length. Used by +dropdown-update Validate. +func validateDropdownOptionsColors(runtime flagView) (int, error) { options, err := requireJSONArray(runtime, "options") if err != nil { return 0, err From 4e44e668f7ad27bb8e401a85cb339d9ce1f7efad Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Thu, 21 May 2026 16:22:15 +0800 Subject: [PATCH 027/114] fix(sheets): align +cond-format / +filter with server schema (#4 + #5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two latent bugs in the object_crud translator surfaced during BOE smoke testing of +batch-update. Both are schema-alignment fixes against manage_conditional_format_object / manage_filter_object as declared in sheet-skill-spec/canonical-spec/tool-schemas/mcp-tools.json. #4 +cond-format: rule_type path + enum vocabulary --------------------------------------------------- condFormatEnhance used to write the user's --rule-type value into `properties.rule.type` (nested under a `rule` object). The server schema actually puts it at flat `properties.rule_type` and silently drops the nested form — so every conditional-format create/update secretly built the wrong document. Worse, the CLI enum exposed via flag-defs.json was its own invented vocabulary (cellValue / formula / duplicate / unique / topBottom / aboveBelowAverage / dataBar / colorScale / iconSet / textContains / dateOccurring / blankCell / errorCell) — none of those values were the strings the server accepts. Fix: - condFormatEnhance now writes `properties.rule_type = ` directly (no nested `rule` object). - Synced flag-defs.json + lark-sheets-conditional-format.md enum vocabulary from base to match the server: duplicateValues, uniqueValues, cellIs, containsText, timePeriod, containsBlanks, notContainsBlanks, dataBar, colorScale, rank, aboveAverage, expression, iconSet. - ⚠️ Breaking: scripts passing the old CLI-invented enum values (e.g. --rule-type cellValue) now get a cobra "invalid value … allowed: …" error listing the new vocabulary. No alias layer. - TestObjectCRUDShortcuts_DryRun's +cond-format-update case updated to assert the flat properties.rule_type shape + new enum. #5 +filter-{update,delete}: auto-inject filter_id = sheet_id ------------------------------------------------------------- manage_filter_object's contract is "filter_id === sheet_id" for the sheet-scoped filter (per per-tool description in mcp-tools.json), and update / delete operations MUST carry filter_id. Standalone filterUpdateInput / filterDeleteInput never set it, so the server rejected with "filter_id is required for update/delete operation" on every call — both standalone AND inside +batch-update. Fix: - filterUpdateInput / filterDeleteInput now set input["filter_id"] = sheetID. - Because filter_id must equal sheet_id (not sheet_name), update / delete reject when only --sheet-name is given — there's no network lookup available inside the builder. The friendly error points at +workbook-info for resolving sheet-name → sheet-id. - create still omits filter_id (server requires that — id is server-allocated on creation). - New tests: * TestObjectCRUDShortcuts_DryRun gains a +filter-update happy-path case asserting filter_id is auto-injected + --range hoisting. * +filter-delete case updated to assert filter_id presence. * TestBatchOp_RejectsBadSubOpInput gains two cases asserting both +filter-update and +filter-delete reject --sheet-name-only with the friendly error. Docs (#2 + #3 + #8) synced from sheet-skill-spec ------------------------------------------------- Companion doc fixes that landed via npm run generate:cli + sync:cli in sheet-skill-spec; included here because the regenerated flag-defs and references markdown are byte-tracked in this repo: - #2: lark-sheets-sheet-structure.md — +dim-{hide,unhide,group, ungroup} --start/--end desc changed from "(0-based, inclusive)" to "(0-based)" / "(exclusive)" to match the half-open range semantics the code has always implemented (requireDimRange: end > start; dimRange uses end - 1 for column end letters). - #3: lark-sheets-workbook.md — +sheet-move section gains a note about the batch-internal requirement to pass --sheet-id AND --source-index explicitly (sheetMoveBatchInput's constraint). - #8: lark-sheets-pivot-table.md — +pivot-create --properties example drops the stale data_range field (the actual server schema uses --source as a hoisted flag; properties only carries rows / columns / values / filters / show_*_grand_total). --- shortcuts/sheets/batch_op_contract_test.go | 19 +++++- shortcuts/sheets/data/flag-defs.json | 62 +++++++++---------- shortcuts/sheets/lark_sheet_object_crud.go | 34 +++++++--- .../sheets/lark_sheet_object_crud_test.go | 45 ++++++++++++-- .../lark-sheets-conditional-format.md | 4 +- .../references/lark-sheets-pivot-table.md | 2 +- .../references/lark-sheets-sheet-structure.md | 16 ++--- .../references/lark-sheets-workbook.md | 4 ++ 8 files changed, 128 insertions(+), 58 deletions(-) diff --git a/shortcuts/sheets/batch_op_contract_test.go b/shortcuts/sheets/batch_op_contract_test.go index aa3c5b897..f3b67ac82 100644 --- a/shortcuts/sheets/batch_op_contract_test.go +++ b/shortcuts/sheets/batch_op_contract_test.go @@ -210,8 +210,8 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) { { shortcut: "+cond-format-create", sc: CondFormatCreate, - args: []string{"--sheet-id", "sh1", "--properties", `{"style":{}}`, "--rule-type", "duplicate", "--ranges", `["A1:A100"]`}, - subInput: `{"sheet-id":"sh1","properties":{"style":{}},"rule-type":"duplicate","ranges":["A1:A100"]}`, + args: []string{"--sheet-id", "sh1", "--properties", `{"style":{}}`, "--rule-type", "duplicateValues", "--ranges", `["A1:A100"]`}, + subInput: `{"sheet-id":"sh1","properties":{"style":{}},"rule-type":"duplicateValues","ranges":["A1:A100"]}`, }, { shortcut: "+filter-create", @@ -516,6 +516,21 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) { `{"sheet-id":"sh1","image-name":"x.png","image-token":"t","position-row":0,"position-col":"A","size-width":100,"size-height":50}`, "--float-image-id is required", }, + // +filter-{update,delete} need sheet-id (not sheet-name) because + // server contract: filter_id === sheet_id, and we can't resolve + // sheet-name → sheet-id mid-batch. + { + "+filter-update with --sheet-name only (filter_id must equal sheet_id)", + "+filter-update", + `{"sheet-name":"Sheet1","range":"A1:F1000","properties":{"rules":[]}}`, + "+filter-update requires --sheet-id", + }, + { + "+filter-delete with --sheet-name only (filter_id must equal sheet_id)", + "+filter-delete", + `{"sheet-name":"Sheet1"}`, + "+filter-delete requires --sheet-id", + }, } for _, tc := range cases { diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index 128b710d0..b55ca2f2e 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -773,14 +773,14 @@ "kind": "own", "type": "int", "required": "required", - "desc": "Start position (0-based, inclusive)" + "desc": "Start position (0-based)" }, { "name": "end", "kind": "own", "type": "int", "required": "required", - "desc": "End position (0-based, inclusive)" + "desc": "End position (exclusive)" }, { "name": "dry-run", @@ -838,14 +838,14 @@ "kind": "own", "type": "int", "required": "required", - "desc": "Start position (0-based, inclusive)" + "desc": "Start position (0-based)" }, { "name": "end", "kind": "own", "type": "int", "required": "required", - "desc": "End position (0-based, inclusive)" + "desc": "End position (exclusive)" }, { "name": "dry-run", @@ -961,14 +961,14 @@ "kind": "own", "type": "int", "required": "required", - "desc": "Start position (0-based, inclusive)" + "desc": "Start position (0-based)" }, { "name": "end", "kind": "own", "type": "int", "required": "required", - "desc": "End position (0-based, inclusive)" + "desc": "End position (exclusive)" }, { "name": "depth", @@ -1046,14 +1046,14 @@ "kind": "own", "type": "int", "required": "required", - "desc": "Start position (0-based, inclusive)" + "desc": "Start position (0-based)" }, { "name": "end", "kind": "own", "type": "int", "required": "required", - "desc": "End position (0-based, inclusive)" + "desc": "End position (exclusive)" }, { "name": "depth", @@ -3184,7 +3184,7 @@ "kind": "own", "type": "string", "required": "required", - "desc": "JSON: `{\"data_range\":\"Sheet1!A1:F1000\",\"rows\":[...],\"columns\":[...],\"values\":[...],\"filters\":[...],\"show_row_grand_total\":true,\"show_col_grand_total\":true}`", + "desc": "JSON: {\"rows\":[...],\"columns\":[...],\"values\":[...],\"filters\":[...],\"show_row_grand_total\":true,\"show_col_grand_total\":true} (data source goes through --source; do not put data_range here)", "input": [ "file", "stdin" @@ -3436,19 +3436,19 @@ "required": "required", "desc": "Conditional format rule type; takes precedence over the same-named field inside `--properties`", "enum": [ - "cellValue", - "formula", - "duplicate", - "unique", - "topBottom", - "aboveBelowAverage", + "duplicateValues", + "uniqueValues", + "cellIs", + "containsText", + "timePeriod", + "containsBlanks", + "notContainsBlanks", "dataBar", "colorScale", - "iconSet", - "textContains", - "dateOccurring", - "blankCell", - "errorCell" + "rank", + "aboveAverage", + "expression", + "iconSet" ] }, { @@ -3527,19 +3527,19 @@ "required": "required", "desc": "Conditional format rule type; takes precedence over the same-named field inside `--properties`", "enum": [ - "cellValue", - "formula", - "duplicate", - "unique", - "topBottom", - "aboveBelowAverage", + "duplicateValues", + "uniqueValues", + "cellIs", + "containsText", + "timePeriod", + "containsBlanks", + "notContainsBlanks", "dataBar", "colorScale", - "iconSet", - "textContains", - "dateOccurring", - "blankCell", - "errorCell" + "rank", + "aboveAverage", + "expression", + "iconSet" ] }, { diff --git a/shortcuts/sheets/lark_sheet_object_crud.go b/shortcuts/sheets/lark_sheet_object_crud.go index 25dba6a9e..f1890508d 100644 --- a/shortcuts/sheets/lark_sheet_object_crud.go +++ b/shortcuts/sheets/lark_sheet_object_crud.go @@ -306,18 +306,21 @@ var PivotDelete = newObjectDeleteShortcut(pivotSpec) // conditional format — CLI surface uses --rule-id (short), wired to the // tool's conditional_format_id on the wire. --rule-type and --ranges are // hoisted out of properties (both required, set on every CRUD write). +// +// Wire shape matches manage_conditional_format_object.properties — the +// enum value lives at properties.rule_type (flat string, NOT nested under +// a `rule` object), and ranges is a sibling array. Earlier CLI builds +// wrote properties.rule.type which the server silently dropped — both +// path and enum vocabulary are now aligned with the server schema (see +// sheet-skill-spec/canonical-spec/tool-schemas/mcp-tools.json line +// 3305-3324). var condFormatEnhance = func(rt flagView, input map[string]interface{}) { props, _ := input["properties"].(map[string]interface{}) if props == nil { return } if ruleType := strings.TrimSpace(rt.Str("rule-type")); ruleType != "" { - rule, _ := props["rule"].(map[string]interface{}) - if rule == nil { - rule = map[string]interface{}{} - } - rule["type"] = ruleType - props["rule"] = rule + props["rule_type"] = ruleType } if rt.Str("ranges") != "" { if arr, err := requireJSONArray(rt, "ranges"); err == nil { @@ -648,6 +651,9 @@ func filterUpdateInput(runtime flagView, token, sheetID, sheetName string) (map[ if err := requireSheetSelector(sheetID, sheetName); err != nil { return nil, err } + if sheetID == "" { + return nil, common.FlagErrorf("+filter-update requires --sheet-id (filter_id must equal sheet_id; --sheet-name needs a network lookup unavailable here — call +workbook-info first or pass --sheet-id directly)") + } if strings.TrimSpace(runtime.Str("range")) == "" { return nil, common.FlagErrorf("--range is required") } @@ -660,6 +666,7 @@ func filterUpdateInput(runtime flagView, token, sheetID, sheetName string) (map[ input := map[string]interface{}{ "excel_id": token, "operation": "update", + "filter_id": sheetID, // server contract: filter_id === sheet_id for sheet-scoped filters "properties": props, } sheetSelectorForToolInput(input, sheetID, sheetName) @@ -706,12 +713,23 @@ var FilterDelete = common.Shortcut{ } // filterDeleteInput mirrors the standalone +filter-delete body for batch -// sub-op reuse. filter_id is implicit (sheet-scoped), so no extra id flag. +// sub-op reuse. Server contract: filter_id === sheet_id, and update/delete +// must populate filter_id (per manage_filter_object schema). The CLI has no +// separate --filter-id flag because the value is fully derived from sheet_id; +// only --sheet-id is accepted (not --sheet-name, since there's no mid-call +// network lookup to resolve it). func filterDeleteInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { if err := requireSheetSelector(sheetID, sheetName); err != nil { return nil, err } - input := map[string]interface{}{"excel_id": token, "operation": "delete"} + if sheetID == "" { + return nil, common.FlagErrorf("+filter-delete requires --sheet-id (filter_id must equal sheet_id; --sheet-name needs a network lookup unavailable here — call +workbook-info first or pass --sheet-id directly)") + } + input := map[string]interface{}{ + "excel_id": token, + "operation": "delete", + "filter_id": sheetID, // server contract: filter_id === sheet_id + } sheetSelectorForToolInput(input, sheetID, sheetName) return input, nil } diff --git a/shortcuts/sheets/lark_sheet_object_crud_test.go b/shortcuts/sheets/lark_sheet_object_crud_test.go index 88c610159..00e66449b 100644 --- a/shortcuts/sheets/lark_sheet_object_crud_test.go +++ b/shortcuts/sheets/lark_sheet_object_crud_test.go @@ -89,15 +89,19 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { "pivot_table_id": "ptA", }, }, - // cond-format — --rule-id rename + --rule-type / --ranges hoist + // cond-format — --rule-id rename + --rule-type / --ranges hoist. + // rule_type lives at properties.rule_type (flat string), not nested + // under a `rule` object; enum vocabulary matches server schema + // (cellIs / duplicateValues / ... — see mcp-tools.json + // manage_conditional_format_object.properties.rule_type). { name: "+cond-format-update id rename + rule-type/ranges", sc: CondFormatUpdate, args: []string{ "--url", testURL, "--sheet-id", testSheetID, "--rule-id", "ruleA", - "--properties", `{"rule":{"operator":"greater_than","value":100}}`, - "--rule-type", "cellValue", + "--properties", `{"attrs":[{"operator":"greaterThan","value":"100"}],"style":{"back_color":"#FFD7D7"}}`, + "--rule-type", "cellIs", "--ranges", `["A1:A100"]`, }, toolName: "manage_conditional_format_object", @@ -107,8 +111,10 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { "operation": "update", "conditional_format_id": "ruleA", "properties": map[string]interface{}{ - "rule": map[string]interface{}{"operator": "greater_than", "value": float64(100), "type": "cellValue"}, - "ranges": []interface{}{"A1:A100"}, + "rule_type": "cellIs", + "attrs": []interface{}{map[string]interface{}{"operator": "greaterThan", "value": "100"}}, + "style": map[string]interface{}{"back_color": "#FFD7D7"}, + "ranges": []interface{}{"A1:A100"}, }, }, }, @@ -138,16 +144,43 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { }, }, { - name: "+filter-delete (no id flag, sheet-scoped)", + // +filter-delete has no separate --filter-id flag because the + // server contract sets filter_id === sheet_id; the translator + // auto-injects filter_id from --sheet-id. update/delete fail + // hard when only --sheet-name is given (no mid-call lookup). + name: "+filter-delete (sheet-scoped, auto-injects filter_id=sheet_id)", sc: FilterDelete, args: []string{"--url", testURL, "--sheet-id", testSheetID}, toolName: "manage_filter_object", wantInput: map[string]interface{}{ "excel_id": testToken, "sheet_id": testSheetID, + "filter_id": testSheetID, "operation": "delete", }, }, + { + // +filter-update auto-injects filter_id from sheet_id, hoists + // --range out of properties, and merges properties.rules. + name: "+filter-update auto-injects filter_id, hoists --range", + sc: FilterUpdate, + args: []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1:F1000", + "--properties", `{"rules":[{"col":"B"}]}`, + }, + toolName: "manage_filter_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "filter_id": testSheetID, + "operation": "update", + "properties": map[string]interface{}{ + "range": "A1:F1000", + "rules": []interface{}{map[string]interface{}{"col": "B"}}, + }, + }, + }, // filter-view CRUD (cli-only via callTool) { name: "+filter-view-create", diff --git a/skills/lark-sheets/references/lark-sheets-conditional-format.md b/skills/lark-sheets/references/lark-sheets-conditional-format.md index b8c69eb1b..47157f217 100644 --- a/skills/lark-sheets/references/lark-sheets-conditional-format.md +++ b/skills/lark-sheets/references/lark-sheets-conditional-format.md @@ -96,7 +96,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--properties` | string + File + Stdin(复合 JSON) | required | 规则配置 JSON,含 `style`(命中样式,必填)和 `attrs?`(规则参数列表,因 `rule_type` 不同结构而异)/ `has_ref?`。`rule_type` 和 `ranges` 已拎为独立 flag | -| `--rule-type` | string | required | 条件格式规则类型;优先级高于 `--properties` 中同名字段(可选值:`cellValue` / `formula` / `duplicate` / `unique` / `topBottom` / `aboveBelowAverage` / `dataBar` / `colorScale` / `iconSet` / `textContains` / `dateOccurring` / `blankCell` / `errorCell`) | +| `--rule-type` | string | required | 条件格式规则类型;优先级高于 `--properties` 中同名字段(可选值:`duplicateValues` / `uniqueValues` / `cellIs` / `containsText` / `timePeriod` / `containsBlanks` / `notContainsBlanks` / `dataBar` / `colorScale` / `rank` / `aboveAverage` / `expression` / `iconSet`) | | `--ranges` | string + File + Stdin(简单 JSON) | required | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--properties` 中同名字段 | ### `+cond-format-update` @@ -107,7 +107,7 @@ _公共四件套 · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--rule-id` | string | required | 目标规则 id | | `--properties` | string + File + Stdin(复合 JSON) | required | 规则配置 JSON,结构同 `+cond-format-create` 的 `--properties`;update 是整组覆盖式 | -| `--rule-type` | string | required | 条件格式规则类型;优先级高于 `--properties` 中同名字段(可选值:`cellValue` / `formula` / `duplicate` / `unique` / `topBottom` / `aboveBelowAverage` / `dataBar` / `colorScale` / `iconSet` / `textContains` / `dateOccurring` / `blankCell` / `errorCell`) | +| `--rule-type` | string | required | 条件格式规则类型;优先级高于 `--properties` 中同名字段(可选值:`duplicateValues` / `uniqueValues` / `cellIs` / `containsText` / `timePeriod` / `containsBlanks` / `notContainsBlanks` / `dataBar` / `colorScale` / `rank` / `aboveAverage` / `expression` / `iconSet`) | | `--ranges` | string + File + Stdin(简单 JSON) | required | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--properties` 中同名字段 | ### `+cond-format-delete` diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md index c30b3a8df..9333d1092 100644 --- a/skills/lark-sheets/references/lark-sheets-pivot-table.md +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -61,7 +61,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--properties` | string + File + Stdin(复合 JSON) | required | JSON:`{"data_range":"Sheet1!A1:F1000","rows":[...],"columns":[...],"values":[...],"filters":[...],"show_row_grand_total":true,"show_col_grand_total":true}` | +| `--properties` | string + File + Stdin(复合 JSON) | required | JSON:{"rows":[...],"columns":[...],"values":[...],"filters":[...],"show_row_grand_total":true,"show_col_grand_total":true}(数据源走 --source,不要再放进 properties.data_range) | | `--target-sheet-id` | string | optional | 透视表落点子表 id;省略时自动新建子表(推荐) | | `--target-position` | string | optional | 落点起始 cell(A1 格式,如 `A1`),默认 `A1` | | `--source` | string | required | 透视表源数据区域(A1 表示法,格式 `SheetName!StartCell:EndCell`,如 `Sheet1!A1:D100`) | diff --git a/skills/lark-sheets/references/lark-sheets-sheet-structure.md b/skills/lark-sheets/references/lark-sheets-sheet-structure.md index 2567e37d3..b4c9d86e9 100644 --- a/skills/lark-sheets/references/lark-sheets-sheet-structure.md +++ b/skills/lark-sheets/references/lark-sheets-sheet-structure.md @@ -88,8 +88,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | -| `--start` | int | required | 起始位置(0-based, inclusive) | -| `--end` | int | required | 结束位置(0-based, inclusive) | +| `--start` | int | required | 起始位置(0-based) | +| `--end` | int | required | 结束位置(exclusive) | ### `+dim-unhide` @@ -98,8 +98,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | -| `--start` | int | required | 起始位置(0-based, inclusive) | -| `--end` | int | required | 结束位置(0-based, inclusive) | +| `--start` | int | required | 起始位置(0-based) | +| `--end` | int | required | 结束位置(exclusive) | ### `+dim-freeze` @@ -117,8 +117,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | -| `--start` | int | required | 起始位置(0-based, inclusive) | -| `--end` | int | required | 结束位置(0-based, inclusive) | +| `--start` | int | required | 起始位置(0-based) | +| `--end` | int | required | 结束位置(exclusive) | | `--depth` | int | optional | 嵌套层级(`+dim-group` 用),默认 1 | | `--group-state` | string | optional | 分组初始展开状态(可选值:`expand` / `fold`) | @@ -129,8 +129,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | -| `--start` | int | required | 起始位置(0-based, inclusive) | -| `--end` | int | required | 结束位置(0-based, inclusive) | +| `--start` | int | required | 起始位置(0-based) | +| `--end` | int | required | 结束位置(exclusive) | | `--depth` | int | optional | 嵌套层级(`+dim-group` 用),默认 1 | ### `+dim-move` diff --git a/skills/lark-sheets/references/lark-sheets-workbook.md b/skills/lark-sheets/references/lark-sheets-workbook.md index eda7e525e..ea65956a4 100644 --- a/skills/lark-sheets/references/lark-sheets-workbook.md +++ b/skills/lark-sheets/references/lark-sheets-workbook.md @@ -161,6 +161,10 @@ lark-cli sheets +sheet-create --url "https://example.feishu.cn/sheets/shtXXX" \ ### `+sheet-move` +standalone 路径在缺 `--source-index` / 只给 `--sheet-name` 时会自动发起一次 `+workbook-info` 读把它们解出来。 + +> ⚠️ **在 `+batch-update` 内调用 `+sheet-move`**:必须同时显式传 `--sheet-id` 和 `--source-index`。batch 中途无法发起结构查询,所以 batch translator 强制要求两者都显式。 + ### `+sheet-copy` ### `+sheet-hide` / `+sheet-unhide` From 5f3e8c638547fdc6d9f8031437697d5851d78b8b Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Thu, 21 May 2026 19:17:33 +0800 Subject: [PATCH 028/114] feat(sheets): add +cells-batch-clear fan-out over batch_update Clear content/formats across many sheet-prefixed ranges in a single atomic batch_update (one clear_cell_range op per range), mirroring the existing +cells-batch-set-style / +dropdown-{update,delete} fan-out wrappers. The --scope to clear_type normalization is shared with standalone +cells-clear (normalizeClearType) so the two stay in lockstep. high-risk-write (requires --yes); rejected as a batch sub-op like the other fan-out wrappers. flag-defs/flag-schemas and skill docs updated to match. --- shortcuts/sheets/batch_op_dispatch.go | 4 +- shortcuts/sheets/data/flag-defs.json | 57 ++++++++++++ shortcuts/sheets/data/flag-schemas.json | 62 +++++++------ shortcuts/sheets/lark_sheet_batch_update.go | 78 ++++++++++++++++ .../sheets/lark_sheet_batch_update_test.go | 88 +++++++++++++++++++ .../sheets/lark_sheet_range_operations.go | 23 +++-- shortcuts/sheets/shortcuts.go | 1 + .../references/lark-sheets-batch-update.md | 25 +++++- .../lark-sheets-range-operations.md | 2 + 9 files changed, 303 insertions(+), 37 deletions(-) diff --git a/shortcuts/sheets/batch_op_dispatch.go b/shortcuts/sheets/batch_op_dispatch.go index 9620d1a1f..2fde04342 100644 --- a/shortcuts/sheets/batch_op_dispatch.go +++ b/shortcuts/sheets/batch_op_dispatch.go @@ -25,7 +25,7 @@ import ( // 产出的 MCP body 与该 shortcut 单独调用产出的 body 完全一致(由 // batch-vs-standalone 契约测试保证)。dispatch 表只列**可纳入 atomic batch // 的 write shortcut**——读操作、fan-out wrapper(+batch-update 自身、 -// +cells-batch-set-style、+dropdown-{update,delete})一律不放进表里, +// +cells-batch-set-style、+cells-batch-clear、+dropdown-{update,delete})一律不放进表里, // 用户传到 +batch-update 里会被 translator 拒绝。 // batchTranslateFn turns a sub-op's CLI-shape input (via flagView) into the MCP @@ -224,7 +224,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte if !ok { return nil, common.FlagErrorf( "operations[%d]: shortcut %q not allowed in +batch-update "+ - "(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +dropdown-{update,delete} are excluded; "+ + "(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded; "+ "run `lark-cli sheets +batch-update --print-schema --flag-name operations` to see the full enum)", index, sc, ) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index b55ca2f2e..326c3f199 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -2891,6 +2891,63 @@ } ] }, + "+cells-batch-clear": { + "risk": "high-risk-write", + "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": "ranges", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target ranges as a JSON array (e.g. `[\"sheet1!A1:B2\",\"sheet1!D1:D10\"]`); each item must include the sheet prefix; the same scope is cleared from every range", + "input": [ + "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": [ + "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", + "desc": "" + } + ] + }, "+chart-list": { "risk": "read", "flags": [ diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json index c8c1a711b..9ceeca13c 100644 --- a/shortcuts/sheets/data/flag-schemas.json +++ b/shortcuts/sheets/data/flag-schemas.json @@ -67,7 +67,7 @@ "+float-image-update", "+float-image-delete" ], - "description": "CLI shortcut 名(不是底层 MCP tool 名)。+dim-move 不在表中——它走 legacy v2 endpoint,无法批;+cells-set-image / +workbook-create 也不在——前者含多步图片上传,后者是新建工作簿,都不属于 atomic batch 范畴;所有读操作、fan-out wrapper(+batch-update 自身 / +cells-batch-set-style / +dropdown-{update,delete})一律禁。" + "description": "CLI shortcut 名(不是底层 MCP tool 名)。+dim-move 不在表中——它走 legacy v2 endpoint,无法批;+cells-set-image / +workbook-create 也不在——前者含多步图片上传,后者是新建工作簿,都不属于 atomic batch 范畴;所有读操作、fan-out wrapper(+batch-update 自身 / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete})一律禁。" }, "input": { "type": "object", @@ -88,12 +88,13 @@ "type": "string" }, "style": { - "description": "边框线型", + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", "enum": [ "solid", "dashed", "dotted", - "double" + "double", + "none" ], "type": "string" }, @@ -116,12 +117,13 @@ "type": "string" }, "style": { - "description": "边框线型", + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", "enum": [ "solid", "dashed", "dotted", - "double" + "double", + "none" ], "type": "string" }, @@ -144,12 +146,13 @@ "type": "string" }, "style": { - "description": "边框线型", + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", "enum": [ "solid", "dashed", "dotted", - "double" + "double", + "none" ], "type": "string" }, @@ -172,12 +175,13 @@ "type": "string" }, "style": { - "description": "边框线型", + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", "enum": [ "solid", "dashed", "dotted", - "double" + "double", + "none" ], "type": "string" }, @@ -210,12 +214,13 @@ "type": "string" }, "style": { - "description": "边框线型", + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", "enum": [ "solid", "dashed", "dotted", - "double" + "double", + "none" ], "type": "string" }, @@ -238,12 +243,13 @@ "type": "string" }, "style": { - "description": "边框线型", + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", "enum": [ "solid", "dashed", "dotted", - "double" + "double", + "none" ], "type": "string" }, @@ -266,12 +272,13 @@ "type": "string" }, "style": { - "description": "边框线型", + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", "enum": [ "solid", "dashed", "dotted", - "double" + "double", + "none" ], "type": "string" }, @@ -294,12 +301,13 @@ "type": "string" }, "style": { - "description": "边框线型", + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", "enum": [ "solid", "dashed", "dotted", - "double" + "double", + "none" ], "type": "string" }, @@ -615,12 +623,13 @@ "type": "string" }, "style": { - "description": "边框线型", + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", "enum": [ "solid", "dashed", "dotted", - "double" + "double", + "none" ], "type": "string" }, @@ -643,12 +652,13 @@ "type": "string" }, "style": { - "description": "边框线型", + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", "enum": [ "solid", "dashed", "dotted", - "double" + "double", + "none" ], "type": "string" }, @@ -671,12 +681,13 @@ "type": "string" }, "style": { - "description": "边框线型", + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", "enum": [ "solid", "dashed", "dotted", - "double" + "double", + "none" ], "type": "string" }, @@ -699,12 +710,13 @@ "type": "string" }, "style": { - "description": "边框线型", + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", "enum": [ "solid", "dashed", "dotted", - "double" + "double", + "none" ], "type": "string" }, diff --git a/shortcuts/sheets/lark_sheet_batch_update.go b/shortcuts/sheets/lark_sheet_batch_update.go index 34f51045d..d6591bb52 100644 --- a/shortcuts/sheets/lark_sheet_batch_update.go +++ b/shortcuts/sheets/lark_sheet_batch_update.go @@ -236,6 +236,84 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[ }, nil } +// CellsBatchClear clears content / formats / both across many sheet-prefixed +// ranges in one atomic batch. --ranges is a JSON array of sheet-prefixed A1 +// strings; --scope reuses the +cells-clear vocabulary (content / formats / +// all). CLI fans each range into a separate clear_cell_range op inside one +// batch_update. high-risk-write because clear is irreversible. +var CellsBatchClear = common.Shortcut{ + Service: "sheets", + Command: "+cells-batch-clear", + Description: "Clear content/formats across many sheet-prefixed ranges in one atomic batch (irreversible).", + Risk: "high-risk-write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+cells-batch-clear"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, err := validateDropdownRanges(runtime); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + input, _ := cellsBatchClearInput(runtime, token) + return invokeToolDryRun(token, ToolKindWrite, "batch_update", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + input, err := cellsBatchClearInput(runtime, token) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + Tips: []string{ + "high-risk-write — always preview with --dry-run; clear is not undoable.", + "Every --ranges item must carry a sheet prefix (e.g. \"Sheet1!A1:A10\"); all ranges are cleared with the same --scope.", + }, +} + +func cellsBatchClearInput(runtime *common.RuntimeContext, token string) (map[string]interface{}, error) { + ranges, err := validateDropdownRanges(runtime) + if err != nil { + return nil, err + } + clearType := normalizeClearType(runtime.Str("scope")) + var ops []interface{} + for _, rng := range ranges { + sheet, sub, err := splitSheetPrefixedRange(rng) + if err != nil { + return nil, err + } + ops = append(ops, map[string]interface{}{ + "tool_name": "clear_cell_range", + "input": map[string]interface{}{ + "excel_id": token, + "sheet_name": sheet, + "range": sub, + "clear_type": clearType, + }, + }) + } + return map[string]interface{}{ + "excel_id": token, + "operations": ops, + }, nil +} + // DropdownUpdate installs/replaces a single dropdown on many ranges in one // atomic batch. Sheet ids come from the per-range sheet prefix. var DropdownUpdate = common.Shortcut{ diff --git a/shortcuts/sheets/lark_sheet_batch_update_test.go b/shortcuts/sheets/lark_sheet_batch_update_test.go index 23937f428..26220ff2a 100644 --- a/shortcuts/sheets/lark_sheet_batch_update_test.go +++ b/shortcuts/sheets/lark_sheet_batch_update_test.go @@ -103,6 +103,89 @@ func TestCellsBatchSetStyle_FansOutOps(t *testing.T) { } } +// TestCellsBatchClear_FansOutOps verifies multiple ranges produce one +// clear_cell_range op each, all sharing the same --scope-derived clear_type, +// with the sheet prefix split into sheet_name + bare range. +func TestCellsBatchClear_FansOutOps(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, CellsBatchClear, []string{ + "--url", testURL, + "--ranges", `["sheet1!A1:A10","sheet2!C1:D5","sheet1!F3"]`, + "--scope", "all", + "--yes", + }) + input := decodeToolInput(t, body, "batch_update") + ops, _ := input["operations"].([]interface{}) + if len(ops) != 3 { + t.Fatalf("operations length = %d, want 3 (one per range)", len(ops)) + } + wantSheet := []string{"sheet1", "sheet2", "sheet1"} + wantRange := []string{"A1:A10", "C1:D5", "F3"} + for i, raw := range ops { + op, _ := raw.(map[string]interface{}) + if op["tool_name"] != "clear_cell_range" { + t.Errorf("op[%d].tool_name = %v, want clear_cell_range", i, op["tool_name"]) + } + params, _ := op["input"].(map[string]interface{}) + if params["sheet_name"] != wantSheet[i] { + t.Errorf("op[%d].sheet_name = %v, want %s", i, params["sheet_name"], wantSheet[i]) + } + if params["range"] != wantRange[i] { + t.Errorf("op[%d].range = %v, want %s", i, params["range"], wantRange[i]) + } + if params["clear_type"] != "all" { + t.Errorf("op[%d].clear_type = %v, want all", i, params["clear_type"]) + } + } +} + +// TestCellsBatchClear_ScopeDefaultsToContents verifies the default --scope +// (content) maps to the tool's clear_type "contents" — identical to the +// standalone +cells-clear normalization. +func TestCellsBatchClear_ScopeDefaultsToContents(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, CellsBatchClear, []string{ + "--url", testURL, + "--ranges", `["sheet1!A1:B2"]`, + "--yes", + }) + input := decodeToolInput(t, body, "batch_update") + ops, _ := input["operations"].([]interface{}) + if len(ops) != 1 { + t.Fatalf("operations length = %d, want 1", len(ops)) + } + params, _ := ops[0].(map[string]interface{})["input"].(map[string]interface{}) + if params["clear_type"] != "contents" { + t.Errorf("clear_type = %v, want contents (default scope)", params["clear_type"]) + } +} + +// TestCellsBatchClear_Guards covers the sheet-prefix requirement and the +// high-risk-write confirmation gate. +func TestCellsBatchClear_Guards(t *testing.T) { + t.Parallel() + + // sheetless range → prefix guard (shared with the dropdown fan-outs). + stdout, stderr, err := runShortcutCapturingErr(t, CellsBatchClear, []string{ + "--url", testURL, + "--ranges", `["A1:A10"]`, + "--yes", + "--dry-run", + }) + if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must include a sheet prefix") { + t.Errorf("expected sheet-prefix guard; got=%s|%s|%v", stdout, stderr, err) + } + + // missing --yes → confirmation_required (high-risk-write). + stdout, stderr, err = runShortcutCapturingErr(t, CellsBatchClear, []string{ + "--url", testURL, + "--ranges", `["sheet1!A1:A10"]`, + }) + if err == nil { + t.Errorf("expected confirmation_required without --yes; stdout=%s stderr=%s", stdout, stderr) + } +} + // TestDropdownUpdate_BatchPayload verifies the multi-range dropdown // update fans out into a single batch_update with one set_cell_range // op per range. @@ -245,6 +328,11 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) { opsJSON: `[{"shortcut":"+cells-batch-set-style","input":{}}]`, wantMatch: "not allowed in +batch-update", }, + { + name: "fan-out wrapper +cells-batch-clear rejected", + opsJSON: `[{"shortcut":"+cells-batch-clear","input":{}}]`, + wantMatch: "not allowed in +batch-update", + }, { name: "legacy v2 +dim-move rejected", opsJSON: `[{"shortcut":"+dim-move","input":{}}]`, diff --git a/shortcuts/sheets/lark_sheet_range_operations.go b/shortcuts/sheets/lark_sheet_range_operations.go index 71dc04ff8..e30b9666c 100644 --- a/shortcuts/sheets/lark_sheet_range_operations.go +++ b/shortcuts/sheets/lark_sheet_range_operations.go @@ -75,23 +75,28 @@ func cellsClearInput(runtime flagView, token, sheetID, sheetName string) (map[st if strings.TrimSpace(runtime.Str("range")) == "" { return nil, common.FlagErrorf("--range is required") } - scope := runtime.Str("scope") - clearType := "contents" - switch scope { - case "content", "": - clearType = "contents" - case "formats", "all": - clearType = scope - } input := map[string]interface{}{ "excel_id": token, "range": strings.TrimSpace(runtime.Str("range")), - "clear_type": clearType, + "clear_type": normalizeClearType(runtime.Str("scope")), } sheetSelectorForToolInput(input, sheetID, sheetName) return input, nil } +// normalizeClearType maps the CLI --scope vocabulary (content / formats / all) +// to the clear_cell_range tool's clear_type vocabulary (contents / formats / +// all). The content↔contents singular/plural mismatch is absorbed here so both +// +cells-clear and the +cells-batch-clear fan-out stay in lockstep. +func normalizeClearType(scope string) string { + switch scope { + case "formats", "all": + return scope + default: // "content" or unset + return "contents" + } +} + // CellsMerge / CellsUnmerge share the merge_cells tool, dispatched by the // `operation` enum. --merge-type applies to merge only and maps to tool // field merge_type (`all` / `rows` / `columns`). diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index c71ef5572..c13b1e2f5 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -97,6 +97,7 @@ func shortcutList() []common.Shortcut { // lark_sheet_batch_update BatchUpdate, CellsBatchSetStyle, + CellsBatchClear, DropdownUpdate, DropdownDelete, } diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 890215f74..d2974f511 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -28,6 +28,7 @@ | `+cells-batch-set-style` | write | 批量 | | `+dropdown-update` | write | 对象 | | `+dropdown-delete` | high-risk-write | 对象 | +| `+cells-batch-clear` | high-risk-write | 批量 | ## Flags @@ -79,6 +80,15 @@ _公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_ | --- | --- | --- | --- | | `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(最多 100 个,每项必须带 sheet 前缀) | +### `+cells-batch-clear` + +_公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["sheet1!A1:B2","sheet1!D1:D10"]`);对所有 range 执行同一 scope 的清除 | +| `--scope` | string | optional | 清除范围 enum:`content`(默认,仅清内容)/ `formats`(仅清格式)/ `all`(清内容 + 格式)(可选值:`content` / `formats` / `all`) | + ## Schemas > 复合 JSON flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag-name `。先 `--print-schema`(不带 `--flag-name`)会列出该 shortcut 所有可查询的 flag。 @@ -151,8 +161,21 @@ lark-cli sheets +cells-batch-set-style --url "..." \ --background-color "#1E5BC6" --font-color "#FFFFFF" --font-weight bold ``` +### `+cells-batch-clear` + +多 range 一次性清除(服务端走 `+batch-update` 原子事务);`--scope` 同 `+cells-clear`(`content` / `formats` / `all`,默认 `content`),`high-risk-write` 强制 `--yes`: + +```bash +# dry-run 先看清除范围 +lark-cli sheets +cells-batch-clear --url "..." \ + --ranges '["sheet1!A2:Z1000","sheet2!A2:Z1000"]' --scope all --dry-run +# 执行 +lark-cli sheets +cells-batch-clear --url "..." \ + --ranges '["sheet1!A2:Z1000","sheet2!A2:Z1000"]' --scope all --yes +``` + ### Validate / DryRun / Execute 约束 -- `Validate`:`+batch-update` 的 `--operations` 必须合法 JSON,且为非空数组;逐个子操作 `shortcut` / `input` 字段必填校验;**禁止嵌套 `+batch-update`**。`+cells-batch-set-style` 的 `--ranges` 必须 JSON 数组、每项带 sheet 前缀;样式 flag 至少一个非空(或带 `--border-styles`)。 +- `Validate`:`+batch-update` 的 `--operations` 必须合法 JSON,且为非空数组;逐个子操作 `shortcut` / `input` 字段必填校验;**禁止嵌套 `+batch-update`**。`+cells-batch-set-style` 的 `--ranges` 必须 JSON 数组、每项带 sheet 前缀;样式 flag 至少一个非空(或带 `--border-styles`)。`+cells-batch-clear` 的 `--ranges` 同样必须 JSON 数组、每项带 sheet 前缀,`high-risk-write` 强制 `--yes` 或 `--dry-run`(`--scope` 默认 `content`)。 - `DryRun`:按顺序输出每个子操作的目标 API + 请求 body 模板;首个失败则整批 fail-fast(不实际执行任何后续)。 - `Execute`:按声明顺序串行执行;任一子操作失败立即中断并回滚到该子操作前状态(具体回滚能力取决于子操作类型,沿用 MCP `+batch-update` 的语义)。 diff --git a/skills/lark-sheets/references/lark-sheets-range-operations.md b/skills/lark-sheets/references/lark-sheets-range-operations.md index b6733b18d..e5e28a887 100644 --- a/skills/lark-sheets/references/lark-sheets-range-operations.md +++ b/skills/lark-sheets/references/lark-sheets-range-operations.md @@ -204,6 +204,8 @@ _排序条件列表(仅 sort 操作)_ ### `+cells-clear` +> 需要一次清除**多个不连续 range**(如把内容搬走后批量去掉散落各处的边框/底色)时,改用 `lark-sheets-batch-update` 的 `+cells-batch-clear`,避免对 `+cells-clear` 逐个 range 调用。 + ```bash # dry-run 先看 lark-cli sheets +cells-clear --url "..." --sheet-id "$SID" --range "A2:Z1000" --scope all --dry-run From 300a5e890602f65e9c1c96f258c41087cbb28c43 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Thu, 21 May 2026 20:31:58 +0800 Subject: [PATCH 029/114] docs(sheets): sync stdin guidance and sparkline reference - skills/lark-shared/SKILL.md: drop the generic "prefer stdin" section - skills/lark-sheets/SKILL.md: add expanded stdin guidance (use stdin over @file abs paths; don't cd or write into the project dir) - skills/lark-sheets/references/lark-sheets-sparkline.md: document the group_id / sparkline_id two-tier model with worked examples --- skills/lark-shared/SKILL.md | 6 -- skills/lark-sheets/SKILL.md | 12 ++++ .../references/lark-sheets-sparkline.md | 63 ++++++++++++++++++- 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/skills/lark-shared/SKILL.md b/skills/lark-shared/SKILL.md index 007ed46e9..c1ed2bd77 100644 --- a/skills/lark-shared/SKILL.md +++ b/skills/lark-shared/SKILL.md @@ -104,12 +104,6 @@ lark-cli 命令执行后,如果检测到新版本,JSON 输出中会包含 `_ - **写入/删除操作前必须确认用户意图**。 - 用 `--dry-run` 预览危险请求。 -## 复合 JSON / 大入参:优先 stdin - -flag 帮助里标注支持 **Stdin** 的入参,当 payload 较大或含换行 / 引号等特殊字符时,优先用 stdin(`-`)传入,避免命令行超长与 shell 转义问题。 - -`@file` 出于安全只接受 cwd 下的相对路径,传绝对路径会被拒;遇到此限制改用 stdin,不要为它切换目录或把临时文件写进用户项目目录。 - ## 高风险操作的审批协议(exit 10) lark-cli 对高风险写操作(`risk: "high-risk-write"`)有强制确认门禁。当你不带 `--yes` 调用这类命令时,CLI 会退出码 `10`、并在 stderr 返回如下结构化 envelope: diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index ee323e850..3350cba8c 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -67,3 +67,15 @@ metadata: | `--flag-name` | string | 否 | 配合 `--print-schema` 使用,指定要打印 JSON Schema 的 flag 名(不带 `--` 前缀,如 `cells` / `properties` / `operations`)。 | **Agent 使用提示**:写复合 JSON flag(`--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys` 等)时,如果对结构不确定,先跑 `lark-cli sheets --print-schema --flag-name ` 把完整 JSON Schema 读出来再构造 payload,比靠 reference 的速查表更精确,也避免因为字段拼写或缺失被服务端拒绝。reference 的 `## Schemas` 段只给一层结构,深层只能靠 `--print-schema` 或 `## Examples` 的真实示例。 + +## 复合 JSON / 大入参:优先 stdin + +flag 帮助里标注支持 **Stdin** 的入参,当 payload 较大、含换行 / 引号等特殊字符,或已经落在某个文件里时,优先用 stdin(`-`)传入,避免命令行超长与 shell 转义问题。 + +推荐写法:payload 写到 cwd 之外的临时文件(如 `/tmp/cells.json`,不污染用户项目目录),再用 stdin 喂进去: + +```bash +lark-cli sheets +cells-set --url "..." --range "A1:B2" --cells - < /tmp/cells.json +``` + +**`@file` 接绝对路径会被拒,且被拒后不要照报错提示做。** `@file` 出于安全只接受 cwd 下的相对路径,传 `@/tmp/cells.json` 这类绝对路径或 cwd 之外的路径会被拒。此时报错会建议"先 cd 到目标目录,或改用相对路径"——**两条都不要照做**:cd 过去、或把临时文件写进用户项目目录,都会污染工作目录。正解是改用 stdin(`-- - < 文件`)。 diff --git a/skills/lark-sheets/references/lark-sheets-sparkline.md b/skills/lark-sheets/references/lark-sheets-sparkline.md index 0d12b0ffe..8e4b0458c 100644 --- a/skills/lark-sheets/references/lark-sheets-sparkline.md +++ b/skills/lark-sheets/references/lark-sheets-sparkline.md @@ -78,24 +78,81 @@ _创建/更新/部分删除的迷你图属性_ ## Examples -公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。迷你图按 `group_id` 管理——一组同形态的迷你图共享类型 / 样式 / 数据源映射。注意:不等同于已禁用的 `SPARKLINE()` 公式函数。 +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。迷你图用 **两层 id** 管理——`group_id` 选组(一组同形态的迷你图共享类型 / 样式 / 数据源映射),`sparkline_id` 在组内选具体某一项。注意:不等同于已禁用的 `SPARKLINE()` 公式函数。 + +> **何时需要先 `+sparkline-list`:** +> - `+sparkline-update`:**总是**需要——拿到组内每一项的 `sparkline_id`,回填到 `properties.sparklines[i]`,server 用它做映射。 +> - `+sparkline-delete` 删单个 / 部分项:需要——同样要把 `sparkline_id` 填进 `properties.sparklines[i]`。 +> - `+sparkline-delete` 删整组:**不需要** `sparkline_id`,只要 `--group-id`。 ### `+sparkline-list` +```bash +# 列出整张子表的所有迷你图组 +lark-cli sheets +sparkline-list --url "..." --sheet-id "$SID" + +# 钉到单组:返回该组每一项的 sparkline_id(update / partial-delete 必需) +lark-cli sheets +sparkline-list --url "..." --sheet-id "$SID" --group-id "grpA" +``` + ### `+sparkline-create` -> `data_range` 是每个迷你图的数据序列;`target_range` 是迷你图生成的目标 cells(通常每个 cell 一个迷你图)。 +> `--properties` 顶层只有 `config`(同组共享样式,如 `line_width` / `points` / `extremum_max` / `extremum_min`)和 `sparklines`(迷你图项数组)两个字段。`sparklines[i]` 每项必须含 `position`(落点 cell,`row` + `col`)+ `source`(数据 A1 范围,与 `source_range` 二选一);create 时 `sparkline_id` 可省略,由系统生成。 ```bash lark-cli sheets +sparkline-create --url "..." --sheet-id "$SID" --properties @sparkline.json ``` +`sparkline.json` 示例(在 F 列嵌入两行折线迷你图,数据分别来自 A2:E2 和 A3:E3): + +```jsonc +{ + "config": { "line_width": 2 }, + "sparklines": [ + {"position": {"row": 1, "col": "F"}, "source": "Sheet1!A2:E2"}, + {"position": {"row": 2, "col": "F"}, "source": "Sheet1!A3:E3"} + ] +} +``` + ### `+sparkline-update` +> 两步式:先 `+sparkline-list --group-id ` 拿当前组的 `sparkline_id` 列表,再构造 `properties.sparklines[]`——**每项必须带 `sparkline_id`**。只改样式可只传 `properties.config`(不带 `sparklines`,整组样式覆盖式更新)。 + +```bash +# 假设 +sparkline-list 已返回 group_id=grpA,组内 sparkline_id=sl_1 / sl_2 +lark-cli sheets +sparkline-update --url "..." --sheet-id "$SID" --group-id "grpA" --properties '{ + "sparklines": [ + {"sparkline_id":"sl_1","source":"Sheet1!A2:A20"}, + {"sparkline_id":"sl_2","source":"Sheet1!B2:B20"} + ] +}' +``` + ### `+sparkline-delete` +> 两种模式: +> - **删整组**:不传 `--properties`,仅 `--group-id`。删完后该 group 整个清掉。 +> - **删单个 / 部分项**:传 `--properties '{"sparklines":[{"sparkline_id":"..."},...]}'`,每项必须含 `sparkline_id`;删指定项后组内为空会自动清理 group。 +> +> 强制 `--yes` 或 `--dry-run`;先 `--dry-run` 确认要删的目标。 + +```bash +# 删整组 +lark-cli sheets +sparkline-delete --url "..." --sheet-id "$SID" --group-id "grpA" --yes + +# 删组内指定项(先 +sparkline-list 拿到 sparkline_id) +lark-cli sheets +sparkline-delete --url "..." --sheet-id "$SID" --group-id "grpA" \ + --properties '{"sparklines":[{"sparkline_id":"sl_1"}]}' --yes +``` + ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`--properties.type` 必须命中 enum(`line` / `column` / `winLoss`);`--properties.data_range` 与 `--properties.target_range` 行/列数需对齐;`+sparkline-delete` 强制 `--yes` 或 `--dry-run`。 +- `Validate`: + - XOR 公共四件套;`+sparkline-{update,delete}` 必须 `--group-id`。 + - **`+sparkline-update`**:当 `properties.sparklines` 非空时,每一项必须含 `sparkline_id`(CLI 预检,错误信息会指回 `+sparkline-list`,避免命中服务端的不可读拒绝);只传 `properties.config`(config-only update)合法、不触发 sparkline_id 检查。 + - **`+sparkline-delete`**:不传 `--properties` = 删整组(合法路径,不需要 sparkline_id);传 `properties.sparklines` 时每项必须含 `sparkline_id`(server contract;CLI 本地暂未预检 delete 的 partial-item 分支,缺 id 会到 server 端才被拒)。 + - `--properties.type`(仅 create / 整组改样式时)必须命中 enum(`line` / `column` / `winLoss`);`--properties.data_range` 与 `--properties.target_range` 行/列数需对齐。 + - `+sparkline-delete` 强制 `--yes` 或 `--dry-run`。 - `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 sparkline group 请求模板"。 - `Execute`:写后调用 `+sparkline-list --group-id ` 回读,envelope.meta.verification 给出 type / style / 生成范围对比。 From d914c851ac75ef5b30df34d906f21950cd30845e Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Thu, 21 May 2026 19:11:04 +0800 Subject: [PATCH 030/114] fix(sheets): require sparkline_id on +sparkline-update items (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manage_sparkline_object uses two layers of IDs: --group-id picks the sparkline group, and properties.sparklines[i].sparkline_id picks each item inside the group. The server contract requires sparkline_id on every update item (server maps each entry back to an existing sparkline by this id). Agents that called +sparkline-update without the per-item ids hit an opaque server-side rejection that didn't mention sparkline_id at all, then got stuck in a try-fail-list-retry loop. Pre-check CLI-side in objectUpdateInput via a new validateUpdateInput hook on objectCRUDSpec. sparklineSpec wires validateSparklineUpdateItems, which walks properties.sparklines[] and rejects with a message that points at +sparkline-list: +sparkline-update properties.sparklines[N] missing sparkline_id (run `+sparkline-list --group-id ` first to read sparkline_id for each item, then echo each id back on the corresponding update entry) Scope is update-only. config-only updates (properties.config without sparklines) stay legal — the validator skips when sparklines is absent. Delete is not pre-checked: objectDeleteInput doesn't pass properties through, so the partial-delete branch can't be reached today (separate follow-up). Tests: - TestObjectCRUDShortcuts_DryRun: positive case for update with sparkline_id present. - TestSparklineUpdate_MissingSparklineID: standalone path — error contains both "missing sparkline_id" and "+sparkline-list". - TestBatchOp_RejectsBadSubOpInput: batch sub-op missing sparkline_id rejected with the same friendly error. Docs synced from sheet-skill-spec (canonical change committed there): skills/lark-sheets/references/lark-sheets-sparkline.md documents the two-layer id model, the three "+sparkline-list first" cases, and both delete modes. --- shortcuts/sheets/batch_op_contract_test.go | 10 ++++ shortcuts/sheets/lark_sheet_object_crud.go | 60 +++++++++++++++++-- .../sheets/lark_sheet_object_crud_test.go | 43 +++++++++++++ 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/shortcuts/sheets/batch_op_contract_test.go b/shortcuts/sheets/batch_op_contract_test.go index f3b67ac82..3f178006f 100644 --- a/shortcuts/sheets/batch_op_contract_test.go +++ b/shortcuts/sheets/batch_op_contract_test.go @@ -531,6 +531,16 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) { `{"sheet-name":"Sheet1"}`, "+filter-delete requires --sheet-id", }, + // +sparkline-update requires sparkline_id on every + // properties.sparklines[i] (server contract). CLI surfaces this + // with a pointer to +sparkline-list so the agent doesn't have to + // guess the id from an opaque server-side rejection. + { + "+sparkline-update item missing sparkline_id", + "+sparkline-update", + `{"sheet-id":"sh1","group-id":"g1","properties":{"sparklines":[{"position":{"row":0,"col":"A"}}]}}`, + "missing sparkline_id", + }, } for _, tc := range cases { diff --git a/shortcuts/sheets/lark_sheet_object_crud.go b/shortcuts/sheets/lark_sheet_object_crud.go index f1890508d..44e5f8839 100644 --- a/shortcuts/sheets/lark_sheet_object_crud.go +++ b/shortcuts/sheets/lark_sheet_object_crud.go @@ -44,6 +44,13 @@ type objectCRUDSpec struct { // right nesting level. enhanceCreateInput func(rt flagView, input map[string]interface{}) enhanceUpdateInput func(rt flagView, input map[string]interface{}) + // validateUpdateInput, when set, runs after enhanceUpdateInput to + // enforce constraints that span across input fields (e.g. sparkline + // requires properties.sparklines[i] to carry sparkline_id on update — + // a server contract the CLI now surfaces with a pointer to + // +sparkline-list instead of letting the caller hit an opaque + // server-side rejection). + validateUpdateInput func(input map[string]interface{}) error } func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { @@ -189,6 +196,11 @@ func objectUpdateInput(runtime flagView, token, sheetID, sheetName string, spec if spec.enhanceUpdateInput != nil { spec.enhanceUpdateInput(runtime, input) } + if spec.validateUpdateInput != nil { + if err := spec.validateUpdateInput(input); err != nil { + return nil, err + } + } return input, nil } @@ -342,11 +354,51 @@ var CondFormatUpdate = newObjectUpdateShortcut(condFormatSpec) var CondFormatDelete = newObjectDeleteShortcut(condFormatSpec) // sparkline — CLI uses --group-id (higher level) as the object selector. +// Two-layer ID model: --group-id picks the sparkline group; individual +// items inside properties.sparklines[] are addressed by sparkline_id. +// On update the server requires sparkline_id on every item (it's how +// the server maps each entry back to an existing sparkline); +// validateSparklineUpdateItems surfaces that requirement CLI-side with +// a pointer to +sparkline-list instead of letting the caller hit a +// server-side rejection that doesn't mention sparkline_id at all. +// +// (sparkline-delete is intentionally not pre-checked here: +// objectDeleteInput doesn't pass properties through, so the partial- +// delete branch — properties.sparklines: [{sparkline_id}] — silently +// degrades to whole-group delete today. Surfacing that gap is a +// separate fix; this validator stays scoped to update.) +func validateSparklineUpdateItems(input map[string]interface{}) error { + props, _ := input["properties"].(map[string]interface{}) + if props == nil { + return nil + } + raw, ok := props["sparklines"] + if !ok { + return nil // config-only update — fine + } + arr, ok := raw.([]interface{}) + if !ok { + return common.FlagErrorf("+sparkline-update properties.sparklines must be an array") + } + for i, item := range arr { + m, _ := item.(map[string]interface{}) + if m == nil { + return common.FlagErrorf("+sparkline-update properties.sparklines[%d] must be an object", i) + } + id, _ := m["sparkline_id"].(string) + if strings.TrimSpace(id) == "" { + return common.FlagErrorf("+sparkline-update properties.sparklines[%d] missing sparkline_id (run `+sparkline-list --group-id ` first to read sparkline_id for each item, then echo each id back on the corresponding update entry)", i) + } + } + return nil +} + var sparklineSpec = objectCRUDSpec{ - commandPrefix: "+sparkline", - toolName: "manage_sparkline_object", - idFlag: "group-id", - idField: "group_id", + commandPrefix: "+sparkline", + toolName: "manage_sparkline_object", + idFlag: "group-id", + idField: "group_id", + validateUpdateInput: validateSparklineUpdateItems, } var SparklineCreate = newObjectCreateShortcut(sparklineSpec) var SparklineUpdate = newObjectUpdateShortcut(sparklineSpec) diff --git a/shortcuts/sheets/lark_sheet_object_crud_test.go b/shortcuts/sheets/lark_sheet_object_crud_test.go index 00e66449b..5d0728737 100644 --- a/shortcuts/sheets/lark_sheet_object_crud_test.go +++ b/shortcuts/sheets/lark_sheet_object_crud_test.go @@ -216,6 +216,27 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { "properties": map[string]interface{}{"type": "line"}, }, }, + { + // happy path for the new sparkline_id check: each + // properties.sparklines[i] carries sparkline_id, so the + // validator passes through cleanly. + name: "+sparkline-update properties.sparklines[] with sparkline_id passes", + sc: SparklineUpdate, + args: []string{ + "--url", testURL, "--sheet-id", testSheetID, "--group-id", "grpA", + "--properties", `{"sparklines":[{"sparkline_id":"sl1","source":"Sheet1!A1:A10"}]}`, + }, + toolName: "manage_sparkline_object", + wantInput: map[string]interface{}{ + "group_id": "grpA", + "operation": "update", + "properties": map[string]interface{}{ + "sparklines": []interface{}{ + map[string]interface{}{"sparkline_id": "sl1", "source": "Sheet1!A1:A10"}, + }, + }, + }, + }, // float-image — fully hoisted to flat flags { name: "+float-image-create with image-token + position/size", @@ -252,6 +273,28 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { } } +// TestSparklineUpdate_MissingSparklineID confirms the standalone-path +// pre-check fires: +sparkline-update with properties.sparklines[] but no +// per-item sparkline_id must fail CLI-side with a pointer to +// +sparkline-list, before any server call goes out. +func TestSparklineUpdate_MissingSparklineID(t *testing.T) { + t.Parallel() + _, stderr, err := runShortcutCapturingErr(t, SparklineUpdate, []string{ + "--url", testURL, "--sheet-id", testSheetID, "--group-id", "grpA", + "--properties", `{"sparklines":[{"source":"Sheet1!A1:A10"}]}`, + }) + if err == nil { + t.Fatalf("expected CLI to reject missing sparkline_id; stderr=%s", stderr) + } + combined := stderr + err.Error() + if !strings.Contains(combined, "missing sparkline_id") { + t.Errorf("expected error to mention missing sparkline_id; got=%s|%v", stderr, err) + } + if !strings.Contains(combined, "+sparkline-list") { + t.Errorf("expected error to point at +sparkline-list; got=%s|%v", stderr, err) + } +} + // TestObjectDelete_AllHighRisk asserts every delete shortcut blocks // without --yes (framework-enforced). func TestObjectDelete_AllHighRisk(t *testing.T) { From 5a42fb5788a8720a32313aadccf1295eb6d61836 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Thu, 21 May 2026 20:52:13 +0800 Subject: [PATCH 031/114] docs(sheets): sync lark-sheets skill from spec (audit 20260521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull latest spec from sheet-skill-spec (PR ee/sheet-skill-spec!6 + earlier develop commits) into skills/lark-sheets/ and shortcuts/sheets/data/. Audit findings now reflected in CLI docs: - A2 +cond-format-create example: --rule-type duplicate → duplicateValues - A3 +cond-format-create Validate: cellValue/formula → cellIs/expression - A5 +csv-put examples: --range → --start-cell; drop redundant --allow-overwrite - A7 +sparkline-create: Validate / Examples aligned with real schema (config/sparklines), executable JSON example added - B13 cross-doc dead links: lark_sheet_*/cli-shortcuts.md → lark-sheets-*.md - C2 +csv-put: `=` literal warning next to Examples - CC5 +rows-resize/+cols-resize --type auto: single point of truth in range-operations reference flag-defs.json description / required sync (from base): - A4 +float-image-update: image-name/position-*/size-* required → optional (patch mode) - A8 +dim-move --start/--end description cleanup - B3 +pivot-create --properties: data_range → source (real field name) Also picks up the +cells-batch-clear shortcut doc (introduced in spec develop). Go-side implementation for that shortcut is intentionally not in this PR — docs-only preview; runtime dispatch will land in a follow-up. `go test ./shortcuts/sheets/...` passes. --- shortcuts/sheets/data/flag-defs.json | 16 ++++++++-------- .../references/lark-sheets-conditional-format.md | 4 ++-- .../references/lark-sheets-float-image.md | 10 +++++----- .../references/lark-sheets-pivot-table.md | 2 +- .../references/lark-sheets-range-operations.md | 2 +- .../references/lark-sheets-sheet-structure.md | 8 ++++---- .../references/lark-sheets-sparkline.md | 4 ++-- .../references/lark-sheets-write-cells.md | 13 +++++++++++-- 8 files changed, 34 insertions(+), 25 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index 326c3f199..9e50ad71e 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -1119,14 +1119,14 @@ "kind": "own", "type": "int", "required": "required", - "desc": "Source range start position (0-based, inclusive)" + "desc": "Source range start position (0-based)" }, { "name": "end", "kind": "own", "type": "int", "required": "required", - "desc": "Source range end position (0-based, inclusive)" + "desc": "Source range end position (inclusive)" }, { "name": "target", @@ -3241,7 +3241,7 @@ "kind": "own", "type": "string", "required": "required", - "desc": "JSON: {\"rows\":[...],\"columns\":[...],\"values\":[...],\"filters\":[...],\"show_row_grand_total\":true,\"show_col_grand_total\":true} (data source goes through --source; do not put data_range here)", + "desc": "JSON: {\"rows\":[...],\"columns\":[...],\"values\":[...],\"filters\":[...],\"show_row_grand_total\":true,\"show_col_grand_total\":true} (data source goes through --source; do not put source here)", "input": [ "file", "stdin" @@ -4523,7 +4523,7 @@ "name": "image-name", "kind": "own", "type": "string", - "required": "required", + "required": "optional", "desc": "Image name, including extension (e.g. `logo.png`)" }, { @@ -4544,28 +4544,28 @@ "name": "position-row", "kind": "own", "type": "int", - "required": "required", + "required": "optional", "desc": "Row anchor of the image's top-left corner (0-based)" }, { "name": "position-col", "kind": "own", "type": "string", - "required": "required", + "required": "optional", "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", + "required": "optional", "desc": "Image width in pixels" }, { "name": "size-height", "kind": "own", "type": "int", - "required": "required", + "required": "optional", "desc": "Image height in pixels" }, { diff --git a/skills/lark-sheets/references/lark-sheets-conditional-format.md b/skills/lark-sheets/references/lark-sheets-conditional-format.md index 47157f217..8f77ebc70 100644 --- a/skills/lark-sheets/references/lark-sheets-conditional-format.md +++ b/skills/lark-sheets/references/lark-sheets-conditional-format.md @@ -146,7 +146,7 @@ _创建/更新的条件格式属性_ ```bash # 重复值高亮 lark-cli sheets +cond-format-create --url "..." --sheet-id "$SID" \ - --rule-type duplicate --ranges '["A1:A100"]' \ + --rule-type duplicateValues --ranges '["A1:A100"]' \ --properties '{"style":{"back_color":"#FFD7D7"}}' # 数据条 @@ -163,6 +163,6 @@ lark-cli sheets +cond-format-create --url "..." --sheet-id "$SID" \ ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`--rule-type` / `--ranges` 必填;`--properties` 必须能解析为合法 JSON;按 `--rule-type` 检查必填子字段(`cellValue` 需 `attrs.operator` + `attrs.value`、`formula` 需 `attrs.expression`、`colorScale` 需 `min/mid/max` 配色等);`+cond-format-delete` 强制 `--yes` 或 `--dry-run`。 +- `Validate`:XOR 公共四件套;`--rule-type` / `--ranges` 必填;`--properties` 必须能解析为合法 JSON;按 `--rule-type` 检查必填子字段(`cellIs` 需 `attrs.operator` + `attrs.value`、`expression` 需 `attrs.formula`、`colorScale` 需 `min/mid/max` 配色等);`+cond-format-delete` 强制 `--yes` 或 `--dry-run`。 - `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 conditional_format 请求模板"。 - `Execute`:写后调用 `+cond-format-list --rule-id ` 回读,envelope.meta.verification 给出规则 / 范围 / 样式对比。 diff --git a/skills/lark-sheets/references/lark-sheets-float-image.md b/skills/lark-sheets/references/lark-sheets-float-image.md index 83cb373fd..aaa64bef2 100644 --- a/skills/lark-sheets/references/lark-sheets-float-image.md +++ b/skills/lark-sheets/references/lark-sheets-float-image.md @@ -77,13 +77,13 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--float-image-id` | string | required | 目标图片 id | -| `--image-name` | string | required | 图片名称,含扩展名(如 `logo.png`) | +| `--image-name` | string | optional | 图片名称,含扩展名(如 `logo.png`) | | `--image-token` | string | xor | 图片 file_token(与 `--image-uri` 二选一)。常见来源:`+float-image-list` 返回的 `image_token` | | `--image-uri` | string | xor | 图片 reference_id(与 `--image-token` 二选一);形如 `<\|image\|>:abcdef` 这种带前缀的字符串 | -| `--position-row` | int | required | 图片左上角所在行(0-based) | -| `--position-col` | string | required | 图片左上角所在列(列字母,如 `A` / `B`) | -| `--size-width` | int | required | 图片宽度(像素) | -| `--size-height` | int | required | 图片高度(像素) | +| `--position-row` | int | optional | 图片左上角所在行(0-based) | +| `--position-col` | string | optional | 图片左上角所在列(列字母,如 `A` / `B`) | +| `--size-width` | int | optional | 图片宽度(像素) | +| `--size-height` | int | optional | 图片高度(像素) | | `--offset-row` | int | optional | 在 `--position-row` 基础上的行内偏移(像素) | | `--offset-col` | int | optional | 在 `--position-col` 基础上的列内偏移(像素) | | `--z-index` | int | optional | 图片 Z 轴层级,控制重叠顺序 | diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md index 9333d1092..84886b0e7 100644 --- a/skills/lark-sheets/references/lark-sheets-pivot-table.md +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -61,7 +61,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--properties` | string + File + Stdin(复合 JSON) | required | JSON:{"rows":[...],"columns":[...],"values":[...],"filters":[...],"show_row_grand_total":true,"show_col_grand_total":true}(数据源走 --source,不要再放进 properties.data_range) | +| `--properties` | string + File + Stdin(复合 JSON) | required | JSON:{"rows":[...],"columns":[...],"values":[...],"filters":[...],"show_row_grand_total":true,"show_col_grand_total":true}(数据源走 --source,不要再放进 properties.source) | | `--target-sheet-id` | string | optional | 透视表落点子表 id;省略时自动新建子表(推荐) | | `--target-position` | string | optional | 落点起始 cell(A1 格式,如 `A1`),默认 `A1` | | `--source` | string | required | 透视表源数据区域(A1 表示法,格式 `SheetName!StartCell:EndCell`,如 `Sheet1!A1:D100`) | diff --git a/skills/lark-sheets/references/lark-sheets-range-operations.md b/skills/lark-sheets/references/lark-sheets-range-operations.md index e5e28a887..de6b3e764 100644 --- a/skills/lark-sheets/references/lark-sheets-range-operations.md +++ b/skills/lark-sheets/references/lark-sheets-range-operations.md @@ -233,7 +233,7 @@ lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --start 0 --end 0 --t lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --start 0 --end 5 --type standard ``` -> 同时出现在 `lark_sheet_sheet_structure/cli-shortcuts.md` —— 行高 / 列宽调整也算行列结构层动作。 +> 同时出现在 `lark-sheets-sheet-structure.md` —— 行高 / 列宽调整也算行列结构层动作。 ### `+range-move` / `+range-copy` diff --git a/skills/lark-sheets/references/lark-sheets-sheet-structure.md b/skills/lark-sheets/references/lark-sheets-sheet-structure.md index b4c9d86e9..8b6852b02 100644 --- a/skills/lark-sheets/references/lark-sheets-sheet-structure.md +++ b/skills/lark-sheets/references/lark-sheets-sheet-structure.md @@ -140,8 +140,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | -| `--start` | int | required | 源起止区间的起始位置(0-based, inclusive) | -| `--end` | int | required | 源起止区间的结束位置(0-based, inclusive) | +| `--start` | int | required | 源起止区间的起始位置(0-based) | +| `--end` | int | required | 源起止区间的结束位置(inclusive) | | `--target` | int | required | 目标位置(move 到该 index 之前;0-based) | ## Examples @@ -168,7 +168,7 @@ lark-cli sheets +dim-insert --url "https://example.feishu.cn/sheets/shtXXX" \ ### `+rows-resize` / `+cols-resize` -> ⚠️ 这两条 shortcut 来自 `lark-sheets-range-operations` 的 `+rows-resize / +cols-resize` tool(分组在"工作表"是为了发现性)。详细参数和示例在 `lark_sheet_range_operations/cli-shortcuts.md`。 +> ⚠️ 这两条 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`(列宽不支持自动适应)。 @@ -180,6 +180,6 @@ lark-cli sheets +dim-insert --url "https://example.feishu.cn/sheets/shtXXX" \ ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`--start ≤ --end`;`+dim-delete` 强制 `--yes` 或 `--dry-run`;`+rows-resize` / `+cols-resize` 的 `--type` 必填,`--type pixel` 时 `--size` 必填、其它 type 时 `--size` 应省略;`+cols-resize.--type` 不接受 `auto`(详见 `lark_sheet_range_operations/cli-shortcuts.md`)。 +- `Validate`:XOR 公共四件套;`--start ≤ --end`;`+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`。 - `DryRun`:写操作输出"将要 PATCH 的 dimension 区间 + 目标参数"。 - `Execute`:写后自动调用 `+sheet-info --include row_heights,col_widths,hidden_rows,hidden_cols,groups,frozen` 回读对比,envelope.meta.verification 给出受影响的范围。 diff --git a/skills/lark-sheets/references/lark-sheets-sparkline.md b/skills/lark-sheets/references/lark-sheets-sparkline.md index 8e4b0458c..6c972ffd5 100644 --- a/skills/lark-sheets/references/lark-sheets-sparkline.md +++ b/skills/lark-sheets/references/lark-sheets-sparkline.md @@ -152,7 +152,7 @@ lark-cli sheets +sparkline-delete --url "..." --sheet-id "$SID" --group-id "grpA - XOR 公共四件套;`+sparkline-{update,delete}` 必须 `--group-id`。 - **`+sparkline-update`**:当 `properties.sparklines` 非空时,每一项必须含 `sparkline_id`(CLI 预检,错误信息会指回 `+sparkline-list`,避免命中服务端的不可读拒绝);只传 `properties.config`(config-only update)合法、不触发 sparkline_id 检查。 - **`+sparkline-delete`**:不传 `--properties` = 删整组(合法路径,不需要 sparkline_id);传 `properties.sparklines` 时每项必须含 `sparkline_id`(server contract;CLI 本地暂未预检 delete 的 partial-item 分支,缺 id 会到 server 端才被拒)。 - - `--properties.type`(仅 create / 整组改样式时)必须命中 enum(`line` / `column` / `winLoss`);`--properties.data_range` 与 `--properties.target_range` 行/列数需对齐。 + - `--properties` 顶层只接 `config`(同组共享样式)和 `sparklines`(迷你图项数组);`+sparkline-create` 要求每个 `sparklines[i]` 含 `position` 与 `source`(或 `source_range`,二选一)。 - `+sparkline-delete` 强制 `--yes` 或 `--dry-run`。 - `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 sparkline group 请求模板"。 -- `Execute`:写后调用 `+sparkline-list --group-id ` 回读,envelope.meta.verification 给出 type / style / 生成范围对比。 +- `Execute`:写后调用 `+sparkline-list --group-id ` 回读,envelope.meta.verification 给出 `config` / `sparklines` 字段级对比。 diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 7b40657b8..76168d403 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -341,15 +341,24 @@ lark-cli sheets +cells-set-image --url "..." --sheet-name "Sheet1" \ ```bash # 内联 CSV lark-cli sheets +csv-put --url "https://example.feishu.cn/sheets/shtXXX" \ - --sheet-name "Sheet1" --range "A1" --allow-overwrite \ + --sheet-name "Sheet1" --start-cell "A1" \ --csv $'name,score\nalice,95\nbob,87' # 从文件 lark-cli sheets +csv-put --spreadsheet-token shtXXX --sheet-id "$SID" \ - --range "A1" --csv @data.csv --allow-overwrite + --start-cell "A1" --csv @data.csv ``` > `+csv-put` 比 `+cells-set` 短得多——只想批量灌纯值时优先用它。需要公式/样式才换 `+cells-set`。 +> +> ⚠️ `=` 开头的字符串会被当字面量写入(**不会变公式**): +> +> ```bash +> lark-cli sheets +csv-put --url "..." --sheet-name "Sheet1" \ +> --start-cell "A1" \ +> --csv $'name,score\nalice,=SUM(B2:B10)' +> # ↑ A2 实际写入字符串 "=SUM(B2:B10)",**不是公式**。需要写公式请用 +cells-set。 +> ``` ### Validate / DryRun / Execute 约束 From e003d4aa0123c8368e9c46e9520a054b65d7304f Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Fri, 22 May 2026 18:10:45 +0800 Subject: [PATCH 032/114] feat(sheets): add +cells-set --copy-to-range and sync skill spec Sync lark-sheets skill references and flag schemas from upstream sheet-skill-spec, and wire the newly-specced --copy-to-range flag into +cells-set: it passes copy_to_range to the set_cell_range tool so a template block written via --cells fans out across a larger range with auto-shifted formula refs. --- shortcuts/sheets/data/flag-defs.json | 7 + shortcuts/sheets/data/flag-schemas.json | 7656 ++++++++--------- shortcuts/sheets/lark_sheet_write_cells.go | 8 +- .../sheets/lark_sheet_write_cells_test.go | 18 + .../references/lark-sheets-batch-update.md | 16 +- .../references/lark-sheets-chart.md | 18 +- .../lark-sheets-conditional-format.md | 21 +- .../references/lark-sheets-core-operations.md | 58 +- .../references/lark-sheets-filter-view.md | 12 +- .../references/lark-sheets-filter.md | 15 +- .../references/lark-sheets-float-image.md | 10 +- .../references/lark-sheets-pivot-table.md | 32 +- .../lark-sheets-range-operations.md | 35 +- .../references/lark-sheets-read-data.md | 6 +- .../references/lark-sheets-search-replace.md | 2 +- .../references/lark-sheets-sheet-structure.md | 31 +- .../references/lark-sheets-sparkline.md | 8 +- .../lark-sheets-visual-standards.md | 18 +- .../references/lark-sheets-workbook.md | 21 +- .../references/lark-sheets-write-cells.md | 69 +- 20 files changed, 4077 insertions(+), 3984 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index 9e50ad71e..a2272865c 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -1610,6 +1610,13 @@ "default": "50000", "hidden": true }, + { + "name": "copy-to-range", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Copy-to range (A1 notation): replicate what --cells wrote into --range (values/formulas/styles, per the fields actually passed) to this range; formula refs auto-shift (C2=B2 -> C3=B3). Write a one-row/one-block template then fill a whole column/area. Supports full rows '3:6', full columns 'C:E', to-col-end 'D3:D', to-row-end 'D3:3', and comma-separated multiple targets like 'C1:D2,E5:F6'." + }, { "name": "dry-run", "kind": "system", diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json index 9ceeca13c..0ae0b2913 100644 --- a/shortcuts/sheets/data/flag-schemas.json +++ b/shortcuts/sheets/data/flag-schemas.json @@ -79,451 +79,393 @@ }, "+cells-batch-set-style": { "border-styles": { + "type": "object", "description": "单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)。", "properties": { - "bottom": { + "top": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } }, - "left": { + "bottom": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } }, - "right": { + "left": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } }, - "top": { + "right": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } } - }, - "type": "object" + } } }, "+cells-set": { "cells": { + "type": "object", "properties": { + "value": { + "description": "静态单元格值(文本、数字、布尔)。公式请优先使用 'formula' 字段;如果误把以 '=' 开头的公式字符串写到这里,工具会按 Excel 语义自动识别为公式入库,但仍应按 'formula' 字段的契约传参。", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + }, + "formula": { + "description": "以 '=' 开头的单元格公式(例如:'=SUM(A1:A10)')。公式必须写在此字段,而不是 'value'。", + "type": "string" + }, + "note": { + "description": "单元格批注/备注", + "type": "string" + }, + "cell_styles": { + "type": "object", + "properties": { + "font_color": { + "description": "字体颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, + "font_size": { + "description": "字体大小(单位:px/像素,例如 10、12、14)", + "type": "number" + }, + "font_weight": { + "description": "字重", + "type": "string", + "enum": [ + "normal", + "bold" + ] + }, + "font_style": { + "description": "字体样式", + "type": "string", + "enum": [ + "normal", + "italic" + ] + }, + "font_line": { + "description": "字体线条样式", + "type": "string", + "enum": [ + "none", + "underline", + "line-through" + ] + }, + "background_color": { + "description": "背景颜色(十六进制,例如 \"#ffffff\")", + "type": "string" + }, + "horizontal_alignment": { + "description": "水平对齐方式", + "type": "string", + "enum": [ + "left", + "center", + "right" + ] + }, + "vertical_alignment": { + "description": "垂直对齐方式", + "type": "string", + "enum": [ + "top", + "middle", + "bottom" + ] + }, + "number_format": { + "description": "数字格式(例如:文本用 \"@\"、数字用 \"0.00\"、货币用 \"$#,##0.00\"、日期用 \"mm/dd/yyyy\")", + "type": "string" + }, + "word_wrap": { + "description": "是否自动换行,默认溢出,可选自动换行或裁剪。", + "type": "string", + "enum": [ + "overflow", + "auto-wrap", + "word-clip" + ] + } + }, + "description": "单元格样式属性,包括字体、颜色、对齐方式和数字格式" + }, "border_styles": { + "type": "object", "description": "单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)。", "properties": { - "bottom": { + "top": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } }, - "left": { + "bottom": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } }, - "right": { + "left": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } }, - "top": { + "right": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } } - }, - "type": "object" - }, - "cell_styles": { - "description": "单元格样式属性,包括字体、颜色、对齐方式和数字格式", - "properties": { - "background_color": { - "description": "背景颜色(十六进制,例如 \"#ffffff\")", - "type": "string" - }, - "font_color": { - "description": "字体颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, - "font_line": { - "description": "字体线条样式", - "enum": [ - "none", - "underline", - "line-through" - ], - "type": "string" - }, - "font_size": { - "description": "字体大小(单位:px/像素,例如 10、12、14)", - "type": "number" - }, - "font_style": { - "description": "字体样式", - "enum": [ - "normal", - "italic" - ], - "type": "string" - }, - "font_weight": { - "description": "字重", - "enum": [ - "normal", - "bold" - ], - "type": "string" - }, - "horizontal_alignment": { - "description": "水平对齐方式", - "enum": [ - "left", - "center", - "right" - ], - "type": "string" - }, - "number_format": { - "description": "数字格式(例如:文本用 \"@\"、数字用 \"0.00\"、货币用 \"$#,##0.00\"、日期用 \"mm/dd/yyyy\")", - "type": "string" - }, - "vertical_alignment": { - "description": "垂直对齐方式", - "enum": [ - "top", - "middle", - "bottom" - ], - "type": "string" - }, - "word_wrap": { - "description": "是否自动换行,默认溢出,可选自动换行或裁剪。", - "enum": [ - "overflow", - "auto-wrap", - "word-clip" - ], - "type": "string" - } - }, - "type": "object" - }, - "data_validation": { - "description": "数据验证配置。设为 null 可清除已有的数据验证。", - "properties": { - "help_text": { - "description": "验证失败时显示的提示文本", - "type": "string" - }, - "items": { - "description": "列表选项(type='list' 时必填)", - "items": { - "type": "string" - }, - "type": "array" - }, - "operator": { - "description": "比较运算符(type='number'/'date'/'textLength' 时必填)", - "enum": [ - "equal", - "notEqual", - "greaterThan", - "greaterThanOrEqual", - "lessThan", - "lessThanOrEqual", - "between", - "notBetween" - ], - "type": "string" - }, - "range": { - "description": "源数据区域(type='listFromRange' 时必填,格式:'SheetName!A1:A10')", - "type": "string" - }, - "support_multiple_values": { - "description": "列表验证是否支持多选(type='list'/'listFromRange' 时可选,默认 false)", - "type": "boolean" - }, - "type": { - "description": "数据验证类型:list(下拉列表)、listFromRange(引用范围下拉列表)、number(数字)、date(日期)、textLength(文本长度)、checkbox(复选框)", - "enum": [ - "list", - "listFromRange", - "number", - "date", - "textLength", - "checkbox" - ], - "type": "string" - }, - "values": { - "description": "比较值(operator 为 'between'/'notBetween' 时需要两个值,其它运算符需要一个值)", - "items": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "type": "array" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - "formula": { - "description": "以 '=' 开头的单元格公式(例如:'=SUM(A1:A10)')。公式必须写在此字段,而不是 'value'。", - "type": "string" - }, - "multiple_values": { - "description": "多值内容,用于支持多选的列表验证单元格。设置后会忽略 value 和 rich_text 字段。", - "items": { - "properties": { - "format": { - "description": "可选的数字格式(例如 '$#,##0.00')", - "type": "string" - }, - "value": { - "description": "值(文本、数字、布尔)", - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ] - } - }, - "required": [ - "value" - ], - "type": "object" - }, - "type": "array" - }, - "note": { - "description": "单元格批注/备注", - "type": "string" + } }, "rich_text": { "description": "富文本内容。设置后会忽略 value 字段。可包含带样式的文本段 text、超链接 link、@提及 mention、单元格图片 embed-image、附件 attachment。", + "type": "array", "items": { + "type": "object", "properties": { - "attachment_name": { - "description": "附件文件名称(仅 type='attachment' 时使用,配合 attachment_reference_id 使用,创建新附件时必填)", + "type": { + "description": "段类型", + "type": "string", + "enum": [ + "text", + "link", + "mention", + "embed-image", + "attachment" + ] + }, + "text": { + "description": "显示文本", "type": "string" }, - "attachment_token": { - "description": "附件文件 token,通过飞书 Drive 上传获取(仅 type='attachment' 时可选,修改已有附件时可从 get_cell_range 获取)", + "style": { + "description": "文本段样式(仅 type='text' 时生效),结构同 cell_styles", + "type": "object" + }, + "link": { + "description": "超链接地址(仅 type='link' 时必填)", "type": "string" }, - "attachment_uri": { - "description": "附件文件 reference_id(仅 type='attachment' 时使用,与 attachment_token 二选一,如`<|attachment|>:abcdef` 或者 `<|superscript|>:abcdef-<|attachment|>:abcdef`,其中 `abcdef` 为实际的对象 ID,占位符仅用于示意)", + "mention_token": { + "description": "@提及目标的 token,如 userId 或 fileId(仅 type='mention' 时必填)", "type": "string" }, - "file_size": { - "description": "附件文件大小(字节,仅 type='attachment' 时使用)", + "mention_type": { + "description": "@提及类型编号(仅 type='mention' 时可选)", + "type": "number" + }, + "notify": { + "description": "是否发送通知(仅 type='mention' 时可选,默认 true)", + "type": "boolean" + }, + "image_width": { + "description": "图片宽度(像素,仅 type='embed-image' 时使用)", "type": "number" }, "image_height": { @@ -534,788 +476,819 @@ "description": "图片名称(仅 type='embed-image' 时使用,创建新图片时必填)", "type": "string" }, - "image_token": { - "description": "图片文件 token(仅 type='embed-image' 时可选,修改已有图片时可从 get_cell_range 获取)", - "type": "string" - }, "image_uri": { "description": "图片文件 reference_id(仅 type='embed-image' 时使用,与 image_token 二选一,如`<|image|>:abcdef` 或者 `<|superscript|>:abcdef-<|image|>:abcdef`,其中 `abcdef` 为实际的对象 ID,占位符仅用于示意)", "type": "string" }, - "image_width": { - "description": "图片宽度(像素,仅 type='embed-image' 时使用)", - "type": "number" + "image_token": { + "description": "图片文件 token(仅 type='embed-image' 时可选,修改已有图片时可从 get_cell_range 获取)", + "type": "string" }, - "link": { - "description": "超链接地址(仅 type='link' 时必填)", + "attachment_token": { + "description": "附件文件 token,通过飞书 Drive 上传获取(仅 type='attachment' 时可选,修改已有附件时可从 get_cell_range 获取)", "type": "string" }, - "mention_token": { - "description": "@提及目标的 token,如 userId 或 fileId(仅 type='mention' 时必填)", + "attachment_uri": { + "description": "附件文件 reference_id(仅 type='attachment' 时使用,与 attachment_token 二选一,如`<|attachment|>:abcdef` 或者 `<|superscript|>:abcdef-<|attachment|>:abcdef`,其中 `abcdef` 为实际的对象 ID,占位符仅用于示意)", "type": "string" }, - "mention_type": { - "description": "@提及类型编号(仅 type='mention' 时可选)", - "type": "number" + "attachment_name": { + "description": "附件文件名称(仅 type='attachment' 时使用,配合 attachment_reference_id 使用,创建新附件时必填)", + "type": "string" }, "mime_type": { "description": "附件 MIME 类型(仅 type='attachment' 时使用,例如 'application/pdf')", "type": "string" }, - "notify": { - "description": "是否发送通知(仅 type='mention' 时可选,默认 true)", - "type": "boolean" - }, - "style": { - "description": "文本段样式(仅 type='text' 时生效),结构同 cell_styles", - "type": "object" - }, - "text": { - "description": "显示文本", - "type": "string" - }, - "type": { - "description": "段类型", - "enum": [ - "text", - "link", - "mention", - "embed-image", - "attachment" - ], - "type": "string" + "file_size": { + "description": "附件文件大小(字节,仅 type='attachment' 时使用)", + "type": "number" } }, "required": [ "type", "text" - ], - "type": "object" - }, - "type": "array" + ] + } }, - "value": { - "description": "静态单元格值(文本、数字、布尔)。公式请优先使用 'formula' 字段;如果误把以 '=' 开头的公式字符串写到这里,工具会按 Excel 语义自动识别为公式入库,但仍应按 'formula' 字段的契约传参。", - "oneOf": [ - { - "type": "string" + "multiple_values": { + "description": "多值内容,用于支持多选的列表验证单元格。设置后会忽略 value 和 rich_text 字段。", + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "description": "值(文本、数字、布尔)", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + }, + "format": { + "description": "可选的数字格式(例如 '$#,##0.00')", + "type": "string" + } }, - { - "type": "number" + "required": [ + "value" + ] + } + }, + "data_validation": { + "description": "数据验证配置。设为 null 可清除已有的数据验证。", + "type": "object", + "properties": { + "type": { + "description": "数据验证类型:list(下拉列表)、listFromRange(引用范围下拉列表)、number(数字)、date(日期)、textLength(文本长度)、checkbox(复选框)", + "type": "string", + "enum": [ + "list", + "listFromRange", + "number", + "date", + "textLength", + "checkbox" + ] }, - { + "items": { + "description": "列表选项", + "type": "array", + "items": { + "type": "string" + } + }, + "range": { + "description": "源数据区域(type='listFromRange' 时必填,格式:'SheetName!A1:A10')", + "type": "string" + }, + "operator": { + "description": "比较运算符(type='number'/'date'/'textLength' 时必填)", + "type": "string", + "enum": [ + "equal", + "notEqual", + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual", + "between", + "notBetween" + ] + }, + "values": { + "description": "比较值(operator 为 'between'/'notBetween' 时需要两个值,其它运算符需要一个值)", + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "support_multiple_values": { + "description": "列表验证是否支持多选(type='list'/'listFromRange' 时可选,默认 false)", "type": "boolean" + }, + "help_text": { + "description": "验证失败时显示的提示文本", + "type": "string" } + }, + "required": [ + "type" ] } - }, - "type": "object" + } } }, "+cells-set-style": { "border-styles": { + "type": "object", "description": "单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)。", "properties": { - "bottom": { + "top": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } }, - "left": { + "bottom": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } }, - "right": { + "left": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } }, - "top": { + "right": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } } - }, - "type": "object" + } } }, "+chart-create": { "properties": { - "additionalProperties": {}, "description": "创建/更新的图表属性。", + "type": "object", "properties": { - "offset": { - "additionalProperties": false, - "description": "可选。图表在位置基础上的偏移量(像素)。", - "properties": { - "col_offset": { - "description": "列偏移量(像素)", - "type": "number" - }, - "row_offset": { - "description": "行偏移量(像素)", - "type": "number" - } - }, - "type": "object" - }, "position": { - "additionalProperties": false, + "type": "object", "description": "必填。图表在表格中的单元格位置。注意:选择位置时应避免覆盖已有数据的单元格,并确保图表不超出当前表格的行列范围。", "properties": { - "col": { - "description": "列索引,例如 \"A\"、\"B\"", - "type": "string" - }, "row": { - "description": "行索引(0-based)", + "type": "number", "minimum": 0, - "type": "number" + "description": "行索引(0-based)" + }, + "col": { + "type": "string", + "description": "列索引,例如 \"A\"、\"B\"" } }, "required": [ "row", "col" ], - "type": "object" + "additionalProperties": false + }, + "offset": { + "type": "object", + "description": "可选。图表在位置基础上的偏移量(像素)。", + "properties": { + "row_offset": { + "type": "number", + "description": "行偏移量(像素)" + }, + "col_offset": { + "type": "number", + "description": "列偏移量(像素)" + } + }, + "additionalProperties": false }, "size": { - "additionalProperties": false, + "type": "object", "description": "必填。图表大小(像素)。注意:设定大小时应确保图表不超出当前表格的行列范围,并避免覆盖已有数据的单元格。", "properties": { - "height": { - "description": "高度(像素)", + "width": { + "type": "number", "minimum": 10, - "type": "number" + "description": "宽度(像素)" }, - "width": { - "description": "宽度(像素)", + "height": { + "type": "number", "minimum": 10, - "type": "number" + "description": "高度(像素)" } }, "required": [ "width", "height" ], - "type": "object" + "additionalProperties": false }, "snapshot": { + "type": "object", "description": "图表快照配置。更新图表时必须传入完整的图表属性定义,不能只传修改的部分。应先通过 get_chart_objects 获取当前图表快照,修改需要变更的字段后,将完整快照传入。", "properties": { - "data": { - "description": "图表数据配置", + "title": { + "type": "object", + "description": "图表标题配置", "properties": { - "dim1": { - "description": "维度1配置(类别维度)", - "properties": { - "field": { - "description": "字段配置(静态数据时传此参数)", - "properties": { - "name": { - "description": "字段名称", - "type": "string" - }, - "text": { - "description": "字段文本数据", - "type": "string" - }, - "valueType": { - "description": "值类型", - "enum": [ - "number", - "string" - ], - "type": "string" - } - }, - "required": [ - "text" - ], - "type": "object" - }, - "serie": { - "description": "系列配置(非静态数据时传此参数)", - "properties": { - "aggregate": { - "description": "是否汇总相同的类别,默认为true,true 情况下 aggregateType 才生效", - "type": "boolean" - }, - "index": { - "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引", - "type": "number" - }, - "nameRef": { - "description": "可选。维度名称的单元格引用(A1 表示法,如 'Sheet1!A1')。当维度名称不直接来自 refs 首行/首列时,可通过此字段把维度名显式指向目标表头单元格,图表会以该单元格当前值作为类别维度名展示。", - "type": "string" - } - }, - "type": "object" - } - }, - "type": "object" - }, - "dim2": { - "description": "维度2配置(值维度)", - "properties": { - "fields": { - "description": "字段配置数组(静态数据时传此参数)", - "items": { - "properties": { - "name": { - "description": "字段名称", - "type": "string" - }, - "text": { - "description": "字段文本数据", - "type": "string" - }, - "valueType": { - "description": "值类型", - "enum": [ - "number" - ], - "type": "string" - } - }, - "required": [ - "text" - ], - "type": "object" - }, - "type": "array" - }, - "series": { - "description": "系列配置数组(非静态数据时传此参数)", - "items": { - "properties": { - "aggregateType": { - "description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效", - "enum": [ - "sum", - "average", - "count", - "min", - "max", - "median" - ], - "type": "string" - }, - "index": { - "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引", - "type": "number" - }, - "nameRef": { - "description": "可选。系列名称的单元格引用(A1 表示法,如 'Sheet1!C1')。当系列名称不直接来自 refs 首行/首列时,可通过此字段把系列名显式指向目标表头单元格,图表会以该单元格当前值作为系列名(图例/tooltip)展示。", - "type": "string" - } - }, - "type": "object" - }, - "type": "array" - } - }, - "type": "object" + "text": { + "type": "string", + "description": "标题文本" }, - "direction": { - "description": "数据方向,可选值:row(行方向)或column(列方向),常用值为column", - "enum": [ - "row", - "column" - ], - "type": "string" + "textAlign": { + "type": "string", + "description": "标题对齐方式", + "enum": [ + "left", + "center", + "right" + ] }, - "headerMode": { - "description": "表头模式。inline 表示 refs 首行/首列就是表头;detached 表示 refs 仅覆盖实际数据范围,维度名和系列名需通过 nameRef 显式指定。", + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" + } + }, + "required": [ + "text" + ] + }, + "subTitle": { + "type": "object", + "description": "图表副标题配置", + "properties": { + "text": { + "type": "string", + "description": "副标题文本" + }, + "textAlign": { + "type": "string", + "description": "副标题对齐方式", "enum": [ - "inline", - "detached" - ], - "type": "string" + "left", + "center", + "right" + ] }, - "includeHiddenOrFilter": { - "description": "是否包含隐藏或过滤的数据", - "type": "boolean" + "fontSize": { + "type": "number", + "description": "字体大小" }, - "isStaticData": { - "description": "是否为静态数据", - "type": "boolean" + "bold": { + "type": "boolean", + "description": "是否加粗" }, - "refs": { - "description": "数据源引用范围数组", - "items": { - "properties": { - "value": { - "description": "图表数据源范围,格式为 'SheetName!A1:D100' 的连续区域", - "type": "string" - } - }, - "required": [ - "value" - ], - "type": "object" - }, - "type": "array" + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" } }, - "type": "object" + "required": [ + "text" + ] }, - "legend": { - "oneOf": [ - { - "description": "图例配置", + "style": { + "type": "object", + "description": "图表样式配置", + "properties": { + "background": { + "type": "object", + "description": "背景配置", "properties": { - "bold": { - "description": "是否加粗", - "type": "boolean" + "color": { + "type": "string", + "description": "背景颜色,格式为 #RRGGBB" + } + } + }, + "font": { + "type": "object", + "description": "字体配置", + "properties": { + "size": { + "type": "number", + "description": "字体大小" }, "color": { - "description": "字体颜色,格式为 #RRGGBB", - "type": "string" + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" + } + } + }, + "border": { + "type": "object", + "description": "边框配置", + "properties": { + "color": { + "type": "string", + "description": "边框颜色,格式为 #RRGGBB" }, - "fontSize": { - "description": "字体大小", - "type": "number" + "width": { + "type": "number", + "description": "边框宽度" }, - "italic": { - "description": "是否斜体", - "type": "boolean" + "style": { + "type": "string", + "description": "边框样式", + "enum": [ + "solid", + "dashed", + "dotted" + ] + }, + "radius": { + "type": "number", + "description": "边框圆角" + } + } + }, + "colorTheme": { + "type": "array", + "description": "颜色主题配置。支持两种模式:1. 仅传一个预设主题名称(字符串数组,长度为1);2. 传多个自定义十六进制颜色字符串。预设主题:brandColorSeries@v2、rainbowColorSeries@v2、complementaryColorSeries@v2、converseColorSeries@v2、primaryColorSeries@v2、singleColorSeries-B-@v2、singleColorSeries-W-@v2、singleColorSeries-G-@v2、singleColorSeries-Y-@v2、singleColorSeries-O-@v2、singleColorSeries-R-@v2、singleColorSeries-D-@v2。十六进制格式:#RRGGBB", + "oneOf": [ + { + "minItems": 1, + "maxItems": 1, + "items": { + "type": "string", + "enum": [ + "brandColorSeries@v2", + "rainbowColorSeries@v2", + "complementaryColorSeries@v2", + "converseColorSeries@v2", + "primaryColorSeries@v2", + "singleColorSeries-B-@v2", + "singleColorSeries-W-@v2", + "singleColorSeries-G-@v2", + "singleColorSeries-Y-@v2", + "singleColorSeries-O-@v2", + "singleColorSeries-R-@v2", + "singleColorSeries-D-@v2" + ] + } }, + { + "minItems": 2, + "items": { + "type": "string", + "description": "颜色字符串,十六进制格式:#RRGGBB" + } + } + ] + }, + "colorGradient": { + "type": "boolean", + "description": "是否启用颜色渐变" + } + } + }, + "legend": { + "oneOf": [ + { + "type": "object", + "description": "图例配置", + "properties": { "position": { + "type": "string", "description": "图例位置", "enum": [ "top", "bottom", "left", "right" - ], - "type": "string" + ] }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" }, "underline": { - "description": "是否下划线", - "type": "boolean" + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" } - }, - "type": "object" + } }, { - "description": "false 表示隐藏图例", - "type": "boolean" + "type": "boolean", + "description": "false 表示隐藏图例" } ] }, "plotArea": { + "type": "object", "description": "绘图区域配置", "properties": { - "axes": { - "description": "坐标轴配置数组", - "items": { - "description": "坐标轴配置", - "properties": { - "axisLine": { - "description": "是否显示轴线", - "type": "boolean" - }, - "gridLine": { - "oneOf": [ - { - "description": "网格线配置", + "plot": { + "type": "object", + "description": "绘图配置", + "properties": { + "type": { + "type": "string", + "description": "图表类型", + "enum": [ + "bar", + "column", + "line", + "area", + "combo", + "pie", + "radar", + "scatter" + ] + }, + "comboType": { + "type": "string", + "description": "组合图表默认类型", + "enum": [ + "column", + "line", + "area" + ] + }, + "yAxisPosition": { + "type": "string", + "description": "Y轴位置", + "enum": [ + "left", + "right" + ] + }, + "extra": { + "type": "object", + "description": "额外配置", + "properties": { + "smooth": { + "type": "boolean", + "description": "是否平滑曲线" + }, + "step": { + "type": "boolean", + "description": "是否阶梯图" + }, + "stack": { + "type": "object", + "description": "堆叠配置", + "properties": { + "percentage": { + "type": "boolean", + "description": "是否百分比堆叠" + } + } + }, + "radar": { + "type": "object", + "description": "雷达图配置", + "properties": { + "shape": { + "type": "string", + "description": "雷达图形状", + "enum": [ + "polygon", + "circle" + ] + }, + "area": { + "type": "boolean", + "description": "是否填充区域" + } + } + } + } + }, + "points": { + "type": "object", + "description": "全系列数据点配置,折线图、面积图、雷达图、散点图、组合图生效。", + "properties": { + "color": { + "type": "string", + "description": "数据点颜色" + }, + "shape": { + "type": "string", + "description": "数据点形状", + "enum": [ + "circle", + "triangle", + "rect", + "diamond", + "square" + ] + }, + "size": { + "type": "number", + "description": "数据点大小" + }, + "point": { + "type": "array", + "description": "单个数据点配置数组", + "items": { + "type": "object", "properties": { + "index": { + "type": "number", + "description": "数据点索引" + }, "color": { - "description": "网格线颜色", - "type": "string" + "type": "string", + "description": "颜色" }, - "width": { - "description": "网格线宽度", - "type": "number" + "shape": { + "type": "string", + "description": "形状" + }, + "size": { + "type": "number", + "description": "大小" } }, - "type": "object" - }, - { - "description": "false 表示隐藏网格线", - "type": "boolean" - } - ] - }, - "label": { - "description": "坐标轴标签配置", - "properties": { - "angle": { - "description": "旋转角度,可选值:-90, -45, 0, 45, 90", - "type": "number" - }, - "bold": { - "description": "是否加粗", - "type": "boolean" - }, - "color": { - "description": "字体颜色", - "type": "string" - }, - "fontSize": { - "description": "字体大小", - "type": "number" - }, - "italic": { - "description": "是否斜体", - "type": "boolean" - }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" - }, - "underline": { - "description": "是否下划线", - "type": "boolean" + "required": [ + "index" + ] } + } + } + }, + "lines": { + "type": "object", + "description": "全系列线条配置,折线图、面积图、雷达图、组合图生效。", + "properties": { + "color": { + "type": "string", + "description": "线条颜色" }, - "type": "object" - }, - "max": { - "description": "最大值", - "type": "number" - }, - "min": { - "description": "最小值", - "type": "number" - }, - "position": { - "description": "坐标轴位置", - "enum": [ - "left", - "right", - "bottom" - ], - "type": "string" - }, - "title": { - "description": "坐标轴标题配置", - "properties": { - "bold": { - "description": "是否加粗", - "type": "boolean" - }, - "color": { - "description": "字体颜色", - "type": "string" - }, - "fontSize": { - "description": "字体大小", - "type": "number" - }, - "italic": { - "description": "是否斜体", - "type": "boolean" - }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" - }, - "text": { - "description": "标题文本", - "type": "string" - }, - "underline": { - "description": "是否下划线", - "type": "boolean" - } + "width": { + "type": "number", + "description": "线条宽度" }, - "required": [ - "text" - ], - "type": "object" - }, - "type": { - "description": "坐标轴类型", - "enum": [ - "x", - "y", - "angle", - "radius" - ], - "type": "string" - }, - "valueType": { - "description": "是否将横轴数字视为文本,linear 表示将数字视为数值, ordinal 表示将数字视为文本", - "enum": [ - "ordinal", - "linear" - ], - "type": "string" + "style": { + "type": "string", + "description": "线条样式", + "enum": [ + "solid", + "dashed", + "dotted" + ] + }, + "invalidType": { + "type": "string", + "description": "无效值处理方式", + "enum": [ + "break", + "zero", + "link" + ] + } } }, - "required": [ - "type" - ], - "type": "object" - }, - "type": "array" - }, - "plot": { - "description": "绘图配置", - "properties": { "areas": { + "type": "object", "description": "全系列面积填充配置,面积图、雷达图、组合图生效。", "properties": { "color": { - "description": "区域填充颜色", - "type": "string" + "type": "string", + "description": "区域填充颜色" } - }, - "type": "object" + } }, "bars": { + "type": "object", "description": "全系列柱状图、条形图、组合图生效。", "properties": { - "backgroundColor": { - "description": "背景颜色", - "type": "string" - }, - "bar": { - "description": "单个柱子配置数组", - "items": { - "properties": { - "borderColor": { - "description": "边框颜色", - "type": "string" - }, - "borderStyle": { - "description": "边框样式", - "type": "string" - }, - "borderWidth": { - "description": "边框宽度", - "type": "number" - }, - "color": { - "description": "颜色", - "type": "string" - }, - "index": { - "description": "柱子索引", - "type": "number" - } - }, - "required": [ - "index" - ], - "type": "object" - }, - "type": "array" + "color": { + "type": "string", + "description": "柱子颜色" }, "borderColor": { - "description": "边框颜色", - "type": "string" + "type": "string", + "description": "边框颜色" + }, + "borderWidth": { + "type": "number", + "description": "边框宽度" }, "borderStyle": { + "type": "string", "description": "边框样式", "enum": [ "solid", "dashed", "dotted" - ], - "type": "string" - }, - "borderWidth": { - "description": "边框宽度", - "type": "number" - }, - "color": { - "description": "柱子颜色", - "type": "string" - }, - "gap": { - "description": "柱子间距比例,0-1之间", - "type": "number" + ] }, "width": { - "description": "柱子宽度", - "type": "number" - } - }, - "type": "object" - }, - "comboType": { - "description": "组合图表默认类型", - "enum": [ - "column", - "line", - "area" - ], - "type": "string" - }, - "extra": { - "description": "额外配置", - "properties": { - "radar": { - "description": "雷达图配置", - "properties": { - "area": { - "description": "是否填充区域", - "type": "boolean" - }, - "shape": { - "description": "雷达图形状", - "enum": [ - "polygon", - "circle" - ], - "type": "string" - } - }, - "type": "object" + "type": "number", + "description": "柱子宽度" }, - "smooth": { - "description": "是否平滑曲线", - "type": "boolean" + "gap": { + "type": "number", + "description": "柱子间距比例,0-1之间" }, - "stack": { - "description": "堆叠配置", - "properties": { - "percentage": { - "description": "是否百分比堆叠", - "type": "boolean" - } - }, - "type": "object" + "backgroundColor": { + "type": "string", + "description": "背景颜色" }, - "step": { - "description": "是否阶梯图", - "type": "boolean" + "bar": { + "type": "array", + "description": "单个柱子配置数组", + "items": { + "type": "object", + "properties": { + "index": { + "type": "number", + "description": "柱子索引" + }, + "color": { + "type": "string", + "description": "颜色" + }, + "borderColor": { + "type": "string", + "description": "边框颜色" + }, + "borderWidth": { + "type": "number", + "description": "边框宽度" + }, + "borderStyle": { + "type": "string", + "description": "边框样式" + } + }, + "required": [ + "index" + ] + } } - }, - "type": "object" + } }, "labels": { + "type": "object", "description": "数据标签配置", "properties": { - "bold": { - "description": "是否加粗", - "type": "boolean" - }, - "category": { - "description": "是否显示类别名", - "type": "boolean" - }, - "color": { - "description": "字体颜色", - "type": "string" - }, - "fontSize": { - "description": "字体大小", - "type": "number" - }, - "italic": { - "description": "是否斜体", - "type": "boolean" - }, - "percentage": { - "description": "是否显示百分比", - "type": "boolean" - }, "position": { + "type": "string", "description": "标签位置", "enum": [ "auto", @@ -1326,1483 +1299,1571 @@ "center", "inside", "outside" - ], - "type": "string" + ] }, "series": { - "description": "是否显示系列名", - "type": "boolean" - }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" + "type": "boolean", + "description": "是否显示系列名" }, - "underline": { - "description": "是否下划线", - "type": "boolean" + "category": { + "type": "boolean", + "description": "是否显示类别名" }, "value": { - "description": "是否显示值", - "type": "boolean" - } - }, - "type": "object" - }, - "lines": { - "description": "全系列线条配置,折线图、面积图、雷达图、组合图生效。", - "properties": { - "color": { - "description": "线条颜色", - "type": "string" + "type": "boolean", + "description": "是否显示值" }, - "invalidType": { - "description": "无效值处理方式", - "enum": [ - "break", - "zero", - "link" - ], - "type": "string" + "percentage": { + "type": "boolean", + "description": "是否显示百分比" }, - "style": { - "description": "线条样式", - "enum": [ - "solid", - "dashed", - "dotted" - ], - "type": "string" + "fontSize": { + "type": "number", + "description": "字体大小" }, - "width": { - "description": "线条宽度", - "type": "number" + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色" } - }, - "type": "object" - }, - "points": { - "description": "全系列数据点配置,折线图、面积图、雷达图、散点图、组合图生效。", - "properties": { - "color": { - "description": "数据点颜色", - "type": "string" - }, - "point": { - "description": "单个数据点配置数组", - "items": { - "properties": { - "color": { - "description": "颜色", - "type": "string" - }, - "index": { - "description": "数据点索引", - "type": "number" - }, - "shape": { - "description": "形状", - "type": "string" - }, - "size": { - "description": "大小", - "type": "number" - } - }, - "required": [ - "index" - ], - "type": "object" - }, - "type": "array" - }, - "shape": { - "description": "数据点形状", - "enum": [ - "circle", - "triangle", - "rect", - "diamond", - "square" - ], - "type": "string" - }, - "size": { - "description": "数据点大小", - "type": "number" - } - }, - "type": "object" + } }, "series": { + "type": "array", "description": "单个系列配置数组", "items": { + "type": "object", "description": "系列配置", "properties": { - "area": { - "description": "区域填充配置,配置项同 plotArea.areas", - "type": "object" - }, - "bars": { - "description": "柱状图配置,配置项同 plotArea.bars", - "type": "object" + "index": { + "type": "number", + "description": "数据列索引,与 dim2.series[].index 值一致,从 1 开始(1 通常对应首列标签维度,2+ 对应后续数据维度列)" }, "comboType": { + "type": "string", "description": "组合图下该系列的图表类型,仅在 type 为 combo 时生效", "enum": [ "column", "line", "area" - ], - "type": "string" + ] }, - "index": { - "description": "数据列索引,与 dim2.series[].index 值一致,从 1 开始(1 通常对应首列标签维度,2+ 对应后续数据维度列)", - "type": "number" + "yAxisPosition": { + "type": "string", + "description": "Y轴位置", + "enum": [ + "left", + "right" + ] }, - "labels": { - "description": "数据标签配置", - "type": "object" + "points": { + "type": "object", + "description": "数据点配置,配置项同 plotArea.points" }, "line": { - "description": "线条配置,配置项同 plotArea.lines", - "type": "object" + "type": "object", + "description": "线条配置,配置项同 plotArea.lines" }, - "points": { - "description": "数据点配置,配置项同 plotArea.points", - "type": "object" + "area": { + "type": "object", + "description": "区域填充配置,配置项同 plotArea.areas" + }, + "bars": { + "type": "object", + "description": "柱状图配置,配置项同 plotArea.bars" + }, + "labels": { + "type": "object", + "description": "数据标签配置" }, "sectors": { + "type": "object", "description": "扇区配置(饼图)", "properties": { "borderColor": { - "description": "边框颜色", - "type": "string" + "type": "string", + "description": "边框颜色" }, "innerRadius": { - "description": "内半径比例,0-1之间", - "type": "number" + "type": "number", + "description": "内半径比例,0-1之间" }, "offsetRadius": { - "description": "偏移半径比例", - "type": "number" + "type": "number", + "description": "偏移半径比例" + }, + "startAngle": { + "type": "number", + "description": "起始角度,0-359" }, "sector": { + "type": "array", "description": "单个扇区配置数组", "items": { + "type": "object", "properties": { - "borderColor": { - "description": "边框颜色", - "type": "string" - }, - "color": { - "description": "颜色", - "type": "string" - }, "index": { - "description": "扇区索引", - "type": "number" + "type": "number", + "description": "扇区索引" + }, + "borderColor": { + "type": "string", + "description": "边框颜色" }, "offsetRadius": { - "description": "偏移半径", - "type": "number" + "type": "number", + "description": "偏移半径" + }, + "color": { + "type": "string", + "description": "颜色" } }, "required": [ "index" - ], - "type": "object" - }, - "type": "array" - }, - "startAngle": { - "description": "起始角度,0-359", - "type": "number" + ] + } } - }, - "type": "object" - }, - "yAxisPosition": { - "description": "Y轴位置", - "enum": [ - "left", - "right" - ], - "type": "string" + } } }, "required": [ "index" - ], - "type": "object" - }, - "type": "array" - }, - "type": { - "description": "图表类型", - "enum": [ - "bar", - "column", - "line", - "area", - "combo", - "pie", - "radar", - "scatter" - ], - "type": "string" - }, - "yAxisPosition": { - "description": "Y轴位置", - "enum": [ - "left", - "right" - ], - "type": "string" + ] + } } }, "required": [ "type" - ], - "type": "object" + ] + }, + "axes": { + "type": "array", + "description": "坐标轴配置数组", + "items": { + "type": "object", + "description": "坐标轴配置", + "properties": { + "type": { + "type": "string", + "description": "坐标轴类型", + "enum": [ + "x", + "y", + "angle", + "radius" + ] + }, + "position": { + "type": "string", + "description": "坐标轴位置", + "enum": [ + "left", + "right", + "bottom" + ] + }, + "max": { + "type": "number", + "description": "最大值" + }, + "min": { + "type": "number", + "description": "最小值" + }, + "valueType": { + "type": "string", + "description": "是否将横轴数字视为文本,linear 表示将数字视为数值, ordinal 表示将数字视为文本", + "enum": [ + "ordinal", + "linear" + ] + }, + "title": { + "type": "object", + "description": "坐标轴标题配置", + "properties": { + "text": { + "type": "string", + "description": "标题文本" + }, + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色" + } + }, + "required": [ + "text" + ] + }, + "label": { + "type": "object", + "description": "坐标轴标签配置", + "properties": { + "angle": { + "type": "number", + "description": "旋转角度,可选值:-90, -45, 0, 45, 90" + }, + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色" + } + } + }, + "axisLine": { + "type": "boolean", + "description": "是否显示轴线" + }, + "gridLine": { + "oneOf": [ + { + "type": "object", + "description": "网格线配置", + "properties": { + "width": { + "type": "number", + "description": "网格线宽度" + }, + "color": { + "type": "string", + "description": "网格线颜色" + } + } + }, + { + "type": "boolean", + "description": "false 表示隐藏网格线" + } + ] + } + }, + "required": [ + "type" + ] + } } - }, - "type": "object" + } }, - "style": { - "description": "图表样式配置", + "data": { + "type": "object", + "description": "图表数据配置", "properties": { - "background": { - "description": "背景配置", - "properties": { - "color": { - "description": "背景颜色,格式为 #RRGGBB", - "type": "string" - } - }, - "type": "object" + "isStaticData": { + "type": "boolean", + "description": "是否为静态数据" }, - "border": { - "description": "边框配置", - "properties": { - "color": { - "description": "边框颜色,格式为 #RRGGBB", - "type": "string" - }, - "radius": { - "description": "边框圆角", - "type": "number" - }, - "style": { - "description": "边框样式", - "enum": [ - "solid", - "dashed", - "dotted" - ], - "type": "string" - }, - "width": { - "description": "边框宽度", - "type": "number" - } - }, - "type": "object" + "includeHiddenOrFilter": { + "type": "boolean", + "description": "是否包含隐藏或过滤的数据" }, - "colorGradient": { - "description": "是否启用颜色渐变", - "type": "boolean" + "direction": { + "type": "string", + "description": "数据方向,可选值:row(行方向)或column(列方向),常用值为column", + "enum": [ + "row", + "column" + ] }, - "colorTheme": { - "description": "颜色主题配置。支持两种模式:1. 仅传一个预设主题名称(字符串数组,长度为1);2. 传多个自定义十六进制颜色字符串。预设主题:brandColorSeries@v2、rainbowColorSeries@v2、complementaryColorSeries@v2、converseColorSeries@v2、primaryColorSeries@v2、singleColorSeries-B-@v2、singleColorSeries-W-@v2、singleColorSeries-G-@v2、singleColorSeries-Y-@v2、singleColorSeries-O-@v2、singleColorSeries-R-@v2、singleColorSeries-D-@v2。十六进制格式:#RRGGBB", - "oneOf": [ - { - "items": { - "enum": [ - "brandColorSeries@v2", - "rainbowColorSeries@v2", - "complementaryColorSeries@v2", - "converseColorSeries@v2", - "primaryColorSeries@v2", - "singleColorSeries-B-@v2", - "singleColorSeries-W-@v2", - "singleColorSeries-G-@v2", - "singleColorSeries-Y-@v2", - "singleColorSeries-O-@v2", - "singleColorSeries-R-@v2", - "singleColorSeries-D-@v2" - ], - "type": "string" - }, - "maxItems": 1, - "minItems": 1 + "headerMode": { + "type": "string", + "description": "表头模式。inline 表示 refs 首行/首列就是表头;detached 表示 refs 仅覆盖实际数据范围,维度名和系列名需通过 nameRef 显式指定。", + "enum": [ + "inline", + "detached" + ] + }, + "refs": { + "type": "array", + "description": "数据源引用范围数组", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "图表数据源范围,格式为 'SheetName!A1:D100' 的连续区域" + } }, - { - "items": { - "description": "颜色字符串,十六进制格式:#RRGGBB", - "type": "string" + "required": [ + "value" + ] + } + }, + "dim1": { + "type": "object", + "description": "维度1配置(类别维度)", + "properties": { + "serie": { + "type": "object", + "description": "系列配置(非静态数据时传此参数)", + "properties": { + "index": { + "type": "number", + "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引" + }, + "aggregate": { + "type": "boolean", + "description": "是否汇总相同的类别,默认为true,true 情况下 aggregateType 才生效" + }, + "nameRef": { + "type": "string", + "description": "可选。维度名称的单元格引用(A1 表示法,如 'Sheet1!A1')。当维度名称不直接来自 refs 首行/首列时,可通过此字段把维度名显式指向目标表头单元格,图表会以该单元格当前值作为类别维度名展示。" + } + } + }, + "field": { + "type": "object", + "description": "字段配置(静态数据时传此参数)", + "properties": { + "valueType": { + "type": "string", + "description": "值类型", + "enum": [ + "number", + "string" + ] + }, + "name": { + "type": "string", + "description": "字段名称" + }, + "text": { + "type": "string", + "description": "字段文本数据" + } }, - "minItems": 2 + "required": [ + "text" + ] } - ], - "type": "array" + } }, - "font": { - "description": "字体配置", + "dim2": { + "type": "object", + "description": "维度2配置(值维度)", "properties": { - "color": { - "description": "字体颜色,格式为 #RRGGBB", - "type": "string" + "series": { + "type": "array", + "description": "系列配置数组(非静态数据时传此参数)", + "items": { + "type": "object", + "properties": { + "index": { + "type": "number", + "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引" + }, + "aggregateType": { + "type": "string", + "description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效", + "enum": [ + "sum", + "average", + "count", + "min", + "max", + "median" + ] + }, + "nameRef": { + "type": "string", + "description": "可选。系列名称的单元格引用(A1 表示法,如 'Sheet1!C1')。当系列名称不直接来自 refs 首行/首列时,可通过此字段把系列名显式指向目标表头单元格,图表会以该单元格当前值作为系列名(图例/tooltip)展示。" + } + } + } }, - "size": { - "description": "字体大小", - "type": "number" + "fields": { + "type": "array", + "description": "字段配置数组(静态数据时传此参数)", + "items": { + "type": "object", + "properties": { + "valueType": { + "type": "string", + "description": "值类型", + "enum": [ + "number" + ] + }, + "name": { + "type": "string", + "description": "字段名称" + }, + "text": { + "type": "string", + "description": "字段文本数据" + } + }, + "required": [ + "text" + ] + } } - }, - "type": "object" - } - }, - "type": "object" - }, - "subTitle": { - "description": "图表副标题配置", - "properties": { - "bold": { - "description": "是否加粗", - "type": "boolean" - }, - "color": { - "description": "字体颜色,格式为 #RRGGBB", - "type": "string" - }, - "fontSize": { - "description": "字体大小", - "type": "number" - }, - "italic": { - "description": "是否斜体", - "type": "boolean" - }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" - }, - "text": { - "description": "副标题文本", - "type": "string" - }, - "textAlign": { - "description": "副标题对齐方式", - "enum": [ - "left", - "center", - "right" - ], - "type": "string" - }, - "underline": { - "description": "是否下划线", - "type": "boolean" - } - }, - "required": [ - "text" - ], - "type": "object" - }, - "title": { - "description": "图表标题配置", - "properties": { - "bold": { - "description": "是否加粗", - "type": "boolean" - }, - "color": { - "description": "字体颜色,格式为 #RRGGBB", - "type": "string" - }, - "fontSize": { - "description": "字体大小", - "type": "number" - }, - "italic": { - "description": "是否斜体", - "type": "boolean" - }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" - }, - "text": { - "description": "标题文本", - "type": "string" - }, - "textAlign": { - "description": "标题对齐方式", - "enum": [ - "left", - "center", - "right" - ], - "type": "string" - }, - "underline": { - "description": "是否下划线", - "type": "boolean" + } } - }, - "required": [ - "text" - ], - "type": "object" + } } - }, - "type": "object" + } } }, - "type": "object" + "additionalProperties": {} } }, "+chart-update": { "properties": { - "additionalProperties": {}, "description": "创建/更新的图表属性。", + "type": "object", "properties": { - "offset": { - "additionalProperties": false, - "description": "可选。图表在位置基础上的偏移量(像素)。", - "properties": { - "col_offset": { - "description": "列偏移量(像素)", - "type": "number" - }, - "row_offset": { - "description": "行偏移量(像素)", - "type": "number" - } - }, - "type": "object" - }, "position": { - "additionalProperties": false, + "type": "object", "description": "必填。图表在表格中的单元格位置。注意:选择位置时应避免覆盖已有数据的单元格,并确保图表不超出当前表格的行列范围。", "properties": { - "col": { - "description": "列索引,例如 \"A\"、\"B\"", - "type": "string" - }, "row": { - "description": "行索引(0-based)", + "type": "number", "minimum": 0, - "type": "number" + "description": "行索引(0-based)" + }, + "col": { + "type": "string", + "description": "列索引,例如 \"A\"、\"B\"" } }, "required": [ "row", "col" ], - "type": "object" + "additionalProperties": false + }, + "offset": { + "type": "object", + "description": "可选。图表在位置基础上的偏移量(像素)。", + "properties": { + "row_offset": { + "type": "number", + "description": "行偏移量(像素)" + }, + "col_offset": { + "type": "number", + "description": "列偏移量(像素)" + } + }, + "additionalProperties": false }, "size": { - "additionalProperties": false, + "type": "object", "description": "必填。图表大小(像素)。注意:设定大小时应确保图表不超出当前表格的行列范围,并避免覆盖已有数据的单元格。", "properties": { - "height": { - "description": "高度(像素)", + "width": { + "type": "number", "minimum": 10, - "type": "number" + "description": "宽度(像素)" }, - "width": { - "description": "宽度(像素)", + "height": { + "type": "number", "minimum": 10, - "type": "number" + "description": "高度(像素)" } }, "required": [ "width", "height" ], - "type": "object" + "additionalProperties": false }, "snapshot": { + "type": "object", "description": "图表快照配置。更新图表时必须传入完整的图表属性定义,不能只传修改的部分。应先通过 get_chart_objects 获取当前图表快照,修改需要变更的字段后,将完整快照传入。", "properties": { - "data": { - "description": "图表数据配置", + "title": { + "type": "object", + "description": "图表标题配置", "properties": { - "dim1": { - "description": "维度1配置(类别维度)", - "properties": { - "field": { - "description": "字段配置(静态数据时传此参数)", - "properties": { - "name": { - "description": "字段名称", - "type": "string" - }, - "text": { - "description": "字段文本数据", - "type": "string" - }, - "valueType": { - "description": "值类型", - "enum": [ - "number", - "string" - ], - "type": "string" - } - }, - "required": [ - "text" - ], - "type": "object" - }, - "serie": { - "description": "系列配置(非静态数据时传此参数)", - "properties": { - "aggregate": { - "description": "是否汇总相同的类别,默认为true,true 情况下 aggregateType 才生效", - "type": "boolean" - }, - "index": { - "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引", - "type": "number" - }, - "nameRef": { - "description": "可选。维度名称的单元格引用(A1 表示法,如 'Sheet1!A1')。当维度名称不直接来自 refs 首行/首列时,可通过此字段把维度名显式指向目标表头单元格,图表会以该单元格当前值作为类别维度名展示。", - "type": "string" - } - }, - "type": "object" - } - }, - "type": "object" + "text": { + "type": "string", + "description": "标题文本" }, - "dim2": { - "description": "维度2配置(值维度)", - "properties": { - "fields": { - "description": "字段配置数组(静态数据时传此参数)", - "items": { - "properties": { - "name": { - "description": "字段名称", - "type": "string" - }, - "text": { - "description": "字段文本数据", - "type": "string" - }, - "valueType": { - "description": "值类型", - "enum": [ - "number" - ], - "type": "string" - } - }, - "required": [ - "text" - ], - "type": "object" - }, - "type": "array" - }, - "series": { - "description": "系列配置数组(非静态数据时传此参数)", - "items": { - "properties": { - "aggregateType": { - "description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效", - "enum": [ - "sum", - "average", - "count", - "min", - "max", - "median" - ], - "type": "string" - }, - "index": { - "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引", - "type": "number" - }, - "nameRef": { - "description": "可选。系列名称的单元格引用(A1 表示法,如 'Sheet1!C1')。当系列名称不直接来自 refs 首行/首列时,可通过此字段把系列名显式指向目标表头单元格,图表会以该单元格当前值作为系列名(图例/tooltip)展示。", - "type": "string" - } - }, - "type": "object" - }, - "type": "array" - } - }, - "type": "object" - }, - "direction": { - "description": "数据方向,可选值:row(行方向)或column(列方向),常用值为column", + "textAlign": { + "type": "string", + "description": "标题对齐方式", "enum": [ - "row", - "column" - ], - "type": "string" + "left", + "center", + "right" + ] }, - "headerMode": { - "description": "表头模式。inline 表示 refs 首行/首列就是表头;detached 表示 refs 仅覆盖实际数据范围,维度名和系列名需通过 nameRef 显式指定。", + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" + } + }, + "required": [ + "text" + ] + }, + "subTitle": { + "type": "object", + "description": "图表副标题配置", + "properties": { + "text": { + "type": "string", + "description": "副标题文本" + }, + "textAlign": { + "type": "string", + "description": "副标题对齐方式", "enum": [ - "inline", - "detached" - ], - "type": "string" + "left", + "center", + "right" + ] }, - "includeHiddenOrFilter": { - "description": "是否包含隐藏或过滤的数据", - "type": "boolean" + "fontSize": { + "type": "number", + "description": "字体大小" }, - "isStaticData": { - "description": "是否为静态数据", - "type": "boolean" + "bold": { + "type": "boolean", + "description": "是否加粗" }, - "refs": { - "description": "数据源引用范围数组", - "items": { - "properties": { - "value": { - "description": "图表数据源范围,格式为 'SheetName!A1:D100' 的连续区域", - "type": "string" - } - }, - "required": [ - "value" - ], - "type": "object" - }, - "type": "array" + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" } }, - "type": "object" + "required": [ + "text" + ] }, - "legend": { - "oneOf": [ - { - "description": "图例配置", + "style": { + "type": "object", + "description": "图表样式配置", + "properties": { + "background": { + "type": "object", + "description": "背景配置", "properties": { - "bold": { - "description": "是否加粗", - "type": "boolean" + "color": { + "type": "string", + "description": "背景颜色,格式为 #RRGGBB" + } + } + }, + "font": { + "type": "object", + "description": "字体配置", + "properties": { + "size": { + "type": "number", + "description": "字体大小" }, "color": { - "description": "字体颜色,格式为 #RRGGBB", - "type": "string" + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" + } + } + }, + "border": { + "type": "object", + "description": "边框配置", + "properties": { + "color": { + "type": "string", + "description": "边框颜色,格式为 #RRGGBB" }, - "fontSize": { - "description": "字体大小", - "type": "number" + "width": { + "type": "number", + "description": "边框宽度" }, - "italic": { - "description": "是否斜体", - "type": "boolean" + "style": { + "type": "string", + "description": "边框样式", + "enum": [ + "solid", + "dashed", + "dotted" + ] + }, + "radius": { + "type": "number", + "description": "边框圆角" + } + } + }, + "colorTheme": { + "type": "array", + "description": "颜色主题配置。支持两种模式:1. 仅传一个预设主题名称(字符串数组,长度为1);2. 传多个自定义十六进制颜色字符串。预设主题:brandColorSeries@v2、rainbowColorSeries@v2、complementaryColorSeries@v2、converseColorSeries@v2、primaryColorSeries@v2、singleColorSeries-B-@v2、singleColorSeries-W-@v2、singleColorSeries-G-@v2、singleColorSeries-Y-@v2、singleColorSeries-O-@v2、singleColorSeries-R-@v2、singleColorSeries-D-@v2。十六进制格式:#RRGGBB", + "oneOf": [ + { + "minItems": 1, + "maxItems": 1, + "items": { + "type": "string", + "enum": [ + "brandColorSeries@v2", + "rainbowColorSeries@v2", + "complementaryColorSeries@v2", + "converseColorSeries@v2", + "primaryColorSeries@v2", + "singleColorSeries-B-@v2", + "singleColorSeries-W-@v2", + "singleColorSeries-G-@v2", + "singleColorSeries-Y-@v2", + "singleColorSeries-O-@v2", + "singleColorSeries-R-@v2", + "singleColorSeries-D-@v2" + ] + } }, + { + "minItems": 2, + "items": { + "type": "string", + "description": "颜色字符串,十六进制格式:#RRGGBB" + } + } + ] + }, + "colorGradient": { + "type": "boolean", + "description": "是否启用颜色渐变" + } + } + }, + "legend": { + "oneOf": [ + { + "type": "object", + "description": "图例配置", + "properties": { "position": { + "type": "string", "description": "图例位置", "enum": [ "top", "bottom", "left", "right" - ], - "type": "string" + ] }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" }, "underline": { - "description": "是否下划线", - "type": "boolean" + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" } - }, - "type": "object" + } }, { - "description": "false 表示隐藏图例", - "type": "boolean" + "type": "boolean", + "description": "false 表示隐藏图例" } ] }, "plotArea": { + "type": "object", "description": "绘图区域配置", "properties": { - "axes": { - "description": "坐标轴配置数组", - "items": { - "description": "坐标轴配置", - "properties": { - "axisLine": { - "description": "是否显示轴线", - "type": "boolean" - }, - "gridLine": { - "oneOf": [ - { - "description": "网格线配置", - "properties": { - "color": { - "description": "网格线颜色", - "type": "string" - }, - "width": { - "description": "网格线宽度", - "type": "number" - } - }, - "type": "object" - }, - { - "description": "false 表示隐藏网格线", - "type": "boolean" - } - ] - }, - "label": { - "description": "坐标轴标签配置", - "properties": { - "angle": { - "description": "旋转角度,可选值:-90, -45, 0, 45, 90", - "type": "number" - }, - "bold": { - "description": "是否加粗", - "type": "boolean" - }, - "color": { - "description": "字体颜色", - "type": "string" - }, - "fontSize": { - "description": "字体大小", - "type": "number" - }, - "italic": { - "description": "是否斜体", - "type": "boolean" - }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" - }, - "underline": { - "description": "是否下划线", - "type": "boolean" - } - }, - "type": "object" - }, - "max": { - "description": "最大值", - "type": "number" - }, - "min": { - "description": "最小值", - "type": "number" - }, - "position": { - "description": "坐标轴位置", - "enum": [ - "left", - "right", - "bottom" - ], - "type": "string" - }, - "title": { - "description": "坐标轴标题配置", - "properties": { - "bold": { - "description": "是否加粗", - "type": "boolean" - }, - "color": { - "description": "字体颜色", - "type": "string" - }, - "fontSize": { - "description": "字体大小", - "type": "number" - }, - "italic": { - "description": "是否斜体", - "type": "boolean" - }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" - }, - "text": { - "description": "标题文本", - "type": "string" - }, - "underline": { - "description": "是否下划线", - "type": "boolean" - } - }, - "required": [ - "text" - ], - "type": "object" - }, - "type": { - "description": "坐标轴类型", - "enum": [ - "x", - "y", - "angle", - "radius" - ], - "type": "string" - }, - "valueType": { - "description": "是否将横轴数字视为文本,linear 表示将数字视为数值, ordinal 表示将数字视为文本", - "enum": [ - "ordinal", - "linear" - ], - "type": "string" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - "type": "array" - }, "plot": { + "type": "object", "description": "绘图配置", "properties": { - "areas": { - "description": "全系列面积填充配置,面积图、雷达图、组合图生效。", - "properties": { - "color": { - "description": "区域填充颜色", - "type": "string" - } - }, - "type": "object" - }, - "bars": { - "description": "全系列柱状图、条形图、组合图生效。", - "properties": { - "backgroundColor": { - "description": "背景颜色", - "type": "string" - }, - "bar": { - "description": "单个柱子配置数组", - "items": { - "properties": { - "borderColor": { - "description": "边框颜色", - "type": "string" - }, - "borderStyle": { - "description": "边框样式", - "type": "string" - }, - "borderWidth": { - "description": "边框宽度", - "type": "number" - }, - "color": { - "description": "颜色", - "type": "string" - }, - "index": { - "description": "柱子索引", - "type": "number" - } - }, - "required": [ - "index" - ], - "type": "object" - }, - "type": "array" - }, - "borderColor": { - "description": "边框颜色", - "type": "string" - }, - "borderStyle": { - "description": "边框样式", - "enum": [ - "solid", - "dashed", - "dotted" - ], - "type": "string" - }, - "borderWidth": { - "description": "边框宽度", - "type": "number" - }, - "color": { - "description": "柱子颜色", - "type": "string" - }, - "gap": { - "description": "柱子间距比例,0-1之间", - "type": "number" - }, - "width": { - "description": "柱子宽度", - "type": "number" - } - }, - "type": "object" + "type": { + "type": "string", + "description": "图表类型", + "enum": [ + "bar", + "column", + "line", + "area", + "combo", + "pie", + "radar", + "scatter" + ] }, "comboType": { + "type": "string", "description": "组合图表默认类型", "enum": [ "column", "line", "area" - ], - "type": "string" + ] + }, + "yAxisPosition": { + "type": "string", + "description": "Y轴位置", + "enum": [ + "left", + "right" + ] }, "extra": { + "type": "object", "description": "额外配置", "properties": { + "smooth": { + "type": "boolean", + "description": "是否平滑曲线" + }, + "step": { + "type": "boolean", + "description": "是否阶梯图" + }, + "stack": { + "type": "object", + "description": "堆叠配置", + "properties": { + "percentage": { + "type": "boolean", + "description": "是否百分比堆叠" + } + } + }, "radar": { + "type": "object", "description": "雷达图配置", "properties": { - "area": { - "description": "是否填充区域", - "type": "boolean" - }, "shape": { + "type": "string", "description": "雷达图形状", "enum": [ "polygon", "circle" - ], - "type": "string" - } - }, - "type": "object" - }, - "smooth": { - "description": "是否平滑曲线", - "type": "boolean" - }, - "stack": { - "description": "堆叠配置", - "properties": { - "percentage": { - "description": "是否百分比堆叠", - "type": "boolean" + ] + }, + "area": { + "type": "boolean", + "description": "是否填充区域" } - }, - "type": "object" - }, - "step": { - "description": "是否阶梯图", - "type": "boolean" + } } - }, - "type": "object" + } }, - "labels": { - "description": "数据标签配置", + "points": { + "type": "object", + "description": "全系列数据点配置,折线图、面积图、雷达图、散点图、组合图生效。", "properties": { - "bold": { - "description": "是否加粗", - "type": "boolean" - }, - "category": { - "description": "是否显示类别名", - "type": "boolean" - }, "color": { - "description": "字体颜色", - "type": "string" - }, - "fontSize": { - "description": "字体大小", - "type": "number" - }, - "italic": { - "description": "是否斜体", - "type": "boolean" - }, - "percentage": { - "description": "是否显示百分比", - "type": "boolean" + "type": "string", + "description": "数据点颜色" }, - "position": { - "description": "标签位置", + "shape": { + "type": "string", + "description": "数据点形状", "enum": [ - "auto", - "top", - "bottom", - "left", - "right", - "center", - "inside", - "outside" - ], - "type": "string" - }, - "series": { - "description": "是否显示系列名", - "type": "boolean" - }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" + "circle", + "triangle", + "rect", + "diamond", + "square" + ] }, - "underline": { - "description": "是否下划线", - "type": "boolean" + "size": { + "type": "number", + "description": "数据点大小" }, - "value": { - "description": "是否显示值", - "type": "boolean" + "point": { + "type": "array", + "description": "单个数据点配置数组", + "items": { + "type": "object", + "properties": { + "index": { + "type": "number", + "description": "数据点索引" + }, + "color": { + "type": "string", + "description": "颜色" + }, + "shape": { + "type": "string", + "description": "形状" + }, + "size": { + "type": "number", + "description": "大小" + } + }, + "required": [ + "index" + ] + } } - }, - "type": "object" + } }, "lines": { + "type": "object", "description": "全系列线条配置,折线图、面积图、雷达图、组合图生效。", "properties": { "color": { - "description": "线条颜色", - "type": "string" + "type": "string", + "description": "线条颜色" }, - "invalidType": { - "description": "无效值处理方式", - "enum": [ - "break", - "zero", - "link" - ], - "type": "string" + "width": { + "type": "number", + "description": "线条宽度" }, "style": { + "type": "string", "description": "线条样式", "enum": [ "solid", "dashed", "dotted" - ], - "type": "string" + ] }, - "width": { - "description": "线条宽度", - "type": "number" + "invalidType": { + "type": "string", + "description": "无效值处理方式", + "enum": [ + "break", + "zero", + "link" + ] } - }, - "type": "object" + } }, - "points": { - "description": "全系列数据点配置,折线图、面积图、雷达图、散点图、组合图生效。", + "areas": { + "type": "object", + "description": "全系列面积填充配置,面积图、雷达图、组合图生效。", + "properties": { + "color": { + "type": "string", + "description": "区域填充颜色" + } + } + }, + "bars": { + "type": "object", + "description": "全系列柱状图、条形图、组合图生效。", "properties": { "color": { - "description": "数据点颜色", - "type": "string" + "type": "string", + "description": "柱子颜色" }, - "point": { - "description": "单个数据点配置数组", - "items": { - "properties": { - "color": { - "description": "颜色", - "type": "string" - }, - "index": { - "description": "数据点索引", - "type": "number" - }, - "shape": { - "description": "形状", - "type": "string" + "borderColor": { + "type": "string", + "description": "边框颜色" + }, + "borderWidth": { + "type": "number", + "description": "边框宽度" + }, + "borderStyle": { + "type": "string", + "description": "边框样式", + "enum": [ + "solid", + "dashed", + "dotted" + ] + }, + "width": { + "type": "number", + "description": "柱子宽度" + }, + "gap": { + "type": "number", + "description": "柱子间距比例,0-1之间" + }, + "backgroundColor": { + "type": "string", + "description": "背景颜色" + }, + "bar": { + "type": "array", + "description": "单个柱子配置数组", + "items": { + "type": "object", + "properties": { + "index": { + "type": "number", + "description": "柱子索引" }, - "size": { - "description": "大小", - "type": "number" + "color": { + "type": "string", + "description": "颜色" + }, + "borderColor": { + "type": "string", + "description": "边框颜色" + }, + "borderWidth": { + "type": "number", + "description": "边框宽度" + }, + "borderStyle": { + "type": "string", + "description": "边框样式" } }, "required": [ "index" - ], - "type": "object" - }, - "type": "array" - }, - "shape": { - "description": "数据点形状", + ] + } + } + } + }, + "labels": { + "type": "object", + "description": "数据标签配置", + "properties": { + "position": { + "type": "string", + "description": "标签位置", "enum": [ - "circle", - "triangle", - "rect", - "diamond", - "square" - ], - "type": "string" + "auto", + "top", + "bottom", + "left", + "right", + "center", + "inside", + "outside" + ] }, - "size": { - "description": "数据点大小", - "type": "number" + "series": { + "type": "boolean", + "description": "是否显示系列名" + }, + "category": { + "type": "boolean", + "description": "是否显示类别名" + }, + "value": { + "type": "boolean", + "description": "是否显示值" + }, + "percentage": { + "type": "boolean", + "description": "是否显示百分比" + }, + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色" } - }, - "type": "object" + } }, "series": { + "type": "array", "description": "单个系列配置数组", "items": { + "type": "object", "description": "系列配置", "properties": { - "area": { - "description": "区域填充配置,配置项同 plotArea.areas", - "type": "object" - }, - "bars": { - "description": "柱状图配置,配置项同 plotArea.bars", - "type": "object" + "index": { + "type": "number", + "description": "数据列索引,与 dim2.series[].index 值一致,从 1 开始(1 通常对应首列标签维度,2+ 对应后续数据维度列)" }, "comboType": { + "type": "string", "description": "组合图下该系列的图表类型,仅在 type 为 combo 时生效", "enum": [ "column", "line", "area" - ], - "type": "string" + ] }, - "index": { - "description": "数据列索引,与 dim2.series[].index 值一致,从 1 开始(1 通常对应首列标签维度,2+ 对应后续数据维度列)", - "type": "number" + "yAxisPosition": { + "type": "string", + "description": "Y轴位置", + "enum": [ + "left", + "right" + ] }, - "labels": { - "description": "数据标签配置", - "type": "object" + "points": { + "type": "object", + "description": "数据点配置,配置项同 plotArea.points" }, "line": { - "description": "线条配置,配置项同 plotArea.lines", - "type": "object" + "type": "object", + "description": "线条配置,配置项同 plotArea.lines" }, - "points": { - "description": "数据点配置,配置项同 plotArea.points", - "type": "object" + "area": { + "type": "object", + "description": "区域填充配置,配置项同 plotArea.areas" + }, + "bars": { + "type": "object", + "description": "柱状图配置,配置项同 plotArea.bars" + }, + "labels": { + "type": "object", + "description": "数据标签配置" }, "sectors": { + "type": "object", "description": "扇区配置(饼图)", "properties": { "borderColor": { - "description": "边框颜色", - "type": "string" + "type": "string", + "description": "边框颜色" }, "innerRadius": { - "description": "内半径比例,0-1之间", - "type": "number" + "type": "number", + "description": "内半径比例,0-1之间" }, "offsetRadius": { - "description": "偏移半径比例", - "type": "number" + "type": "number", + "description": "偏移半径比例" + }, + "startAngle": { + "type": "number", + "description": "起始角度,0-359" }, "sector": { + "type": "array", "description": "单个扇区配置数组", "items": { + "type": "object", "properties": { - "borderColor": { - "description": "边框颜色", - "type": "string" - }, - "color": { - "description": "颜色", - "type": "string" - }, "index": { - "description": "扇区索引", - "type": "number" + "type": "number", + "description": "扇区索引" + }, + "borderColor": { + "type": "string", + "description": "边框颜色" }, "offsetRadius": { - "description": "偏移半径", - "type": "number" + "type": "number", + "description": "偏移半径" + }, + "color": { + "type": "string", + "description": "颜色" } }, "required": [ "index" - ], - "type": "object" - }, - "type": "array" - }, - "startAngle": { - "description": "起始角度,0-359", - "type": "number" + ] + } } - }, - "type": "object" - }, - "yAxisPosition": { - "description": "Y轴位置", - "enum": [ - "left", - "right" - ], - "type": "string" + } } }, "required": [ "index" - ], - "type": "object" - }, - "type": "array" - }, - "type": { - "description": "图表类型", - "enum": [ - "bar", - "column", - "line", - "area", - "combo", - "pie", - "radar", - "scatter" - ], - "type": "string" - }, - "yAxisPosition": { - "description": "Y轴位置", - "enum": [ - "left", - "right" - ], - "type": "string" + ] + } } }, "required": [ "type" - ], - "type": "object" - } - }, - "type": "object" - }, - "style": { - "description": "图表样式配置", - "properties": { - "background": { - "description": "背景配置", - "properties": { - "color": { - "description": "背景颜色,格式为 #RRGGBB", - "type": "string" - } - }, - "type": "object" - }, - "border": { - "description": "边框配置", - "properties": { - "color": { - "description": "边框颜色,格式为 #RRGGBB", - "type": "string" - }, - "radius": { - "description": "边框圆角", - "type": "number" - }, - "style": { - "description": "边框样式", - "enum": [ - "solid", - "dashed", - "dotted" - ], - "type": "string" - }, - "width": { - "description": "边框宽度", - "type": "number" - } - }, - "type": "object" - }, - "colorGradient": { - "description": "是否启用颜色渐变", - "type": "boolean" + ] }, - "colorTheme": { - "description": "颜色主题配置。支持两种模式:1. 仅传一个预设主题名称(字符串数组,长度为1);2. 传多个自定义十六进制颜色字符串。预设主题:brandColorSeries@v2、rainbowColorSeries@v2、complementaryColorSeries@v2、converseColorSeries@v2、primaryColorSeries@v2、singleColorSeries-B-@v2、singleColorSeries-W-@v2、singleColorSeries-G-@v2、singleColorSeries-Y-@v2、singleColorSeries-O-@v2、singleColorSeries-R-@v2、singleColorSeries-D-@v2。十六进制格式:#RRGGBB", - "oneOf": [ - { - "items": { + "axes": { + "type": "array", + "description": "坐标轴配置数组", + "items": { + "type": "object", + "description": "坐标轴配置", + "properties": { + "type": { + "type": "string", + "description": "坐标轴类型", "enum": [ - "brandColorSeries@v2", - "rainbowColorSeries@v2", - "complementaryColorSeries@v2", - "converseColorSeries@v2", - "primaryColorSeries@v2", - "singleColorSeries-B-@v2", - "singleColorSeries-W-@v2", - "singleColorSeries-G-@v2", - "singleColorSeries-Y-@v2", - "singleColorSeries-O-@v2", - "singleColorSeries-R-@v2", - "singleColorSeries-D-@v2" - ], - "type": "string" + "x", + "y", + "angle", + "radius" + ] }, - "maxItems": 1, - "minItems": 1 - }, - { - "items": { - "description": "颜色字符串,十六进制格式:#RRGGBB", - "type": "string" + "position": { + "type": "string", + "description": "坐标轴位置", + "enum": [ + "left", + "right", + "bottom" + ] }, - "minItems": 2 - } - ], - "type": "array" - }, - "font": { - "description": "字体配置", - "properties": { - "color": { - "description": "字体颜色,格式为 #RRGGBB", - "type": "string" + "max": { + "type": "number", + "description": "最大值" + }, + "min": { + "type": "number", + "description": "最小值" + }, + "valueType": { + "type": "string", + "description": "是否将横轴数字视为文本,linear 表示将数字视为数值, ordinal 表示将数字视为文本", + "enum": [ + "ordinal", + "linear" + ] + }, + "title": { + "type": "object", + "description": "坐标轴标题配置", + "properties": { + "text": { + "type": "string", + "description": "标题文本" + }, + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色" + } + }, + "required": [ + "text" + ] + }, + "label": { + "type": "object", + "description": "坐标轴标签配置", + "properties": { + "angle": { + "type": "number", + "description": "旋转角度,可选值:-90, -45, 0, 45, 90" + }, + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色" + } + } + }, + "axisLine": { + "type": "boolean", + "description": "是否显示轴线" + }, + "gridLine": { + "oneOf": [ + { + "type": "object", + "description": "网格线配置", + "properties": { + "width": { + "type": "number", + "description": "网格线宽度" + }, + "color": { + "type": "string", + "description": "网格线颜色" + } + } + }, + { + "type": "boolean", + "description": "false 表示隐藏网格线" + } + ] + } }, - "size": { - "description": "字体大小", - "type": "number" - } - }, - "type": "object" + "required": [ + "type" + ] + } } - }, - "type": "object" + } }, - "subTitle": { - "description": "图表副标题配置", + "data": { + "type": "object", + "description": "图表数据配置", "properties": { - "bold": { - "description": "是否加粗", - "type": "boolean" - }, - "color": { - "description": "字体颜色,格式为 #RRGGBB", - "type": "string" - }, - "fontSize": { - "description": "字体大小", - "type": "number" - }, - "italic": { - "description": "是否斜体", - "type": "boolean" - }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" + "isStaticData": { + "type": "boolean", + "description": "是否为静态数据" }, - "text": { - "description": "副标题文本", - "type": "string" + "includeHiddenOrFilter": { + "type": "boolean", + "description": "是否包含隐藏或过滤的数据" }, - "textAlign": { - "description": "副标题对齐方式", + "direction": { + "type": "string", + "description": "数据方向,可选值:row(行方向)或column(列方向),常用值为column", "enum": [ - "left", - "center", - "right" - ], - "type": "string" - }, - "underline": { - "description": "是否下划线", - "type": "boolean" - } - }, - "required": [ - "text" - ], - "type": "object" - }, - "title": { - "description": "图表标题配置", - "properties": { - "bold": { - "description": "是否加粗", - "type": "boolean" - }, - "color": { - "description": "字体颜色,格式为 #RRGGBB", - "type": "string" - }, - "fontSize": { - "description": "字体大小", - "type": "number" - }, - "italic": { - "description": "是否斜体", - "type": "boolean" + "row", + "column" + ] }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" + "headerMode": { + "type": "string", + "description": "表头模式。inline 表示 refs 首行/首列就是表头;detached 表示 refs 仅覆盖实际数据范围,维度名和系列名需通过 nameRef 显式指定。", + "enum": [ + "inline", + "detached" + ] }, - "text": { - "description": "标题文本", - "type": "string" + "refs": { + "type": "array", + "description": "数据源引用范围数组", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "图表数据源范围,格式为 'SheetName!A1:D100' 的连续区域" + } + }, + "required": [ + "value" + ] + } }, - "textAlign": { - "description": "标题对齐方式", - "enum": [ - "left", - "center", - "right" - ], - "type": "string" + "dim1": { + "type": "object", + "description": "维度1配置(类别维度)", + "properties": { + "serie": { + "type": "object", + "description": "系列配置(非静态数据时传此参数)", + "properties": { + "index": { + "type": "number", + "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引" + }, + "aggregate": { + "type": "boolean", + "description": "是否汇总相同的类别,默认为true,true 情况下 aggregateType 才生效" + }, + "nameRef": { + "type": "string", + "description": "可选。维度名称的单元格引用(A1 表示法,如 'Sheet1!A1')。当维度名称不直接来自 refs 首行/首列时,可通过此字段把维度名显式指向目标表头单元格,图表会以该单元格当前值作为类别维度名展示。" + } + } + }, + "field": { + "type": "object", + "description": "字段配置(静态数据时传此参数)", + "properties": { + "valueType": { + "type": "string", + "description": "值类型", + "enum": [ + "number", + "string" + ] + }, + "name": { + "type": "string", + "description": "字段名称" + }, + "text": { + "type": "string", + "description": "字段文本数据" + } + }, + "required": [ + "text" + ] + } + } }, - "underline": { - "description": "是否下划线", - "type": "boolean" + "dim2": { + "type": "object", + "description": "维度2配置(值维度)", + "properties": { + "series": { + "type": "array", + "description": "系列配置数组(非静态数据时传此参数)", + "items": { + "type": "object", + "properties": { + "index": { + "type": "number", + "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引" + }, + "aggregateType": { + "type": "string", + "description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效", + "enum": [ + "sum", + "average", + "count", + "min", + "max", + "median" + ] + }, + "nameRef": { + "type": "string", + "description": "可选。系列名称的单元格引用(A1 表示法,如 'Sheet1!C1')。当系列名称不直接来自 refs 首行/首列时,可通过此字段把系列名显式指向目标表头单元格,图表会以该单元格当前值作为系列名(图例/tooltip)展示。" + } + } + } + }, + "fields": { + "type": "array", + "description": "字段配置数组(静态数据时传此参数)", + "items": { + "type": "object", + "properties": { + "valueType": { + "type": "string", + "description": "值类型", + "enum": [ + "number" + ] + }, + "name": { + "type": "string", + "description": "字段名称" + }, + "text": { + "type": "string", + "description": "字段文本数据" + } + }, + "required": [ + "text" + ] + } + } + } } - }, - "required": [ - "text" - ], - "type": "object" + } } - }, - "type": "object" + } } }, - "type": "object" + "additionalProperties": {} } }, "+cond-format-create": { "properties": { - "additionalProperties": false, "description": "创建/更新的条件格式属性。", + "type": "object", "properties": { + "rule_type": { + "type": "string", + "enum": [ + "duplicateValues", + "uniqueValues", + "cellIs", + "containsText", + "timePeriod", + "containsBlanks", + "notContainsBlanks", + "dataBar", + "colorScale", + "rank", + "aboveAverage", + "expression", + "iconSet" + ], + "description": "条件格式规则类型。" + }, + "ranges": { + "type": "array", + "description": "应用条件格式的 A1 范围列表。每个元素支持:矩形范围 \"A1:A10\"、整行 \"3:6\"、整列 \"C:C\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。例如 [\"A1:A10\",\"C:C\"]。", + "items": { + "type": "string" + } + }, + "style": { + "type": "object", + "description": "命中规则时应用的单元格样式。", + "properties": { + "back_color": { + "type": "string", + "description": "背景色,CSS 十六进制表示,例如 \"#FF0000\"。" + }, + "fore_color": { + "type": "string", + "description": "前景色/字体颜色。" + }, + "text_decoration": { + "type": "string", + "enum": [ + "none", + "underline", + "strikethrough", + "underline_strikethrough" + ], + "description": "文字装饰:none=无,underline=下划线,strikethrough=删除线,underline_strikethrough=下划线+删除线。" + }, + "font": { + "type": "string", + "enum": [ + "bold", + "italic", + "bold italic" + ], + "description": "字体样式:\"bold\"=加粗,\"italic\"=斜体,\"bold italic\"=加粗+斜体。" + } + }, + "additionalProperties": false + }, "attrs": { + "type": "array", "description": "规则参数列表。不同 rule_type 下取值结构不同。当 rule_type 为 dataBar 时,attrs 必须包含两个对象,分别定义正值和负值的数据条颜色。", "items": { "oneOf": [ { - "additionalProperties": false, + "type": "object", "description": "数值比较类规则参数。", "properties": { "compare_type": { - "description": "比较运算符。", + "type": "string", "enum": [ "equal", "notEqual", @@ -2813,25 +2874,29 @@ "between", "notBetween" ], - "type": "string" + "description": "比较运算符。" }, "value": { - "description": "用于比较的值,例如 \"100\"、\"hello\"。between/notBetween 时用逗号分隔两个值。", - "type": "string" + "type": "string", + "description": "用于比较的值,例如 \"100\"、\"hello\"。between/notBetween 时用逗号分隔两个值。" } }, "required": [ "compare_type", "value" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "文本包含类规则参数。", "properties": { + "text": { + "type": "string", + "description": "用于匹配的文本内容。" + }, "compare_type": { - "description": "文本匹配方式。", + "type": "string", "enum": [ "beginsWith", "endsWith", @@ -2839,34 +2904,30 @@ "notContains", "is" ], - "type": "string" - }, - "text": { - "description": "用于匹配的文本内容。", - "type": "string" + "description": "文本匹配方式。" } }, "required": [ "compare_type", "text" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "时间段类规则参数。", "properties": { "operator": { - "description": "与指定时间段的比较关系。", + "type": "string", "enum": [ "before", "is", "after" ], - "type": "string" + "description": "与指定时间段的比较关系。" }, "time_period": { - "description": "时间段类型。", + "type": "string", "enum": [ "today", "yesterday", @@ -2879,37 +2940,37 @@ "lastWeek", "nextWeek" ], - "type": "string" + "description": "时间段类型。" } }, "required": [ "operator", "time_period" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "数据条规则参数。", "properties": { - "color": { - "description": "主颜色,例如 \"#63BE7B\"。", - "type": "string" - }, "gradient": { - "description": "是否使用渐变色数据条。", - "type": "boolean" - }, - "hide_value": { - "description": "是否隐藏单元格中的原始值,仅显示数据条。", - "type": "boolean" + "type": "boolean", + "description": "是否使用渐变色数据条。" }, "value": { - "description": "阈值或比例值,含义由 value_type 决定。", - "type": "number" + "type": "number", + "description": "阈值或比例值,含义由 value_type 决定。" + }, + "color": { + "type": "string", + "description": "主颜色,例如 \"#63BE7B\"。" + }, + "hide_value": { + "type": "boolean", + "description": "是否隐藏单元格中的原始值,仅显示数据条。" }, "value_type": { - "description": "阈值类型。", + "type": "string", "enum": [ "minValue", "maxValue", @@ -2919,29 +2980,21 @@ "formula", "auto" ], - "type": "string" + "description": "阈值类型。" } }, "required": [ "color", "value_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "色阶规则中的单个分段。ColorScaleAttrs 由 2 或 3 个该对象组成,分别表示最小值/中间值/最大值。", "properties": { - "color": { - "description": "该分段对应的颜色。", - "type": "string" - }, - "value": { - "description": "阈值数值,例如百分位或具体数值。", - "type": "number" - }, "value_type": { - "description": "阈值类型。", + "type": "string", "enum": [ "minValue", "maxValue", @@ -2951,89 +3004,93 @@ "formula", "auto" ], - "type": "string" + "description": "阈值类型。" + }, + "value": { + "type": "number", + "description": "阈值数值,例如百分位或具体数值。" + }, + "color": { + "type": "string", + "description": "该分段对应的颜色。" } }, "required": [ "value_type", "color" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "前 N/后 N 规则参数。", "properties": { "is_bottom": { - "description": "是否选取最后 N 个。true 表示最后 N 个,false 表示前 N 个。", - "type": "boolean" - }, - "value": { - "description": "N 或百分比数值。", - "type": "number" + "type": "boolean", + "description": "是否选取最后 N 个。true 表示最后 N 个,false 表示前 N 个。" }, "value_type": { - "description": "排名方式:percent 表示百分比,sort 表示按条目数。", + "type": "string", "enum": [ "percent", "sort" ], - "type": "string" + "description": "排名方式:percent 表示百分比,sort 表示按条目数。" + }, + "value": { + "type": "number", + "description": "N 或百分比数值。" } }, "required": [ "is_bottom", "value_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "平均值规则参数。", "properties": { "operator": { - "description": "与平均值的比较关系。", + "type": "string", "enum": [ "greaterThan", "greaterThanOrEqual", "lessThan", "lessThanOrEqual" ], - "type": "string" + "description": "与平均值的比较关系。" } }, "required": [ "operator" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "自定义公式规则参数。", "properties": { "formula": { - "description": "条件公式列表,例如 [\"=A1>0\"]。", + "type": "array", "items": { "type": "string" }, - "type": "array" + "description": "条件公式列表,例如 [\"=A1>0\"]。" } }, "required": [ "formula" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "图标集规则参数。", "properties": { - "hide_value": { - "description": "是否隐藏单元格原始值,仅显示图标。", - "type": "boolean" - }, "icon_type": { - "description": "图标集类型。其中 3Circles/4Circles/5Circles 为不同颜色的实心圆(如红、黄、绿),用于表示状态等级;5CirclesRatio 为不同填充比例的圆(从空心◯到半圆◑到满圆●),用于表示完成度、进度、比例。当涉及半圆、满圆、空心圆、填充比例、进度等场景时,应选择 5CirclesRatio。", + "type": "string", "enum": [ "3Arrows", "3ArrowsGray", @@ -3055,28 +3112,14 @@ "3Mood", "5CirclesRatio" ], - "type": "string" - }, - "operator": { - "description": "与阈值的比较关系。", - "enum": [ - "greaterThan", - "greaterThanOrEqual", - "lessThan", - "lessThanOrEqual" - ], - "type": "string" - }, - "reverse_icons": { - "description": "是否反转图标顺序。", - "type": "boolean" + "description": "图标集类型。其中 3Circles/4Circles/5Circles 为不同颜色的实心圆(如红、黄、绿),用于表示状态等级;5CirclesRatio 为不同填充比例的圆(从空心◯到半圆◑到满圆●),用于表示完成度、进度、比例。当涉及半圆、满圆、空心圆、填充比例、进度等场景时,应选择 5CirclesRatio。" }, - "value": { - "description": "用于比较的数值,含义由 value_type 决定。", - "type": "number" + "hide_value": { + "type": "boolean", + "description": "是否隐藏单元格原始值,仅显示图标。" }, "value_type": { - "description": "阈值类型。", + "type": "string", "enum": [ "minValue", "maxValue", @@ -3086,7 +3129,25 @@ "formula", "auto" ], - "type": "string" + "description": "阈值类型。" + }, + "operator": { + "type": "string", + "enum": [ + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual" + ], + "description": "与阈值的比较关系。" + }, + "value": { + "type": "number", + "description": "用于比较的数值,含义由 value_type 决定。" + }, + "reverse_icons": { + "type": "boolean", + "description": "是否反转图标顺序。" } }, "required": [ @@ -3094,25 +3155,31 @@ "value_type", "operator" ], - "type": "object" + "additionalProperties": false } ] - }, - "type": "array" + } }, "has_ref": { - "description": "可选。是否存在引用,用于标记规则是否依赖其他对象。", - "type": "boolean" - }, - "ranges": { - "description": "应用条件格式的 A1 范围列表。每个元素支持:矩形范围 \"A1:A10\"、整行 \"3:6\"、整列 \"C:C\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。例如 [\"A1:A10\",\"C:C\"]。", - "items": { - "type": "string" - }, - "type": "array" - }, + "type": "boolean", + "description": "可选。是否存在引用,用于标记规则是否依赖其他对象。" + } + }, + "required": [ + "rule_type", + "ranges", + "style" + ], + "additionalProperties": false + } + }, + "+cond-format-update": { + "properties": { + "description": "创建/更新的条件格式属性。", + "type": "object", + "properties": { "rule_type": { - "description": "条件格式规则类型。", + "type": "string", "enum": [ "duplicateValues", "uniqueValues", @@ -3128,66 +3195,60 @@ "expression", "iconSet" ], - "type": "string" + "description": "条件格式规则类型。" + }, + "ranges": { + "type": "array", + "description": "应用条件格式的 A1 范围列表。每个元素支持:矩形范围 \"A1:A10\"、整行 \"3:6\"、整列 \"C:C\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。例如 [\"A1:A10\",\"C:C\"]。", + "items": { + "type": "string" + } }, "style": { - "additionalProperties": false, - "description": "命中规则时应用的单元格样式。", - "properties": { - "back_color": { - "description": "背景色,CSS 十六进制表示,例如 \"#FF0000\"。", - "type": "string" - }, - "font": { - "description": "字体样式:\"bold\"=加粗,\"italic\"=斜体,\"bold italic\"=加粗+斜体。", - "enum": [ - "bold", - "italic", - "bold italic" - ], - "type": "string" + "type": "object", + "description": "命中规则时应用的单元格样式。", + "properties": { + "back_color": { + "type": "string", + "description": "背景色,CSS 十六进制表示,例如 \"#FF0000\"。" }, "fore_color": { - "description": "前景色/字体颜色。", - "type": "string" + "type": "string", + "description": "前景色/字体颜色。" }, "text_decoration": { - "description": "文字装饰:none=无,underline=下划线,strikethrough=删除线,underline_strikethrough=下划线+删除线。", + "type": "string", "enum": [ "none", "underline", "strikethrough", "underline_strikethrough" ], - "type": "string" + "description": "文字装饰:none=无,underline=下划线,strikethrough=删除线,underline_strikethrough=下划线+删除线。" + }, + "font": { + "type": "string", + "enum": [ + "bold", + "italic", + "bold italic" + ], + "description": "字体样式:\"bold\"=加粗,\"italic\"=斜体,\"bold italic\"=加粗+斜体。" } }, - "type": "object" - } - }, - "required": [ - "rule_type", - "ranges", - "style" - ], - "type": "object" - } - }, - "+cond-format-update": { - "properties": { - "additionalProperties": false, - "description": "创建/更新的条件格式属性。", - "properties": { + "additionalProperties": false + }, "attrs": { + "type": "array", "description": "规则参数列表。不同 rule_type 下取值结构不同。当 rule_type 为 dataBar 时,attrs 必须包含两个对象,分别定义正值和负值的数据条颜色。", "items": { "oneOf": [ { - "additionalProperties": false, + "type": "object", "description": "数值比较类规则参数。", "properties": { "compare_type": { - "description": "比较运算符。", + "type": "string", "enum": [ "equal", "notEqual", @@ -3198,25 +3259,29 @@ "between", "notBetween" ], - "type": "string" + "description": "比较运算符。" }, "value": { - "description": "用于比较的值,例如 \"100\"、\"hello\"。between/notBetween 时用逗号分隔两个值。", - "type": "string" + "type": "string", + "description": "用于比较的值,例如 \"100\"、\"hello\"。between/notBetween 时用逗号分隔两个值。" } }, "required": [ "compare_type", "value" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "文本包含类规则参数。", "properties": { + "text": { + "type": "string", + "description": "用于匹配的文本内容。" + }, "compare_type": { - "description": "文本匹配方式。", + "type": "string", "enum": [ "beginsWith", "endsWith", @@ -3224,34 +3289,30 @@ "notContains", "is" ], - "type": "string" - }, - "text": { - "description": "用于匹配的文本内容。", - "type": "string" + "description": "文本匹配方式。" } }, "required": [ "compare_type", "text" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "时间段类规则参数。", "properties": { "operator": { - "description": "与指定时间段的比较关系。", + "type": "string", "enum": [ "before", "is", "after" ], - "type": "string" + "description": "与指定时间段的比较关系。" }, "time_period": { - "description": "时间段类型。", + "type": "string", "enum": [ "today", "yesterday", @@ -3264,37 +3325,37 @@ "lastWeek", "nextWeek" ], - "type": "string" + "description": "时间段类型。" } }, "required": [ "operator", "time_period" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "数据条规则参数。", "properties": { - "color": { - "description": "主颜色,例如 \"#63BE7B\"。", - "type": "string" - }, "gradient": { - "description": "是否使用渐变色数据条。", - "type": "boolean" - }, - "hide_value": { - "description": "是否隐藏单元格中的原始值,仅显示数据条。", - "type": "boolean" + "type": "boolean", + "description": "是否使用渐变色数据条。" }, "value": { - "description": "阈值或比例值,含义由 value_type 决定。", - "type": "number" + "type": "number", + "description": "阈值或比例值,含义由 value_type 决定。" + }, + "color": { + "type": "string", + "description": "主颜色,例如 \"#63BE7B\"。" + }, + "hide_value": { + "type": "boolean", + "description": "是否隐藏单元格中的原始值,仅显示数据条。" }, "value_type": { - "description": "阈值类型。", + "type": "string", "enum": [ "minValue", "maxValue", @@ -3304,29 +3365,21 @@ "formula", "auto" ], - "type": "string" + "description": "阈值类型。" } }, "required": [ "color", "value_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "色阶规则中的单个分段。ColorScaleAttrs 由 2 或 3 个该对象组成,分别表示最小值/中间值/最大值。", "properties": { - "color": { - "description": "该分段对应的颜色。", - "type": "string" - }, - "value": { - "description": "阈值数值,例如百分位或具体数值。", - "type": "number" - }, "value_type": { - "description": "阈值类型。", + "type": "string", "enum": [ "minValue", "maxValue", @@ -3336,89 +3389,93 @@ "formula", "auto" ], - "type": "string" + "description": "阈值类型。" + }, + "value": { + "type": "number", + "description": "阈值数值,例如百分位或具体数值。" + }, + "color": { + "type": "string", + "description": "该分段对应的颜色。" } }, "required": [ "value_type", "color" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "前 N/后 N 规则参数。", "properties": { "is_bottom": { - "description": "是否选取最后 N 个。true 表示最后 N 个,false 表示前 N 个。", - "type": "boolean" - }, - "value": { - "description": "N 或百分比数值。", - "type": "number" + "type": "boolean", + "description": "是否选取最后 N 个。true 表示最后 N 个,false 表示前 N 个。" }, "value_type": { - "description": "排名方式:percent 表示百分比,sort 表示按条目数。", + "type": "string", "enum": [ "percent", "sort" ], - "type": "string" + "description": "排名方式:percent 表示百分比,sort 表示按条目数。" + }, + "value": { + "type": "number", + "description": "N 或百分比数值。" } }, "required": [ "is_bottom", "value_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "平均值规则参数。", "properties": { "operator": { - "description": "与平均值的比较关系。", + "type": "string", "enum": [ "greaterThan", "greaterThanOrEqual", "lessThan", "lessThanOrEqual" ], - "type": "string" + "description": "与平均值的比较关系。" } }, "required": [ "operator" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "自定义公式规则参数。", "properties": { "formula": { - "description": "条件公式列表,例如 [\"=A1>0\"]。", + "type": "array", "items": { "type": "string" }, - "type": "array" + "description": "条件公式列表,例如 [\"=A1>0\"]。" } }, "required": [ "formula" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "图标集规则参数。", "properties": { - "hide_value": { - "description": "是否隐藏单元格原始值,仅显示图标。", - "type": "boolean" - }, "icon_type": { - "description": "图标集类型。其中 3Circles/4Circles/5Circles 为不同颜色的实心圆(如红、黄、绿),用于表示状态等级;5CirclesRatio 为不同填充比例的圆(从空心◯到半圆◑到满圆●),用于表示完成度、进度、比例。当涉及半圆、满圆、空心圆、填充比例、进度等场景时,应选择 5CirclesRatio。", + "type": "string", "enum": [ "3Arrows", "3ArrowsGray", @@ -3440,28 +3497,14 @@ "3Mood", "5CirclesRatio" ], - "type": "string" - }, - "operator": { - "description": "与阈值的比较关系。", - "enum": [ - "greaterThan", - "greaterThanOrEqual", - "lessThan", - "lessThanOrEqual" - ], - "type": "string" - }, - "reverse_icons": { - "description": "是否反转图标顺序。", - "type": "boolean" + "description": "图标集类型。其中 3Circles/4Circles/5Circles 为不同颜色的实心圆(如红、黄、绿),用于表示状态等级;5CirclesRatio 为不同填充比例的圆(从空心◯到半圆◑到满圆●),用于表示完成度、进度、比例。当涉及半圆、满圆、空心圆、填充比例、进度等场景时,应选择 5CirclesRatio。" }, - "value": { - "description": "用于比较的数值,含义由 value_type 决定。", - "type": "number" + "hide_value": { + "type": "boolean", + "description": "是否隐藏单元格原始值,仅显示图标。" }, "value_type": { - "description": "阈值类型。", + "type": "string", "enum": [ "minValue", "maxValue", @@ -3471,7 +3514,25 @@ "formula", "auto" ], - "type": "string" + "description": "阈值类型。" + }, + "operator": { + "type": "string", + "enum": [ + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual" + ], + "description": "与阈值的比较关系。" + }, + "value": { + "type": "number", + "description": "用于比较的数值,含义由 value_type 决定。" + }, + "reverse_icons": { + "type": "boolean", + "description": "是否反转图标顺序。" } }, "required": [ @@ -3479,75 +3540,14 @@ "value_type", "operator" ], - "type": "object" + "additionalProperties": false } ] - }, - "type": "array" + } }, "has_ref": { - "description": "可选。是否存在引用,用于标记规则是否依赖其他对象。", - "type": "boolean" - }, - "ranges": { - "description": "应用条件格式的 A1 范围列表。每个元素支持:矩形范围 \"A1:A10\"、整行 \"3:6\"、整列 \"C:C\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。例如 [\"A1:A10\",\"C:C\"]。", - "items": { - "type": "string" - }, - "type": "array" - }, - "rule_type": { - "description": "条件格式规则类型。", - "enum": [ - "duplicateValues", - "uniqueValues", - "cellIs", - "containsText", - "timePeriod", - "containsBlanks", - "notContainsBlanks", - "dataBar", - "colorScale", - "rank", - "aboveAverage", - "expression", - "iconSet" - ], - "type": "string" - }, - "style": { - "additionalProperties": false, - "description": "命中规则时应用的单元格样式。", - "properties": { - "back_color": { - "description": "背景色,CSS 十六进制表示,例如 \"#FF0000\"。", - "type": "string" - }, - "font": { - "description": "字体样式:\"bold\"=加粗,\"italic\"=斜体,\"bold italic\"=加粗+斜体。", - "enum": [ - "bold", - "italic", - "bold italic" - ], - "type": "string" - }, - "fore_color": { - "description": "前景色/字体颜色。", - "type": "string" - }, - "text_decoration": { - "description": "文字装饰:none=无,underline=下划线,strikethrough=删除线,underline_strikethrough=下划线+删除线。", - "enum": [ - "none", - "underline", - "strikethrough", - "underline_strikethrough" - ], - "type": "string" - } - }, - "type": "object" + "type": "boolean", + "description": "可选。是否存在引用,用于标记规则是否依赖其他对象。" } }, "required": [ @@ -3555,63 +3555,65 @@ "ranges", "style" ], - "type": "object" + "additionalProperties": false } }, "+dropdown-set": { "options": { - "description": "列表选项(type='list' 时必填)", + "description": "列表选项", + "type": "array", "items": { "type": "string" - }, - "type": "array" + } } }, "+dropdown-update": { "options": { - "description": "列表选项(type='list' 时必填)", + "description": "列表选项", + "type": "array", "items": { "type": "string" - }, - "type": "array" + } } }, "+filter-create": { "properties": { - "additionalProperties": false, "description": "创建/更新的筛选器属性。", + "type": "object", "properties": { - "filtered_columns": { - "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", - "items": { - "type": "string" - }, - "type": "array" - }, "range": { - "description": "筛选对象作用的单元格范围(A1 表示法)。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。", - "type": "string" + "type": "string", + "description": "筛选对象作用的单元格范围(A1 表示法)。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。" }, "rules": { + "type": "array", "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。update 时传空数组 [] 表示清空所有现有列的规则。", "items": { - "additionalProperties": false, + "type": "object", "description": "单列筛选规则。", "properties": { "column_index": { - "description": "作用的列索引,例如 \"A\"、\"B\"。", - "type": "string" + "type": "string", + "description": "作用的列索引,例如 \"A\"、\"B\"。" }, "conditions": { + "type": "array", "description": "该列上的条件列表。**目前每列仅支持 1 个 condition**(传入多个会被拒绝,请用 multiValue 或单条件表达),数组形式为未来扩展多条件组合预留。", "items": { "oneOf": [ { - "additionalProperties": false, + "type": "object", "description": "文本条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "条件类型固定为 \"text\"。" + }, "compare_type": { - "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。", + "type": "string", "enum": [ "beginsWith", "doesNotBeginWith", @@ -3622,35 +3624,35 @@ "equals", "notEquals" ], - "type": "string" - }, - "type": { - "description": "条件类型固定为 \"text\"。", - "enum": [ - "text" - ], - "type": "string" + "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。" }, "values": { + "type": "array", "description": "可选。用于比较的文本值列表,只需要提供一个值,例如 [\"华东\"]。", "items": { "type": "string" - }, - "type": "array" + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "数值条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "number" + ], + "description": "条件类型固定为 \"number\"。" + }, "compare_type": { - "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。", + "type": "string", "enum": [ "equal", "notEqual", @@ -3661,16 +3663,10 @@ "between", "notBetween" ], - "type": "string" - }, - "type": { - "description": "条件类型固定为 \"number\"。", - "enum": [ - "number" - ], - "type": "string" + "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。" }, "values": { + "type": "array", "description": "可选。用于比较的数值参数列表。大多数比较运算需要 1 个值(如 [5000]),between/notBetween 需要 2 个值(如 [1000, 5000])。", "items": { "oneOf": [ @@ -3681,73 +3677,82 @@ "type": "string" } ] - }, - "type": "array" + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "颜色条件筛选。", "properties": { - "compare_type": { - "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。", + "type": { + "type": "string", "enum": [ - "backgroundColor", - "foregroundColor" + "color" ], - "type": "string" + "description": "条件类型固定为 \"color\"。" }, - "type": { - "description": "条件类型固定为 \"color\"。", + "compare_type": { + "type": "string", "enum": [ - "color" + "backgroundColor", + "foregroundColor" ], - "type": "string" + "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。" }, "value": { - "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。", - "type": "string" + "type": "string", + "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。" } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "多值条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "multiValue" + ], + "description": "条件类型固定为 \"multiValue\"。" + }, "compare_type": { - "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。", + "type": "string", "enum": [ "equal", "notEqual" ], - "type": "string" + "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。" + }, + "values": { + "type": "array", + "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", + "items": { + "type": "string" + } }, "date_groups": { + "type": "array", "description": "可选。年月日等聚合筛选信息。", "items": { - "additionalProperties": false, + "type": "object", "properties": { - "date_time_grouping": { - "enum": [ - "year", - "month", - "day", - "hour", - "minute", - "second" - ], - "type": "string" + "year": { + "type": "number" + }, + "month": { + "type": "number" }, "day": { "type": "number" @@ -3758,105 +3763,102 @@ "minute": { "type": "number" }, - "month": { - "type": "number" - }, "second": { "type": "number" }, - "year": { - "type": "number" - } - }, - "type": "object" - }, - "type": "array" - }, - "type": { - "description": "条件类型固定为 \"multiValue\"。", - "enum": [ - "multiValue" - ], - "type": "string" - }, - "values": { - "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", - "items": { - "type": "string" - }, - "type": "array" + "date_time_grouping": { + "type": "string", + "enum": [ + "year", + "month", + "day", + "hour", + "minute", + "second" + ] + } + }, + "additionalProperties": false + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false } ] - }, - "type": "array" + } }, "filtered_rows": { + "type": "array", "description": "可选。由该列条件直接导致被隐藏的行索引列表(0-based),用于调试或回显。", "items": { "type": "number" - }, - "type": "array" + } } }, "required": [ "column_index", "conditions" ], - "type": "object" - }, - "type": "array" + "additionalProperties": false + } + }, + "filtered_columns": { + "type": "array", + "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", + "items": { + "type": "string" + } } }, "required": [ "range", "rules" ], - "type": "object" + "additionalProperties": false } }, "+filter-update": { "properties": { - "additionalProperties": false, "description": "创建/更新的筛选器属性。", + "type": "object", "properties": { - "filtered_columns": { - "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", - "items": { - "type": "string" - }, - "type": "array" - }, "range": { - "description": "筛选对象作用的单元格范围(A1 表示法)。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。", - "type": "string" + "type": "string", + "description": "筛选对象作用的单元格范围(A1 表示法)。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。" }, "rules": { + "type": "array", "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。update 时传空数组 [] 表示清空所有现有列的规则。", "items": { - "additionalProperties": false, + "type": "object", "description": "单列筛选规则。", "properties": { "column_index": { - "description": "作用的列索引,例如 \"A\"、\"B\"。", - "type": "string" + "type": "string", + "description": "作用的列索引,例如 \"A\"、\"B\"。" }, "conditions": { + "type": "array", "description": "该列上的条件列表。**目前每列仅支持 1 个 condition**(传入多个会被拒绝,请用 multiValue 或单条件表达),数组形式为未来扩展多条件组合预留。", "items": { "oneOf": [ { - "additionalProperties": false, + "type": "object", "description": "文本条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "条件类型固定为 \"text\"。" + }, "compare_type": { - "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。", + "type": "string", "enum": [ "beginsWith", "doesNotBeginWith", @@ -3867,35 +3869,35 @@ "equals", "notEquals" ], - "type": "string" - }, - "type": { - "description": "条件类型固定为 \"text\"。", - "enum": [ - "text" - ], - "type": "string" + "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。" }, "values": { + "type": "array", "description": "可选。用于比较的文本值列表,只需要提供一个值,例如 [\"华东\"]。", "items": { "type": "string" - }, - "type": "array" + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "数值条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "number" + ], + "description": "条件类型固定为 \"number\"。" + }, "compare_type": { - "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。", + "type": "string", "enum": [ "equal", "notEqual", @@ -3906,16 +3908,10 @@ "between", "notBetween" ], - "type": "string" - }, - "type": { - "description": "条件类型固定为 \"number\"。", - "enum": [ - "number" - ], - "type": "string" + "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。" }, "values": { + "type": "array", "description": "可选。用于比较的数值参数列表。大多数比较运算需要 1 个值(如 [5000]),between/notBetween 需要 2 个值(如 [1000, 5000])。", "items": { "oneOf": [ @@ -3926,73 +3922,82 @@ "type": "string" } ] - }, - "type": "array" + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "颜色条件筛选。", "properties": { - "compare_type": { - "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。", + "type": { + "type": "string", "enum": [ - "backgroundColor", - "foregroundColor" + "color" ], - "type": "string" + "description": "条件类型固定为 \"color\"。" }, - "type": { - "description": "条件类型固定为 \"color\"。", + "compare_type": { + "type": "string", "enum": [ - "color" + "backgroundColor", + "foregroundColor" ], - "type": "string" + "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。" }, "value": { - "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。", - "type": "string" + "type": "string", + "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。" } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "多值条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "multiValue" + ], + "description": "条件类型固定为 \"multiValue\"。" + }, "compare_type": { - "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。", + "type": "string", "enum": [ "equal", "notEqual" ], - "type": "string" + "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。" + }, + "values": { + "type": "array", + "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", + "items": { + "type": "string" + } }, "date_groups": { + "type": "array", "description": "可选。年月日等聚合筛选信息。", "items": { - "additionalProperties": false, + "type": "object", "properties": { - "date_time_grouping": { - "enum": [ - "year", - "month", - "day", - "hour", - "minute", - "second" - ], - "type": "string" + "year": { + "type": "number" + }, + "month": { + "type": "number" }, "day": { "type": "number" @@ -4003,105 +4008,106 @@ "minute": { "type": "number" }, - "month": { - "type": "number" - }, "second": { "type": "number" }, - "year": { - "type": "number" + "date_time_grouping": { + "type": "string", + "enum": [ + "year", + "month", + "day", + "hour", + "minute", + "second" + ] } }, - "type": "object" - }, - "type": "array" - }, - "type": { - "description": "条件类型固定为 \"multiValue\"。", - "enum": [ - "multiValue" - ], - "type": "string" - }, - "values": { - "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", - "items": { - "type": "string" - }, - "type": "array" + "additionalProperties": false + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false } ] - }, - "type": "array" + } }, "filtered_rows": { + "type": "array", "description": "可选。由该列条件直接导致被隐藏的行索引列表(0-based),用于调试或回显。", "items": { "type": "number" - }, - "type": "array" + } } }, "required": [ "column_index", "conditions" ], - "type": "object" - }, - "type": "array" + "additionalProperties": false + } + }, + "filtered_columns": { + "type": "array", + "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", + "items": { + "type": "string" + } } }, "required": [ "range", "rules" ], - "type": "object" + "additionalProperties": false } }, "+filter-view-create": { "properties": { - "additionalProperties": false, "description": "create / update 的视图属性。create 必须传 range;update 至少传 view_name / range / rules 之一。delete 禁止传该字段。", + "type": "object", "properties": { - "filtered_columns": { - "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", - "items": { - "type": "string" - }, - "type": "array" + "view_name": { + "type": "string", + "description": "可选。视图名称。create 不传时系统自动分配;create / update 传入时若与现有视图重名,会自动以后缀避让,不会抛错。" }, "range": { - "description": "视图作用的单元格范围(A1 表示法)。create 必填;update 可选——未提供时沿用视图当前 range。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。", - "type": "string" + "type": "string", + "description": "视图作用的单元格范围(A1 表示法)。create 必填;update 可选——未提供时沿用视图当前 range。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。" }, "rules": { + "type": "array", "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。结构与 manage_filter_object.properties.rules 完全一致。", "items": { - "additionalProperties": false, + "type": "object", "description": "单列筛选规则。", "properties": { "column_index": { - "description": "作用的列索引,例如 \"A\"、\"B\"。", - "type": "string" + "type": "string", + "description": "作用的列索引,例如 \"A\"、\"B\"。" }, "conditions": { + "type": "array", "description": "该列上的条件列表。**目前每列仅支持 1 个 condition**(传入多个会被拒绝,请用 multiValue 或单条件表达),数组形式为未来扩展多条件组合预留。", "items": { "oneOf": [ { - "additionalProperties": false, + "type": "object", "description": "文本条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "条件类型固定为 \"text\"。" + }, "compare_type": { - "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。", + "type": "string", "enum": [ "beginsWith", "doesNotBeginWith", @@ -4112,35 +4118,35 @@ "equals", "notEquals" ], - "type": "string" - }, - "type": { - "description": "条件类型固定为 \"text\"。", - "enum": [ - "text" - ], - "type": "string" + "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。" }, "values": { + "type": "array", "description": "可选。用于比较的文本值列表,只需要提供一个值,例如 [\"华东\"]。", "items": { "type": "string" - }, - "type": "array" + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "数值条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "number" + ], + "description": "条件类型固定为 \"number\"。" + }, "compare_type": { - "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。", + "type": "string", "enum": [ "equal", "notEqual", @@ -4151,16 +4157,10 @@ "between", "notBetween" ], - "type": "string" - }, - "type": { - "description": "条件类型固定为 \"number\"。", - "enum": [ - "number" - ], - "type": "string" + "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。" }, "values": { + "type": "array", "description": "可选。用于比较的数值参数列表。大多数比较运算需要 1 个值(如 [5000]),between/notBetween 需要 2 个值(如 [1000, 5000])。", "items": { "oneOf": [ @@ -4171,73 +4171,82 @@ "type": "string" } ] - }, - "type": "array" + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "颜色条件筛选。", "properties": { - "compare_type": { - "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。", + "type": { + "type": "string", "enum": [ - "backgroundColor", - "foregroundColor" + "color" ], - "type": "string" + "description": "条件类型固定为 \"color\"。" }, - "type": { - "description": "条件类型固定为 \"color\"。", + "compare_type": { + "type": "string", "enum": [ - "color" + "backgroundColor", + "foregroundColor" ], - "type": "string" + "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。" }, "value": { - "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。", - "type": "string" + "type": "string", + "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。" } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "多值条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "multiValue" + ], + "description": "条件类型固定为 \"multiValue\"。" + }, "compare_type": { - "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。", + "type": "string", "enum": [ "equal", "notEqual" ], - "type": "string" + "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。" + }, + "values": { + "type": "array", + "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", + "items": { + "type": "string" + } }, "date_groups": { + "type": "array", "description": "可选。年月日等聚合筛选信息。", "items": { - "additionalProperties": false, + "type": "object", "properties": { - "date_time_grouping": { - "enum": [ - "year", - "month", - "day", - "hour", - "minute", - "second" - ], - "type": "string" + "year": { + "type": "number" + }, + "month": { + "type": "number" }, "day": { "type": "number" @@ -4248,105 +4257,102 @@ "minute": { "type": "number" }, - "month": { - "type": "number" - }, "second": { "type": "number" }, - "year": { - "type": "number" + "date_time_grouping": { + "type": "string", + "enum": [ + "year", + "month", + "day", + "hour", + "minute", + "second" + ] } }, - "type": "object" - }, - "type": "array" - }, - "type": { - "description": "条件类型固定为 \"multiValue\"。", - "enum": [ - "multiValue" - ], - "type": "string" - }, - "values": { - "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", - "items": { - "type": "string" - }, - "type": "array" + "additionalProperties": false + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false } ] - }, - "type": "array" + } }, "filtered_rows": { + "type": "array", "description": "可选。由该列条件直接导致被隐藏的行索引列表(0-based),用于调试或回显。", "items": { "type": "number" - }, - "type": "array" + } } }, "required": [ "column_index", "conditions" ], - "type": "object" - }, - "type": "array" + "additionalProperties": false + } }, - "view_name": { - "description": "可选。视图名称。create 不传时系统自动分配;create / update 传入时若与现有视图重名,会自动以后缀避让,不会抛错。", - "type": "string" + "filtered_columns": { + "type": "array", + "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", + "items": { + "type": "string" + } } }, - "type": "object" + "additionalProperties": false } }, "+filter-view-update": { "properties": { - "additionalProperties": false, "description": "create / update 的视图属性。create 必须传 range;update 至少传 view_name / range / rules 之一。delete 禁止传该字段。", + "type": "object", "properties": { - "filtered_columns": { - "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", - "items": { - "type": "string" - }, - "type": "array" + "view_name": { + "type": "string", + "description": "可选。视图名称。create 不传时系统自动分配;create / update 传入时若与现有视图重名,会自动以后缀避让,不会抛错。" }, "range": { - "description": "视图作用的单元格范围(A1 表示法)。create 必填;update 可选——未提供时沿用视图当前 range。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。", - "type": "string" + "type": "string", + "description": "视图作用的单元格范围(A1 表示法)。create 必填;update 可选——未提供时沿用视图当前 range。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。" }, "rules": { + "type": "array", "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。结构与 manage_filter_object.properties.rules 完全一致。", "items": { - "additionalProperties": false, + "type": "object", "description": "单列筛选规则。", "properties": { "column_index": { - "description": "作用的列索引,例如 \"A\"、\"B\"。", - "type": "string" + "type": "string", + "description": "作用的列索引,例如 \"A\"、\"B\"。" }, "conditions": { + "type": "array", "description": "该列上的条件列表。**目前每列仅支持 1 个 condition**(传入多个会被拒绝,请用 multiValue 或单条件表达),数组形式为未来扩展多条件组合预留。", "items": { "oneOf": [ { - "additionalProperties": false, + "type": "object", "description": "文本条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "条件类型固定为 \"text\"。" + }, "compare_type": { - "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。", + "type": "string", "enum": [ "beginsWith", "doesNotBeginWith", @@ -4355,37 +4361,37 @@ "contains", "doesNotContain", "equals", - "notEquals" - ], - "type": "string" - }, - "type": { - "description": "条件类型固定为 \"text\"。", - "enum": [ - "text" + "notEquals" ], - "type": "string" + "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。" }, "values": { + "type": "array", "description": "可选。用于比较的文本值列表,只需要提供一个值,例如 [\"华东\"]。", "items": { "type": "string" - }, - "type": "array" + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "数值条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "number" + ], + "description": "条件类型固定为 \"number\"。" + }, "compare_type": { - "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。", + "type": "string", "enum": [ "equal", "notEqual", @@ -4396,16 +4402,10 @@ "between", "notBetween" ], - "type": "string" - }, - "type": { - "description": "条件类型固定为 \"number\"。", - "enum": [ - "number" - ], - "type": "string" + "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。" }, "values": { + "type": "array", "description": "可选。用于比较的数值参数列表。大多数比较运算需要 1 个值(如 [5000]),between/notBetween 需要 2 个值(如 [1000, 5000])。", "items": { "oneOf": [ @@ -4416,73 +4416,82 @@ "type": "string" } ] - }, - "type": "array" + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "颜色条件筛选。", "properties": { - "compare_type": { - "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。", + "type": { + "type": "string", "enum": [ - "backgroundColor", - "foregroundColor" + "color" ], - "type": "string" + "description": "条件类型固定为 \"color\"。" }, - "type": { - "description": "条件类型固定为 \"color\"。", + "compare_type": { + "type": "string", "enum": [ - "color" + "backgroundColor", + "foregroundColor" ], - "type": "string" + "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。" }, "value": { - "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。", - "type": "string" + "type": "string", + "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。" } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "多值条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "multiValue" + ], + "description": "条件类型固定为 \"multiValue\"。" + }, "compare_type": { - "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。", + "type": "string", "enum": [ "equal", "notEqual" ], - "type": "string" + "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。" + }, + "values": { + "type": "array", + "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", + "items": { + "type": "string" + } }, "date_groups": { + "type": "array", "description": "可选。年月日等聚合筛选信息。", "items": { - "additionalProperties": false, + "type": "object", "properties": { - "date_time_grouping": { - "enum": [ - "year", - "month", - "day", - "hour", - "minute", - "second" - ], - "type": "string" + "year": { + "type": "number" + }, + "month": { + "type": "number" }, "day": { "type": "number" @@ -4493,288 +4502,148 @@ "minute": { "type": "number" }, - "month": { - "type": "number" - }, "second": { "type": "number" }, - "year": { - "type": "number" + "date_time_grouping": { + "type": "string", + "enum": [ + "year", + "month", + "day", + "hour", + "minute", + "second" + ] } }, - "type": "object" - }, - "type": "array" - }, - "type": { - "description": "条件类型固定为 \"multiValue\"。", - "enum": [ - "multiValue" - ], - "type": "string" - }, - "values": { - "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", - "items": { - "type": "string" - }, - "type": "array" + "additionalProperties": false + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false } ] - }, - "type": "array" + } }, "filtered_rows": { + "type": "array", "description": "可选。由该列条件直接导致被隐藏的行索引列表(0-based),用于调试或回显。", "items": { "type": "number" - }, - "type": "array" + } } }, "required": [ "column_index", "conditions" ], - "type": "object" - }, - "type": "array" + "additionalProperties": false + } }, - "view_name": { - "description": "可选。视图名称。create 不传时系统自动分配;create / update 传入时若与现有视图重名,会自动以后缀避让,不会抛错。", - "type": "string" + "filtered_columns": { + "type": "array", + "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", + "items": { + "type": "string" + } } }, - "type": "object" + "additionalProperties": false } }, "+pivot-create": { "properties": { - "additionalProperties": {}, "description": "创建/更新的透视表属性。", + "type": "object", "properties": { - "auto_fit_col": { - "description": "是否自动调整列宽以适应内容", - "type": "boolean" - }, - "calculated_fields": { - "description": "计算字段列表", - "items": { - "properties": { - "formula": { - "description": "公式表达式,引用其他字段名,例如 \"'Sales' + 'Tax'\"", - "type": "string" - }, - "name": { - "description": "计算字段的显示名称", - "type": "string" - }, - "summarize_by": { - "default": "sum", - "description": "计算字段汇总方式(默认 'sum',仅支持 sum/custom 两种):'sum' 表示先对 formula 中引用的各源字段分别求和后再代入公式(formula 是逐字段求和后的标量表达式,例如 \"'Sales'+'Tax'\" 等价于 SUM(Sales)+SUM(Tax));'custom' 表示数组直接传给 formula,要求 formula 自带聚合函数(例如 \"SUM('Sales')+SUM('Tax')\" 或 \"AVERAGE('Sales')\")。需要 count/average/max/min 等其他聚合方式时,请改用 values[] 字段对源字段直接聚合,calculated_fields 不适用。", - "enum": [ - "sum", - "custom" - ], - "type": "string" - } - }, - "required": [ - "name", - "formula" - ], - "type": "object" - }, - "type": "array" + "range": { + "type": "string", + "description": "放置透视表的左上角单元格 A1 地址(例如:'F1')(仅 create 时有效)。可省略——省略后透视表放在新建子表的 A1 位置。" }, - "collapse": { - "additionalProperties": { - "items": { - "type": "string" - }, - "type": "array" - }, - "description": "行字段展开/折叠状态:字段名 -> 要折叠的项目列表", - "type": "object" + "source": { + "type": "string", + "description": "源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100')。create 时必填;update 时提供则切换数据源。" }, - "columns": { - "description": "横向分组字段(列字段)", + "rows": { + "description": "纵向分组字段(行字段)", + "type": "array", "items": { + "type": "object", "properties": { - "condition_filter": { - "description": "条件筛选:按文本/数值/日期条件筛选", - "properties": { - "operator": { - "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", - "type": "string" - }, - "type": { - "description": "条件类型", - "enum": [ - "text", - "number", - "date" - ], - "type": "string" - }, - "value": { - "description": "比较值" - }, - "value2": { - "description": "'between'/'notBetween' 的第二个边界值" - } - }, - "required": [ - "type", - "operator" - ], - "type": "object" - }, - "display_name": { - "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。", - "type": "string" - }, "field": { - "description": "源数据中的字段名(OOXML 字段引用)", - "type": "string" + "type": "string", + "description": "源数据中的字段名(OOXML 字段引用)" }, - "filter": { - "description": "快速筛选:只显示指定的项目", - "properties": { - "items": { - "description": "要显示的项目列表(其余项目被隐藏)", - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "items" - ], - "type": "object" + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用;否则保持源字段原名,透视表产物已足够清晰。" }, - "group": { - "description": "分组配置", + "sort": { + "type": "object", + "description": "排序配置", "properties": { - "date_group_by": { - "description": "日期分组粒度(type='date' 时必填)", + "order": { + "type": "string", "enum": [ - "year", - "yearMonth", - "yearQuarter", - "yearMonthDate", - "quarter", - "month", - "monthDate", - "date", - "hour", - "hourMinute", - "minute" + "asc", + "desc" ], - "type": "string" - }, - "end": { - "description": "数值分组结束值", - "type": "number" - }, - "groups": { - "additionalProperties": { - "items": { - "type": "string" - }, - "type": "array" - }, - "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", - "type": "object" - }, - "interval": { - "description": "数值分组间隔(type='number' 时必填)", - "type": "number" - }, - "start": { - "description": "数值分组起始值", - "type": "number" + "description": "排序方向" }, - "type": { - "description": "分组类型", - "enum": [ - "date", - "number", - "element" - ], - "type": "string" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - "sort": { - "description": "排序配置", - "properties": { "by": { - "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序", + "type": "string", "enum": [ "label", "value" ], - "type": "string" - }, - "order": { - "description": "排序方向", - "enum": [ - "asc", - "desc" - ], - "type": "string" + "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序" }, "value_field": { - "description": "by='value' 时必填,指定按哪个值字段排序", - "type": "string" + "type": "string", + "description": "by='value' 时必填,指定按哪个值字段排序" } }, "required": [ "order" - ], - "type": "object" - } - }, - "required": [ - "field" - ], - "type": "object" - }, - "type": "array" - }, - "filters": { - "description": "筛选区域字段(页字段)", - "items": { - "properties": { + ] + }, + "filter": { + "type": "object", + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + }, + "description": "要显示的项目列表(其余项目被隐藏)" + } + }, + "required": [ + "items" + ] + }, "condition_filter": { + "type": "object", "description": "条件筛选:按文本/数值/日期条件筛选", "properties": { - "operator": { - "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", - "type": "string" - }, "type": { - "description": "条件类型", + "type": "string", "enum": [ "text", "number", "date" ], - "type": "string" + "description": "条件类型" + }, + "operator": { + "type": "string", + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" }, "value": { "description": "比较值" @@ -4786,38 +4655,23 @@ "required": [ "type", "operator" - ], - "type": "object" - }, - "display_name": { - "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。", - "type": "string" - }, - "field": { - "description": "源数据中的字段名(OOXML 字段引用)", - "type": "string" - }, - "filter": { - "description": "快速筛选:只显示指定的项目", - "properties": { - "items": { - "description": "要显示的项目列表(其余项目被隐藏)", - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "items" - ], - "type": "object" + ] }, "group": { + "type": "object", "description": "分组配置", "properties": { + "type": { + "type": "string", + "enum": [ + "date", + "number", + "element" + ], + "description": "分组类型" + }, "date_group_by": { - "description": "日期分组粒度(type='date' 时必填)", + "type": "string", "enum": [ "year", "yearMonth", @@ -4831,123 +4685,144 @@ "hourMinute", "minute" ], - "type": "string" + "description": "日期分组粒度(type='date' 时必填)" + }, + "interval": { + "type": "number", + "description": "数值分组间隔(type='number' 时必填)" + }, + "start": { + "type": "number", + "description": "数值分组起始值" }, "end": { - "description": "数值分组结束值", - "type": "number" + "type": "number", + "description": "数值分组结束值" }, "groups": { + "type": "object", + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", "additionalProperties": { + "type": "array", "items": { "type": "string" - }, - "type": "array" - }, - "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", - "type": "object" - }, - "interval": { - "description": "数值分组间隔(type='number' 时必填)", - "type": "number" - }, - "start": { - "description": "数值分组起始值", - "type": "number" - }, - "type": { - "description": "分组类型", - "enum": [ - "date", - "number", - "element" - ], - "type": "string" + } + } } }, "required": [ "type" - ], - "type": "object" + ] } }, "required": [ "field" - ], - "type": "object" - }, - "type": "array" - }, - "range": { - "description": "放置透视表的左上角单元格 A1 地址(例如:'F1')(仅 create 时有效)。可省略——省略后透视表放在新建子表的 A1 位置。", - "type": "string" - }, - "repeat_row_labels": { - "description": "是否显示重复项标签", - "type": "boolean" + ] + } }, - "rows": { - "description": "纵向分组字段(行字段)", + "columns": { + "description": "横向分组字段(列字段)", + "type": "array", "items": { + "type": "object", "properties": { - "condition_filter": { - "description": "条件筛选:按文本/数值/日期条件筛选", + "field": { + "type": "string", + "description": "源数据中的字段名(OOXML 字段引用)" + }, + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。" + }, + "sort": { + "type": "object", + "description": "排序配置", "properties": { - "operator": { - "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", - "type": "string" - }, - "type": { - "description": "条件类型", + "order": { + "type": "string", "enum": [ - "text", - "number", - "date" + "asc", + "desc" ], - "type": "string" + "description": "排序方向" }, - "value": { - "description": "比较值" + "by": { + "type": "string", + "enum": [ + "label", + "value" + ], + "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序" }, - "value2": { - "description": "'between'/'notBetween' 的第二个边界值" + "value_field": { + "type": "string", + "description": "by='value' 时必填,指定按哪个值字段排序" } }, "required": [ - "type", - "operator" - ], - "type": "object" - }, - "display_name": { - "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用;否则保持源字段原名,透视表产物已足够清晰。", - "type": "string" - }, - "field": { - "description": "源数据中的字段名(OOXML 字段引用)", - "type": "string" + "order" + ] }, "filter": { + "type": "object", "description": "快速筛选:只显示指定的项目", "properties": { "items": { - "description": "要显示的项目列表(其余项目被隐藏)", + "type": "array", "items": { "type": "string" }, - "type": "array" + "description": "要显示的项目列表(其余项目被隐藏)" } }, "required": [ "items" - ], - "type": "object" + ] + }, + "condition_filter": { + "type": "object", + "description": "条件筛选:按文本/数值/日期条件筛选", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "number", + "date" + ], + "description": "条件类型" + }, + "operator": { + "type": "string", + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" + }, + "value": { + "description": "比较值" + }, + "value2": { + "description": "'between'/'notBetween' 的第二个边界值" + } + }, + "required": [ + "type", + "operator" + ] }, "group": { + "type": "object", "description": "分组配置", "properties": { + "type": { + "type": "string", + "enum": [ + "date", + "number", + "element" + ], + "description": "分组类型" + }, "date_group_by": { - "description": "日期分组粒度(type='date' 时必填)", + "type": "string", "enum": [ "year", "yearMonth", @@ -4961,130 +4836,181 @@ "hourMinute", "minute" ], - "type": "string" + "description": "日期分组粒度(type='date' 时必填)" + }, + "interval": { + "type": "number", + "description": "数值分组间隔(type='number' 时必填)" + }, + "start": { + "type": "number", + "description": "数值分组起始值" }, "end": { - "description": "数值分组结束值", - "type": "number" + "type": "number", + "description": "数值分组结束值" }, "groups": { + "type": "object", + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", "additionalProperties": { + "type": "array", "items": { "type": "string" - }, - "type": "array" + } + } + } + }, + "required": [ + "type" + ] + } + }, + "required": [ + "field" + ] + } + }, + "filters": { + "description": "筛选区域字段(页字段)", + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "源数据中的字段名(OOXML 字段引用)" + }, + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。" + }, + "filter": { + "type": "object", + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" }, - "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", - "type": "object" - }, - "interval": { - "description": "数值分组间隔(type='number' 时必填)", - "type": "number" - }, - "start": { - "description": "数值分组起始值", - "type": "number" - }, + "description": "要显示的项目列表(其余项目被隐藏)" + } + }, + "required": [ + "items" + ] + }, + "condition_filter": { + "type": "object", + "description": "条件筛选:按文本/数值/日期条件筛选", + "properties": { "type": { - "description": "分组类型", + "type": "string", "enum": [ - "date", + "text", "number", - "element" + "date" ], - "type": "string" + "description": "条件类型" + }, + "operator": { + "type": "string", + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" + }, + "value": { + "description": "比较值" + }, + "value2": { + "description": "'between'/'notBetween' 的第二个边界值" } }, "required": [ - "type" - ], - "type": "object" + "type", + "operator" + ] }, - "sort": { - "description": "排序配置", + "group": { + "type": "object", + "description": "分组配置", "properties": { - "by": { - "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序", + "type": { + "type": "string", "enum": [ - "label", - "value" + "date", + "number", + "element" + ], + "description": "分组类型" + }, + "date_group_by": { + "type": "string", + "enum": [ + "year", + "yearMonth", + "yearQuarter", + "yearMonthDate", + "quarter", + "month", + "monthDate", + "date", + "hour", + "hourMinute", + "minute" ], - "type": "string" + "description": "日期分组粒度(type='date' 时必填)" }, - "order": { - "description": "排序方向", - "enum": [ - "asc", - "desc" - ], - "type": "string" + "interval": { + "type": "number", + "description": "数值分组间隔(type='number' 时必填)" }, - "value_field": { - "description": "by='value' 时必填,指定按哪个值字段排序", - "type": "string" + "start": { + "type": "number", + "description": "数值分组起始值" + }, + "end": { + "type": "number", + "description": "数值分组结束值" + }, + "groups": { + "type": "object", + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } } }, "required": [ - "order" - ], - "type": "object" + "type" + ] } }, "required": [ "field" - ], - "type": "object" - }, - "type": "array" - }, - "show_col_grand_total": { - "description": "是否显示列总计(默认 true)", - "type": "boolean" - }, - "show_row_grand_total": { - "description": "是否显示行总计(默认 true)", - "type": "boolean" - }, - "show_subtotals": { - "description": "是否显示分类小计(默认 true,应用于所有字段)", - "type": "boolean" - }, - "source": { - "description": "源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100')。create 时必填;update 时提供则切换数据源。", - "type": "string" + ] + } }, "values": { - "description": "要汇总的字段(至少需要 1 个)", + "minItems": 1, + "type": "array", "items": { + "type": "object", "properties": { - "base_field": { - "description": "show_data_as 需要基准字段时的字段名", - "type": "string" - }, - "display_name": { - "description": "字段在透视表中的显示名(典型场景:把 count(销售额) 的值列头显示为 'GMV')。不传则使用源字段原名。仅在用户明确要求重命名时使用。", - "type": "string" - }, "field": { - "description": "要汇总的源数据字段名", - "type": "string" + "type": "string", + "description": "要汇总的源数据字段名" }, - "show_data_as": { - "description": "值显示方式(默认 'normal')", - "enum": [ - "normal", - "percentOfTotal", - "percentOfCol", - "percentOfRow", - "percentOfParentRow", - "percentOfParentCol", - "index" - ], - "type": "string" + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名(典型场景:把 count(销售额) 的值列头显示为 'GMV')。不传则使用源字段原名。仅在用户明确要求重命名时使用。" }, "summarize_by": { "default": "sum", "description": "汇总函数", + "type": "string", "enum": [ "sum", "count", @@ -5099,133 +5025,212 @@ "varp", "distinct", "median" - ], - "type": "string" + ] + }, + "show_data_as": { + "type": "string", + "description": "值显示方式(默认 'normal')", + "enum": [ + "normal", + "percentOfTotal", + "percentOfCol", + "percentOfRow", + "percentOfParentRow", + "percentOfParentCol", + "index" + ] + }, + "base_field": { + "type": "string", + "description": "show_data_as 需要基准字段时的字段名" } }, "required": [ "field" - ], - "type": "object" + ] }, - "minItems": 1, - "type": "array" - } - }, - "type": "object" - } - }, - "+pivot-update": { - "properties": { - "additionalProperties": {}, - "description": "创建/更新的透视表属性。", - "properties": { + "description": "要汇总的字段(至少需要 1 个)" + }, "auto_fit_col": { - "description": "是否自动调整列宽以适应内容", - "type": "boolean" + "type": "boolean", + "description": "是否自动调整列宽以适应内容" + }, + "show_row_grand_total": { + "type": "boolean", + "description": "是否显示行总计(默认 true)" + }, + "show_col_grand_total": { + "type": "boolean", + "description": "是否显示列总计(默认 true)" + }, + "show_subtotals": { + "type": "boolean", + "description": "是否显示分类小计(默认 true,应用于所有字段)" + }, + "repeat_row_labels": { + "type": "boolean", + "description": "是否显示重复项标签" }, "calculated_fields": { + "type": "array", "description": "计算字段列表", "items": { + "type": "object", "properties": { - "formula": { - "description": "公式表达式,引用其他字段名,例如 \"'Sales' + 'Tax'\"", - "type": "string" - }, "name": { - "description": "计算字段的显示名称", - "type": "string" + "type": "string", + "description": "计算字段的显示名称" + }, + "formula": { + "type": "string", + "description": "公式表达式,引用其他字段名,例如 \"'Sales' + 'Tax'\"" }, "summarize_by": { + "type": "string", "default": "sum", "description": "计算字段汇总方式(默认 'sum',仅支持 sum/custom 两种):'sum' 表示先对 formula 中引用的各源字段分别求和后再代入公式(formula 是逐字段求和后的标量表达式,例如 \"'Sales'+'Tax'\" 等价于 SUM(Sales)+SUM(Tax));'custom' 表示数组直接传给 formula,要求 formula 自带聚合函数(例如 \"SUM('Sales')+SUM('Tax')\" 或 \"AVERAGE('Sales')\")。需要 count/average/max/min 等其他聚合方式时,请改用 values[] 字段对源字段直接聚合,calculated_fields 不适用。", "enum": [ "sum", "custom" - ], - "type": "string" + ] } }, "required": [ "name", "formula" - ], - "type": "object" - }, - "type": "array" + ] + } }, "collapse": { + "type": "object", + "description": "行字段展开/折叠状态:字段名 -> 要折叠的项目列表", "additionalProperties": { + "type": "array", "items": { "type": "string" - }, - "type": "array" - }, - "description": "行字段展开/折叠状态:字段名 -> 要折叠的项目列表", - "type": "object" + } + } + } + }, + "additionalProperties": {} + } + }, + "+pivot-update": { + "properties": { + "description": "创建/更新的透视表属性。", + "type": "object", + "properties": { + "range": { + "type": "string", + "description": "放置透视表的左上角单元格 A1 地址(例如:'F1')(仅 create 时有效)。可省略——省略后透视表放在新建子表的 A1 位置。" }, - "columns": { - "description": "横向分组字段(列字段)", + "source": { + "type": "string", + "description": "源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100')。create 时必填;update 时提供则切换数据源。" + }, + "rows": { + "description": "纵向分组字段(行字段)", + "type": "array", "items": { + "type": "object", "properties": { - "condition_filter": { - "description": "条件筛选:按文本/数值/日期条件筛选", + "field": { + "type": "string", + "description": "源数据中的字段名(OOXML 字段引用)" + }, + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用;否则保持源字段原名,透视表产物已足够清晰。" + }, + "sort": { + "type": "object", + "description": "排序配置", "properties": { - "operator": { - "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", - "type": "string" - }, - "type": { - "description": "条件类型", + "order": { + "type": "string", "enum": [ - "text", - "number", - "date" + "asc", + "desc" ], - "type": "string" + "description": "排序方向" }, - "value": { - "description": "比较值" + "by": { + "type": "string", + "enum": [ + "label", + "value" + ], + "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序" }, - "value2": { - "description": "'between'/'notBetween' 的第二个边界值" + "value_field": { + "type": "string", + "description": "by='value' 时必填,指定按哪个值字段排序" } }, "required": [ - "type", - "operator" - ], - "type": "object" - }, - "display_name": { - "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。", - "type": "string" - }, - "field": { - "description": "源数据中的字段名(OOXML 字段引用)", - "type": "string" + "order" + ] }, "filter": { + "type": "object", "description": "快速筛选:只显示指定的项目", "properties": { "items": { - "description": "要显示的项目列表(其余项目被隐藏)", + "type": "array", "items": { "type": "string" }, - "type": "array" + "description": "要显示的项目列表(其余项目被隐藏)" } }, "required": [ "items" - ], - "type": "object" + ] + }, + "condition_filter": { + "type": "object", + "description": "条件筛选:按文本/数值/日期条件筛选", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "number", + "date" + ], + "description": "条件类型" + }, + "operator": { + "type": "string", + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" + }, + "value": { + "description": "比较值" + }, + "value2": { + "description": "'between'/'notBetween' 的第二个边界值" + } + }, + "required": [ + "type", + "operator" + ] }, "group": { + "type": "object", "description": "分组配置", "properties": { + "type": { + "type": "string", + "enum": [ + "date", + "number", + "element" + ], + "description": "分组类型" + }, "date_group_by": { - "description": "日期分组粒度(type='date' 时必填)", + "type": "string", "enum": [ "year", "yearMonth", @@ -5239,101 +5244,116 @@ "hourMinute", "minute" ], - "type": "string" + "description": "日期分组粒度(type='date' 时必填)" + }, + "interval": { + "type": "number", + "description": "数值分组间隔(type='number' 时必填)" + }, + "start": { + "type": "number", + "description": "数值分组起始值" }, "end": { - "description": "数值分组结束值", - "type": "number" + "type": "number", + "description": "数值分组结束值" }, "groups": { + "type": "object", + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", "additionalProperties": { + "type": "array", "items": { "type": "string" - }, - "type": "array" - }, - "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", - "type": "object" - }, - "interval": { - "description": "数值分组间隔(type='number' 时必填)", - "type": "number" - }, - "start": { - "description": "数值分组起始值", - "type": "number" - }, - "type": { - "description": "分组类型", - "enum": [ - "date", - "number", - "element" - ], - "type": "string" + } + } } }, "required": [ "type" - ], - "type": "object" + ] + } + }, + "required": [ + "field" + ] + } + }, + "columns": { + "description": "横向分组字段(列字段)", + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "源数据中的字段名(OOXML 字段引用)" + }, + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。" }, "sort": { + "type": "object", "description": "排序配置", "properties": { - "by": { - "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序", - "enum": [ - "label", - "value" - ], - "type": "string" - }, "order": { - "description": "排序方向", + "type": "string", "enum": [ "asc", "desc" ], - "type": "string" + "description": "排序方向" + }, + "by": { + "type": "string", + "enum": [ + "label", + "value" + ], + "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序" }, "value_field": { - "description": "by='value' 时必填,指定按哪个值字段排序", - "type": "string" + "type": "string", + "description": "by='value' 时必填,指定按哪个值字段排序" } }, "required": [ "order" - ], - "type": "object" - } - }, - "required": [ - "field" - ], - "type": "object" - }, - "type": "array" - }, - "filters": { - "description": "筛选区域字段(页字段)", - "items": { - "properties": { + ] + }, + "filter": { + "type": "object", + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + }, + "description": "要显示的项目列表(其余项目被隐藏)" + } + }, + "required": [ + "items" + ] + }, "condition_filter": { + "type": "object", "description": "条件筛选:按文本/数值/日期条件筛选", "properties": { - "operator": { - "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", - "type": "string" - }, "type": { - "description": "条件类型", + "type": "string", "enum": [ "text", "number", "date" ], - "type": "string" + "description": "条件类型" + }, + "operator": { + "type": "string", + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" }, "value": { "description": "比较值" @@ -5345,38 +5365,23 @@ "required": [ "type", "operator" - ], - "type": "object" - }, - "display_name": { - "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。", - "type": "string" - }, - "field": { - "description": "源数据中的字段名(OOXML 字段引用)", - "type": "string" - }, - "filter": { - "description": "快速筛选:只显示指定的项目", - "properties": { - "items": { - "description": "要显示的项目列表(其余项目被隐藏)", - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "items" - ], - "type": "object" + ] }, "group": { + "type": "object", "description": "分组配置", "properties": { + "type": { + "type": "string", + "enum": [ + "date", + "number", + "element" + ], + "description": "分组类型" + }, "date_group_by": { - "description": "日期分组粒度(type='date' 时必填)", + "type": "string", "enum": [ "year", "yearMonth", @@ -5390,80 +5395,87 @@ "hourMinute", "minute" ], - "type": "string" + "description": "日期分组粒度(type='date' 时必填)" + }, + "interval": { + "type": "number", + "description": "数值分组间隔(type='number' 时必填)" + }, + "start": { + "type": "number", + "description": "数值分组起始值" }, "end": { - "description": "数值分组结束值", - "type": "number" + "type": "number", + "description": "数值分组结束值" }, "groups": { + "type": "object", + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", "additionalProperties": { + "type": "array", "items": { "type": "string" - }, - "type": "array" - }, - "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", - "type": "object" - }, - "interval": { - "description": "数值分组间隔(type='number' 时必填)", - "type": "number" - }, - "start": { - "description": "数值分组起始值", - "type": "number" - }, - "type": { - "description": "分组类型", - "enum": [ - "date", - "number", - "element" - ], - "type": "string" + } + } } }, "required": [ "type" - ], - "type": "object" + ] } }, "required": [ "field" - ], - "type": "object" - }, - "type": "array" - }, - "range": { - "description": "放置透视表的左上角单元格 A1 地址(例如:'F1')(仅 create 时有效)。可省略——省略后透视表放在新建子表的 A1 位置。", - "type": "string" - }, - "repeat_row_labels": { - "description": "是否显示重复项标签", - "type": "boolean" + ] + } }, - "rows": { - "description": "纵向分组字段(行字段)", + "filters": { + "description": "筛选区域字段(页字段)", + "type": "array", "items": { + "type": "object", "properties": { + "field": { + "type": "string", + "description": "源数据中的字段名(OOXML 字段引用)" + }, + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。" + }, + "filter": { + "type": "object", + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + }, + "description": "要显示的项目列表(其余项目被隐藏)" + } + }, + "required": [ + "items" + ] + }, "condition_filter": { + "type": "object", "description": "条件筛选:按文本/数值/日期条件筛选", "properties": { - "operator": { - "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", - "type": "string" - }, "type": { - "description": "条件类型", + "type": "string", "enum": [ "text", "number", "date" ], - "type": "string" + "description": "条件类型" + }, + "operator": { + "type": "string", + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" }, "value": { "description": "比较值" @@ -5475,38 +5487,23 @@ "required": [ "type", "operator" - ], - "type": "object" - }, - "display_name": { - "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用;否则保持源字段原名,透视表产物已足够清晰。", - "type": "string" - }, - "field": { - "description": "源数据中的字段名(OOXML 字段引用)", - "type": "string" - }, - "filter": { - "description": "快速筛选:只显示指定的项目", - "properties": { - "items": { - "description": "要显示的项目列表(其余项目被隐藏)", - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "items" - ], - "type": "object" + ] }, "group": { + "type": "object", "description": "分组配置", "properties": { + "type": { + "type": "string", + "enum": [ + "date", + "number", + "element" + ], + "description": "分组类型" + }, "date_group_by": { - "description": "日期分组粒度(type='date' 时必填)", + "type": "string", "enum": [ "year", "yearMonth", @@ -5520,130 +5517,59 @@ "hourMinute", "minute" ], - "type": "string" + "description": "日期分组粒度(type='date' 时必填)" + }, + "interval": { + "type": "number", + "description": "数值分组间隔(type='number' 时必填)" + }, + "start": { + "type": "number", + "description": "数值分组起始值" }, "end": { - "description": "数值分组结束值", - "type": "number" + "type": "number", + "description": "数值分组结束值" }, "groups": { + "type": "object", + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", "additionalProperties": { + "type": "array", "items": { "type": "string" - }, - "type": "array" - }, - "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", - "type": "object" - }, - "interval": { - "description": "数值分组间隔(type='number' 时必填)", - "type": "number" - }, - "start": { - "description": "数值分组起始值", - "type": "number" - }, - "type": { - "description": "分组类型", - "enum": [ - "date", - "number", - "element" - ], - "type": "string" + } + } } }, "required": [ "type" - ], - "type": "object" - }, - "sort": { - "description": "排序配置", - "properties": { - "by": { - "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序", - "enum": [ - "label", - "value" - ], - "type": "string" - }, - "order": { - "description": "排序方向", - "enum": [ - "asc", - "desc" - ], - "type": "string" - }, - "value_field": { - "description": "by='value' 时必填,指定按哪个值字段排序", - "type": "string" - } - }, - "required": [ - "order" - ], - "type": "object" + ] } }, "required": [ "field" - ], - "type": "object" - }, - "type": "array" - }, - "show_col_grand_total": { - "description": "是否显示列总计(默认 true)", - "type": "boolean" - }, - "show_row_grand_total": { - "description": "是否显示行总计(默认 true)", - "type": "boolean" - }, - "show_subtotals": { - "description": "是否显示分类小计(默认 true,应用于所有字段)", - "type": "boolean" - }, - "source": { - "description": "源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100')。create 时必填;update 时提供则切换数据源。", - "type": "string" + ] + } }, "values": { - "description": "要汇总的字段(至少需要 1 个)", + "minItems": 1, + "type": "array", "items": { - "properties": { - "base_field": { - "description": "show_data_as 需要基准字段时的字段名", - "type": "string" - }, - "display_name": { - "description": "字段在透视表中的显示名(典型场景:把 count(销售额) 的值列头显示为 'GMV')。不传则使用源字段原名。仅在用户明确要求重命名时使用。", - "type": "string" - }, + "type": "object", + "properties": { "field": { - "description": "要汇总的源数据字段名", - "type": "string" + "type": "string", + "description": "要汇总的源数据字段名" }, - "show_data_as": { - "description": "值显示方式(默认 'normal')", - "enum": [ - "normal", - "percentOfTotal", - "percentOfCol", - "percentOfRow", - "percentOfParentRow", - "percentOfParentCol", - "index" - ], - "type": "string" + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名(典型场景:把 count(销售额) 的值列头显示为 'GMV')。不传则使用源字段原名。仅在用户明确要求重命名时使用。" }, "summarize_by": { "default": "sum", "description": "汇总函数", + "type": "string", "enum": [ "sum", "count", @@ -5658,633 +5584,707 @@ "varp", "distinct", "median" - ], - "type": "string" + ] + }, + "show_data_as": { + "type": "string", + "description": "值显示方式(默认 'normal')", + "enum": [ + "normal", + "percentOfTotal", + "percentOfCol", + "percentOfRow", + "percentOfParentRow", + "percentOfParentCol", + "index" + ] + }, + "base_field": { + "type": "string", + "description": "show_data_as 需要基准字段时的字段名" } }, "required": [ "field" - ], - "type": "object" + ] }, - "minItems": 1, - "type": "array" + "description": "要汇总的字段(至少需要 1 个)" + }, + "auto_fit_col": { + "type": "boolean", + "description": "是否自动调整列宽以适应内容" + }, + "show_row_grand_total": { + "type": "boolean", + "description": "是否显示行总计(默认 true)" + }, + "show_col_grand_total": { + "type": "boolean", + "description": "是否显示列总计(默认 true)" + }, + "show_subtotals": { + "type": "boolean", + "description": "是否显示分类小计(默认 true,应用于所有字段)" + }, + "repeat_row_labels": { + "type": "boolean", + "description": "是否显示重复项标签" + }, + "calculated_fields": { + "type": "array", + "description": "计算字段列表", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "计算字段的显示名称" + }, + "formula": { + "type": "string", + "description": "公式表达式,引用其他字段名,例如 \"'Sales' + 'Tax'\"" + }, + "summarize_by": { + "type": "string", + "default": "sum", + "description": "计算字段汇总方式(默认 'sum',仅支持 sum/custom 两种):'sum' 表示先对 formula 中引用的各源字段分别求和后再代入公式(formula 是逐字段求和后的标量表达式,例如 \"'Sales'+'Tax'\" 等价于 SUM(Sales)+SUM(Tax));'custom' 表示数组直接传给 formula,要求 formula 自带聚合函数(例如 \"SUM('Sales')+SUM('Tax')\" 或 \"AVERAGE('Sales')\")。需要 count/average/max/min 等其他聚合方式时,请改用 values[] 字段对源字段直接聚合,calculated_fields 不适用。", + "enum": [ + "sum", + "custom" + ] + } + }, + "required": [ + "name", + "formula" + ] + } + }, + "collapse": { + "type": "object", + "description": "行字段展开/折叠状态:字段名 -> 要折叠的项目列表", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } } }, - "type": "object" + "additionalProperties": {} } }, "+range-sort": { "sort-keys": { - "description": "排序条件列表(仅 sort 操作)。支持多级排序,靠前的条件优先级更高。", + "type": "array", "items": { + "type": "object", "properties": { - "ascending": { - "description": "是否升序排序", - "type": "boolean" - }, "column": { - "description": "排序依据的列字母(如 \"C\"、\"D\"),必须在 range 范围内", - "type": "string" + "type": "string", + "description": "排序依据的列字母(如 \"C\"、\"D\"),必须在 range 范围内" + }, + "ascending": { + "type": "boolean", + "description": "是否升序排序" } }, "required": [ "column", "ascending" - ], - "type": "object" + ] }, - "type": "array" + "description": "排序条件列表(仅 sort 操作)。支持多级排序,靠前的条件优先级更高。" } }, "+sparkline-create": { "properties": { - "additionalProperties": false, "description": "创建/更新/部分删除的迷你图属性。delete 时不传 sparklines 即删整组,传则删指定项。", + "type": "object", "properties": { "config": { - "additionalProperties": false, + "type": "object", "description": "迷你图样式配置, 相同 groupId 的迷你图共享相同的样式。", "properties": { - "axis": { - "additionalProperties": false, - "description": "坐标轴配置,包含坐标轴颜色、是否翻转、是否显示坐标轴。", - "properties": { - "color": { - "description": "坐标轴颜色。", - "type": "string" - }, - "reverse": { - "description": "是否翻转坐标轴方向。", - "type": "boolean" - }, - "visible": { - "description": "是否显示坐标轴。", - "type": "boolean" - } - }, - "type": "object" - }, - "contain_hidden_cells": { - "description": "隐藏的单元格数据是否参与绘制。", - "type": "boolean" + "theme_type": { + "type": "string", + "enum": [ + "pro", + "light", + "soft", + "brand", + "fresh" + ], + "description": "主题类型:pro、light、soft、brand、fresh。" }, - "empty_show_as": { - "description": "空单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。", + "non_num_show_as": { + "type": "string", "enum": [ "zero", "gap", "average" ], - "type": "string" - }, - "extremum_max": { - "additionalProperties": false, - "description": "最大极值配置,包含极值类型、极值。", - "properties": { - "type": { - "description": "极值类型,可选值:individual=单个迷你图极值,group=同组内极值,custom=自定义。", - "enum": [ - "individual", - "group", - "custom" - ], - "type": "string" - }, - "value": { - "description": "当 type='custom' 时生效的具体数值。", - "type": "number" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - "extremum_min": { - "additionalProperties": false, - "description": "最小极值配置,包含极值类型、极值。", - "properties": { - "type": { - "description": "极值类型:individual=单个迷你图极值,group=同组内极值,custom=自定义。", - "enum": [ - "individual", - "group", - "custom" - ], - "type": "string" - }, - "value": { - "description": "当 type='custom' 时生效的具体数值。", - "type": "number" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - "line_width": { - "description": "折线图线宽,可选值:1=1px,2=2px,3=3px,4=4px。", - "enum": [ - 1, - 2, - 3, - 4 - ], - "type": "number" + "description": "非数字单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。" }, - "non_num_show_as": { - "description": "非数字单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。", + "empty_show_as": { + "type": "string", "enum": [ "zero", "gap", "average" ], - "type": "string" + "description": "空单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。" + }, + "contain_hidden_cells": { + "type": "boolean", + "description": "隐藏的单元格数据是否参与绘制。" + }, + "series_color": { + "type": "string", + "description": "主系列颜色,例如 \"#4472C4\"。" }, "points": { - "additionalProperties": false, + "type": "object", "description": "特殊点样式配置,包含高点、低点、标记点、首点、尾点、负点。", "properties": { - "first_point": { - "additionalProperties": false, - "description": "首点配置,第一个数据点的样式。", + "last_point": { + "type": "object", + "description": "尾点配置,最后一个数据点的样式。", "properties": { "color": { - "description": "点的颜色。", - "type": "string" + "type": "string", + "description": "点的颜色。" }, "visible": { - "description": "是否显示该点。", - "type": "boolean" + "type": "boolean", + "description": "是否显示该点。" } }, - "type": "object" + "additionalProperties": false }, - "high_point": { - "additionalProperties": false, - "description": "高点配置,最高点的样式。", + "negative_point": { + "type": "object", + "description": "负点配置,负数点的样式。", "properties": { "color": { - "description": "点的颜色。", - "type": "string" + "type": "string", + "description": "点的颜色。" }, "visible": { - "description": "是否显示该点。", - "type": "boolean" + "type": "boolean", + "description": "是否显示该点。" } }, - "type": "object" + "additionalProperties": false }, - "last_point": { - "additionalProperties": false, - "description": "尾点配置,最后一个数据点的样式。", + "markers_point": { + "type": "object", + "description": "标记点配置,所有标记点的样式。", "properties": { "color": { - "description": "点的颜色。", - "type": "string" + "type": "string", + "description": "点的颜色。" }, "visible": { - "description": "是否显示该点。", - "type": "boolean" + "type": "boolean", + "description": "是否显示该点。" } }, - "type": "object" + "additionalProperties": false }, - "low_point": { - "additionalProperties": false, - "description": "低点配置,最低点的样式。", + "first_point": { + "type": "object", + "description": "首点配置,第一个数据点的样式。", "properties": { "color": { - "description": "点的颜色。", - "type": "string" + "type": "string", + "description": "点的颜色。" }, "visible": { - "description": "是否显示该点。", - "type": "boolean" + "type": "boolean", + "description": "是否显示该点。" } }, - "type": "object" + "additionalProperties": false }, - "markers_point": { - "additionalProperties": false, - "description": "标记点配置,所有标记点的样式。", + "high_point": { + "type": "object", + "description": "高点配置,最高点的样式。", "properties": { "color": { - "description": "点的颜色。", - "type": "string" + "type": "string", + "description": "点的颜色。" + }, + "visible": { + "type": "boolean", + "description": "是否显示该点。" + } + }, + "additionalProperties": false + }, + "low_point": { + "type": "object", + "description": "低点配置,最低点的样式。", + "properties": { + "color": { + "type": "string", + "description": "点的颜色。" }, "visible": { - "description": "是否显示该点。", - "type": "boolean" + "type": "boolean", + "description": "是否显示该点。" } }, - "type": "object" + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "line_width": { + "type": "number", + "enum": [ + 1, + 2, + 3, + 4 + ], + "description": "折线图线宽,可选值:1=1px,2=2px,3=3px,4=4px。" + }, + "type": { + "type": "string", + "enum": [ + "line", + "column", + "win_loss" + ], + "description": "迷你图类型,可选值:line=折线图,column=柱形图,win_loss=盈亏图。" + }, + "axis": { + "type": "object", + "description": "坐标轴配置,包含坐标轴颜色、是否翻转、是否显示坐标轴。", + "properties": { + "color": { + "type": "string", + "description": "坐标轴颜色。" + }, + "reverse": { + "type": "boolean", + "description": "是否翻转坐标轴方向。" + }, + "visible": { + "type": "boolean", + "description": "是否显示坐标轴。" + } + }, + "additionalProperties": false + }, + "show_gradient": { + "type": "boolean", + "description": "是否显示渐变效果。" + }, + "show_radius": { + "type": "boolean", + "description": "是否显示圆角,仅对柱形图和盈亏图生效。" + }, + "extremum_max": { + "type": "object", + "description": "最大极值配置,包含极值类型、极值。", + "properties": { + "type": { + "type": "string", + "enum": [ + "individual", + "group", + "custom" + ], + "description": "极值类型,可选值:individual=单个迷你图极值,group=同组内极值,custom=自定义。" + }, + "value": { + "type": "number", + "description": "当 type='custom' 时生效的具体数值。" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "extremum_min": { + "type": "object", + "description": "最小极值配置,包含极值类型、极值。", + "properties": { + "type": { + "type": "string", + "enum": [ + "individual", + "group", + "custom" + ], + "description": "极值类型:individual=单个迷你图极值,group=同组内极值,custom=自定义。" }, - "negative_point": { - "additionalProperties": false, - "description": "负点配置,负数点的样式。", - "properties": { - "color": { - "description": "点的颜色。", - "type": "string" - }, - "visible": { - "description": "是否显示该点。", - "type": "boolean" - } - }, - "type": "object" + "value": { + "type": "number", + "description": "当 type='custom' 时生效的具体数值。" } }, - "type": "object" - }, - "series_color": { - "description": "主系列颜色,例如 \"#4472C4\"。", - "type": "string" - }, - "show_gradient": { - "description": "是否显示渐变效果。", - "type": "boolean" - }, - "show_radius": { - "description": "是否显示圆角,仅对柱形图和盈亏图生效。", - "type": "boolean" - }, - "theme_type": { - "description": "主题类型:pro、light、soft、brand、fresh。", - "enum": [ - "pro", - "light", - "soft", - "brand", - "fresh" - ], - "type": "string" - }, - "type": { - "description": "迷你图类型,可选值:line=折线图,column=柱形图,win_loss=盈亏图。", - "enum": [ - "line", - "column", - "win_loss" + "required": [ + "type" ], - "type": "string" + "additionalProperties": false } }, - "type": "object" + "additionalProperties": false }, "sparklines": { + "type": "array", "description": "迷你图项列表。create 时为待创建项(每项需 position + source/source_range);update 时为待变更/新增项(每项需 sparkline_id;upsert=true 新增时需 position + source);delete 时为待删除项(每项需 sparkline_id)。", "items": { - "additionalProperties": false, + "type": "object", "description": "单个迷你图项。", "properties": { + "sparkline_id": { + "type": "string", + "description": "迷你图 reference_id。create 时可选(不传系统生成);update / delete 时必填。" + }, "position": { - "additionalProperties": false, + "type": "object", "description": "迷你图位置。create / update 时必填;delete 时省略。", "properties": { - "col": { - "description": "列索引,例如 \"A\"、\"B\"", - "type": "string" - }, "row": { - "description": "行索引(0-based)", + "type": "number", "minimum": 0, - "type": "number" + "description": "行索引(0-based)" + }, + "col": { + "type": "string", + "description": "列索引,例如 \"A\"、\"B\"" } }, "required": [ "row", "col" ], - "type": "object" + "additionalProperties": false }, "source": { - "description": "A1 范围字符串,表示数据来源,例如 \"Sheet1!A2:A10\"。create 必填(与 source_range 二选一);update 仅在改源时传;delete 时省略。", - "type": "string" + "type": "string", + "description": "A1 范围字符串,表示数据来源,例如 \"Sheet1!A2:A10\"。create 必填(与 source_range 二选一);update 仅在改源时传;delete 时省略。" }, "source_range": { - "additionalProperties": false, + "type": "object", "description": "结构化数据源范围(与 source 等价)。", "properties": { "range": { - "description": "数据源的 A1 引用区域", - "type": "string" + "type": "string", + "description": "数据源的 A1 引用区域" } }, "required": [ "range" ], - "type": "object" - }, - "sparkline_id": { - "description": "迷你图 reference_id。create 时可选(不传系统生成);update / delete 时必填。", - "type": "string" + "additionalProperties": false } }, - "type": "object" - }, - "type": "array" + "additionalProperties": false + } } }, - "type": "object" + "additionalProperties": false } }, "+sparkline-update": { "properties": { - "additionalProperties": false, "description": "创建/更新/部分删除的迷你图属性。delete 时不传 sparklines 即删整组,传则删指定项。", + "type": "object", "properties": { "config": { - "additionalProperties": false, + "type": "object", "description": "迷你图样式配置, 相同 groupId 的迷你图共享相同的样式。", "properties": { - "axis": { - "additionalProperties": false, - "description": "坐标轴配置,包含坐标轴颜色、是否翻转、是否显示坐标轴。", - "properties": { - "color": { - "description": "坐标轴颜色。", - "type": "string" - }, - "reverse": { - "description": "是否翻转坐标轴方向。", - "type": "boolean" - }, - "visible": { - "description": "是否显示坐标轴。", - "type": "boolean" - } - }, - "type": "object" - }, - "contain_hidden_cells": { - "description": "隐藏的单元格数据是否参与绘制。", - "type": "boolean" + "theme_type": { + "type": "string", + "enum": [ + "pro", + "light", + "soft", + "brand", + "fresh" + ], + "description": "主题类型:pro、light、soft、brand、fresh。" }, - "empty_show_as": { - "description": "空单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。", + "non_num_show_as": { + "type": "string", "enum": [ "zero", "gap", "average" ], - "type": "string" - }, - "extremum_max": { - "additionalProperties": false, - "description": "最大极值配置,包含极值类型、极值。", - "properties": { - "type": { - "description": "极值类型,可选值:individual=单个迷你图极值,group=同组内极值,custom=自定义。", - "enum": [ - "individual", - "group", - "custom" - ], - "type": "string" - }, - "value": { - "description": "当 type='custom' 时生效的具体数值。", - "type": "number" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - "extremum_min": { - "additionalProperties": false, - "description": "最小极值配置,包含极值类型、极值。", - "properties": { - "type": { - "description": "极值类型:individual=单个迷你图极值,group=同组内极值,custom=自定义。", - "enum": [ - "individual", - "group", - "custom" - ], - "type": "string" - }, - "value": { - "description": "当 type='custom' 时生效的具体数值。", - "type": "number" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - "line_width": { - "description": "折线图线宽,可选值:1=1px,2=2px,3=3px,4=4px。", - "enum": [ - 1, - 2, - 3, - 4 - ], - "type": "number" + "description": "非数字单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。" }, - "non_num_show_as": { - "description": "非数字单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。", + "empty_show_as": { + "type": "string", "enum": [ "zero", "gap", "average" ], - "type": "string" + "description": "空单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。" + }, + "contain_hidden_cells": { + "type": "boolean", + "description": "隐藏的单元格数据是否参与绘制。" + }, + "series_color": { + "type": "string", + "description": "主系列颜色,例如 \"#4472C4\"。" }, "points": { - "additionalProperties": false, + "type": "object", "description": "特殊点样式配置,包含高点、低点、标记点、首点、尾点、负点。", "properties": { - "first_point": { - "additionalProperties": false, - "description": "首点配置,第一个数据点的样式。", + "last_point": { + "type": "object", + "description": "尾点配置,最后一个数据点的样式。", "properties": { "color": { - "description": "点的颜色。", - "type": "string" + "type": "string", + "description": "点的颜色。" }, "visible": { - "description": "是否显示该点。", - "type": "boolean" + "type": "boolean", + "description": "是否显示该点。" } }, - "type": "object" + "additionalProperties": false }, - "high_point": { - "additionalProperties": false, - "description": "高点配置,最高点的样式。", + "negative_point": { + "type": "object", + "description": "负点配置,负数点的样式。", "properties": { "color": { - "description": "点的颜色。", - "type": "string" + "type": "string", + "description": "点的颜色。" }, "visible": { - "description": "是否显示该点。", - "type": "boolean" + "type": "boolean", + "description": "是否显示该点。" } }, - "type": "object" + "additionalProperties": false }, - "last_point": { - "additionalProperties": false, - "description": "尾点配置,最后一个数据点的样式。", + "markers_point": { + "type": "object", + "description": "标记点配置,所有标记点的样式。", "properties": { "color": { - "description": "点的颜色。", - "type": "string" + "type": "string", + "description": "点的颜色。" }, "visible": { - "description": "是否显示该点。", - "type": "boolean" + "type": "boolean", + "description": "是否显示该点。" } }, - "type": "object" + "additionalProperties": false }, - "low_point": { - "additionalProperties": false, - "description": "低点配置,最低点的样式。", + "first_point": { + "type": "object", + "description": "首点配置,第一个数据点的样式。", "properties": { "color": { - "description": "点的颜色。", - "type": "string" + "type": "string", + "description": "点的颜色。" }, "visible": { - "description": "是否显示该点。", - "type": "boolean" + "type": "boolean", + "description": "是否显示该点。" } }, - "type": "object" + "additionalProperties": false }, - "markers_point": { - "additionalProperties": false, - "description": "标记点配置,所有标记点的样式。", + "high_point": { + "type": "object", + "description": "高点配置,最高点的样式。", "properties": { "color": { - "description": "点的颜色。", - "type": "string" + "type": "string", + "description": "点的颜色。" }, "visible": { - "description": "是否显示该点。", - "type": "boolean" + "type": "boolean", + "description": "是否显示该点。" } }, - "type": "object" + "additionalProperties": false }, - "negative_point": { - "additionalProperties": false, - "description": "负点配置,负数点的样式。", + "low_point": { + "type": "object", + "description": "低点配置,最低点的样式。", "properties": { "color": { - "description": "点的颜色。", - "type": "string" + "type": "string", + "description": "点的颜色。" }, "visible": { - "description": "是否显示该点。", - "type": "boolean" + "type": "boolean", + "description": "是否显示该点。" } }, - "type": "object" + "additionalProperties": false } }, - "type": "object" - }, - "series_color": { - "description": "主系列颜色,例如 \"#4472C4\"。", - "type": "string" - }, - "show_gradient": { - "description": "是否显示渐变效果。", - "type": "boolean" - }, - "show_radius": { - "description": "是否显示圆角,仅对柱形图和盈亏图生效。", - "type": "boolean" + "additionalProperties": false }, - "theme_type": { - "description": "主题类型:pro、light、soft、brand、fresh。", + "line_width": { + "type": "number", "enum": [ - "pro", - "light", - "soft", - "brand", - "fresh" + 1, + 2, + 3, + 4 ], - "type": "string" + "description": "折线图线宽,可选值:1=1px,2=2px,3=3px,4=4px。" }, "type": { - "description": "迷你图类型,可选值:line=折线图,column=柱形图,win_loss=盈亏图。", + "type": "string", "enum": [ "line", "column", "win_loss" ], - "type": "string" + "description": "迷你图类型,可选值:line=折线图,column=柱形图,win_loss=盈亏图。" + }, + "axis": { + "type": "object", + "description": "坐标轴配置,包含坐标轴颜色、是否翻转、是否显示坐标轴。", + "properties": { + "color": { + "type": "string", + "description": "坐标轴颜色。" + }, + "reverse": { + "type": "boolean", + "description": "是否翻转坐标轴方向。" + }, + "visible": { + "type": "boolean", + "description": "是否显示坐标轴。" + } + }, + "additionalProperties": false + }, + "show_gradient": { + "type": "boolean", + "description": "是否显示渐变效果。" + }, + "show_radius": { + "type": "boolean", + "description": "是否显示圆角,仅对柱形图和盈亏图生效。" + }, + "extremum_max": { + "type": "object", + "description": "最大极值配置,包含极值类型、极值。", + "properties": { + "type": { + "type": "string", + "enum": [ + "individual", + "group", + "custom" + ], + "description": "极值类型,可选值:individual=单个迷你图极值,group=同组内极值,custom=自定义。" + }, + "value": { + "type": "number", + "description": "当 type='custom' 时生效的具体数值。" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "extremum_min": { + "type": "object", + "description": "最小极值配置,包含极值类型、极值。", + "properties": { + "type": { + "type": "string", + "enum": [ + "individual", + "group", + "custom" + ], + "description": "极值类型:individual=单个迷你图极值,group=同组内极值,custom=自定义。" + }, + "value": { + "type": "number", + "description": "当 type='custom' 时生效的具体数值。" + } + }, + "required": [ + "type" + ], + "additionalProperties": false } }, - "type": "object" + "additionalProperties": false }, "sparklines": { + "type": "array", "description": "迷你图项列表。create 时为待创建项(每项需 position + source/source_range);update 时为待变更/新增项(每项需 sparkline_id;upsert=true 新增时需 position + source);delete 时为待删除项(每项需 sparkline_id)。", "items": { - "additionalProperties": false, + "type": "object", "description": "单个迷你图项。", "properties": { + "sparkline_id": { + "type": "string", + "description": "迷你图 reference_id。create 时可选(不传系统生成);update / delete 时必填。" + }, "position": { - "additionalProperties": false, + "type": "object", "description": "迷你图位置。create / update 时必填;delete 时省略。", "properties": { - "col": { - "description": "列索引,例如 \"A\"、\"B\"", - "type": "string" - }, "row": { - "description": "行索引(0-based)", + "type": "number", "minimum": 0, - "type": "number" + "description": "行索引(0-based)" + }, + "col": { + "type": "string", + "description": "列索引,例如 \"A\"、\"B\"" } }, "required": [ "row", "col" ], - "type": "object" + "additionalProperties": false }, "source": { - "description": "A1 范围字符串,表示数据来源,例如 \"Sheet1!A2:A10\"。create 必填(与 source_range 二选一);update 仅在改源时传;delete 时省略。", - "type": "string" + "type": "string", + "description": "A1 范围字符串,表示数据来源,例如 \"Sheet1!A2:A10\"。create 必填(与 source_range 二选一);update 仅在改源时传;delete 时省略。" }, "source_range": { - "additionalProperties": false, + "type": "object", "description": "结构化数据源范围(与 source 等价)。", "properties": { "range": { - "description": "数据源的 A1 引用区域", - "type": "string" + "type": "string", + "description": "数据源的 A1 引用区域" } }, "required": [ "range" ], - "type": "object" - }, - "sparkline_id": { - "description": "迷你图 reference_id。create 时可选(不传系统生成);update / delete 时必填。", - "type": "string" + "additionalProperties": false } }, - "type": "object" - }, - "type": "array" + "additionalProperties": false + } } }, - "type": "object" + "additionalProperties": false } } } diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index 0c606fa6e..3693129de 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -32,8 +32,9 @@ import ( // All set_cell_range-backed shortcuts construct a cells matrix whose // dimensions exactly match the target range — the tool errors on mismatch. -// CellsSet wraps set_cell_range with raw --data: caller provides the cells -// matrix (and any optional copy_to_range / resize_* fields) as JSON. +// CellsSet wraps set_cell_range: caller provides the cells matrix via --cells +// (JSON), with an optional --copy-to-range to replicate the written block +// across a larger area (formula refs auto-shift). var CellsSet = common.Shortcut{ Service: "sheets", Command: "+cells-set", @@ -92,6 +93,9 @@ func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[stri if !runtime.Bool("allow-overwrite") { input["allow_overwrite"] = false } + if copyTo := strings.TrimSpace(runtime.Str("copy-to-range")); copyTo != "" { + input["copy_to_range"] = copyTo + } return input, nil } diff --git a/shortcuts/sheets/lark_sheet_write_cells_test.go b/shortcuts/sheets/lark_sheet_write_cells_test.go index f3e8e319e..e9a0b289e 100644 --- a/shortcuts/sheets/lark_sheet_write_cells_test.go +++ b/shortcuts/sheets/lark_sheet_write_cells_test.go @@ -54,6 +54,24 @@ func TestWriteCellsShortcuts_DryRun(t *testing.T) { "allow_overwrite": false, }, }, + { + name: "+cells-set --copy-to-range passes copy_to_range", + sc: CellsSet, + args: []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "H2", + "--cells", `[[{"formula":"=A2*B2"}]]`, + "--copy-to-range", "H2:H100", + }, + toolName: "set_cell_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "range": "H2", + "cells": []interface{}{[]interface{}{map[string]interface{}{"formula": "=A2*B2"}}}, + "copy_to_range": "H2:H100", + }, + }, { name: "+csv-put inline csv", sc: CsvPut, diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index d2974f511..1a80ed7f9 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -38,7 +38,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--operations` | string + File + Stdin(复合 JSON) | required | JSON 数组:[{"shortcut":"+xxx-yyy","input":{...}}, ...]。shortcut 用 CLI 名;input 是该 shortcut 的入参集(不含 spreadsheet 定位),基础 flag 查 --help,复合 JSON flag 查 --print-schema --flag-name ;禁手填 operation。默认严格事务,传 --continue-on-error 翻软批;不支持嵌套;按数组顺序串行执行 | +| `--operations` | string + File + Stdin(复合 JSON) | required | JSON 数组:[{"shortcut":"+xxx-yyy","input":{...}}, ...]。shortcut 用 CLI 名;input 是该 shortcut 的入参集(不含 spreadsheet 定位),基础 flag 查 --help,复合 JSON flag 查 --print-schema --flag-name ;不要手填 operation 字段(由 CLI 按 shortcut 自动注入)。默认严格事务(首个失败即整批中断),传 --continue-on-error 切换为软批量(遇失败仍继续);不支持嵌套;按数组顺序串行执行 | | `--continue-on-error` | bool | optional | 遇子操作失败时继续执行剩余操作;默认 false(首个失败即整批中断) | ### `+cells-batch-set-style` @@ -91,14 +91,14 @@ _公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_ ## Schemas -> 复合 JSON flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag-name `。先 `--print-schema`(不带 `--flag-name`)会列出该 shortcut 所有可查询的 flag。 +> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。 ### `+batch-update` `--operations` _要批量执行的 CLI shortcut 操作列表,按声明顺序串行执行;任一失败立即中断_ **数组项**(类型 object): -- `shortcut` (enum) — CLI shortcut 名(不是底层 MCP tool 名) [+cells-set / +cells-set-style / +cells-clear / +cells-merge / +cells-unmerge / +cells-replace / +csv-put / +dropdown-set / …共 50 项] +- `shortcut` (enum) — CLI shortcut 名(不是底层 MCP tool 名) [+cells-set / +cells-set-style / +cells-clear / +cells-merge / +cells-unmerge / +cells-replace / +csv-put / +dropdown-set / +dim-insert / +dim-delete / +dim-hide / +dim-unhide / +dim-freeze / +dim-group / …共 50 项,完整集见 SKILL.md 的 References 表与各 reference 的 Shortcuts 段] - `input` (object) — 该 shortcut 的入参集(不含 spreadsheet 定位);基础 flag 跑 `lark-cli sheets --help… ### `+cells-batch-set-style` `--border-styles` @@ -106,14 +106,14 @@ _要批量执行的 CLI shortcut 操作列表,按声明顺序串行执行; _单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)_ **顶层字段**: -- `bottom` (object?) { color?: string, style?: enum, weight?: enum } -- `left` (object?) { color?: string, style?: enum, weight?: enum } -- `right` (object?) { color?: string, style?: enum, weight?: enum } -- `top` (object?) { color?: string, style?: enum, weight?: enum } +- `top` (object?) { style?: enum, weight?: enum, color?: string } +- `bottom` (object?) { style?: enum, weight?: enum, color?: string } +- `left` (object?) { style?: enum, weight?: enum, color?: string } +- `right` (object?) { style?: enum, weight?: enum, color?: string } ### `+dropdown-update` `--options` -_列表选项(type='list' 时必填)_ +_列表选项_ **数组项**(类型 string): - 标量:string diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md index 4ea612bed..18caf776c 100644 --- a/skills/lark-sheets/references/lark-sheets-chart.md +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -6,7 +6,7 @@ ## 使用场景 -读写图表对象。本 Skill 包含两个工具: +读写图表对象。本 reference 覆盖 4 个 shortcut: | 操作需求 | 使用工具 | 说明 | |---------|---------|------| @@ -76,7 +76,7 @@ 3. 识别并排除"总计"/"小计"行(通常最后一行;嵌套 pivot 还要排除中间层小计) 4. `+chart-create create` 时 `data.refs` 精确到数据行(如 pivot 占 A1:D9、总计在 row9 → chart 用 `A1:D8`) -详细规则见 `lark-sheets-pivot-table` skill 第 5 节"pivot → chart 组合场景"。 +详细规则见 `lark-sheets-pivot-table` 第 5 节"pivot → chart 组合场景"。 ## 图表位置选择(创建前必做) @@ -87,12 +87,12 @@ 3. **校验**:`position.row + needRows ≤ rowCount` 且 `col_idx + needCols ≤ columnCount`(col 按 A=0、B=1、…、Z=25、AA=26… 换算)。 4. **不够就先扩表**,二选一,禁止硬塞越界位置: - **优先**放数据下方空区:`position = {row: data_end_row + 2, col: "A"}`; - - 否则先调 `+dim-insert(operation="insert")`(`lark-sheets-sheet-structure` skill)扩行/列,再 create。 + - 否则先调 `+dim-insert`(`lark-sheets-sheet-structure`)扩行/列,再 create。 **示例**:21 列 sheet 放 600×400 图 → `needCols=6, needRows=15` - ❌ `{row: 0, col: "W"}` — col=22 越界 - ✅ `{row: 42, col: "A"}` — 放数据下方 -- ✅ 先 `insert position="U" count=6 side="after"`,再 `{row: 0, col: "V"}` +- ✅ 先 `+dim-insert --dimension column --start 21 --end 27`(在 U 列后插 6 列;U=index 20,after 即从 21 起),再放图到 `{row: 0, col: "V"}` ## Shortcuts @@ -140,17 +140,17 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ ## Schemas -> 复合 JSON flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag-name `。先 `--print-schema`(不带 `--flag-name`)会列出该 shortcut 所有可查询的 flag。 +> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。 ### `+chart-create` `--properties` / `+chart-update` `--properties` _创建/更新的图表属性_ **顶层字段**: -- `offset` (object?) — 可选 { col_offset?: number, row_offset?: number } -- `position` (object?) — 必填 { col: string, row: number } -- `size` (object?) — 必填 { height: number, width: number } -- `snapshot` (object?) — 图表快照配置 { data?: object, legend?: oneOf, plotArea?: object, style?: object, subTitle?: object, …共 6 项 } +- `position` (object?) — 必填 { row: number, col: string } +- `offset` (object?) — 可选 { row_offset?: number, col_offset?: number } +- `size` (object?) — 必填 { width: number, height: number } +- `snapshot` (object?) — 图表快照配置 { title?: object, subTitle?: object, style?: object, legend?: oneOf, plotArea?: object, …共 6 项 } ## Examples diff --git a/skills/lark-sheets/references/lark-sheets-conditional-format.md b/skills/lark-sheets/references/lark-sheets-conditional-format.md index 8f77ebc70..bf970e3a1 100644 --- a/skills/lark-sheets/references/lark-sheets-conditional-format.md +++ b/skills/lark-sheets/references/lark-sheets-conditional-format.md @@ -18,7 +18,7 @@ ## 使用场景 -读写条件格式对象。本 Skill 包含两个工具: +读写条件格式对象。本 reference 覆盖 4 个 shortcut: | 操作需求 | 使用工具 | 说明 | |---------|---------|------| @@ -45,7 +45,7 @@ ``` Step 1: `+cells-set` 在新列写判断公式(形成"是/否"或布尔辅助列) - range="H2", cells=[[{formula: "=IF(A2>B2, \"是\", \"否\")"}]], copy_to_range="H2:H100" + range="H2", cells=[[{formula: "=IF(A2>B2, \"是\", \"否\")"}]], --copy-to-range="H2:H100" Step 2: 基于辅助列值做条件格式(用 cellIs 或引用辅助列的 expression) `+cond-{format-create|format-update|format-delete}` create @@ -120,18 +120,18 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ ## Schemas -> 复合 JSON flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag-name `。先 `--print-schema`(不带 `--flag-name`)会列出该 shortcut 所有可查询的 flag。 +> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。 ### `+cond-format-create` `--properties` / `+cond-format-update` `--properties` _创建/更新的条件格式属性_ **顶层字段**: +- `rule_type` (enum) — 条件格式规则类型 [duplicateValues / uniqueValues / cellIs / containsText / timePeriod / containsBlanks / notContainsBlanks / dataBar / colorScale / rank / aboveAverage / expression / iconSet] +- `ranges` (array) — 应用条件格式的 A1 范围列表 +- `style` (object) — 命中规则时应用的单元格样式 { back_color?: string, fore_color?: string, text_decoration?: enum, font?: enum } - `attrs` (array?) — 规则参数列表 - `has_ref` (boolean?) — 可选 -- `ranges` (array) — 应用条件格式的 A1 范围列表 -- `rule_type` (enum) — 条件格式规则类型 [duplicateValues / uniqueValues / cellIs / containsText / timePeriod / containsBlanks / notContainsBlanks / dataBar / …共 13 项] -- `style` (object) — 命中规则时应用的单元格样式 { back_color?: string, font?: enum, fore_color?: string, text_decoration?: enum } ## Examples @@ -139,6 +139,11 @@ _创建/更新的条件格式属性_ ### `+cond-format-list` +```bash +# 列出当前 sheet 全部条件格式规则(拿 rule_id 供 update/delete) +lark-cli sheets +cond-format-list --url "..." --sheet-id "$SID" +``` + ### `+cond-format-create` `--rule-type` / `--ranges` 是独立 flag(不要再放 `--properties`);`style` / `attrs` 等结构走 `--properties`: @@ -161,6 +166,10 @@ lark-cli sheets +cond-format-create --url "..." --sheet-id "$SID" \ ### `+cond-format-delete` +```bash +lark-cli sheets +cond-format-delete --url "..." --rule-id "$RULE_ID" --yes +``` + ### Validate / DryRun / Execute 约束 - `Validate`:XOR 公共四件套;`--rule-type` / `--ranges` 必填;`--properties` 必须能解析为合法 JSON;按 `--rule-type` 检查必填子字段(`cellIs` 需 `attrs.operator` + `attrs.value`、`expression` 需 `attrs.formula`、`colorScale` 需 `min/mid/max` 配色等);`+cond-format-delete` 强制 `--yes` 或 `--dry-run`。 diff --git a/skills/lark-sheets/references/lark-sheets-core-operations.md b/skills/lark-sheets/references/lark-sheets-core-operations.md index 188887342..f01a7a2f7 100644 --- a/skills/lark-sheets/references/lark-sheets-core-operations.md +++ b/skills/lark-sheets/references/lark-sheets-core-operations.md @@ -9,7 +9,7 @@ > 所有编辑类任务(修改 / 排序 / 筛选 / 删除 / 透视 / 批量填充)**必须**先满足以下 8 条,再进入下方「硬性规则」和具体子 skill。任何子 skill 不得放宽这 8 条。 1. **R1 最小改动**:除用户明示要修改的单元格 / 列外,原表其它单元格、行列结构、Sheet 名称、合并区域、格式必须 1:1 保持。中间结果 / 标注列优先放在原数据列**右侧**;当中间结果会与原数据混淆,或需要承载结构化对象(透视表 / 图表)时可**新建空白 Sheet**。**禁止**擅自删除 / 重命名 / 隐藏 / 移动**已存在**的原 Sheet(新建是允许的,节制使用即可)。 -2. **R2 真实写回**:编辑任务的最终交付必须是对在线表格的真实写入并回读校验。**严禁**只在文本里描述"已完成 X" 没有任何写入;**严禁**用普通公式 / 文本汇总假装"透视表 / 筛选 / 图表 / 条件格式 / 迷你图"等结构化对象;**严禁**只输出 `{"type": "LarkExcelCard", "refs": [...]}` 形式的引用作为交付——LarkExcelCard 引用 ≠ 真实写入,必须有对应的 `+cells-set` / `manage_*_object` 工具调用并能用 `get_*` 工具回读到改动结果。 +2. **R2 真实写回**:编辑任务的最终交付必须是对在线表格的真实写入并回读校验。**严禁**只在文本里描述"已完成 X" 没有任何写入;**严禁**用普通公式 / 文本汇总假装"透视表 / 筛选 / 图表 / 条件格式 / 迷你图"等结构化对象;**严禁**只输出 `{"type": "LarkExcelCard", "refs": [...]}` 形式的引用作为交付——LarkExcelCard 引用 ≠ 真实写入,必须有对应的 `+cells-set` / `+<对象>-{create|update|delete}`(图表 / 透视表 / 条件格式 / 筛选 / 迷你图 / 浮动图片)工具调用,并能用 `+<对象>-list`(或 `+csv-get` / `+cells-get`)回读到改动结果。 3. **R3 计算复现**:涉及计算、排序、筛选、聚合、批量数据提取的任务,必须用 本地脚本 独立复现一份预期值,与回读结果对照通过后再交付。设计公式 / 筛选条件前先 sample 至少 50 行识别该列所有值类型变体(纯数值 / 公式文本 / 多种日期格式 / 空值),不能只看前 10 行。 4. **R4 处理完整性**:全量逐条处理类任务(翻译 / 打标 / 删除指定行 / 批量公式落地 / 按条件保留),落地前先把"预期处理条数"硬编码进代码,处理完后 `assert actual == expected`。**严禁**输出"已完成前 N 条,剩余将继续"这类半成品文案。 5. **R5 指令语义还原**:"按 X 排序" / "筛选 X" / "把 X 删除" 落地前先回答:① X 在哪一列?该列实际值类型是什么?② 期望结果集大小是多少?答完再动手;禁止直接对混合文本列做字符串排序 / 筛选。 @@ -26,7 +26,7 @@ | 任务类型 | 必须使用 | 禁止 | |---|---|---| | 重复检测 / 条件高亮 / 颜色标记 | `+cond-{format-create|format-update|format-delete}` | 本地脚本 逐行 + `+cells-set` 静态背景色 | - | 大批量行公式填充 | 模板公式 + `copy_to_range "X:X"` | 本地脚本 计算每行 + 静态写入 | + | 大批量行公式填充 | 模板公式 + `--copy-to-range "X:X"` | 本地脚本 计算每行 + 静态写入 | | 大数据筛选 | `+filter-{create|update|delete}` 或 `+pivot-{create|update|delete}` | 复制到新 sheet 后覆盖 | | 大批量数据 set | 分批 `+cells-set`,每批 ≤ 100 行 | 一次写 1000+ 行 | | 大批量翻译 / NLP | 分 30 行/批,每批后立即写回 | 一次性处理全表后才写回 | @@ -40,7 +40,7 @@ - 如果本轮对话中已经读取过某个 skill,不要重复读取 - 本 skill(`lark-sheets-core-operations`)+ `lark-sheets-workbook` 是几乎每次都需要的基础 skill,读完后应立即进入操作,不要在读取阶段停留过久 -2. **先了解结构再操作**:飞书表格的行列数、冻结位置、合并区域等信息不可猜测,猜错会导致写入越界或覆盖数据。操作前先调用 `+workbook-info` 获取子表概览;然后根据任务类型选择读取方式(三个读取工具均在 `lark-sheets-read-data` skill 中): +2. **先了解结构再操作**:飞书表格的行列数、冻结位置、合并区域等信息不可猜测,猜错会导致写入越界或覆盖数据。操作前先调用 `+workbook-info` 获取子表概览;然后根据任务类型选择读取方式(三个读取工具均在 `lark-sheets-read-data` 中): - **批量填充/补齐/完善/修正多行**类任务 → **必须先用 `+csv-get` 翻页读全(关注 `has_more` / `current_region`),或导出到本地用 `pandas` 等本地脚本确定真实数据行数**(路径 A)。**禁止**对这类任务直接用 `+csv-get` 探 10 行就进入写入——实测会漏写表尾多行(高频致命错误)。 - 数据分析/清洗/可视化/大数据集 → 同上路径 A - 快速查看少量数据或简单问答(只读、不回写) → `+csv-get` 读取到对话上下文 @@ -67,27 +67,27 @@ 4. **公式优先于硬编码值**:写公式(如 `=SUM(B2:B9)`)而非计算后的静态数值(如 `5000`),因为公式会在源数据变化时自动重算。写死数值后,用户改了源数据结果就不对了。 -5. **区分公式语法和工具参数语法**:公式字符串中的范围引用(如 `H:H`、`$A$2:$B$5`)遵循飞书公式语法;而 MCP tool 的 `range` / `ranges` / `copy_to_range` 参数使用 A1 表示法(如 `A1:D3`、`1:1`)。两者写法不同,混淆会导致调用失败。 +5. **区分公式语法和工具参数语法**:公式字符串中的范围引用(如 `H:H`、`$A$2:$B$5`)遵循飞书公式语法;而 MCP tool 的 `range` / `ranges` / `--copy-to-range` 参数使用 A1 表示法(如 `A1:D3`、`1:1`)。两者写法不同,混淆会导致调用失败。 6. **合并单元格需特殊处理**:合并区域只有左上角单元格存储数据,其余位置读取为空——这不代表”无内容”,而是合并的正常行为。写入时只能写左上角,写其他位置会报错或被忽略。如需修改合并区域中间的某格,先取消合并再操作。**在合并区域中间行插入数据之前,必须先调用 `+cells-get` 或 `+sheet-info` 确认目标行是否落在某个合并区域内**——直接用 `+cells-set` 写入合并区域的非左上角单元格,后端会返回 `cell at row N, col M is inside a merged region` 错误;即使 LLM 响应错误后改调 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` 插入行,行号也可能因合并扩展而错位。同理,**新增列后若原表存在合并的标题行(如 A1:F1),需要手动用 `+cells-{merge|unmerge}` 扩展合并范围到新列(如 A1:I1)**,否则标题行不会跟着变宽。 -7. **多步写入优先用 `+batch-update`**:当任务涉及多个连续写入操作时,优先使用 `lark-sheets-batch-update` skill 中的 `+batch-update` 将它们合并为单次请求,减少调用轮次。**特别是以下场景必须用 `+batch-update`**: +7. **多步写入优先用 `+batch-update`**:当任务涉及多个连续写入操作时,优先使用 `lark-sheets-batch-update` 中的 `+batch-update` 将它们合并为单次请求,减少调用轮次。**特别是以下场景必须用 `+batch-update`**: - 需要对多个不同区域执行 `+cells-{merge|unmerge}`(如按合同编号合并多列相同内容)→ 将所有 merge 操作放进一个 `+batch-update` - 需要对多个不同区域执行 `+rows-resize / +cols-resize`(如统一调整多列列宽或多行行高)→ 将所有 resize 操作放进一个 `+batch-update` - 需要先插入行列再写入数据 → 将 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` + `+cells-set` 放进一个 `+batch-update` - 当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求 -8. **结构操作用专用工具**:插入/删除行列用 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}`(一次操作可处理数千行),不要用 `+cells-set` 逐行搬数据——后者既慢又容易出错。用户给多步骤请求时,每个步骤都要执行,执行后重新验证。**`+dim-insert insert` 只继承相邻行的值/公式/边框,不继承 `row_height`**:插入后新行行高会回落到默认值(约 22px),若原行是高行高(容纳多行自动换行文本),新行长文本会被裁切(显示不全,末尾字符被截断)。**在中间插入行并填入文本前,必须先用 `+sheet-info` 读取相邻原行的 `row_height`,再用 `+batch-update` 合并 `+rows-resize / +cols-resize` 把同一高度应用到新行**——"在中间插入行填数据"本质等价于"续写已有内容",必须走硬性规则 12 的同一套样式继承流程(行高、边框、合并、对齐)。 +8. **结构操作用专用工具**:插入/删除行列用 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}`(一次操作可处理数千行),不要用 `+cells-set` 逐行搬数据——后者既慢又容易出错。用户给多步骤请求时,每个步骤都要执行,执行后重新验证。**`+dim-insert`(`--inherit-style before`/`after`)只继承相邻行的值/公式/边框,不继承 `row_height`**:插入后新行行高会回落到默认值(约 22px),若原行是高行高(容纳多行自动换行文本),新行长文本会被裁切(显示不全,末尾字符被截断)。**在中间插入行并填入文本前,必须先用 `+sheet-info` 读取相邻原行的 `row_height`,再用 `+batch-update` 合并 `+rows-resize / +cols-resize` 把同一高度应用到新行**——"在中间插入行填数据"本质等价于"续写已有内容",必须走硬性规则 12 的同一套样式继承流程(行高、边框、合并、对齐)。 9. **写入前精确定位表头和数据区域**:在执行任何写操作之前,必须先通过读取数据确认: - 表头在哪一行(不要假设表头一定在第 1 行,可能存在标题行、空行等) - 数据区域的实际起止范围(行数、列数)——可通过 `+csv-get` 返回的 `current_region` 快速获知连续数据区域的实际边界 - - **确认数据真实结束行**:`current_region` 末尾可能包含汇总行(合计/总计/小计)、签名/审批行(编制人/审核人)、空行、备注脚注等非数据内容,必须再读末尾 5~10 行排除这些行;最终数据范围 = 起始行 ~ 最后一条有效数据行。识别规则与完整示例见 `lark-sheets-read-data` skill 的「确定数据范围的正确流程」 + - **确认数据真实结束行**:`current_region` 末尾可能包含汇总行(合计/总计/小计)、签名/审批行(编制人/审核人)、空行、备注脚注等非数据内容,必须再读末尾 5~10 行排除这些行;最终数据范围 = 起始行 ~ 最后一条有效数据行。识别规则与完整示例见 `lark-sheets-read-data` 的「确定数据范围的正确流程」 - 目标列的实际列字母——**必须通过 `col_indices[j]` 获取,禁止通过手动计数 CSV 表头的逗号或字段来确定列位置**。手动数列在列数较多(>10 列)时极易产生 off-by-one 偏移错误 - **区分"表尺寸"与"数据占用范围"(新增列场景关键)**:`+workbook-info` 返回的 `column_count`(如 20 列)和 `row_count` 是**整个 sheet 的物理尺寸**,默认值可能远大于真实数据范围(比如一张只有 6 列数据的表可能 `column_count=20` 甚至 `column_count=100`)。**新增列 / 插入列之前,必须先调用一次 `+csv-get`(请求 `range: "A1:Z1"` 或表头附近一小块即可),读取返回值里的 `current_region` 作为真实数据矩形**,再基于 `current_region` 的右边界决定插入位置。例如 `current_region: "A1:F72"` → 数据末列是 F → 新增 3 列应插到 G/H/I,禁止插到 T(表尺寸末列)。否则新列和原数据之间会有一大片空列,用户看到的是"数据没动,三个空列在表尾"。 如果表头定位错误,后续所有公式和写入都会偏移,导致整体失败。 -10. **公式填充必须用 `copy_to_range`,禁止逐行写入**:当同一公式需要向下填充到多行时,必须先用 `+cells-set` 在第一行写入模板公式,再用 `copy_to_range` 填充整列(如 `"copy_to_range": "H2:H100"` 或 `"copy_to_range": "H:H"`)。**禁止**对每一行单独调用 `+cells-set` 写入相同结构的公式——这会浪费大量调用轮次。 +10. **公式填充必须用 `--copy-to-range`,禁止逐行写入**:当同一公式需要向下填充到多行时,必须先用 `+cells-set` 在第一行写入模板公式,再用 `--copy-to-range` 填充整列(如 `--copy-to-range "H2:H100"` 或 `--copy-to-range "H:H"`)。**禁止**对每一行单独调用 `+cells-set` 写入相同结构的公式——这会浪费大量调用轮次。 11. **分组汇总必须用透视表**:当用户说"按XX统计YY"、"分组汇总"、"各部门/地区的数量/金额"、"汇总每个XX的YY"时,必须使用 `+pivot-{create|update|delete}` 创建透视表(推荐省略 sheet_id 自动新建子表)。禁止用 SUMIF/COUNTIF 公式或 本地脚本 代码替代——后者会导致统计结果覆盖原表数据。 @@ -166,7 +166,7 @@ print(df.head(10)) # 必做:横向——确认表头行 + **路径 D:续写/扩展/完善已有内容(必须走此路径)** → 当用户要求"按已有格式续写"、"继续填充"、"复制区块"、"增加到第N周/月"、"帮我完善 XX"、"补齐 XX"、"填空"时,**必须**同时读取值和样式:先用 `+csv-get` 快速了解数据结构和范围,再用 `+cells-get` 读取源区块的 `cell_styles` + `border_styles`,并用 `+sheet-info` 读取行高和合并信息。**禁止跳过样式读取直接写入。** 这类任务默认要覆盖所有待补齐的行,**禁止只处理 `head(10)` 可见的行**——必须按 `df.info()` 的 non-null 数或 `current_region` 的实际行数确定写入范围。 -需要按关键字定位区域时使用 `lark-sheets-search-replace` skill 中的 `+cells-search`。 +需要按关键字定位区域时使用 `lark-sheets-search-replace` 中的 `+cells-search`。 4. 写入前重新确认数据边界(批量写入/修改时必做) @@ -219,7 +219,7 @@ print(df.head(10)) # 必做:横向——确认表头行 + | 查找匹配 | 公式(VLOOKUP/INDEX+MATCH) | pandas merge → 写静态值 | **只有以下场景才用代码**:多步数据清洗(正则+拆分+合并流水线)、统计建模、公式试错 3 次仍失败时的降级方案。代码计算结果写回表格时: -- **批量 CSV 纯值回写**(本地脚本清洗 / 聚合 / 筛选 / 合并的结果)→ `+csv-put`(直接传 CSV 文本 + start_cell,必要时自动扩容行列) +- **批量 CSV 纯值回写**(本地脚本清洗 / 聚合 / 筛选 / 合并的结果)→ `+csv-put`(直接传 CSV 文本 + `--start-cell`,必要时自动扩容行列) - **少量数据或需要公式/样式** → `+cells-set` - **能用飞书公式表达的** → 写飞书公式(源数据变化时自动重算) @@ -227,8 +227,8 @@ print(df.head(10)) # 必做:横向——确认表头行 + - 范围写入使用 `lark-sheets-write-cells`。`+cells-set` 的 `range` 必须落在当前已有行列范围内,`cells` 二维数组必须与 `range` 严格同维度;若是大块 CSV 纯值回写,优先用 `+csv-put`(直接传 CSV 文本,必要时自动扩容)。 - 如需在表尾追加数据,先插入行或列,再执行写入。 - **多步写入优先用 `+batch-update`**(见硬性规则 7):将多个写入操作合并为一次 `+batch-update` 调用,减少调用轮次。尤其是多次 `+cells-{merge|unmerge}`、多次 `+rows-resize / +cols-resize`、多次 `+cells-set` 场景,必须合并。 -- **公式填充必须用 `copy_to_range`(见硬性规则 10)**:先写一行模板公式,再用 `copy_to_range` 一次填充整列或整区域。示例:在 H2 写 `=SUM(B2:G2)` 后,设 `copy_to_range: "H2:H100"` 即可填充 99 行。**禁止逐行调用 `+cells-set` 写入相同结构的公式。** -- 对整行/整列统一设置值、公式、格式或批注时,优先写一个模板单元格,再用 `copy_to_range` 扩展到 `1:1`、`J:J`、`A:A` 等目标范围。 +- **公式填充必须用 `--copy-to-range`(见硬性规则 10)**:先写一行模板公式,再用 `--copy-to-range` 一次填充整列或整区域。示例:在 H2 写 `=SUM(B2:G2)` 后,设 `--copy-to-range "H2:H100"` 即可填充 99 行。**禁止逐行调用 `+cells-set` 写入相同结构的公式。** +- 对整行/整列统一设置值、公式、格式或批注时,优先写一个模板单元格,再用 `--copy-to-range` 扩展到 `1:1`、`J:J`、`A:A` 等目标范围。 - 当用户请求“宽一点 / 高一点 / 和其他行一样高 / 和其他列一样宽”时,先读取相邻可见行列的当前尺寸,再决定使用精确尺寸、`standard` 或 `auto`,不要随意猜测数值。 - 对图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象,按各自 skill 的“先读后改后验证”工作流执行。 @@ -248,7 +248,7 @@ print(df.head(10)) # 必做:横向——确认表头行 + 飞书表格公式与 Excel 公式基本相同,但需要特别注意以下差异: -- 公式来自 Excel 或包含数组场景时,先读取 `lark-sheets-formula-translation` skill 完成改写,再生成公式。 +- 公式来自 Excel 或包含数组场景时,先读取 `lark-sheets-formula-translation` 完成改写,再生成公式。 - 数组公式必须写成 `=ARRAYFORMULA(数组公式)` 语法。 - 在公式字符串中,数据范围应使用飞书支持的语法,例如 `H:H`、`A2:B5`。禁止使用不符合飞书公式语法的写法,如 `H2:H`、`2:2` 等。 - 飞书表格不支持以下函数,禁止主动使用;当用户明确要求使用这些函数时,应拒绝并说明飞书不支持: @@ -289,40 +289,20 @@ print(df.head(10)) # 必做:横向——确认表头行 + - 引用错误:验证所有单元格引用是否仍然有效 - 跨工作表引用:使用 `Sheet!A1` 风格引用 - 整行整列语义丢失:用户说“这行 / 这列 / 首行 / 整列”时,不要把操作范围截断为当前读取到的 `A1:U1`、`J1:J41` 等局部范围 -- **重复写入未使用 `copy_to_range`(高频致命错误)**:整列公式、整列格式、首行样式、向下复制等场景,**必须**用模板单元格 + `copy_to_range`,**禁止**逐行 `+cells-set`。这是最常见的导致轮次耗尽的错误 +- **重复写入未使用 `--copy-to-range`(高频致命错误)**:整列公式、整列格式、首行样式、向下复制等场景,**必须**用模板单元格 + `--copy-to-range`,**禁止**逐行 `+cells-set`。这是最常见的导致轮次耗尽的错误 - **重复调用 `+cells-{merge|unmerge}` / `+rows-resize / +cols-resize` 未合并为 `+batch-update`(高频致命错误)**:当需要合并/调整多个区域时,**必须**使用 `+batch-update` 将多个操作合并为单次调用。逐个调用会快速耗尽轮次上限(60R) - 多步骤请求漏做:若用户要求”先重命名,再新建”,两个动作都必须执行 - **表头定位不精确导致写入全偏(高频致命错误)**:不要假设表头在第 1 行。很多表格有标题行、说明行或空行,实际表头可能在第 2、3 行甚至更后。写入公式或数据前,必须先读取前几行确认表头行号和各列实际含义,再基于确认后的行列号构造写入 range - **参数冗余**:只需修改 10 个单元格时,不要把全表重写一遍;`+cells-set` 的 range 和 cells 应精确覆盖变更区域 - **表头理解路径不对**:要了解表格结构和字段含义时,先用 `+csv-get` 读取前 5-10 行查看表头与字段格式;大表需要全量统计才考虑分批导出后用本地脚本(如 `df.info()` + `df.head()`)分析。不要一行行用 `+cells-get` 逐行读取,也不要依赖 `+cells-search` 去”猜”字段名 -- **隐藏行列导致定位偏移**:`+csv-get` 默认 `skip_hidden=false`(返回完整数据含隐藏行列)。如需只看可见数据,显式设 `skip_hidden=true`,但注意跳过隐藏行后返回数据的行序号与实际行号不对应 +- **隐藏行列导致定位偏移**:`+csv-get` 默认 `--skip-hidden=false`(返回完整数据含隐藏行列)。如需只看可见数据,显式设 `--skip-hidden=true`,但注意跳过隐藏行后返回数据的行序号与实际行号不对应 - **写入前读取范围不充分**:涉及批量写入或修改时,必须先读取足够的数据范围。如果表格有 100 行而只读了 20 行,后续操作会漏掉剩余数据。使用 `+workbook-info` 获取行数后,根据实际行数决定读取范围。注意:了解表头和数据结构只需前几行,但批量操作前需要掌握完整数据 - **`+cells-search` 不是万能的**:用户说”汇总金额”是一个操作动作(求和),不是要搜索”汇总金额”这个文本。只有当确实需要定位某个文本值的位置时才用 `+cells-search` - **跨 sheet 对象**:图表、条件格式、透视表、浮动图片可能分布在多个子表中。操作前先用 `+workbook-info` 掌握全局,不要只看当前子表 -- **copy_to_range 不含行列尺寸**:`copy_to_range` 复制的是值、公式和样式,不包含行高列宽设置。需要统一行列尺寸时,应另行调用 `lark-sheets-range-operations` 中的 `+rows-resize / +cols-resize` +- **--copy-to-range 不含行列尺寸**:`--copy-to-range` 复制的是值、公式和样式,不包含行高列宽设置。需要统一行列尺寸时,应另行调用 `lark-sheets-range-operations` 中的 `+rows-resize / +cols-resize` - **写入前先确保行列存在**:`+cells-set` 不会自动扩展表格。如果要写入的 range 超出当前行列范围,必须先用 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` 插入行列 - **写入后保持原表样式(高频致命错误)**:原表已有边框线、背景色、行高、合并单元格等样式时,写入新数据后**必须**延续相同样式,不要只写值不管格式。具体做法:先用 `+cells-get` 读取源区域的样式(`cell_styles`、`border_styles`),写入时在 cells 中携带相同的样式字段;若源区域有合并单元格,用 `+cells-{merge|unmerge}` 对新区域做相同合并;若源区域有特殊行高,用 `+rows-resize / +cols-resize` 对新区域设置相同行高。详见下方"特殊场景 → 续写/复制已有区块格式" -- **CSV 行号按物理换行计数导致行号全错(高频致命错误)**:`+csv-get` 返回的 CSV 中,被双引号包裹的字段内换行符是**单元格内换行**,不是新行。例如 `"2026年3月2日\n星期一"` 是**一个单元格**,不是两行。计算行号时必须按逻辑记录计数。详见 `lark-sheets-read-data` skill 中的"CSV 行号计算规则" - -## Skill Set - -通过 Read 工具读取对应 reference 获取详细用法。涉及样式/美化时,同时参考 `lark-sheets-visual-standards`。 - -| 类别 | reference | 一句话用途 | 主要 shortcut | -|------|-----------|-----------|--------------| -| 读写 | `lark-sheets-workbook` | 获取工作簿全局结构(首步必调);增删/重命名/移动/复制子表 | `+workbook-info`、`+sheet-{create\|delete\|rename\|move\|copy}` | -| 读取 | `lark-sheets-read-data` | 读取单元格数据:CSV 快速查看、含公式/样式/批注的完整信息 | `+csv-get`(快速查看与分批导出)、`+cells-get`(公式/样式/批注) | -| 读写 | `lark-sheets-sheet-structure` | 获取子表行列布局;增删/隐藏/冻结/分组行列 | `+sheet-info`、`+dim-{insert\|delete\|hide\|unhide\|freeze\|group\|ungroup}` | -| 读写 | `lark-sheets-search-replace` | 按关键字搜索定位单元格;查找并替换文本 | `+cells-search`、`+cells-replace` | -| 写入 | `lark-sheets-write-cells` | 向指定区域写入值/公式/样式/批注,或批量灌入 CSV 纯值 | `+csv-put`(CSV 文本直接铺)、`+cells-set`(精确控制)、`+cells-set-style` / `+cells-set-image`(兄弟拆分) | -| 写入 | `lark-sheets-range-operations` | 清除内容、合并单元格、调整行列尺寸、排序、移动/复制区域 | `+cells-clear`、`+cells-{merge\|unmerge}`、`+rows-resize` / `+cols-resize`、`+range-{move\|copy\|fill\|sort}` | -| 写入 | `lark-sheets-batch-update` | 将多个写入操作合并为单次请求,减少调用次数 | `+batch-update` | -| 对象 | `lark-sheets-chart` | 查询、创建、更新或删除图表 | `+chart-{create\|update\|delete}` | -| 对象 | `lark-sheets-pivot-table` | 查询、创建、更新或删除数据透视表 | `+pivot-{create\|update\|delete}` | -| 对象 | `lark-sheets-conditional-format` | 查询、创建、更新或删除条件格式规则 | `+cond-{format-create\|format-update\|format-delete}` | -| 对象 | `lark-sheets-filter` | 查询、创建、更新或删除筛选器 | `+filter-{create\|update\|delete}` | -| 对象 | `lark-sheets-sparkline` | 查询、创建、更新或删除迷你图 | `+sparkline-{create\|update\|delete}` | -| 对象 | `lark-sheets-float-image` | 查询、创建、更新或删除浮动图片 | `+float-image-{create\|update\|delete}` | +- **CSV 行号按物理换行计数导致行号全错(高频致命错误)**:`+csv-get` 返回的 CSV 中,被双引号包裹的字段内换行符是**单元格内换行**,不是新行。例如 `"2026年3月2日\n星期一"` 是**一个单元格**,不是两行。计算行号时必须按逻辑记录计数。详见 `lark-sheets-read-data` 中的"CSV 行号计算规则" ## 特殊场景 @@ -333,7 +313,7 @@ print(df.head(10)) # 必做:横向——确认表头行 + 1. **用 `+cells-get` 读取源区块的样式**:`+csv-get` 只返回值,无法获取样式。必须用 `+cells-get` 读取源区块,获取每个单元格的 `cell_styles`(字体、背景色、对齐等)和 `border_styles`(边框) 2. **用 `+sheet-info` 读取布局信息**:获取源区块的行高、列宽、合并单元格信息 3. **规划写入范围并扩行**:计算目标行数,若超出当前 sheet 边界,先用 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` 插入行 -4. **带样式写入数据**:`+cells-set` 的 cells 中同时携带 `value` + `cell_styles` + `border_styles`。推荐使用"内容与样式分离写入"策略(见 `lark-sheets-write-cells` skill):先写值,再用模板单元格 + `copy_to_range` 刷样式 +4. **带样式写入数据**:`+cells-set` 的 cells 中同时携带 `value` + `cell_styles` + `border_styles`。推荐使用"内容与样式分离写入"策略(见 `lark-sheets-write-cells`):先写值,再用模板单元格 + `--copy-to-range` 刷样式 5. **合并单元格**:对标题行等需要合并的区域,用 `+batch-update` 批量调用 `+cells-{merge|unmerge}` 6. **设置行高**:用 `+batch-update` 批量调用 `+rows-resize / +cols-resize`,统一设置新区域的行高与源区块一致 7. **回读校验**:用 `+csv-get` 校验值,用 `+cells-get` 抽查样式 @@ -362,4 +342,4 @@ print(df.head(10)) # 必做:横向——确认表头行 + ### 格式处理优先公式 -当用户需求涉及"去除多余零"、"提取数字"、"文本格式转换"、"日期格式化"等数据清洗操作时,**必须优先使用公式方案**(如 `SUBSTITUTE`、`TEXT`、`VALUE`、`LEFT`、`RIGHT`、`MID` 等函数),而非逐行读取数据后手动修改。公式方案只需写一个模板 + `copy_to_range` 即可完成整列处理,远比逐行修改高效。 +当用户需求涉及"去除多余零"、"提取数字"、"文本格式转换"、"日期格式化"等数据清洗操作时,**必须优先使用公式方案**(如 `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 6da48d78a..d2a353ac0 100644 --- a/skills/lark-sheets/references/lark-sheets-filter-view.md +++ b/skills/lark-sheets/references/lark-sheets-filter-view.md @@ -8,12 +8,12 @@ ## 使用场景 -读写筛选视图对象。本 Skill 包含两个工具: +读写筛选视图对象。本 reference 覆盖 4 个 shortcut: | 操作需求 | 使用工具 | 说明 | |---------|---------|------| | 查看已有筛选视图 | `+filter-view-list` | 获取 sheet 上所有视图(视图名、范围、规则) | -| 创建 / 更新 / 删除筛选视图 | `+filter-{view-create|view-update|view-delete}` | 3 种 operation:create / update / delete | +| 创建 / 更新 / 删除筛选视图 | `+filter-{view-create|view-update|view-delete}` | create / update / delete 三个独立 shortcut | 典型工作流:先读取现有视图了解配置 → 执行创建 / 更新 / 删除 → **必须再次读取验证结果**。 @@ -73,22 +73,20 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ ## Schemas -> 复合 JSON flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag-name `。先 `--print-schema`(不带 `--flag-name`)会列出该 shortcut 所有可查询的 flag。 +> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。 ### `+filter-view-create` `--properties` / `+filter-view-update` `--properties` _create / update 的视图属性_ **顶层字段**: -- `filtered_columns` (array?) — 可选 +- `view_name` (string?) — 可选 - `range` (string?) — 视图作用的单元格范围(A1 表示法) - `rules` (array?) — 列级筛选规则列表,每一项对应一个具体列的筛选条件 each: { column_index: string, conditions: array, filtered_rows?: array } -- `view_name` (string?) — 可选 +- `filtered_columns` (array?) — 可选 ## Examples -> ⚠️ 本 skill 是 **CLI 独有**(meta `surface: cli-only`);`generate_mcp` 跳过,不会进 sheet-ai-skills SKILL 集。AI/MCP 侧暂不暴露筛选视图能力。 - 公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。`view_id` 是 10 位随机字符串,每个 sheet 可有多个视图。 ### `+filter-view-list` diff --git a/skills/lark-sheets/references/lark-sheets-filter.md b/skills/lark-sheets/references/lark-sheets-filter.md index 04bef892e..ef2c596a9 100644 --- a/skills/lark-sheets/references/lark-sheets-filter.md +++ b/skills/lark-sheets/references/lark-sheets-filter.md @@ -8,7 +8,7 @@ ## 使用场景 -读写筛选器对象。本 Skill 包含两个工具: +读写筛选器对象。本 reference 覆盖 4 个 shortcut: | 操作需求 | 使用工具 | 说明 | |---------|---------|------| @@ -70,16 +70,16 @@ _仅含公共 / 系统 flag。_ ## Schemas -> 复合 JSON flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag-name `。先 `--print-schema`(不带 `--flag-name`)会列出该 shortcut 所有可查询的 flag。 +> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。 ### `+filter-create` `--properties` / `+filter-update` `--properties` _创建/更新的筛选器属性_ **顶层字段**: -- `filtered_columns` (array?) — 可选 - `range` (string) — 筛选对象作用的单元格范围(A1 表示法) - `rules` (array) — 列级筛选规则列表,每一项对应一个具体列的筛选条件 each: { column_index: string, conditions: array, filtered_rows?: array } +- `filtered_columns` (array?) — 可选 ## Examples @@ -87,6 +87,11 @@ _创建/更新的筛选器属性_ ### `+filter-list` +```bash +# 查看当前 sheet 的筛选器配置(filter_id 等于 sheet_id) +lark-cli sheets +filter-list --url "..." --sheet-id "$SID" +``` + ### `+filter-create` `--range` 是独立 flag(含表头行);`rules` 走 `--properties`: @@ -103,6 +108,10 @@ lark-cli sheets +filter-create --url "..." --sheet-id "$SID" \ ### `+filter-delete` +```bash +lark-cli sheets +filter-delete --url "..." --sheet-id "$SID" --yes +``` + ### Validate / DryRun / Execute 约束 - `Validate`:XOR 公共四件套;`+filter-create` 校验 `--range` 至少 2 行(表头 + 至少 1 行数据);`+filter-update` 必须先 `+filter-list` 确认目标存在;`+filter-delete` 强制 `--yes` 或 `--dry-run`。 diff --git a/skills/lark-sheets/references/lark-sheets-float-image.md b/skills/lark-sheets/references/lark-sheets-float-image.md index aaa64bef2..4733f4a7f 100644 --- a/skills/lark-sheets/references/lark-sheets-float-image.md +++ b/skills/lark-sheets/references/lark-sheets-float-image.md @@ -10,7 +10,7 @@ ## 使用场景 -读写**浮动图片**对象(悬浮在单元格上方的图片,不属于单元格内容)。本 Skill 包含两个工具: +读写**浮动图片**对象(悬浮在单元格上方的图片,不属于单元格内容)。本 reference 覆盖 4 个 shortcut: | 操作需求 | 使用工具 | 说明 | |---------|---------|------| @@ -102,6 +102,10 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ ### `+float-image-list` +```bash +lark-cli sheets +float-image-list --url "..." --sheet-id "$SID" +``` + ### `+float-image-create` 所有字段拍平为独立 flag:`--image-name` / `--image-token` 或 `--image-uri`(XOR) / `--position-{row,col}` / `--size-{width,height}` / `--offset-{row,col}` / `--z-index`。 @@ -136,6 +140,10 @@ lark-cli sheets +float-image-update --url "..." --sheet-id "$SID" \ ### `+float-image-delete` +```bash +lark-cli sheets +float-image-delete --url "..." --float-image-id "$IMG_ID" --yes +``` + ### Validate / DryRun / Execute 约束 - `Validate`:XOR 公共四件套;`+float-image-create` 校验 `--image-name` 非空,`--image-token` 与 `--image-uri` 互斥且至少一个非空,`--position-row/col` 与 `--size-width/height` 必填且为合法整数;`+float-image-update` 必须 `--float-image-id`,其余 `--image-name` / `--image-token` / `--image-uri` / `--position-*` / `--size-*` / `--offset-*` / `--z-index` 至少传 1 个(patch 模式:未传字段保持原值);`+float-image-delete` 强制 `--yes` 或 `--dry-run`。 diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md index 84886b0e7..844da32b5 100644 --- a/skills/lark-sheets/references/lark-sheets-pivot-table.md +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -6,7 +6,7 @@ ## 使用场景 -读写透视表对象。本 Skill 包含两个工具: +读写透视表对象。本 reference 覆盖 4 个 shortcut: | 操作需求 | 使用工具 | 说明 | |---------|---------|------| @@ -86,26 +86,26 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ ## Schemas -> 复合 JSON flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag-name `。先 `--print-schema`(不带 `--flag-name`)会列出该 shortcut 所有可查询的 flag。 +> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。 ### `+pivot-create` `--properties` / `+pivot-update` `--properties` _创建/更新的透视表属性_ **顶层字段**: -- `auto_fit_col` (boolean?) — 是否自动调整列宽以适应内容 -- `calculated_fields` (array?) — 计算字段列表 each: { formula: string, name: string, summarize_by?: enum } -- `collapse` (object?) — 行字段展开/折叠状态:字段名 -> 要折叠的项目列表 -- `columns` (array?) — 横向分组字段(列字段) each: { condition_filter?: object, display_name?: string, field: string, filter?: object, group?: object, …共 6 项 } -- `filters` (array?) — 筛选区域字段(页字段) each: { condition_filter?: object, display_name?: string, field: string, filter?: object, group?: object } - `range` (string?) — 放置透视表的左上角单元格 A1 地址(例如:'F1')(仅 create 时有效) -- `repeat_row_labels` (boolean?) — 是否显示重复项标签 -- `rows` (array?) — 纵向分组字段(行字段) each: { condition_filter?: object, display_name?: string, field: string, filter?: object, group?: object, …共 6 项 } -- `show_col_grand_total` (boolean?) — 是否显示列总计(默认 true) +- `source` (string?) — 源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100') +- `rows` (array?) — 纵向分组字段(行字段) each: { field: string, display_name?: string, sort?: object, filter?: object, condition_filter?: object, …共 6 项 } +- `columns` (array?) — 横向分组字段(列字段) each: { field: string, display_name?: string, sort?: object, filter?: object, condition_filter?: object, …共 6 项 } +- `filters` (array?) — 筛选区域字段(页字段) each: { field: string, display_name?: string, filter?: object, condition_filter?: object, group?: object } +- `values` (array?) — 要汇总的字段(至少需要 1 个) each: { field: string, display_name?: string, summarize_by?: enum, show_data_as?: enum, base_field?: string } +- `auto_fit_col` (boolean?) — 是否自动调整列宽以适应内容 - `show_row_grand_total` (boolean?) — 是否显示行总计(默认 true) +- `show_col_grand_total` (boolean?) — 是否显示列总计(默认 true) - `show_subtotals` (boolean?) — 是否显示分类小计(默认 true,应用于所有字段) -- `source` (string?) — 源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100') -- `values` (array?) — 要汇总的字段(至少需要 1 个) each: { base_field?: string, display_name?: string, field: string, show_data_as?: enum, summarize_by?: enum } +- `repeat_row_labels` (boolean?) — 是否显示重复项标签 +- `calculated_fields` (array?) — 计算字段列表 each: { name: string, formula: string, summarize_by?: enum } +- `collapse` (object?) — 行字段展开/折叠状态:字段名 -> 要折叠的项目列表 ## Examples @@ -113,6 +113,10 @@ _创建/更新的透视表属性_ ### `+pivot-list` +```bash +lark-cli sheets +pivot-list --url "..." --sheet-id "$SID" +``` + ### `+pivot-create` > 数据源 `--source` 必须从表头行开始;空行 / 汇总行会被当作数据参与聚合,需提前用 `+csv-get` 确认起止边界。`--source` 和 `--range` 是独立 flag(不要再放 `--properties`);`rows` / `columns` / `values` 等数组字段走 `--properties`。 @@ -128,6 +132,10 @@ lark-cli sheets +pivot-create --url "..." --sheet-id "$SRC_SID" \ ### `+pivot-delete` +```bash +lark-cli sheets +pivot-delete --url "..." --pivot-table-id "$PID" --yes +``` + ### Validate / DryRun / Execute 约束 - `Validate`:XOR 公共四件套;`+pivot-create` 的 `--source` 必填且必须含表头行;`--properties` 中 `rows` / `columns` / `values` 至少非空之一;`+pivot-delete` 强制 `--yes` 或 `--dry-run`。 diff --git a/skills/lark-sheets/references/lark-sheets-range-operations.md b/skills/lark-sheets/references/lark-sheets-range-operations.md index de6b3e764..240848043 100644 --- a/skills/lark-sheets/references/lark-sheets-range-operations.md +++ b/skills/lark-sheets/references/lark-sheets-range-operations.md @@ -25,14 +25,14 @@ - 用户说"这行 / 整行 / 首行"时,优先使用整行范围如 `1:1`;"这列 / 整列"时使用 `J:J`。不要截断为局部矩形 - 合并后只保留左上角单元格的内容,其余清除。写入合并区域用 `+cells-set` 对左上角单元格操作 - 调整行高列宽时,先读取相邻行列尺寸再决定像素值,不要随意猜测 -- `copy_to_range`(`+cells-set` 的参数)复制的是值/公式/样式,不含行高列宽。需要统一尺寸时另行调用 `+rows-resize / +cols-resize` +- `--copy-to-range`(`+cells-set` 的参数)复制的是值/公式/样式,不含行高列宽。需要统一尺寸时另行调用 `+rows-resize / +cols-resize` ## 写入后列宽自适应(防内容遮挡) 写入文本 / 数值后**必须**主动检查列宽是否适配,否则会出现"内容被截断 / 长数字显示为科学计数法 / 文本溢出被相邻列遮挡"等用户感知问题: 1. **写入后回读最长内容字符数**:用 `+csv-get` 读目标列的实际写入内容,统计最长单元格的字符数(`max(len(cell) for cell in col)`)。汉字按 2 字符宽度估算,半角字母数字按 1 字符。 -2. **判定阈值**:当前列宽(用 `+sheet-info --info_type=row_heights_column_widths` 拿)≥ 最长字符数 × 字体宽度系数 + buffer 才算适配。默认列宽 11 通常只够 11 个半角字符或 5-6 个汉字,写长文本前必扩宽。 +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` 调高对应行的行高 @@ -42,18 +42,18 @@ **⚠️ 合并单元格安全操作规则**(`+cells-{merge|unmerge}` 必读): -1. **先读后写**:操作前必须用 `+sheet-info`(`info_type: merged_cells_infos`)或 `+cells-get` 识别已有合并区域(特征:多个连续单元格中只有左上角有值,其余为空)。 +1. **先读后写**:操作前必须用 `+sheet-info --include merges` 或 `+cells-get` 识别已有合并区域(特征:多个连续单元格中只有左上角有值,其余为空)。 2. **不要对已合并区域重复 merge**:对已合并的区域再次调用 merge 会报错或产生不可预期结果。 3. **修改合并区域的正确顺序**:先 `unmerge` → 修改内容/样式 → 再 `merge`。 4. **对合并区域设置样式**:只对完整 range 设置一次 `cell_styles`(写在左上角单元格),其余位置用 `{}` 占位。 5. **新增合并时数据保护**:合并前确认目标区域只有左上角有数据,其余单元格为空,否则合并会导致非左上角的数据丢失。 -6. **批量取消合并一次调用即可**:当一个范围(整列 `A:A`、整行 `3:3`、矩形 `A1:D100`)内存在多个合并区域,直接调一次 `+cells-{merge|unmerge}(operation: unmerge)` 传入这个大范围,会一次性取消该范围内所有合并区域;**不要**为每个合并区域单独调用 unmerge,也不要用 `+batch-update` 拆成多次 unmerge。 +6. **批量取消合并一次调用即可**:当一个范围(整列 `A:A`、整行 `3:3`、矩形 `A1:D100`)内存在多个合并区域,直接调一次 `+cells-unmerge` 传入这个大范围,会一次性取消该范围内所有合并区域;**不要**为每个合并区域单独调用 unmerge,也不要用 `+batch-update` 拆成多次 unmerge。 **⚠️ 批量操作必须用 `+batch-update`**: -当需要对**多个**不同区域执行 `+cells-{merge|unmerge}`(merge)或 `+rows-resize / +cols-resize` 时,**禁止逐个调用**,必须使用 `+batch-update`(参见 `lark-sheets-batch-update` skill)将所有操作合并为一次请求。逐个调用会快速耗尽工具调用轮次上限。 +当需要对**多个**不同区域执行 `+cells-{merge|unmerge}`(merge)或 `+rows-resize / +cols-resize` 时,**禁止逐个调用**,必须使用 `+batch-update`(参见 `lark-sheets-batch-update`)将所有操作合并为一次请求。逐个调用会快速耗尽工具调用轮次上限。 -**例外**:`+cells-{merge|unmerge}(operation: unmerge)` 原生支持对覆盖多个合并区域的大 range 一次性取消,应直接单次调用,**不要**拆进 `+batch-update`。 +**例外**:`+cells-unmerge` 原生支持对覆盖多个合并区域的大 range 一次性取消,应直接单次调用,**不要**拆进 `+batch-update`。 > 多操作组合示例(合并多区域、批量调整列宽行高的 `+batch-update --operations` JSON 入参格式)见 `lark-sheets-batch-update` 文档。 @@ -73,7 +73,7 @@ **硬性流程**: -1. sort 前先用 `+csv-get` 抽样目标列的前 3–5 行,或用 `+cells-get`(`value_render_option: "raw_value"` 看原始值;默认 `formatted_value` 返回显示值)确认原始值形态,不要只看列名和用户问题就直接排。 +1. sort 前先用 `+csv-get` 抽样目标列的前 3–5 行,或用 `+cells-get`(`--value-render-option raw_value` 看原始值;默认 `formatted_value` 返回显示值)确认原始值形态,不要只看列名和用户问题就直接排。 2. 若是纯数字或日期 → 直接 sort。 3. 若是带符号 / 表达式 / 单位的文本 → **不要直接排**: - 简单场景(货币、千分位、单位前缀):新增辅助列,用公式提取数值(如 `=VALUE(SUBSTITUTE(SUBSTITUTE(A2,"¥",""),",",""))`),按辅助列排序,排完可按需清除辅助列。 @@ -186,15 +186,15 @@ _公共四件套 · 系统:`--dry-run`_ ## Schemas -> 复合 JSON flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag-name `。先 `--print-schema`(不带 `--flag-name`)会列出该 shortcut 所有可查询的 flag。 +> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。 ### `+range-sort` `--sort-keys` _排序条件列表(仅 sort 操作)_ **数组项**(类型 object): -- `ascending` (boolean) — 是否升序排序 - `column` (string) — 排序依据的列字母(如 "C"、"D"),必须在 range 范围内 +- `ascending` (boolean) — 是否升序排序 ## Examples @@ -215,6 +215,13 @@ lark-cli sheets +cells-clear --url "..." --sheet-id "$SID" --range "A2:Z1000" -- ### `+cells-merge` / `+cells-unmerge` +```bash +# 合并 A1:C1(可选 --merge-type all/rows/columns) +lark-cli sheets +cells-merge --url "..." --sheet-id "$SID" --range "A1:C1" +# 取消合并:传大 range 一次性取消其中所有合并区域 +lark-cli sheets +cells-unmerge --url "..." --sheet-id "$SID" --range "A1:C100" +``` + ### `+rows-resize` / `+cols-resize` 行高列宽分两条 shortcut,避免行 / 列在底层 schema 的差异(行支持 `auto`,列不支持)混在一起。每条 `--type` 必填: @@ -241,8 +248,18 @@ lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --start 0 --end 5 --t ### `+range-fill` +```bash +# 用 A1:A2 的序列规律自动填充到 A1:A100 +lark-cli sheets +range-fill --url "..." --sheet-id "$SID" --source-range "A1:A2" --target-range "A1:A100" --series-type auto +``` + ### `+range-sort` +```bash +# 按 C 列降序排 A1:E100(首行为表头不参与) +lark-cli sheets +range-sort --url "..." --sheet-id "$SID" --range "A1:E100" --has-header --sort-keys '[{"column":"C","ascending":false}]' +``` + ### 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`(只行高支持自适应)。 diff --git a/skills/lark-sheets/references/lark-sheets-read-data.md b/skills/lark-sheets/references/lark-sheets-read-data.md index 43246151e..2a6f01d82 100644 --- a/skills/lark-sheets/references/lark-sheets-read-data.md +++ b/skills/lark-sheets/references/lark-sheets-read-data.md @@ -15,7 +15,7 @@ ## 使用场景 -读取。从飞书表格中读取单元格数据。本 skill 提供两个 CLI shortcut,按读取目的选择: +读取。从飞书表格中读取单元格数据。本 reference 覆盖 3 个 shortcut,按读取目的选择: | 读取目的 | 用这个 shortcut | 数据去向 | 说明 | |---------|----------------|---------|------| @@ -37,7 +37,7 @@ 注意: - `+csv-get` 和 `+cells-get` 支持分页/截断,注意检查 `has_more` / `truncated` 标志;使用 `+cells-get` 时,在读取 `cells` 之前还必须先看 `warning_message`,并用每个 range 的 `actual_range` / `row_indices` / `col_indices` 判断真实位置 -- 隐藏行列默认包含在返回结果中(`skip_hidden=false`),如需只看可见数据设为 `true` +- 隐藏行列默认包含在返回结果中(`--skip-hidden=false`),如需只看可见数据设为 `true` **常见配置错误(必须注意)**: - **全量读取导致上下文溢出(高频致命错误)**:不要对大表(数百行以上)直接用 `+csv-get` 或 `+cells-get` 读取全部数据到上下文。大表场景必须分批读取:`+csv-get` 控制 `--max-rows` / `--max-chars`,`+cells-get` 控制 `--range` / `--cell-limit` / `--max-chars`;过大时考虑导出到本地文件后用脚本处理再分批回写 @@ -47,7 +47,7 @@ - 数据量大或会进入上下文上限时,分批读 + 本地处理 + 分批回写,不要一口气拉全表到上下文。 - **`+cells-get` 滥用**:当只需要数据值时,使用 `+csv-get`(token 开销约为 `+cells-get` 的 1/5)。只有确实需要公式、样式或批注时才用 `+cells-get` - **忽略分页标志**:读取返回 `has_more=true` 时,说明还有更多数据。如果任务需要完整数据,必须继续分页读取,不能只处理第一页就开始写入 -- **直接按 `+cells-get` 返回二维数组下标推导真实位置(高频错误)**:`ranges[n].cells[i][j]` 里的 `i/j` 只是返回数组下标,不等于真实表格行列。定位真实行号必须用 `ranges[n].row_indices[i]`,定位真实列字母必须用 `ranges[n].col_indices[j]`;若 `skip_hidden=true`、请求范围越界被裁剪,或最后一行是部分返回,错误地自己数下标会立刻错位 +- **直接按 `+cells-get` 返回二维数组下标推导真实位置(高频错误)**:`ranges[n].cells[i][j]` 里的 `i/j` 只是返回数组下标,不等于真实表格行列。定位真实行号必须用 `ranges[n].row_indices[i]`,定位真实列字母必须用 `ranges[n].col_indices[j]`;若 `--skip-hidden=true`、请求范围越界被裁剪,或最后一行是部分返回,错误地自己数下标会立刻错位 - **CSV 行号计数错误(高频致命错误)**:`+csv-get` 返回的 CSV 遵循 RFC 4180 标准,被双引号 `"..."` 包裹的字段中的换行符属于**字段内容的一部分**(即单元格内换行),不代表新的一行。计算行号时必须按**逻辑记录**计数,而非按物理换行符 `\n` 计数 - **手动数列确定列号(高频致命错误)**:禁止通过在 CSV 表头中手动数逗号/字段来确定目标列的列字母。当列数超过 10 时,手动计数极易产生 off-by-one 偏移(例如把 W 列误判为 X 列)。**必须使用 `col_indices`**:先在 CSV 表头中找到目标字段名是第 j 个字段(0-based),再用 `col_indices[j]` 获取该列的实际列字母 - **用数据列的值推导行号(高频致命错误,常被巧合掩盖)**:CSV 中常见"序号 / ID / 编号 / No."等形似行号的列,其值与实际表格行号**没有任何绑定关系**——序号可能跳号(1,2,3,5,6...)、可能从非 1 开始、可能有重复或被中途重置。此规则适用于**所有需要行号的下游操作**:合并单元格、区间写入/清空/格式化、插入/删除行、条件格式范围、筛选器范围、图表数据源、透视表范围、搜索替换范围等等——**凡是要把行号填进任何工具参数的场景,行号一律从 `annotated_csv` 中目标行开头的 `[row=N]` 前缀直接读取**,禁止用"序号=行号"、"表头占 1 行所以数据从第 2 行开始"、"第 N 个序号就在第 N+1 行"等心算,也禁止先心算再"事后核对"。**危险特征**:前几十行中序号恰好等于表格行号(典型成因:表头 +1 与一次跳号 -1 的偏移互相抵消形成巧合),模型一旦把这个巧合当作规律,会在后续所有行沿用;而中间再出现跳号时,从该行起整块区域全部错位,且错位不自查很难发现。**正确工作流**:①在 `annotated_csv` 里定位目标逻辑行(按字段内容匹配);②直接读取该行开头的 `[row=N]` 前缀得到真实表格行号;③把这个行号填进下游工具参数。区间操作时,起始行用 start 行的 `[row=N]`、结束行用 end 行的 `[row=N]`。**自检**:动手前,在 `annotated_csv` 靠后位置再抽 1~2 行,核对 `[row=N]` 是否与首列"序号"一致——不一致(典型:`[row=57] 58,...`)即说明有跳号/隐藏行,更要严格从 `[row=N]` 取值,不要被序号列迷惑 diff --git a/skills/lark-sheets/references/lark-sheets-search-replace.md b/skills/lark-sheets/references/lark-sheets-search-replace.md index f0813bb9a..fa2489244 100644 --- a/skills/lark-sheets/references/lark-sheets-search-replace.md +++ b/skills/lark-sheets/references/lark-sheets-search-replace.md @@ -10,7 +10,7 @@ ## 使用场景 -读写。在飞书表格中搜索和替换文本。本 Skill 包含两个工具: +读写。在飞书表格中搜索和替换文本。本 reference 覆盖 2 个 shortcut: | 操作需求 | 使用工具 | 说明 | |---------|---------|------| diff --git a/skills/lark-sheets/references/lark-sheets-sheet-structure.md b/skills/lark-sheets/references/lark-sheets-sheet-structure.md index 8b6852b02..8d229dd47 100644 --- a/skills/lark-sheets/references/lark-sheets-sheet-structure.md +++ b/skills/lark-sheets/references/lark-sheets-sheet-structure.md @@ -12,7 +12,7 @@ ## 使用场景 -读写。管理子表结构与布局。本 Skill 包含两个工具: +读写。管理子表结构与布局。本 reference 覆盖 9 个 shortcut(按用途分两类): | 操作需求 | 使用工具 | 说明 | |---------|---------|------| @@ -23,16 +23,16 @@ - 当表格存在合并单元格时,应结合返回的 `merged_cells` 判断表头、分组标题和区域语义 - 不要把合并区域中非左上角的空白单元格理解为"无内容";通常应将左上角单元格的内容视为整个合并区域的语义内容 -- 当前插入语义使用 `operation="insert"` + `position` + `count` + `side` -- 处理"在第 N 行后追加"这类请求时,要显式区分 `before` 和 `after`,避免 off-by-one -- 例如"在第 20 行后新增 116 行",应优先理解为 `position="20"`、`side="after"`、`count=116` +- 插入用 `+dim-insert`:`--dimension`(`row`/`column`)+ `--start`(插入起始 index,0-based)+ `--end`(结束 index,exclusive);插入行/列数 = `--end` − `--start`。新行/列样式继承用 `--inherit-style`(`before`/`after`/`none`) +- 处理"在第 N 行后追加"这类请求时,注意 `--start` 是 0-based 索引、`--end` 是 exclusive,换算时避免 off-by-one +- 例如"在第 20 行后新增 116 行":`--dimension row --start 21 --end 137`("第 20 行后"即从 index 21 起插入,`--end` = `--start` + 116) **常见配置错误(必须注意)**: -- **插入列位置偏移**:插入列时 `position` 是基于 0 的列索引,不是列字母。插入前先通过 `+workbook-info` 或读取表头确认目标位置的实际列索引,不要凭猜测 +- **插入列位置偏移**:插入列时 `--start` 是基于 0 的列索引,不是列字母。插入前先通过 `+workbook-info` 或读取表头确认目标位置的实际列索引,不要凭猜测 - **插入后引用偏移**:插入行/列后,原有数据的行列号会发生偏移。如果插入后还需要对原有区域执行写入操作,必须重新计算偏移后的行列号 -- **删除行列前先确认范围**:删除操作不可逆,执行前应确认 `position` 和 `count` 精确无误。可先用 `+csv-get` 读取目标区域验证内容 -- **"在左侧新增一列"的正确写法**:用户说"在 D 列左侧新增一列"时,应使用 `position` 对应 D 列索引 + `side="before"`,而不是 C 列 + `side="after"`(两者效果一样但前者语义更清晰) -- **插入列后必须检查多行表头合并区域**:很多表格有 2-3 行的合并表头。插入列后,原有的合并区域不会自动扩展到新列。必须先用 `+sheet-info`(`info_type: merged_cells_infos`)读取合并区域,插入后将跨越插入位置的合并区域重新设置(用 `+cells-{merge|unmerge}`),否则新列的表头会是空的、格式不连续 +- **删除行列前先确认范围**:删除操作不可逆,执行前应确认 `--start` / `--end` 精确无误(`+dim-delete` 用 `--dimension` + `--start`(0-based)+ `--end`(exclusive))。可先用 `+csv-get` 读取目标区域验证内容 +- **"在左侧新增一列"的正确写法**:用户说"在 D 列左侧新增一列"时,`--dimension column`、`--start` 取 D 列的 0-based 索引(新列插在该 index 之前)、`--end = --start + 1`;要继承左侧列样式加 `--inherit-style before` +- **插入列后必须检查多行表头合并区域**:很多表格有 2-3 行的合并表头。插入列后,原有的合并区域不会自动扩展到新列。必须先用 `+sheet-info --include merges` 读取合并区域,插入后将跨越插入位置的合并区域重新设置(用 `+cells-{merge|unmerge}`),否则新列的表头会是空的、格式不连续 - **公式写入范围跳过表头行**:写入公式时从数据行开始(不是第 1 行)。先确认表头占几行(可能 1-3 行),公式的起始行 = 表头行数 + 1 ## Shortcuts @@ -164,8 +164,18 @@ lark-cli sheets +dim-insert --url "https://example.feishu.cn/sheets/shtXXX" \ ### `+dim-delete` +```bash +# 删除第 5-7 行(0-based,--end 为 exclusive) +lark-cli sheets +dim-delete --url "..." --sheet-id "$SID" --dimension row --start 5 --end 8 --yes +``` + ### `+dim-hide` / `+dim-unhide` +```bash +lark-cli sheets +dim-hide --url "..." --sheet-id "$SID" --dimension row --start 5 --end 8 +lark-cli sheets +dim-unhide --url "..." --sheet-id "$SID" --dimension row --start 5 --end 8 +``` + ### `+rows-resize` / `+cols-resize` > ⚠️ 这两条 shortcut 来自 `lark-sheets-range-operations` 的 `+rows-resize / +cols-resize` tool(分组在"工作表"是为了发现性)。详细参数和示例在 `lark-sheets-range-operations.md`。 @@ -174,6 +184,11 @@ lark-cli sheets +dim-insert --url "https://example.feishu.cn/sheets/shtXXX" \ ### `+dim-freeze` +```bash +# 冻结前 1 行(--count 传 0 解除冻结) +lark-cli sheets +dim-freeze --url "..." --sheet-id "$SID" --dimension row --count 1 +``` + ### `+dim-group` / `+dim-ungroup`(大纲) > 仅当用户明确说"行分组 / 列分组 / 大纲 / outline"时触发;按字段做数据分组用 `+pivot-create`。 diff --git a/skills/lark-sheets/references/lark-sheets-sparkline.md b/skills/lark-sheets/references/lark-sheets-sparkline.md index 6c972ffd5..cbe5ba675 100644 --- a/skills/lark-sheets/references/lark-sheets-sparkline.md +++ b/skills/lark-sheets/references/lark-sheets-sparkline.md @@ -6,7 +6,7 @@ ## 使用场景 -读写迷你图对象。本 Skill 包含两个工具: +读写迷你图对象。本 reference 覆盖 4 个 shortcut: | 操作需求 | 使用工具 | 说明 | |---------|---------|------| @@ -66,15 +66,15 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ ## Schemas -> 复合 JSON flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag-name `。先 `--print-schema`(不带 `--flag-name`)会列出该 shortcut 所有可查询的 flag。 +> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。 ### `+sparkline-create` `--properties` / `+sparkline-update` `--properties` _创建/更新/部分删除的迷你图属性_ **顶层字段**: -- `config` (object?) — 迷你图样式配置, 相同 groupId 的迷你图共享相同的样式 { axis?: object, contain_hidden_cells?: boolean, empty_show_as?: enum, extremum_max?: object, extremum_min?: object, …共 13 项 } -- `sparklines` (array?) — 迷你图项列表 each: { position?: object, source?: string, source_range?: object, sparkline_id?: string } +- `config` (object?) — 迷你图样式配置, 相同 groupId 的迷你图共享相同的样式 { theme_type?: enum, non_num_show_as?: enum, empty_show_as?: enum, contain_hidden_cells?: boolean, series_color?: string, …共 13 项 } +- `sparklines` (array?) — 迷你图项列表 each: { sparkline_id?: string, position?: object, source?: string, source_range?: object } ## Examples diff --git a/skills/lark-sheets/references/lark-sheets-visual-standards.md b/skills/lark-sheets/references/lark-sheets-visual-standards.md index 8581b8c09..3cc284540 100644 --- a/skills/lark-sheets/references/lark-sheets-visual-standards.md +++ b/skills/lark-sheets/references/lark-sheets-visual-standards.md @@ -142,10 +142,10 @@ **核心思路:三步分层法** ``` -Step 1 — 格式铺开:`+batch-update` + `+range-{move|copy|fill|sort}`(copy/fill) +Step 1 — 格式铺开:`+batch-update` + `+range-copy`(或 `+range-fill`) └── 将模板行/区域的 **全部格式**(样式、边框、数字格式、数据验证等)复制到目标区域 - └── 推荐传 paste_type: "format_only"(仅复制格式,目标值/公式保留),即"格式刷" - └── 若需连带公式平移填充(如公式列结构一致),改用 fill(copyCells) 或 copy 默认的 paste_type: "all" + └── 推荐用 `+range-copy --paste-type formats`(仅复制格式,目标值/公式保留),即"格式刷" + └── 若需连带公式平移填充(如公式列结构一致),改用 `+range-fill --series-type copy` 或 `+range-copy --paste-type all` Step 2 — 内容覆写:`+batch-update` + `+cells-set`(仅传 value/formula,不传任何样式) └── 将每行的实际数据写入,cell_styles 全部省略,因为格式已在 Step 1 中就位 @@ -155,14 +155,14 @@ Step 3 — 微调收尾:`+batch-update` + `+rows-resize / +cols-resize` / `+ce ``` **关键注意事项:** -- Step 1 用 `paste_type: "format_only"` 时,目标区域的值/公式不会被覆盖,Step 2 的 `+cells-set` 无需 `allow_overwrite: true`;若 Step 1 用默认 `all` 连带复制了值/公式,则 Step 2 需要 `allow_overwrite: true` -- `+range-{move|copy|fill|sort}(fill)` 的 `fillSeries` 模式会自动递增数字序列(1→2→3)和日期序列,`copyCells` 则原样复制值但公式引用会自动平移 +- Step 1 用 `+range-copy --paste-type formats` 时只铺格式、不动值/公式,Step 2 再用 `+cells-set` 写值即可(`+cells-set` 默认覆盖,无需额外 flag);若 Step 1 用 `--paste-type all` 连带复制了值/公式,Step 2 写入同样会覆盖(默认行为) +- `+range-fill --series-type auto`(或 `linear`/`date`)会自动递增数字序列(1→2→3)和日期序列,`+range-fill --series-type copy` 则原样复制值但公式引用会自动平移 - 如果模板区域存在合并单元格,copy/fill 不会复制合并状态,必须在 Step 3 中用 `+cells-{merge|unmerge}` 补全 -- 如果模板区域有条件格式,需要在 Step 3 中通过 `+cond-{format-create|format-update|format-delete}(update)` 扩展 ranges +- 如果模板区域有条件格式,需要在 Step 3 中通过 `+cond-format-update` 扩展 ranges **场景:纯"格式刷"(用户说"把 A 列样式应用到 B 列"、"格式复制过去"、"只刷格式不改数据")** -单步即可,无需三步分层:调用 `+range-copy(operation=copy, paste_type="format_only")`,源区域为样式来源,`destination_range` 为目标起点。参数细节见 `lark-sheets-range-operations`。 +单步即可,无需三步分层:调用 `+range-copy --paste-type formats`,`--source-range` 为样式来源、`--target-range` 为目标起点。参数细节见 `lark-sheets-range-operations`。 ### 场景三:已有区域格式美化 @@ -173,8 +173,8 @@ Step 3 — 微调收尾:`+batch-update` + `+rows-resize / +cols-resize` / `+ce ``` 1. 探查阶段 ├── `+workbook-info` → 获取子表列表、行列数、冻结位置 - ├── `+sheet-info`(info_type: merged_cells_infos)→ 获取合并区域 - ├── `+cells-get`(前几行 + 末尾几行,include_styles: true)→ 采样表头/数据区/汇总行样式 + ├── `+sheet-info --include merges` → 获取合并区域 + ├── `+cells-get`(前几行 + 末尾几行,`--include style`)→ 采样表头/数据区/汇总行样式 └── 分析结果 → 建立区域地图(表头行号、数据起止行号、汇总行号、合并区域列表) 2. 规划阶段 diff --git a/skills/lark-sheets/references/lark-sheets-workbook.md b/skills/lark-sheets/references/lark-sheets-workbook.md index ea65956a4..a3cfc05d6 100644 --- a/skills/lark-sheets/references/lark-sheets-workbook.md +++ b/skills/lark-sheets/references/lark-sheets-workbook.md @@ -10,7 +10,7 @@ ## 使用场景 -读写。管理工作簿结构。本 Skill 包含两个工具: +读写。管理工作簿结构。本 reference 覆盖 11 个 shortcut: | 操作需求 | 使用工具 | 说明 | |---------|---------|------| @@ -159,6 +159,10 @@ lark-cli sheets +sheet-create --url "https://example.feishu.cn/sheets/shtXXX" \ ### `+sheet-rename` +```bash +lark-cli sheets +sheet-rename --url "..." --sheet-id "$SID" --title "汇总" +``` + ### `+sheet-move` standalone 路径在缺 `--source-index` / 只给 `--sheet-name` 时会自动发起一次 `+workbook-info` 读把它们解出来。 @@ -167,10 +171,25 @@ standalone 路径在缺 `--source-index` / 只给 `--sheet-name` 时会自动发 ### `+sheet-copy` +```bash +# --title 省略时由服务端生成副本名 +lark-cli sheets +sheet-copy --url "..." --sheet-id "$SID" --title "副本" +``` + ### `+sheet-hide` / `+sheet-unhide` +```bash +lark-cli sheets +sheet-hide --url "..." --sheet-id "$SID" +lark-cli sheets +sheet-unhide --url "..." --sheet-id "$SID" +``` + ### `+sheet-set-tab-color` +```bash +# Hex 色值;传空字符串 "" 清除标签色 +lark-cli sheets +sheet-set-tab-color --url "..." --sheet-id "$SID" --color "#FF0000" +``` + ### Validate / DryRun / Execute 约束 - `Validate`:XOR 公共四件套;`+sheet-create` 校验 `--title` 非空、`--row-count` ≤ 50000、`--col-count` ≤ 200;`+sheet-delete` 必须 `--yes` 或 `--dry-run`。 diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 76168d403..4473db177 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -4,7 +4,7 @@ 1. **明确写入边界**:写入前必须能回答"目标 range 的起止行列号是多少?是否落在用户授权范围内?"。除用户明示要修改的区域外,禁止扩张到原数据列以外或新建 Sheet。 2. **完整性断言**:批量写入前先把"预期写入条数"硬编码到代码里(如要填 106 条翻译 → `expected = 106`),写完后回读断言 `actual == expected`。少于预期就继续写,禁止交付半成品。 -3. **回读抽样校验**:写完关键值 / 公式后,用 `+csv-get` 或 `+cells-get` 重新读取写入区域,至少抽样 3-5 个代表性单元格(首 / 中 / 末),核对值与预期一致(与 本地脚本 计算的预期值对照)。公式特定的"先验证模板再 copy_to_range / 修完再读回"细则见下方相关章节。 +3. **回读抽样校验**:写完关键值 / 公式后,用 `+csv-get` 或 `+cells-get` 重新读取写入区域,至少抽样 3-5 个代表性单元格(首 / 中 / 末),核对值与预期一致(与 本地脚本 计算的预期值对照)。公式特定的"先验证模板再 --copy-to-range / 修完再读回"细则见下方相关章节。 ## 新增列 / 新增行的样式继承(防止视觉风格不一致) @@ -17,7 +17,7 @@ 3. `cell_styles.number_format`(小数位 / 千分位 / 百分比 / 日期格式)—— 漏继承会导致同列数值格式混乱 4. `cell_styles.background_color`(背景色) 5. `border_styles`(四边框) -6. **`merged_cells`(合并范围)**——续写场景必查(高频致命错误):用 `+sheet-info --info_type=merged_cells_infos` 读原数据区域的合并信息。**原行有跨列合并**(如标题行 `A1:G1` 合并)时,新行**必须**用 `+cells-{merge|unmerge}` 工具复制相同合并模式到新行(如续写第 3 个周报块的标题行 `A23:G23` 必须合并)。仅传 cells 数组的 5 类样式不够——合并范围要单独靠 `+cells-{merge|unmerge}` 工具落地(典型反例:续写多周记录表时,新增周次的标题行未合并,视觉上与原前几周风格不一致) +6. **`merged_cells`(合并范围)**——续写场景必查(高频致命错误):用 `+sheet-info --include merges` 读原数据区域的合并信息。**原行有跨列合并**(如标题行 `A1:G1` 合并)时,新行**必须**用 `+cells-{merge|unmerge}` 工具复制相同合并模式到新行(如续写第 3 个周报块的标题行 `A23:G23` 必须合并)。仅传 cells 数组的 5 类样式不够——合并范围要单独靠 `+cells-{merge|unmerge}` 工具落地(典型反例:续写多周记录表时,新增周次的标题行未合并,视觉上与原前几周风格不一致) **采样模板的正确做法**: - 表头新列 → 读相邻表头单元格(如新加 D1 → 读 A1/B1/C1 任一) @@ -44,7 +44,7 @@ ## 使用场景 -写入。为一块单元格区域设置值、公式、批注/备注和/或格式。也支持通过 `rich_text` 中 `type: "embed-image"` 在单元格内嵌入图片(单元格图片)。关键:数组维度必须严格匹配——`cells` 二维数组必须与 `range` 的行列维度完全一致,range 是闭区间,否则会触发 `InvalidCellRangeError`。计算示例:区域 `A1:D3` = 3 行 × 4 列 = `[[r1c1,r1c2,r1c3,r1c4],[r2c1,r2c2,r2c3,r2c4],[r3c1,r3c2,r3c3,r3c4]]`;区域 `A41:N48` = 8 行 × 14 列 = 8 个数组且每个数组 14 个单元;单个单元格 `A1` = `[[cell]]`;单列区域 `B5:B7` = `[[cell1],[cell2],[cell3]]`。空单元请使用 `{}`。**如果填写的区域存在大量重复内容,务必优先使用 `copy_to_range` 字段复制,可大幅减少 `cells` 长度。** +写入。为一块单元格区域设置值、公式、批注/备注和/或格式。也支持通过 `rich_text` 中 `type: "embed-image"` 在单元格内嵌入图片(单元格图片)。关键:数组维度必须严格匹配——`cells` 二维数组必须与 `range` 的行列维度完全一致,range 是闭区间,否则会触发 `InvalidCellRangeError`。计算示例:区域 `A1:D3` = 3 行 × 4 列 = `[[r1c1,r1c2,r1c3,r1c4],[r2c1,r2c2,r2c3,r2c4],[r3c1,r3c2,r3c3,r3c4]]`;区域 `A41:N48` = 8 行 × 14 列 = 8 个数组且每个数组 14 个单元;单个单元格 `A1` = `[[cell]]`;单列区域 `B5:B7` = `[[cell1],[cell2],[cell3]]`。空单元请使用 `{}`。**如果填写的区域存在大量重复内容,务必优先使用 `--copy-to-range` 字段复制,可大幅减少 `cells` 长度。** > **单元格图片 vs 浮动图片**: > - **单元格图片**(本工具):图片嵌入在单元格内部,属于单元格内容,随单元格移动。通过 `rich_text` 中 `type: "embed-image"` 写入。 @@ -52,24 +52,24 @@ 高频模式(**必须遵守,禁止逐行写入替代**): -- 整列公式:先在 `H2` 写一个公式,再用 `copy_to_range: "H2:H100"` 或 `copy_to_range: "H:H"` 向下填充。**禁止对每一行单独调用 `+cells-set` 写入相同结构的公式** -- 整列格式:先在 `J1` 写一个带样式的模板单元格,再用 `copy_to_range: "J:J"` -- 首行样式:先在 `A1` 写一个模板单元格,再用 `copy_to_range: "1:1"` -- 用户说”这列 / 整列 / 这行 / 首行 / 向下复制”时,**必须**使用模板单元格 + `copy_to_range` -- 多区域写入相同格式/公式结构时,优先写一个模板,再用 `copy_to_range` 复制到所有目标区域 +- 整列公式:先在 `H2` 写一个公式,再用 `--copy-to-range "H2:H100"` 或 `--copy-to-range "H:H"` 向下填充。**禁止对每一行单独调用 `+cells-set` 写入相同结构的公式** +- 整列格式:先在 `J1` 写一个带样式的模板单元格,再用 `--copy-to-range "J:J"` +- 首行样式:先在 `A1` 写一个模板单元格,再用 `--copy-to-range "1:1"` +- 用户说”这列 / 整列 / 这行 / 首行 / 向下复制”时,**必须**使用模板单元格 + `--copy-to-range` +- 多区域写入相同格式/公式结构时,优先写一个模板,再用 `--copy-to-range` 复制到所有目标区域 -⚠️ **逐行写入公式是最常见的致命错误**:对每一行单独调用 `+cells-set` 写入公式(如调用 26 次),会快速耗尽轮次上限导致操作不完整。正确做法是 1 次模板写入 + 1 次 `copy_to_range` = 2 次调用完成。 +⚠️ **逐行写入公式是最常见的致命错误**:对每一行单独调用 `+cells-set` 写入公式(如调用 26 次),会快速耗尽轮次上限导致操作不完整。正确做法是 1 次模板写入 + 1 次 `--copy-to-range` = 2 次调用完成。 -💡 **写入公式前先按迁移规则改写**:如果公式来自 Excel 或包含数组场景,先读取并遵循 `lark-sheets-formula-translation` skill 的规则完成改写,再把最终公式写入 `formula` 字段。 +💡 **写入公式前先按迁移规则改写**:如果公式来自 Excel 或包含数组场景,先读取并遵循 `lark-sheets-formula-translation` 的规则完成改写,再把最终公式写入 `formula` 字段。 💡 **内容与样式分离写入(推荐)**:当需要同时写入内容和样式时,`cells` 中每个单元格都带上 `cell_styles` / `border_styles` 会导致入参非常冗长。由于同一区域的样式通常高度重复(如整列统一背景色、统一边框),推荐拆成两步: 1. **先写内容**:`+cells-set` 只传 `value` / `formula`,不带样式,`cells` 入参精简 -2. **再批量刷样式**:对区域中的一个单元格写入目标样式作为模板,再用 `copy_to_range` 将样式扩展到整列 / 整行 / 整个区域(`copy_to_range` 会复制值、公式和样式,所以模板单元格应已包含正确的值) +2. **再批量刷样式**:对区域中的一个单元格写入目标样式作为模板,再用 `--copy-to-range` 将样式扩展到整列 / 整行 / 整个区域(`--copy-to-range` 会复制值、公式和样式,所以模板单元格应已包含正确的值) 示例:要对 A2:A100 写入数据并统一设置蓝色背景 + 边框: ``` Step 1: `+cells-set` — range="A2:A100", cells 只含 value(无样式,入参短) -Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styles(单个模板), copy_to_range="A2:A100" +Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styles(单个模板), --copy-to-range="A2:A100" ``` 这比在 99 个单元格中都重复写样式 JSON 高效得多。 @@ -78,25 +78,25 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl 注意: - 不要把 `cells` 写成字符串化 JSON -- 如果目标区域中已有值、公式或样式需要被覆盖,显式设置 `allow_overwrite=true` +- `+cells-set` 默认即覆盖非空 cell(`--allow-overwrite` 默认 true);若要**保护**非空 cell 不被覆盖,显式传 `--allow-overwrite=false`(遇非空 cell 报错) - 若目标区域涉及合并单元格,不要向合并区域中的非左上角单元格写入数据;如需写入,应改写合并区域左上角单元格,或先调整/取消合并区域 - **构造 `range` 时行号必须基于逻辑行号**:如果之前通过 `+csv-get` 读取了数据,CSV 中被双引号包裹的多行字段(如 `"2026年3月2日\n星期一"`)是**一个单元格**,不是两行。写入时的行号必须按逻辑记录计算,不能按物理换行符计数,否则 `range` 会整体偏移导致写入到错误位置 ⚠️ **"样式与原表一致"必须包含 `border_styles`(高频致命错误)**:当用户说"样式和原表一致"、"保持原表格式"、"边框继承"等要求时,cells 里的 `cell_styles` **不能只传 `font_size` / `horizontal_alignment` / `vertical_alignment`**——这几项只覆盖字体和对齐,**不包含边框**。边框必须用独立的 `border_styles` 字段传(或在源 cell 用 `+cells-get` 读出来再原样复制)。 - **反模式**:`cells=[[{cell_styles:{font_size:16, horizontal_alignment:"center", vertical_alignment:"middle"}}]]`(字体+对齐都有,但**新 cell 仍然没边框**,视觉上与原表断裂) - **正确做法**:`cell_styles` + `border_styles` 一起传,`border_styles` 覆盖 top/bottom/left/right 四条边(或至少 data 区该加的几条),确保视觉连续 -- 特别是**新列/新行**场景,新 cell 底子里本来就没边框,如果不显式传 `border_styles`,copy_to_range 复制的模板也没边框 → 整列/整行无边框 +- 特别是**新列/新行**场景,新 cell 底子里本来就没边框,如果不显式传 `border_styles`,--copy-to-range 复制的模板也没边框 → 整列/整行无边框 -⚠️ **公式写入必须自己校验结果(后端不会报语法错)**:`+cells-set` 写公式时,即便公式有括号不配对(如 `=IFERROR(VALUE(REGEXEXTRACT(D5, "\d+"))), 0)` 比 IFERROR 多一个 `)`)或函数名拼错(`=UNIQUE(...)` 飞书不支持),**后端工具也会返回 `updated_cells_count=N, rc=0` 的"成功"**——错误会静默写进单元格显示为 `#VALUE!` / `#NAME?` / `#REF!`。因此: +⚠️ **公式写入必须自己校验结果(后端不会报语法错)**:`+cells-set` 写公式时,即便公式有括号不配对(如 `=IFERROR(VALUE(REGEXEXTRACT(D5, "\d+"))), 0)` 比 IFERROR 多一个 `)`)或用了飞书不支持的函数(如 `GOOGLETRANSLATE` / `CUBEVALUE`),**后端工具也会返回 `updated_cells_count=N, rc=0` 的"成功"**——错误会静默写进单元格显示为 `#VALUE!` / `#NAME?` / `#REF!`。因此: 1. **写完立即读回**:`+cells-set` 后紧跟 `+csv-get`(或 `+cells-get`)读目标范围前几行,检查是否出现 `#VALUE!` / `#NAME?` / `#REF!` / `#N/A` / `#DIV/0!` / `#NUM!` -2. **看到 `#` 开头的错误值**立即修公式:`#NAME?` 多半是函数名拼错或飞书不支持(UNIQUE/DISTINCT 等);`#VALUE!` 多半是类型不匹配或括号错位;`#REF!` 是引用错误;`~CIRCULAR~REF~` 是循环引用(公式引用了自身或会闭环) -3. **`copy_to_range` 扩展前先验证模板**:模板单元格公式自己都算错,`copy_to_range` 复制到 100 行就是 100 个错误 -4. **飞书不支持的函数**:`UNIQUE` / `DISTINCT` / `FILTER`(部分)—— 对应"去重"场景改用透视表(`+pivot-{create|update|delete}`,值字段聚合方式选 count) +2. **看到 `#` 开头的错误值**立即修公式:`#NAME?` 多半是函数名拼错或用了飞书不支持的函数(如 `GOOGLETRANSLATE` / CUBE 系列;注意 `UNIQUE` / `FILTER` / `SPLIT` 飞书是支持的);`#VALUE!` 多半是类型不匹配或括号错位;`#REF!` 是引用错误;`~CIRCULAR~REF~` 是循环引用(公式引用了自身或会闭环) +3. **`--copy-to-range` 扩展前先验证模板**:模板单元格公式自己都算错,`--copy-to-range` 复制到 100 行就是 100 个错误 +4. **去重 / 筛选函数**:飞书**支持** `UNIQUE` / `FILTER` / `SPLIT`(原生数组函数,详见 `lark-sheets-formula-translation`),可直接用;`DISTINCT` 不是飞书函数,去重用 `UNIQUE`。大数据量去重 / 分组也可用透视表(`+pivot-{create|update|delete}`,值字段聚合方式选 count) 5. **循环引用预检(高频致命错误)**:写聚合公式(SUM / AVERAGE / COUNT 等)前必须明确**引用范围不包含目标单元格自身或其传递依赖**。典型反例:在 C3 写 `=SUMIF(B:B,LEFT(B3,9)&"*",C:C)`,B 列匹配 B3 前 9 位时 C3 自己也命中,导致 C3 自引用 → `~CIRCULAR~REF~`。修法:用辅助列 / 显式排除自身(`SUMIFS(C:C, B:B, ..., A:A, "<>"&A3)`)/ 缩小范围避开自己 6. **REGEX 模式覆盖率验证**:公式里的 `REGEXEXTRACT` / `REGEXMATCH` / `REGEXREPLACE` 等正则模式落地前必须用 本地脚本 在源列上跑一遍命中率统计(`df[col].str.contains(pattern).mean()`);命中率 < 100% 时必须扩展 pattern 或加多分支(IFS / 多个 IFERROR 串联)兜底,**禁止**只覆盖样本前 N 行就交付(典型反例:用 `REGEXEXTRACT(D5,"长(\d+)")` 只匹配带"长"前缀的尺寸文本,对"宽×高"、"×"、"*"等其它分隔符直接漏匹配) 7. **公式范围与用户指令字面对齐**:用户说"对 F 至 L 列求和"就必须写 `SUM(F2:L2)` 或 `F2+G2+H2+I2+J2+K2+L2`,**不能漏列、多列、错列**。写完用 `+cells-get` 拿回 `formula` 字符串,与用户原话逐字对照(参与求和的列名一致 / 起止列号一致 / 运算符一致),不一致就是违规 -⚠️ **收到 `formula_errors` 反馈后不要只打补丁(高频致命错误)**:`+cells-set` 返回值里若出现 `formula_errors: [{cell, formula, error_type, detail}]`,说明某些 cell 公式编译失败(`error_type=compile_failed` 通常是函数语法错如 `SPLIT(x)[1]` 飞书不支持;`non_formula` 是 `=` 开头但解析不通过)。此时**禁止只聚焦修报错点的局部语法**(如仅把 `[1]` 换成 `INDEX(..,1)`),必须: +⚠️ **收到 `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 兜底漏条件、引用列写错),修完语法错后立即整体复核 2. **同步对称修复所有相似列**:如果同一任务涉及多列相似处理(如"算 H 列面积"用 D 列尺寸、"算 I 列面积"用 E 列尺寸),**修完一列必须把同样的清洗/兜底逻辑同步到所有相似列**,禁止出现 H 列用 `SUBSTITUTE(长)+SUBSTITUTE(高)+SUBSTITUTE(×)` 而 I 列只用 `SUBSTITUTE(×)` 这种不对称处理——会导致一列编译通过有值、另一列编译通过但 IFERROR 全返回空,用户看到的是"数据为空"而非"公式错" @@ -121,11 +121,11 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl **做法 A(推荐):两步走——先铺样式、再覆内容** ``` -Step 1: 用模板单元格 + copy_to_range 铺"完整样式"(不是只铺 border)到新区域 +Step 1: 用模板单元格 + --copy-to-range 铺"完整样式"(不是只铺 border)到新区域 `+cells-set` — range="A11", cells=[[{ border_styles: {...}, cell_styles: { /* 按行性质填充:数据行继承数据区样式;汇总行见 lark_sheet_visual_standards */ } - }]], copy_to_range="A11:H11" + }]], --copy-to-range="A11:H11" Step 2: 再用 `+cells-set` 单独写具体 value/formula(不再传样式,避免覆盖) `+cells-set` — range="A11", cells=[[{value: "平均分"}]] @@ -154,7 +154,7 @@ Step 2: 再用 `+cells-set` 单独写具体 value/formula(不再传样式, | 场景 | 用这个 shortcut | 原因 | |------|----------------|------| -| 模型手里已经有 CSV 文本(小规模手动构造、从 `+csv-get` 取到后简单加工) | `+csv-put` | 直接传 CSV 文本 + start_cell,不用自己拼二维 cells 数组;必要时自动扩容行列 | +| 模型手里已经有 CSV 文本(小规模手动构造、从 `+csv-get` 取到后简单加工) | `+csv-put` | 直接传 CSV 文本 + `--start-cell`,不用自己拼二维 cells 数组;必要时自动扩容行列 | | 写入含公式、样式、批注、图片、数据校验等任意富写入 | `+cells-set` | 唯一支持完整字段的 shortcut | | 只改已有 cell 的样式,不动 value/formula | `+cells-set-style` | 拍平 10 个样式字段为独立 flag;不触发不必要的值写入 | | 单 cell 嵌入图片 | `+cells-set-image` | 比 `+cells-set` 参数更简短 | @@ -188,6 +188,7 @@ _公共四件套 · 系统:`--dry-run`_ | `--cells` | string + File + Stdin(复合 JSON) | required | JSON:2D 数组 `[[{cell},...],...]`,维度与 `--range` 完全一致;每个 cell 可含 `value` / `formula` / `cell_styles` / `note` / `rich_text`(含 `type="embed-image"` 单元格嵌图)等,完整字段跑 `--print-schema` | | `--allow-overwrite` | bool | optional | 允许覆盖非空 cell(默认 true);设为 false 时遇非空 cell 报错 | | `--max-cells` | int | optional | 防爆,默认 50000 | +| `--copy-to-range` | string | optional | 复制范围(A1 表示法):把 --range 中 --cells 写入的内容(值/公式/样式,取决于实际传入字段)复制到该区域,公式引用自动平移(如 C2=B2 → C3=B3)。适合先写一行/一块模板再扩展填充整列/整区域(如 --range A1:G1 写模板、--copy-to-range A1:G100 填充 100 行)。支持整行 3:6、整列 C:E、到列尾 D3:D、到行尾 D3:3;支持英文逗号分隔多个目标区域,如 C1:D2,E5:F6 | ### `+cells-set-style` @@ -242,34 +243,34 @@ _公共四件套 · 系统:`--dry-run`_ ## Schemas -> 复合 JSON flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag-name `。先 `--print-schema`(不带 `--flag-name`)会列出该 shortcut 所有可查询的 flag。 +> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。 ### `+cells-set` `--cells` **顶层字段**: -- `border_styles` (object?) — 单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top) { bottom?: object, left?: object, right?: object, top?: object } -- `cell_styles` (object?) — 单元格样式属性,包括字体、颜色、对齐方式和数字格式 { background_color?: string, font_color?: string, font_line?: enum, font_size?: number, font_style?: enum, …共 10 项 } -- `data_validation` (object?) — 数据验证配置 { help_text?: string, items?: array, operator?: enum, range?: string, support_multiple_values?: boolean, …共 7 项 } +- `value` (oneOf?) — 静态单元格值(文本、数字、布尔) - `formula` (string?) — 以 '=' 开头的单元格公式(例如:'=SUM(A1:A10)') -- `multiple_values` (array?) — 多值内容,用于支持多选的列表验证单元格 each: { format?: string, value: oneOf } - `note` (string?) — 单元格批注/备注 -- `rich_text` (array?) — 富文本内容 each: { attachment_name?: string, attachment_token?: string, attachment_uri?: string, file_size?: number, image_height?: number, …共 17 项 } -- `value` (oneOf?) — 静态单元格值(文本、数字、布尔) +- `cell_styles` (object?) — 单元格样式属性,包括字体、颜色、对齐方式和数字格式 { font_color?: string, font_size?: number, font_weight?: enum, font_style?: enum, font_line?: enum, …共 10 项 } +- `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 } +- `data_validation` (object?) — 数据验证配置 { type: enum, items?: array, range?: string, operator?: enum, values?: array, …共 7 项 } ### `+cells-set-style` `--border-styles` _单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)_ **顶层字段**: -- `bottom` (object?) { color?: string, style?: enum, weight?: enum } -- `left` (object?) { color?: string, style?: enum, weight?: enum } -- `right` (object?) { color?: string, style?: enum, weight?: enum } -- `top` (object?) { color?: string, style?: enum, weight?: enum } +- `top` (object?) { style?: enum, weight?: enum, color?: string } +- `bottom` (object?) { style?: enum, weight?: enum, color?: string } +- `left` (object?) { style?: enum, weight?: enum, color?: string } +- `right` (object?) { style?: enum, weight?: enum, color?: string } ### `+dropdown-set` `--options` -_列表选项(type='list' 时必填)_ +_列表选项_ **数组项**(类型 string): - 标量:string From efcc55460b525131f44bddbc6e8bd5e26c52a66e Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Sat, 23 May 2026 01:18:25 +0800 Subject: [PATCH 033/114] docs(sheets): sync lark-sheets skill spec (chart/pivot wire mappings, --end semantics) Sync skill references and flag-defs descriptions from upstream sheet-skill-spec: clarify +chart-create properties structure (snapshot.data), +pivot-create --target-position / --range wire-field mappings, add a cross-command --end endpoint-semantics table (insert/delete/hide/group exclusive vs move/resize inclusive), note --group-state default, and rename reference identifiers to lark-sheets-*. Description-only refinement; the existing CLI implementation already matches the clarified wire mappings and --end semantics. --- shortcuts/sheets/data/flag-defs.json | 6 +++--- skills/lark-sheets/SKILL.md | 10 +++++----- .../references/lark-sheets-batch-update.md | 4 ++-- skills/lark-sheets/references/lark-sheets-chart.md | 12 +++++++----- .../references/lark-sheets-conditional-format.md | 12 ++++++------ .../references/lark-sheets-core-operations.md | 8 ++++---- .../references/lark-sheets-filter-view.md | 8 ++++---- skills/lark-sheets/references/lark-sheets-filter.md | 2 +- .../references/lark-sheets-float-image.md | 8 ++++---- .../references/lark-sheets-pivot-table.md | 8 ++++---- .../references/lark-sheets-range-operations.md | 6 +++--- .../lark-sheets/references/lark-sheets-read-data.md | 10 +++++----- .../references/lark-sheets-search-replace.md | 2 +- .../references/lark-sheets-sheet-structure.md | 12 +++++++++++- .../lark-sheets/references/lark-sheets-workbook.md | 2 +- .../references/lark-sheets-write-cells.md | 6 +++--- 16 files changed, 64 insertions(+), 52 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index a2272865c..ba8390fb9 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -3038,7 +3038,7 @@ "kind": "own", "type": "string", "required": "required", - "desc": "Full chart config JSON (`position` / `data` / `properties` etc.); deeply nested, must be passed as JSON", + "desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.", "input": [ "file", "stdin" @@ -3266,7 +3266,7 @@ "kind": "own", "type": "string", "required": "optional", - "desc": "Destination anchor cell (A1 notation, e.g. `A1`); default `A1`", + "desc": "Top-left cell within the target sub-sheet (A1 notation, e.g. `A1`); pairs with `--target-sheet-id`, maps to the top-level `target_position`, default `A1` (not sent when the value is A1). It and `--range` both express placement but map to different wire fields — avoid passing conflicting values for both.", "default": "A1" }, { @@ -3281,7 +3281,7 @@ "kind": "own", "type": "string", "required": "optional", - "desc": "Pivot table placement (single A1 anchor for the top-left, e.g. `F1`); placed at the top-left of a newly created sub-sheet when omitted" + "desc": "Pivot table top-left placement (single A1 value, e.g. `F1`; create only), maps to `properties.range`; placed at the top-left of the target sub-sheet (a newly created one by default) when omitted. It and `--target-position` both express placement but map to different wire fields — avoid passing conflicting values for both." }, { "name": "dry-run", diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index 3350cba8c..ace2edcf5 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -20,14 +20,14 @@ metadata: | Reference | 描述 | | --- | --- | | [飞书表格核心操作:分析、编辑与可视化](references/lark-sheets-core-operations.md) | 飞书表格核心操作工作流。当用户需要对已有的飞书表格进行查看、分析、编辑或可视化时使用。适用场景:数据查询与统计、公式计算、表格美化、创建图表/透视表、筛选排序、批量修改数据、调整表格结构等。即使用户没有明确说"飞书表格",只要操作对象是已有的在线表格,都应触发此工作流。不适用于本地 Excel 文件操作。 | -| [飞书表格样式与配色规范](references/lark-sheets-visual-standards.md) | 飞书表格样式与配色规范:表头/数据区/汇总行的颜色、字号、对齐、边框等取值标准,以及新增汇总行、追加行列继承原表风格、已有区域美化等典型场景的决策流程与样式要点。工具调用参数细节请参考对应的 lark_sheet_write_cells / lark_sheet_range_operations / lark_sheet_batch_update。条件格式(高亮、标红、数据条、色阶)请使用 lark_sheet_conditional_format。仅针对飞书表格;Excel 请参考 excel_general_visual_standards。 | +| [飞书表格样式与配色规范](references/lark-sheets-visual-standards.md) | 飞书表格样式与配色规范:表头/数据区/汇总行的颜色、字号、对齐、边框等取值标准,以及新增汇总行、追加行列继承原表风格、已有区域美化等典型场景的决策流程与样式要点。工具调用参数细节请参考对应的 lark-sheets-write-cells / lark-sheets-range-operations / lark-sheets-batch-update。条件格式(高亮、标红、数据条、色阶)请使用 lark-sheets-conditional-format。仅针对飞书表格,不适用于本地 Excel 文件。 | | [飞书表格公式生成规则](references/lark-sheets-formula-translation.md) | Excel 公式到飞书表格公式的迁移与生成规则。核心目标不是保留 Excel 原语法,而是按飞书表格可执行规则重写公式,并在结果上尽量对齐 Excel。当用户要求把 Excel 公式改写成飞书表格公式,或需要生成飞书公式(尤其涉及 ARRAYFORMULA、原生数组函数、INDEX/OFFSET、MAP/LAMBDA、日期差、多层范围结果与二次展开)时使用。仅针对飞书在线表格,不适用于本地 Excel 文件执行。 | | [Lark Sheet Workbook](references/lark-sheets-workbook.md) | 管理飞书表格的工作簿结构(子表列表及元数据)。当用户提到"看看这个表格有什么"、"表格结构"、"有哪些 sheet"、"新建一个 sheet"、"删除这个工作表"、"重命名"、"复制一份"、"移动到前面"时使用。仅针对飞书表格。 | -| [Lark Sheet Sheet Structure](references/lark-sheets-sheet-structure.md) | 管理飞书表格的子表结构与布局。适用场景:查看行高、列宽、隐藏行列、合并单元格等布局信息,以及"插入一行"、"删除这列"、"隐藏行"、"冻结表头"、行列分组(大纲折叠/展开)等操作。行列大纲仅在用户明确提到"行分组"、"列分组"、"大纲"、"outline"时才触发,"按XXX分组"等数据分组场景请使用 lark_sheet_pivot_table。如需在表尾追加数据,应先通过此 skill 插入行,再通过 lark_sheet_write_cells 写入。仅针对飞书表格。 | +| [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_sheet_float_image);若只需把一块 CSV 纯值批量铺到表格上(不带公式/样式),直接使用 `+csv-put` 更短更快。追加数据需先通过 lark_sheet_sheet_structure 插入行列。仅针对飞书表格。 | -| [Lark Sheet Range Operations](references/lark-sheets-range-operations.md) | 对飞书表格中指定区域执行结构性操作(不涉及写入单元格数据值)。适用场景:清除内容或格式("清空"、"删除内容"、"去掉格式")、合并/取消合并单元格、调整行高列宽("加宽列"、"自适应列宽")、移动/复制/填充/排序数据("移动数据"、"复制到"、"自动填充"、"按某列排序")。写入单元格数据请使用 lark_sheet_write_cells。仅针对飞书表格。 | +| [Lark Sheet Write Cells](references/lark-sheets-write-cells.md) | 向飞书表格的指定区域批量写入值、公式、样式、批注或单元格图片。适用场景:填写数据、设置公式、修改格式、添加批注、嵌入单元格图片(如需操作浮动图片,请使用 lark-sheets-float-image);若只需把一块 CSV 纯值批量铺到表格上(不带公式/样式),直接使用 `+csv-put` 更短更快。追加数据需先通过 lark-sheets-sheet-structure 插入行列。仅针对飞书表格。 | +| [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) | 管理飞书表格中的图表(柱形图、折线图、饼图、条形图、面积图、散点图、组合图、雷达图等)。当用户需要创建图表、修改图表样式或数据源、查看已有图表配置、删除图表时使用。也适用于用户提到"数据可视化"、"画个图"、"趋势分析"、"对比图"、"占比分析"、"做个图表"等数据可视化相关场景。仅针对飞书表格。 | | [Lark Sheet Pivot Table](references/lark-sheets-pivot-table.md) | 管理飞书表格中的数据透视表。当用户需要创建透视表、修改透视表的行列字段/聚合方式/筛选条件、查看已有透视表配置、删除透视表时使用。也适用于用户提到"分组汇总"、"交叉分析"、"按XXX统计"、"按字段分组"、"再分下组"、"多维分析"、"数据透视"等场景。仅针对飞书表格。 | @@ -35,7 +35,7 @@ metadata: | [Lark Sheet Filter](references/lark-sheets-filter.md) | 管理飞书表格中的筛选器(filter)。当用户需要筛选数据(按文本/数值/颜色/日期条件过滤行)、查看已有筛选配置、修改或删除筛选器时使用。也适用于"只看"、"筛选出"、"仅保留符合条件的"等场景。仅针对飞书表格。 | | [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_sheet_write_cells Skill。仅针对飞书表格。 | +| [Lark Sheet Float Image](references/lark-sheets-float-image.md) | 管理飞书表格中的浮动图片。当用户需要在表格中插入浮动图片、调整图片位置和大小、查看已有浮动图片、删除图片时使用。也适用于"插入图片"、"添加 logo"、"放一张图"等场景。注意:如果用户需要将图片嵌入到某个单元格内部(单元格图片),请阅读 lark-sheets-write-cells。仅针对飞书表格。 | ## 公共 flag 速查 diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 1a80ed7f9..1bb8a009d 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -98,7 +98,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_ _要批量执行的 CLI shortcut 操作列表,按声明顺序串行执行;任一失败立即中断_ **数组项**(类型 object): -- `shortcut` (enum) — CLI shortcut 名(不是底层 MCP tool 名) [+cells-set / +cells-set-style / +cells-clear / +cells-merge / +cells-unmerge / +cells-replace / +csv-put / +dropdown-set / +dim-insert / +dim-delete / +dim-hide / +dim-unhide / +dim-freeze / +dim-group / …共 50 项,完整集见 SKILL.md 的 References 表与各 reference 的 Shortcuts 段] +- `shortcut` (enum) — CLI shortcut 名(不是底层 MCP tool 名) [+cells-set / +cells-set-style / +cells-clear / +cells-merge / +cells-unmerge / +cells-replace / +csv-put / +dropdown-set / +dim-insert / +dim-delete / +dim-hide / +dim-unhide / +dim-freeze / +dim-group / +dim-ungroup / +rows-resize / +cols-resize / +range-move / +range-copy / +range-fill / +range-sort / +sheet-create / +sheet-delete / +sheet-rename / +sheet-move / +sheet-copy / +sheet-hide / +sheet-unhide / +sheet-set-tab-color / +chart-create / +chart-update / +chart-delete / +pivot-create / +pivot-update / +pivot-delete / +cond-format-create / +cond-format-update / +cond-format-delete / +filter-create / +filter-update / +filter-delete / +filter-view-create / +filter-view-update / +filter-view-delete / +sparkline-create / +sparkline-update / +sparkline-delete / +float-image-create / +float-image-update / +float-image-delete] - `input` (object) — 该 shortcut 的入参集(不含 spreadsheet 定位);基础 flag 跑 `lark-cli sheets --help… ### `+cells-batch-set-style` `--border-styles` @@ -178,4 +178,4 @@ lark-cli sheets +cells-batch-clear --url "..." \ - `Validate`:`+batch-update` 的 `--operations` 必须合法 JSON,且为非空数组;逐个子操作 `shortcut` / `input` 字段必填校验;**禁止嵌套 `+batch-update`**。`+cells-batch-set-style` 的 `--ranges` 必须 JSON 数组、每项带 sheet 前缀;样式 flag 至少一个非空(或带 `--border-styles`)。`+cells-batch-clear` 的 `--ranges` 同样必须 JSON 数组、每项带 sheet 前缀,`high-risk-write` 强制 `--yes` 或 `--dry-run`(`--scope` 默认 `content`)。 - `DryRun`:按顺序输出每个子操作的目标 API + 请求 body 模板;首个失败则整批 fail-fast(不实际执行任何后续)。 -- `Execute`:按声明顺序串行执行;任一子操作失败立即中断并回滚到该子操作前状态(具体回滚能力取决于子操作类型,沿用 MCP `+batch-update` 的语义)。 +- `Execute`:按声明顺序串行执行;任一子操作失败立即中断并回滚到该子操作前状态(具体回滚能力取决于子操作类型,沿用 `+batch-update` 的语义)。 diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md index 18caf776c..312f0a482 100644 --- a/skills/lark-sheets/references/lark-sheets-chart.md +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -27,20 +27,22 @@ **多图表需求**:当用户同时提到多种分析(如"统计占比 + 对比数量"),必须创建多个图表,每个对应一种类型,不要只做一个。 +**`--properties` 结构锚点(构造前必读)**:`--properties` 顶层只有 `position` / `offset` / `size` / `snapshot` 四个字段,**没有**顶层 `data`,也没有再嵌一层 `properties`。图表数据配置全部挂在 `snapshot.data` 下——下文及示例里出现的 `refs` / `headerMode` / `dim1` / `dim2` / `nameRef` 一律指 `snapshot.data.refs` / `snapshot.data.headerMode` / `snapshot.data.dim1` / `snapshot.data.dim2`(及其下的 `serie.nameRef` / `series[].nameRef`);样式 / 堆叠 / 数据标签等在 `snapshot.plotArea` 下。完整结构以 `lark-cli sheets +chart-create --print-schema --flag-name properties` 为准。 + **常见配置错误(必须注意)**: - **图表类型选择错误**:用户说"堆积柱状图/百分比堆积"时,应在 `properties.snapshot.plotArea.plot.extra.stack` 中配置堆叠;百分比堆叠需在该 stack 下设置 `percentage: true`。用户说"占比/比例"时,优先考虑饼图或百分比堆积图 - **数据标签缺失**:用户需要看到具体数值时,需配置 `properties.snapshot.plotArea.plot.labels`(数据标签)相关字段 - **数据源范围与系列名来源要对齐**: - **默认情况(inline 模式)**:`refs` 范围**应包含表头行**(首行/首列即系列名),且范围要精确覆盖目标数据,不要多选或少选。 - **合并标题行要跳过**:如果表格在表头上方存在合并的标题行(如"员工统计表"横跨多列的大标题),`refs` 必须跳过标题行、从真正的列标题行开始。例如表头在第 3 行、数据在第 4-20 行,则 `refs` 应为 `A3:G20` 而非 `A1:G20`。包含合并标题行会导致列名识别错误、表头被当作数据参与聚合计算。 - - **数据与表头分离时必须用 detached 模式**:当 `refs` 只覆盖完整数据的一个子集(按筛选/分组只画其中一段),而真正的语义表头在该子集之外时,**必须**设置 `data.headerMode='detached'`:refs 仅传纯数据范围,维度名/系列名通过 `dim1.serie.nameRef` / `dim2.series[].nameRef` 指向真正的表头单元格。详见下文"硬性规则:数据与表头分离场景必须使用 detached 模式"。 + - **数据与表头分离时必须用 detached 模式**:当 `refs` 只覆盖完整数据的一个子集(按筛选/分组只画其中一段),而真正的语义表头在该子集之外时,**必须**设置 `snapshot.data.headerMode='detached'`:refs 仅传纯数据范围,维度名/系列名通过 `snapshot.data.dim1.serie.nameRef` / `snapshot.data.dim2.series[].nameRef` 指向真正的表头单元格。详见下文"硬性规则:数据与表头分离场景必须使用 detached 模式"。 - **axes[].label 不接受 `format` / `number_format` 字段**:想给坐标轴数值加千分位、百分号等格式化时,不要在 `axes[i].label` 里传 `format` 或 `number_format`(schema 未定义,会报 `unexpected property "format" is not defined in schema`)。数值格式化统一在源数据单元格的 `cell_styles.number_format` 里设置(写 `+cells-set` 时),图表会沿用单元格格式。 - **创建后必须验证**:图表创建后必须调用 `+chart-list` 验证配置是否正确 > **⚠️ 硬性规则:当用户通过列标题名称(而非列索引)指定横轴/纵轴系列时,必须先读取表格首行(表头)来确定列名与列索引的对应关系,再设置 `dim1`/`dim2` 的 `index`。** > 例如用户说"横轴为车型系列,纵轴为Q1-Q4的销量",你不能猜测列索引,必须先通过读取表格数据源范围的首行内容(使用 `lark-sheets-read-data` 的 `+cells-get` 或其他读取单元格的工具),确认"车型系列"是第几列、"Q1"~"Q4"分别是第几列,然后再将正确的列索引填入 `dim1.serie.index` 和 `dim2.series[].index`。 -> **⚠️ 硬性规则:数据与表头分离场景必须使用 detached 模式。** 当 `refs` 仅覆盖数据的一个子集,而真正的语义表头行/列位于该子集之外时,**必须** `data.headerMode='detached'` 并配上 `nameRef`。不能用 inline 模式 + 把 refs 多带 1 行兜底表头来替代——那种写法已废弃。否则图表会把错误的首行/首列当系列名,或图例显示成"系列1/系列2"等默认名,或者 refs 里混入相邻分组的数据。 +> **⚠️ 硬性规则:数据与表头分离场景必须使用 detached 模式。** 当 `refs` 仅覆盖数据的一个子集,而真正的语义表头行/列位于该子集之外时,**必须** `snapshot.data.headerMode='detached'` 并配上 `nameRef`。不能用 inline 模式 + 把 refs 多带 1 行兜底表头来替代——那种写法已废弃。否则图表会把错误的首行/首列当系列名,或图例显示成"系列1/系列2"等默认名,或者 refs 里混入相邻分组的数据。 > > **触发该规则的典型信号**(满足任意一条都必须走 detached): > - 用户要求"针对 X 类的数据画图"、"只看某个分组"、"只画筛选后的部分",而 X 类对应的行段在数据中间或末尾,与表头不连续; @@ -74,7 +76,7 @@ 1. `+pivot-create create` 返回 `sheet_id` + `pivot_table_id` 2. 调 `+csv-get(sheet_id, 'A1:E30')` 或 `+pivot-list` 读 pivot 产物的**实际数据范围** 3. 识别并排除"总计"/"小计"行(通常最后一行;嵌套 pivot 还要排除中间层小计) -4. `+chart-create create` 时 `data.refs` 精确到数据行(如 pivot 占 A1:D9、总计在 row9 → chart 用 `A1:D8`) +4. `+chart-create create` 时 `snapshot.data.refs` 精确到数据行(如 pivot 占 A1:D9、总计在 row9 → chart 用 `A1:D8`) 详细规则见 `lark-sheets-pivot-table` 第 5 节"pivot → chart 组合场景"。 @@ -119,7 +121,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--properties` | string + File + Stdin(复合 JSON) | required | 图表完整配置 JSON(`position` / `data` / `properties` 等);结构嵌套深,统一走 JSON 注入 | +| `--properties` | string + File + Stdin(复合 JSON) | required | 图表完整配置 JSON。顶层字段为 `position` / `offset` / `size` / `snapshot`(无顶层 `data`,也无再嵌一层 `properties`);图表数据配置在 `snapshot.data` 下(含 `refs` / `headerMode` / `dim1` / `dim2`)。结构嵌套深,完整结构跑 `--print-schema --flag-name properties` | ### `+chart-update` @@ -177,7 +179,7 @@ lark-cli sheets +chart-create --url "https://example.feishu.cn/sheets/shtXXX" \ > **`--properties` JSON 关键字段**(结构见上方 `## Schemas` 段;详见语义内容章节): > - `position.row` / `position.col` 必须留足空间,越界会被 API 拒 > - `snapshot.data.headerMode`:默认 inline;当 refs 仅覆盖数据子集且语义表头在子集之外,必须 `detached` + `nameRef` -> - chart 引用 pivot 输出时,`snapshot.data.data_range` 必须排除总计 / 小计行 +> - chart 引用 pivot 输出时,`snapshot.data.refs` 必须排除总计 / 小计行 ### `+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 bf970e3a1..3af0eda7b 100644 --- a/skills/lark-sheets/references/lark-sheets-conditional-format.md +++ b/skills/lark-sheets/references/lark-sheets-conditional-format.md @@ -2,7 +2,7 @@ ## 真对象硬约束 + 触发词清单 -用户出现以下口语指令时,**强制**走 `+cond-{format-create|format-update|format-delete}`,**禁止**用 `+cells-set` 写静态背景色 / 字体色代替: +用户出现以下口语指令时,**强制**走 `+cond-format-{create|update|delete}`,**禁止**用 `+cells-set` 写静态背景色 / 字体色代替: - **颜色动作**:"标红 / 标黄 / 标绿 / 上色 / 染色 / 涂色 / 表红色 / 表黄色" - **视觉强调**:"高亮 / 突出 / 标记 / 标注 / 区分" @@ -23,7 +23,7 @@ | 操作需求 | 使用工具 | 说明 | |---------|---------|------| | 查看已有条件格式 | `+cond-format-list` | 获取规则类型、范围和样式配置 | -| 创建/更新/删除条件格式 | `+cond-{format-create|format-update|format-delete}` | 对条件格式规则执行写入操作 | +| 创建/更新/删除条件格式 | `+cond-format-{create|update|delete}` | 对条件格式规则执行写入操作 | 典型工作流:先读取现有条件格式了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**。 @@ -48,7 +48,7 @@ Step 1: `+cells-set` 在新列写判断公式(形成"是/否"或布尔辅助 range="H2", cells=[[{formula: "=IF(A2>B2, \"是\", \"否\")"}]], --copy-to-range="H2:H100" Step 2: 基于辅助列值做条件格式(用 cellIs 或引用辅助列的 expression) - `+cond-{format-create|format-update|format-delete}` create + `+cond-format-{create|update|delete}` create rule_type: "expression" ranges: ["A2:H100"] // 整行高亮 attrs: [{formula: ["=$H2=\"是\""]}] // 引用辅助列 @@ -58,7 +58,7 @@ Step 2: 基于辅助列值做条件格式(用 cellIs 或引用辅助列的 exp **错误做法(一步走绕过辅助列)**: ``` -`+cond-{format-create|format-update|format-delete}` create +`+cond-format-{create|update|delete}` create rule_type: "expression" ranges: ["2:145"] attrs: [{formula: ["=$O2>$H2"]}] ← 虽然逻辑等价,但产物里缺辅助列 → 扣配置需求分 @@ -127,8 +127,8 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ _创建/更新的条件格式属性_ **顶层字段**: -- `rule_type` (enum) — 条件格式规则类型 [duplicateValues / uniqueValues / cellIs / containsText / timePeriod / containsBlanks / notContainsBlanks / dataBar / colorScale / rank / aboveAverage / expression / iconSet] -- `ranges` (array) — 应用条件格式的 A1 范围列表 +- `rule_type` (enum) — 条件格式规则类型 [duplicateValues / uniqueValues / cellIs / containsText / timePeriod / containsBlanks / notContainsBlanks / dataBar / colorScale / rank / aboveAverage / expression / iconSet] — ⚠️ 已拎为独立 flag `--rule-type`,请勿在此 JSON 内重复填写(同名以独立 flag 为准) +- `ranges` (array) — 应用条件格式的 A1 范围列表 — ⚠️ 已拎为独立 flag `--ranges`,请勿在此 JSON 内重复填写(同名以独立 flag 为准) - `style` (object) — 命中规则时应用的单元格样式 { back_color?: string, fore_color?: string, text_decoration?: enum, font?: enum } - `attrs` (array?) — 规则参数列表 - `has_ref` (boolean?) — 可选 diff --git a/skills/lark-sheets/references/lark-sheets-core-operations.md b/skills/lark-sheets/references/lark-sheets-core-operations.md index f01a7a2f7..1b1543a80 100644 --- a/skills/lark-sheets/references/lark-sheets-core-operations.md +++ b/skills/lark-sheets/references/lark-sheets-core-operations.md @@ -25,7 +25,7 @@ | 任务类型 | 必须使用 | 禁止 | |---|---|---| - | 重复检测 / 条件高亮 / 颜色标记 | `+cond-{format-create|format-update|format-delete}` | 本地脚本 逐行 + `+cells-set` 静态背景色 | + | 重复检测 / 条件高亮 / 颜色标记 | `+cond-format-{create|update|delete}` | 本地脚本 逐行 + `+cells-set` 静态背景色 | | 大批量行公式填充 | 模板公式 + `--copy-to-range "X:X"` | 本地脚本 计算每行 + 静态写入 | | 大数据筛选 | `+filter-{create|update|delete}` 或 `+pivot-{create|update|delete}` | 复制到新 sheet 后覆盖 | | 大批量数据 set | 分批 `+cells-set`,每批 ≤ 100 行 | 一次写 1000+ 行 | @@ -35,7 +35,7 @@ ## 硬性规则 -1. **先读 skill 再调工具,但要高效读取**:每个工具的参数约束、边界条件和常见陷阱都记录在对应的子 skill 中。跳过 skill 直接调工具,容易传错参数或遗漏关键步骤。**但必须控制 read_skill 的调用次数**: +1. **先读 skill 再调工具,但要高效读取**:每个工具的参数约束、边界条件和常见陷阱都记录在对应的子 skill 中。跳过 skill 直接调工具,容易传错参数或遗漏关键步骤。**但必须控制读取 reference 的次数**: - 在开始操作前,先规划本次任务需要哪些工具,一次性列出要读取的 skill 清单,而不是用一个读一个 - 如果本轮对话中已经读取过某个 skill,不要重复读取 - 本 skill(`lark-sheets-core-operations`)+ `lark-sheets-workbook` 是几乎每次都需要的基础 skill,读完后应立即进入操作,不要在读取阶段停留过久 @@ -67,7 +67,7 @@ 4. **公式优先于硬编码值**:写公式(如 `=SUM(B2:B9)`)而非计算后的静态数值(如 `5000`),因为公式会在源数据变化时自动重算。写死数值后,用户改了源数据结果就不对了。 -5. **区分公式语法和工具参数语法**:公式字符串中的范围引用(如 `H:H`、`$A$2:$B$5`)遵循飞书公式语法;而 MCP tool 的 `range` / `ranges` / `--copy-to-range` 参数使用 A1 表示法(如 `A1:D3`、`1:1`)。两者写法不同,混淆会导致调用失败。 +5. **区分公式语法和工具参数语法**:公式字符串中的范围引用(如 `H:H`、`$A$2:$B$5`)遵循飞书公式语法;而 CLI 工具的 `range` / `ranges` / `--copy-to-range` 参数使用 A1 表示法(如 `A1:D3`、`1:1`)。两者写法不同,混淆会导致调用失败。 6. **合并单元格需特殊处理**:合并区域只有左上角单元格存储数据,其余位置读取为空——这不代表”无内容”,而是合并的正常行为。写入时只能写左上角,写其他位置会报错或被忽略。如需修改合并区域中间的某格,先取消合并再操作。**在合并区域中间行插入数据之前,必须先调用 `+cells-get` 或 `+sheet-info` 确认目标行是否落在某个合并区域内**——直接用 `+cells-set` 写入合并区域的非左上角单元格,后端会返回 `cell at row N, col M is inside a merged region` 错误;即使 LLM 响应错误后改调 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` 插入行,行号也可能因合并扩展而错位。同理,**新增列后若原表存在合并的标题行(如 A1:F1),需要手动用 `+cells-{merge|unmerge}` 扩展合并范围到新列(如 A1:I1)**,否则标题行不会跟着变宽。 @@ -213,7 +213,7 @@ print(df.head(10)) # 必做:横向——确认表头行 + | 按XX统计YY、分组汇总 | `+pivot-{create|update|delete}` | pandas groupby → `+cells-set` | | 求和/计数/平均/占比 | 公式(SUM/COUNT/AVERAGE) | Python 计算 → 写静态值 | | 画图表、可视化 | `+chart-{create|update|delete}` | matplotlib/seaborn 画图 | -| 条件高亮、色阶 | `+cond-{format-create|format-update|format-delete}` | 逐单元格设样式 | +| 条件高亮、色阶 | `+cond-format-{create|update|delete}` | 逐单元格设样式 | | 数据筛选 | `+filter-{create|update|delete}` | pandas filter → 覆盖写入 | | 文本提取/转换 | 公式(REGEXEXTRACT/TEXT/VALUE) | Python 正则 → 写静态值 | | 查找匹配 | 公式(VLOOKUP/INDEX+MATCH) | pandas merge → 写静态值 | diff --git a/skills/lark-sheets/references/lark-sheets-filter-view.md b/skills/lark-sheets/references/lark-sheets-filter-view.md index d2a353ac0..a5cfeb1d7 100644 --- a/skills/lark-sheets/references/lark-sheets-filter-view.md +++ b/skills/lark-sheets/references/lark-sheets-filter-view.md @@ -4,7 +4,7 @@ 筛选视图是 sheet 内的多份独立筛选配置,每个视图持有自己的 `range` 和 `rules`,由独立 `view_id`(10 位随机字符串)标识。一个 sheet 可有多个视图,视图的隐藏行仅在用户进入该视图时本地生效,不影响其他协作者,也不与该 sheet 上可能并存的筛选器(filter)互相影响。 -`+filter-{view-create|view-update|view-delete}` 负责视图本身的 CRUD(create / update / delete);视图的"进入 / 退出"(激活态)是本地状态,不在工具语义内。 +`+filter-view-{create|update|delete}` 负责视图本身的 CRUD(create / update / delete);视图的"进入 / 退出"(激活态)是本地状态,不在工具语义内。 ## 使用场景 @@ -13,7 +13,7 @@ | 操作需求 | 使用工具 | 说明 | |---------|---------|------| | 查看已有筛选视图 | `+filter-view-list` | 获取 sheet 上所有视图(视图名、范围、规则) | -| 创建 / 更新 / 删除筛选视图 | `+filter-{view-create|view-update|view-delete}` | create / update / delete 三个独立 shortcut | +| 创建 / 更新 / 删除筛选视图 | `+filter-view-{create|update|delete}` | create / update / delete 三个独立 shortcut | 典型工作流:先读取现有视图了解配置 → 执行创建 / 更新 / 删除 → **必须再次读取验证结果**。 @@ -80,8 +80,8 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ _create / update 的视图属性_ **顶层字段**: -- `view_name` (string?) — 可选 -- `range` (string?) — 视图作用的单元格范围(A1 表示法) +- `view_name` (string?) — 可选 — ⚠️ 已拎为独立 flag `--view-name`,请勿在此 JSON 内重复填写(同名以独立 flag 为准) +- `range` (string?) — 视图作用的单元格范围(A1 表示法) — ⚠️ 已拎为独立 flag `--range`,请勿在此 JSON 内重复填写(同名以独立 flag 为准) - `rules` (array?) — 列级筛选规则列表,每一项对应一个具体列的筛选条件 each: { column_index: string, conditions: array, filtered_rows?: array } - `filtered_columns` (array?) — 可选 diff --git a/skills/lark-sheets/references/lark-sheets-filter.md b/skills/lark-sheets/references/lark-sheets-filter.md index ef2c596a9..19e612cb8 100644 --- a/skills/lark-sheets/references/lark-sheets-filter.md +++ b/skills/lark-sheets/references/lark-sheets-filter.md @@ -77,7 +77,7 @@ _仅含公共 / 系统 flag。_ _创建/更新的筛选器属性_ **顶层字段**: -- `range` (string) — 筛选对象作用的单元格范围(A1 表示法) +- `range` (string) — 筛选对象作用的单元格范围(A1 表示法) — ⚠️ 已拎为独立 flag `--range`,请勿在此 JSON 内重复填写(同名以独立 flag 为准) - `rules` (array) — 列级筛选规则列表,每一项对应一个具体列的筛选条件 each: { column_index: string, conditions: array, filtered_rows?: array } - `filtered_columns` (array?) — 可选 diff --git a/skills/lark-sheets/references/lark-sheets-float-image.md b/skills/lark-sheets/references/lark-sheets-float-image.md index 4733f4a7f..e358532bf 100644 --- a/skills/lark-sheets/references/lark-sheets-float-image.md +++ b/skills/lark-sheets/references/lark-sheets-float-image.md @@ -1,12 +1,12 @@ # Lark Sheet Float Image > **单元格图片 vs 浮动图片**:飞书表格有两种图片类型,请根据需求选择正确的工具: -> - **单元格图片**:图片嵌入在单元格内部,随单元格移动,属于单元格内容的一部分。→ 使用 `+cells-set`,在 `rich_text` 中设置 `type: "embed-image"`(见 lark_sheet_write_cells Skill)。 -> - **浮动图片**(本 Skill):图片悬浮在单元格上方,可自由指定位置、大小和层级,不属于任何单元格的内容。→ 使用本 Skill 的 `+float-{image-create|image-update|image-delete}`。 +> - **单元格图片**:图片嵌入在单元格内部,随单元格移动,属于单元格内容的一部分。→ 使用 `+cells-set`,在 `rich_text` 中设置 `type: "embed-image"`(见 lark-sheets-write-cells)。 +> - **浮动图片**(本 Skill):图片悬浮在单元格上方,可自由指定位置、大小和层级,不属于任何单元格的内容。→ 使用本 Skill 的 `+float-image-{create|update|delete}`。 ## 真对象硬约束 -当用户要求"插入图片 / 添加 logo / 放一张图"时,**必须**通过 `+float-{image-create|image-update|image-delete}`(浮动图片)或 `+cells-set` 的 `embed-image`(单元格图片)创建真实的图片对象。**禁止**只在文本回复中给出图片链接 / 描述图片内容代替插入。判断标准:交付后 `+float-image-list` 或单元格 `rich_text` 必须能读到该图片对象。 +当用户要求"插入图片 / 添加 logo / 放一张图"时,**必须**通过 `+float-image-{create|update|delete}`(浮动图片)或 `+cells-set` 的 `embed-image`(单元格图片)创建真实的图片对象。**禁止**只在文本回复中给出图片链接 / 描述图片内容代替插入。判断标准:交付后 `+float-image-list` 或单元格 `rich_text` 必须能读到该图片对象。 ## 使用场景 @@ -15,7 +15,7 @@ | 操作需求 | 使用工具 | 说明 | |---------|---------|------| | 查看已有浮动图片 | `+float-image-list` | 获取浮动图片的位置、大小和层级配置 | -| 创建/更新/删除浮动图片 | `+float-{image-create|image-update|image-delete}` | 对浮动图片执行写入操作 | +| 创建/更新/删除浮动图片 | `+float-image-{create|update|delete}` | 对浮动图片执行写入操作 | 典型工作流:先读取现有浮动图片了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**。 diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md index 844da32b5..dbc8512b3 100644 --- a/skills/lark-sheets/references/lark-sheets-pivot-table.md +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -63,9 +63,9 @@ _公共四件套 · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--properties` | string + File + Stdin(复合 JSON) | required | JSON:{"rows":[...],"columns":[...],"values":[...],"filters":[...],"show_row_grand_total":true,"show_col_grand_total":true}(数据源走 --source,不要再放进 properties.source) | | `--target-sheet-id` | string | optional | 透视表落点子表 id;省略时自动新建子表(推荐) | -| `--target-position` | string | optional | 落点起始 cell(A1 格式,如 `A1`),默认 `A1` | +| `--target-position` | string | optional | 透视表落点子表内的起始 cell(A1 格式,如 `A1`),与 `--target-sheet-id` 配套、映射到顶层 `target_position`,默认 `A1`(值为 A1 时不下发)。它与 `--range` 都表达落点但落在不同 wire 字段,避免两者同时给冲突值 | | `--source` | string | required | 透视表源数据区域(A1 表示法,格式 `SheetName!StartCell:EndCell`,如 `Sheet1!A1:D100`) | -| `--range` | string | optional | 透视表放置位置(左上角 A1 单值,如 `F1`);省略时放在新建子表的左上角 | +| `--range` | string | optional | 透视表左上角放置位置(A1 单值,如 `F1`,仅 create 生效),映射到 `properties.range`;省略时放在落点子表(默认新建子表)的左上角。它与 `--target-position` 都表达落点但落在不同 wire 字段,避免两者同时给冲突值 | ### `+pivot-update` @@ -93,8 +93,8 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ _创建/更新的透视表属性_ **顶层字段**: -- `range` (string?) — 放置透视表的左上角单元格 A1 地址(例如:'F1')(仅 create 时有效) -- `source` (string?) — 源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100') +- `range` (string?) — 放置透视表的左上角单元格 A1 地址(例如:'F1')(仅 create 时有效) — ⚠️ 已拎为独立 flag `--range`,请勿在此 JSON 内重复填写(同名以独立 flag 为准) +- `source` (string?) — 源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100') — ⚠️ 已拎为独立 flag `--source`,请勿在此 JSON 内重复填写(同名以独立 flag 为准) - `rows` (array?) — 纵向分组字段(行字段) each: { field: string, display_name?: string, sort?: object, filter?: object, condition_filter?: object, …共 6 项 } - `columns` (array?) — 横向分组字段(列字段) each: { field: string, display_name?: string, sort?: object, filter?: object, condition_filter?: object, …共 6 项 } - `filters` (array?) — 筛选区域字段(页字段) each: { field: string, display_name?: string, filter?: object, condition_filter?: object, group?: object } diff --git a/skills/lark-sheets/references/lark-sheets-range-operations.md b/skills/lark-sheets/references/lark-sheets-range-operations.md index 240848043..94ae8593b 100644 --- a/skills/lark-sheets/references/lark-sheets-range-operations.md +++ b/skills/lark-sheets/references/lark-sheets-range-operations.md @@ -111,7 +111,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--range` | string | required | 待合并 / 取消合并的范围(A1 格式) | -| `--merge-type` | string | optional | 合并方向(仅 `+cells-merge`)(可选值:`all` / `rows` / `columns`) | +| `--merge-type` | string | optional | 合并方向(仅 `+cells-merge`)(可选值:`all` / `rows` / `columns`)(默认 `all`) | ### `+cells-unmerge` @@ -162,7 +162,7 @@ _公共四件套 · 系统:`--dry-run`_ | `--source-range` | string | required | 源 A1 范围 | | `--target-sheet-id` | string | optional | 目标子表 id;省略时同源 sheet | | `--target-range` | string | required | 目标 A1 范围(传起点 cell 即可,按源尺寸自动推断) | -| `--paste-type` | string | optional | 粘贴内容(仅 `+range-copy`)(可选值:`values` / `formulas` / `formats` / `all`) | +| `--paste-type` | string | optional | 粘贴内容(仅 `+range-copy`)(可选值:`values` / `formulas` / `formats` / `all`)(默认 `all`) | ### `+range-fill` @@ -172,7 +172,7 @@ _公共四件套 · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--source-range` | string | required | 填充模板范围(系列起始 cells) | | `--target-range` | string | required | 目标填充范围(A1 格式) | -| `--series-type` | string | optional | 填充序列类型(可选值:`auto` / `linear` / `growth` / `date` / `copy`) | +| `--series-type` | string | optional | 填充序列类型(可选值:`auto` / `linear` / `growth` / `date` / `copy`)(默认 `auto`) | ### `+range-sort` diff --git a/skills/lark-sheets/references/lark-sheets-read-data.md b/skills/lark-sheets/references/lark-sheets-read-data.md index 2a6f01d82..1599e06a8 100644 --- a/skills/lark-sheets/references/lark-sheets-read-data.md +++ b/skills/lark-sheets/references/lark-sheets-read-data.md @@ -90,8 +90,8 @@ _公共四件套 · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--range` | string | required | A1 范围,如 `Sheet1!A1:F10` | | `--include` | string_slice | optional | 要返回的信息类别,逗号分隔多个(可选值:`value` / `formula` / `style` / `comment` / `data_validation`) | -| `--cell-limit` | int | optional | 防爆,默认 5000 | -| `--max-chars` | int | optional | 防爆,默认 200000 | +| `--cell-limit` | int | optional | 防爆,默认 5000(隐藏 flag:不在 `--help` 列出,但可正常传入) | +| `--max-chars` | int | optional | 防爆,默认 200000(隐藏 flag:不在 `--help` 列出,但可正常传入) | | `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` | ### `+dropdown-get` @@ -109,9 +109,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--range` | string | required | A1 范围,如 `Sheet1!A1:F30` | -| `--value-render-option` | string | optional | 单元格取值模式(可选值:`formatted_value` / `raw_value` / `formula`) | -| `--max-rows` | int | optional | 防爆,默认 100000 | -| `--max-chars` | int | optional | 防爆,默认 200000 | +| `--value-render-option` | string | optional | 单元格取值模式(可选值:`formatted_value` / `raw_value` / `formula`)(默认 `formatted_value`) | +| `--max-rows` | int | optional | 防爆,默认 100000(隐藏 flag:不在 `--help` 列出,但可正常传入) | +| `--max-chars` | int | optional | 防爆,默认 200000(隐藏 flag:不在 `--help` 列出,但可正常传入) | | `--include-row-prefix` | bool | optional | 是否在每行前加 `[row=N]` 前缀,默认 `true` | | `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` | diff --git a/skills/lark-sheets/references/lark-sheets-search-replace.md b/skills/lark-sheets/references/lark-sheets-search-replace.md index fa2489244..473e7dc12 100644 --- a/skills/lark-sheets/references/lark-sheets-search-replace.md +++ b/skills/lark-sheets/references/lark-sheets-search-replace.md @@ -43,7 +43,7 @@ _公共四件套 · 系统:`--dry-run`_ | `--match-entire-cell` | bool | optional | 完全匹配整个单元格 | | `--regex` | bool | optional | 把 `--find` 按正则解释 | | `--include-formulas` | bool | optional | 也在公式文本中搜索 | -| `--max-matches` | int | optional | 防爆,默认 5000 | +| `--max-matches` | int | optional | 防爆,默认 5000(隐藏 flag:不在 `--help` 列出,但可正常传入) | | `--offset` | int | optional | 跳过前 N 个匹配(分页用),默认 0 | ### `+cells-replace` diff --git a/skills/lark-sheets/references/lark-sheets-sheet-structure.md b/skills/lark-sheets/references/lark-sheets-sheet-structure.md index 8d229dd47..4fccee7d3 100644 --- a/skills/lark-sheets/references/lark-sheets-sheet-structure.md +++ b/skills/lark-sheets/references/lark-sheets-sheet-structure.md @@ -27,6 +27,16 @@ - 处理"在第 N 行后追加"这类请求时,注意 `--start` 是 0-based 索引、`--end` 是 exclusive,换算时避免 off-by-one - 例如"在第 20 行后新增 116 行":`--dimension row --start 21 --end 137`("第 20 行后"即从 index 21 起插入,`--end` = `--start` + 116) +**⚠️ `--end` 区间端点语义对照(跨命令不一致,最高发的 off-by-one 来源)**:同样叫 `--start` / `--end`、同样作用于行/列区间,但 `--end` 含义因命令而异,构造参数前务必对照本表: + +| 命令 | `--end` 语义 | 备注 | +| --- | --- | --- | +| `+dim-insert` / `+dim-delete` / `+dim-hide` / `+dim-unhide` / `+dim-group` / `+dim-ungroup` | **exclusive**(不含 end) | 操作行/列数 = `--end` − `--start` | +| `+dim-move` | **inclusive**(含 end) | ⚠️ 与同族 `+dim-*` **相反**!`--start`/`--end` 是**源区间**(闭区间),目标位置另用 `--target` | +| `+rows-resize` / `+cols-resize` | **inclusive**(含 end) | `--start`/`--end` 均为 0-based 闭区间 | + +把 `+dim-insert` / `+dim-delete` 的 exclusive 习惯照搬到 `+dim-move` / `+rows-resize` / `+cols-resize`(或反过来)会少算/多算一行/一列——动手前先在本表确认目标命令的 `--end` 端点语义。 + **常见配置错误(必须注意)**: - **插入列位置偏移**:插入列时 `--start` 是基于 0 的列索引,不是列字母。插入前先通过 `+workbook-info` 或读取表头确认目标位置的实际列索引,不要凭猜测 - **插入后引用偏移**:插入行/列后,原有数据的行列号会发生偏移。如果插入后还需要对原有区域执行写入操作,必须重新计算偏移后的行列号 @@ -120,7 +130,7 @@ _公共四件套 · 系统:`--dry-run`_ | `--start` | int | required | 起始位置(0-based) | | `--end` | int | required | 结束位置(exclusive) | | `--depth` | int | optional | 嵌套层级(`+dim-group` 用),默认 1 | -| `--group-state` | string | optional | 分组初始展开状态(可选值:`expand` / `fold`) | +| `--group-state` | string | optional | 分组初始展开状态(可选值:`expand` / `fold`)(默认 `expand`) | ### `+dim-ungroup` diff --git a/skills/lark-sheets/references/lark-sheets-workbook.md b/skills/lark-sheets/references/lark-sheets-workbook.md index a3cfc05d6..a5ca2007e 100644 --- a/skills/lark-sheets/references/lark-sheets-workbook.md +++ b/skills/lark-sheets/references/lark-sheets-workbook.md @@ -132,7 +132,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--file-extension` | string | optional | 导出文件格式;`csv` 模式必须配 `--sheet-id`(可选值:`xlsx` / `csv`) | +| `--file-extension` | string | optional | 导出文件格式;`csv` 模式必须配 `--sheet-id`(可选值:`xlsx` / `csv`)(默认 `xlsx`) | | `--sheet-id` | string | optional | 仅 csv 模式必填:指定要导出的 sheet reference_id | | `--output-path` | string | optional | 本地保存路径;省略时只触发导出不下载 | diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 4473db177..556a7bdac 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -48,7 +48,7 @@ > **单元格图片 vs 浮动图片**: > - **单元格图片**(本工具):图片嵌入在单元格内部,属于单元格内容,随单元格移动。通过 `rich_text` 中 `type: "embed-image"` 写入。 -> - **浮动图片**:图片悬浮在单元格上方,可自由定位和调整大小,不属于单元格内容。→ 使用 lark_sheet_float_image Skill。 +> - **浮动图片**:图片悬浮在单元格上方,可自由定位和调整大小,不属于单元格内容。→ 使用 lark-sheets-float-image。 高频模式(**必须遵守,禁止逐行写入替代**): @@ -124,7 +124,7 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl Step 1: 用模板单元格 + --copy-to-range 铺"完整样式"(不是只铺 border)到新区域 `+cells-set` — range="A11", cells=[[{ border_styles: {...}, - cell_styles: { /* 按行性质填充:数据行继承数据区样式;汇总行见 lark_sheet_visual_standards */ } + cell_styles: { /* 按行性质填充:数据行继承数据区样式;汇总行见 lark-sheets-visual-standards */ } }]], --copy-to-range="A11:H11" Step 2: 再用 `+cells-set` 单独写具体 value/formula(不再传样式,避免覆盖) @@ -187,7 +187,7 @@ _公共四件套 · 系统:`--dry-run`_ | `--range` | string | required | 写入区域(A1 格式) | | `--cells` | string + File + Stdin(复合 JSON) | required | JSON:2D 数组 `[[{cell},...],...]`,维度与 `--range` 完全一致;每个 cell 可含 `value` / `formula` / `cell_styles` / `note` / `rich_text`(含 `type="embed-image"` 单元格嵌图)等,完整字段跑 `--print-schema` | | `--allow-overwrite` | bool | optional | 允许覆盖非空 cell(默认 true);设为 false 时遇非空 cell 报错 | -| `--max-cells` | int | optional | 防爆,默认 50000 | +| `--max-cells` | int | optional | 防爆,默认 50000(隐藏 flag:不在 `--help` 列出,但可正常传入) | | `--copy-to-range` | string | optional | 复制范围(A1 表示法):把 --range 中 --cells 写入的内容(值/公式/样式,取决于实际传入字段)复制到该区域,公式引用自动平移(如 C2=B2 → C3=B3)。适合先写一行/一块模板再扩展填充整列/整区域(如 --range A1:G1 写模板、--copy-to-range A1:G100 填充 100 行)。支持整行 3:6、整列 C:E、到列尾 D3:D、到行尾 D3:3;支持英文逗号分隔多个目标区域,如 C1:D2,E5:F6 | ### `+cells-set-style` From e0c22d6ee0b7d0783b5806425b41bd83ab0a30ce Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Sat, 23 May 2026 08:59:11 +0800 Subject: [PATCH 034/114] fix(sheets): make --max-chars the single read cap for +cells-get / +csv-get Drop --cell-limit (+cells-get) and --max-rows (+csv-get) from the CLI surface and pin the underlying tool's cell_limit / max_rows to a very large sentinel so the tool's own defaults never truncate before --max-chars. --max-chars stays the only knob (default 200000, unchanged). - lark_sheet_read_data.go: add unboundedReadLimit (1e9); cellsGetInput pins cell_limit, csvGetInput pins max_rows; --max-chars still passed through - data/flag-defs.json: synced from spec (drops the two flags) - tests: spot-check moved to --max-chars; dry-run wantInput asserts cell_limit / max_rows are pinned high Mirrors sheet-skill-spec (Base flag records removed). go build ./... + go test ./shortcuts/sheets/ green. --- shortcuts/sheets/data/flag-defs.json | 18 --------------- shortcuts/sheets/flag_defs_test.go | 6 ++--- shortcuts/sheets/lark_sheet_read_data.go | 22 ++++++++++++++----- shortcuts/sheets/lark_sheet_read_data_test.go | 2 ++ 4 files changed, 21 insertions(+), 27 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index ba8390fb9..48dcdc94c 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -1196,15 +1196,6 @@ "data_validation" ] }, - { - "name": "cell-limit", - "kind": "own", - "type": "int", - "required": "optional", - "desc": "Safety cap; default 5000", - "default": "5000", - "hidden": true - }, { "name": "max-chars", "kind": "own", @@ -1314,15 +1305,6 @@ "formula" ] }, - { - "name": "max-rows", - "kind": "own", - "type": "int", - "required": "optional", - "desc": "Safety cap; default 100000", - "default": "100000", - "hidden": true - }, { "name": "max-chars", "kind": "own", diff --git a/shortcuts/sheets/flag_defs_test.go b/shortcuts/sheets/flag_defs_test.go index a4c47fdf3..2d0217849 100644 --- a/shortcuts/sheets/flag_defs_test.go +++ b/shortcuts/sheets/flag_defs_test.go @@ -66,9 +66,9 @@ func TestFlagsFor_MapsAllFields(t *testing.T) { t.Errorf("+sheet-create --url should not be cobra-required: %+v", url) } // hidden + int default - cap := byName("+cells-get", "cell-limit") - if cap == nil || !cap.Hidden || cap.Default != "5000" { - t.Errorf("+cells-get --cell-limit not mapped: %+v", cap) + cap := byName("+cells-get", "max-chars") + if cap == nil || !cap.Hidden || cap.Default != "200000" { + t.Errorf("+cells-get --max-chars not mapped: %+v", cap) } // input sources cells := byName("+cells-set", "cells") diff --git a/shortcuts/sheets/lark_sheet_read_data.go b/shortcuts/sheets/lark_sheet_read_data.go index 511413899..7c036ac3e 100644 --- a/shortcuts/sheets/lark_sheet_read_data.go +++ b/shortcuts/sheets/lark_sheet_read_data.go @@ -19,6 +19,14 @@ import ( // The sandbox tool (export_sheet_to_sandbox) is Sheet-Tool-only and has no // CLI surface here. +// unboundedReadLimit is pinned into the tool's cell_limit / max_rows so that +// --max-chars is the single effective read cap. The underlying tools default +// those two to smaller values; without an explicit high value they could +// truncate before max_chars. The CLI no longer exposes --cell-limit / --max-rows +// (only --max-chars), so we pass this sentinel to neutralize the tool defaults. +// Large enough to never bind on any real sheet. +const unboundedReadLimit = 1_000_000_000 + // CellsGet wraps get_cell_ranges: read multiple A1 ranges and return per-cell // values, formulas, styles, and other metadata as requested via --include. var CellsGet = common.Shortcut{ @@ -75,9 +83,10 @@ func cellsGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName str if runtime.Bool("skip-hidden") { input["skip_hidden"] = true } - if n := runtime.Int("cell-limit"); n > 0 { - input["cell_limit"] = n - } + // --cell-limit was removed from the CLI surface; --max-chars is the single + // read cap. Pin cell_limit very high so the tool's own default never binds + // before max_chars. + input["cell_limit"] = unboundedReadLimit if n := runtime.Int("max-chars"); n > 0 { input["max_chars"] = n } @@ -172,9 +181,10 @@ func csvGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName strin if runtime.Bool("skip-hidden") { input["skip_hidden"] = true } - if n := runtime.Int("max-rows"); n > 0 { - input["max_rows"] = n - } + // --max-rows was removed from the CLI surface; --max-chars is the single + // read cap. Pin max_rows very high so the tool's own default never binds + // before max_chars. + input["max_rows"] = unboundedReadLimit if n := runtime.Int("max-chars"); n > 0 { input["max_chars"] = n } diff --git a/shortcuts/sheets/lark_sheet_read_data_test.go b/shortcuts/sheets/lark_sheet_read_data_test.go index d63d4214a..30a9a1a55 100644 --- a/shortcuts/sheets/lark_sheet_read_data_test.go +++ b/shortcuts/sheets/lark_sheet_read_data_test.go @@ -31,6 +31,7 @@ func TestReadDataShortcuts_DryRun(t *testing.T) { "ranges": []interface{}{"A1:B2"}, "include_styles": true, "value_render_option": "formula", + "cell_limit": float64(unboundedReadLimit), // pinned high; --max-chars is the only cap }, }, { @@ -43,6 +44,7 @@ func TestReadDataShortcuts_DryRun(t *testing.T) { "sheet_id": testSheetID, "range": "A1:C10", "value_render_option": "formula", + "max_rows": float64(unboundedReadLimit), // pinned high; --max-chars is the only cap }, }, { From 2f5c625ac7d8f29a7a170d01f67d49572d561c5f Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Sat, 23 May 2026 09:05:55 +0800 Subject: [PATCH 035/114] =?UTF-8?q?docs(sheets):=20sync=20lark-sheets=20re?= =?UTF-8?q?ad=20docs=20=E2=80=94=20--max-chars=20as=20single=20read=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync skills/lark-sheets references from spec: drop --cell-limit / --max-rows guidance; 大表分批读 switches to --range row windows + --max-chars auto cap + has_more. Mirrors sheet-skill-spec 58e7456 and handler change 2befc49. --- .../lark-sheets/references/lark-sheets-core-operations.md | 4 ++-- skills/lark-sheets/references/lark-sheets-read-data.md | 8 +++----- skills/lark-sheets/references/lark-sheets-write-cells.md | 2 +- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/skills/lark-sheets/references/lark-sheets-core-operations.md b/skills/lark-sheets/references/lark-sheets-core-operations.md index 1b1543a80..d489700ef 100644 --- a/skills/lark-sheets/references/lark-sheets-core-operations.md +++ b/skills/lark-sheets/references/lark-sheets-core-operations.md @@ -130,8 +130,8 @@ **路径 A:数据分析/清洗/可视化/大数据集/"完善 / 补齐 / 填空 / 修正所有 XX"** → 分批 `+csv-get` 把数据导出到本地文件,再用本地脚本(如 pandas)处理: ```bash -# 分批导出(按 has_more 翻页拼接到本地 data.csv,直到读完) -lark-cli sheets +csv-get --url "$URL" --range "A:Z" --max-rows 500 > data.csv +# 分批导出(按 --range 行窗口翻页拼接到本地 data.csv,直到读完;单次返回量由 --max-chars 自动兜底,看 has_more / actual_range 续读) +lark-cli sheets +csv-get --url "$URL" --range "A1:Z500" > data.csv # 首窗口;后续 A501:Z1000 … 用 >> 追加 ``` ```python import pandas as pd diff --git a/skills/lark-sheets/references/lark-sheets-read-data.md b/skills/lark-sheets/references/lark-sheets-read-data.md index 1599e06a8..9bed8b3d9 100644 --- a/skills/lark-sheets/references/lark-sheets-read-data.md +++ b/skills/lark-sheets/references/lark-sheets-read-data.md @@ -19,14 +19,14 @@ | 读取目的 | 用这个 shortcut | 数据去向 | 说明 | |---------|----------------|---------|------| -| 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本;大表请分批读(控制 `--max-rows` / `--max-chars`) | +| 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本;大表请按 `--range` 行窗口分批读(单次返回量由 `--max-chars` 自动兜底,截断时看 `has_more`) | | 查看公式、样式、批注、数据验证 | `+cells-get` | 对话上下文 | 返回单元格完整信息,token 开销较大 | **选择原则**: - 只看值或做数据处理 → `+csv-get`;大表分批读取,避免一次拉全表撑爆上下文 - 需要公式/样式/批注 → `+cells-get` -⚠️ 超大数据请走"`+csv-get --max-rows N` 分批读到本地文件 + 本地脚本处理 + `+csv-put` 分批回写"。 +⚠️ 超大数据请走"`+csv-get` 按 `--range` 行窗口(如 `A1:Z500` / `A501:Z1000` …)分批读到本地文件 + 本地脚本处理 + `+csv-put` 分批回写"。 **`+csv-get` 返回值核心设计**: - `annotated_csv` — **CSV 数据唯一入口**。每一逻辑行前加 `[row=N] ` 前缀(N = 真实表格行号)。任何需要行号的下游操作(合并、写入、清空、格式化、插入/删除、条件格式、筛选、图表/透视表范围、搜索替换等),**行号一律直接从 `[row=N]` 读取**。若需要纯 CSV(如喂给本地脚本做解析),去前缀即可:`line.replace(/^\[row=\d+\] /, '')`。 @@ -40,7 +40,7 @@ - 隐藏行列默认包含在返回结果中(`--skip-hidden=false`),如需只看可见数据设为 `true` **常见配置错误(必须注意)**: -- **全量读取导致上下文溢出(高频致命错误)**:不要对大表(数百行以上)直接用 `+csv-get` 或 `+cells-get` 读取全部数据到上下文。大表场景必须分批读取:`+csv-get` 控制 `--max-rows` / `--max-chars`,`+cells-get` 控制 `--range` / `--cell-limit` / `--max-chars`;过大时考虑导出到本地文件后用脚本处理再分批回写 +- **全量读取导致上下文溢出(高频致命错误)**:不要对大表(数百行以上)直接用 `+csv-get` 或 `+cells-get` 读取全部数据到上下文。大表场景必须分批读取:用 `--range` 切行窗口逐块读(`+csv-get` / `+cells-get` 单次返回量由 `--max-chars` 自动兜底,截断时返回 `has_more`);过大时考虑导出到本地文件后用脚本处理再分批回写 - **了解结构 ≠ 读取全量数据**:探表不用读全表,但必须同时探两个方向的表头: - **横向(列头)**:先读前几行,且**列范围必须覆盖所有列**——用 `+workbook-info` 拿总列数,`range` 末列填到最后一列(例如总列数是 N,则 `range: "A1:[列N]10"`)。列范围截短会遗漏右侧字段、后续写入列定位错误。 - **纵向(行标)**:若左侧 1-2 列是行标签(日期/类别/编号枚举每行含义,典型交叉表/透视布局),**必须再读 `A:A` 或 `A:B` 把行标列读到底**,拿全部行标。只读前几行会看不全表尾的行,导致批量写入漏改——这是"只改前 N 行、其余未更新"的主要成因。扁平列表(每行独立记录、列是字段)可跳过这一步,但仍要靠 `current_region` 兜底。 @@ -90,7 +90,6 @@ _公共四件套 · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--range` | string | required | A1 范围,如 `Sheet1!A1:F10` | | `--include` | string_slice | optional | 要返回的信息类别,逗号分隔多个(可选值:`value` / `formula` / `style` / `comment` / `data_validation`) | -| `--cell-limit` | int | optional | 防爆,默认 5000(隐藏 flag:不在 `--help` 列出,但可正常传入) | | `--max-chars` | int | optional | 防爆,默认 200000(隐藏 flag:不在 `--help` 列出,但可正常传入) | | `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` | @@ -110,7 +109,6 @@ _公共四件套 · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--range` | string | required | A1 范围,如 `Sheet1!A1:F30` | | `--value-render-option` | string | optional | 单元格取值模式(可选值:`formatted_value` / `raw_value` / `formula`)(默认 `formatted_value`) | -| `--max-rows` | int | optional | 防爆,默认 100000(隐藏 flag:不在 `--help` 列出,但可正常传入) | | `--max-chars` | int | optional | 防爆,默认 200000(隐藏 flag:不在 `--help` 列出,但可正常传入) | | `--include-row-prefix` | bool | optional | 是否在每行前加 `[row=N]` 前缀,默认 `true` | | `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` | diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 556a7bdac..5f1718f51 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -164,7 +164,7 @@ Step 2: 再用 `+cells-set` 单独写具体 value/formula(不再传样式, ⚠️ `+csv-put` 只写纯值,**不会**携带公式/样式/批注/图片;公式字符串以 `=` 开头会被当作字面量文本落地。如果数据里需要公式或样式,**必须**用 `+cells-set`(或"写值 + 补样式"两步法)。 -⚠️ 大数据回写走"`+csv-get --max-rows N` 分批读到本地 + 本地脚本处理 + `+csv-put` 分批回写"。 +⚠️ 大数据回写走"`+csv-get` 按 `--range` 行窗口分批读到本地 + 本地脚本处理 + `+csv-put` 分批回写"。 ## Shortcuts From b85311c8730da485bbdae998852fcb28074643b6 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Sat, 23 May 2026 13:45:44 +0800 Subject: [PATCH 036/114] docs(sheets): sync lark-sheets skill spec from upstream Refine reference docs and flag-defs descriptions from upstream sheet-skill-spec (--depth wording for +dim-group / +dim-ungroup, plus assorted reference clarifications). Description-only; no CLI behavior or flag surface change. --- shortcuts/sheets/data/flag-defs.json | 4 ++-- skills/lark-sheets/SKILL.md | 2 +- .../lark-sheets/references/lark-sheets-chart.md | 10 ++++------ .../references/lark-sheets-core-operations.md | 12 ++---------- .../lark-sheets-formula-translation.md | 11 ++++++++--- .../references/lark-sheets-pivot-table.md | 7 ++++++- .../references/lark-sheets-range-operations.md | 2 +- .../references/lark-sheets-read-data.md | 2 ++ .../references/lark-sheets-search-replace.md | 6 +++++- .../references/lark-sheets-sheet-structure.md | 6 +++--- .../references/lark-sheets-sparkline.md | 17 ++++------------- .../references/lark-sheets-visual-standards.md | 4 ++-- .../references/lark-sheets-write-cells.md | 8 ++++---- 13 files changed, 44 insertions(+), 47 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index 48dcdc94c..ab97963bc 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -975,7 +975,7 @@ "kind": "own", "type": "int", "required": "optional", - "desc": "Nesting level (used by `+dim-group`); default 1", + "desc": "Nesting level for grouping; default 1", "default": "1" }, { @@ -1060,7 +1060,7 @@ "kind": "own", "type": "int", "required": "optional", - "desc": "Nesting level (used by `+dim-group`); default 1", + "desc": "Group nesting level to ungroup; default 1 (outermost)", "default": "1" }, { diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index ace2edcf5..91cddfd53 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -63,7 +63,7 @@ metadata: | --- | --- | --- | --- | | `--dry-run` | bool | 否 | 零副作用:仅打印请求路径与参数模板,不发起调用;多步操作会输出每个子操作的请求模板 | | `--yes` | bool | 是(仅 `high-risk-write`) | 二次确认;不带时退出码 10。详见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 高风险审批协议 | -| `--print-schema` | bool | 否 | 本地打印复合 JSON flag 的 JSON Schema 并退出,不发起任何调用、不需要其它 required flag。与 `--flag-name ` 搭配指定要查哪个 flag;省略 `--flag-name` 时列出该 shortcut 所有可查询的 flag。仅在 shortcut 含复合 JSON flag 时有效。 | +| `--print-schema` | bool | 否 | 本地打印复合 JSON flag 的 JSON Schema 并退出,不发起任何调用、不需要其它 required flag。与 `--flag-name ` 搭配指定要查哪个 flag;省略 `--flag-name` 时列出该 shortcut 所有可查询的 flag。**仅在 shortcut 含复合 JSON flag 时有效**——判断方法:该 shortcut 的 Flags 表里出现类型标注为「复合 JSON」的 flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys` / `--options`)即支持;纯标量 flag 的 shortcut 不支持。 | | `--flag-name` | string | 否 | 配合 `--print-schema` 使用,指定要打印 JSON Schema 的 flag 名(不带 `--` 前缀,如 `cells` / `properties` / `operations`)。 | **Agent 使用提示**:写复合 JSON flag(`--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys` 等)时,如果对结构不确定,先跑 `lark-cli sheets --print-schema --flag-name ` 把完整 JSON Schema 读出来再构造 payload,比靠 reference 的速查表更精确,也避免因为字段拼写或缺失被服务端拒绝。reference 的 `## Schemas` 段只给一层结构,深层只能靠 `--print-schema` 或 `## Examples` 的真实示例。 diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md index 312f0a482..7134f94fe 100644 --- a/skills/lark-sheets/references/lark-sheets-chart.md +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -20,9 +20,9 @@ | 用户说 | 图表类型 | 备注 | |--------|---------|------| | "占比"、"比例"、"各XX占多少" | 饼图(pie) | 单维度占比首选 | -| "对比"、"各XX的YY" | 柱状图(bar) | 多类别数值对比 | +| "对比"、"各XX的YY" | 柱形图(column,纵向) | 多类别数值对比;横向条形用 `bar` | | "趋势"、"变化"、"走势" | 折线图(line) | 时间序列首选 | -| "堆积"、"组成构成" | 堆积柱状图(bar + stack) | 多系列累加 | +| "堆积"、"组成构成" | 堆积柱形图(column + stack) | 多系列累加 | | "分布"、"相关性" | 散点图(scatter) | 两变量关系 | **多图表需求**:当用户同时提到多种分析(如"统计占比 + 对比数量"),必须创建多个图表,每个对应一种类型,不要只做一个。 @@ -30,7 +30,7 @@ **`--properties` 结构锚点(构造前必读)**:`--properties` 顶层只有 `position` / `offset` / `size` / `snapshot` 四个字段,**没有**顶层 `data`,也没有再嵌一层 `properties`。图表数据配置全部挂在 `snapshot.data` 下——下文及示例里出现的 `refs` / `headerMode` / `dim1` / `dim2` / `nameRef` 一律指 `snapshot.data.refs` / `snapshot.data.headerMode` / `snapshot.data.dim1` / `snapshot.data.dim2`(及其下的 `serie.nameRef` / `series[].nameRef`);样式 / 堆叠 / 数据标签等在 `snapshot.plotArea` 下。完整结构以 `lark-cli sheets +chart-create --print-schema --flag-name properties` 为准。 **常见配置错误(必须注意)**: -- **图表类型选择错误**:用户说"堆积柱状图/百分比堆积"时,应在 `properties.snapshot.plotArea.plot.extra.stack` 中配置堆叠;百分比堆叠需在该 stack 下设置 `percentage: true`。用户说"占比/比例"时,优先考虑饼图或百分比堆积图 +- **图表类型选择错误**:用户说"堆积柱形图/百分比堆积"时,应在 `properties.snapshot.plotArea.plot.extra.stack` 中配置堆叠;百分比堆叠需在该 stack 下设置 `percentage: true`。用户说"占比/比例"时,优先考虑饼图或百分比堆积图。注意区分 `column`(柱形图,纵向)与 `bar`(条形图,横向)是两个不同的 type 取值,"对比/各 XX" 类纵向柱默认用 `column` - **数据标签缺失**:用户需要看到具体数值时,需配置 `properties.snapshot.plotArea.plot.labels`(数据标签)相关字段 - **数据源范围与系列名来源要对齐**: - **默认情况(inline 模式)**:`refs` 范围**应包含表头行**(首行/首列即系列名),且范围要精确覆盖目标数据,不要多选或少选。 @@ -69,7 +69,7 @@ ## ⚠️ chart 数据源引用 pivot 时必须排除总计行(高频致命错误) 当 chart 要基于刚创建的 pivot 产物画图时,**禁止凭猜写 `refs`**。pivot 默认启用 `show_row_grand_total` / `show_col_grand_total`,产物最后一行/一列通常是"总计"。如果 `refs` 把总计行一并框进去: -- **柱状图**末尾会多一根天文数字柱子(=所有数据求和),把其他柱子压扁到看不见 +- **柱形图**末尾会多一根天文数字柱子(=所有数据求和),把其他柱子压扁到看不见 - **饼图**会多一个"总计"扇区占 33%+,真实类别的比例完全失真 **正确流程**: @@ -78,8 +78,6 @@ 3. 识别并排除"总计"/"小计"行(通常最后一行;嵌套 pivot 还要排除中间层小计) 4. `+chart-create create` 时 `snapshot.data.refs` 精确到数据行(如 pivot 占 A1:D9、总计在 row9 → chart 用 `A1:D8`) -详细规则见 `lark-sheets-pivot-table` 第 5 节"pivot → chart 组合场景"。 - ## 图表位置选择(创建前必做) 凭感觉挑列号/行号会被 API 拒(`position is out of sheet range`),浪费一轮调用。按以下四步走: diff --git a/skills/lark-sheets/references/lark-sheets-core-operations.md b/skills/lark-sheets/references/lark-sheets-core-operations.md index d489700ef..62e6138ca 100644 --- a/skills/lark-sheets/references/lark-sheets-core-operations.md +++ b/skills/lark-sheets/references/lark-sheets-core-operations.md @@ -57,7 +57,7 @@ 除此之外,每次读完还必须查三个字段——交叉表和扁平列表都用得到: - `actual_range`:本次实际返回的范围(请求超边界时自动裁剪) - `current_region`:连续数据矩形(Excel Ctrl+Shift+\*);若末行 > 请求 range 末行说明数据还没读全 - - `has_more`:因 `max_bytes` 被截断时为 `true`,按 `actual_range` 末行+1 分页续读,或改走路径 A + - `has_more`:返回体超过 `--max-chars` 被截断时为 `true`,按 `actual_range` 末行+1 分页续读,或改走路径 A **`current_region` 远大于可灌入上下文的量(如几百行)时**:切换到路径 A(分批 `+csv-get` 导出到本地 + 本地脚本处理),不要用路径 B 翻页硬塞到上下文。 @@ -251,15 +251,7 @@ print(df.head(10)) # 必做:横向——确认表头行 + - 公式来自 Excel 或包含数组场景时,先读取 `lark-sheets-formula-translation` 完成改写,再生成公式。 - 数组公式必须写成 `=ARRAYFORMULA(数组公式)` 语法。 - 在公式字符串中,数据范围应使用飞书支持的语法,例如 `H:H`、`A2:B5`。禁止使用不符合飞书公式语法的写法,如 `H2:H`、`2:2` 等。 -- 飞书表格不支持以下函数,禁止主动使用;当用户明确要求使用这些函数时,应拒绝并说明飞书不支持: - - CUBE 相关函数,如 `CUBEMEMBER`、`CUBESET`、`CUBEVALUE`、`CUBERANK` - - `GOOGLEFINANCE`、`GOOGLETRANSLATE` 等 Google 特有函数 - - `FORECAST.ETS` 相关函数,如 `FORECAST.ETS`、`FORECAST.ETS.STAT` - - `INFO`、`RTD` 等系统信息相关函数 - - `PIVOT` - - `AMORDEGRC` - - `PHONETIC` - - `DETECTLANGUAGE` +- 飞书表格不支持部分 Excel / Google 专有函数(如 CUBE 系列、`GOOGLETRANSLATE`、`STOCKHISTORY`、`WEBSERVICE` 等),禁止主动使用;当用户明确要求使用这些函数时,应拒绝并说明飞书不支持。**完整的"飞书不支持函数"清单(含替代方案)见 `lark-sheets-formula-translation` 的「飞书不支持的函数」段——以那里为唯一权威清单。** ### 向下填充与绝对引用 diff --git a/skills/lark-sheets/references/lark-sheets-formula-translation.md b/skills/lark-sheets/references/lark-sheets-formula-translation.md index 03ceef07c..50fe849a7 100644 --- a/skills/lark-sheets/references/lark-sheets-formula-translation.md +++ b/skills/lark-sheets/references/lark-sheets-formula-translation.md @@ -205,13 +205,18 @@ Excel:`{=A1:A10*B1:B10}`(Ctrl+Shift+Enter 输入) - 年份差:`=DATEDIF(A2,B2,"Y")` - 工作日差:`=NETWORKDAYS(A2,B2)` -## 飞书不支持的 Excel 专有函数 +## 飞书不支持的函数 -以下函数在飞书里不存在,遇到时需要告知用户并提供替代方案: +> 本段是"飞书不支持函数"的**唯一权威清单**(`lark-sheets-core-operations` 不再单列,统一指向这里)。以下函数在飞书里不存在或被禁用,禁止主动使用;用户明确要求时应拒绝并提供替代方案: - `STOCKHISTORY` — 实时股票数据,飞书无等价函数,需手动导入数据 - `WEBSERVICE` — 外部 HTTP 请求,飞书无等价函数 -- `CUBEVALUE`、`CUBEMEMBER`、`CUBESET` 等 — OLAP cube 函数,飞书不支持 +- CUBE 系列(`CUBEVALUE`、`CUBEMEMBER`、`CUBESET`、`CUBERANK` 等)— OLAP cube 函数,飞书不支持 +- `GOOGLEFINANCE`、`GOOGLETRANSLATE` 等 Google 特有函数 — 无等价函数 +- `FORECAST.ETS` 系列(`FORECAST.ETS`、`FORECAST.ETS.STAT` 等)— 飞书不支持 +- `INFO`、`RTD` — 系统信息 / 实时数据函数,飞书不支持 +- `PIVOT` — 用 `+pivot-{create|update|delete}` 透视表对象替代 +- `AMORDEGRC`、`PHONETIC`、`DETECTLANGUAGE` — 飞书不支持 ## 代表性改写示例 diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md index dbc8512b3..3f59214be 100644 --- a/skills/lark-sheets/references/lark-sheets-pivot-table.md +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -120,6 +120,11 @@ lark-cli sheets +pivot-list --url "..." --sheet-id "$SID" ### `+pivot-create` > 数据源 `--source` 必须从表头行开始;空行 / 汇总行会被当作数据参与聚合,需提前用 `+csv-get` 确认起止边界。`--source` 和 `--range` 是独立 flag(不要再放 `--properties`);`rows` / `columns` / `values` 等数组字段走 `--properties`。 +> +> **落点 flag 三选一的决策(避免冲突)**: +> - **默认(推荐)**:`--target-sheet-id` / `--target-position` / `--range` 都不传 → 自动新建子表存放透视表产物。 +> - **放进指定的已有子表**:传 `--target-sheet-id <落点子表 id>` + 可选 `--target-position <子表内起点 cell,默认 A1>`。 +> - **`--range`** 只在不指定落点子表、想精确指定左上角 cell(映射到 `properties.range`)时用;与 `--target-position` 表达同一意图但落不同 wire 字段,**两者不要同时给**。一般用前两种即可,无需 `--range`。 ```bash lark-cli sheets +pivot-create --url "..." --sheet-id "$SRC_SID" \ @@ -142,4 +147,4 @@ lark-cli sheets +pivot-delete --url "..." --pivot-table-id "$PID" --yes - `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 pivot 请求模板"+ 预估输出尺寸(行数 × 列数)。 - `Execute`:写后调用 `+pivot-list --pivot-table-id ` 回读 + `+csv-get` 抽样读透视产物,envelope.meta.verification 给出实际输出尺寸 + 总计行位置。 -> ⚠️ pivot 输出包含总计 / 小计行;后续 chart 引用 pivot 时,`data_range` 必须排除这些行(见 `lark-sheets-chart` 决策段)。 +> ⚠️ pivot 输出包含总计 / 小计行;后续 chart 引用 pivot 时,`snapshot.data.refs` 必须排除这些行(见 `lark-sheets-chart` 的「⚠️ chart 数据源引用 pivot 时必须排除总计行」段)。 diff --git a/skills/lark-sheets/references/lark-sheets-range-operations.md b/skills/lark-sheets/references/lark-sheets-range-operations.md index 94ae8593b..77527029b 100644 --- a/skills/lark-sheets/references/lark-sheets-range-operations.md +++ b/skills/lark-sheets/references/lark-sheets-range-operations.md @@ -262,6 +262,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` 的 `--type` 必填,`--type pixel` 时 `--size` 必填、其它 type 时 `--size` 会被忽略(传了无害);`+cols-resize.--type` 不接受 `auto`(只行高支持自适应)。 - `DryRun`:所有写操作输出"将要 PATCH 的 range + 受影响 cell 数估算"。 - `Execute`:写后调用 `+cells-get --range <影响范围>` 抽样回读对比,envelope.meta.verification 沉淀对比结果。 diff --git a/skills/lark-sheets/references/lark-sheets-read-data.md b/skills/lark-sheets/references/lark-sheets-read-data.md index 9bed8b3d9..71a754e12 100644 --- a/skills/lark-sheets/references/lark-sheets-read-data.md +++ b/skills/lark-sheets/references/lark-sheets-read-data.md @@ -21,10 +21,12 @@ |---------|----------------|---------|------| | 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本;大表请按 `--range` 行窗口分批读(单次返回量由 `--max-chars` 自动兜底,截断时看 `has_more`) | | 查看公式、样式、批注、数据验证 | `+cells-get` | 对话上下文 | 返回单元格完整信息,token 开销较大 | +| 查看某区域的下拉框(数据验证)选项 | `+dropdown-get` | 对话上下文 | 返回该 A1 范围已配置的下拉列表选项 | **选择原则**: - 只看值或做数据处理 → `+csv-get`;大表分批读取,避免一次拉全表撑爆上下文 - 需要公式/样式/批注 → `+cells-get` +- 只想知道某区域下拉框有哪些选项 → `+dropdown-get` ⚠️ 超大数据请走"`+csv-get` 按 `--range` 行窗口(如 `A1:Z500` / `A501:Z1000` …)分批读到本地文件 + 本地脚本处理 + `+csv-put` 分批回写"。 diff --git a/skills/lark-sheets/references/lark-sheets-search-replace.md b/skills/lark-sheets/references/lark-sheets-search-replace.md index 473e7dc12..2272e9a06 100644 --- a/skills/lark-sheets/references/lark-sheets-search-replace.md +++ b/skills/lark-sheets/references/lark-sheets-search-replace.md @@ -15,7 +15,7 @@ | 操作需求 | 使用工具 | 说明 | |---------|---------|------| | 搜索/定位文本 | `+cells-search` | 返回匹配的单元格位置,支持正则、精确匹配等 | -| 查找并替换文本 | `+cells-replace` | 批量替换文本,支持正则捕获组引用 | +| 查找并替换文本 | `+cells-replace` | 批量替换文本;`--regex` 模式下 `--replacement` 可用 `$1`、`$2` 引用 `--find` 的捕获组 | **常见配置错误(必须注意)**: - **不要把操作动词当搜索词**:用户说"汇总金额"是一个操作动作(求和),不是要搜索"汇总金额"这个文本。只有当确实需要定位某个文本值的位置时才用 `+cells-search` @@ -95,6 +95,10 @@ lark-cli sheets +cells-replace --url "https://example.feishu.cn/sheets/shtXXX" \ # 确认后执行 lark-cli sheets +cells-replace --url "https://example.feishu.cn/sheets/shtXXX" \ --sheet-name "Sheet1" --find "v1" --replacement "v2" + +# 正则捕获组:把 "2026-03" 重排成 "03/2026"($1/$2 引用 --find 的捕获组) +lark-cli sheets +cells-replace --url "https://example.feishu.cn/sheets/shtXXX" \ + --sheet-name "Sheet1" --regex --find "(\\d{4})-(\\d{2})" --replacement "$2/$1" --dry-run ``` > `+cells-replace` 虽然 Risk = write,但范围大或正则错可能改一堆。**强烈推荐工作流**:先 `+cells-search` 看匹配数,再 `+cells-replace --dry-run` 预览,最后真正执行。 diff --git a/skills/lark-sheets/references/lark-sheets-sheet-structure.md b/skills/lark-sheets/references/lark-sheets-sheet-structure.md index 4fccee7d3..1a8636b50 100644 --- a/skills/lark-sheets/references/lark-sheets-sheet-structure.md +++ b/skills/lark-sheets/references/lark-sheets-sheet-structure.md @@ -129,7 +129,7 @@ _公共四件套 · 系统:`--dry-run`_ | `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | | `--start` | int | required | 起始位置(0-based) | | `--end` | int | required | 结束位置(exclusive) | -| `--depth` | int | optional | 嵌套层级(`+dim-group` 用),默认 1 | +| `--depth` | int | optional | 嵌套分组的层级(创建到第几层),默认 1 | | `--group-state` | string | optional | 分组初始展开状态(可选值:`expand` / `fold`)(默认 `expand`) | ### `+dim-ungroup` @@ -141,7 +141,7 @@ _公共四件套 · 系统:`--dry-run`_ | `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | | `--start` | int | required | 起始位置(0-based) | | `--end` | int | required | 结束位置(exclusive) | -| `--depth` | int | optional | 嵌套层级(`+dim-group` 用),默认 1 | +| `--depth` | int | optional | 要取消的分组层级,默认 1(最外层) | ### `+dim-move` @@ -205,6 +205,6 @@ lark-cli sheets +dim-freeze --url "..." --sheet-id "$SID" --dimension row --coun ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`--start ≤ --end`;`+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 公共四件套;`--start ≤ --end`;`+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`。 - `DryRun`:写操作输出"将要 PATCH 的 dimension 区间 + 目标参数"。 - `Execute`:写后自动调用 `+sheet-info --include row_heights,col_widths,hidden_rows,hidden_cols,groups,frozen` 回读对比,envelope.meta.verification 给出受影响的范围。 diff --git a/skills/lark-sheets/references/lark-sheets-sparkline.md b/skills/lark-sheets/references/lark-sheets-sparkline.md index cbe5ba675..8eadd0978 100644 --- a/skills/lark-sheets/references/lark-sheets-sparkline.md +++ b/skills/lark-sheets/references/lark-sheets-sparkline.md @@ -82,8 +82,7 @@ _创建/更新/部分删除的迷你图属性_ > **何时需要先 `+sparkline-list`:** > - `+sparkline-update`:**总是**需要——拿到组内每一项的 `sparkline_id`,回填到 `properties.sparklines[i]`,server 用它做映射。 -> - `+sparkline-delete` 删单个 / 部分项:需要——同样要把 `sparkline_id` 填进 `properties.sparklines[i]`。 -> - `+sparkline-delete` 删整组:**不需要** `sparkline_id`,只要 `--group-id`。 +> - `+sparkline-delete`:**不需要** `sparkline_id`——CLI 仅支持按 `--group-id` 整组删除(该 shortcut 没有 `--properties`)。 ### `+sparkline-list` @@ -131,19 +130,11 @@ lark-cli sheets +sparkline-update --url "..." --sheet-id "$SID" --group-id "grpA ### `+sparkline-delete` -> 两种模式: -> - **删整组**:不传 `--properties`,仅 `--group-id`。删完后该 group 整个清掉。 -> - **删单个 / 部分项**:传 `--properties '{"sparklines":[{"sparkline_id":"..."},...]}'`,每项必须含 `sparkline_id`;删指定项后组内为空会自动清理 group。 -> -> 强制 `--yes` 或 `--dry-run`;先 `--dry-run` 确认要删的目标。 +> CLI 仅支持**整组删除**:传 `--group-id` 删掉该组全部迷你图。该 shortcut **没有** `--properties`,无法只删组内单项(需求上要"留一部分"时,改用 `+sparkline-update` 重写该组的 `sparklines` 列表,而不是 delete)。强制 `--yes` 或 `--dry-run`;先 `--dry-run` 确认要删的目标组。 ```bash # 删整组 lark-cli sheets +sparkline-delete --url "..." --sheet-id "$SID" --group-id "grpA" --yes - -# 删组内指定项(先 +sparkline-list 拿到 sparkline_id) -lark-cli sheets +sparkline-delete --url "..." --sheet-id "$SID" --group-id "grpA" \ - --properties '{"sparklines":[{"sparkline_id":"sl_1"}]}' --yes ``` ### Validate / DryRun / Execute 约束 @@ -151,8 +142,8 @@ lark-cli sheets +sparkline-delete --url "..." --sheet-id "$SID" --group-id "grpA - `Validate`: - XOR 公共四件套;`+sparkline-{update,delete}` 必须 `--group-id`。 - **`+sparkline-update`**:当 `properties.sparklines` 非空时,每一项必须含 `sparkline_id`(CLI 预检,错误信息会指回 `+sparkline-list`,避免命中服务端的不可读拒绝);只传 `properties.config`(config-only update)合法、不触发 sparkline_id 检查。 - - **`+sparkline-delete`**:不传 `--properties` = 删整组(合法路径,不需要 sparkline_id);传 `properties.sparklines` 时每项必须含 `sparkline_id`(server contract;CLI 本地暂未预检 delete 的 partial-item 分支,缺 id 会到 server 端才被拒)。 - - `--properties` 顶层只接 `config`(同组共享样式)和 `sparklines`(迷你图项数组);`+sparkline-create` 要求每个 `sparklines[i]` 含 `position` 与 `source`(或 `source_range`,二选一)。 + - **`+sparkline-delete`**:只接 `--group-id`(整组删除),**没有** `--properties`,无法删组内单项。 + - `--properties`(仅 `+sparkline-create` / `+sparkline-update`)顶层只接 `config`(同组共享样式)和 `sparklines`(迷你图项数组);`+sparkline-create` 要求每个 `sparklines[i]` 含 `position` 与 `source`(或 `source_range`,二选一)。 - `+sparkline-delete` 强制 `--yes` 或 `--dry-run`。 - `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 sparkline group 请求模板"。 - `Execute`:写后调用 `+sparkline-list --group-id ` 回读,envelope.meta.verification 给出 `config` / `sparklines` 字段级对比。 diff --git a/skills/lark-sheets/references/lark-sheets-visual-standards.md b/skills/lark-sheets/references/lark-sheets-visual-standards.md index 3cc284540..df00c7679 100644 --- a/skills/lark-sheets/references/lark-sheets-visual-standards.md +++ b/skills/lark-sheets/references/lark-sheets-visual-standards.md @@ -17,7 +17,7 @@ 2. **对齐方式**:文本列左对齐、数值 / 货币 / 百分比列右对齐、日期 / 分类列居中;垂直方向统一居中 3. **数值格式**:每列统一小数位 + 千分位(用 `number_format`);金额列统一货币符号;同一列内**禁止**出现 0 位 / 1 位 / 2 位小数混杂 4. **边框**:覆盖范围按上方「美化范围必须覆盖所有用户语义目标」规则(含汇总 / 总计 / 表尾说明行),内外框线清晰 -5. **列宽 + 行高 + 自动换行**:详细规则见 `lark-sheets-range-operations` 的「写入后列宽自适应」章节(按最长字符数扩列宽 / 长文本设置 `wrap=true` + 调高行高 / 长数字设置 `number_format` 防科学计数法) +5. **列宽 + 行高 + 自动换行**:详细规则见 `lark-sheets-range-operations` 的「写入后列宽自适应」章节(按最长字符数扩列宽 / 长文本设置 `cell_styles.word_wrap="auto-wrap"` + 调高行高 / 长数字设置 `number_format` 防科学计数法) **差异化标注场景**:用户要求"重复行 / 异常值 / 重要项视觉区分"时,标注列 / 行必须设置与普通数据**显著不同**的 `cell_styles`(背景色 + 加粗 + 字体色至少改一项),不能与普通数据格式完全一致。 @@ -135,7 +135,7 @@ - 若倒数两行背景色不同(如 #FFFFFF 与 #F3F4F6),新行按奇偶延续,不要固定一个色 - `border_styles` 最易遗漏(四边都要复制),否则新行会缺框线 -> 具体采样调用与工作流见 `lark-sheets-read-data` 的「格式采样工作流(新增行时继承原有格式)」章节。 +> 具体采样调用(用 `+cells-get` 读源行 `cell_styles` + `border_styles`、`+sheet-info` 读行高合并)见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」章节。 #### 2B. 基于模板区域的修改(copy 保留所有格式) diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 5f1718f51..935932f65 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -109,7 +109,7 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl - **语义信号**(二选一):用户 prompt 含"合计/汇总/总计/统计/各科平均分/最下面加一行算…/底部总计"等意图词;或上下文明确是"表尾追加一行做聚合" - **结构信号**:新行全行都在做聚合(含 `=SUM/AVERAGE/COUNT/MAX/MIN/SUBTOTAL(...)`,支持 IFERROR 包裹),**不是**单个 cell 算个参考值或每行都算的派生列 -满足上述时,**不要在本 skill 里猜样式**,直接去读 `lark-sheets-visual-standards` 的"汇总行规范"章节,按那里的规则配齐 `font.bold / horizontal_alignment / background_color / border_styles`。 +满足上述时,**不要在本 skill 里猜样式**,直接去读 `lark-sheets-visual-standards` 的「场景一 → 1A. 添加汇总行 / 表头行」章节,按那里的样式要点配齐 `font.bold / horizontal_alignment / background_color / border_styles`。 反例(**不是**汇总行,禁止自动加粗): - 用户说"在 H5 帮我算个 AVERAGE 参考"→ 单 cell 计算 @@ -132,7 +132,7 @@ Step 2: 再用 `+cells-set` 单独写具体 value/formula(不再传样式, `+cells-set` — range="C11:F11", cells=[[{formula: "=AVERAGE(C2:C10)"}, {formula: "=AVERAGE(D2:D10)"}, ...]] ``` -⚠️ **Step 1 `cell_styles` 禁止留空**:只铺 border、不铺 `cell_styles`,等于新行从格式上"裸奔"——没字体、没对齐、没背景色。如果新行是汇总行,这意味着 bold 丢失,用户感受"没做样式"。Step 1 的 `cell_styles` 要么继承源区块(`+cells-get` 读相邻已有行样式后复用),要么按汇总行规范(见 `lark-sheets-visual-standards`)配齐。 +⚠️ **Step 1 `cell_styles` 禁止留空**:只铺 border、不铺 `cell_styles`,等于新行从格式上"裸奔"——没字体、没对齐、没背景色。如果新行是汇总行,这意味着 bold 丢失,用户感受"没做样式"。Step 1 的 `cell_styles` 要么继承源区块(`+cells-get` 读相邻已有行样式后复用),要么按汇总行样式要点(见 `lark-sheets-visual-standards` 的「场景一 → 1A. 添加汇总行 / 表头行」)配齐。 **做法 B:一次写入但每个 cell 都显式带样式** @@ -295,9 +295,9 @@ _列表选项_ 示例: ```bash -# 纯值(数组形态) +# 纯值(数组形态);默认即覆盖非空 cell,无需显式传 --allow-overwrite lark-cli sheets +cells-set --url "https://example.feishu.cn/sheets/shtXXX" \ - --sheet-name "Sheet1" --range "A1:B2" --allow-overwrite \ + --sheet-name "Sheet1" --range "A1:B2" \ --cells '[[{"value":"name"},{"value":"score"}],[{"value":"alice"},{"value":95}]]' # 富 cell(公式 + 样式,cells 是二维矩阵每元素一个 cell schema) From 48e60723424d6ff862dd2ca71ebe8547254c78b7 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Sat, 23 May 2026 19:09:02 +0800 Subject: [PATCH 037/114] docs(sheets): sync chart properties schema (position/size required) Regenerate flag-schemas.json from upstream sheet-skill-spec: the chart properties schema now marks position and size as required, and the chart reference doc reflects the same. flag-schemas.json is print-schema-only (no client-side validation), so this is a generated-artifact + doc sync with no CLI behavior change. --- shortcuts/sheets/data/flag-schemas.json | 8 ++++++++ skills/lark-sheets/references/lark-sheets-chart.md | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json index 0ae0b2913..8135bd147 100644 --- a/shortcuts/sheets/data/flag-schemas.json +++ b/shortcuts/sheets/data/flag-schemas.json @@ -1759,6 +1759,10 @@ } } }, + "required": [ + "position", + "size" + ], "additionalProperties": {} } }, @@ -2785,6 +2789,10 @@ } } }, + "required": [ + "position", + "size" + ], "additionalProperties": {} } }, diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md index 7134f94fe..9ca096071 100644 --- a/skills/lark-sheets/references/lark-sheets-chart.md +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -147,9 +147,9 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ _创建/更新的图表属性_ **顶层字段**: -- `position` (object?) — 必填 { row: number, col: string } +- `position` (object) — 必填 { row: number, col: string } - `offset` (object?) — 可选 { row_offset?: number, col_offset?: number } -- `size` (object?) — 必填 { width: number, height: number } +- `size` (object) — 必填 { width: number, height: number } - `snapshot` (object?) — 图表快照配置 { title?: object, subTitle?: object, style?: object, legend?: oneOf, plotArea?: object, …共 6 项 } ## Examples From 370137e1c315ff7f4fb2fb13ad85cf3f2dec5737 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Sun, 24 May 2026 09:55:47 +0800 Subject: [PATCH 038/114] docs(sheets): sync lark-sheets skill spec from upstream Refine reference docs and flag-defs descriptions from upstream sheet-skill-spec: clarify +workbook-export sheet flag scope, +filter-* --properties optionality (omitted => empty filter on --range; rules must be non-empty when provided), float-image reference_id wording, and assorted reference cleanups. Description-only; existing CLI behavior (filter passthrough, properties optional) already matches. --- shortcuts/sheets/data/flag-defs.json | 8 +- skills/lark-sheets/SKILL.md | 40 +++++++-- .../references/lark-sheets-batch-update.md | 6 +- .../references/lark-sheets-chart.md | 17 ++-- .../lark-sheets-conditional-format.md | 6 +- .../references/lark-sheets-core-operations.md | 84 ++++++------------- .../references/lark-sheets-filter-view.md | 2 +- .../references/lark-sheets-filter.md | 4 +- .../references/lark-sheets-float-image.md | 17 ++-- .../lark-sheets-formula-translation.md | 2 +- .../references/lark-sheets-pivot-table.md | 26 ++++-- .../lark-sheets-range-operations.md | 6 +- .../references/lark-sheets-read-data.md | 10 +-- .../lark-sheets-visual-standards.md | 7 +- .../references/lark-sheets-workbook.md | 2 +- .../references/lark-sheets-write-cells.md | 8 +- 16 files changed, 123 insertions(+), 122 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index ab97963bc..d8687e227 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -495,7 +495,7 @@ "kind": "own", "type": "string", "required": "optional", - "desc": "Required only in csv mode: the sheet reference_id to export" + "desc": "Required only in csv mode: which sheet to export as CSV. This is a `+workbook-export`-specific flag, unrelated to the common four-tuple sheet locator (this shortcut does not accept the common sheet locator)" }, { "name": "output-path", @@ -3745,7 +3745,7 @@ "kind": "own", "type": "string", "required": "optional", - "desc": "Filter rule JSON: `rules` (required, per-column rule array), `filtered_columns?` (active column index hint). `range` is a separate flag (do not duplicate inside this JSON)", + "desc": "Filter rule JSON: `rules` (per-column rule array), `filtered_columns?` (active column index hint). The flag is optional overall — if provided, `rules` must be non-empty; if omitted, an empty filter is created on `--range` (no column conditions). `range` is a separate flag (do not duplicate inside this JSON)", "input": [ "file", "stdin" @@ -4410,7 +4410,7 @@ "kind": "own", "type": "string", "required": "xor", - "desc": "Image reference_id (XOR with `--image-token`); a prefixed string like `<|image|>:abcdef`" + "desc": "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow" }, { "name": "position-row", @@ -4527,7 +4527,7 @@ "kind": "own", "type": "string", "required": "xor", - "desc": "Image reference_id (XOR with `--image-token`); a prefixed string like `<|image|>:abcdef`" + "desc": "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow" }, { "name": "position-row", diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index 91cddfd53..55ffe244f 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -13,6 +13,16 @@ metadata: **CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理。** +## 术语约定 + +下列词在本 skill 各文档中可能交替出现,但**指同一对象**;解析用户口语时按此映射,不要当成不同概念: + +| 标准用语 | 同义 / 口语(均指同一对象) | 说明 | +| --- | --- | --- | +| 工作表(sheet) | 子表、tab、标签页 | spreadsheet 内的单张表;`sheet_id` 是其稳定标识 | +| 电子表格(spreadsheet) | 工作簿、表格 | 顶层容器;由 `--url` 或 `--spreadsheet-token` 定位 | +| reference_id | id | 各 `*-id` flag(`--sheet-id` / `--chart-id` / `--pivot-table-id` / `--group-id` / `--view-id` 等)接受的对象标识符,统称 reference_id | + ## References 本 skill 按能力子域组织,每个子域有独立 reference。先按下表索引定位到目标子域,再进入对应 reference 查 shortcut / 调用细节。 @@ -41,21 +51,35 @@ metadata: 各 reference 的每个 shortcut 标题下用一行徽章标注该 shortcut 支持的公共 / 系统 flag,例如: -- `_公共四件套 · 系统:--dry-run_` — URL/token + sheet 定位全 4 个公共 flag,加 `--dry-run` +- `_公共四件套 · 系统:--dry-run_` — URL/token + sheet 定位(两组各**必给一个**,详见下方「公共 flag」),加 `--dry-run` - `_公共:URL/token(无 sheet 定位) · 系统:--yes、--dry-run_` — 只接 URL/token,常见于 `+batch-update` 等不强制 sheet 定位的 shortcut 徽章里只列名字。type / 必填 / 描述都在本段统一声明: ### 公共 flag(定位资源) -**公共四件套** = `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`。前两者 XOR 互斥(spreadsheet 定位),后两者 XOR 互斥(sheet 定位)。 +**公共四件套** = `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`,分成两组 XOR,**每组都必须给且只能给一个**(XOR = 二选一必填,不是"可选"): + +1. **spreadsheet 定位(必填)**:`--url` 与 `--spreadsheet-token` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --url or --spreadsheet-token`;两个都给 → 互斥冲突。 +2. **sheet 定位(公共四件套 shortcut 必填)**:`--sheet-id` 与 `--sheet-name` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --sheet-id or --sheet-name`。 + - ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:即使写了 `--range "Sheet1!A1:B2"`,仍**必须**额外传 `--sheet-id` 或 `--sheet-name`,否则照样报上面的错。 + - **例外**:徽章标为 `_公共:URL/token(无 sheet 定位)…_` 的 shortcut(如 `+workbook-info` / `+workbook-export` / `+batch-update` / `+dropdown-get|update|delete` / `+cells-batch-set-style` / `+cells-batch-clear` / `+sheet-create`)**不接受也不需要** sheet 定位,只给一组 spreadsheet 定位即可。 | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--url` | string | XOR | spreadsheet URL;与 `--spreadsheet-token` 二选一 | -| `--spreadsheet-token` | string | XOR | spreadsheet token;与 `--url` 二选一 | -| `--sheet-id` | string | XOR | 工作表 reference_id;与 `--sheet-name` 二选一 | -| `--sheet-name` | string | XOR | 工作表名称;与 `--sheet-id` 二选一 | +| `--url` | string | 二选一必填(与 `--spreadsheet-token`) | spreadsheet URL | +| `--spreadsheet-token` | string | 二选一必填(与 `--url`) | spreadsheet token | +| `--sheet-id` | string | 二选一必填(与 `--sheet-name`;仅公共四件套 shortcut) | 工作表 reference_id | +| `--sheet-name` | string | 二选一必填(与 `--sheet-id`;仅公共四件套 shortcut) | 工作表名称 | + +**统一调用范式**(公共四件套 shortcut 的所有示例都遵循此形状,两组定位缺一不可): + +```bash +lark-cli sheets <其它 flag> +# workbook 定位:--url "..." 或 --spreadsheet-token "..." (二选一,必给) +# sheet 定位: --sheet-id "$SID" 或 --sheet-name "Sheet1" (二选一,必给) +# 例:lark-cli sheets +csv-get --url "https://.../sheets/shtXXX" --sheet-name "Sheet1" --range "A1:F30" +``` ### 系统 flag @@ -66,7 +90,7 @@ metadata: | `--print-schema` | bool | 否 | 本地打印复合 JSON flag 的 JSON Schema 并退出,不发起任何调用、不需要其它 required flag。与 `--flag-name ` 搭配指定要查哪个 flag;省略 `--flag-name` 时列出该 shortcut 所有可查询的 flag。**仅在 shortcut 含复合 JSON flag 时有效**——判断方法:该 shortcut 的 Flags 表里出现类型标注为「复合 JSON」的 flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys` / `--options`)即支持;纯标量 flag 的 shortcut 不支持。 | | `--flag-name` | string | 否 | 配合 `--print-schema` 使用,指定要打印 JSON Schema 的 flag 名(不带 `--` 前缀,如 `cells` / `properties` / `operations`)。 | -**Agent 使用提示**:写复合 JSON flag(`--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys` 等)时,如果对结构不确定,先跑 `lark-cli sheets --print-schema --flag-name ` 把完整 JSON Schema 读出来再构造 payload,比靠 reference 的速查表更精确,也避免因为字段拼写或缺失被服务端拒绝。reference 的 `## Schemas` 段只给一层结构,深层只能靠 `--print-schema` 或 `## Examples` 的真实示例。 +**Agent 使用提示**:写复合 JSON flag(`--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys` / `--options` 等)时,如果对结构不确定,先跑 `lark-cli sheets --print-schema --flag-name ` 把完整 JSON Schema 读出来再构造 payload,比靠 reference 的速查表更精确,也避免因为字段拼写或缺失被服务端拒绝。reference 的 `## Schemas` 段只给一层结构,深层只能靠 `--print-schema` 或 `## Examples` 的真实示例。 ## 复合 JSON / 大入参:优先 stdin @@ -75,7 +99,7 @@ flag 帮助里标注支持 **Stdin** 的入参,当 payload 较大、含换行 推荐写法:payload 写到 cwd 之外的临时文件(如 `/tmp/cells.json`,不污染用户项目目录),再用 stdin 喂进去: ```bash -lark-cli sheets +cells-set --url "..." --range "A1:B2" --cells - < /tmp/cells.json +lark-cli sheets +cells-set --url "..." --sheet-name "Sheet1" --range "A1:B2" --cells - < /tmp/cells.json ``` **`@file` 接绝对路径会被拒,且被拒后不要照报错提示做。** `@file` 出于安全只接受 cwd 下的相对路径,传 `@/tmp/cells.json` 这类绝对路径或 cwd 之外的路径会被拒。此时报错会建议"先 cd 到目标目录,或改用相对路径"——**两条都不要照做**:cd 过去、或把临时文件写进用户项目目录,都会污染工作目录。正解是改用 stdin(`-- - < 文件`)。 diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 1bb8a009d..ba3160e0b 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -5,20 +5,22 @@ `+batch-update` 把多次写入打包成单次请求,但每个子操作仍受编辑类任务硬性默认规则约束: 1. **目标 range 必须落在用户授权范围内**:除用户明示要修改的区域外,子操作禁止扩张到无关单元格 / 列 / Sheet。规划 range 时先确认每个子操作的边界。 -2. **批次完成后必须回读校验**:整个 `+batch-update` 执行成功后,用 `+csv-get` 或 `+cells-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末),与 本地脚本 预先计算的预期值对照。 +2. **批次完成后必须回读校验**:整个 `+batch-update` 执行成功后,用 `+csv-get` 或 `+cells-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末),与本地脚本预先计算的预期值对照。 3. **预期条数前置断言**:涉及"批量填充 N 行"或"对 M 个区域分别写入"时,先把 N、M 硬编码进代码,回读后断言实际等于预期;不一致就再发一轮 `+batch-update` 补齐,禁止交付半成品。 ## 使用场景 写入。批量执行多个写入工具操作。将多个工具调用合并为一次请求,按顺序依次执行。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。注意:不支持嵌套 `+batch-update`。 +**不可放进 `--operations` 的写 shortcut**(`shortcut` 枚举不含它们,强行写入会被校验拒):`+cells-set-image`(需本地上传图片)、`+dropdown-update` / `+dropdown-delete` / `+cells-batch-set-style` / `+cells-batch-clear`(自身已是批量入口,不可再嵌套)、`+dim-move`。这些操作需在 `+batch-update` 之外单独调用。 + **⚠️ 何时必须使用 `+batch-update`(硬性要求)**: - 需要对**多个**不同区域执行 `+cells-{merge|unmerge}` 时(如按分组合并多列相同内容) - 需要对**多个**不同区域执行 `+rows-resize / +cols-resize` 时(如统一调整多列列宽或多行行高) - 需要先插入行列再写入数据时(`+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` + `+cells-set`) - 需要对多个区域执行不同写入操作时(多次 `+cells-set` + `+cells-clear` 等组合) -当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求。逐个调用会快速耗尽工具调用轮次上限(60R),导致任务无法完成。 +当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求——`+batch-update` 是原子提交(要么全成功要么整批回滚);逐个调用非原子,中途失败会留下半成品。 ## Shortcuts diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md index 9ca096071..b477e7742 100644 --- a/skills/lark-sheets/references/lark-sheets-chart.md +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -2,7 +2,7 @@ ## 真对象硬约束 -当用户要求"画个图 / 数据可视化 / 趋势图 / 对比图 / 占比图"时,**必须**通过 `+chart-{create|update|delete}` 创建真实的图表对象。**禁止**用 本地脚本 调 matplotlib / seaborn 生成图片再插入到表格代替——静态图片无法随源数据更新,且失去交互能力。判断标准:交付后 `+chart-list` 必须能返回该对象。 +当用户要求"画个图 / 数据可视化 / 趋势图 / 对比图 / 占比图"时,**必须**通过 `+chart-{create|update|delete}` 创建真实的图表对象。**禁止**用本地脚本调 matplotlib / seaborn 生成图片再插入到表格代替——静态图片无法随源数据更新,且失去交互能力。判断标准:交付后 `+chart-list` 必须能返回该对象。 ## 使用场景 @@ -80,9 +80,9 @@ ## 图表位置选择(创建前必做) -凭感觉挑列号/行号会被 API 拒(`position is out of sheet range`),浪费一轮调用。按以下四步走: +凭感觉挑列号/行号会被 API 拒(`position is out of sheet range`)。按以下四步走: -1. **查尺寸**:`+sheet-info` 拿 `rowCount` / `columnCount`。 +1. **查尺寸**:`+workbook-info` 拿该 sheet 的 `row_count` / `column_count`(下文记为 rowCount / columnCount;`+sheet-info` 只返回布局,不含行列总数)。 2. **估跨度**:默认单元格 **105 px 宽 × 27 px 高**,`needCols = ceil(width/105)`,`needRows = ceil(height/27)`。 3. **校验**:`position.row + needRows ≤ rowCount` 且 `col_idx + needCols ≤ columnCount`(col 按 A=0、B=1、…、Z=25、AA=26… 换算)。 4. **不够就先扩表**,二选一,禁止硬塞越界位置: @@ -165,9 +165,10 @@ _创建/更新的图表属性_ 示例: ```bash -# 内联 JSON +# 内联 JSON —— 最小列图骨架(inline 模式:refs 含表头行,首列/首行即类别/系列名) +# 完整字段(堆叠/数据标签/detached/坐标轴等)跑 --print-schema --flag-name properties lark-cli sheets +chart-create --url "https://example.feishu.cn/sheets/shtXXX" \ - --sheet-name "Sheet1" --properties '{"position":{"row":42,"col":"A"},"size":{"width":600,"height":400},"snapshot":{...}}' + --sheet-name "Sheet1" --properties '{"position":{"row":42,"col":"A"},"size":{"width":600,"height":400},"snapshot":{"data":{"refs":[{"value":"Sheet1!A1:B10"}]},"plotArea":{"plot":{"type":"column"}}}}' # 走文件(推荐配置较多时) lark-cli sheets +chart-create --url "https://example.feishu.cn/sheets/shtXXX" \ @@ -188,12 +189,12 @@ lark-cli sheets +chart-create --url "https://example.feishu.cn/sheets/shtXXX" \ 示例: ```bash -# dry-run 先看会删什么 -lark-cli sheets +chart-delete --url "https://example.feishu.cn/sheets/shtXXX" \ +# dry-run 先看会删什么(sheet 定位必填) +lark-cli sheets +chart-delete --url "https://example.feishu.cn/sheets/shtXXX" --sheet-id "$SID" \ --chart-id "chrXXX" --dry-run # 真正执行 -lark-cli sheets +chart-delete --url "https://example.feishu.cn/sheets/shtXXX" \ +lark-cli sheets +chart-delete --url "https://example.feishu.cn/sheets/shtXXX" --sheet-id "$SID" \ --chart-id "chrXXX" --yes ``` diff --git a/skills/lark-sheets/references/lark-sheets-conditional-format.md b/skills/lark-sheets/references/lark-sheets-conditional-format.md index 3af0eda7b..5e497033c 100644 --- a/skills/lark-sheets/references/lark-sheets-conditional-format.md +++ b/skills/lark-sheets/references/lark-sheets-conditional-format.md @@ -14,7 +14,7 @@ **判断标准**:交付后 `+cond-format-list` 必须能返回该规则;否则视为违规。 -**大数据量加分项**:当数据量 > 1000 行时,条件格式是首选——它由飞书自身渲染,不会触发 本地脚本 50 秒超时(同 R8)。 +**大数据量首选**:当数据量 > 1000 行时,条件格式是首选——它由飞书自身渲染,比"本地脚本逐行计算 + `+cells-set` 写静态背景色"更高效、更稳(颜色还能随源数据自动联动)。 ## 使用场景 @@ -61,7 +61,7 @@ Step 2: 基于辅助列值做条件格式(用 cellIs 或引用辅助列的 exp `+cond-format-{create|update|delete}` create rule_type: "expression" ranges: ["2:145"] - attrs: [{formula: ["=$O2>$H2"]}] ← 虽然逻辑等价,但产物里缺辅助列 → 扣配置需求分 + attrs: [{formula: ["=$O2>$H2"]}] ← 虽然逻辑等价,但产物里缺辅助列 → 不满足用户明确要求的"辅助列"诉求 ``` 为什么禁止一步走:用户明确要求辅助列是有**业务意图**的——让人肉眼能在表里看到"是/否"列;条件格式只是视觉辅助。一步 expression 虽然效果对了,但用户打开表格看不到辅助列,被视为"操作不完整/未采用公式"。 @@ -167,7 +167,7 @@ lark-cli sheets +cond-format-create --url "..." --sheet-id "$SID" \ ### `+cond-format-delete` ```bash -lark-cli sheets +cond-format-delete --url "..." --rule-id "$RULE_ID" --yes +lark-cli sheets +cond-format-delete --url "..." --sheet-id "$SID" --rule-id "$RULE_ID" --yes ``` ### Validate / DryRun / Execute 约束 diff --git a/skills/lark-sheets/references/lark-sheets-core-operations.md b/skills/lark-sheets/references/lark-sheets-core-operations.md index 62e6138ca..68902e234 100644 --- a/skills/lark-sheets/references/lark-sheets-core-operations.md +++ b/skills/lark-sheets/references/lark-sheets-core-operations.md @@ -4,13 +4,13 @@ 这是面向"已有飞书表格"的核心工作流。核心原则是:先了解,再分析或写入,最后验证。 -## 编辑前必读:8 条默认规则 +## 编辑前必读:7 条默认规则 -> 所有编辑类任务(修改 / 排序 / 筛选 / 删除 / 透视 / 批量填充)**必须**先满足以下 8 条,再进入下方「硬性规则」和具体子 skill。任何子 skill 不得放宽这 8 条。 +> 所有编辑类任务(修改 / 排序 / 筛选 / 删除 / 透视 / 批量填充)**必须**先满足以下 7 条,再进入下方「硬性规则」和具体子 skill。任何子 skill 不得放宽这 7 条。 1. **R1 最小改动**:除用户明示要修改的单元格 / 列外,原表其它单元格、行列结构、Sheet 名称、合并区域、格式必须 1:1 保持。中间结果 / 标注列优先放在原数据列**右侧**;当中间结果会与原数据混淆,或需要承载结构化对象(透视表 / 图表)时可**新建空白 Sheet**。**禁止**擅自删除 / 重命名 / 隐藏 / 移动**已存在**的原 Sheet(新建是允许的,节制使用即可)。 -2. **R2 真实写回**:编辑任务的最终交付必须是对在线表格的真实写入并回读校验。**严禁**只在文本里描述"已完成 X" 没有任何写入;**严禁**用普通公式 / 文本汇总假装"透视表 / 筛选 / 图表 / 条件格式 / 迷你图"等结构化对象;**严禁**只输出 `{"type": "LarkExcelCard", "refs": [...]}` 形式的引用作为交付——LarkExcelCard 引用 ≠ 真实写入,必须有对应的 `+cells-set` / `+<对象>-{create|update|delete}`(图表 / 透视表 / 条件格式 / 筛选 / 迷你图 / 浮动图片)工具调用,并能用 `+<对象>-list`(或 `+csv-get` / `+cells-get`)回读到改动结果。 -3. **R3 计算复现**:涉及计算、排序、筛选、聚合、批量数据提取的任务,必须用 本地脚本 独立复现一份预期值,与回读结果对照通过后再交付。设计公式 / 筛选条件前先 sample 至少 50 行识别该列所有值类型变体(纯数值 / 公式文本 / 多种日期格式 / 空值),不能只看前 10 行。 +2. **R2 真实写回**:编辑任务的最终交付必须是对在线表格的真实写入并回读校验。**严禁**只在文本里描述"已完成 X" 没有任何写入;**严禁**用普通公式 / 文本汇总假装"透视表 / 筛选 / 图表 / 条件格式 / 迷你图"等结构化对象;**严禁**只给出引用 / 占位而无真实写入。必须有对应的 `+cells-set` / `+<对象>-{create|update|delete}`(图表 / 透视表 / 条件格式 / 筛选 / 迷你图 / 浮动图片)工具调用,并能用 `+<对象>-list`(或 `+csv-get` / `+cells-get`)回读到改动结果。 +3. **R3 计算复现**:涉及计算、排序、筛选、聚合、批量数据提取的任务,必须用本地脚本独立复现一份预期值,与回读结果对照通过后再交付。设计公式 / 筛选条件前先 sample 至少 50 行识别该列所有值类型变体(纯数值 / 公式文本 / 多种日期格式 / 空值),不能只看前 10 行。 4. **R4 处理完整性**:全量逐条处理类任务(翻译 / 打标 / 删除指定行 / 批量公式落地 / 按条件保留),落地前先把"预期处理条数"硬编码进代码,处理完后 `assert actual == expected`。**严禁**输出"已完成前 N 条,剩余将继续"这类半成品文案。 5. **R5 指令语义还原**:"按 X 排序" / "筛选 X" / "把 X 删除" 落地前先回答:① X 在哪一列?该列实际值类型是什么?② 期望结果集大小是多少?答完再动手;禁止直接对混合文本列做字符串排序 / 筛选。 6. **R6 任务拆解为可验证 checklist**:用户的指令落地前,**必须**先拆成所有"独立可验证子要点",每点对应一个 `assert`,全部通过才交付: @@ -18,20 +18,9 @@ - **多目标操作**("删除 N 条 / N 行")→ 每目标一个 assert - **多格式兼容**(日期 YYYYMM / YYYY-MM-DD / 时间戳)→ 每种格式至少一个样本通过 - **范围类操作**("加边框 A1:H11"、"覆盖第 2~218 行")→ 起始 / 末行 / 末列三个边界都要核 - - **单一指令隐含多个失败模式**(如"用公式算面积"隐含"提取数值 / 乘以数量 / 单位转换 / 公式而非硬编码 / 不超时"等多个验收点)→ 每点独立 assert + - **单一指令隐含多个失败模式**(如"用公式算面积"隐含"提取数值 / 乘以数量 / 单位转换 / 公式而非硬编码"等多个验收点)→ 每点独立 assert 只完成第一个要点就交付(典型如:按部门只排一级、删 3 行只删 1 行、兼容日期只兼容 YYYYMM)属于违规。 7. **R7 公式模式延续**:扩展 / 续写 / 新增行列时,**必须**先用 `+cells-get` 读原数据区域的 `formula` 字段,识别公式模式,新行新列必须延续相同模式。**禁止**把原表 `=C3/B3` / `=SUM(B3:B9)` 模式在新行替换为硬编码常数(如 `0.85` / `50`)这种破坏数据驱动性的写法。**用户口头操作("分列 / 排序 / 提取 / 求和")也必须落地为公式或原生工具**(SORT 公式 / 分列 / `TEXTBEFORE` / `MID` / `+filter-{create|update|delete}` / `+pivot-{create|update|delete}` 等),不能只写静态结果——否则用户改源数据时结果不再联动,等同破坏了表格的数据驱动性。 -8. **R8 大数据量超时降级走原生工具**:当任务涉及 **> 1000 行**数据 / 预估 本地脚本 超 50 秒时,**禁止**继续用代码逐行处理,**必须**改走原生工具: - - | 任务类型 | 必须使用 | 禁止 | - |---|---|---| - | 重复检测 / 条件高亮 / 颜色标记 | `+cond-format-{create|update|delete}` | 本地脚本 逐行 + `+cells-set` 静态背景色 | - | 大批量行公式填充 | 模板公式 + `--copy-to-range "X:X"` | 本地脚本 计算每行 + 静态写入 | - | 大数据筛选 | `+filter-{create|update|delete}` 或 `+pivot-{create|update|delete}` | 复制到新 sheet 后覆盖 | - | 大批量数据 set | 分批 `+cells-set`,每批 ≤ 100 行 | 一次写 1000+ 行 | - | 大批量翻译 / NLP | 分 30 行/批,每批后立即写回 | 一次性处理全表后才写回 | - - **严禁**遇到超时仅输出"由于数据量过大,无法自动完成"的文本说明 + 手动操作步骤——这等同于零分交付。**严禁**输出 LarkExcelCard 引用作为"已完成"的证据(同 R2)。 ## 硬性规则 @@ -45,7 +34,7 @@ - 数据分析/清洗/可视化/大数据集 → 同上路径 A - 快速查看少量数据或简单问答(只读、不回写) → `+csv-get` 读取到对话上下文 - 需要公式/样式/批注 → `+cells-get` - - **续写/扩展已有内容** → 必须用 `+cells-get` 读取源区块样式 + `+sheet-info` 读取行高和合并信息(见硬性规则 12) + - **续写/扩展已有内容** → 必须用 `+cells-get` 读取源区块样式 + `+sheet-info --include row_heights,merges` 读取行高和合并信息(见硬性规则 12) **读取前 10 行后按表格形态分流(路径 B/C 批量写入前强制)**:表格形态决定第二步读不读、读什么。先用 `range: "A1:Z10"` 探查,然后根据返回的 `annotated_csv` 分类: @@ -69,15 +58,15 @@ 5. **区分公式语法和工具参数语法**:公式字符串中的范围引用(如 `H:H`、`$A$2:$B$5`)遵循飞书公式语法;而 CLI 工具的 `range` / `ranges` / `--copy-to-range` 参数使用 A1 表示法(如 `A1:D3`、`1:1`)。两者写法不同,混淆会导致调用失败。 -6. **合并单元格需特殊处理**:合并区域只有左上角单元格存储数据,其余位置读取为空——这不代表”无内容”,而是合并的正常行为。写入时只能写左上角,写其他位置会报错或被忽略。如需修改合并区域中间的某格,先取消合并再操作。**在合并区域中间行插入数据之前,必须先调用 `+cells-get` 或 `+sheet-info` 确认目标行是否落在某个合并区域内**——直接用 `+cells-set` 写入合并区域的非左上角单元格,后端会返回 `cell at row N, col M is inside a merged region` 错误;即使 LLM 响应错误后改调 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` 插入行,行号也可能因合并扩展而错位。同理,**新增列后若原表存在合并的标题行(如 A1:F1),需要手动用 `+cells-{merge|unmerge}` 扩展合并范围到新列(如 A1:I1)**,否则标题行不会跟着变宽。 +6. **合并单元格需特殊处理**:合并区域只有左上角单元格存储数据,其余位置读取为空——这不代表”无内容”,而是合并的正常行为。写入时只能写左上角,写其他位置会报错或被忽略。如需修改合并区域中间的某格,先取消合并再操作。**在合并区域中间行插入数据之前,必须先调用 `+cells-get` 或 `+sheet-info --include merges` 确认目标行是否落在某个合并区域内**——直接用 `+cells-set` 写入合并区域的非左上角单元格,后端会返回 `cell at row N, col M is inside a merged region` 错误;即使 LLM 响应错误后改调 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` 插入行,行号也可能因合并扩展而错位。同理,**新增列后若原表存在合并的标题行(如 A1:F1),需要手动用 `+cells-{merge|unmerge}` 扩展合并范围到新列(如 A1:I1)**,否则标题行不会跟着变宽。 -7. **多步写入优先用 `+batch-update`**:当任务涉及多个连续写入操作时,优先使用 `lark-sheets-batch-update` 中的 `+batch-update` 将它们合并为单次请求,减少调用轮次。**特别是以下场景必须用 `+batch-update`**: +7. **多步写入优先用 `+batch-update`**:当任务涉及多个连续写入操作时,优先使用 `lark-sheets-batch-update` 中的 `+batch-update` 将它们合并为单次原子请求(一次提交,要么全成功要么整批回滚,也更快、更不易出错)。**特别是以下场景必须用 `+batch-update`**: - 需要对多个不同区域执行 `+cells-{merge|unmerge}`(如按合同编号合并多列相同内容)→ 将所有 merge 操作放进一个 `+batch-update` - 需要对多个不同区域执行 `+rows-resize / +cols-resize`(如统一调整多列列宽或多行行高)→ 将所有 resize 操作放进一个 `+batch-update` - 需要先插入行列再写入数据 → 将 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` + `+cells-set` 放进一个 `+batch-update` - 当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求 -8. **结构操作用专用工具**:插入/删除行列用 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}`(一次操作可处理数千行),不要用 `+cells-set` 逐行搬数据——后者既慢又容易出错。用户给多步骤请求时,每个步骤都要执行,执行后重新验证。**`+dim-insert`(`--inherit-style before`/`after`)只继承相邻行的值/公式/边框,不继承 `row_height`**:插入后新行行高会回落到默认值(约 22px),若原行是高行高(容纳多行自动换行文本),新行长文本会被裁切(显示不全,末尾字符被截断)。**在中间插入行并填入文本前,必须先用 `+sheet-info` 读取相邻原行的 `row_height`,再用 `+batch-update` 合并 `+rows-resize / +cols-resize` 把同一高度应用到新行**——"在中间插入行填数据"本质等价于"续写已有内容",必须走硬性规则 12 的同一套样式继承流程(行高、边框、合并、对齐)。 +8. **结构操作用专用工具**:插入/删除行列用 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}`(一次操作可处理数千行),不要用 `+cells-set` 逐行搬数据——后者既慢又容易出错。用户给多步骤请求时,每个步骤都要执行,执行后重新验证。**`+dim-insert`(`--inherit-style before`/`after`)只继承相邻行的值/公式/边框,不继承 `row_height`**:插入后新行行高会回落到默认值(约 22px),若原行是高行高(容纳多行自动换行文本),新行长文本会被裁切(显示不全,末尾字符被截断)。**在中间插入行并填入文本前,必须先用 `+sheet-info --include row_heights` 读取相邻原行的 `row_height`,再用 `+batch-update` 合并 `+rows-resize / +cols-resize` 把同一高度应用到新行**——"在中间插入行填数据"本质等价于"续写已有内容",必须走硬性规则 12 的同一套样式继承流程(行高、边框、合并、对齐)。 9. **写入前精确定位表头和数据区域**:在执行任何写操作之前,必须先通过读取数据确认: - 表头在哪一行(不要假设表头一定在第 1 行,可能存在标题行、空行等) @@ -87,19 +76,19 @@ - **区分"表尺寸"与"数据占用范围"(新增列场景关键)**:`+workbook-info` 返回的 `column_count`(如 20 列)和 `row_count` 是**整个 sheet 的物理尺寸**,默认值可能远大于真实数据范围(比如一张只有 6 列数据的表可能 `column_count=20` 甚至 `column_count=100`)。**新增列 / 插入列之前,必须先调用一次 `+csv-get`(请求 `range: "A1:Z1"` 或表头附近一小块即可),读取返回值里的 `current_region` 作为真实数据矩形**,再基于 `current_region` 的右边界决定插入位置。例如 `current_region: "A1:F72"` → 数据末列是 F → 新增 3 列应插到 G/H/I,禁止插到 T(表尺寸末列)。否则新列和原数据之间会有一大片空列,用户看到的是"数据没动,三个空列在表尾"。 如果表头定位错误,后续所有公式和写入都会偏移,导致整体失败。 -10. **公式填充必须用 `--copy-to-range`,禁止逐行写入**:当同一公式需要向下填充到多行时,必须先用 `+cells-set` 在第一行写入模板公式,再用 `--copy-to-range` 填充整列(如 `--copy-to-range "H2:H100"` 或 `--copy-to-range "H:H"`)。**禁止**对每一行单独调用 `+cells-set` 写入相同结构的公式——这会浪费大量调用轮次。 +10. **公式填充必须用 `--copy-to-range`,禁止逐行写入**:当同一公式需要向下填充到多行时,必须先用 `+cells-set` 在第一行写入模板公式,再用 `--copy-to-range` 填充整列(如 `--copy-to-range "H2:H100"` 或 `--copy-to-range "H:H"`)。**禁止**对每一行单独调用 `+cells-set` 写入相同结构的公式——逐行写既慢又易错,而 `--copy-to-range` 会自动平移公式引用(C2=B2 → C3=B3)。 -11. **分组汇总必须用透视表**:当用户说"按XX统计YY"、"分组汇总"、"各部门/地区的数量/金额"、"汇总每个XX的YY"时,必须使用 `+pivot-{create|update|delete}` 创建透视表(推荐省略 sheet_id 自动新建子表)。禁止用 SUMIF/COUNTIF 公式或 本地脚本 代码替代——后者会导致统计结果覆盖原表数据。 +11. **分组汇总必须用透视表**:当用户说"按XX统计YY"、"分组汇总"、"各部门/地区的数量/金额"、"汇总每个XX的YY"时,必须使用 `+pivot-{create|update|delete}` 创建透视表(推荐省略 sheet_id 自动新建子表)。禁止用 SUMIF/COUNTIF 公式或本地脚本替代——后者会导致统计结果覆盖原表数据。 12. **续写/扩展已有内容时必须继承样式(高频致命错误)**:当用户要求"按已有格式续写"、"继续填充"、"复制区块"、"增加到第N周/月"等扩展任务时,**禁止只用 `+csv-get` 读值后只写值**。必须按以下顺序执行: - 用 `+cells-get` 读取源区块的 `cell_styles` 和 `border_styles` - - 用 `+sheet-info` 读取源区块的行高、合并单元格信息 + - 用 `+sheet-info --include row_heights,merges` 读取源区块的行高、合并单元格信息 - 写入时 cells 中同时携带 `value` + `cell_styles` + `border_styles` - 用 `+batch-update` 批量执行 `+cells-{merge|unmerge}`(复制合并)和 `+rows-resize / +cols-resize`(复制行高) - 只写值不写样式会导致新区块与源区块视觉完全不一致,这是最常见的致命错误 13. **"新增列"任务必须跑完整 checklist(高频致命错误)**:当用户要求"在表格中增加列/新增列/加几列"时,心智模型不是"只写表头 + 填公式"。**执行前必须完成以下 4 步 checklist,禁止跳步**: - - **Step 1 — 读原表 row1 的合并区域**:用 `+cells-get` 或 `+sheet-info` 读取 row1 的合并信息。**如果 row1 存在跨数据区的合并标题行(如 A1:F1 合并为一个大标题),新增 N 列后必须用 `+cells-{merge|unmerge}` 扩展合并范围到新列末**(如新增 3 列 → 合并改为 A1:I1)。否则新列在 row1 裸露在原标题区之外,视觉割裂。这一步被跳过会被 PM 判定"操作不完整"。 + - **Step 1 — 读原表 row1 的合并区域**:用 `+cells-get` 或 `+sheet-info --include merges` 读取 row1 的合并信息。**如果 row1 存在跨数据区的合并标题行(如 A1:F1 合并为一个大标题),新增 N 列后必须用 `+cells-{merge|unmerge}` 扩展合并范围到新列末**(如新增 3 列 → 合并改为 A1:I1)。否则新列在 row1 裸露在原标题区之外,视觉割裂——这一步被跳过会被判为"操作不完整"。 - **Step 2 — 读表头和原数据行的完整样式**:用 `+cells-get` 读原表头行(如 row2/row3)和数据行(row4)的 `cell_styles` **和 `border_styles`**,记录字体/加粗/对齐/**边框**/数字格式等。 - **Step 3 — 新列 cells 必须同时带 `cell_styles` + `border_styles`**:写新列时 cells 里的每个对象都要完整复制原表样式(包括边框),不能只传 font_size / alignment 就算"样式一致"。 - **Step 4 — 列宽对齐**:用 `+batch-update` 合并 `+rows-resize / +cols-resize` 把新列列宽与原数据列保持一致。 @@ -131,7 +120,7 @@ **路径 A:数据分析/清洗/可视化/大数据集/"完善 / 补齐 / 填空 / 修正所有 XX"** → 分批 `+csv-get` 把数据导出到本地文件,再用本地脚本(如 pandas)处理: ```bash # 分批导出(按 --range 行窗口翻页拼接到本地 data.csv,直到读完;单次返回量由 --max-chars 自动兜底,看 has_more / actual_range 续读) -lark-cli sheets +csv-get --url "$URL" --range "A1:Z500" > data.csv # 首窗口;后续 A501:Z1000 … 用 >> 追加 +lark-cli sheets +csv-get --url "$URL" --sheet-name "Sheet1" --range "A1:Z500" > data.csv # 首窗口;后续 A501:Z1000 … 用 >> 追加(sheet 定位必填) ``` ```python import pandas as pd @@ -164,7 +153,7 @@ print(df.head(10)) # 必做:横向——确认表头行 + **路径 C:需要公式/样式/批注** → 用 `+cells-get` -**路径 D:续写/扩展/完善已有内容(必须走此路径)** → 当用户要求"按已有格式续写"、"继续填充"、"复制区块"、"增加到第N周/月"、"帮我完善 XX"、"补齐 XX"、"填空"时,**必须**同时读取值和样式:先用 `+csv-get` 快速了解数据结构和范围,再用 `+cells-get` 读取源区块的 `cell_styles` + `border_styles`,并用 `+sheet-info` 读取行高和合并信息。**禁止跳过样式读取直接写入。** 这类任务默认要覆盖所有待补齐的行,**禁止只处理 `head(10)` 可见的行**——必须按 `df.info()` 的 non-null 数或 `current_region` 的实际行数确定写入范围。 +**路径 D:续写/扩展/完善已有内容(必须走此路径)** → 当用户要求"按已有格式续写"、"继续填充"、"复制区块"、"增加到第N周/月"、"帮我完善 XX"、"补齐 XX"、"填空"时,**必须**同时读取值和样式:先用 `+csv-get` 快速了解数据结构和范围,再用 `+cells-get` 读取源区块的 `cell_styles` + `border_styles`,并用 `+sheet-info --include row_heights,merges` 读取行高和合并信息。**禁止跳过样式读取直接写入。** 这类任务默认要覆盖所有待补齐的行,**禁止只处理 `head(10)` 可见的行**——必须按 `df.info()` 的 non-null 数或 `current_region` 的实际行数确定写入范围。 需要按关键字定位区域时使用 `lark-sheets-search-replace` 中的 `+cells-search`。 @@ -226,7 +215,7 @@ print(df.head(10)) # 必做:横向——确认表头行 + 7. 写入与修改 - 范围写入使用 `lark-sheets-write-cells`。`+cells-set` 的 `range` 必须落在当前已有行列范围内,`cells` 二维数组必须与 `range` 严格同维度;若是大块 CSV 纯值回写,优先用 `+csv-put`(直接传 CSV 文本,必要时自动扩容)。 - 如需在表尾追加数据,先插入行或列,再执行写入。 -- **多步写入优先用 `+batch-update`**(见硬性规则 7):将多个写入操作合并为一次 `+batch-update` 调用,减少调用轮次。尤其是多次 `+cells-{merge|unmerge}`、多次 `+rows-resize / +cols-resize`、多次 `+cells-set` 场景,必须合并。 +- **多步写入优先用 `+batch-update`**(见硬性规则 7):将多个写入操作合并为一次 `+batch-update` 原子调用。尤其是多次 `+cells-{merge|unmerge}`、多次 `+rows-resize / +cols-resize`、多次 `+cells-set` 场景,必须合并。 - **公式填充必须用 `--copy-to-range`(见硬性规则 10)**:先写一行模板公式,再用 `--copy-to-range` 一次填充整列或整区域。示例:在 H2 写 `=SUM(B2:G2)` 后,设 `--copy-to-range "H2:H100"` 即可填充 99 行。**禁止逐行调用 `+cells-set` 写入相同结构的公式。** - 对整行/整列统一设置值、公式、格式或批注时,优先写一个模板单元格,再用 `--copy-to-range` 扩展到 `1:1`、`J:J`、`A:A` 等目标范围。 - 当用户请求“宽一点 / 高一点 / 和其他行一样高 / 和其他列一样宽”时,先读取相邻可见行列的当前尺寸,再决定使用精确尺寸、`standard` 或 `auto`,不要随意猜测数值。 @@ -239,19 +228,8 @@ print(df.head(10)) # 必做:横向——确认表头行 + ## 公式策略 -### 优先使用公式,而非硬编码值 - -当可以用飞书表格公式表达计算逻辑时,必须写公式,而不是在 Python 中计算后写入静态结果。这样当源数据变化时,表格仍能自动重算。 -这适用于总计、平均值、增长率、占比、差值等常见计算。 - -### 飞书公式差异与限制 - -飞书表格公式与 Excel 公式基本相同,但需要特别注意以下差异: - -- 公式来自 Excel 或包含数组场景时,先读取 `lark-sheets-formula-translation` 完成改写,再生成公式。 -- 数组公式必须写成 `=ARRAYFORMULA(数组公式)` 语法。 -- 在公式字符串中,数据范围应使用飞书支持的语法,例如 `H:H`、`A2:B5`。禁止使用不符合飞书公式语法的写法,如 `H2:H`、`2:2` 等。 -- 飞书表格不支持部分 Excel / Google 专有函数(如 CUBE 系列、`GOOGLETRANSLATE`、`STOCKHISTORY`、`WEBSERVICE` 等),禁止主动使用;当用户明确要求使用这些函数时,应拒绝并说明飞书不支持。**完整的"飞书不支持函数"清单(含替代方案)见 `lark-sheets-formula-translation` 的「飞书不支持的函数」段——以那里为唯一权威清单。** +- **公式优先于硬编码**(同硬性规则 4):能用飞书公式表达的计算(总计 / 平均 / 增长率 / 占比 / 差值等)必须写公式,而非 Python 算出的静态值——源数据变化时才能自动重算。 +- **Excel 迁移 / 数组语义(ARRAYFORMULA)/ 飞书函数差异 / 不支持函数清单**:一律以 `lark-sheets-formula-translation` 为唯一权威。公式来自 Excel 或含数组(FILTER / MAP / XLOOKUP 等)场景,先读它完成改写再写入;公式字符串里的范围用飞书语法(`H:H`、`A2:B5`,禁止 `H2:H` / `2:2`)。 ### 向下填充与绝对引用 @@ -281,36 +259,26 @@ print(df.head(10)) # 必做:横向——确认表头行 + - 引用错误:验证所有单元格引用是否仍然有效 - 跨工作表引用:使用 `Sheet!A1` 风格引用 - 整行整列语义丢失:用户说“这行 / 这列 / 首行 / 整列”时,不要把操作范围截断为当前读取到的 `A1:U1`、`J1:J41` 等局部范围 -- **重复写入未使用 `--copy-to-range`(高频致命错误)**:整列公式、整列格式、首行样式、向下复制等场景,**必须**用模板单元格 + `--copy-to-range`,**禁止**逐行 `+cells-set`。这是最常见的导致轮次耗尽的错误 -- **重复调用 `+cells-{merge|unmerge}` / `+rows-resize / +cols-resize` 未合并为 `+batch-update`(高频致命错误)**:当需要合并/调整多个区域时,**必须**使用 `+batch-update` 将多个操作合并为单次调用。逐个调用会快速耗尽轮次上限(60R) +- **重复写入未使用 `--copy-to-range`(高频致命错误)**:整列公式、整列格式、首行样式、向下复制等场景,**必须**用模板单元格 + `--copy-to-range`,**禁止**逐行 `+cells-set`。逐行写既慢又易错,且不会自动平移公式引用 +- **重复调用 `+cells-{merge|unmerge}` / `+rows-resize / +cols-resize` 未合并为 `+batch-update`(高频致命错误)**:当需要合并/调整多个区域时,**必须**使用 `+batch-update` 将多个操作合并为单次原子调用。逐个调用慢且非原子(中途失败会留下半成品) - 多步骤请求漏做:若用户要求”先重命名,再新建”,两个动作都必须执行 - **表头定位不精确导致写入全偏(高频致命错误)**:不要假设表头在第 1 行。很多表格有标题行、说明行或空行,实际表头可能在第 2、3 行甚至更后。写入公式或数据前,必须先读取前几行确认表头行号和各列实际含义,再基于确认后的行列号构造写入 range - **参数冗余**:只需修改 10 个单元格时,不要把全表重写一遍;`+cells-set` 的 range 和 cells 应精确覆盖变更区域 - **表头理解路径不对**:要了解表格结构和字段含义时,先用 `+csv-get` 读取前 5-10 行查看表头与字段格式;大表需要全量统计才考虑分批导出后用本地脚本(如 `df.info()` + `df.head()`)分析。不要一行行用 `+cells-get` 逐行读取,也不要依赖 `+cells-search` 去”猜”字段名 - **隐藏行列导致定位偏移**:`+csv-get` 默认 `--skip-hidden=false`(返回完整数据含隐藏行列)。如需只看可见数据,显式设 `--skip-hidden=true`,但注意跳过隐藏行后返回数据的行序号与实际行号不对应 -- **写入前读取范围不充分**:涉及批量写入或修改时,必须先读取足够的数据范围。如果表格有 100 行而只读了 20 行,后续操作会漏掉剩余数据。使用 `+workbook-info` 获取行数后,根据实际行数决定读取范围。注意:了解表头和数据结构只需前几行,但批量操作前需要掌握完整数据 +- **写入前读取范围不充分**:批量写入 / 修改前必须读全数据范围(详见上方工作流第 4 步「写入前重新确认数据边界」),只读前 N 行就写会漏掉表尾 - **`+cells-search` 不是万能的**:用户说”汇总金额”是一个操作动作(求和),不是要搜索”汇总金额”这个文本。只有当确实需要定位某个文本值的位置时才用 `+cells-search` - **跨 sheet 对象**:图表、条件格式、透视表、浮动图片可能分布在多个子表中。操作前先用 `+workbook-info` 掌握全局,不要只看当前子表 - **--copy-to-range 不含行列尺寸**:`--copy-to-range` 复制的是值、公式和样式,不包含行高列宽设置。需要统一行列尺寸时,应另行调用 `lark-sheets-range-operations` 中的 `+rows-resize / +cols-resize` - **写入前先确保行列存在**:`+cells-set` 不会自动扩展表格。如果要写入的 range 超出当前行列范围,必须先用 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` 插入行列 -- **写入后保持原表样式(高频致命错误)**:原表已有边框线、背景色、行高、合并单元格等样式时,写入新数据后**必须**延续相同样式,不要只写值不管格式。具体做法:先用 `+cells-get` 读取源区域的样式(`cell_styles`、`border_styles`),写入时在 cells 中携带相同的样式字段;若源区域有合并单元格,用 `+cells-{merge|unmerge}` 对新区域做相同合并;若源区域有特殊行高,用 `+rows-resize / +cols-resize` 对新区域设置相同行高。详见下方"特殊场景 → 续写/复制已有区块格式" -- **CSV 行号按物理换行计数导致行号全错(高频致命错误)**:`+csv-get` 返回的 CSV 中,被双引号包裹的字段内换行符是**单元格内换行**,不是新行。例如 `"2026年3月2日\n星期一"` 是**一个单元格**,不是两行。计算行号时必须按逻辑记录计数。详见 `lark-sheets-read-data` 中的"CSV 行号计算规则" +- **写入后保持原表样式(高频致命错误)**:原表已有边框 / 背景色 / 行高 / 合并等样式时,写入新数据后**必须**延续,不要只写值不管格式(同硬性规则 12)。完整写法见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」 +- **CSV 行号按物理换行计数导致行号全错(高频致命错误)**:`+csv-get` 的 CSV 里被双引号包裹的字段内换行是**单元格内换行**、不是新行;行号一律按 `[row=N]` 前缀取,不要数 `\n`。详见 `lark-sheets-read-data` 的「CSV 行号计算规则」 ## 特殊场景 ### 续写/复制已有区块格式 -当用户要求"按照已有内容格式继续填充"(如"按前两周格式续写到第 20 周"、"把第一个模块复制 N 遍并改日期")时,必须按以下步骤执行: - -1. **用 `+cells-get` 读取源区块的样式**:`+csv-get` 只返回值,无法获取样式。必须用 `+cells-get` 读取源区块,获取每个单元格的 `cell_styles`(字体、背景色、对齐等)和 `border_styles`(边框) -2. **用 `+sheet-info` 读取布局信息**:获取源区块的行高、列宽、合并单元格信息 -3. **规划写入范围并扩行**:计算目标行数,若超出当前 sheet 边界,先用 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` 插入行 -4. **带样式写入数据**:`+cells-set` 的 cells 中同时携带 `value` + `cell_styles` + `border_styles`。推荐使用"内容与样式分离写入"策略(见 `lark-sheets-write-cells`):先写值,再用模板单元格 + `--copy-to-range` 刷样式 -5. **合并单元格**:对标题行等需要合并的区域,用 `+batch-update` 批量调用 `+cells-{merge|unmerge}` -6. **设置行高**:用 `+batch-update` 批量调用 `+rows-resize / +cols-resize`,统一设置新区域的行高与源区块一致 -7. **回读校验**:用 `+csv-get` 校验值,用 `+cells-get` 抽查样式 - -> **反面案例**:只用 `+csv-get` 读值 → 只传 `{"value": ...}` 写入 → 新区块没有边框、没有合并、没有行高,与源区块视觉不一致。这是本场景最常见的错误。 +续写 / 复制已有区块("按前两周格式续写到第 20 周"、"把第一个模块复制 N 遍并改日期")的**完整写法见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」与「内容与样式分离写入」**;样式标准(斑马纹 / 配色 / 边框层级)见 `lark-sheets-visual-standards` 场景二。核心(同硬性规则 12):用 `+cells-get` 读源区块 `cell_styles` + `border_styles`、`+sheet-info --include row_heights,merges` 读行高 / 合并,写入时带齐样式,再用 `+batch-update` 复制合并与行高,最后回读校验。**禁止**只用 `+csv-get` 读值后只写 `{"value": ...}`——否则新区块缺边框 / 合并 / 行高,与源区块视觉不一致(本场景最常见错误)。 ### NLP 任务处理 @@ -329,8 +297,8 @@ print(df.head(10)) # 必做:横向——确认表头行 + - NLP 处理本身不应退化为纯规则代码;但可以使用代码做分批、行号映射、结果拼装和写回。 - 数据量大时**必须**分批处理,通常 30 行一批。每批处理完后立即写回表格,不要等全部处理完再一次性写入。 -- 为避免超时,NLP 处理通常不超过 300 行;超过时根据任务性质选择抽样或分批执行,并向用户明确处理范围。 -- 翻译、信息提取等任务,优先使用 `+batch-update` 将多批写入合并,减少调用轮次。 +- 为控制单次生成量,NLP 处理通常单批不超过 300 行;超过时根据任务性质选择抽样或分批执行,并向用户明确处理范围。 +- 翻译、信息提取等任务,优先使用 `+batch-update` 将多批写入合并为原子提交。 ### 格式处理优先公式 diff --git a/skills/lark-sheets/references/lark-sheets-filter-view.md b/skills/lark-sheets/references/lark-sheets-filter-view.md index a5cfeb1d7..c2a93331b 100644 --- a/skills/lark-sheets/references/lark-sheets-filter-view.md +++ b/skills/lark-sheets/references/lark-sheets-filter-view.md @@ -117,7 +117,7 @@ lark-cli sheets +filter-view-create --url "..." --sheet-id "$SID" \ ### `+filter-view-delete` -> ⚠️ 视图删除不可逆;视图不存在按幂等成功处理。先 `--dry-run` 看 view_id 确认。 +> ⚠️ 删除**已存在**的视图不可逆;目标 view_id **不存在**时按幂等成功返回(不报错)。先 `--dry-run` 看 view_id 确认。 ### Validate / DryRun / Execute 约束 diff --git a/skills/lark-sheets/references/lark-sheets-filter.md b/skills/lark-sheets/references/lark-sheets-filter.md index 19e612cb8..bd3aee05d 100644 --- a/skills/lark-sheets/references/lark-sheets-filter.md +++ b/skills/lark-sheets/references/lark-sheets-filter.md @@ -3,7 +3,7 @@ ## 真对象硬约束 + 数量校验 1. **真对象**:当用户要求"筛选 / 只看 / 仅保留 X"时,**必须**通过 `+filter-{create|update|delete}` 创建真实的筛选器对象。**禁止**用"删除不符合条件的行" / "新建子表只放符合条件的行" / 用 `+cells-set` 覆盖原表来代替——这些做法会让原数据丢失或不可恢复。 -2. **筛选数量必校**:执行筛选后**必须**回读,断言 `len(visible_rows) == expected_count`。`expected_count` 来自先用 本地脚本 在源数据上独立复现该筛选条件得到的结果数。两者不一致时禁止交付,需排查筛选条件 / 数据列类型问题。 +2. **筛选数量必校**:执行筛选后**必须**回读,断言 `len(visible_rows) == expected_count`。`expected_count` 来自先用本地脚本在源数据上独立复现该筛选条件得到的结果数。两者不一致时禁止交付,需排查筛选条件 / 数据列类型问题。 3. **混合文本列禁止字面比较**:筛选 key 是公式文本(如 `1000+200=1200`)或带单位的混合文本时,先在辅助列里抽出纯数值再筛选;不能直接用文本比较。 ## 使用场景 @@ -51,7 +51,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--range` | string | required | 筛选范围(A1 表示法,含表头行,如 `A1:F1000`);不要重复写入 `--properties` 中的 range 字段 | -| `--properties` | string + File + Stdin(复合 JSON) | optional | 筛选规则 JSON,含 `rules`(列级筛选规则数组,必填)和 `filtered_columns?`(激活列索引提示)。`range` 是独立 flag(不要再放此 JSON 里) | +| `--properties` | string + File + Stdin(复合 JSON) | optional | 筛选规则 JSON:`rules`(列级筛选规则数组)+ `filtered_columns?`(激活列索引提示)。`--properties` 整体可选——传它时 `rules` 不可为空;不传则只在 `--range` 上建立空筛选器(无列条件)。`range` 是独立 flag(不要再放此 JSON 里) | ### `+filter-update` diff --git a/skills/lark-sheets/references/lark-sheets-float-image.md b/skills/lark-sheets/references/lark-sheets-float-image.md index e358532bf..2475f9873 100644 --- a/skills/lark-sheets/references/lark-sheets-float-image.md +++ b/skills/lark-sheets/references/lark-sheets-float-image.md @@ -24,13 +24,8 @@ - **图片位置参数要精确**:锚点单元格的行列索引和偏移量决定了图片位置,设置不当会导致图片遮挡数据 - **创建后必须验证**:调用 `+float-image-list` 确认图片位置和大小正确 -reference_id 的映射规则: -- `image_uri`:`<|image|>:abcdef` 或者 `<|superscript|>:abcdef-<|image|>:abcdef` -- `float_image_id`:`<|float_image|>:abcdef` -其中 `abcdef` 为实际的对象 ID,占位符仅用于示意,不可直接使用。 - `image_uri` 与 `image_token` 是「指定图片资源」的两种等价方式(与 `+cells-set` 中 `embed-image` 的语义一致): -- `image_uri`:上传链路给到的图片 reference_id(如 `<|image|>:abcdef`),由系统自动转 fileToken +- `image_uri`:图片上传链路返回的 reference_id,由系统自动转 fileToken - `image_token`:图片 fileToken,常见来源是 `+float-image-list` 返回的 `image_token`(适合"换皮不换位置"等基于已有图片的复用场景) - create 时二者必须有其一;update 时**仅在需要替换图片本身时**传入新的 `image_uri` 或 `image_token`,不传则保留原图。 @@ -61,7 +56,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` 二选一);形如 `<\|image\|>:abcdef` 这种带前缀的字符串 | +| `--image-uri` | string | xor | 图片 reference_id(与 `--image-token` 二选一);图片上传链路返回的 reference_id | | `--position-row` | int | required | 图片左上角所在行(0-based) | | `--position-col` | string | required | 图片左上角所在列(列字母,如 `A` / `B`) | | `--size-width` | int | required | 图片宽度(像素) | @@ -79,7 +74,7 @@ _公共四件套 · 系统:`--dry-run`_ | `--float-image-id` | string | required | 目标图片 id | | `--image-name` | string | optional | 图片名称,含扩展名(如 `logo.png`) | | `--image-token` | string | xor | 图片 file_token(与 `--image-uri` 二选一)。常见来源:`+float-image-list` 返回的 `image_token` | -| `--image-uri` | string | xor | 图片 reference_id(与 `--image-token` 二选一);形如 `<\|image\|>:abcdef` 这种带前缀的字符串 | +| `--image-uri` | string | xor | 图片 reference_id(与 `--image-token` 二选一);图片上传链路返回的 reference_id | | `--position-row` | int | optional | 图片左上角所在行(0-based) | | `--position-col` | string | optional | 图片左上角所在列(列字母,如 `A` / `B`) | | `--size-width` | int | optional | 图片宽度(像素) | @@ -116,9 +111,9 @@ 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(部分租户直接引用) +# 用 reference_id(图片上传链路返回的 image reference_id;与 --image-token 二选一) lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \ - --image-name "logo.png" --image-uri "<|image|>:abcdef" \ + --image-name "logo.png" --image-uri "$IMAGE_URI" \ --position-row 2 --position-col B --size-width 300 --size-height 200 --z-index 1 ``` @@ -141,7 +136,7 @@ lark-cli sheets +float-image-update --url "..." --sheet-id "$SID" \ ### `+float-image-delete` ```bash -lark-cli sheets +float-image-delete --url "..." --float-image-id "$IMG_ID" --yes +lark-cli sheets +float-image-delete --url "..." --sheet-id "$SID" --float-image-id "$IMG_ID" --yes ``` ### Validate / DryRun / Execute 约束 diff --git a/skills/lark-sheets/references/lark-sheets-formula-translation.md b/skills/lark-sheets/references/lark-sheets-formula-translation.md index 50fe849a7..3c2249bfe 100644 --- a/skills/lark-sheets/references/lark-sheets-formula-translation.md +++ b/skills/lark-sheets/references/lark-sheets-formula-translation.md @@ -4,7 +4,7 @@ ## 翻译后必做:代码复现校验 -公式语法翻译完之后,**必须**用 本地脚本 在源数据上独立复现一份"等价计算结果"再写入。流程: +公式语法翻译完之后,**必须**用本地脚本在源数据上独立复现一份"等价计算结果"再写入。流程: 1. **挑 3-5 个代表性输入行**(首行 / 中段 / 末行 / 含空值 / 含异常格式各一) 2. **用 Python 复现 Excel 原公式的语义**(不是飞书译文的语义,而是用户原本想要的结果) diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md index 3f59214be..dff2e3dfa 100644 --- a/skills/lark-sheets/references/lark-sheets-pivot-table.md +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -121,14 +121,26 @@ lark-cli sheets +pivot-list --url "..." --sheet-id "$SID" > 数据源 `--source` 必须从表头行开始;空行 / 汇总行会被当作数据参与聚合,需提前用 `+csv-get` 确认起止边界。`--source` 和 `--range` 是独立 flag(不要再放 `--properties`);`rows` / `columns` / `values` 等数组字段走 `--properties`。 > -> **落点 flag 三选一的决策(避免冲突)**: -> - **默认(推荐)**:`--target-sheet-id` / `--target-position` / `--range` 都不传 → 自动新建子表存放透视表产物。 -> - **放进指定的已有子表**:传 `--target-sheet-id <落点子表 id>` + 可选 `--target-position <子表内起点 cell,默认 A1>`。 -> - **`--range`** 只在不指定落点子表、想精确指定左上角 cell(映射到 `properties.range`)时用;与 `--target-position` 表达同一意图但落不同 wire 字段,**两者不要同时给**。一般用前两种即可,无需 `--range`。 +> **先理清 `+pivot-create` 上 5 个位置类入参(语义不同,别混)**: +> - 公共 `--sheet-id` / `--sheet-name`(**必填**,公共四件套):定位**操作所在工作表**(数据源 sheet 的上下文)。 +> - `--source`(**必填**):**源数据**区域,须自带 `Sheet!` 前缀(如 `Sheet1!A1:D100`)。 +> - `--target-sheet-id` / `--target-position` / `--range`:**产物落点**,按下面 3 种策略二选其一。 +> +> **落点 3 种策略(互斥,选其一)**: +> 1. **默认(强烈推荐)**:`--target-sheet-id` / `--target-position` / `--range` **都不传** → 服务端**自动新建子表**存放产物,绝不碰源数据。 +> 2. **放进指定的已有子表**:`--target-sheet-id <落点子表 id>`,可选 `--target-position <子表内起点 cell,默认 A1>`(这两个是**配套**使用,不是互斥)。 +> 3. **`--range`**:仅在不指定落点子表、想精确指定左上角 cell(映射到 `properties.range`)时用。⚠️ **`--range` 把产物落在公共 `--sheet-id` 指向的那张 sheet 上**——若 `--sheet-id` 就是源数据 sheet,产物会盖在源数据旁/上、**可能覆盖数据**。它与 `--target-position` 表达同一意图但落不同 wire 字段,**两者不要同时给**。 +> +> 一般用策略 1(默认新建子表)即可,无需 `--range`/`--target-*`。 ```bash -lark-cli sheets +pivot-create --url "..." --sheet-id "$SRC_SID" \ - --source "Sheet1!A1:D100" --range "F1" --properties @pivot.json +# 策略 1(推荐):只给必填的公共 sheet 定位 + 源数据,不给落点 flag → 自动新建子表,零覆盖风险 +lark-cli sheets +pivot-create --url "..." --sheet-id "$SID" \ + --source "Sheet1!A1:D100" --properties @pivot.json + +# 策略 2:落进指定的已有目标子表(不会碰源数据) +lark-cli sheets +pivot-create --url "..." --sheet-id "$SID" \ + --source "Sheet1!A1:D100" --target-sheet-id "$DEST_SID" --target-position "A1" --properties @pivot.json ``` ### `+pivot-update` @@ -138,7 +150,7 @@ lark-cli sheets +pivot-create --url "..." --sheet-id "$SRC_SID" \ ### `+pivot-delete` ```bash -lark-cli sheets +pivot-delete --url "..." --pivot-table-id "$PID" --yes +lark-cli sheets +pivot-delete --url "..." --sheet-id "$SID" --pivot-table-id "$PID" --yes ``` ### Validate / DryRun / Execute 约束 diff --git a/skills/lark-sheets/references/lark-sheets-range-operations.md b/skills/lark-sheets/references/lark-sheets-range-operations.md index 77527029b..87c26b39d 100644 --- a/skills/lark-sheets/references/lark-sheets-range-operations.md +++ b/skills/lark-sheets/references/lark-sheets-range-operations.md @@ -4,14 +4,14 @@ `+cells-clear`、`+cells-{merge|unmerge}`、`+range-{move|copy|fill|sort}`(移动 / 复制 / 排序 / 自动填充)都会让既有引用关系发生偏移或失效。**操作前必须**先确认以下两点;否则禁止执行: -1. **打印当前合并单元格 + 公式引用 + 数据验证范围**:用 `+sheet-info` + `+cells-get` 抽样目标区域和它周边的公式 / 透视表 / 图表 / 条件格式 / 筛选器的数据源;评估操作后这些引用是否仍指向正确数据。 +1. **打印当前合并单元格 + 公式引用 + 数据验证范围**:用 `+sheet-info --include merges` + `+cells-get` 抽样目标区域和它周边的公式 / 透视表 / 图表 / 条件格式 / 筛选器的数据源;评估操作后这些引用是否仍指向正确数据。 2. **`+cells-clear` 不得侵入用户授权范围之外**:清除范围只能是用户明示要清的区域;不要顺手清除"看起来没用"的相邻单元格。 排序场景的存储类型识别 + 辅助列抽数值的细则见下方「sort 操作前必读」章节。 ## 使用场景 -写入。对指定区域执行结构性操作。本 Skill 包含四个工具: +写入。对指定区域执行结构性操作。本 reference 覆盖 9 个 shortcut,按 4 类用途组织: | 操作需求 | 使用工具 | 说明 | |---------|---------|------| @@ -51,7 +51,7 @@ **⚠️ 批量操作必须用 `+batch-update`**: -当需要对**多个**不同区域执行 `+cells-{merge|unmerge}`(merge)或 `+rows-resize / +cols-resize` 时,**禁止逐个调用**,必须使用 `+batch-update`(参见 `lark-sheets-batch-update`)将所有操作合并为一次请求。逐个调用会快速耗尽工具调用轮次上限。 +当需要对**多个**不同区域执行 `+cells-{merge|unmerge}`(merge)或 `+rows-resize / +cols-resize` 时,**禁止逐个调用**,必须使用 `+batch-update`(参见 `lark-sheets-batch-update`)将所有操作合并为一次原子请求。逐个调用慢且非原子。 **例外**:`+cells-unmerge` 原生支持对覆盖多个合并区域的大 range 一次性取消,应直接单次调用,**不要**拆进 `+batch-update`。 diff --git a/skills/lark-sheets/references/lark-sheets-read-data.md b/skills/lark-sheets/references/lark-sheets-read-data.md index 71a754e12..e7cb0a5e4 100644 --- a/skills/lark-sheets/references/lark-sheets-read-data.md +++ b/skills/lark-sheets/references/lark-sheets-read-data.md @@ -124,8 +124,8 @@ _公共四件套 · 系统:`--dry-run`_ 示例: ```bash -# 简单读 -lark-cli sheets +csv-get --url "https://example.feishu.cn/sheets/shtXXX" --range "Sheet1!A1:F30" +# 简单读(sheet 定位必填:--sheet-name 或 --sheet-id 必给一个;range 的 Sheet1! 前缀不能替代它) +lark-cli sheets +csv-get --url "https://example.feishu.cn/sheets/shtXXX" --sheet-name "Sheet1" --range "A1:F30" # 用 sheet-name 模糊定位(运行时框架会先解析到 sheet-id) lark-cli sheets +csv-get --spreadsheet-token shtXXX --sheet-name "销售明细" --range "A1:F30" @@ -143,9 +143,9 @@ lark-cli sheets +csv-get --spreadsheet-token shtXXX --sheet-name "销售明细" 示例: ```bash -# 读 A1:F10 的公式 + 样式 -lark-cli sheets +cells-get --url "https://example.feishu.cn/sheets/shtXXX" \ - --range "Sheet1!A1:F10" --include formula,style +# 读 A1:F10 的公式 + 样式(sheet 定位必填) +lark-cli sheets +cells-get --url "https://example.feishu.cn/sheets/shtXXX" --sheet-name "Sheet1" \ + --range "A1:F10" --include formula,style ``` > ⚠️ 调用方在 `cells[i][j]` 中**不能**用下标推真实行列:必须读 `ranges[n].row_indices[i]` / `ranges[n].col_indices[j]`。 diff --git a/skills/lark-sheets/references/lark-sheets-visual-standards.md b/skills/lark-sheets/references/lark-sheets-visual-standards.md index df00c7679..03f59c586 100644 --- a/skills/lark-sheets/references/lark-sheets-visual-standards.md +++ b/skills/lark-sheets/references/lark-sheets-visual-standards.md @@ -127,15 +127,14 @@ #### 2A. 继续补充行/列(数据性质与已有内容一致) -**核心规则**:采样紧邻 2 行样式 → 延续 Zebra Stripes 奇偶性 → 写入时原样应用 `cell_styles` + `border_styles`。 +**核心规则**:采样紧邻 2 行 → 判断并延续 Zebra Stripes 奇偶性 → 按 write-cells 的继承清单带齐样式写入。 -**关键样式要点**: +**斑马纹延续要点**(本节只管"奇偶判断"这一标准,"带哪些样式字段写入"的机制见下方指针): - 至少读 2 行(末行 + 倒数第二行)才能判断是否有斑马纹交替色 - 若倒数两行背景色不同(如 #FFFFFF 与 #F3F4F6),新行按奇偶延续,不要固定一个色 -- `border_styles` 最易遗漏(四边都要复制),否则新行会缺框线 -> 具体采样调用(用 `+cells-get` 读源行 `cell_styles` + `border_styles`、`+sheet-info` 读行高合并)见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」章节。 +> 具体继承哪些字段、怎么采样与写入(`+cells-get` 读源行 `cell_styles` + `border_styles`、`+sheet-info --include row_heights,merges` 读行高合并、带齐 6 类样式写入)见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」章节——`border_styles` 四边最易遗漏,以那里为准。 #### 2B. 基于模板区域的修改(copy 保留所有格式) diff --git a/skills/lark-sheets/references/lark-sheets-workbook.md b/skills/lark-sheets/references/lark-sheets-workbook.md index a5ca2007e..3406212cc 100644 --- a/skills/lark-sheets/references/lark-sheets-workbook.md +++ b/skills/lark-sheets/references/lark-sheets-workbook.md @@ -133,7 +133,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--file-extension` | string | optional | 导出文件格式;`csv` 模式必须配 `--sheet-id`(可选值:`xlsx` / `csv`)(默认 `xlsx`) | -| `--sheet-id` | string | optional | 仅 csv 模式必填:指定要导出的 sheet reference_id | +| `--sheet-id` | string | optional | 仅 csv 模式必填:指定要导出哪张 sheet 为 CSV。这是 `+workbook-export` 专有 flag,与公共四件套的 sheet 定位无关(本 shortcut 不接受公共 sheet 定位) | | `--output-path` | string | optional | 本地保存路径;省略时只触发导出不下载 | ## Examples diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 935932f65..567a2de66 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -4,7 +4,7 @@ 1. **明确写入边界**:写入前必须能回答"目标 range 的起止行列号是多少?是否落在用户授权范围内?"。除用户明示要修改的区域外,禁止扩张到原数据列以外或新建 Sheet。 2. **完整性断言**:批量写入前先把"预期写入条数"硬编码到代码里(如要填 106 条翻译 → `expected = 106`),写完后回读断言 `actual == expected`。少于预期就继续写,禁止交付半成品。 -3. **回读抽样校验**:写完关键值 / 公式后,用 `+csv-get` 或 `+cells-get` 重新读取写入区域,至少抽样 3-5 个代表性单元格(首 / 中 / 末),核对值与预期一致(与 本地脚本 计算的预期值对照)。公式特定的"先验证模板再 --copy-to-range / 修完再读回"细则见下方相关章节。 +3. **回读抽样校验**:写完关键值 / 公式后,用 `+csv-get` 或 `+cells-get` 重新读取写入区域,至少抽样 3-5 个代表性单元格(首 / 中 / 末),核对值与预期一致(与本地脚本计算的预期值对照)。公式特定的"先验证模板再 --copy-to-range / 修完再读回"细则见下方相关章节。 ## 新增列 / 新增行的样式继承(防止视觉风格不一致) @@ -58,7 +58,7 @@ - 用户说”这列 / 整列 / 这行 / 首行 / 向下复制”时,**必须**使用模板单元格 + `--copy-to-range` - 多区域写入相同格式/公式结构时,优先写一个模板,再用 `--copy-to-range` 复制到所有目标区域 -⚠️ **逐行写入公式是最常见的致命错误**:对每一行单独调用 `+cells-set` 写入公式(如调用 26 次),会快速耗尽轮次上限导致操作不完整。正确做法是 1 次模板写入 + 1 次 `--copy-to-range` = 2 次调用完成。 +⚠️ **逐行写入公式是常见低效写法**:对每一行单独调用 `+cells-set` 写公式(如 26 次)既慢又易错,且不会自动平移公式引用。正确做法是 1 次模板写入 + 1 次 `--copy-to-range`(公式引用自动平移)。 💡 **写入公式前先按迁移规则改写**:如果公式来自 Excel 或包含数组场景,先读取并遵循 `lark-sheets-formula-translation` 的规则完成改写,再把最终公式写入 `formula` 字段。 @@ -73,7 +73,7 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl ``` 这比在 99 个单元格中都重复写样式 JSON 高效得多。 -💡 **大批量数据分批写入(推荐)**:当需要写入大量行(如几十行以上)时,不要试图在一次调用中生成全部 `cells` 数据——`cells` 数组过大会导致模型生成内容过长而超时。应将数据拆分为多批,每批 20-50 行,分多次调用 `+cells-set` 逐批生成并写入(如先写 `A2:D21`,再写 `A22:D41`,依此类推)。每次调用只需生成当前批次的数据,控制单次生成量,避免超时。 +💡 **大批量数据分批写入(推荐)**:当需要写入大量行(如几十行以上)时,不要试图在一次调用中生成全部 `cells` 数据——`cells` 数组过大会让单次生成的内容过长,容易出错或被截断。应将数据拆分为多批,每批 20-50 行,分多次调用 `+cells-set` 逐批生成并写入(如先写 `A2:D21`,再写 `A22:D41`,依此类推)。每次只生成当前批次的数据,控制单次生成量。 注意: @@ -93,7 +93,7 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl 3. **`--copy-to-range` 扩展前先验证模板**:模板单元格公式自己都算错,`--copy-to-range` 复制到 100 行就是 100 个错误 4. **去重 / 筛选函数**:飞书**支持** `UNIQUE` / `FILTER` / `SPLIT`(原生数组函数,详见 `lark-sheets-formula-translation`),可直接用;`DISTINCT` 不是飞书函数,去重用 `UNIQUE`。大数据量去重 / 分组也可用透视表(`+pivot-{create|update|delete}`,值字段聚合方式选 count) 5. **循环引用预检(高频致命错误)**:写聚合公式(SUM / AVERAGE / COUNT 等)前必须明确**引用范围不包含目标单元格自身或其传递依赖**。典型反例:在 C3 写 `=SUMIF(B:B,LEFT(B3,9)&"*",C:C)`,B 列匹配 B3 前 9 位时 C3 自己也命中,导致 C3 自引用 → `~CIRCULAR~REF~`。修法:用辅助列 / 显式排除自身(`SUMIFS(C:C, B:B, ..., A:A, "<>"&A3)`)/ 缩小范围避开自己 -6. **REGEX 模式覆盖率验证**:公式里的 `REGEXEXTRACT` / `REGEXMATCH` / `REGEXREPLACE` 等正则模式落地前必须用 本地脚本 在源列上跑一遍命中率统计(`df[col].str.contains(pattern).mean()`);命中率 < 100% 时必须扩展 pattern 或加多分支(IFS / 多个 IFERROR 串联)兜底,**禁止**只覆盖样本前 N 行就交付(典型反例:用 `REGEXEXTRACT(D5,"长(\d+)")` 只匹配带"长"前缀的尺寸文本,对"宽×高"、"×"、"*"等其它分隔符直接漏匹配) +6. **REGEX 模式覆盖率验证**:公式里的 `REGEXEXTRACT` / `REGEXMATCH` / `REGEXREPLACE` 等正则模式落地前必须用本地脚本在源列上跑一遍命中率统计(`df[col].str.contains(pattern).mean()`);命中率 < 100% 时必须扩展 pattern 或加多分支(IFS / 多个 IFERROR 串联)兜底,**禁止**只覆盖样本前 N 行就交付(典型反例:用 `REGEXEXTRACT(D5,"长(\d+)")` 只匹配带"长"前缀的尺寸文本,对"宽×高"、"×"、"*"等其它分隔符直接漏匹配) 7. **公式范围与用户指令字面对齐**:用户说"对 F 至 L 列求和"就必须写 `SUM(F2:L2)` 或 `F2+G2+H2+I2+J2+K2+L2`,**不能漏列、多列、错列**。写完用 `+cells-get` 拿回 `formula` 字符串,与用户原话逐字对照(参与求和的列名一致 / 起止列号一致 / 运算符一致),不一致就是违规 ⚠️ **收到 `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)`),必须: From 81bb61359d4b25e8f3e84d166be1e1707cea9f2b Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Sun, 24 May 2026 14:44:17 +0800 Subject: [PATCH 039/114] docs(sheets): sync lark-sheets skill spec from upstream Trim and refine reference docs from upstream sheet-skill-spec (condense core-operations workflow, tidy write-cells / range-operations / float-image / SKILL guidance). Description-only; no flag or CLI behavior change. --- skills/lark-sheets/SKILL.md | 17 +- .../references/lark-sheets-core-operations.md | 332 +++--------------- .../references/lark-sheets-float-image.md | 12 +- .../lark-sheets-range-operations.md | 8 +- .../references/lark-sheets-write-cells.md | 36 +- 5 files changed, 87 insertions(+), 318 deletions(-) diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index 55ffe244f..8aad462a2 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -21,7 +21,16 @@ metadata: | --- | --- | --- | | 工作表(sheet) | 子表、tab、标签页 | spreadsheet 内的单张表;`sheet_id` 是其稳定标识 | | 电子表格(spreadsheet) | 工作簿、表格 | 顶层容器;由 `--url` 或 `--spreadsheet-token` 定位 | -| reference_id | id | 各 `*-id` flag(`--sheet-id` / `--chart-id` / `--pivot-table-id` / `--group-id` / `--view-id` 等)接受的对象标识符,统称 reference_id | +| reference_id | id | **表内对象**的稳定标识,即各对象主键 flag 接受的值(见下表)。⚠️ 与 `lark-sheets-float-image` 的 `--image-uri`(图片上传句柄)不是一回事,后者不属于 reference_id | + +每类对象用各自的主键 flag 定位(命名不统一,按此表对照,不要凭直觉拼): + +| 对象 | 主键 flag | 对象 | 主键 flag | +| --- | --- | --- | --- | +| 工作表 sheet | `--sheet-id` | 条件格式规则 | `--rule-id` | +| 图表 chart | `--chart-id` | 筛选视图 | `--view-id` | +| 透视表 pivot | `--pivot-table-id` | 迷你图(按组) | `--group-id` | +| 浮动图片 | `--float-image-id` | | | ## References @@ -61,6 +70,7 @@ metadata: **公共四件套** = `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`,分成两组 XOR,**每组都必须给且只能给一个**(XOR = 二选一必填,不是"可选"): 1. **spreadsheet 定位(必填)**:`--url` 与 `--spreadsheet-token` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --url or --spreadsheet-token`;两个都给 → 互斥冲突。 + - **例外**:`+workbook-create` 是新建一个还不存在的表格,**不接受任何 spreadsheet / sheet 定位 flag**(只有 `--title` / `--folder-token` / `--headers` / `--values`)。 2. **sheet 定位(公共四件套 shortcut 必填)**:`--sheet-id` 与 `--sheet-name` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --sheet-id or --sheet-name`。 - ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:即使写了 `--range "Sheet1!A1:B2"`,仍**必须**额外传 `--sheet-id` 或 `--sheet-name`,否则照样报上面的错。 - **例外**:徽章标为 `_公共:URL/token(无 sheet 定位)…_` 的 shortcut(如 `+workbook-info` / `+workbook-export` / `+batch-update` / `+dropdown-get|update|delete` / `+cells-batch-set-style` / `+cells-batch-clear` / `+sheet-create`)**不接受也不需要** sheet 定位,只给一组 spreadsheet 定位即可。 @@ -92,6 +102,11 @@ lark-cli sheets <其它 flag> **Agent 使用提示**:写复合 JSON flag(`--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys` / `--options` 等)时,如果对结构不确定,先跑 `lark-cli sheets --print-schema --flag-name ` 把完整 JSON Schema 读出来再构造 payload,比靠 reference 的速查表更精确,也避免因为字段拼写或缺失被服务端拒绝。reference 的 `## Schemas` 段只给一层结构,深层只能靠 `--print-schema` 或 `## Examples` 的真实示例。 +### flag 内容类型与输出约定(术语速记) + +- flag 表里 JSON 类入参标三类:**复合 JSON** = 深层嵌套对象(用 `--print-schema` 取完整结构);**简单 JSON** = 一维 / 二维标量数组(如 `["sheet1!A1:B2",...]` / `[["alice",95]]`,结构简单无需 print-schema);**非 JSON 文本** = 原样文本(如 CSV)。`--print-schema` 只对**复合 JSON** flag 有效(同一 shortcut 的简单 JSON flag 如 `--colors` 不在此列)。 +- **envelope**:所有 shortcut 返回统一外层结构 `{ok, identity, data, ...}`。正文里 `envelope.data` 指业务数据层(如 `+csv-get` 的 `annotated_csv`);`envelope.meta.verification` 指写操作执行后 CLI 自动回读、给出的"预期 vs 实际"对比。 + ## 复合 JSON / 大入参:优先 stdin flag 帮助里标注支持 **Stdin** 的入参,当 payload 较大、含换行 / 引号等特殊字符,或已经落在某个文件里时,优先用 stdin(`-`)传入,避免命令行超长与 shell 转义问题。 diff --git a/skills/lark-sheets/references/lark-sheets-core-operations.md b/skills/lark-sheets/references/lark-sheets-core-operations.md index 68902e234..0209724e9 100644 --- a/skills/lark-sheets/references/lark-sheets-core-operations.md +++ b/skills/lark-sheets/references/lark-sheets-core-operations.md @@ -2,304 +2,86 @@ ## 概览 -这是面向"已有飞书表格"的核心工作流。核心原则是:先了解,再分析或写入,最后验证。 +面向"已有飞书表格"的核心工作流,核心原则:**先了解,再分析或写入,最后验证**。本文是方法论总纲;具体工具的参数细节、边界陷阱在对应子 skill,本文用指针引到那里,不重复展开。 -## 编辑前必读:7 条默认规则 +## 铁律(所有编辑类任务必须满足,子 skill 不得放宽) -> 所有编辑类任务(修改 / 排序 / 筛选 / 删除 / 透视 / 批量填充)**必须**先满足以下 7 条,再进入下方「硬性规则」和具体子 skill。任何子 skill 不得放宽这 7 条。 - -1. **R1 最小改动**:除用户明示要修改的单元格 / 列外,原表其它单元格、行列结构、Sheet 名称、合并区域、格式必须 1:1 保持。中间结果 / 标注列优先放在原数据列**右侧**;当中间结果会与原数据混淆,或需要承载结构化对象(透视表 / 图表)时可**新建空白 Sheet**。**禁止**擅自删除 / 重命名 / 隐藏 / 移动**已存在**的原 Sheet(新建是允许的,节制使用即可)。 -2. **R2 真实写回**:编辑任务的最终交付必须是对在线表格的真实写入并回读校验。**严禁**只在文本里描述"已完成 X" 没有任何写入;**严禁**用普通公式 / 文本汇总假装"透视表 / 筛选 / 图表 / 条件格式 / 迷你图"等结构化对象;**严禁**只给出引用 / 占位而无真实写入。必须有对应的 `+cells-set` / `+<对象>-{create|update|delete}`(图表 / 透视表 / 条件格式 / 筛选 / 迷你图 / 浮动图片)工具调用,并能用 `+<对象>-list`(或 `+csv-get` / `+cells-get`)回读到改动结果。 -3. **R3 计算复现**:涉及计算、排序、筛选、聚合、批量数据提取的任务,必须用本地脚本独立复现一份预期值,与回读结果对照通过后再交付。设计公式 / 筛选条件前先 sample 至少 50 行识别该列所有值类型变体(纯数值 / 公式文本 / 多种日期格式 / 空值),不能只看前 10 行。 -4. **R4 处理完整性**:全量逐条处理类任务(翻译 / 打标 / 删除指定行 / 批量公式落地 / 按条件保留),落地前先把"预期处理条数"硬编码进代码,处理完后 `assert actual == expected`。**严禁**输出"已完成前 N 条,剩余将继续"这类半成品文案。 -5. **R5 指令语义还原**:"按 X 排序" / "筛选 X" / "把 X 删除" 落地前先回答:① X 在哪一列?该列实际值类型是什么?② 期望结果集大小是多少?答完再动手;禁止直接对混合文本列做字符串排序 / 筛选。 -6. **R6 任务拆解为可验证 checklist**:用户的指令落地前,**必须**先拆成所有"独立可验证子要点",每点对应一个 `assert`,全部通过才交付: - - **多维度操作**("按部门排序"含一/二/三级)→ 每维度一个 assert - - **多目标操作**("删除 N 条 / N 行")→ 每目标一个 assert - - **多格式兼容**(日期 YYYYMM / YYYY-MM-DD / 时间戳)→ 每种格式至少一个样本通过 - - **范围类操作**("加边框 A1:H11"、"覆盖第 2~218 行")→ 起始 / 末行 / 末列三个边界都要核 - - **单一指令隐含多个失败模式**(如"用公式算面积"隐含"提取数值 / 乘以数量 / 单位转换 / 公式而非硬编码"等多个验收点)→ 每点独立 assert - 只完成第一个要点就交付(典型如:按部门只排一级、删 3 行只删 1 行、兼容日期只兼容 YYYYMM)属于违规。 -7. **R7 公式模式延续**:扩展 / 续写 / 新增行列时,**必须**先用 `+cells-get` 读原数据区域的 `formula` 字段,识别公式模式,新行新列必须延续相同模式。**禁止**把原表 `=C3/B3` / `=SUM(B3:B9)` 模式在新行替换为硬编码常数(如 `0.85` / `50`)这种破坏数据驱动性的写法。**用户口头操作("分列 / 排序 / 提取 / 求和")也必须落地为公式或原生工具**(SORT 公式 / 分列 / `TEXTBEFORE` / `MID` / `+filter-{create|update|delete}` / `+pivot-{create|update|delete}` 等),不能只写静态结果——否则用户改源数据时结果不再联动,等同破坏了表格的数据驱动性。 - -## 硬性规则 - -1. **先读 skill 再调工具,但要高效读取**:每个工具的参数约束、边界条件和常见陷阱都记录在对应的子 skill 中。跳过 skill 直接调工具,容易传错参数或遗漏关键步骤。**但必须控制读取 reference 的次数**: - - 在开始操作前,先规划本次任务需要哪些工具,一次性列出要读取的 skill 清单,而不是用一个读一个 - - 如果本轮对话中已经读取过某个 skill,不要重复读取 - - 本 skill(`lark-sheets-core-operations`)+ `lark-sheets-workbook` 是几乎每次都需要的基础 skill,读完后应立即进入操作,不要在读取阶段停留过久 - -2. **先了解结构再操作**:飞书表格的行列数、冻结位置、合并区域等信息不可猜测,猜错会导致写入越界或覆盖数据。操作前先调用 `+workbook-info` 获取子表概览;然后根据任务类型选择读取方式(三个读取工具均在 `lark-sheets-read-data` 中): -- **批量填充/补齐/完善/修正多行**类任务 → **必须先用 `+csv-get` 翻页读全(关注 `has_more` / `current_region`),或导出到本地用 `pandas` 等本地脚本确定真实数据行数**(路径 A)。**禁止**对这类任务直接用 `+csv-get` 探 10 行就进入写入——实测会漏写表尾多行(高频致命错误)。 - - 数据分析/清洗/可视化/大数据集 → 同上路径 A - - 快速查看少量数据或简单问答(只读、不回写) → `+csv-get` 读取到对话上下文 - - 需要公式/样式/批注 → `+cells-get` - - **续写/扩展已有内容** → 必须用 `+cells-get` 读取源区块样式 + `+sheet-info --include row_heights,merges` 读取行高和合并信息(见硬性规则 12) - - **读取前 10 行后按表格形态分流(路径 B/C 批量写入前强制)**:表格形态决定第二步读不读、读什么。先用 `range: "A1:Z10"` 探查,然后根据返回的 `annotated_csv` 分类: - - - **交叉表/透视布局**:左侧 1-2 列是**行标签**(日期/类别/编号/名称等枚举每一行的含义),其余列是维度(如门店/产品/月份)或指标。**只按横向读前 N 行,只能看到横向表头 + 前 N 个行标,看不全纵向表头**——这是"只改前 N 行、其余未更新"类故障的根因。跟读了多少行无关,哪怕首次读到 20/50 行,只要真实数据超过就一样会漏。 - → **必做**:再调一次 `range: "A:A"` 或 `A:C` 单独读纵向表头所在列到底,拿到全部行标;这类列通常只有 1-3 列、每行一个短标签,整列读完也不会爆上下文 - - **扁平列表**:每行一条独立记录,列是字段(如 ID/姓名/部门/金额/日期),无"行标签"概念 - → 不需要补读左侧列;但回写前仍要靠下面的兜底字段确认数据范围 - - 除此之外,每次读完还必须查三个字段——交叉表和扁平列表都用得到: - - `actual_range`:本次实际返回的范围(请求超边界时自动裁剪) - - `current_region`:连续数据矩形(Excel Ctrl+Shift+\*);若末行 > 请求 range 末行说明数据还没读全 - - `has_more`:返回体超过 `--max-chars` 被截断时为 `true`,按 `actual_range` 末行+1 分页续读,或改走路径 A - -**`current_region` 远大于可灌入上下文的量(如几百行)时**:切换到路径 A(分批 `+csv-get` 导出到本地 + 本地脚本处理),不要用路径 B 翻页硬塞到上下文。 - - **禁止仅凭首次探查范围就进入批量写入**——不管什么形态,都要先由形态判断决定是否补读纵向表头,再用 `current_region` / `has_more` 兜底确认没漏行。 - -3. **结果写回表格**:纯分析问答可以只读;但当用户需要多行结果、持续更新或可视化输出时,优先写回飞书表格——这样用户能直接在表格中查看和复用,而不是只看到一段文本。 - -4. **公式优先于硬编码值**:写公式(如 `=SUM(B2:B9)`)而非计算后的静态数值(如 `5000`),因为公式会在源数据变化时自动重算。写死数值后,用户改了源数据结果就不对了。 - -5. **区分公式语法和工具参数语法**:公式字符串中的范围引用(如 `H:H`、`$A$2:$B$5`)遵循飞书公式语法;而 CLI 工具的 `range` / `ranges` / `--copy-to-range` 参数使用 A1 表示法(如 `A1:D3`、`1:1`)。两者写法不同,混淆会导致调用失败。 - -6. **合并单元格需特殊处理**:合并区域只有左上角单元格存储数据,其余位置读取为空——这不代表”无内容”,而是合并的正常行为。写入时只能写左上角,写其他位置会报错或被忽略。如需修改合并区域中间的某格,先取消合并再操作。**在合并区域中间行插入数据之前,必须先调用 `+cells-get` 或 `+sheet-info --include merges` 确认目标行是否落在某个合并区域内**——直接用 `+cells-set` 写入合并区域的非左上角单元格,后端会返回 `cell at row N, col M is inside a merged region` 错误;即使 LLM 响应错误后改调 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` 插入行,行号也可能因合并扩展而错位。同理,**新增列后若原表存在合并的标题行(如 A1:F1),需要手动用 `+cells-{merge|unmerge}` 扩展合并范围到新列(如 A1:I1)**,否则标题行不会跟着变宽。 - -7. **多步写入优先用 `+batch-update`**:当任务涉及多个连续写入操作时,优先使用 `lark-sheets-batch-update` 中的 `+batch-update` 将它们合并为单次原子请求(一次提交,要么全成功要么整批回滚,也更快、更不易出错)。**特别是以下场景必须用 `+batch-update`**: - - 需要对多个不同区域执行 `+cells-{merge|unmerge}`(如按合同编号合并多列相同内容)→ 将所有 merge 操作放进一个 `+batch-update` - - 需要对多个不同区域执行 `+rows-resize / +cols-resize`(如统一调整多列列宽或多行行高)→ 将所有 resize 操作放进一个 `+batch-update` - - 需要先插入行列再写入数据 → 将 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` + `+cells-set` 放进一个 `+batch-update` - - 当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求 - -8. **结构操作用专用工具**:插入/删除行列用 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}`(一次操作可处理数千行),不要用 `+cells-set` 逐行搬数据——后者既慢又容易出错。用户给多步骤请求时,每个步骤都要执行,执行后重新验证。**`+dim-insert`(`--inherit-style before`/`after`)只继承相邻行的值/公式/边框,不继承 `row_height`**:插入后新行行高会回落到默认值(约 22px),若原行是高行高(容纳多行自动换行文本),新行长文本会被裁切(显示不全,末尾字符被截断)。**在中间插入行并填入文本前,必须先用 `+sheet-info --include row_heights` 读取相邻原行的 `row_height`,再用 `+batch-update` 合并 `+rows-resize / +cols-resize` 把同一高度应用到新行**——"在中间插入行填数据"本质等价于"续写已有内容",必须走硬性规则 12 的同一套样式继承流程(行高、边框、合并、对齐)。 - -9. **写入前精确定位表头和数据区域**:在执行任何写操作之前,必须先通过读取数据确认: - - 表头在哪一行(不要假设表头一定在第 1 行,可能存在标题行、空行等) - - 数据区域的实际起止范围(行数、列数)——可通过 `+csv-get` 返回的 `current_region` 快速获知连续数据区域的实际边界 - - **确认数据真实结束行**:`current_region` 末尾可能包含汇总行(合计/总计/小计)、签名/审批行(编制人/审核人)、空行、备注脚注等非数据内容,必须再读末尾 5~10 行排除这些行;最终数据范围 = 起始行 ~ 最后一条有效数据行。识别规则与完整示例见 `lark-sheets-read-data` 的「确定数据范围的正确流程」 - - 目标列的实际列字母——**必须通过 `col_indices[j]` 获取,禁止通过手动计数 CSV 表头的逗号或字段来确定列位置**。手动数列在列数较多(>10 列)时极易产生 off-by-one 偏移错误 - - **区分"表尺寸"与"数据占用范围"(新增列场景关键)**:`+workbook-info` 返回的 `column_count`(如 20 列)和 `row_count` 是**整个 sheet 的物理尺寸**,默认值可能远大于真实数据范围(比如一张只有 6 列数据的表可能 `column_count=20` 甚至 `column_count=100`)。**新增列 / 插入列之前,必须先调用一次 `+csv-get`(请求 `range: "A1:Z1"` 或表头附近一小块即可),读取返回值里的 `current_region` 作为真实数据矩形**,再基于 `current_region` 的右边界决定插入位置。例如 `current_region: "A1:F72"` → 数据末列是 F → 新增 3 列应插到 G/H/I,禁止插到 T(表尺寸末列)。否则新列和原数据之间会有一大片空列,用户看到的是"数据没动,三个空列在表尾"。 - 如果表头定位错误,后续所有公式和写入都会偏移,导致整体失败。 - -10. **公式填充必须用 `--copy-to-range`,禁止逐行写入**:当同一公式需要向下填充到多行时,必须先用 `+cells-set` 在第一行写入模板公式,再用 `--copy-to-range` 填充整列(如 `--copy-to-range "H2:H100"` 或 `--copy-to-range "H:H"`)。**禁止**对每一行单独调用 `+cells-set` 写入相同结构的公式——逐行写既慢又易错,而 `--copy-to-range` 会自动平移公式引用(C2=B2 → C3=B3)。 - -11. **分组汇总必须用透视表**:当用户说"按XX统计YY"、"分组汇总"、"各部门/地区的数量/金额"、"汇总每个XX的YY"时,必须使用 `+pivot-{create|update|delete}` 创建透视表(推荐省略 sheet_id 自动新建子表)。禁止用 SUMIF/COUNTIF 公式或本地脚本替代——后者会导致统计结果覆盖原表数据。 - -12. **续写/扩展已有内容时必须继承样式(高频致命错误)**:当用户要求"按已有格式续写"、"继续填充"、"复制区块"、"增加到第N周/月"等扩展任务时,**禁止只用 `+csv-get` 读值后只写值**。必须按以下顺序执行: - - 用 `+cells-get` 读取源区块的 `cell_styles` 和 `border_styles` - - 用 `+sheet-info --include row_heights,merges` 读取源区块的行高、合并单元格信息 - - 写入时 cells 中同时携带 `value` + `cell_styles` + `border_styles` - - 用 `+batch-update` 批量执行 `+cells-{merge|unmerge}`(复制合并)和 `+rows-resize / +cols-resize`(复制行高) - - 只写值不写样式会导致新区块与源区块视觉完全不一致,这是最常见的致命错误 - -13. **"新增列"任务必须跑完整 checklist(高频致命错误)**:当用户要求"在表格中增加列/新增列/加几列"时,心智模型不是"只写表头 + 填公式"。**执行前必须完成以下 4 步 checklist,禁止跳步**: - - **Step 1 — 读原表 row1 的合并区域**:用 `+cells-get` 或 `+sheet-info --include merges` 读取 row1 的合并信息。**如果 row1 存在跨数据区的合并标题行(如 A1:F1 合并为一个大标题),新增 N 列后必须用 `+cells-{merge|unmerge}` 扩展合并范围到新列末**(如新增 3 列 → 合并改为 A1:I1)。否则新列在 row1 裸露在原标题区之外,视觉割裂——这一步被跳过会被判为"操作不完整"。 - - **Step 2 — 读表头和原数据行的完整样式**:用 `+cells-get` 读原表头行(如 row2/row3)和数据行(row4)的 `cell_styles` **和 `border_styles`**,记录字体/加粗/对齐/**边框**/数字格式等。 - - **Step 3 — 新列 cells 必须同时带 `cell_styles` + `border_styles`**:写新列时 cells 里的每个对象都要完整复制原表样式(包括边框),不能只传 font_size / alignment 就算"样式一致"。 - - **Step 4 — 列宽对齐**:用 `+batch-update` 合并 `+rows-resize / +cols-resize` 把新列列宽与原数据列保持一致。 - - 典型反模式:AI 只在 `+batch-update` 里放 3 个 `+cells-set`(表头 + 空列样式 + 公式列),完全跳过 Step 1 的合并扩展 和 Step 2-3 的边框复制 → row1 不跟着变宽、新列无边框,用户打开产物感受"新列被孤立在原表外"。 +1. **最小改动**:除用户明示要改的单元格 / 列外,原表其它单元格、行列结构、Sheet 名、合并区、格式必须 1:1 保持。中间结果优先放原数据**右侧**;会与原数据混淆或要承载透视表 / 图表时才**新建空白 Sheet**。**禁止**擅自删 / 改名 / 隐藏 / 移动**已存在**的 Sheet(新建允许,节制使用)。 +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` 为唯一权威。 +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 行)属违规。 +9. **全量处理要前置断言条数**:翻译 / 打标 / 批量公式落地等逐条任务,落地前把"预期处理条数"硬编码进代码,处理完 `assert actual == expected`。**严禁**输出"已完成前 N 条,剩余将继续"的半成品。 ## 推荐工作流程 -### 第 0 步(最优先):按「任务类型 × 用户需求语义」直接锁定读取路径 - -**在做任何其他判断之前**,先按任务类型选路径,跳过这一步直接进入"探表"是"只改前 N 行"故障的根源。 - -| 用户需求语义 | 强制路径 | 写入范围默认值 | -|------------|---------|-------------| -| **"完善 / 补齐 / 填空 / 修正所有 XX"** | **路径 A(导出 + 本地处理 + 分批回写)** | **覆盖所有对应类别的完整数据行**——不以用户 `` 圈选为准(圈选通常只是光标位置) | -| "加一列 / 加 N 行 / 扩展到第 X 周" 等**扩展**类 | 路径 D(参见硬性规则 12/13) | 按用户指定或选区末行 | -| "查一下 / 看看 / 统计 / 汇总" 等**只读**类 | 路径 B (`+csv-get`) | n/a | -| 其他复杂任务 | 按任务类型在下方 A/B/C/D 路径中选 | — | - -**【高频致命错误 绝对禁止】** 对"完善 / 补齐 / 填空"类任务用路径 B 探 10 行就进入写入——`+csv-get A1:Z10` → `+cells-set ?3:?10` 的模式实测会漏写表尾多行。 - -1. 选择相关 skill -根据任务组合相关 skill。涉及样式、美化、补齐边框和格式时,参考 `lark-sheets-visual-standards`。不要只读完本 skill 就直接调用具体工具;应继续读取对应的工具 skill,包括 `lark-sheets-workbook`、`lark-sheets-write-cells`、`lark-sheets-filter`、`lark-sheets-pivot-table` 等,再执行工具调用。 - -2. 了解工作簿与工作表 -先用 `lark-sheets-workbook` 获取工作簿与子表概览。涉及隐藏、分组、合并、列宽或行高等布局信息时,再使用 `lark-sheets-sheet-structure`。 - -3. 读取数据(按第 0 步锁定的路径) - -**路径 A:数据分析/清洗/可视化/大数据集/"完善 / 补齐 / 填空 / 修正所有 XX"** → 分批 `+csv-get` 把数据导出到本地文件,再用本地脚本(如 pandas)处理: -```bash -# 分批导出(按 --range 行窗口翻页拼接到本地 data.csv,直到读完;单次返回量由 --max-chars 自动兜底,看 has_more / actual_range 续读) -lark-cli sheets +csv-get --url "$URL" --sheet-name "Sheet1" --range "A1:Z500" > data.csv # 首窗口;后续 A501:Z1000 … 用 >> 追加(sheet 定位必填) -``` -```python -import pandas as pd -df = pd.read_csv('data.csv') -print(df.info()) # 必做:获得「实际数据行数」(non-null count) 和列类型 -print(df.head(10)) # 必做:横向——确认表头行 + 前 10 行数据样貌 - -# 看完 head(10) 后判断表格形态: -# (a) 交叉表/透视布局:最左侧 1-2 列是行标签(如 日期/类别/编号枚举每一行含义),其余列是维度/指标 -# → 必做:print(df.iloc[:, :2].to_string()) 把左侧 1-2 列全部行标打到底 -# → 写入 range 的末行必须 == 纵向表头列读到的最后一行的表格行号(由 [row=N] 前缀取) -# (b) 扁平列表:每行一条独立记录,列是字段(如 ID/姓名/部门/金额),无"行标签"概念 -# → 不需要补读左侧列;但如果 len(df) 或 info() 的 non-null > 10,写入前仍需 print(df.to_string()) -# 或 print(df.iloc[10:].to_string()) 看完所有行再规划写入范围 -# print(df.describe()) # 按需:涉及数值分析(统计、异常值、分布)时再调用 -``` +1. **规划 skill 清单**:开工前一次性列出本任务要读的子 skill(避免读一个调一个),本轮已读过的不重复读。本 skill + `lark-sheets-workbook` 几乎每次都要。 +2. **了解结构**:先 `+workbook-info` 拿子表列表 / 行列数 / 冻结位置(不可猜测,猜错会越界覆盖);涉及合并 / 隐藏 / 分组 / 行高列宽再用 `lark-sheets-sheet-structure` 的 `+sheet-info`。 +3. **读取数据(按任务类型选路径,细则见 `lark-sheets-read-data`)**: -**路径 B:快速查看少量数据/简单问答(只读场景)** + | 用户需求语义 | 路径 | + |---|---| + | "完善 / 补齐 / 填空 / 修正所有 XX" / 数据分析 / 清洗 / 大数据集 | **A:分批 `+csv-get` 导出到本地 + pandas 处理 + 分批回写**(默认覆盖所有对应数据行,不以用户选区为准) | + | "查一下 / 看看 / 统计 / 汇总" 等只读 | B:`+csv-get` 读到上下文 | + | 需要公式 / 样式 / 批注 | C:`+cells-get` | + | 续写 / 扩展 / 完善已有内容 | D:`+csv-get` 看结构 + `+cells-get` 读源区样式 + `+sheet-info --include row_heights,merges`(见铁律 5) | -> **【场景限制 绝对禁止】** 本路径**仅适用于只读问答/简单查询/只需要看头部几行**。**绝对禁止**用于批量写入/完善/补齐/填空类任务——对这类任务踩本路径必漏写表尾(见第 0 步)。若任务最终要回写多行数据,直接去路径 A。 + **【高频致命错误】** 对"完善 / 补齐 / 填空"类任务用路径 B 探 10 行就写入,实测会漏写表尾多行。写入前必须按 `lark-sheets-read-data`「确定数据范围的正确流程」确认真实数据末行。按关键字定位区域用 `lark-sheets-search-replace` 的 `+cells-search`。 -用 `+csv-get` 读取到上下文。若本路径真的适用(确认是只读场景),按以下顺序: +4. **理解数据语义(写入前必做)**:读表头 + 3-5 行样本确认各列含义与格式(文本 / 数字 / 日期 / 混合);写公式前先分析样本值格式模式再选提取策略;建透视表前先列清"行字段=分组维度、值字段=聚合指标"。需求模糊时(如"加入加减乘除"未说逻辑)基于表头与已有公式推断,不确定就问用户,禁止臆造业务逻辑。 -1. `range: "A1:Z10"` — 横向读前 10 行探表头结构 -2. 看完第 1 步的 `annotated_csv` 后,**判断表格形态**: - - 左侧 1-2 列是行标签(日期/类别/编号枚举每行含义,其余列是维度/指标)→ 交叉表/透视布局 → **必做**:再调一次 `range: "A:A"` 或 `A:C` 读纵向表头列到底,拿到全部行标 - - 每行是独立记录、列是字段(如 ID/姓名/部门/金额)→ 扁平列表 → 不需补读左侧列;但写入前仍要靠 `current_region` 末行确认数据范围,末行 > 第 1 步读到的行数时需再读一次覆盖 `current_region` 全区 +5. **分析与计算(原生工具优先,代码兜底)**:飞书原生能力能随数据自动更新,**必须优先**: -若 `current_region` 远大于 `actual_range`(比如几百行),**切换到路径 A**,不要用路径 B 翻页硬灌。 + | 用户需求 | 必须用的原生工具 | 禁止用代码替代 | + |---|---|---| + | 按 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 → 写静态值 | -**路径 C:需要公式/样式/批注** → 用 `+cells-get` + **只有以下才用代码**:多步清洗流水线、统计建模、公式试错 3 次仍失败的降级。代码结果回写:大块纯值用 `+csv-put`(+ `--start-cell`,必要时自动扩容);少量或需公式 / 样式用 `+cells-set`;能用飞书公式表达的写飞书公式。 -**路径 D:续写/扩展/完善已有内容(必须走此路径)** → 当用户要求"按已有格式续写"、"继续填充"、"复制区块"、"增加到第N周/月"、"帮我完善 XX"、"补齐 XX"、"填空"时,**必须**同时读取值和样式:先用 `+csv-get` 快速了解数据结构和范围,再用 `+cells-get` 读取源区块的 `cell_styles` + `border_styles`,并用 `+sheet-info --include row_heights,merges` 读取行高和合并信息。**禁止跳过样式读取直接写入。** 这类任务默认要覆盖所有待补齐的行,**禁止只处理 `head(10)` 可见的行**——必须按 `df.info()` 的 non-null 数或 `current_region` 的实际行数确定写入范围。 +6. **写入与修改(细节见 `lark-sheets-write-cells`)**:`+cells-set` 的 `range` 必须落在已有行列范围内、`cells` 二维数组与 `range` 严格同维;表尾追加先用 `+dim-insert` 插行列再写;整列 / 整行同结构的值 / 公式 / 格式用模板单元格 + `--copy-to-range`,禁止逐行 `+cells-set`;多步写入合并为 `+batch-update`;改尺寸先读相邻可见行列当前尺寸再决定 `pixel` / `standard` / `auto`,不要猜数值。 -需要按关键字定位区域时使用 `lark-sheets-search-replace` 中的 `+cells-search`。 - -4. 写入前重新确认数据边界(批量写入/修改时必做) - -批量填充、续写、补齐、完善、替换、覆盖多行等写入场景,在动手写入前必须先拿到两个量:**"已读取到的完整数据范围末行"** 和 **"真实数据末行"**,然后按下面的强校验逻辑确认。 - -**强校验(必做,任一条不满足都不得写入)**: - -1. **已读完整数据范围末行 ≥ 真实数据末行**——注意,比较对象是**真实数据末行**(不是规划写入范围末行)。只读了前 10 行、规划写 10 行、二者相等,**形式上满足 "已读 ≥ 写入" 但实际漏了 11 行之后的数据**——这种"看起来满足校验"的场景属于 loophole,**默认判为违规**。 -2. **交叉表场景下,写入 range 末行必须 == 纵向表头列读到的最后一行的行号**。禁止以首次横向探查的末行(如 `A1:Z10` 的第 10 行)作为写入末行,即使已读 range 覆盖到了。 -3. **"完善 / 补齐 / 填空 / 修正所有 XX" 类需求**,写入范围默认 = 真实数据末行(所有待补齐行),**以用户需求语义为最高优先级**,不以用户 `` 圈选为准(圈选通常只是光标位置,不代表修改意图)。 - -**真实数据末行**的来源随读取路径不同: - -| 读取路径 | 真实数据末行的字段 | 触发再读的条件 | -|---------|-------------------|--------------| -| 路径 A(Python/export,强制路径) | `df.info()` 的 non-null 最大值 **或** `len(df)`;交叉表场景还要看 `df.iloc[:, :2].to_string()` 的行数 | non-null > head 行数 → 执行 `print(df.to_string())` 或 `print(df.iloc[N:].to_string())` | -| 路径 B(`+csv-get`,仅只读场景) | 响应里的 `current_region` 末行;交叉表还要看纵向表头列 `A:A` / `A:C` 读到底的最后一行 | `current_region` 末行 > 首次请求 range 的末行 → 再调一次 `+csv-get` 覆盖全区 | -| 路径 C(`+cells-get`) | 响应里的 `cell_range.end_row` | `end_row` > 首次读 range 的末行 → 再读一次 | - -**共同规则(强制)**: -- **禁止**在首次读 range 写死 `10` / `20` / `100` 等经验数字就直接进入写入。 -- **禁止**用首次读的末行当"已读完整数据范围末行"——要先确认 `current_region` / `non-null` / `end_row` 都不大于它,否则必须再读。 -- 写入范围应根据**用户意图 + 真实数据末行**推断: - - 补齐/完善/修正类任务 → 写入末行 = 真实数据末行(覆盖所有待补齐行) - - 扩展/新增类任务 → 写入末行 = 用户指定或选区末行 -- **反例(违规操作,必查)**:仅探查前 10 行、用 10 当作"已读 range 末行"、规划写入 10 行、形式上"已读 ≥ 写入" → 实际真实数据有 15 行 → **属于违规**,必须再读完剩余 11-15 行后再写。 - -只读问答或简单查询(路径 B 且只需统计/查询、不写回)可以跳过本步。 - -5. 理解数据语义(写入前必做) -在动手写入或创建公式/透视表之前,先建立字段映射: -- 读取表头 + 3-5 行样本数据,确认各列含义和数据格式(文本/数字/日期/混合) -- 需要写公式时:先分析样本值的格式模式(如"长2900*高3650"的分隔符),再选提取策略 -- 需要创建透视表时:先列出"行字段 = 分组维度、值字段 = 聚合指标"的对应关系 -- 需求模糊时(如"加入加减乘除函数"但未说明逻辑):基于表头和已有公式推断,不确定时询问用户,禁止臆造业务逻辑 - -6. 分析与计算(原生工具优先,代码兜底) -飞书表格的原生能力(公式、透视表、图表、筛选、条件格式)可以随数据变化自动更新,**必须优先使用**。只有原生能力确实无法完成时才退化到代码。 - -**场景→工具强制路由(禁止跳过直接用代码)**: - -| 用户需求 | 必须用的原生工具 | 禁止用代码替代 | -|---------|----------------|--------------| -| 按XX统计YY、分组汇总 | `+pivot-{create|update|delete}` | pandas groupby → `+cells-set` | -| 求和/计数/平均/占比 | 公式(SUM/COUNT/AVERAGE) | Python 计算 → 写静态值 | -| 画图表、可视化 | `+chart-{create|update|delete}` | matplotlib/seaborn 画图 | -| 条件高亮、色阶 | `+cond-format-{create|update|delete}` | 逐单元格设样式 | -| 数据筛选 | `+filter-{create|update|delete}` | pandas filter → 覆盖写入 | -| 文本提取/转换 | 公式(REGEXEXTRACT/TEXT/VALUE) | Python 正则 → 写静态值 | -| 查找匹配 | 公式(VLOOKUP/INDEX+MATCH) | pandas merge → 写静态值 | - -**只有以下场景才用代码**:多步数据清洗(正则+拆分+合并流水线)、统计建模、公式试错 3 次仍失败时的降级方案。代码计算结果写回表格时: -- **批量 CSV 纯值回写**(本地脚本清洗 / 聚合 / 筛选 / 合并的结果)→ `+csv-put`(直接传 CSV 文本 + `--start-cell`,必要时自动扩容行列) - - **少量数据或需要公式/样式** → `+cells-set` - - **能用飞书公式表达的** → 写飞书公式(源数据变化时自动重算) - -7. 写入与修改 -- 范围写入使用 `lark-sheets-write-cells`。`+cells-set` 的 `range` 必须落在当前已有行列范围内,`cells` 二维数组必须与 `range` 严格同维度;若是大块 CSV 纯值回写,优先用 `+csv-put`(直接传 CSV 文本,必要时自动扩容)。 -- 如需在表尾追加数据,先插入行或列,再执行写入。 -- **多步写入优先用 `+batch-update`**(见硬性规则 7):将多个写入操作合并为一次 `+batch-update` 原子调用。尤其是多次 `+cells-{merge|unmerge}`、多次 `+rows-resize / +cols-resize`、多次 `+cells-set` 场景,必须合并。 -- **公式填充必须用 `--copy-to-range`(见硬性规则 10)**:先写一行模板公式,再用 `--copy-to-range` 一次填充整列或整区域。示例:在 H2 写 `=SUM(B2:G2)` 后,设 `--copy-to-range "H2:H100"` 即可填充 99 行。**禁止逐行调用 `+cells-set` 写入相同结构的公式。** -- 对整行/整列统一设置值、公式、格式或批注时,优先写一个模板单元格,再用 `--copy-to-range` 扩展到 `1:1`、`J:J`、`A:A` 等目标范围。 -- 当用户请求“宽一点 / 高一点 / 和其他行一样高 / 和其他列一样宽”时,先读取相邻可见行列的当前尺寸,再决定使用精确尺寸、`standard` 或 `auto`,不要随意猜测数值。 -- 对图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象,按各自 skill 的“先读后改后验证”工作流执行。 - -8. 验证 -- 重新读取受影响单元格区域,确认值、公式、样式、批注/备注符合预期。 -- 对图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象,重新读取对象配置确认结果。 -- 如出现错误,优先定位错误类型、受影响区域和根因,再修复后重新验证。 +7. **验证**:重新读取受影响区域确认值 / 公式 / 样式 / 批注符合预期;对象类(图表 / 透视表 / 条件格式 / 筛选 / 迷你图 / 浮动图片)重新读对象配置确认;出错先定位错误类型 / 受影响区域 / 根因再修复重验。 ## 公式策略 -- **公式优先于硬编码**(同硬性规则 4):能用飞书公式表达的计算(总计 / 平均 / 增长率 / 占比 / 差值等)必须写公式,而非 Python 算出的静态值——源数据变化时才能自动重算。 -- **Excel 迁移 / 数组语义(ARRAYFORMULA)/ 飞书函数差异 / 不支持函数清单**:一律以 `lark-sheets-formula-translation` 为唯一权威。公式来自 Excel 或含数组(FILTER / MAP / XLOOKUP 等)场景,先读它完成改写再写入;公式字符串里的范围用飞书语法(`H:H`、`A2:B5`,禁止 `H2:H` / `2:2`)。 - -### 向下填充与绝对引用 - -对需要向下、向右填充的公式,必须先检查哪些引用需要固定,并正确使用绝对引用 `$`。 - -常见需要绝对引用的情况: - -- 用户要求中提到的特定单元格,例如 `$C$3`、`$A$2` -- 公式中需要固定的数据范围,例如 `$A$2:$B$5` -- 需要锁定列但允许行变化,或锁定行但允许列变化的场景,例如 `$A2`、`B$1` - -填充前应检查: - -- 是否需要固定汇率、税率、基准值等单个参数单元格 -- 是否需要固定查找表、权重表、映射表的数据范围 -- 同一列或同一行的公式结构是否一致 - -## 常见陷阱 - -- NaN / 空值处理:检查空值,避免直接参与运算 -- 多重匹配:搜索时检查所有匹配位置,而不是只取第一个 -- 除数为零:优先使用 `IF` 或 `IFERROR` -- 公式容错必须预置:日期公式(DATEDIF/DATEVALUE)、查找公式(VLOOKUP/INDEX)、数值转换必须用 `IFERROR` 包裹,避免单个异常值导致整列报错 -- 公式写入后必须校验:写入公式后读取结果列前 5 行 + 末 5 行,检查是否有 #VALUE!/#NUM!/#REF! 等错误值。发现错误时定位异常行并修复 -- 公式试错上限 3 次:同一个公式方案尝试 3 次仍失败时,改用代码计算并以值写入,不要无限循环 -- 操作语义映射:「改写」/「替换」= 覆盖原位数据(辅助列写公式 → 复制为值 → 粘贴到原列 → 删辅助列);「新增列」/「添加列」= 在旁边加列。搞反会破坏原表或不符合预期 -- 引用错误:验证所有单元格引用是否仍然有效 -- 跨工作表引用:使用 `Sheet!A1` 风格引用 -- 整行整列语义丢失:用户说“这行 / 这列 / 首行 / 整列”时,不要把操作范围截断为当前读取到的 `A1:U1`、`J1:J41` 等局部范围 -- **重复写入未使用 `--copy-to-range`(高频致命错误)**:整列公式、整列格式、首行样式、向下复制等场景,**必须**用模板单元格 + `--copy-to-range`,**禁止**逐行 `+cells-set`。逐行写既慢又易错,且不会自动平移公式引用 -- **重复调用 `+cells-{merge|unmerge}` / `+rows-resize / +cols-resize` 未合并为 `+batch-update`(高频致命错误)**:当需要合并/调整多个区域时,**必须**使用 `+batch-update` 将多个操作合并为单次原子调用。逐个调用慢且非原子(中途失败会留下半成品) -- 多步骤请求漏做:若用户要求”先重命名,再新建”,两个动作都必须执行 -- **表头定位不精确导致写入全偏(高频致命错误)**:不要假设表头在第 1 行。很多表格有标题行、说明行或空行,实际表头可能在第 2、3 行甚至更后。写入公式或数据前,必须先读取前几行确认表头行号和各列实际含义,再基于确认后的行列号构造写入 range -- **参数冗余**:只需修改 10 个单元格时,不要把全表重写一遍;`+cells-set` 的 range 和 cells 应精确覆盖变更区域 -- **表头理解路径不对**:要了解表格结构和字段含义时,先用 `+csv-get` 读取前 5-10 行查看表头与字段格式;大表需要全量统计才考虑分批导出后用本地脚本(如 `df.info()` + `df.head()`)分析。不要一行行用 `+cells-get` 逐行读取,也不要依赖 `+cells-search` 去”猜”字段名 -- **隐藏行列导致定位偏移**:`+csv-get` 默认 `--skip-hidden=false`(返回完整数据含隐藏行列)。如需只看可见数据,显式设 `--skip-hidden=true`,但注意跳过隐藏行后返回数据的行序号与实际行号不对应 -- **写入前读取范围不充分**:批量写入 / 修改前必须读全数据范围(详见上方工作流第 4 步「写入前重新确认数据边界」),只读前 N 行就写会漏掉表尾 -- **`+cells-search` 不是万能的**:用户说”汇总金额”是一个操作动作(求和),不是要搜索”汇总金额”这个文本。只有当确实需要定位某个文本值的位置时才用 `+cells-search` -- **跨 sheet 对象**:图表、条件格式、透视表、浮动图片可能分布在多个子表中。操作前先用 `+workbook-info` 掌握全局,不要只看当前子表 -- **--copy-to-range 不含行列尺寸**:`--copy-to-range` 复制的是值、公式和样式,不包含行高列宽设置。需要统一行列尺寸时,应另行调用 `lark-sheets-range-operations` 中的 `+rows-resize / +cols-resize` -- **写入前先确保行列存在**:`+cells-set` 不会自动扩展表格。如果要写入的 range 超出当前行列范围,必须先用 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` 插入行列 -- **写入后保持原表样式(高频致命错误)**:原表已有边框 / 背景色 / 行高 / 合并等样式时,写入新数据后**必须**延续,不要只写值不管格式(同硬性规则 12)。完整写法见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」 -- **CSV 行号按物理换行计数导致行号全错(高频致命错误)**:`+csv-get` 的 CSV 里被双引号包裹的字段内换行是**单元格内换行**、不是新行;行号一律按 `[row=N]` 前缀取,不要数 `\n`。详见 `lark-sheets-read-data` 的「CSV 行号计算规则」 +- **公式优先于硬编码**(同铁律 4)。 +- **向下 / 向右填充先判断绝对引用**:用户指定的固定 cell(`$C$3`)、要固定的数据范围(`$A$2:$B$5`)、锁列不锁行(`$A2`)或锁行不锁列(`B$1`)要正确加 `$`;填充前检查是否需固定汇率 / 税率 / 查找表 / 权重表,以及同列 / 同行公式结构是否一致。 +- **公式字符串用飞书范围语法**(`H:H`、`A2:B5`,禁止 `H2:H` / `2:2`);与 CLI 工具参数的 A1 表示法(`A1:D3`、`1:1`)写法不同,混淆会调用失败。 +- Excel 迁移 / 数组语义(ARRAYFORMULA)/ 函数差异 / 不支持函数清单:一律以 `lark-sheets-formula-translation` 为唯一权威,先读它再写。 + +## 常见陷阱(铁律已覆盖的不再重复,仅列易漏点) + +- **合并单元格**:合并区只有左上角存数据,其余读为空是正常行为;写入只能写左上角,写其它位置会报 `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` 不是万能**:用户说"汇总金额"是操作动作(求和),不是搜索该文本;只在确需定位某文本位置时才用。 ## 特殊场景 -### 续写/复制已有区块格式 +### 续写 / 复制已有区块格式 -续写 / 复制已有区块("按前两周格式续写到第 20 周"、"把第一个模块复制 N 遍并改日期")的**完整写法见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」与「内容与样式分离写入」**;样式标准(斑马纹 / 配色 / 边框层级)见 `lark-sheets-visual-standards` 场景二。核心(同硬性规则 12):用 `+cells-get` 读源区块 `cell_styles` + `border_styles`、`+sheet-info --include row_heights,merges` 读行高 / 合并,写入时带齐样式,再用 `+batch-update` 复制合并与行高,最后回读校验。**禁止**只用 `+csv-get` 读值后只写 `{"value": ...}`——否则新区块缺边框 / 合并 / 行高,与源区块视觉不一致(本场景最常见错误)。 +完整写法见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」,样式标准(斑马纹 / 配色 / 边框层级)见 `lark-sheets-visual-standards` 场景二。核心(同铁律 5):读源区 `cell_styles` + `border_styles` + 行高 / 合并,写入带齐样式,再 `+batch-update` 复制合并与行高,最后回读校验。 ### NLP 任务处理 -当任务涉及语义理解、翻译、改写、摘要、分类、抽取或多行内容聚合时,应以 NLP 方式处理,不要试图用纯规则代码替代语义理解。 - -判断标准: - -特征 | 示例 ----|--- -内容转换 | 翻译、改写、摘要 -内容分析 | 情感分析、分类 -语义提取 | 提取人名、日期、金额 -内容聚合 | 多行信息合并 - -注意: - -- NLP 处理本身不应退化为纯规则代码;但可以使用代码做分批、行号映射、结果拼装和写回。 -- 数据量大时**必须**分批处理,通常 30 行一批。每批处理完后立即写回表格,不要等全部处理完再一次性写入。 -- 为控制单次生成量,NLP 处理通常单批不超过 300 行;超过时根据任务性质选择抽样或分批执行,并向用户明确处理范围。 -- 翻译、信息提取等任务,优先使用 `+batch-update` 将多批写入合并为原子提交。 +任务涉及语义理解、翻译、改写、摘要、分类、抽取、多行聚合时,以 NLP 方式处理,不要用纯规则代码替代语义理解(但可用代码做分批、行号映射、结果拼装与写回)。数据量大时**必须**分批(通常 30 行一批),每批处理完立即写回,不要全处理完再一次写入;单批生成通常不超 300 行,超出时按性质抽样或分批并向用户说明范围;多批写入优先用 `+batch-update` 合并为原子提交。 ### 格式处理优先公式 -当用户需求涉及"去除多余零"、"提取数字"、"文本格式转换"、"日期格式化"等数据清洗操作时,**必须优先使用公式方案**(如 `SUBSTITUTE`、`TEXT`、`VALUE`、`LEFT`、`RIGHT`、`MID` 等函数),而非逐行读取数据后手动修改。公式方案只需写一个模板 + `--copy-to-range` 即可完成整列处理,远比逐行修改高效。 +"去除多余零 / 提取数字 / 文本格式转换 / 日期格式化"等清洗,**必须优先用公式**(`SUBSTITUTE` / `TEXT` / `VALUE` / `LEFT` / `RIGHT` / `MID` 等):写一个模板 + `--copy-to-range` 即可整列处理,远比逐行修改高效。 diff --git a/skills/lark-sheets/references/lark-sheets-float-image.md b/skills/lark-sheets/references/lark-sheets-float-image.md index 2475f9873..ee8f92fb6 100644 --- a/skills/lark-sheets/references/lark-sheets-float-image.md +++ b/skills/lark-sheets/references/lark-sheets-float-image.md @@ -24,10 +24,14 @@ - **图片位置参数要精确**:锚点单元格的行列索引和偏移量决定了图片位置,设置不当会导致图片遮挡数据 - **创建后必须验证**:调用 `+float-image-list` 确认图片位置和大小正确 -`image_uri` 与 `image_token` 是「指定图片资源」的两种等价方式(与 `+cells-set` 中 `embed-image` 的语义一致): -- `image_uri`:图片上传链路返回的 reference_id,由系统自动转 fileToken -- `image_token`:图片 fileToken,常见来源是 `+float-image-list` 返回的 `image_token`(适合"换皮不换位置"等基于已有图片的复用场景) -- create 时二者必须有其一;update 时**仅在需要替换图片本身时**传入新的 `image_uri` 或 `image_token`,不传则保留原图。 +`--image-uri` 与 `--image-token` 是「指定**已有**图片资源」的两种等价方式(XOR,create 时必给其一): +- `--image-token`:图片 fileToken。常见来源是 `+float-image-list` 返回的 `image_token`(适合"换皮不换位置"等复用已有图片的场景)。 +- `--image-uri`:图片上传句柄(image URI),由系统自动转 fileToken。 +- update 时**仅在需要替换图片本身时**才传新的 `--image-uri` / `--image-token`,不传则保留原图。 + +⚠️ **本 shortcut 不接受本地图片文件路径**——只能引用已存在的图片资源。若手上只有本地新图(如 `logo.png`): +- **可接受单元格内嵌图** → 直接用 `+cells-set-image --image <本地路径>`(见 `lark-sheets-write-cells`,它支持本地路径)。 +- **必须是浮动图片** → 需先把本地图片上传到飞书拿到 file_token(上传步骤不在本 skill 内,例如经云空间),再把该 token 传给 `--image-token`。 ## Shortcuts diff --git a/skills/lark-sheets/references/lark-sheets-range-operations.md b/skills/lark-sheets/references/lark-sheets-range-operations.md index 87c26b39d..1e0938529 100644 --- a/skills/lark-sheets/references/lark-sheets-range-operations.md +++ b/skills/lark-sheets/references/lark-sheets-range-operations.md @@ -49,13 +49,9 @@ 5. **新增合并时数据保护**:合并前确认目标区域只有左上角有数据,其余单元格为空,否则合并会导致非左上角的数据丢失。 6. **批量取消合并一次调用即可**:当一个范围(整列 `A:A`、整行 `3:3`、矩形 `A1:D100`)内存在多个合并区域,直接调一次 `+cells-unmerge` 传入这个大范围,会一次性取消该范围内所有合并区域;**不要**为每个合并区域单独调用 unmerge,也不要用 `+batch-update` 拆成多次 unmerge。 -**⚠️ 批量操作必须用 `+batch-update`**: +**⚠️ 批量操作必须用 `+batch-update`**:对**多个**不同区域执行 `+cells-merge` 或 `+rows-resize / +cols-resize` 时,禁止逐个调用,合并为单次原子 `+batch-update`(语义与 `--operations` 入参格式见 `lark-sheets-batch-update`)。 -当需要对**多个**不同区域执行 `+cells-{merge|unmerge}`(merge)或 `+rows-resize / +cols-resize` 时,**禁止逐个调用**,必须使用 `+batch-update`(参见 `lark-sheets-batch-update`)将所有操作合并为一次原子请求。逐个调用慢且非原子。 - -**例外**:`+cells-unmerge` 原生支持对覆盖多个合并区域的大 range 一次性取消,应直接单次调用,**不要**拆进 `+batch-update`。 - -> 多操作组合示例(合并多区域、批量调整列宽行高的 `+batch-update --operations` JSON 入参格式)见 `lark-sheets-batch-update` 文档。 +**唯一例外**:`+cells-unmerge` 原生支持传一个大 range 一次性取消其中所有合并区域,应直接单次调用,**不要**拆进 `+batch-update`。 **⚠️ sort 操作前必读:确认目标列的数据类型** diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 567a2de66..6e1887031 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -82,10 +82,7 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl - 若目标区域涉及合并单元格,不要向合并区域中的非左上角单元格写入数据;如需写入,应改写合并区域左上角单元格,或先调整/取消合并区域 - **构造 `range` 时行号必须基于逻辑行号**:如果之前通过 `+csv-get` 读取了数据,CSV 中被双引号包裹的多行字段(如 `"2026年3月2日\n星期一"`)是**一个单元格**,不是两行。写入时的行号必须按逻辑记录计算,不能按物理换行符计数,否则 `range` 会整体偏移导致写入到错误位置 -⚠️ **"样式与原表一致"必须包含 `border_styles`(高频致命错误)**:当用户说"样式和原表一致"、"保持原表格式"、"边框继承"等要求时,cells 里的 `cell_styles` **不能只传 `font_size` / `horizontal_alignment` / `vertical_alignment`**——这几项只覆盖字体和对齐,**不包含边框**。边框必须用独立的 `border_styles` 字段传(或在源 cell 用 `+cells-get` 读出来再原样复制)。 -- **反模式**:`cells=[[{cell_styles:{font_size:16, horizontal_alignment:"center", vertical_alignment:"middle"}}]]`(字体+对齐都有,但**新 cell 仍然没边框**,视觉上与原表断裂) -- **正确做法**:`cell_styles` + `border_styles` 一起传,`border_styles` 覆盖 top/bottom/left/right 四条边(或至少 data 区该加的几条),确保视觉连续 -- 特别是**新列/新行**场景,新 cell 底子里本来就没边框,如果不显式传 `border_styles`,--copy-to-range 复制的模板也没边框 → 整列/整行无边框 +> 用户说"样式和原表一致 / 保持原表格式 / 边框继承"时同理:`cell_styles` 只覆盖字体和对齐、**不含边框**,边框必须用独立 `border_styles` 字段传——完整继承清单见上方「新增列 / 新增行的样式继承」。 ⚠️ **公式写入必须自己校验结果(后端不会报语法错)**:`+cells-set` 写公式时,即便公式有括号不配对(如 `=IFERROR(VALUE(REGEXEXTRACT(D5, "\d+"))), 0)` 比 IFERROR 多一个 `)`)或用了飞书不支持的函数(如 `GOOGLETRANSLATE` / `CUBEVALUE`),**后端工具也会返回 `updated_cells_count=N, rc=0` 的"成功"**——错误会静默写进单元格显示为 `#VALUE!` / `#NAME?` / `#REF!`。因此: 1. **写完立即读回**:`+cells-set` 后紧跟 `+csv-get`(或 `+cells-get`)读目标范围前几行,检查是否出现 `#VALUE!` / `#NAME?` / `#REF!` / `#N/A` / `#DIV/0!` / `#NUM!` @@ -118,35 +115,10 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl **正确做法**(二选一): -**做法 A(推荐):两步走——先铺样式、再覆内容** +- **做法 A(推荐)**:按上方「内容与样式分离写入」两步法——先用模板单元格 + `--copy-to-range` 铺**完整样式**(`cell_styles` + `border_styles` 都要,不能只铺 border,否则新行字体 / 对齐 / 背景色全裸奔),再单独 `+cells-set` 写 value / formula。汇总行的 `cell_styles` 要点(bold / 背景色 / 上边框)见 `lark-sheets-visual-standards` 的「场景一 → 1A. 添加汇总行 / 表头行」。 +- **做法 B**:一次写入,但每个 cell(含空白格)都显式带 `cell_styles` + `border_styles`,**不能用 `{}`**。 -``` -Step 1: 用模板单元格 + --copy-to-range 铺"完整样式"(不是只铺 border)到新区域 - `+cells-set` — range="A11", cells=[[{ - border_styles: {...}, - cell_styles: { /* 按行性质填充:数据行继承数据区样式;汇总行见 lark-sheets-visual-standards */ } - }]], --copy-to-range="A11:H11" - -Step 2: 再用 `+cells-set` 单独写具体 value/formula(不再传样式,避免覆盖) - `+cells-set` — range="A11", cells=[[{value: "平均分"}]] - `+cells-set` — range="C11:F11", cells=[[{formula: "=AVERAGE(C2:C10)"}, {formula: "=AVERAGE(D2:D10)"}, ...]] -``` - -⚠️ **Step 1 `cell_styles` 禁止留空**:只铺 border、不铺 `cell_styles`,等于新行从格式上"裸奔"——没字体、没对齐、没背景色。如果新行是汇总行,这意味着 bold 丢失,用户感受"没做样式"。Step 1 的 `cell_styles` 要么继承源区块(`+cells-get` 读相邻已有行样式后复用),要么按汇总行样式要点(见 `lark-sheets-visual-standards` 的「场景一 → 1A. 添加汇总行 / 表头行」)配齐。 - -**做法 B:一次写入但每个 cell 都显式带样式** - -``` -`+cells-set` — range="A11:H11", cells=[[ - {value: "平均分", cell_styles: {...}, border_styles: {...}}, - {value: "", cell_styles: {...}, border_styles: {...}}, ← B11 不能是 {},要显式带 border - {formula: "=AVERAGE(C2:C10)", cell_styles: {...}, border_styles: {...}}, - {formula: "=AVERAGE(D2:D10)", cell_styles: {...}, border_styles: {...}}, - ... -]] -``` - -**判断是不是"新行"**:`+csv-get` 返回的 `current_region` 是 `A1:H10`,你要写入的 range 是 `A11:H11`(超出 `current_region` 右/下边界),就是新行——必须按上述做法处理边框。 +**判断是不是"新行"**:写入 range 超出 `+csv-get` 返回的 `current_region` 右 / 下边界(如 `current_region=A1:H10`、写 `A11:H11`)即新行,必须按上述做法补边框。 ## 工具选择 From 868beaf0042dfdc49bc2cc428522ac6296b2dda1 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Mon, 25 May 2026 11:54:43 +0800 Subject: [PATCH 040/114] docs(sheets): sync lark-sheets skill spec from upstream Refine reference docs from upstream sheet-skill-spec (core-operations, formula-translation, visual-standards, SKILL guidance). Description-only; no flag or CLI behavior change. --- skills/lark-sheets/SKILL.md | 9 ++++++++- .../references/lark-sheets-core-operations.md | 16 +++++++++++----- .../lark-sheets-formula-translation.md | 8 ++++++++ .../references/lark-sheets-visual-standards.md | 8 ++++++-- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index 8aad462a2..922fbb9ec 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -34,13 +34,20 @@ metadata: ## References -本 skill 按能力子域组织,每个子域有独立 reference。先按下表索引定位到目标子域,再进入对应 reference 查 shortcut / 调用细节。 +本 skill 的 reference 分两组:先读**通用方法与规范**(横切所有任务的工作流、铁律、样式、公式规则,不含具体 shortcut),它们规定了"怎么做对";再按操作对象进入**工具参考**查具体 shortcut 与调用细节。编辑类任务务必先过一遍通用方法与规范,其中的铁律对所有工具参考一律生效。 + +### 通用方法与规范(先读,横切所有任务,不含具体 shortcut) | Reference | 描述 | | --- | --- | | [飞书表格核心操作:分析、编辑与可视化](references/lark-sheets-core-operations.md) | 飞书表格核心操作工作流。当用户需要对已有的飞书表格进行查看、分析、编辑或可视化时使用。适用场景:数据查询与统计、公式计算、表格美化、创建图表/透视表、筛选排序、批量修改数据、调整表格结构等。即使用户没有明确说"飞书表格",只要操作对象是已有的在线表格,都应触发此工作流。不适用于本地 Excel 文件操作。 | | [飞书表格样式与配色规范](references/lark-sheets-visual-standards.md) | 飞书表格样式与配色规范:表头/数据区/汇总行的颜色、字号、对齐、边框等取值标准,以及新增汇总行、追加行列继承原表风格、已有区域美化等典型场景的决策流程与样式要点。工具调用参数细节请参考对应的 lark-sheets-write-cells / lark-sheets-range-operations / lark-sheets-batch-update。条件格式(高亮、标红、数据条、色阶)请使用 lark-sheets-conditional-format。仅针对飞书表格,不适用于本地 Excel 文件。 | | [飞书表格公式生成规则](references/lark-sheets-formula-translation.md) | Excel 公式到飞书表格公式的迁移与生成规则。核心目标不是保留 Excel 原语法,而是按飞书表格可执行规则重写公式,并在结果上尽量对齐 Excel。当用户要求把 Excel 公式改写成飞书表格公式,或需要生成飞书公式(尤其涉及 ARRAYFORMULA、原生数组函数、INDEX/OFFSET、MAP/LAMBDA、日期差、多层范围结果与二次展开)时使用。仅针对飞书在线表格,不适用于本地 Excel 文件执行。 | + +### 按对象的工具参考(含 shortcut) + +| Reference | 描述 | +| --- | --- | | [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) | 读取飞书表格中的单元格数据。当用户需要"看看数据"、"分析数据"、"统计/汇总"时使用;也适用于需要查看公式、样式、批注等详细信息的场景。仅针对飞书表格。 | diff --git a/skills/lark-sheets/references/lark-sheets-core-operations.md b/skills/lark-sheets/references/lark-sheets-core-operations.md index 0209724e9..cce1ad08b 100644 --- a/skills/lark-sheets/references/lark-sheets-core-operations.md +++ b/skills/lark-sheets/references/lark-sheets-core-operations.md @@ -4,6 +4,14 @@ 面向"已有飞书表格"的核心工作流,核心原则:**先了解,再分析或写入,最后验证**。本文是方法论总纲;具体工具的参数细节、边界陷阱在对应子 skill,本文用指针引到那里,不重复展开。 +**三份「通用方法与规范」如何分工**(都不含 shortcut,按主题单一归属): + +- **本文(core-operations)= 流程与铁律**:端到端工作流 + 全局铁律 + 横切陷阱,是读取入口与枢纽。 +- **`lark-sheets-visual-standards` = 样式知识**:配色 / 表头 / 数值格式 / 斑马纹 / 美化决策等"正确视觉输出"的全部标准。 +- **`lark-sheets-formula-translation` = 公式知识**:飞书公式书写与 Excel 迁移的全部正确性规则(绝对引用、范围语法、数组语义、不支持函数等)。 + +> **下面的铁律对所有任务一律生效**,即使你是被索引直接路由进 visual 或 formula 而没经过本文——编辑类任务务必先回到这里过一遍铁律。 + ## 铁律(所有编辑类任务必须满足,子 skill 不得放宽) 1. **最小改动**:除用户明示要改的单元格 / 列外,原表其它单元格、行列结构、Sheet 名、合并区、格式必须 1:1 保持。中间结果优先放原数据**右侧**;会与原数据混淆或要承载透视表 / 图表时才**新建空白 Sheet**。**禁止**擅自删 / 改名 / 隐藏 / 移动**已存在**的 Sheet(新建允许,节制使用)。 @@ -53,10 +61,8 @@ ## 公式策略 -- **公式优先于硬编码**(同铁律 4)。 -- **向下 / 向右填充先判断绝对引用**:用户指定的固定 cell(`$C$3`)、要固定的数据范围(`$A$2:$B$5`)、锁列不锁行(`$A2`)或锁行不锁列(`B$1`)要正确加 `$`;填充前检查是否需固定汇率 / 税率 / 查找表 / 权重表,以及同列 / 同行公式结构是否一致。 -- **公式字符串用飞书范围语法**(`H:H`、`A2:B5`,禁止 `H2:H` / `2:2`);与 CLI 工具参数的 A1 表示法(`A1:D3`、`1:1`)写法不同,混淆会调用失败。 -- Excel 迁移 / 数组语义(ARRAYFORMULA)/ 函数差异 / 不支持函数清单:一律以 `lark-sheets-formula-translation` 为唯一权威,先读它再写。 +- **公式优先于硬编码**(同铁律 4):能用公式表达的计算一律写公式,源数据变化才能自动重算。 +- **写任何公式前先读 `lark-sheets-formula-translation`**:它是公式正确性的唯一权威,覆盖绝对引用(`$`)、飞书范围语法(`H:H` 与工具 A1 表示法的区别)、ARRAYFORMULA / 数组语义、Excel 迁移、不支持函数清单等全部规则。本文不再单列这些细则。 ## 常见陷阱(铁律已覆盖的不再重复,仅列易漏点) @@ -76,7 +82,7 @@ ### 续写 / 复制已有区块格式 -完整写法见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」,样式标准(斑马纹 / 配色 / 边框层级)见 `lark-sheets-visual-standards` 场景二。核心(同铁律 5):读源区 `cell_styles` + `border_styles` + 行高 / 合并,写入带齐样式,再 `+batch-update` 复制合并与行高,最后回读校验。 +核心要求见铁律 5。机制(带齐哪些样式字段、怎么采样写入)见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」;样式标准(斑马纹奇偶 / 配色 / 边框层级)见 `lark-sheets-visual-standards` 场景二。本文不再展开。 ### NLP 任务处理 diff --git a/skills/lark-sheets/references/lark-sheets-formula-translation.md b/skills/lark-sheets/references/lark-sheets-formula-translation.md index 3c2249bfe..1145b0574 100644 --- a/skills/lark-sheets/references/lark-sheets-formula-translation.md +++ b/skills/lark-sheets/references/lark-sheets-formula-translation.md @@ -1,7 +1,15 @@ # 飞书表格公式生成规则 +> **本文定位**:飞书公式正确性的**唯一权威**——书写任何飞书公式、或把 Excel 公式迁移到飞书前,先读本文。涵盖公式书写约定(绝对引用、范围语法)、投影 vs spill、ARRAYFORMULA / 数组语义、高风险引用函数、日期差、不支持函数清单。 +> **边界**:本文只讲"公式怎么写对";公式**怎么写入表格**(`+cells-set` / 模板单元格 + `--copy-to-range` / 容错回读)见 `lark-sheets-write-cells` 与 `lark-sheets-core-operations`。本文不含 shortcut,铁律见 `lark-sheets-core-operations`。 + **核心原则:飞书不像 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`)写法不同,两者混淆会导致调用失败或公式报错。 + ## 翻译后必做:代码复现校验 公式语法翻译完之后,**必须**用本地脚本在源数据上独立复现一份"等价计算结果"再写入。流程: diff --git a/skills/lark-sheets/references/lark-sheets-visual-standards.md b/skills/lark-sheets/references/lark-sheets-visual-standards.md index 03f59c586..d1ffc57ac 100644 --- a/skills/lark-sheets/references/lark-sheets-visual-standards.md +++ b/skills/lark-sheets/references/lark-sheets-visual-standards.md @@ -1,5 +1,8 @@ # 飞书表格样式与配色规范 +> **本文定位**:飞书表格"正确视觉输出"的取值标准与美化决策流——配色、表头、对齐、数值格式、斑马纹、列宽行高、图表展示,以及新增 / 继承 / 美化已有区域三类场景的做法。 +> **边界**:本文只讲"样式长什么样、怎么决策";**怎么调用工具写入样式**(`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`。 + ## 最高优先级原则 - **用户指令优先**:用户明确提出的格式要求(如"使用红色背景")具有最高权重,即使与通用审美冲突。 @@ -23,6 +26,8 @@ ## 通用样式规范 +> 以下取值标准都在「最高优先级原则」的**继承原表风格 / 扩展而非覆盖**前提下生效:凡涉及"沿用原表"的条目,遵循该原则即可,本节不再逐条复述。 + ### 1. 表头样式 - 表头/汇总行须与数据区域有明确视觉区分。 @@ -66,9 +71,8 @@ ### 5. 配色 -- 优先沿用原文件配色。 +- 优先沿用原表色板与明暗层级(见「继承原表风格」),新增区域不凭空换色,确保视觉连续。 - 背景填充选择柔和色(如浅蓝 `#DDEBF7`),区分颜色时优先同一主题色不同深浅,避免超过 3 种主题色。 -- 新增区域禁止凭空换色,须沿用原表色板与明暗层级,确保视觉连续。 ### 6. 图表展示 From 4be06c85f6bbb4c41292dcc9a68f3fd0b4195d7a Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Mon, 25 May 2026 14:15:48 +0800 Subject: [PATCH 041/114] fix(sheets): correct +workbook-create initial fill and +dim-move endpoint +workbook-create: the v3 create response does not echo the default sheet's id, so the initial-fill set_cell_range was sent with an empty sheet_id and rejected ("sheet_id or sheet_name is required"). Resolve the workbook's first sheet via get_workbook_structure before filling. +dim-move: the move request was POSTed to the v2 dimension_range endpoint (the add/update/delete surface, which requires a `dimension` object) and rejected with "[9499] Missing required parameter: Dimension". Switch to the native v3 move_dimension endpoint (sheet_id in path; snake_case source.{major_dimension,start_index,end_index} + destination_index). CLI --end and v3 end_index are both 0-based inclusive, so they pass through unchanged. --- shortcuts/sheets/execute_paths_test.go | 28 +++++--- .../sheets/lark_sheet_sheet_structure.go | 48 ++++++++------ .../sheets/lark_sheet_sheet_structure_test.go | 31 +++++---- shortcuts/sheets/lark_sheet_workbook.go | 66 +++++++++++++++++-- 4 files changed, 124 insertions(+), 49 deletions(-) diff --git a/shortcuts/sheets/execute_paths_test.go b/shortcuts/sheets/execute_paths_test.go index 770ad7bd0..fbe55c76c 100644 --- a/shortcuts/sheets/execute_paths_test.go +++ b/shortcuts/sheets/execute_paths_test.go @@ -271,8 +271,8 @@ func TestExecute_BatchUpdate_Translated(t *testing.T) { } } -// TestExecute_WorkbookCreate covers the legacy POST + optional -// set_cell_range follow-up. Stubs both endpoints. +// TestExecute_WorkbookCreate covers the create POST + first-sheet lookup + +// set_cell_range follow-up. Stubs all three endpoints. func TestExecute_WorkbookCreate(t *testing.T) { t.Parallel() create := &httpmock.Stub{ @@ -289,12 +289,15 @@ func TestExecute_WorkbookCreate(t *testing.T) { }, }, } + // Initial fill first reads the workbook structure to resolve the default + // sheet's id (the create response doesn't echo it), then writes. + structure := toolOutputStub("shtcnBRAND", "read", `{"sheets":[{"sheet_id":"shtFirst","sheet_name":"Sheet1","index":0}]}`) fill := toolOutputStub("shtcnBRAND", "write", `{"updated_cells":4}`) out, err := runShortcutWithStubs(t, WorkbookCreate, []string{ "--title", "Sales", "--headers", `["Name","Score"]`, "--values", `[["alice",95]]`, - }, create, fill) + }, create, structure, fill) if err != nil { t.Fatalf("execute failed: %v\nout=%s", err, out) } @@ -306,15 +309,21 @@ func TestExecute_WorkbookCreate(t *testing.T) { if data["initial_fill"] == nil { t.Errorf("initial_fill missing in envelope") } + // The fill must target the resolved first sheet, not an empty selector. + fillInput := decodeToolInput(t, decodeRawEnvelopeBody(t, fill.CapturedBody), "set_cell_range") + if fillInput["sheet_id"] != "shtFirst" { + t.Errorf("fill sheet_id = %v, want shtFirst (resolved from workbook structure)", fillInput["sheet_id"]) + } } -// TestExecute_DimMove covers the legacy v2 dimension_range call with -// CLI inclusive → API exclusive end-index conversion. +// TestExecute_DimMove covers the native v3 move_dimension call. CLI's +// 0-based inclusive --start/--end pass straight through to v3's +// source.{start_index,end_index} (also 0-based inclusive). func TestExecute_DimMove(t *testing.T) { t.Parallel() move := &httpmock.Stub{ Method: "POST", - URL: "/open-apis/sheets/v2/spreadsheets/" + testToken + "/dimension_range", + URL: "/open-apis/sheets/v3/spreadsheets/" + testToken + "/sheets/" + testSheetID + "/move_dimension", Body: map[string]interface{}{ "code": 0, "msg": "success", @@ -330,8 +339,11 @@ func TestExecute_DimMove(t *testing.T) { } body := decodeRawEnvelopeBody(t, move.CapturedBody) src, _ := body["source"].(map[string]interface{}) - if src["startIndex"].(float64) != 0 || src["endIndex"].(float64) != 3 { - t.Errorf("indices = (%v,%v), want (0,3)", src["startIndex"], src["endIndex"]) + if src["start_index"].(float64) != 0 || src["end_index"].(float64) != 2 { + t.Errorf("indices = (%v,%v), want (0,2) — 0-based inclusive", src["start_index"], src["end_index"]) + } + if body["destination_index"].(float64) != 10 { + t.Errorf("destination_index = %v, want 10", body["destination_index"]) } } diff --git a/shortcuts/sheets/lark_sheet_sheet_structure.go b/shortcuts/sheets/lark_sheet_sheet_structure.go index 934f6d889..d4c477a27 100644 --- a/shortcuts/sheets/lark_sheet_sheet_structure.go +++ b/shortcuts/sheets/lark_sheet_sheet_structure.go @@ -531,12 +531,17 @@ func columnIndexToLetter(idx int) string { return string(out) } -// ─── +dim-move (legacy OAPI, cli_status: cli-only) ─────────────────── +// ─── +dim-move (native v3 move_dimension, cli_status: cli-only) ────── // // Moves a contiguous block of rows or columns to a new index in the same -// sheet via the legacy v2 endpoint (not the One-OpenAPI dispatcher). -// CLI's --start / --end are 0-based inclusive; the endpoint expects -// half-open [startIndex, endIndex). +// sheet via the native v3 move_dimension endpoint (not the One-OpenAPI +// dispatcher). CLI's --start / --end are 0-based inclusive; v3 +// move_dimension's source.{start_index,end_index} are likewise 0-based +// inclusive, so they pass straight through. The earlier build POSTed a +// {source,destinationIndex} body to the v2 dimension_range endpoint, which +// is the add/update/delete surface and expects a `dimension` object — +// hence the server rejected it with "[9499] Missing required parameter: +// Dimension". var DimMove = common.Shortcut{ Service: "sheets", @@ -568,10 +573,9 @@ var DimMove = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - body := dimMoveBody(runtime, sheetSelectorPlaceholder(sheetID, sheetName)) return common.NewDryRunAPI(). - POST(fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", token)). - Body(body). + POST(dimMovePath(token, sheetSelectorPlaceholder(sheetID, sheetName))). + Body(dimMoveBody(runtime)). Set("spreadsheet_token", token) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { @@ -583,8 +587,9 @@ var DimMove = common.Shortcut{ if err != nil { return err } - // Legacy v2 endpoint needs sheet_id. Resolve sheet_name client-side - // when needed (reuses lookupSheetIndex which fetches workbook structure). + // v3 move_dimension carries sheet_id in the path. Resolve + // sheet_name client-side when needed (reuses lookupSheetIndex + // which fetches workbook structure). if sheetID == "" { lookedID, _, err := lookupSheetIndex(ctx, runtime, token, "", sheetName) if err != nil { @@ -592,12 +597,7 @@ var DimMove = common.Shortcut{ } sheetID = lookedID } - body := dimMoveBody(runtime, sheetID) - data, err := runtime.CallAPI( - "POST", - fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)), - nil, body, - ) + data, err := runtime.CallAPI("POST", dimMovePath(token, sheetID), nil, dimMoveBody(runtime)) if err != nil { return err } @@ -606,18 +606,24 @@ var DimMove = common.Shortcut{ }, } -func dimMoveBody(runtime *common.RuntimeContext, sheetID string) map[string]interface{} { +// dimMovePath builds the native v3 move_dimension endpoint. sheet_id lives in +// the path (unlike the v2 dimension_range body that the earlier build used). +func dimMovePath(token, sheetID string) string { + return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/move_dimension", + validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)) +} + +func dimMoveBody(runtime *common.RuntimeContext) map[string]interface{} { dim := "ROWS" if runtime.Str("dimension") == "column" { dim = "COLUMNS" } return map[string]interface{}{ "source": map[string]interface{}{ - "sheetId": sheetID, - "majorDimension": dim, - "startIndex": runtime.Int("start"), - "endIndex": runtime.Int("end") + 1, // CLI inclusive → API exclusive + "major_dimension": dim, + "start_index": runtime.Int("start"), + "end_index": runtime.Int("end"), // both CLI --end and v3 end_index are 0-based inclusive }, - "destinationIndex": runtime.Int("target"), + "destination_index": runtime.Int("target"), } } diff --git a/shortcuts/sheets/lark_sheet_sheet_structure_test.go b/shortcuts/sheets/lark_sheet_sheet_structure_test.go index a5e6a3ff1..d5443ef81 100644 --- a/shortcuts/sheets/lark_sheet_sheet_structure_test.go +++ b/shortcuts/sheets/lark_sheet_sheet_structure_test.go @@ -169,9 +169,10 @@ func TestDimRange_StartEndValidation(t *testing.T) { } } -// TestDimMove_DryRun verifies the legacy v2 dimension_range payload -// shape. CLI's 0-based inclusive (--start / --end) becomes the v2 -// endpoint's half-open [startIndex, endIndex). +// TestDimMove_DryRun verifies the native v3 move_dimension payload shape. +// CLI's 0-based inclusive (--start / --end) maps straight through to v3's +// source.{start_index,end_index} (also 0-based inclusive), and sheet_id is +// carried in the path, not the body. func TestDimMove_DryRun(t *testing.T) { t.Parallel() calls := parseDryRunAPI(t, DimMove, []string{ @@ -182,25 +183,23 @@ func TestDimMove_DryRun(t *testing.T) { t.Fatalf("api calls = %d, want 1", len(calls)) } c := calls[0].(map[string]interface{}) - if !strings.Contains(c["url"].(string), "/sheets/v2/spreadsheets/") { - t.Errorf("url = %v, want sheets/v2 path", c["url"]) + wantURL := "/sheets/v3/spreadsheets/" + testToken + "/sheets/" + testSheetID + "/move_dimension" + if !strings.Contains(c["url"].(string), wantURL) { + t.Errorf("url = %v, want suffix %v", c["url"], wantURL) } body, _ := c["body"].(map[string]interface{}) src, _ := body["source"].(map[string]interface{}) - if src["sheetId"] != testSheetID { - t.Errorf("source.sheetId = %v", src["sheetId"]) + if src["major_dimension"] != "ROWS" { + t.Errorf("source.major_dimension = %v, want ROWS", src["major_dimension"]) } - if src["majorDimension"] != "ROWS" { - t.Errorf("source.majorDimension = %v, want ROWS", src["majorDimension"]) + if src["start_index"].(float64) != 0 { + t.Errorf("start_index = %v, want 0", src["start_index"]) } - if src["startIndex"].(float64) != 0 { - t.Errorf("startIndex = %v, want 0", src["startIndex"]) + if src["end_index"].(float64) != 2 { + t.Errorf("end_index = %v, want 2 (0-based inclusive, passes straight through)", src["end_index"]) } - if src["endIndex"].(float64) != 3 { - t.Errorf("endIndex = %v, want 3 (CLI end+1 for half-open)", src["endIndex"]) - } - if body["destinationIndex"].(float64) != 10 { - t.Errorf("destinationIndex = %v, want 10", body["destinationIndex"]) + if body["destination_index"].(float64) != 10 { + t.Errorf("destination_index = %v, want 10", body["destination_index"]) } } diff --git a/shortcuts/sheets/lark_sheet_workbook.go b/shortcuts/sheets/lark_sheet_workbook.go index a062214bd..2d788421c 100644 --- a/shortcuts/sheets/lark_sheet_workbook.go +++ b/shortcuts/sheets/lark_sheet_workbook.go @@ -600,9 +600,11 @@ var WorkbookCreate = common.Shortcut{ Body(body) if runtime.Str("headers") != "" || runtime.Str("values") != "" { fill, _ := buildInitialFillInput(runtime) + fill["excel_id"] = "" + fill["sheet_id"] = "" // resolved from the workbook at execute time wireBody, _ := buildToolBody("set_cell_range", fill) dry.POST("/open-apis/sheet_ai/v2/spreadsheets//tools/invoke_write"). - Desc("fill headers + data via set_cell_range"). + Desc("fill headers + data via set_cell_range (sheet_id resolved after create)"). Body(wireBody) } return dry @@ -633,6 +635,13 @@ var WorkbookCreate = common.Shortcut{ return err } fill["excel_id"] = token + // set_cell_range needs a concrete sheet selector; the create + // response doesn't echo the default sheet's id, so read it back. + firstSheetID, err := lookupFirstSheetID(ctx, runtime, token) + if err != nil { + return fmt.Errorf("spreadsheet %s created but resolving its first sheet for initial fill failed: %w", token, err) + } + fill["sheet_id"] = firstSheetID fillOut, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", fill) if err != nil { // Spreadsheet exists; surface the fill failure but keep the new @@ -692,9 +701,11 @@ func buildInitialFillInput(runtime *common.RuntimeContext) (map[string]interface endCol := columnIndexToLetter(maxCols - 1) rangeStr := fmt.Sprintf("A1:%s%d", endCol, len(rows)) return map[string]interface{}{ - "range": rangeStr, - "cells": rows, - "sheet_id": "", // filled in by caller if sheet_id known; otherwise server picks first sheet + "range": rangeStr, + "cells": rows, + // sheet_id is left for the caller to fill: Execute resolves the new + // workbook's first sheet via lookupFirstSheetID. The DryRun preview + // can't know it yet (the workbook doesn't exist), so it stays absent. }, nil } @@ -940,3 +951,50 @@ func lookupSheetIndex(ctx context.Context, runtime *common.RuntimeContext, token } return "", 0, output.Errorf(output.ExitAPI, "not_found", fmt.Sprintf("sheet %q not found in workbook", target)) } + +// lookupFirstSheetID returns the sheet_id of the sub-sheet at index 0 (the +// default sheet of a freshly created workbook). Used by +workbook-create to +// target the initial-fill set_cell_range write — set_cell_range rejects an +// empty sheet selector ("sheet_id or sheet_name is required"), and the v3 +// create-spreadsheet response does not echo the default sheet's id. +func lookupFirstSheetID(ctx context.Context, runtime *common.RuntimeContext, token string) (string, error) { + out, err := callTool(ctx, runtime, token, ToolKindRead, "get_workbook_structure", map[string]interface{}{ + "excel_id": token, + }) + if err != nil { + return "", err + } + m, ok := out.(map[string]interface{}) + if !ok { + return "", output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned non-object output") + } + sheets, _ := m["sheets"].([]interface{}) + bestID := "" + bestIdx := -1 + for _, raw := range sheets { + sm, ok := raw.(map[string]interface{}) + if !ok { + continue + } + id, _ := sm["sheet_id"].(string) + if id == "" { + continue + } + idx, ok := util.ToFloat64(sm["index"]) + if !ok { + // No index field — fall back to first encountered sheet. + if bestID == "" { + bestID = id + } + continue + } + if bestIdx < 0 || int(idx) < bestIdx { + bestIdx = int(idx) + bestID = id + } + } + if bestID == "" { + return "", output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned no sheets") + } + return bestID, nil +} From 556b2292c703631d9d9548726bb17e045b42e2bd Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Fri, 22 May 2026 11:37:35 +0800 Subject: [PATCH 042/114] fix(sheets): align +workbook-create, +dropdown-*, +dim-move, +range-sort with server schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five separate E2E failures in shortcuts/sheets/ that all trace back to a CLI ↔ server contract mismatch. Each is independently scoped; bundling them because they share the test-report citation and the same one-line fix shape in most cases. buildInitialFillInput sent {"sheet_id": ""} on the secondary set_cell_range call after creating the workbook. The empty value was a holdover from "...otherwise server picks first sheet" — but set_cell_range rejects an empty selector with "sheet_id or sheet_name is required" rather than falling back to the default sheet. Use sheet_name "Sheet1" instead. POST /sheets/v3/spreadsheets always creates that sheet on workbook creation, and set_cell_range accepts sheet_name as an equivalent selector — saves an extra get_workbook_structure round-trip just to learn the auto-generated id. buildDropdownValidation emitted four fields that don't exist in the canonical set_cell_range.data_validation schema: - "values" (options list) → renamed to "items" - "multiple_values" → renamed to "support_multiple_values" - "colors" (per-option color) → removed (not in schema; flag also removed from data/flag-defs.json for +dropdown-set / -update) - "highlight_options" → removed (not in schema; flag also removed) The canonical schema lives at sheet-skill-spec/canonical-spec/tool- schemas/mcp-tools.json (set_cell_range tool, data_validation property); the colors / highlight knobs were CLI inventions the server never accepted, so removing the flags is correct (renaming would leave the flags broken). Skill reference docs (write-cells.md, batch-update.md) synced. validateDropdownOptionsColors lost its colors check; renamed to validateDropdownOptions to reflect the narrower contract. dropdownGetInput sent "Sheet1!C2:C6" verbatim as a ranges[] entry. get_cell_ranges expects sheet_id / sheet_name as separate fields and ranges entries without the sheet prefix; the server bounced with "sheet not found, sheetId:" (empty). Use the existing splitSheetPrefixedRange helper (declared in lark_sheet_batch_update.go) to break "Sheet1!C2:C6" into ("Sheet1", "C2:C6"), then thread the sheet name through sheetSelectorForToolInput exactly like +cells-get does. The shortcut was POSTing to /sheets/v2/spreadsheets/{token}/dimension_ range, which is the v2 insert-dimension endpoint and requires a top- level {"dimension": {...}} body. Move uses a separate endpoint: POST /sheets/v2/spreadsheets/{token}/move_dimension body: { "source": {...}, "destination_index": N } (camelCase "destinationIndex" → snake_case "destination_index" to match the v2 contract.) Both DryRun and Execute updated, plus the TestDimMove_DryRun and TestExecute_DimMove assertions. transform_range.sort_conditions[i] requires both `column` (string) and `ascending` (bool); rangeSortInput passed the --sort-keys array through to the server unvalidated, so missing fields surfaced as opaque "required property X missing" errors with no per-item context. Walk the parsed array client-side, reject with item-pointing messages. Test fixtures and a contract-test fixture switched from the historical {col, order} vocabulary (which the server has never accepted) to the correct {column, ascending}. Server-schema citations and test-report case mapping in this branch's plan file. --- shortcuts/sheets/batch_op_contract_test.go | 4 +- shortcuts/sheets/data/flag-defs.json | 36 --------------- shortcuts/sheets/helpers.go | 2 +- shortcuts/sheets/lark_sheet_batch_update.go | 2 +- .../sheets/lark_sheet_batch_update_test.go | 8 +++- .../sheets/lark_sheet_range_operations.go | 17 +++++++ .../lark_sheet_range_operations_test.go | 41 +++++++++++++++-- shortcuts/sheets/lark_sheet_read_data.go | 17 +++++-- shortcuts/sheets/lark_sheet_read_data_test.go | 3 +- shortcuts/sheets/lark_sheet_workbook_test.go | 9 ++++ shortcuts/sheets/lark_sheet_write_cells.go | 44 ++++++------------- .../sheets/lark_sheet_write_cells_test.go | 13 ++++-- .../references/lark-sheets-batch-update.md | 2 - .../references/lark-sheets-write-cells.md | 2 - 14 files changed, 113 insertions(+), 87 deletions(-) diff --git a/shortcuts/sheets/batch_op_contract_test.go b/shortcuts/sheets/batch_op_contract_test.go index 3f178006f..58d1d7847 100644 --- a/shortcuts/sheets/batch_op_contract_test.go +++ b/shortcuts/sheets/batch_op_contract_test.go @@ -132,8 +132,8 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) { { shortcut: "+range-sort", sc: RangeSort, - args: []string{"--sheet-id", "sh1", "--range", "A1:D10", "--sort-keys", `[{"col":"B","order":"asc"}]`, "--has-header"}, - subInput: `{"sheet-id":"sh1","range":"A1:D10","sort-keys":[{"col":"B","order":"asc"}],"has-header":true}`, + args: []string{"--sheet-id", "sh1", "--range", "A1:D10", "--sort-keys", `[{"column":"B","ascending":true}]`, "--has-header"}, + subInput: `{"sheet-id":"sh1","range":"A1:D10","sort-keys":[{"column":"B","ascending":true}],"has-header":true}`, }, { shortcut: "+sheet-create", diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index d8687e227..b1000009e 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -1874,17 +1874,6 @@ "stdin" ] }, - { - "name": "colors", - "kind": "own", - "type": "string", - "required": "optional", - "desc": "RGB hex color array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`); length must equal `--options`", - "input": [ - "file", - "stdin" - ] - }, { "name": "multiple", "kind": "own", @@ -1892,13 +1881,6 @@ "required": "optional", "desc": "Enable multi-select; default `false`" }, - { - "name": "highlight", - "kind": "own", - "type": "bool", - "required": "optional", - "desc": "Color-highlight options; default `false`" - }, { "name": "dry-run", "kind": "system", @@ -2802,17 +2784,6 @@ "stdin" ] }, - { - "name": "colors", - "kind": "own", - "type": "string", - "required": "optional", - "desc": "Color array (same length as `--options`)", - "input": [ - "file", - "stdin" - ] - }, { "name": "multiple", "kind": "own", @@ -2820,13 +2791,6 @@ "required": "optional", "desc": "Enable multi-select" }, - { - "name": "highlight", - "kind": "own", - "type": "bool", - "required": "optional", - "desc": "Color-highlight options" - }, { "name": "dry-run", "kind": "system", diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go index 64206b9e2..79b7223a3 100644 --- a/shortcuts/sheets/helpers.go +++ b/shortcuts/sheets/helpers.go @@ -156,7 +156,7 @@ func sheetSelectorPlaceholder(sheetID, sheetName string) string { // parseJSONFlag parses a JSON string from a flag value. Returns nil when the // flag is empty (caller decides if that's acceptable). Used by --data / -// --style / --options / --ranges / --colors and friends. +// --style / --options / --ranges / --properties and friends. func parseJSONFlag(runtime flagView, name string) (interface{}, error) { raw := strings.TrimSpace(runtime.Str(name)) if raw == "" { diff --git a/shortcuts/sheets/lark_sheet_batch_update.go b/shortcuts/sheets/lark_sheet_batch_update.go index d6591bb52..abf51868c 100644 --- a/shortcuts/sheets/lark_sheet_batch_update.go +++ b/shortcuts/sheets/lark_sheet_batch_update.go @@ -332,7 +332,7 @@ var DropdownUpdate = common.Shortcut{ if _, err := validateDropdownRanges(runtime); err != nil { return err } - if _, err := validateDropdownOptionsColors(runtime); err != nil { + if _, err := validateDropdownOptions(runtime); err != nil { return err } return nil diff --git a/shortcuts/sheets/lark_sheet_batch_update_test.go b/shortcuts/sheets/lark_sheet_batch_update_test.go index 26220ff2a..e7885ab5c 100644 --- a/shortcuts/sheets/lark_sheet_batch_update_test.go +++ b/shortcuts/sheets/lark_sheet_batch_update_test.go @@ -215,8 +215,12 @@ func TestDropdownUpdate_BatchPayload(t *testing.T) { if dv == nil || dv["type"] != "list" { t.Errorf("op[%d] missing data_validation list: %#v", i, cell) } - if dv["multiple_values"] != true { - t.Errorf("op[%d] multiple_values = %v, want true", i, dv["multiple_values"]) + items, _ := dv["items"].([]interface{}) + if len(items) != 3 { + t.Errorf("op[%d] data_validation.items length = %d, want 3", i, len(items)) + } + if dv["support_multiple_values"] != true { + t.Errorf("op[%d] support_multiple_values = %v, want true", i, dv["support_multiple_values"]) } } } diff --git a/shortcuts/sheets/lark_sheet_range_operations.go b/shortcuts/sheets/lark_sheet_range_operations.go index e30b9666c..2e00c11d5 100644 --- a/shortcuts/sheets/lark_sheet_range_operations.go +++ b/shortcuts/sheets/lark_sheet_range_operations.go @@ -598,6 +598,23 @@ func rangeSortInput(runtime flagView, token, sheetID, sheetName string) (map[str if err != nil { return nil, err } + // transform_range.sort_conditions[i] requires both `column` (string) + // and `ascending` (bool); the server's own validation surfaces a + // terse "required property X is missing" with no per-item context. + // Pre-check here so the user sees which entry is malformed. + for i, raw := range keys { + item, ok := raw.(map[string]interface{}) + if !ok { + return nil, common.FlagErrorf("--sort-keys[%d] must be an object {column, ascending}; got %T", i, raw) + } + col, _ := item["column"].(string) + if strings.TrimSpace(col) == "" { + return nil, common.FlagErrorf("--sort-keys[%d] missing required string field `column` (the column letter to sort by, e.g. \"C\")", i) + } + if _, ok := item["ascending"].(bool); !ok { + return nil, common.FlagErrorf("--sort-keys[%d] missing required bool field `ascending`", i) + } + } input := map[string]interface{}{ "excel_id": token, "operation": "sort", diff --git a/shortcuts/sheets/lark_sheet_range_operations_test.go b/shortcuts/sheets/lark_sheet_range_operations_test.go index 73930f697..8bc3c171d 100644 --- a/shortcuts/sheets/lark_sheet_range_operations_test.go +++ b/shortcuts/sheets/lark_sheet_range_operations_test.go @@ -185,7 +185,7 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) { { name: "+range-sort multi-key with header", sc: RangeSort, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:E100", "--has-header", "--sort-keys", `[{"col":"B","order":"asc"},{"col":"D","order":"desc"}]`}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:E100", "--has-header", "--sort-keys", `[{"column":"B","ascending":true},{"column":"D","ascending":false}]`}, toolName: "transform_range", wantInput: map[string]interface{}{ "excel_id": testToken, @@ -194,8 +194,8 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) { "range": "A1:E100", "has_header": true, "sort_conditions": []interface{}{ - map[string]interface{}{"col": "B", "order": "asc"}, - map[string]interface{}{"col": "D", "order": "desc"}, + map[string]interface{}{"column": "B", "ascending": true}, + map[string]interface{}{"column": "D", "ascending": false}, }, }, }, @@ -211,6 +211,41 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) { } } +// TestRangeSort_RejectsMalformedKeys verifies the pre-check that each +// --sort-keys entry has both `column` (string) and `ascending` (bool); +// previously the CLI passed any JSON through and the server bounced +// with a terse "required property X missing" that didn't name the bad +// entry. +func TestRangeSort_RejectsMalformedKeys(t *testing.T) { + t.Parallel() + cases := []struct { + name string + keys string + want string + }{ + {"missing column", `[{"ascending":true}]`, "missing required string field `column`"}, + {"missing ascending", `[{"column":"B"}]`, "missing required bool field `ascending`"}, + {"old vocab col/order", `[{"col":"B","order":"asc"}]`, "missing required string field `column`"}, + {"non-object item", `["B"]`, "must be an object"}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, RangeSort, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1:E10", "--sort-keys", c.keys, "--dry-run", + }) + if err == nil { + t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) + } + if !strings.Contains(stdout+stderr+err.Error(), c.want) { + t.Errorf("want substring %q in error; got stdout=%s stderr=%s err=%v", c.want, stdout, stderr, err) + } + }) + } +} + func TestResize_TypeAndSizeGuards(t *testing.T) { t.Parallel() cases := []struct { diff --git a/shortcuts/sheets/lark_sheet_read_data.go b/shortcuts/sheets/lark_sheet_read_data.go index 7c036ac3e..89a4b49db 100644 --- a/shortcuts/sheets/lark_sheet_read_data.go +++ b/shortcuts/sheets/lark_sheet_read_data.go @@ -215,8 +215,12 @@ func stripRowPrefixFromCsvOutput(out interface{}) interface{} { } // DropdownGet wraps get_cell_ranges scoped to data_validation: read the -// dropdown configuration on a range. The range carries its own sheet prefix -// (e.g. "sheet1!A2:A100"), so no separate --sheet-id / --sheet-name is needed. +// dropdown configuration on a range. The CLI accepts the range in the +// sheet-prefixed form (e.g. "sheet1!A2:A100") for convenience; the +// prefix is split client-side into sheet_name + bare A1 because the +// get_cell_ranges tool wants sheet selector and ranges as separate +// fields (ranges with the "sheet!" prefix gets the empty-sheet_id +// rejection from the server). var DropdownGet = common.Shortcut{ Service: "sheets", Command: "+dropdown-get", @@ -257,10 +261,15 @@ var DropdownGet = common.Shortcut{ } func dropdownGetInput(runtime *common.RuntimeContext, token string) map[string]interface{} { - return map[string]interface{}{ + // Validate already enforced the "Sheet!range" prefix, so the + // split error path can't be reached here in practice. + sheetName, bareRange, _ := splitSheetPrefixedRange(strings.TrimSpace(runtime.Str("range"))) + input := map[string]interface{}{ "excel_id": token, - "ranges": []string{strings.TrimSpace(runtime.Str("range"))}, + "ranges": []string{bareRange}, "include_styles": false, "value_render_option": "formatted_value", } + sheetSelectorForToolInput(input, "", sheetName) + return input } diff --git a/shortcuts/sheets/lark_sheet_read_data_test.go b/shortcuts/sheets/lark_sheet_read_data_test.go index 30a9a1a55..78233f795 100644 --- a/shortcuts/sheets/lark_sheet_read_data_test.go +++ b/shortcuts/sheets/lark_sheet_read_data_test.go @@ -54,7 +54,8 @@ func TestReadDataShortcuts_DryRun(t *testing.T) { toolName: "get_cell_ranges", wantInput: map[string]interface{}{ "excel_id": testToken, - "ranges": []interface{}{"sheet1!A2:A100"}, + "sheet_name": "sheet1", + "ranges": []interface{}{"A2:A100"}, "include_styles": false, "value_render_option": "formatted_value", }, diff --git a/shortcuts/sheets/lark_sheet_workbook_test.go b/shortcuts/sheets/lark_sheet_workbook_test.go index f7b257fc2..5298d41c0 100644 --- a/shortcuts/sheets/lark_sheet_workbook_test.go +++ b/shortcuts/sheets/lark_sheet_workbook_test.go @@ -317,6 +317,15 @@ func TestWorkbookCreate_DryRun(t *testing.T) { if input["range"] != "A1:B3" { t.Errorf("fill range = %v, want A1:B3 (1 header + 2 data rows × 2 cols)", input["range"]) } + // New workbook → fill targets the default sheet by name (no + // extra get_workbook_structure call is needed to learn the + // auto-generated sheet_id). + if input["sheet_name"] != "Sheet1" { + t.Errorf("fill sheet_name = %v, want Sheet1", input["sheet_name"]) + } + if _, hasID := input["sheet_id"]; hasID { + t.Errorf("fill sheet_id should be omitted (server rejects empty); got %v", input["sheet_id"]) + } }) } diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index 3693129de..671fd673d 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -331,52 +331,36 @@ func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[s // ─── shared dropdown helpers ────────────────────────────────────────── -// buildDropdownValidation packs --options / --colors / --multiple / --highlight -// into the data_validation block expected by set_cell_range. +// buildDropdownValidation packs --options / --multiple into the +// data_validation block expected by set_cell_range. Field names match +// the canonical schema: items (not values) for the option list, and +// support_multiple_values (not multiple_values) for multi-select. +// Earlier CLI builds also emitted `colors` and `highlight_options`, +// neither of which exists in the server schema for set_cell_range; both +// were rejected as "unexpected property". The --colors / --highlight +// flags have been removed; only --options + --multiple remain. func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) { options, err := requireJSONArray(runtime, "options") if err != nil { return nil, err } dv := map[string]interface{}{ - "type": "list", - "values": options, - } - if runtime.Str("colors") != "" { - colors, err := requireJSONArray(runtime, "colors") - if err != nil { - return nil, err - } - if len(colors) != len(options) { - return nil, common.FlagErrorf("--colors length (%d) must equal --options length (%d)", len(colors), len(options)) - } - dv["colors"] = colors + "type": "list", + "items": options, } if runtime.Bool("multiple") { - dv["multiple_values"] = true - } - if runtime.Bool("highlight") { - dv["highlight_options"] = true + dv["support_multiple_values"] = true } return dv, nil } -// validateDropdownOptionsColors validates --options is a JSON array and that -// --colors (when set) has matching length. Used by +dropdown-update Validate. -func validateDropdownOptionsColors(runtime flagView) (int, error) { +// validateDropdownOptions checks that --options parses as a JSON array +// and returns its length so callers can size their cells matrix. +func validateDropdownOptions(runtime flagView) (int, error) { options, err := requireJSONArray(runtime, "options") if err != nil { return 0, err } - if runtime.Str("colors") != "" { - colors, err := requireJSONArray(runtime, "colors") - if err != nil { - return 0, err - } - if len(colors) != len(options) { - return 0, common.FlagErrorf("--colors length (%d) must equal --options length (%d)", len(colors), len(options)) - } - } return len(options), nil } diff --git a/shortcuts/sheets/lark_sheet_write_cells_test.go b/shortcuts/sheets/lark_sheet_write_cells_test.go index e9a0b289e..2e3543bb9 100644 --- a/shortcuts/sheets/lark_sheet_write_cells_test.go +++ b/shortcuts/sheets/lark_sheet_write_cells_test.go @@ -95,7 +95,7 @@ func TestWriteCellsShortcuts_DryRun(t *testing.T) { "--url", testURL, "--sheet-id", testSheetID, "--range", "A2:A4", "--options", `["a","b"]`, - "--multiple", "--highlight", + "--multiple", }, toolName: "set_cell_range", wantInput: map[string]interface{}{ @@ -143,8 +143,15 @@ func TestDropdownSet_CellsShape(t *testing.T) { if dv["type"] != "list" { t.Errorf("row %d data_validation.type = %v, want list", i, dv["type"]) } - if dv["multiple_values"] != true { - t.Errorf("row %d data_validation.multiple_values = %v, want true", i, dv["multiple_values"]) + items, _ := dv["items"].([]interface{}) + if len(items) != 2 || items[0] != "a" || items[1] != "b" { + t.Errorf("row %d data_validation.items = %#v, want [\"a\",\"b\"]", i, dv["items"]) + } + if dv["support_multiple_values"] != true { + t.Errorf("row %d data_validation.support_multiple_values = %v, want true", i, dv["support_multiple_values"]) + } + if _, hasLegacy := dv["multiple_values"]; hasLegacy { + t.Errorf("row %d data_validation should not emit legacy `multiple_values`", i) } } } diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index ba3160e0b..7b04905a1 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -70,9 +70,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(如 `["sheet1!A2:A100"]`),每项必须带 sheet 前缀 | | `--options` | string + File + Stdin(复合 JSON) | required | 选项 JSON 数组(如 `["opt1","opt2"]`) | -| `--colors` | string + File + Stdin(简单 JSON) | optional | 颜色数组(与 `--options` 等长) | | `--multiple` | bool | optional | 启用多选 | -| `--highlight` | bool | optional | 选项配色 | ### `+dropdown-delete` diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 6e1887031..6ebe2b154 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -199,9 +199,7 @@ _公共四件套 · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--range` | string | required | 目标范围(A1 格式,如 `A2:A100`) | | `--options` | string + File + Stdin(复合 JSON) | required | 选项 JSON 数组 `["opt1","opt2"]`;最多 500 项,每项 ≤100 字符,不含逗号 | -| `--colors` | string + File + Stdin(简单 JSON) | optional | RGB hex 颜色数组(如 `["#1FB6C1","#F006C2"]`),长度必须与 `--options` 一致 | | `--multiple` | bool | optional | 启用多选;默认 `false` | -| `--highlight` | bool | optional | 选项配色显示;默认 `false` | ### `+csv-put` From 5926e89ce33c2b9ccd7affc97f0b3fc206b9c4d4 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Fri, 22 May 2026 11:42:36 +0800 Subject: [PATCH 043/114] =?UTF-8?q?revert(sheets):=20drop=20direct=20flag-?= =?UTF-8?q?defs.json=20edits=20=E2=80=94=20generated=20from=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit data/flag-defs.json is regenerated from the upstream sheet-skill-spec canonical-spec; editing it here gets clobbered on the next sync. The schema realignment for +dropdown-set / -update --colors / --highlight removal needs to land on the base table first, then flow back through sheet-skill-spec → larksuite-cli sync, not via a direct CLI-side edit. Restore the previous flag entries verbatim. The Go-side change in buildDropdownValidation still drops the wire fields, so: - users passing --colors / --highlight today see the flag accepted silently (no effect on the wire) until the upstream removal lands; - after upstream removal + sync, both the flag declarations and the Go-side handling will be in sync. Functional fixes (#1 workbook-create, #3 dropdown-get, #4 dim-move, #5 range-sort) and dropdown wire-shape rename (#2) are unaffected. --- shortcuts/sheets/data/flag-defs.json | 36 ++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index b1000009e..d8687e227 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -1874,6 +1874,17 @@ "stdin" ] }, + { + "name": "colors", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "RGB hex color array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`); length must equal `--options`", + "input": [ + "file", + "stdin" + ] + }, { "name": "multiple", "kind": "own", @@ -1881,6 +1892,13 @@ "required": "optional", "desc": "Enable multi-select; default `false`" }, + { + "name": "highlight", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Color-highlight options; default `false`" + }, { "name": "dry-run", "kind": "system", @@ -2784,6 +2802,17 @@ "stdin" ] }, + { + "name": "colors", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Color array (same length as `--options`)", + "input": [ + "file", + "stdin" + ] + }, { "name": "multiple", "kind": "own", @@ -2791,6 +2820,13 @@ "required": "optional", "desc": "Enable multi-select" }, + { + "name": "highlight", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Color-highlight options" + }, { "name": "dry-run", "kind": "system", From 9d06652aa929f5f1c254570199f29c0b2ba7410b Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Fri, 22 May 2026 12:25:27 +0800 Subject: [PATCH 044/114] revert(sheets): drop direct edits to skills/lark-sheets/references/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These md files are sync targets generated from sheet-skill-spec; editing them here gets clobbered on the next sync, same as data/flag-defs.json. The --colors / --highlight row removals belong on the upstream base table → canonical-spec sync, not here. Restore the previous --colors / --highlight rows in both lark-sheets-write-cells.md (+dropdown-set) and lark-sheets-batch-update.md (+dropdown-update). The Go-side change in buildDropdownValidation still drops the wire fields, so: - users passing --colors / --highlight today see the flag accepted silently (no effect on the wire) until upstream removes the flag; - after upstream removal + sync, both flag declarations, ref docs, and Go-side handling will be in sync. Functional fixes (#1 workbook-create, #3 dropdown-get, #4 dim-move, #5 range-sort) and dropdown wire-shape rename (#2) are unaffected. --- skills/lark-sheets/references/lark-sheets-batch-update.md | 2 ++ skills/lark-sheets/references/lark-sheets-write-cells.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 7b04905a1..ba3160e0b 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -70,7 +70,9 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(如 `["sheet1!A2:A100"]`),每项必须带 sheet 前缀 | | `--options` | string + File + Stdin(复合 JSON) | required | 选项 JSON 数组(如 `["opt1","opt2"]`) | +| `--colors` | string + File + Stdin(简单 JSON) | optional | 颜色数组(与 `--options` 等长) | | `--multiple` | bool | optional | 启用多选 | +| `--highlight` | bool | optional | 选项配色 | ### `+dropdown-delete` diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 6ebe2b154..6e1887031 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -199,7 +199,9 @@ _公共四件套 · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--range` | string | required | 目标范围(A1 格式,如 `A2:A100`) | | `--options` | string + File + Stdin(复合 JSON) | required | 选项 JSON 数组 `["opt1","opt2"]`;最多 500 项,每项 ≤100 字符,不含逗号 | +| `--colors` | string + File + Stdin(简单 JSON) | optional | RGB hex 颜色数组(如 `["#1FB6C1","#F006C2"]`),长度必须与 `--options` 一致 | | `--multiple` | bool | optional | 启用多选;默认 `false` | +| `--highlight` | bool | optional | 选项配色显示;默认 `false` | ### `+csv-put` From 101c572d6489d42d5e86563d4490c6d9b644c37b Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Fri, 22 May 2026 15:41:57 +0800 Subject: [PATCH 045/114] =?UTF-8?q?docs(sheets):=20sync=20from=20sheet-ski?= =?UTF-8?q?ll-spec=20=E2=80=94=20remove=20dropdown=20--colors=20/=20--high?= =?UTF-8?q?light?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream sheet-skill-spec base table deleted the --colors and --highlight flags on +dropdown-set / +dropdown-update (the corresponding wire fields data_validation.colors / .highlight_options were never accepted by the server schema; see prior fix in this branch). Re-running the sync from canonical-spec brings the CLI flag-defs and skill reference docs back in line with the Go-side handling that already drops these fields. Generated by `npm run sync:cli` in sheet-skill-spec @ ac7acef. --- shortcuts/sheets/data/flag-defs.json | 36 ------------------- .../references/lark-sheets-batch-update.md | 2 -- .../references/lark-sheets-write-cells.md | 2 -- 3 files changed, 40 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index d8687e227..b1000009e 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -1874,17 +1874,6 @@ "stdin" ] }, - { - "name": "colors", - "kind": "own", - "type": "string", - "required": "optional", - "desc": "RGB hex color array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`); length must equal `--options`", - "input": [ - "file", - "stdin" - ] - }, { "name": "multiple", "kind": "own", @@ -1892,13 +1881,6 @@ "required": "optional", "desc": "Enable multi-select; default `false`" }, - { - "name": "highlight", - "kind": "own", - "type": "bool", - "required": "optional", - "desc": "Color-highlight options; default `false`" - }, { "name": "dry-run", "kind": "system", @@ -2802,17 +2784,6 @@ "stdin" ] }, - { - "name": "colors", - "kind": "own", - "type": "string", - "required": "optional", - "desc": "Color array (same length as `--options`)", - "input": [ - "file", - "stdin" - ] - }, { "name": "multiple", "kind": "own", @@ -2820,13 +2791,6 @@ "required": "optional", "desc": "Enable multi-select" }, - { - "name": "highlight", - "kind": "own", - "type": "bool", - "required": "optional", - "desc": "Color-highlight options" - }, { "name": "dry-run", "kind": "system", diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index ba3160e0b..7b04905a1 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -70,9 +70,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(如 `["sheet1!A2:A100"]`),每项必须带 sheet 前缀 | | `--options` | string + File + Stdin(复合 JSON) | required | 选项 JSON 数组(如 `["opt1","opt2"]`) | -| `--colors` | string + File + Stdin(简单 JSON) | optional | 颜色数组(与 `--options` 等长) | | `--multiple` | bool | optional | 启用多选 | -| `--highlight` | bool | optional | 选项配色 | ### `+dropdown-delete` diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 6e1887031..6ebe2b154 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -199,9 +199,7 @@ _公共四件套 · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--range` | string | required | 目标范围(A1 格式,如 `A2:A100`) | | `--options` | string + File + Stdin(复合 JSON) | required | 选项 JSON 数组 `["opt1","opt2"]`;最多 500 项,每项 ≤100 字符,不含逗号 | -| `--colors` | string + File + Stdin(简单 JSON) | optional | RGB hex 颜色数组(如 `["#1FB6C1","#F006C2"]`),长度必须与 `--options` 一致 | | `--multiple` | bool | optional | 启用多选;默认 `false` | -| `--highlight` | bool | optional | 选项配色显示;默认 `false` | ### `+csv-put` From 96c338735ae7d23b277f76f616fb87e2a8e3742c Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Fri, 22 May 2026 18:06:50 +0800 Subject: [PATCH 046/114] fix(sheets): restore +dropdown --colors / --highlight, map to canonical fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverses the --colors / --highlight removal from 7932ab2 (item #2 of the batch-1 schema-alignment commit). That commit dropped both flags after the test report flagged data_validation.colors / highlight_options as "unexpected property" — at the time the canonical set_cell_range.data_validation schema listed only help_text / items / operator / range / support_multiple_values / type / values, so the flags had no server-side target and the removal was correct. Since then, set_cell_range.data_validation has gained two fields explicitly modelling the dropdown highlight UI (mcp-tools.json in sheet-skill-spec 2026-05-22 base sync): enable_highlight (bool) — show pill backgrounds highlight_colors (string[]) — hex pill colors, length must match items So the flags are back, but rewired: --colors -> data_validation.highlight_colors (was: colors) --highlight -> data_validation.enable_highlight (was: highlight_options) --options -> items and --multiple -> support_multiple_values renames from 7932ab2 are kept. Changes: - buildDropdownValidation: re-add --colors / --highlight handling against the new field names; --colors length check stays inline (so dropdownSetInput Validate path catches it via validateViaInput, no separate guard needed). - validateDropdownOptions -> validateDropdownOptionsColors: restore the Validate-time --colors length check on +dropdown-update / +dropdown-delete (called from lark_sheet_batch_update.go). - TestDropdownSet_CellsShape: extend to assert highlight_colors / enable_highlight emitted; assert legacy `colors` / `highlight_options` absent. - TestDropdownSet_ColorsLengthMismatch: new — covers the early Validate error path. - TestDropdownUpdate_BatchPayload: extend to cover dropdownBatchInput propagation of --colors / --highlight through batch_update. - skills/lark-sheets/references/lark-sheets-{write-cells,batch-update}.md, shortcuts/sheets/data/flag-defs.json, flag-schemas.json: synced from sheet-skill-spec generate output (MR !7). --- shortcuts/sheets/data/flag-defs.json | 36 + shortcuts/sheets/data/flag-schemas.json | 7721 +++++++++-------- shortcuts/sheets/lark_sheet_batch_update.go | 2 +- .../sheets/lark_sheet_batch_update_test.go | 13 +- shortcuts/sheets/lark_sheet_write_cells.go | 47 +- .../sheets/lark_sheet_write_cells_test.go | 36 + .../references/lark-sheets-batch-update.md | 2 + .../references/lark-sheets-write-cells.md | 116 +- 8 files changed, 4057 insertions(+), 3916 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index b1000009e..d8687e227 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -1874,6 +1874,17 @@ "stdin" ] }, + { + "name": "colors", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "RGB hex color array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`); length must equal `--options`", + "input": [ + "file", + "stdin" + ] + }, { "name": "multiple", "kind": "own", @@ -1881,6 +1892,13 @@ "required": "optional", "desc": "Enable multi-select; default `false`" }, + { + "name": "highlight", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Color-highlight options; default `false`" + }, { "name": "dry-run", "kind": "system", @@ -2784,6 +2802,17 @@ "stdin" ] }, + { + "name": "colors", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Color array (same length as `--options`)", + "input": [ + "file", + "stdin" + ] + }, { "name": "multiple", "kind": "own", @@ -2791,6 +2820,13 @@ "required": "optional", "desc": "Enable multi-select" }, + { + "name": "highlight", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Color-highlight options" + }, { "name": "dry-run", "kind": "system", diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json index 8135bd147..3d377a1db 100644 --- a/shortcuts/sheets/data/flag-schemas.json +++ b/shortcuts/sheets/data/flag-schemas.json @@ -79,498 +79,354 @@ }, "+cells-batch-set-style": { "border-styles": { - "type": "object", "description": "单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)。", "properties": { - "top": { - "type": "object", + "bottom": { "properties": { + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", - "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ] + ], + "type": "string" }, "weight": { "description": "边框粗细/线宽", - "type": "string", "enum": [ "thin", "medium", "thick" - ] - }, - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", + ], "type": "string" } - } + }, + "type": "object" }, - "bottom": { - "type": "object", + "left": { "properties": { + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", - "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ] + ], + "type": "string" }, "weight": { "description": "边框粗细/线宽", - "type": "string", "enum": [ "thin", "medium", "thick" - ] - }, - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", + ], "type": "string" } - } + }, + "type": "object" }, - "left": { - "type": "object", + "right": { "properties": { + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", - "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ] + ], + "type": "string" }, "weight": { "description": "边框粗细/线宽", - "type": "string", "enum": [ "thin", "medium", "thick" - ] - }, - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", + ], "type": "string" } - } + }, + "type": "object" }, - "right": { - "type": "object", + "top": { "properties": { + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", - "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ] + ], + "type": "string" }, "weight": { "description": "边框粗细/线宽", - "type": "string", "enum": [ "thin", "medium", "thick" - ] - }, - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", + ], "type": "string" } - } + }, + "type": "object" } - } + }, + "type": "object" } }, "+cells-set": { "cells": { - "type": "object", "properties": { - "value": { - "description": "静态单元格值(文本、数字、布尔)。公式请优先使用 'formula' 字段;如果误把以 '=' 开头的公式字符串写到这里,工具会按 Excel 语义自动识别为公式入库,但仍应按 'formula' 字段的契约传参。", - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ] - }, - "formula": { - "description": "以 '=' 开头的单元格公式(例如:'=SUM(A1:A10)')。公式必须写在此字段,而不是 'value'。", - "type": "string" - }, - "note": { - "description": "单元格批注/备注", - "type": "string" - }, - "cell_styles": { - "type": "object", - "properties": { - "font_color": { - "description": "字体颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, - "font_size": { - "description": "字体大小(单位:px/像素,例如 10、12、14)", - "type": "number" - }, - "font_weight": { - "description": "字重", - "type": "string", - "enum": [ - "normal", - "bold" - ] - }, - "font_style": { - "description": "字体样式", - "type": "string", - "enum": [ - "normal", - "italic" - ] - }, - "font_line": { - "description": "字体线条样式", - "type": "string", - "enum": [ - "none", - "underline", - "line-through" - ] - }, - "background_color": { - "description": "背景颜色(十六进制,例如 \"#ffffff\")", - "type": "string" - }, - "horizontal_alignment": { - "description": "水平对齐方式", - "type": "string", - "enum": [ - "left", - "center", - "right" - ] - }, - "vertical_alignment": { - "description": "垂直对齐方式", - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ] - }, - "number_format": { - "description": "数字格式(例如:文本用 \"@\"、数字用 \"0.00\"、货币用 \"$#,##0.00\"、日期用 \"mm/dd/yyyy\")", - "type": "string" - }, - "word_wrap": { - "description": "是否自动换行,默认溢出,可选自动换行或裁剪。", - "type": "string", - "enum": [ - "overflow", - "auto-wrap", - "word-clip" - ] - } - }, - "description": "单元格样式属性,包括字体、颜色、对齐方式和数字格式" - }, "border_styles": { - "type": "object", "description": "单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)。", "properties": { - "top": { - "type": "object", + "bottom": { "properties": { + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", - "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ] + ], + "type": "string" }, "weight": { "description": "边框粗细/线宽", - "type": "string", "enum": [ "thin", "medium", "thick" - ] - }, - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", + ], "type": "string" } - } + }, + "type": "object" }, - "bottom": { - "type": "object", + "left": { "properties": { + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", - "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ] + ], + "type": "string" }, "weight": { "description": "边框粗细/线宽", - "type": "string", "enum": [ "thin", "medium", "thick" - ] - }, - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", + ], "type": "string" } - } + }, + "type": "object" }, - "left": { - "type": "object", + "right": { "properties": { + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", - "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ] + ], + "type": "string" }, "weight": { "description": "边框粗细/线宽", - "type": "string", "enum": [ "thin", "medium", "thick" - ] - }, - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", + ], "type": "string" } - } + }, + "type": "object" }, - "right": { - "type": "object", + "top": { "properties": { + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", - "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ] + ], + "type": "string" }, "weight": { "description": "边框粗细/线宽", - "type": "string", "enum": [ "thin", "medium", "thick" - ] - }, - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", + ], "type": "string" } - } + }, + "type": "object" } - } + }, + "type": "object" }, - "rich_text": { - "description": "富文本内容。设置后会忽略 value 字段。可包含带样式的文本段 text、超链接 link、@提及 mention、单元格图片 embed-image、附件 attachment。", - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "description": "段类型", - "type": "string", - "enum": [ - "text", - "link", - "mention", - "embed-image", - "attachment" - ] - }, - "text": { - "description": "显示文本", - "type": "string" - }, - "style": { - "description": "文本段样式(仅 type='text' 时生效),结构同 cell_styles", - "type": "object" - }, - "link": { - "description": "超链接地址(仅 type='link' 时必填)", - "type": "string" - }, - "mention_token": { - "description": "@提及目标的 token,如 userId 或 fileId(仅 type='mention' 时必填)", - "type": "string" - }, - "mention_type": { - "description": "@提及类型编号(仅 type='mention' 时可选)", - "type": "number" - }, - "notify": { - "description": "是否发送通知(仅 type='mention' 时可选,默认 true)", - "type": "boolean" - }, - "image_width": { - "description": "图片宽度(像素,仅 type='embed-image' 时使用)", - "type": "number" - }, - "image_height": { - "description": "图片高度(像素,仅 type='embed-image' 时使用)", - "type": "number" - }, - "image_name": { - "description": "图片名称(仅 type='embed-image' 时使用,创建新图片时必填)", - "type": "string" - }, - "image_uri": { - "description": "图片文件 reference_id(仅 type='embed-image' 时使用,与 image_token 二选一,如`<|image|>:abcdef` 或者 `<|superscript|>:abcdef-<|image|>:abcdef`,其中 `abcdef` 为实际的对象 ID,占位符仅用于示意)", - "type": "string" - }, - "image_token": { - "description": "图片文件 token(仅 type='embed-image' 时可选,修改已有图片时可从 get_cell_range 获取)", - "type": "string" - }, - "attachment_token": { - "description": "附件文件 token,通过飞书 Drive 上传获取(仅 type='attachment' 时可选,修改已有附件时可从 get_cell_range 获取)", - "type": "string" - }, - "attachment_uri": { - "description": "附件文件 reference_id(仅 type='attachment' 时使用,与 attachment_token 二选一,如`<|attachment|>:abcdef` 或者 `<|superscript|>:abcdef-<|attachment|>:abcdef`,其中 `abcdef` 为实际的对象 ID,占位符仅用于示意)", - "type": "string" - }, - "attachment_name": { - "description": "附件文件名称(仅 type='attachment' 时使用,配合 attachment_reference_id 使用,创建新附件时必填)", - "type": "string" - }, - "mime_type": { - "description": "附件 MIME 类型(仅 type='attachment' 时使用,例如 'application/pdf')", - "type": "string" - }, - "file_size": { - "description": "附件文件大小(字节,仅 type='attachment' 时使用)", - "type": "number" - } + "cell_styles": { + "description": "单元格样式属性,包括字体、颜色、对齐方式和数字格式", + "properties": { + "background_color": { + "description": "背景颜色(十六进制,例如 \"#ffffff\")", + "type": "string" }, - "required": [ - "type", - "text" - ] - } - }, - "multiple_values": { - "description": "多值内容,用于支持多选的列表验证单元格。设置后会忽略 value 和 rich_text 字段。", - "type": "array", - "items": { - "type": "object", - "properties": { - "value": { - "description": "值(文本、数字、布尔)", - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ] - }, - "format": { - "description": "可选的数字格式(例如 '$#,##0.00')", - "type": "string" - } + "font_color": { + "description": "字体颜色(十六进制,例如 \"#000000\")", + "type": "string" }, - "required": [ - "value" - ] - } + "font_line": { + "description": "字体线条样式", + "enum": [ + "none", + "underline", + "line-through" + ], + "type": "string" + }, + "font_size": { + "description": "字体大小(单位:px/像素,例如 10、12、14)", + "type": "number" + }, + "font_style": { + "description": "字体样式", + "enum": [ + "normal", + "italic" + ], + "type": "string" + }, + "font_weight": { + "description": "字重", + "enum": [ + "normal", + "bold" + ], + "type": "string" + }, + "horizontal_alignment": { + "description": "水平对齐方式", + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "number_format": { + "description": "数字格式(例如:文本用 \"@\"、数字用 \"0.00\"、货币用 \"$#,##0.00\"、日期用 \"mm/dd/yyyy\")", + "type": "string" + }, + "vertical_alignment": { + "description": "垂直对齐方式", + "enum": [ + "top", + "middle", + "bottom" + ], + "type": "string" + }, + "word_wrap": { + "description": "是否自动换行,默认溢出,可选自动换行或裁剪。", + "enum": [ + "overflow", + "auto-wrap", + "word-clip" + ], + "type": "string" + } + }, + "type": "object" }, "data_validation": { "description": "数据验证配置。设为 null 可清除已有的数据验证。", - "type": "object", "properties": { - "type": { - "description": "数据验证类型:list(下拉列表)、listFromRange(引用范围下拉列表)、number(数字)、date(日期)、textLength(文本长度)、checkbox(复选框)", - "type": "string", - "enum": [ - "list", - "listFromRange", - "number", - "date", - "textLength", - "checkbox" - ] + "enable_highlight": { + "description": "是否开启下拉选项的胶囊背景色高亮,仅 type='list'/'listFromRange' 生效,默认 false。", + "type": "boolean" }, - "items": { - "description": "列表选项", - "type": "array", + "help_text": { + "description": "验证失败时显示的提示文本", + "type": "string" + }, + "highlight_colors": { + "description": "下拉选项的胶囊背景色数组(十六进制,例如 [\"#FFE699\",\"#bff7d9\",\"#ffb3b3\"]),仅当 enable_highlight=true 时生效。如提供,长度必须严格匹配:type='list' 时等于 items 长度;type='listFromRange' 时等于 range 内单元格数(行优先顺序,如 A1:A10 对应第 1-10 项;A1:B2 顺序为 A1,B1,A2,B2)。长度不匹配工具会报错。不提供时所有选项使用默认色 #bacefd。", "items": { "type": "string" - } + }, + "type": "array" }, - "range": { - "description": "源数据区域(type='listFromRange' 时必填,格式:'SheetName!A1:A10')", - "type": "string" + "items": { + "description": "列表选项(type='list' 时必填)", + "items": { + "type": "string" + }, + "type": "array" }, "operator": { "description": "比较运算符(type='number'/'date'/'textLength' 时必填)", - "type": "string", "enum": [ "equal", "notEqual", @@ -580,11 +436,31 @@ "lessThanOrEqual", "between", "notBetween" - ] + ], + "type": "string" + }, + "range": { + "description": "源数据区域(type='listFromRange' 时必填,格式:'SheetName!A1:A10')", + "type": "string" + }, + "support_multiple_values": { + "description": "列表验证是否支持多选(type='list'/'listFromRange' 时可选,默认 false)", + "type": "boolean" + }, + "type": { + "description": "数据验证类型:list(下拉列表)、listFromRange(引用范围下拉列表)、number(数字)、date(日期)、textLength(文本长度)、checkbox(复选框)", + "enum": [ + "list", + "listFromRange", + "number", + "date", + "textLength", + "checkbox" + ], + "type": "string" }, "values": { "description": "比较值(operator 为 'between'/'notBetween' 时需要两个值,其它运算符需要一个值)", - "type": "array", "items": { "oneOf": [ { @@ -594,2284 +470,2350 @@ "type": "number" } ] - } - }, - "support_multiple_values": { - "description": "列表验证是否支持多选(type='list'/'listFromRange' 时可选,默认 false)", - "type": "boolean" - }, - "help_text": { - "description": "验证失败时显示的提示文本", - "type": "string" + }, + "type": "array" } }, "required": [ "type" - ] - } - } - } - }, - "+cells-set-style": { - "border-styles": { - "type": "object", - "description": "单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)。", - "properties": { - "top": { - "type": "object", - "properties": { - "style": { - "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", - "type": "string", - "enum": [ - "solid", - "dashed", - "dotted", - "double", - "none" - ] - }, - "weight": { - "description": "边框粗细/线宽", - "type": "string", - "enum": [ - "thin", - "medium", - "thick" - ] - }, - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - } - } + ], + "type": "object" }, - "bottom": { - "type": "object", - "properties": { - "style": { - "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", - "type": "string", - "enum": [ - "solid", - "dashed", - "dotted", - "double", - "none" - ] - }, - "weight": { - "description": "边框粗细/线宽", - "type": "string", - "enum": [ - "thin", - "medium", - "thick" - ] + "formula": { + "description": "以 '=' 开头的单元格公式(例如:'=SUM(A1:A10)')。公式必须写在此字段,而不是 'value'。", + "type": "string" + }, + "multiple_values": { + "description": "多值内容,用于支持多选的列表验证单元格。设置后会忽略 value 和 rich_text 字段。", + "items": { + "properties": { + "format": { + "description": "可选的数字格式(例如 '$#,##0.00')", + "type": "string" + }, + "value": { + "description": "值(文本、数字、布尔)", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + }, + "required": [ + "value" + ], + "type": "object" + }, + "type": "array" + }, + "note": { + "description": "单元格批注/备注", + "type": "string" + }, + "rich_text": { + "description": "富文本内容。设置后会忽略 value 字段。可包含带样式的文本段 text、超链接 link、@提及 mention、单元格图片 embed-image、附件 attachment。", + "items": { + "properties": { + "attachment_name": { + "description": "附件文件名称(仅 type='attachment' 时使用,配合 attachment_reference_id 使用,创建新附件时必填)", + "type": "string" + }, + "attachment_token": { + "description": "附件文件 token,通过飞书 Drive 上传获取(仅 type='attachment' 时可选,修改已有附件时可从 get_cell_range 获取)", + "type": "string" + }, + "attachment_uri": { + "description": "附件文件 reference_id(仅 type='attachment' 时使用,与 attachment_token 二选一,如`<|attachment|>:abcdef` 或者 `<|superscript|>:abcdef-<|attachment|>:abcdef`,其中 `abcdef` 为实际的对象 ID,占位符仅用于示意)", + "type": "string" + }, + "file_size": { + "description": "附件文件大小(字节,仅 type='attachment' 时使用)", + "type": "number" + }, + "image_height": { + "description": "图片高度(像素,仅 type='embed-image' 时使用)", + "type": "number" + }, + "image_name": { + "description": "图片名称(仅 type='embed-image' 时使用,创建新图片时必填)", + "type": "string" + }, + "image_token": { + "description": "图片文件 token(仅 type='embed-image' 时可选,修改已有图片时可从 get_cell_range 获取)", + "type": "string" + }, + "image_uri": { + "description": "图片文件 reference_id(仅 type='embed-image' 时使用,与 image_token 二选一,如`<|image|>:abcdef` 或者 `<|superscript|>:abcdef-<|image|>:abcdef`,其中 `abcdef` 为实际的对象 ID,占位符仅用于示意)", + "type": "string" + }, + "image_width": { + "description": "图片宽度(像素,仅 type='embed-image' 时使用)", + "type": "number" + }, + "link": { + "description": "超链接地址(仅 type='link' 时必填)", + "type": "string" + }, + "mention_token": { + "description": "@提及目标的 token,如 userId 或 fileId(仅 type='mention' 时必填)", + "type": "string" + }, + "mention_type": { + "description": "@提及类型编号(仅 type='mention' 时可选)", + "type": "number" + }, + "mime_type": { + "description": "附件 MIME 类型(仅 type='attachment' 时使用,例如 'application/pdf')", + "type": "string" + }, + "notify": { + "description": "是否发送通知(仅 type='mention' 时可选,默认 true)", + "type": "boolean" + }, + "style": { + "description": "文本段样式(仅 type='text' 时生效),结构同 cell_styles", + "type": "object" + }, + "text": { + "description": "显示文本", + "type": "string" + }, + "type": { + "description": "段类型", + "enum": [ + "text", + "link", + "mention", + "embed-image", + "attachment" + ], + "type": "string" + } + }, + "required": [ + "type", + "text" + ], + "type": "object" + }, + "type": "array" + }, + "value": { + "description": "静态单元格值(文本、数字、布尔)。公式请优先使用 'formula' 字段;如果误把以 '=' 开头的公式字符串写到这里,工具会按 Excel 语义自动识别为公式入库,但仍应按 'formula' 字段的契约传参。", + "oneOf": [ + { + "type": "string" }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + }, + "type": "object" + } + }, + "+cells-set-style": { + "border-styles": { + "description": "单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)。", + "properties": { + "bottom": { + "properties": { "color": { "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" + }, + "style": { + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "enum": [ + "solid", + "dashed", + "dotted", + "double", + "none" + ], + "type": "string" + }, + "weight": { + "description": "边框粗细/线宽", + "enum": [ + "thin", + "medium", + "thick" + ], + "type": "string" } - } + }, + "type": "object" }, "left": { - "type": "object", "properties": { + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", - "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ] + ], + "type": "string" }, "weight": { "description": "边框粗细/线宽", - "type": "string", "enum": [ "thin", "medium", "thick" - ] - }, - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", + ], "type": "string" } - } + }, + "type": "object" }, "right": { - "type": "object", "properties": { + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", - "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ] + ], + "type": "string" }, "weight": { "description": "边框粗细/线宽", - "type": "string", "enum": [ "thin", "medium", "thick" - ] - }, + ], + "type": "string" + } + }, + "type": "object" + }, + "top": { + "properties": { "color": { "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" + }, + "style": { + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "enum": [ + "solid", + "dashed", + "dotted", + "double", + "none" + ], + "type": "string" + }, + "weight": { + "description": "边框粗细/线宽", + "enum": [ + "thin", + "medium", + "thick" + ], + "type": "string" } - } + }, + "type": "object" } - } + }, + "type": "object" } }, "+chart-create": { "properties": { + "additionalProperties": {}, "description": "创建/更新的图表属性。", - "type": "object", "properties": { + "offset": { + "additionalProperties": false, + "description": "可选。图表在位置基础上的偏移量(像素)。", + "properties": { + "col_offset": { + "description": "列偏移量(像素)", + "type": "number" + }, + "row_offset": { + "description": "行偏移量(像素)", + "type": "number" + } + }, + "type": "object" + }, "position": { - "type": "object", + "additionalProperties": false, "description": "必填。图表在表格中的单元格位置。注意:选择位置时应避免覆盖已有数据的单元格,并确保图表不超出当前表格的行列范围。", "properties": { + "col": { + "description": "列索引,例如 \"A\"、\"B\"", + "type": "string" + }, "row": { - "type": "number", + "description": "行索引(0-based)", "minimum": 0, - "description": "行索引(0-based)" - }, - "col": { - "type": "string", - "description": "列索引,例如 \"A\"、\"B\"" + "type": "number" } }, "required": [ "row", "col" ], - "additionalProperties": false - }, - "offset": { - "type": "object", - "description": "可选。图表在位置基础上的偏移量(像素)。", - "properties": { - "row_offset": { - "type": "number", - "description": "行偏移量(像素)" - }, - "col_offset": { - "type": "number", - "description": "列偏移量(像素)" - } - }, - "additionalProperties": false + "type": "object" }, "size": { - "type": "object", + "additionalProperties": false, "description": "必填。图表大小(像素)。注意:设定大小时应确保图表不超出当前表格的行列范围,并避免覆盖已有数据的单元格。", "properties": { - "width": { - "type": "number", + "height": { + "description": "高度(像素)", "minimum": 10, - "description": "宽度(像素)" + "type": "number" }, - "height": { - "type": "number", + "width": { + "description": "宽度(像素)", "minimum": 10, - "description": "高度(像素)" + "type": "number" } }, "required": [ "width", "height" ], - "additionalProperties": false + "type": "object" }, "snapshot": { - "type": "object", "description": "图表快照配置。更新图表时必须传入完整的图表属性定义,不能只传修改的部分。应先通过 get_chart_objects 获取当前图表快照,修改需要变更的字段后,将完整快照传入。", "properties": { - "title": { - "type": "object", - "description": "图表标题配置", - "properties": { - "text": { - "type": "string", - "description": "标题文本" - }, - "textAlign": { - "type": "string", - "description": "标题对齐方式", - "enum": [ - "left", - "center", - "right" - ] - }, - "fontSize": { - "type": "number", - "description": "字体大小" - }, - "bold": { - "type": "boolean", - "description": "是否加粗" - }, - "italic": { - "type": "boolean", - "description": "是否斜体" - }, - "underline": { - "type": "boolean", - "description": "是否下划线" - }, - "strikethrough": { - "type": "boolean", - "description": "是否删除线" - }, - "color": { - "type": "string", - "description": "字体颜色,格式为 #RRGGBB" - } - }, - "required": [ - "text" - ] - }, - "subTitle": { - "type": "object", - "description": "图表副标题配置", - "properties": { - "text": { - "type": "string", - "description": "副标题文本" - }, - "textAlign": { - "type": "string", - "description": "副标题对齐方式", - "enum": [ - "left", - "center", - "right" - ] - }, - "fontSize": { - "type": "number", - "description": "字体大小" - }, - "bold": { - "type": "boolean", - "description": "是否加粗" - }, - "italic": { - "type": "boolean", - "description": "是否斜体" - }, - "underline": { - "type": "boolean", - "description": "是否下划线" - }, - "strikethrough": { - "type": "boolean", - "description": "是否删除线" - }, - "color": { - "type": "string", - "description": "字体颜色,格式为 #RRGGBB" - } - }, - "required": [ - "text" - ] - }, - "style": { - "type": "object", - "description": "图表样式配置", + "data": { + "description": "图表数据配置", "properties": { - "background": { - "type": "object", - "description": "背景配置", - "properties": { - "color": { - "type": "string", - "description": "背景颜色,格式为 #RRGGBB" - } - } - }, - "font": { - "type": "object", - "description": "字体配置", + "dim1": { + "description": "维度1配置(类别维度)", "properties": { - "size": { - "type": "number", - "description": "字体大小" + "field": { + "description": "字段配置(静态数据时传此参数)", + "properties": { + "name": { + "description": "字段名称", + "type": "string" + }, + "text": { + "description": "字段文本数据", + "type": "string" + }, + "valueType": { + "description": "值类型", + "enum": [ + "number", + "string" + ], + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" }, - "color": { - "type": "string", - "description": "字体颜色,格式为 #RRGGBB" + "serie": { + "description": "系列配置(非静态数据时传此参数)", + "properties": { + "aggregate": { + "description": "是否汇总相同的类别,默认为true,true 情况下 aggregateType 才生效", + "type": "boolean" + }, + "index": { + "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引", + "type": "number" + }, + "nameRef": { + "description": "可选。维度名称的单元格引用(A1 表示法,如 'Sheet1!A1')。当维度名称不直接来自 refs 首行/首列时,可通过此字段把维度名显式指向目标表头单元格,图表会以该单元格当前值作为类别维度名展示。", + "type": "string" + } + }, + "type": "object" } - } + }, + "type": "object" }, - "border": { - "type": "object", - "description": "边框配置", + "dim2": { + "description": "维度2配置(值维度)", "properties": { - "color": { - "type": "string", - "description": "边框颜色,格式为 #RRGGBB" - }, - "width": { - "type": "number", - "description": "边框宽度" - }, - "style": { - "type": "string", - "description": "边框样式", - "enum": [ - "solid", - "dashed", - "dotted" - ] - }, - "radius": { - "type": "number", - "description": "边框圆角" - } - } - }, - "colorTheme": { - "type": "array", - "description": "颜色主题配置。支持两种模式:1. 仅传一个预设主题名称(字符串数组,长度为1);2. 传多个自定义十六进制颜色字符串。预设主题:brandColorSeries@v2、rainbowColorSeries@v2、complementaryColorSeries@v2、converseColorSeries@v2、primaryColorSeries@v2、singleColorSeries-B-@v2、singleColorSeries-W-@v2、singleColorSeries-G-@v2、singleColorSeries-Y-@v2、singleColorSeries-O-@v2、singleColorSeries-R-@v2、singleColorSeries-D-@v2。十六进制格式:#RRGGBB", - "oneOf": [ - { - "minItems": 1, - "maxItems": 1, + "fields": { + "description": "字段配置数组(静态数据时传此参数)", "items": { - "type": "string", - "enum": [ - "brandColorSeries@v2", - "rainbowColorSeries@v2", - "complementaryColorSeries@v2", - "converseColorSeries@v2", - "primaryColorSeries@v2", - "singleColorSeries-B-@v2", - "singleColorSeries-W-@v2", - "singleColorSeries-G-@v2", - "singleColorSeries-Y-@v2", - "singleColorSeries-O-@v2", - "singleColorSeries-R-@v2", - "singleColorSeries-D-@v2" - ] - } + "properties": { + "name": { + "description": "字段名称", + "type": "string" + }, + "text": { + "description": "字段文本数据", + "type": "string" + }, + "valueType": { + "description": "值类型", + "enum": [ + "number" + ], + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "type": "array" }, - { - "minItems": 2, + "series": { + "description": "系列配置数组(非静态数据时传此参数)", "items": { - "type": "string", - "description": "颜色字符串,十六进制格式:#RRGGBB" - } + "properties": { + "aggregateType": { + "description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效", + "enum": [ + "sum", + "average", + "count", + "min", + "max", + "median" + ], + "type": "string" + }, + "index": { + "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引", + "type": "number" + }, + "nameRef": { + "description": "可选。系列名称的单元格引用(A1 表示法,如 'Sheet1!C1')。当系列名称不直接来自 refs 首行/首列时,可通过此字段把系列名显式指向目标表头单元格,图表会以该单元格当前值作为系列名(图例/tooltip)展示。", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" } - ] + }, + "type": "object" }, - "colorGradient": { - "type": "boolean", - "description": "是否启用颜色渐变" + "direction": { + "description": "数据方向,可选值:row(行方向)或column(列方向),常用值为column", + "enum": [ + "row", + "column" + ], + "type": "string" + }, + "headerMode": { + "description": "表头模式。inline 表示 refs 首行/首列就是表头;detached 表示 refs 仅覆盖实际数据范围,维度名和系列名需通过 nameRef 显式指定。", + "enum": [ + "inline", + "detached" + ], + "type": "string" + }, + "includeHiddenOrFilter": { + "description": "是否包含隐藏或过滤的数据", + "type": "boolean" + }, + "isStaticData": { + "description": "是否为静态数据", + "type": "boolean" + }, + "refs": { + "description": "数据源引用范围数组", + "items": { + "properties": { + "value": { + "description": "图表数据源范围,格式为 'SheetName!A1:D100' 的连续区域", + "type": "string" + } + }, + "required": [ + "value" + ], + "type": "object" + }, + "type": "array" } - } + }, + "type": "object" }, "legend": { "oneOf": [ { - "type": "object", "description": "图例配置", "properties": { + "bold": { + "description": "是否加粗", + "type": "boolean" + }, + "color": { + "description": "字体颜色,格式为 #RRGGBB", + "type": "string" + }, + "fontSize": { + "description": "字体大小", + "type": "number" + }, + "italic": { + "description": "是否斜体", + "type": "boolean" + }, "position": { - "type": "string", "description": "图例位置", "enum": [ "top", "bottom", "left", "right" - ] - }, - "fontSize": { - "type": "number", - "description": "字体大小" - }, - "bold": { - "type": "boolean", - "description": "是否加粗" - }, - "italic": { - "type": "boolean", - "description": "是否斜体" - }, - "underline": { - "type": "boolean", - "description": "是否下划线" + ], + "type": "string" }, "strikethrough": { - "type": "boolean", - "description": "是否删除线" + "description": "是否删除线", + "type": "boolean" }, - "color": { - "type": "string", - "description": "字体颜色,格式为 #RRGGBB" + "underline": { + "description": "是否下划线", + "type": "boolean" } - } + }, + "type": "object" }, { - "type": "boolean", - "description": "false 表示隐藏图例" + "description": "false 表示隐藏图例", + "type": "boolean" } ] }, "plotArea": { - "type": "object", "description": "绘图区域配置", "properties": { - "plot": { - "type": "object", - "description": "绘图配置", - "properties": { - "type": { - "type": "string", - "description": "图表类型", - "enum": [ - "bar", - "column", - "line", - "area", - "combo", - "pie", - "radar", - "scatter" - ] - }, - "comboType": { - "type": "string", - "description": "组合图表默认类型", - "enum": [ - "column", - "line", - "area" - ] - }, - "yAxisPosition": { - "type": "string", - "description": "Y轴位置", - "enum": [ - "left", - "right" - ] - }, - "extra": { - "type": "object", - "description": "额外配置", - "properties": { - "smooth": { - "type": "boolean", - "description": "是否平滑曲线" - }, - "step": { - "type": "boolean", - "description": "是否阶梯图" - }, - "stack": { - "type": "object", - "description": "堆叠配置", - "properties": { - "percentage": { - "type": "boolean", - "description": "是否百分比堆叠" - } - } - }, - "radar": { - "type": "object", - "description": "雷达图配置", - "properties": { - "shape": { - "type": "string", - "description": "雷达图形状", - "enum": [ - "polygon", - "circle" - ] - }, - "area": { - "type": "boolean", - "description": "是否填充区域" - } - } - } - } - }, - "points": { - "type": "object", - "description": "全系列数据点配置,折线图、面积图、雷达图、散点图、组合图生效。", - "properties": { - "color": { - "type": "string", - "description": "数据点颜色" - }, - "shape": { - "type": "string", - "description": "数据点形状", - "enum": [ - "circle", - "triangle", - "rect", - "diamond", - "square" - ] - }, - "size": { - "type": "number", - "description": "数据点大小" - }, - "point": { - "type": "array", - "description": "单个数据点配置数组", - "items": { - "type": "object", + "axes": { + "description": "坐标轴配置数组", + "items": { + "description": "坐标轴配置", + "properties": { + "axisLine": { + "description": "是否显示轴线", + "type": "boolean" + }, + "gridLine": { + "oneOf": [ + { + "description": "网格线配置", "properties": { - "index": { - "type": "number", - "description": "数据点索引" - }, "color": { - "type": "string", - "description": "颜色" - }, - "shape": { - "type": "string", - "description": "形状" + "description": "网格线颜色", + "type": "string" }, - "size": { - "type": "number", - "description": "大小" + "width": { + "description": "网格线宽度", + "type": "number" } }, - "required": [ - "index" - ] + "type": "object" + }, + { + "description": "false 表示隐藏网格线", + "type": "boolean" + } + ] + }, + "label": { + "description": "坐标轴标签配置", + "properties": { + "angle": { + "description": "旋转角度,可选值:-90, -45, 0, 45, 90", + "type": "number" + }, + "bold": { + "description": "是否加粗", + "type": "boolean" + }, + "color": { + "description": "字体颜色", + "type": "string" + }, + "fontSize": { + "description": "字体大小", + "type": "number" + }, + "italic": { + "description": "是否斜体", + "type": "boolean" + }, + "strikethrough": { + "description": "是否删除线", + "type": "boolean" + }, + "underline": { + "description": "是否下划线", + "type": "boolean" } - } - } - }, - "lines": { - "type": "object", - "description": "全系列线条配置,折线图、面积图、雷达图、组合图生效。", - "properties": { - "color": { - "type": "string", - "description": "线条颜色" - }, - "width": { - "type": "number", - "description": "线条宽度" }, - "style": { - "type": "string", - "description": "线条样式", - "enum": [ - "solid", - "dashed", - "dotted" - ] + "type": "object" + }, + "max": { + "description": "最大值", + "type": "number" + }, + "min": { + "description": "最小值", + "type": "number" + }, + "position": { + "description": "坐标轴位置", + "enum": [ + "left", + "right", + "bottom" + ], + "type": "string" + }, + "title": { + "description": "坐标轴标题配置", + "properties": { + "bold": { + "description": "是否加粗", + "type": "boolean" + }, + "color": { + "description": "字体颜色", + "type": "string" + }, + "fontSize": { + "description": "字体大小", + "type": "number" + }, + "italic": { + "description": "是否斜体", + "type": "boolean" + }, + "strikethrough": { + "description": "是否删除线", + "type": "boolean" + }, + "text": { + "description": "标题文本", + "type": "string" + }, + "underline": { + "description": "是否下划线", + "type": "boolean" + } }, - "invalidType": { - "type": "string", - "description": "无效值处理方式", - "enum": [ - "break", - "zero", - "link" - ] - } + "required": [ + "text" + ], + "type": "object" + }, + "type": { + "description": "坐标轴类型", + "enum": [ + "x", + "y", + "angle", + "radius" + ], + "type": "string" + }, + "valueType": { + "description": "是否将横轴数字视为文本,linear 表示将数字视为数值, ordinal 表示将数字视为文本", + "enum": [ + "ordinal", + "linear" + ], + "type": "string" } }, + "required": [ + "type" + ], + "type": "object" + }, + "type": "array" + }, + "plot": { + "description": "绘图配置", + "properties": { "areas": { - "type": "object", "description": "全系列面积填充配置,面积图、雷达图、组合图生效。", "properties": { "color": { - "type": "string", - "description": "区域填充颜色" + "description": "区域填充颜色", + "type": "string" } - } + }, + "type": "object" }, "bars": { - "type": "object", "description": "全系列柱状图、条形图、组合图生效。", "properties": { - "color": { - "type": "string", - "description": "柱子颜色" - }, - "borderColor": { - "type": "string", - "description": "边框颜色" - }, - "borderWidth": { - "type": "number", - "description": "边框宽度" - }, - "borderStyle": { - "type": "string", - "description": "边框样式", - "enum": [ - "solid", - "dashed", - "dotted" - ] - }, - "width": { - "type": "number", - "description": "柱子宽度" - }, - "gap": { - "type": "number", - "description": "柱子间距比例,0-1之间" - }, "backgroundColor": { - "type": "string", - "description": "背景颜色" + "description": "背景颜色", + "type": "string" }, "bar": { - "type": "array", "description": "单个柱子配置数组", "items": { - "type": "object", "properties": { - "index": { - "type": "number", - "description": "柱子索引" - }, - "color": { - "type": "string", - "description": "颜色" - }, "borderColor": { - "type": "string", - "description": "边框颜色" + "description": "边框颜色", + "type": "string" + }, + "borderStyle": { + "description": "边框样式", + "type": "string" }, "borderWidth": { - "type": "number", - "description": "边框宽度" + "description": "边框宽度", + "type": "number" }, - "borderStyle": { - "type": "string", - "description": "边框样式" + "color": { + "description": "颜色", + "type": "string" + }, + "index": { + "description": "柱子索引", + "type": "number" } }, "required": [ "index" - ] - } - } - } - }, - "labels": { - "type": "object", - "description": "数据标签配置", - "properties": { - "position": { - "type": "string", - "description": "标签位置", - "enum": [ - "auto", - "top", - "bottom", - "left", - "right", - "center", - "inside", - "outside" - ] + ], + "type": "object" + }, + "type": "array" }, - "series": { - "type": "boolean", - "description": "是否显示系列名" + "borderColor": { + "description": "边框颜色", + "type": "string" }, - "category": { - "type": "boolean", - "description": "是否显示类别名" + "borderStyle": { + "description": "边框样式", + "enum": [ + "solid", + "dashed", + "dotted" + ], + "type": "string" }, - "value": { - "type": "boolean", - "description": "是否显示值" + "borderWidth": { + "description": "边框宽度", + "type": "number" }, - "percentage": { - "type": "boolean", - "description": "是否显示百分比" + "color": { + "description": "柱子颜色", + "type": "string" }, - "fontSize": { - "type": "number", - "description": "字体大小" + "gap": { + "description": "柱子间距比例,0-1之间", + "type": "number" + }, + "width": { + "description": "柱子宽度", + "type": "number" + } + }, + "type": "object" + }, + "comboType": { + "description": "组合图表默认类型", + "enum": [ + "column", + "line", + "area" + ], + "type": "string" + }, + "extra": { + "description": "额外配置", + "properties": { + "radar": { + "description": "雷达图配置", + "properties": { + "area": { + "description": "是否填充区域", + "type": "boolean" + }, + "shape": { + "description": "雷达图形状", + "enum": [ + "polygon", + "circle" + ], + "type": "string" + } + }, + "type": "object" }, + "smooth": { + "description": "是否平滑曲线", + "type": "boolean" + }, + "stack": { + "description": "堆叠配置", + "properties": { + "percentage": { + "description": "是否百分比堆叠", + "type": "boolean" + } + }, + "type": "object" + }, + "step": { + "description": "是否阶梯图", + "type": "boolean" + } + }, + "type": "object" + }, + "labels": { + "description": "数据标签配置", + "properties": { "bold": { - "type": "boolean", - "description": "是否加粗" + "description": "是否加粗", + "type": "boolean" + }, + "category": { + "description": "是否显示类别名", + "type": "boolean" + }, + "color": { + "description": "字体颜色", + "type": "string" + }, + "fontSize": { + "description": "字体大小", + "type": "number" }, "italic": { - "type": "boolean", - "description": "是否斜体" + "description": "是否斜体", + "type": "boolean" }, - "underline": { - "type": "boolean", - "description": "是否下划线" + "percentage": { + "description": "是否显示百分比", + "type": "boolean" + }, + "position": { + "description": "标签位置", + "enum": [ + "auto", + "top", + "bottom", + "left", + "right", + "center", + "inside", + "outside" + ], + "type": "string" + }, + "series": { + "description": "是否显示系列名", + "type": "boolean" }, "strikethrough": { - "type": "boolean", - "description": "是否删除线" + "description": "是否删除线", + "type": "boolean" + }, + "underline": { + "description": "是否下划线", + "type": "boolean" }, + "value": { + "description": "是否显示值", + "type": "boolean" + } + }, + "type": "object" + }, + "lines": { + "description": "全系列线条配置,折线图、面积图、雷达图、组合图生效。", + "properties": { "color": { - "type": "string", - "description": "字体颜色" + "description": "线条颜色", + "type": "string" + }, + "invalidType": { + "description": "无效值处理方式", + "enum": [ + "break", + "zero", + "link" + ], + "type": "string" + }, + "style": { + "description": "线条样式", + "enum": [ + "solid", + "dashed", + "dotted" + ], + "type": "string" + }, + "width": { + "description": "线条宽度", + "type": "number" } - } + }, + "type": "object" + }, + "points": { + "description": "全系列数据点配置,折线图、面积图、雷达图、散点图、组合图生效。", + "properties": { + "color": { + "description": "数据点颜色", + "type": "string" + }, + "point": { + "description": "单个数据点配置数组", + "items": { + "properties": { + "color": { + "description": "颜色", + "type": "string" + }, + "index": { + "description": "数据点索引", + "type": "number" + }, + "shape": { + "description": "形状", + "type": "string" + }, + "size": { + "description": "大小", + "type": "number" + } + }, + "required": [ + "index" + ], + "type": "object" + }, + "type": "array" + }, + "shape": { + "description": "数据点形状", + "enum": [ + "circle", + "triangle", + "rect", + "diamond", + "square" + ], + "type": "string" + }, + "size": { + "description": "数据点大小", + "type": "number" + } + }, + "type": "object" }, "series": { - "type": "array", "description": "单个系列配置数组", "items": { - "type": "object", "description": "系列配置", "properties": { - "index": { - "type": "number", - "description": "数据列索引,与 dim2.series[].index 值一致,从 1 开始(1 通常对应首列标签维度,2+ 对应后续数据维度列)" + "area": { + "description": "区域填充配置,配置项同 plotArea.areas", + "type": "object" + }, + "bars": { + "description": "柱状图配置,配置项同 plotArea.bars", + "type": "object" }, "comboType": { - "type": "string", "description": "组合图下该系列的图表类型,仅在 type 为 combo 时生效", "enum": [ "column", "line", "area" - ] + ], + "type": "string" }, - "yAxisPosition": { - "type": "string", - "description": "Y轴位置", - "enum": [ - "left", - "right" - ] + "index": { + "description": "数据列索引,与 dim2.series[].index 值一致,从 1 开始(1 通常对应首列标签维度,2+ 对应后续数据维度列)", + "type": "number" }, - "points": { - "type": "object", - "description": "数据点配置,配置项同 plotArea.points" + "labels": { + "description": "数据标签配置", + "type": "object" }, "line": { - "type": "object", - "description": "线条配置,配置项同 plotArea.lines" - }, - "area": { - "type": "object", - "description": "区域填充配置,配置项同 plotArea.areas" - }, - "bars": { - "type": "object", - "description": "柱状图配置,配置项同 plotArea.bars" + "description": "线条配置,配置项同 plotArea.lines", + "type": "object" }, - "labels": { - "type": "object", - "description": "数据标签配置" + "points": { + "description": "数据点配置,配置项同 plotArea.points", + "type": "object" }, "sectors": { - "type": "object", "description": "扇区配置(饼图)", "properties": { "borderColor": { - "type": "string", - "description": "边框颜色" + "description": "边框颜色", + "type": "string" }, "innerRadius": { - "type": "number", - "description": "内半径比例,0-1之间" + "description": "内半径比例,0-1之间", + "type": "number" }, "offsetRadius": { - "type": "number", - "description": "偏移半径比例" - }, - "startAngle": { - "type": "number", - "description": "起始角度,0-359" + "description": "偏移半径比例", + "type": "number" }, "sector": { - "type": "array", "description": "单个扇区配置数组", "items": { - "type": "object", "properties": { - "index": { - "type": "number", - "description": "扇区索引" - }, "borderColor": { - "type": "string", - "description": "边框颜色" - }, - "offsetRadius": { - "type": "number", - "description": "偏移半径" + "description": "边框颜色", + "type": "string" }, "color": { - "type": "string", - "description": "颜色" + "description": "颜色", + "type": "string" + }, + "index": { + "description": "扇区索引", + "type": "number" + }, + "offsetRadius": { + "description": "偏移半径", + "type": "number" } }, "required": [ "index" - ] - } + ], + "type": "object" + }, + "type": "array" + }, + "startAngle": { + "description": "起始角度,0-359", + "type": "number" } - } + }, + "type": "object" + }, + "yAxisPosition": { + "description": "Y轴位置", + "enum": [ + "left", + "right" + ], + "type": "string" } }, "required": [ "index" - ] - } + ], + "type": "object" + }, + "type": "array" + }, + "type": { + "description": "图表类型", + "enum": [ + "bar", + "column", + "line", + "area", + "combo", + "pie", + "radar", + "scatter" + ], + "type": "string" + }, + "yAxisPosition": { + "description": "Y轴位置", + "enum": [ + "left", + "right" + ], + "type": "string" } }, "required": [ "type" - ] + ], + "type": "object" + } + }, + "type": "object" + }, + "style": { + "description": "图表样式配置", + "properties": { + "background": { + "description": "背景配置", + "properties": { + "color": { + "description": "背景颜色,格式为 #RRGGBB", + "type": "string" + } + }, + "type": "object" }, - "axes": { - "type": "array", - "description": "坐标轴配置数组", - "items": { - "type": "object", - "description": "坐标轴配置", - "properties": { - "type": { - "type": "string", - "description": "坐标轴类型", - "enum": [ - "x", - "y", - "angle", - "radius" - ] - }, - "position": { - "type": "string", - "description": "坐标轴位置", - "enum": [ - "left", - "right", - "bottom" - ] - }, - "max": { - "type": "number", - "description": "最大值" - }, - "min": { - "type": "number", - "description": "最小值" - }, - "valueType": { - "type": "string", - "description": "是否将横轴数字视为文本,linear 表示将数字视为数值, ordinal 表示将数字视为文本", - "enum": [ - "ordinal", - "linear" - ] - }, - "title": { - "type": "object", - "description": "坐标轴标题配置", - "properties": { - "text": { - "type": "string", - "description": "标题文本" - }, - "fontSize": { - "type": "number", - "description": "字体大小" - }, - "bold": { - "type": "boolean", - "description": "是否加粗" - }, - "italic": { - "type": "boolean", - "description": "是否斜体" - }, - "underline": { - "type": "boolean", - "description": "是否下划线" - }, - "strikethrough": { - "type": "boolean", - "description": "是否删除线" - }, - "color": { - "type": "string", - "description": "字体颜色" - } - }, - "required": [ - "text" - ] - }, - "label": { - "type": "object", - "description": "坐标轴标签配置", - "properties": { - "angle": { - "type": "number", - "description": "旋转角度,可选值:-90, -45, 0, 45, 90" - }, - "fontSize": { - "type": "number", - "description": "字体大小" - }, - "bold": { - "type": "boolean", - "description": "是否加粗" - }, - "italic": { - "type": "boolean", - "description": "是否斜体" - }, - "underline": { - "type": "boolean", - "description": "是否下划线" - }, - "strikethrough": { - "type": "boolean", - "description": "是否删除线" - }, - "color": { - "type": "string", - "description": "字体颜色" - } - } + "border": { + "description": "边框配置", + "properties": { + "color": { + "description": "边框颜色,格式为 #RRGGBB", + "type": "string" + }, + "radius": { + "description": "边框圆角", + "type": "number" + }, + "style": { + "description": "边框样式", + "enum": [ + "solid", + "dashed", + "dotted" + ], + "type": "string" + }, + "width": { + "description": "边框宽度", + "type": "number" + } + }, + "type": "object" + }, + "colorGradient": { + "description": "是否启用颜色渐变", + "type": "boolean" + }, + "colorTheme": { + "description": "颜色主题配置。支持两种模式:1. 仅传一个预设主题名称(字符串数组,长度为1);2. 传多个自定义十六进制颜色字符串。预设主题:brandColorSeries@v2、rainbowColorSeries@v2、complementaryColorSeries@v2、converseColorSeries@v2、primaryColorSeries@v2、singleColorSeries-B-@v2、singleColorSeries-W-@v2、singleColorSeries-G-@v2、singleColorSeries-Y-@v2、singleColorSeries-O-@v2、singleColorSeries-R-@v2、singleColorSeries-D-@v2。十六进制格式:#RRGGBB", + "oneOf": [ + { + "items": { + "enum": [ + "brandColorSeries@v2", + "rainbowColorSeries@v2", + "complementaryColorSeries@v2", + "converseColorSeries@v2", + "primaryColorSeries@v2", + "singleColorSeries-B-@v2", + "singleColorSeries-W-@v2", + "singleColorSeries-G-@v2", + "singleColorSeries-Y-@v2", + "singleColorSeries-O-@v2", + "singleColorSeries-R-@v2", + "singleColorSeries-D-@v2" + ], + "type": "string" }, - "axisLine": { - "type": "boolean", - "description": "是否显示轴线" + "maxItems": 1, + "minItems": 1 + }, + { + "items": { + "description": "颜色字符串,十六进制格式:#RRGGBB", + "type": "string" }, - "gridLine": { - "oneOf": [ - { - "type": "object", - "description": "网格线配置", - "properties": { - "width": { - "type": "number", - "description": "网格线宽度" - }, - "color": { - "type": "string", - "description": "网格线颜色" - } - } - }, - { - "type": "boolean", - "description": "false 表示隐藏网格线" - } - ] - } + "minItems": 2 + } + ], + "type": "array" + }, + "font": { + "description": "字体配置", + "properties": { + "color": { + "description": "字体颜色,格式为 #RRGGBB", + "type": "string" }, - "required": [ - "type" - ] - } + "size": { + "description": "字体大小", + "type": "number" + } + }, + "type": "object" } - } + }, + "type": "object" }, - "data": { - "type": "object", - "description": "图表数据配置", + "subTitle": { + "description": "图表副标题配置", "properties": { - "isStaticData": { - "type": "boolean", - "description": "是否为静态数据" + "bold": { + "description": "是否加粗", + "type": "boolean" }, - "includeHiddenOrFilter": { - "type": "boolean", - "description": "是否包含隐藏或过滤的数据" + "color": { + "description": "字体颜色,格式为 #RRGGBB", + "type": "string" }, - "direction": { - "type": "string", - "description": "数据方向,可选值:row(行方向)或column(列方向),常用值为column", - "enum": [ - "row", - "column" - ] + "fontSize": { + "description": "字体大小", + "type": "number" }, - "headerMode": { - "type": "string", - "description": "表头模式。inline 表示 refs 首行/首列就是表头;detached 表示 refs 仅覆盖实际数据范围,维度名和系列名需通过 nameRef 显式指定。", + "italic": { + "description": "是否斜体", + "type": "boolean" + }, + "strikethrough": { + "description": "是否删除线", + "type": "boolean" + }, + "text": { + "description": "副标题文本", + "type": "string" + }, + "textAlign": { + "description": "副标题对齐方式", "enum": [ - "inline", - "detached" - ] + "left", + "center", + "right" + ], + "type": "string" }, - "refs": { - "type": "array", - "description": "数据源引用范围数组", - "items": { - "type": "object", - "properties": { - "value": { - "type": "string", - "description": "图表数据源范围,格式为 'SheetName!A1:D100' 的连续区域" - } - }, - "required": [ - "value" - ] - } + "underline": { + "description": "是否下划线", + "type": "boolean" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "title": { + "description": "图表标题配置", + "properties": { + "bold": { + "description": "是否加粗", + "type": "boolean" }, - "dim1": { - "type": "object", - "description": "维度1配置(类别维度)", - "properties": { - "serie": { - "type": "object", - "description": "系列配置(非静态数据时传此参数)", - "properties": { - "index": { - "type": "number", - "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引" - }, - "aggregate": { - "type": "boolean", - "description": "是否汇总相同的类别,默认为true,true 情况下 aggregateType 才生效" - }, - "nameRef": { - "type": "string", - "description": "可选。维度名称的单元格引用(A1 表示法,如 'Sheet1!A1')。当维度名称不直接来自 refs 首行/首列时,可通过此字段把维度名显式指向目标表头单元格,图表会以该单元格当前值作为类别维度名展示。" - } - } - }, - "field": { - "type": "object", - "description": "字段配置(静态数据时传此参数)", - "properties": { - "valueType": { - "type": "string", - "description": "值类型", - "enum": [ - "number", - "string" - ] - }, - "name": { - "type": "string", - "description": "字段名称" - }, - "text": { - "type": "string", - "description": "字段文本数据" - } - }, - "required": [ - "text" - ] - } - } + "color": { + "description": "字体颜色,格式为 #RRGGBB", + "type": "string" }, - "dim2": { - "type": "object", - "description": "维度2配置(值维度)", - "properties": { - "series": { - "type": "array", - "description": "系列配置数组(非静态数据时传此参数)", - "items": { - "type": "object", - "properties": { - "index": { - "type": "number", - "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引" - }, - "aggregateType": { - "type": "string", - "description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效", - "enum": [ - "sum", - "average", - "count", - "min", - "max", - "median" - ] - }, - "nameRef": { - "type": "string", - "description": "可选。系列名称的单元格引用(A1 表示法,如 'Sheet1!C1')。当系列名称不直接来自 refs 首行/首列时,可通过此字段把系列名显式指向目标表头单元格,图表会以该单元格当前值作为系列名(图例/tooltip)展示。" - } - } - } - }, - "fields": { - "type": "array", - "description": "字段配置数组(静态数据时传此参数)", - "items": { - "type": "object", - "properties": { - "valueType": { - "type": "string", - "description": "值类型", - "enum": [ - "number" - ] - }, - "name": { - "type": "string", - "description": "字段名称" - }, - "text": { - "type": "string", - "description": "字段文本数据" - } - }, - "required": [ - "text" - ] - } - } - } + "fontSize": { + "description": "字体大小", + "type": "number" + }, + "italic": { + "description": "是否斜体", + "type": "boolean" + }, + "strikethrough": { + "description": "是否删除线", + "type": "boolean" + }, + "text": { + "description": "标题文本", + "type": "string" + }, + "textAlign": { + "description": "标题对齐方式", + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "underline": { + "description": "是否下划线", + "type": "boolean" } - } + }, + "required": [ + "text" + ], + "type": "object" } - } + }, + "type": "object" } }, - "required": [ - "position", - "size" - ], - "additionalProperties": {} + "type": "object" } }, "+chart-update": { "properties": { + "additionalProperties": {}, "description": "创建/更新的图表属性。", - "type": "object", "properties": { + "offset": { + "additionalProperties": false, + "description": "可选。图表在位置基础上的偏移量(像素)。", + "properties": { + "col_offset": { + "description": "列偏移量(像素)", + "type": "number" + }, + "row_offset": { + "description": "行偏移量(像素)", + "type": "number" + } + }, + "type": "object" + }, "position": { - "type": "object", + "additionalProperties": false, "description": "必填。图表在表格中的单元格位置。注意:选择位置时应避免覆盖已有数据的单元格,并确保图表不超出当前表格的行列范围。", "properties": { + "col": { + "description": "列索引,例如 \"A\"、\"B\"", + "type": "string" + }, "row": { - "type": "number", + "description": "行索引(0-based)", "minimum": 0, - "description": "行索引(0-based)" - }, - "col": { - "type": "string", - "description": "列索引,例如 \"A\"、\"B\"" + "type": "number" } }, "required": [ "row", "col" ], - "additionalProperties": false - }, - "offset": { - "type": "object", - "description": "可选。图表在位置基础上的偏移量(像素)。", - "properties": { - "row_offset": { - "type": "number", - "description": "行偏移量(像素)" - }, - "col_offset": { - "type": "number", - "description": "列偏移量(像素)" - } - }, - "additionalProperties": false + "type": "object" }, "size": { - "type": "object", + "additionalProperties": false, "description": "必填。图表大小(像素)。注意:设定大小时应确保图表不超出当前表格的行列范围,并避免覆盖已有数据的单元格。", "properties": { - "width": { - "type": "number", + "height": { + "description": "高度(像素)", "minimum": 10, - "description": "宽度(像素)" + "type": "number" }, - "height": { - "type": "number", + "width": { + "description": "宽度(像素)", "minimum": 10, - "description": "高度(像素)" + "type": "number" } }, "required": [ "width", "height" ], - "additionalProperties": false + "type": "object" }, "snapshot": { - "type": "object", "description": "图表快照配置。更新图表时必须传入完整的图表属性定义,不能只传修改的部分。应先通过 get_chart_objects 获取当前图表快照,修改需要变更的字段后,将完整快照传入。", "properties": { - "title": { - "type": "object", - "description": "图表标题配置", - "properties": { - "text": { - "type": "string", - "description": "标题文本" - }, - "textAlign": { - "type": "string", - "description": "标题对齐方式", - "enum": [ - "left", - "center", - "right" - ] - }, - "fontSize": { - "type": "number", - "description": "字体大小" - }, - "bold": { - "type": "boolean", - "description": "是否加粗" - }, - "italic": { - "type": "boolean", - "description": "是否斜体" - }, - "underline": { - "type": "boolean", - "description": "是否下划线" - }, - "strikethrough": { - "type": "boolean", - "description": "是否删除线" - }, - "color": { - "type": "string", - "description": "字体颜色,格式为 #RRGGBB" - } - }, - "required": [ - "text" - ] - }, - "subTitle": { - "type": "object", - "description": "图表副标题配置", + "data": { + "description": "图表数据配置", "properties": { - "text": { - "type": "string", - "description": "副标题文本" - }, - "textAlign": { - "type": "string", - "description": "副标题对齐方式", - "enum": [ - "left", - "center", - "right" - ] - }, - "fontSize": { - "type": "number", - "description": "字体大小" - }, - "bold": { - "type": "boolean", - "description": "是否加粗" - }, - "italic": { - "type": "boolean", - "description": "是否斜体" - }, - "underline": { - "type": "boolean", - "description": "是否下划线" - }, - "strikethrough": { - "type": "boolean", - "description": "是否删除线" + "dim1": { + "description": "维度1配置(类别维度)", + "properties": { + "field": { + "description": "字段配置(静态数据时传此参数)", + "properties": { + "name": { + "description": "字段名称", + "type": "string" + }, + "text": { + "description": "字段文本数据", + "type": "string" + }, + "valueType": { + "description": "值类型", + "enum": [ + "number", + "string" + ], + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "serie": { + "description": "系列配置(非静态数据时传此参数)", + "properties": { + "aggregate": { + "description": "是否汇总相同的类别,默认为true,true 情况下 aggregateType 才生效", + "type": "boolean" + }, + "index": { + "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引", + "type": "number" + }, + "nameRef": { + "description": "可选。维度名称的单元格引用(A1 表示法,如 'Sheet1!A1')。当维度名称不直接来自 refs 首行/首列时,可通过此字段把维度名显式指向目标表头单元格,图表会以该单元格当前值作为类别维度名展示。", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" }, - "color": { - "type": "string", - "description": "字体颜色,格式为 #RRGGBB" - } - }, - "required": [ - "text" - ] - }, - "style": { - "type": "object", - "description": "图表样式配置", - "properties": { - "background": { - "type": "object", - "description": "背景配置", + "dim2": { + "description": "维度2配置(值维度)", "properties": { - "color": { - "type": "string", - "description": "背景颜色,格式为 #RRGGBB" + "fields": { + "description": "字段配置数组(静态数据时传此参数)", + "items": { + "properties": { + "name": { + "description": "字段名称", + "type": "string" + }, + "text": { + "description": "字段文本数据", + "type": "string" + }, + "valueType": { + "description": "值类型", + "enum": [ + "number" + ], + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "type": "array" + }, + "series": { + "description": "系列配置数组(非静态数据时传此参数)", + "items": { + "properties": { + "aggregateType": { + "description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效", + "enum": [ + "sum", + "average", + "count", + "min", + "max", + "median" + ], + "type": "string" + }, + "index": { + "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引", + "type": "number" + }, + "nameRef": { + "description": "可选。系列名称的单元格引用(A1 表示法,如 'Sheet1!C1')。当系列名称不直接来自 refs 首行/首列时,可通过此字段把系列名显式指向目标表头单元格,图表会以该单元格当前值作为系列名(图例/tooltip)展示。", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" } - } + }, + "type": "object" }, - "font": { - "type": "object", - "description": "字体配置", - "properties": { - "size": { - "type": "number", - "description": "字体大小" - }, - "color": { - "type": "string", - "description": "字体颜色,格式为 #RRGGBB" - } - } + "direction": { + "description": "数据方向,可选值:row(行方向)或column(列方向),常用值为column", + "enum": [ + "row", + "column" + ], + "type": "string" }, - "border": { - "type": "object", - "description": "边框配置", - "properties": { - "color": { - "type": "string", - "description": "边框颜色,格式为 #RRGGBB" - }, - "width": { - "type": "number", - "description": "边框宽度" - }, - "style": { - "type": "string", - "description": "边框样式", - "enum": [ - "solid", - "dashed", - "dotted" - ] - }, - "radius": { - "type": "number", - "description": "边框圆角" - } - } + "headerMode": { + "description": "表头模式。inline 表示 refs 首行/首列就是表头;detached 表示 refs 仅覆盖实际数据范围,维度名和系列名需通过 nameRef 显式指定。", + "enum": [ + "inline", + "detached" + ], + "type": "string" }, - "colorTheme": { - "type": "array", - "description": "颜色主题配置。支持两种模式:1. 仅传一个预设主题名称(字符串数组,长度为1);2. 传多个自定义十六进制颜色字符串。预设主题:brandColorSeries@v2、rainbowColorSeries@v2、complementaryColorSeries@v2、converseColorSeries@v2、primaryColorSeries@v2、singleColorSeries-B-@v2、singleColorSeries-W-@v2、singleColorSeries-G-@v2、singleColorSeries-Y-@v2、singleColorSeries-O-@v2、singleColorSeries-R-@v2、singleColorSeries-D-@v2。十六进制格式:#RRGGBB", - "oneOf": [ - { - "minItems": 1, - "maxItems": 1, - "items": { - "type": "string", - "enum": [ - "brandColorSeries@v2", - "rainbowColorSeries@v2", - "complementaryColorSeries@v2", - "converseColorSeries@v2", - "primaryColorSeries@v2", - "singleColorSeries-B-@v2", - "singleColorSeries-W-@v2", - "singleColorSeries-G-@v2", - "singleColorSeries-Y-@v2", - "singleColorSeries-O-@v2", - "singleColorSeries-R-@v2", - "singleColorSeries-D-@v2" - ] + "includeHiddenOrFilter": { + "description": "是否包含隐藏或过滤的数据", + "type": "boolean" + }, + "isStaticData": { + "description": "是否为静态数据", + "type": "boolean" + }, + "refs": { + "description": "数据源引用范围数组", + "items": { + "properties": { + "value": { + "description": "图表数据源范围,格式为 'SheetName!A1:D100' 的连续区域", + "type": "string" } }, - { - "minItems": 2, - "items": { - "type": "string", - "description": "颜色字符串,十六进制格式:#RRGGBB" - } - } - ] - }, - "colorGradient": { - "type": "boolean", - "description": "是否启用颜色渐变" + "required": [ + "value" + ], + "type": "object" + }, + "type": "array" } - } + }, + "type": "object" }, "legend": { "oneOf": [ { - "type": "object", "description": "图例配置", "properties": { + "bold": { + "description": "是否加粗", + "type": "boolean" + }, + "color": { + "description": "字体颜色,格式为 #RRGGBB", + "type": "string" + }, + "fontSize": { + "description": "字体大小", + "type": "number" + }, + "italic": { + "description": "是否斜体", + "type": "boolean" + }, "position": { - "type": "string", "description": "图例位置", "enum": [ "top", "bottom", "left", "right" - ] - }, - "fontSize": { - "type": "number", - "description": "字体大小" - }, - "bold": { - "type": "boolean", - "description": "是否加粗" - }, - "italic": { - "type": "boolean", - "description": "是否斜体" - }, - "underline": { - "type": "boolean", - "description": "是否下划线" + ], + "type": "string" }, "strikethrough": { - "type": "boolean", - "description": "是否删除线" + "description": "是否删除线", + "type": "boolean" }, - "color": { - "type": "string", - "description": "字体颜色,格式为 #RRGGBB" + "underline": { + "description": "是否下划线", + "type": "boolean" } - } + }, + "type": "object" }, { - "type": "boolean", - "description": "false 表示隐藏图例" + "description": "false 表示隐藏图例", + "type": "boolean" } ] }, "plotArea": { - "type": "object", "description": "绘图区域配置", "properties": { + "axes": { + "description": "坐标轴配置数组", + "items": { + "description": "坐标轴配置", + "properties": { + "axisLine": { + "description": "是否显示轴线", + "type": "boolean" + }, + "gridLine": { + "oneOf": [ + { + "description": "网格线配置", + "properties": { + "color": { + "description": "网格线颜色", + "type": "string" + }, + "width": { + "description": "网格线宽度", + "type": "number" + } + }, + "type": "object" + }, + { + "description": "false 表示隐藏网格线", + "type": "boolean" + } + ] + }, + "label": { + "description": "坐标轴标签配置", + "properties": { + "angle": { + "description": "旋转角度,可选值:-90, -45, 0, 45, 90", + "type": "number" + }, + "bold": { + "description": "是否加粗", + "type": "boolean" + }, + "color": { + "description": "字体颜色", + "type": "string" + }, + "fontSize": { + "description": "字体大小", + "type": "number" + }, + "italic": { + "description": "是否斜体", + "type": "boolean" + }, + "strikethrough": { + "description": "是否删除线", + "type": "boolean" + }, + "underline": { + "description": "是否下划线", + "type": "boolean" + } + }, + "type": "object" + }, + "max": { + "description": "最大值", + "type": "number" + }, + "min": { + "description": "最小值", + "type": "number" + }, + "position": { + "description": "坐标轴位置", + "enum": [ + "left", + "right", + "bottom" + ], + "type": "string" + }, + "title": { + "description": "坐标轴标题配置", + "properties": { + "bold": { + "description": "是否加粗", + "type": "boolean" + }, + "color": { + "description": "字体颜色", + "type": "string" + }, + "fontSize": { + "description": "字体大小", + "type": "number" + }, + "italic": { + "description": "是否斜体", + "type": "boolean" + }, + "strikethrough": { + "description": "是否删除线", + "type": "boolean" + }, + "text": { + "description": "标题文本", + "type": "string" + }, + "underline": { + "description": "是否下划线", + "type": "boolean" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "type": { + "description": "坐标轴类型", + "enum": [ + "x", + "y", + "angle", + "radius" + ], + "type": "string" + }, + "valueType": { + "description": "是否将横轴数字视为文本,linear 表示将数字视为数值, ordinal 表示将数字视为文本", + "enum": [ + "ordinal", + "linear" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "type": "array" + }, "plot": { - "type": "object", "description": "绘图配置", "properties": { - "type": { - "type": "string", - "description": "图表类型", - "enum": [ - "bar", - "column", - "line", - "area", - "combo", - "pie", - "radar", - "scatter" - ] + "areas": { + "description": "全系列面积填充配置,面积图、雷达图、组合图生效。", + "properties": { + "color": { + "description": "区域填充颜色", + "type": "string" + } + }, + "type": "object" + }, + "bars": { + "description": "全系列柱状图、条形图、组合图生效。", + "properties": { + "backgroundColor": { + "description": "背景颜色", + "type": "string" + }, + "bar": { + "description": "单个柱子配置数组", + "items": { + "properties": { + "borderColor": { + "description": "边框颜色", + "type": "string" + }, + "borderStyle": { + "description": "边框样式", + "type": "string" + }, + "borderWidth": { + "description": "边框宽度", + "type": "number" + }, + "color": { + "description": "颜色", + "type": "string" + }, + "index": { + "description": "柱子索引", + "type": "number" + } + }, + "required": [ + "index" + ], + "type": "object" + }, + "type": "array" + }, + "borderColor": { + "description": "边框颜色", + "type": "string" + }, + "borderStyle": { + "description": "边框样式", + "enum": [ + "solid", + "dashed", + "dotted" + ], + "type": "string" + }, + "borderWidth": { + "description": "边框宽度", + "type": "number" + }, + "color": { + "description": "柱子颜色", + "type": "string" + }, + "gap": { + "description": "柱子间距比例,0-1之间", + "type": "number" + }, + "width": { + "description": "柱子宽度", + "type": "number" + } + }, + "type": "object" }, "comboType": { - "type": "string", "description": "组合图表默认类型", "enum": [ "column", "line", "area" - ] - }, - "yAxisPosition": { - "type": "string", - "description": "Y轴位置", - "enum": [ - "left", - "right" - ] + ], + "type": "string" }, "extra": { - "type": "object", "description": "额外配置", "properties": { - "smooth": { - "type": "boolean", - "description": "是否平滑曲线" - }, - "step": { - "type": "boolean", - "description": "是否阶梯图" - }, - "stack": { - "type": "object", - "description": "堆叠配置", - "properties": { - "percentage": { - "type": "boolean", - "description": "是否百分比堆叠" - } - } - }, "radar": { - "type": "object", "description": "雷达图配置", "properties": { + "area": { + "description": "是否填充区域", + "type": "boolean" + }, "shape": { - "type": "string", "description": "雷达图形状", "enum": [ "polygon", "circle" - ] - }, - "area": { - "type": "boolean", - "description": "是否填充区域" + ], + "type": "string" } - } + }, + "type": "object" + }, + "smooth": { + "description": "是否平滑曲线", + "type": "boolean" + }, + "stack": { + "description": "堆叠配置", + "properties": { + "percentage": { + "description": "是否百分比堆叠", + "type": "boolean" + } + }, + "type": "object" + }, + "step": { + "description": "是否阶梯图", + "type": "boolean" } - } + }, + "type": "object" }, - "points": { - "type": "object", - "description": "全系列数据点配置,折线图、面积图、雷达图、散点图、组合图生效。", + "labels": { + "description": "数据标签配置", "properties": { + "bold": { + "description": "是否加粗", + "type": "boolean" + }, + "category": { + "description": "是否显示类别名", + "type": "boolean" + }, "color": { - "type": "string", - "description": "数据点颜色" + "description": "字体颜色", + "type": "string" }, - "shape": { - "type": "string", - "description": "数据点形状", + "fontSize": { + "description": "字体大小", + "type": "number" + }, + "italic": { + "description": "是否斜体", + "type": "boolean" + }, + "percentage": { + "description": "是否显示百分比", + "type": "boolean" + }, + "position": { + "description": "标签位置", "enum": [ - "circle", - "triangle", - "rect", - "diamond", - "square" - ] + "auto", + "top", + "bottom", + "left", + "right", + "center", + "inside", + "outside" + ], + "type": "string" }, - "size": { - "type": "number", - "description": "数据点大小" + "series": { + "description": "是否显示系列名", + "type": "boolean" }, - "point": { - "type": "array", - "description": "单个数据点配置数组", - "items": { - "type": "object", - "properties": { - "index": { - "type": "number", - "description": "数据点索引" - }, - "color": { - "type": "string", - "description": "颜色" - }, - "shape": { - "type": "string", - "description": "形状" - }, - "size": { - "type": "number", - "description": "大小" - } - }, - "required": [ - "index" - ] - } + "strikethrough": { + "description": "是否删除线", + "type": "boolean" + }, + "underline": { + "description": "是否下划线", + "type": "boolean" + }, + "value": { + "description": "是否显示值", + "type": "boolean" } - } + }, + "type": "object" }, "lines": { - "type": "object", "description": "全系列线条配置,折线图、面积图、雷达图、组合图生效。", "properties": { "color": { - "type": "string", - "description": "线条颜色" - }, - "width": { - "type": "number", - "description": "线条宽度" - }, - "style": { - "type": "string", - "description": "线条样式", - "enum": [ - "solid", - "dashed", - "dotted" - ] + "description": "线条颜色", + "type": "string" }, "invalidType": { - "type": "string", "description": "无效值处理方式", "enum": [ "break", "zero", "link" - ] - } - } - }, - "areas": { - "type": "object", - "description": "全系列面积填充配置,面积图、雷达图、组合图生效。", - "properties": { - "color": { - "type": "string", - "description": "区域填充颜色" - } - } - }, - "bars": { - "type": "object", - "description": "全系列柱状图、条形图、组合图生效。", - "properties": { - "color": { - "type": "string", - "description": "柱子颜色" - }, - "borderColor": { - "type": "string", - "description": "边框颜色" - }, - "borderWidth": { - "type": "number", - "description": "边框宽度" + ], + "type": "string" }, - "borderStyle": { - "type": "string", - "description": "边框样式", + "style": { + "description": "线条样式", "enum": [ "solid", "dashed", "dotted" - ] + ], + "type": "string" }, "width": { - "type": "number", - "description": "柱子宽度" - }, - "gap": { - "type": "number", - "description": "柱子间距比例,0-1之间" - }, - "backgroundColor": { - "type": "string", - "description": "背景颜色" + "description": "线条宽度", + "type": "number" + } + }, + "type": "object" + }, + "points": { + "description": "全系列数据点配置,折线图、面积图、雷达图、散点图、组合图生效。", + "properties": { + "color": { + "description": "数据点颜色", + "type": "string" }, - "bar": { - "type": "array", - "description": "单个柱子配置数组", + "point": { + "description": "单个数据点配置数组", "items": { - "type": "object", "properties": { - "index": { - "type": "number", - "description": "柱子索引" - }, "color": { - "type": "string", - "description": "颜色" + "description": "颜色", + "type": "string" }, - "borderColor": { - "type": "string", - "description": "边框颜色" + "index": { + "description": "数据点索引", + "type": "number" }, - "borderWidth": { - "type": "number", - "description": "边框宽度" + "shape": { + "description": "形状", + "type": "string" }, - "borderStyle": { - "type": "string", - "description": "边框样式" + "size": { + "description": "大小", + "type": "number" } }, "required": [ "index" - ] - } - } - } - }, - "labels": { - "type": "object", - "description": "数据标签配置", - "properties": { - "position": { - "type": "string", - "description": "标签位置", - "enum": [ - "auto", - "top", - "bottom", - "left", - "right", - "center", - "inside", - "outside" - ] - }, - "series": { - "type": "boolean", - "description": "是否显示系列名" - }, - "category": { - "type": "boolean", - "description": "是否显示类别名" - }, - "value": { - "type": "boolean", - "description": "是否显示值" - }, - "percentage": { - "type": "boolean", - "description": "是否显示百分比" - }, - "fontSize": { - "type": "number", - "description": "字体大小" - }, - "bold": { - "type": "boolean", - "description": "是否加粗" - }, - "italic": { - "type": "boolean", - "description": "是否斜体" - }, - "underline": { - "type": "boolean", - "description": "是否下划线" + ], + "type": "object" + }, + "type": "array" }, - "strikethrough": { - "type": "boolean", - "description": "是否删除线" + "shape": { + "description": "数据点形状", + "enum": [ + "circle", + "triangle", + "rect", + "diamond", + "square" + ], + "type": "string" }, - "color": { - "type": "string", - "description": "字体颜色" + "size": { + "description": "数据点大小", + "type": "number" } - } + }, + "type": "object" }, "series": { - "type": "array", "description": "单个系列配置数组", "items": { - "type": "object", "description": "系列配置", "properties": { - "index": { - "type": "number", - "description": "数据列索引,与 dim2.series[].index 值一致,从 1 开始(1 通常对应首列标签维度,2+ 对应后续数据维度列)" + "area": { + "description": "区域填充配置,配置项同 plotArea.areas", + "type": "object" + }, + "bars": { + "description": "柱状图配置,配置项同 plotArea.bars", + "type": "object" }, "comboType": { - "type": "string", "description": "组合图下该系列的图表类型,仅在 type 为 combo 时生效", "enum": [ "column", "line", "area" - ] + ], + "type": "string" }, - "yAxisPosition": { - "type": "string", - "description": "Y轴位置", - "enum": [ - "left", - "right" - ] + "index": { + "description": "数据列索引,与 dim2.series[].index 值一致,从 1 开始(1 通常对应首列标签维度,2+ 对应后续数据维度列)", + "type": "number" }, - "points": { - "type": "object", - "description": "数据点配置,配置项同 plotArea.points" + "labels": { + "description": "数据标签配置", + "type": "object" }, "line": { - "type": "object", - "description": "线条配置,配置项同 plotArea.lines" - }, - "area": { - "type": "object", - "description": "区域填充配置,配置项同 plotArea.areas" + "description": "线条配置,配置项同 plotArea.lines", + "type": "object" }, - "bars": { - "type": "object", - "description": "柱状图配置,配置项同 plotArea.bars" - }, - "labels": { - "type": "object", - "description": "数据标签配置" + "points": { + "description": "数据点配置,配置项同 plotArea.points", + "type": "object" }, "sectors": { - "type": "object", "description": "扇区配置(饼图)", "properties": { "borderColor": { - "type": "string", - "description": "边框颜色" + "description": "边框颜色", + "type": "string" }, "innerRadius": { - "type": "number", - "description": "内半径比例,0-1之间" + "description": "内半径比例,0-1之间", + "type": "number" }, "offsetRadius": { - "type": "number", - "description": "偏移半径比例" - }, - "startAngle": { - "type": "number", - "description": "起始角度,0-359" + "description": "偏移半径比例", + "type": "number" }, "sector": { - "type": "array", "description": "单个扇区配置数组", "items": { - "type": "object", "properties": { - "index": { - "type": "number", - "description": "扇区索引" - }, "borderColor": { - "type": "string", - "description": "边框颜色" - }, - "offsetRadius": { - "type": "number", - "description": "偏移半径" + "description": "边框颜色", + "type": "string" }, "color": { - "type": "string", - "description": "颜色" + "description": "颜色", + "type": "string" + }, + "index": { + "description": "扇区索引", + "type": "number" + }, + "offsetRadius": { + "description": "偏移半径", + "type": "number" } }, "required": [ "index" - ] - } + ], + "type": "object" + }, + "type": "array" + }, + "startAngle": { + "description": "起始角度,0-359", + "type": "number" } - } + }, + "type": "object" + }, + "yAxisPosition": { + "description": "Y轴位置", + "enum": [ + "left", + "right" + ], + "type": "string" } }, "required": [ "index" - ] - } + ], + "type": "object" + }, + "type": "array" + }, + "type": { + "description": "图表类型", + "enum": [ + "bar", + "column", + "line", + "area", + "combo", + "pie", + "radar", + "scatter" + ], + "type": "string" + }, + "yAxisPosition": { + "description": "Y轴位置", + "enum": [ + "left", + "right" + ], + "type": "string" } }, "required": [ "type" - ] - }, - "axes": { - "type": "array", - "description": "坐标轴配置数组", - "items": { - "type": "object", - "description": "坐标轴配置", - "properties": { - "type": { - "type": "string", - "description": "坐标轴类型", - "enum": [ - "x", - "y", - "angle", - "radius" - ] - }, - "position": { - "type": "string", - "description": "坐标轴位置", - "enum": [ - "left", - "right", - "bottom" - ] - }, - "max": { - "type": "number", - "description": "最大值" - }, - "min": { - "type": "number", - "description": "最小值" - }, - "valueType": { - "type": "string", - "description": "是否将横轴数字视为文本,linear 表示将数字视为数值, ordinal 表示将数字视为文本", - "enum": [ - "ordinal", - "linear" - ] - }, - "title": { - "type": "object", - "description": "坐标轴标题配置", - "properties": { - "text": { - "type": "string", - "description": "标题文本" - }, - "fontSize": { - "type": "number", - "description": "字体大小" - }, - "bold": { - "type": "boolean", - "description": "是否加粗" - }, - "italic": { - "type": "boolean", - "description": "是否斜体" - }, - "underline": { - "type": "boolean", - "description": "是否下划线" - }, - "strikethrough": { - "type": "boolean", - "description": "是否删除线" - }, - "color": { - "type": "string", - "description": "字体颜色" - } - }, - "required": [ - "text" - ] - }, - "label": { - "type": "object", - "description": "坐标轴标签配置", - "properties": { - "angle": { - "type": "number", - "description": "旋转角度,可选值:-90, -45, 0, 45, 90" - }, - "fontSize": { - "type": "number", - "description": "字体大小" - }, - "bold": { - "type": "boolean", - "description": "是否加粗" - }, - "italic": { - "type": "boolean", - "description": "是否斜体" - }, - "underline": { - "type": "boolean", - "description": "是否下划线" - }, - "strikethrough": { - "type": "boolean", - "description": "是否删除线" - }, - "color": { - "type": "string", - "description": "字体颜色" - } - } - }, - "axisLine": { - "type": "boolean", - "description": "是否显示轴线" - }, - "gridLine": { - "oneOf": [ - { - "type": "object", - "description": "网格线配置", - "properties": { - "width": { - "type": "number", - "description": "网格线宽度" - }, - "color": { - "type": "string", - "description": "网格线颜色" - } - } - }, - { - "type": "boolean", - "description": "false 表示隐藏网格线" - } - ] - } - }, - "required": [ - "type" - ] - } + ], + "type": "object" } - } + }, + "type": "object" }, - "data": { - "type": "object", - "description": "图表数据配置", + "style": { + "description": "图表样式配置", "properties": { - "isStaticData": { - "type": "boolean", - "description": "是否为静态数据" - }, - "includeHiddenOrFilter": { - "type": "boolean", - "description": "是否包含隐藏或过滤的数据" - }, - "direction": { - "type": "string", - "description": "数据方向,可选值:row(行方向)或column(列方向),常用值为column", - "enum": [ - "row", - "column" - ] - }, - "headerMode": { - "type": "string", - "description": "表头模式。inline 表示 refs 首行/首列就是表头;detached 表示 refs 仅覆盖实际数据范围,维度名和系列名需通过 nameRef 显式指定。", - "enum": [ - "inline", - "detached" - ] + "background": { + "description": "背景配置", + "properties": { + "color": { + "description": "背景颜色,格式为 #RRGGBB", + "type": "string" + } + }, + "type": "object" }, - "refs": { - "type": "array", - "description": "数据源引用范围数组", - "items": { - "type": "object", - "properties": { - "value": { - "type": "string", - "description": "图表数据源范围,格式为 'SheetName!A1:D100' 的连续区域" - } + "border": { + "description": "边框配置", + "properties": { + "color": { + "description": "边框颜色,格式为 #RRGGBB", + "type": "string" }, - "required": [ - "value" - ] - } + "radius": { + "description": "边框圆角", + "type": "number" + }, + "style": { + "description": "边框样式", + "enum": [ + "solid", + "dashed", + "dotted" + ], + "type": "string" + }, + "width": { + "description": "边框宽度", + "type": "number" + } + }, + "type": "object" }, - "dim1": { - "type": "object", - "description": "维度1配置(类别维度)", - "properties": { - "serie": { - "type": "object", - "description": "系列配置(非静态数据时传此参数)", - "properties": { - "index": { - "type": "number", - "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引" - }, - "aggregate": { - "type": "boolean", - "description": "是否汇总相同的类别,默认为true,true 情况下 aggregateType 才生效" - }, - "nameRef": { - "type": "string", - "description": "可选。维度名称的单元格引用(A1 表示法,如 'Sheet1!A1')。当维度名称不直接来自 refs 首行/首列时,可通过此字段把维度名显式指向目标表头单元格,图表会以该单元格当前值作为类别维度名展示。" - } - } + "colorGradient": { + "description": "是否启用颜色渐变", + "type": "boolean" + }, + "colorTheme": { + "description": "颜色主题配置。支持两种模式:1. 仅传一个预设主题名称(字符串数组,长度为1);2. 传多个自定义十六进制颜色字符串。预设主题:brandColorSeries@v2、rainbowColorSeries@v2、complementaryColorSeries@v2、converseColorSeries@v2、primaryColorSeries@v2、singleColorSeries-B-@v2、singleColorSeries-W-@v2、singleColorSeries-G-@v2、singleColorSeries-Y-@v2、singleColorSeries-O-@v2、singleColorSeries-R-@v2、singleColorSeries-D-@v2。十六进制格式:#RRGGBB", + "oneOf": [ + { + "items": { + "enum": [ + "brandColorSeries@v2", + "rainbowColorSeries@v2", + "complementaryColorSeries@v2", + "converseColorSeries@v2", + "primaryColorSeries@v2", + "singleColorSeries-B-@v2", + "singleColorSeries-W-@v2", + "singleColorSeries-G-@v2", + "singleColorSeries-Y-@v2", + "singleColorSeries-O-@v2", + "singleColorSeries-R-@v2", + "singleColorSeries-D-@v2" + ], + "type": "string" + }, + "maxItems": 1, + "minItems": 1 }, - "field": { - "type": "object", - "description": "字段配置(静态数据时传此参数)", - "properties": { - "valueType": { - "type": "string", - "description": "值类型", - "enum": [ - "number", - "string" - ] - }, - "name": { - "type": "string", - "description": "字段名称" - }, - "text": { - "type": "string", - "description": "字段文本数据" - } + { + "items": { + "description": "颜色字符串,十六进制格式:#RRGGBB", + "type": "string" }, - "required": [ - "text" - ] + "minItems": 2 } - } + ], + "type": "array" }, - "dim2": { - "type": "object", - "description": "维度2配置(值维度)", + "font": { + "description": "字体配置", "properties": { - "series": { - "type": "array", - "description": "系列配置数组(非静态数据时传此参数)", - "items": { - "type": "object", - "properties": { - "index": { - "type": "number", - "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引" - }, - "aggregateType": { - "type": "string", - "description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效", - "enum": [ - "sum", - "average", - "count", - "min", - "max", - "median" - ] - }, - "nameRef": { - "type": "string", - "description": "可选。系列名称的单元格引用(A1 表示法,如 'Sheet1!C1')。当系列名称不直接来自 refs 首行/首列时,可通过此字段把系列名显式指向目标表头单元格,图表会以该单元格当前值作为系列名(图例/tooltip)展示。" - } - } - } + "color": { + "description": "字体颜色,格式为 #RRGGBB", + "type": "string" }, - "fields": { - "type": "array", - "description": "字段配置数组(静态数据时传此参数)", - "items": { - "type": "object", - "properties": { - "valueType": { - "type": "string", - "description": "值类型", - "enum": [ - "number" - ] - }, - "name": { - "type": "string", - "description": "字段名称" - }, - "text": { - "type": "string", - "description": "字段文本数据" - } - }, - "required": [ - "text" - ] - } + "size": { + "description": "字体大小", + "type": "number" } - } + }, + "type": "object" } - } + }, + "type": "object" + }, + "subTitle": { + "description": "图表副标题配置", + "properties": { + "bold": { + "description": "是否加粗", + "type": "boolean" + }, + "color": { + "description": "字体颜色,格式为 #RRGGBB", + "type": "string" + }, + "fontSize": { + "description": "字体大小", + "type": "number" + }, + "italic": { + "description": "是否斜体", + "type": "boolean" + }, + "strikethrough": { + "description": "是否删除线", + "type": "boolean" + }, + "text": { + "description": "副标题文本", + "type": "string" + }, + "textAlign": { + "description": "副标题对齐方式", + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "underline": { + "description": "是否下划线", + "type": "boolean" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "title": { + "description": "图表标题配置", + "properties": { + "bold": { + "description": "是否加粗", + "type": "boolean" + }, + "color": { + "description": "字体颜色,格式为 #RRGGBB", + "type": "string" + }, + "fontSize": { + "description": "字体大小", + "type": "number" + }, + "italic": { + "description": "是否斜体", + "type": "boolean" + }, + "strikethrough": { + "description": "是否删除线", + "type": "boolean" + }, + "text": { + "description": "标题文本", + "type": "string" + }, + "textAlign": { + "description": "标题对齐方式", + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "underline": { + "description": "是否下划线", + "type": "boolean" + } + }, + "required": [ + "text" + ], + "type": "object" } - } + }, + "type": "object" } }, - "required": [ - "position", - "size" - ], - "additionalProperties": {} + "type": "object" } }, "+cond-format-create": { "properties": { + "additionalProperties": false, "description": "创建/更新的条件格式属性。", - "type": "object", "properties": { - "rule_type": { - "type": "string", - "enum": [ - "duplicateValues", - "uniqueValues", - "cellIs", - "containsText", - "timePeriod", - "containsBlanks", - "notContainsBlanks", - "dataBar", - "colorScale", - "rank", - "aboveAverage", - "expression", - "iconSet" - ], - "description": "条件格式规则类型。" - }, - "ranges": { - "type": "array", - "description": "应用条件格式的 A1 范围列表。每个元素支持:矩形范围 \"A1:A10\"、整行 \"3:6\"、整列 \"C:C\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。例如 [\"A1:A10\",\"C:C\"]。", - "items": { - "type": "string" - } - }, - "style": { - "type": "object", - "description": "命中规则时应用的单元格样式。", - "properties": { - "back_color": { - "type": "string", - "description": "背景色,CSS 十六进制表示,例如 \"#FF0000\"。" - }, - "fore_color": { - "type": "string", - "description": "前景色/字体颜色。" - }, - "text_decoration": { - "type": "string", - "enum": [ - "none", - "underline", - "strikethrough", - "underline_strikethrough" - ], - "description": "文字装饰:none=无,underline=下划线,strikethrough=删除线,underline_strikethrough=下划线+删除线。" - }, - "font": { - "type": "string", - "enum": [ - "bold", - "italic", - "bold italic" - ], - "description": "字体样式:\"bold\"=加粗,\"italic\"=斜体,\"bold italic\"=加粗+斜体。" - } - }, - "additionalProperties": false - }, "attrs": { - "type": "array", "description": "规则参数列表。不同 rule_type 下取值结构不同。当 rule_type 为 dataBar 时,attrs 必须包含两个对象,分别定义正值和负值的数据条颜色。", "items": { "oneOf": [ { - "type": "object", + "additionalProperties": false, "description": "数值比较类规则参数。", "properties": { "compare_type": { - "type": "string", + "description": "比较运算符。", "enum": [ "equal", "notEqual", @@ -2882,29 +2824,25 @@ "between", "notBetween" ], - "description": "比较运算符。" + "type": "string" }, "value": { - "type": "string", - "description": "用于比较的值,例如 \"100\"、\"hello\"。between/notBetween 时用逗号分隔两个值。" + "description": "用于比较的值,例如 \"100\"、\"hello\"。between/notBetween 时用逗号分隔两个值。", + "type": "string" } }, "required": [ "compare_type", "value" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "文本包含类规则参数。", "properties": { - "text": { - "type": "string", - "description": "用于匹配的文本内容。" - }, "compare_type": { - "type": "string", + "description": "文本匹配方式。", "enum": [ "beginsWith", "endsWith", @@ -2912,30 +2850,34 @@ "notContains", "is" ], - "description": "文本匹配方式。" + "type": "string" + }, + "text": { + "description": "用于匹配的文本内容。", + "type": "string" } }, "required": [ "compare_type", "text" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "时间段类规则参数。", "properties": { "operator": { - "type": "string", + "description": "与指定时间段的比较关系。", "enum": [ "before", "is", "after" ], - "description": "与指定时间段的比较关系。" + "type": "string" }, "time_period": { - "type": "string", + "description": "时间段类型。", "enum": [ "today", "yesterday", @@ -2948,37 +2890,37 @@ "lastWeek", "nextWeek" ], - "description": "时间段类型。" + "type": "string" } }, "required": [ "operator", "time_period" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "数据条规则参数。", "properties": { - "gradient": { - "type": "boolean", - "description": "是否使用渐变色数据条。" - }, - "value": { - "type": "number", - "description": "阈值或比例值,含义由 value_type 决定。" - }, "color": { - "type": "string", - "description": "主颜色,例如 \"#63BE7B\"。" + "description": "主颜色,例如 \"#63BE7B\"。", + "type": "string" + }, + "gradient": { + "description": "是否使用渐变色数据条。", + "type": "boolean" }, "hide_value": { - "type": "boolean", - "description": "是否隐藏单元格中的原始值,仅显示数据条。" + "description": "是否隐藏单元格中的原始值,仅显示数据条。", + "type": "boolean" + }, + "value": { + "description": "阈值或比例值,含义由 value_type 决定。", + "type": "number" }, "value_type": { - "type": "string", + "description": "阈值类型。", "enum": [ "minValue", "maxValue", @@ -2988,21 +2930,29 @@ "formula", "auto" ], - "description": "阈值类型。" + "type": "string" } }, "required": [ "color", "value_type" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "色阶规则中的单个分段。ColorScaleAttrs 由 2 或 3 个该对象组成,分别表示最小值/中间值/最大值。", "properties": { + "color": { + "description": "该分段对应的颜色。", + "type": "string" + }, + "value": { + "description": "阈值数值,例如百分位或具体数值。", + "type": "number" + }, "value_type": { - "type": "string", + "description": "阈值类型。", "enum": [ "minValue", "maxValue", @@ -3012,93 +2962,89 @@ "formula", "auto" ], - "description": "阈值类型。" - }, - "value": { - "type": "number", - "description": "阈值数值,例如百分位或具体数值。" - }, - "color": { - "type": "string", - "description": "该分段对应的颜色。" + "type": "string" } }, "required": [ "value_type", "color" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "前 N/后 N 规则参数。", "properties": { "is_bottom": { - "type": "boolean", - "description": "是否选取最后 N 个。true 表示最后 N 个,false 表示前 N 个。" + "description": "是否选取最后 N 个。true 表示最后 N 个,false 表示前 N 个。", + "type": "boolean" + }, + "value": { + "description": "N 或百分比数值。", + "type": "number" }, "value_type": { - "type": "string", + "description": "排名方式:percent 表示百分比,sort 表示按条目数。", "enum": [ "percent", "sort" ], - "description": "排名方式:percent 表示百分比,sort 表示按条目数。" - }, - "value": { - "type": "number", - "description": "N 或百分比数值。" + "type": "string" } }, "required": [ "is_bottom", "value_type" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "平均值规则参数。", "properties": { "operator": { - "type": "string", + "description": "与平均值的比较关系。", "enum": [ "greaterThan", "greaterThanOrEqual", "lessThan", "lessThanOrEqual" ], - "description": "与平均值的比较关系。" + "type": "string" } }, "required": [ "operator" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "自定义公式规则参数。", "properties": { "formula": { - "type": "array", + "description": "条件公式列表,例如 [\"=A1>0\"]。", "items": { "type": "string" }, - "description": "条件公式列表,例如 [\"=A1>0\"]。" + "type": "array" } }, "required": [ "formula" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "图标集规则参数。", "properties": { + "hide_value": { + "description": "是否隐藏单元格原始值,仅显示图标。", + "type": "boolean" + }, "icon_type": { - "type": "string", + "description": "图标集类型。其中 3Circles/4Circles/5Circles 为不同颜色的实心圆(如红、黄、绿),用于表示状态等级;5CirclesRatio 为不同填充比例的圆(从空心◯到半圆◑到满圆●),用于表示完成度、进度、比例。当涉及半圆、满圆、空心圆、填充比例、进度等场景时,应选择 5CirclesRatio。", "enum": [ "3Arrows", "3ArrowsGray", @@ -3120,42 +3066,38 @@ "3Mood", "5CirclesRatio" ], - "description": "图标集类型。其中 3Circles/4Circles/5Circles 为不同颜色的实心圆(如红、黄、绿),用于表示状态等级;5CirclesRatio 为不同填充比例的圆(从空心◯到半圆◑到满圆●),用于表示完成度、进度、比例。当涉及半圆、满圆、空心圆、填充比例、进度等场景时,应选择 5CirclesRatio。" - }, - "hide_value": { - "type": "boolean", - "description": "是否隐藏单元格原始值,仅显示图标。" - }, - "value_type": { - "type": "string", - "enum": [ - "minValue", - "maxValue", - "num", - "percentile", - "percent", - "formula", - "auto" - ], - "description": "阈值类型。" + "type": "string" }, "operator": { - "type": "string", + "description": "与阈值的比较关系。", "enum": [ "greaterThan", "greaterThanOrEqual", "lessThan", "lessThanOrEqual" ], - "description": "与阈值的比较关系。" - }, - "value": { - "type": "number", - "description": "用于比较的数值,含义由 value_type 决定。" - }, - "reverse_icons": { - "type": "boolean", - "description": "是否反转图标顺序。" + "type": "string" + }, + "reverse_icons": { + "description": "是否反转图标顺序。", + "type": "boolean" + }, + "value": { + "description": "用于比较的数值,含义由 value_type 决定。", + "type": "number" + }, + "value_type": { + "description": "阈值类型。", + "enum": [ + "minValue", + "maxValue", + "num", + "percentile", + "percent", + "formula", + "auto" + ], + "type": "string" } }, "required": [ @@ -3163,31 +3105,25 @@ "value_type", "operator" ], - "additionalProperties": false + "type": "object" } ] - } + }, + "type": "array" }, "has_ref": { - "type": "boolean", - "description": "可选。是否存在引用,用于标记规则是否依赖其他对象。" - } - }, - "required": [ - "rule_type", - "ranges", - "style" - ], - "additionalProperties": false - } - }, - "+cond-format-update": { - "properties": { - "description": "创建/更新的条件格式属性。", - "type": "object", - "properties": { + "description": "可选。是否存在引用,用于标记规则是否依赖其他对象。", + "type": "boolean" + }, + "ranges": { + "description": "应用条件格式的 A1 范围列表。每个元素支持:矩形范围 \"A1:A10\"、整行 \"3:6\"、整列 \"C:C\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。例如 [\"A1:A10\",\"C:C\"]。", + "items": { + "type": "string" + }, + "type": "array" + }, "rule_type": { - "type": "string", + "description": "条件格式规则类型。", "enum": [ "duplicateValues", "uniqueValues", @@ -3203,60 +3139,66 @@ "expression", "iconSet" ], - "description": "条件格式规则类型。" - }, - "ranges": { - "type": "array", - "description": "应用条件格式的 A1 范围列表。每个元素支持:矩形范围 \"A1:A10\"、整行 \"3:6\"、整列 \"C:C\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。例如 [\"A1:A10\",\"C:C\"]。", - "items": { - "type": "string" - } + "type": "string" }, "style": { - "type": "object", + "additionalProperties": false, "description": "命中规则时应用的单元格样式。", "properties": { "back_color": { - "type": "string", - "description": "背景色,CSS 十六进制表示,例如 \"#FF0000\"。" + "description": "背景色,CSS 十六进制表示,例如 \"#FF0000\"。", + "type": "string" + }, + "font": { + "description": "字体样式:\"bold\"=加粗,\"italic\"=斜体,\"bold italic\"=加粗+斜体。", + "enum": [ + "bold", + "italic", + "bold italic" + ], + "type": "string" }, "fore_color": { - "type": "string", - "description": "前景色/字体颜色。" + "description": "前景色/字体颜色。", + "type": "string" }, "text_decoration": { - "type": "string", + "description": "文字装饰:none=无,underline=下划线,strikethrough=删除线,underline_strikethrough=下划线+删除线。", "enum": [ "none", "underline", "strikethrough", "underline_strikethrough" ], - "description": "文字装饰:none=无,underline=下划线,strikethrough=删除线,underline_strikethrough=下划线+删除线。" - }, - "font": { - "type": "string", - "enum": [ - "bold", - "italic", - "bold italic" - ], - "description": "字体样式:\"bold\"=加粗,\"italic\"=斜体,\"bold italic\"=加粗+斜体。" + "type": "string" } }, - "additionalProperties": false - }, + "type": "object" + } + }, + "required": [ + "rule_type", + "ranges", + "style" + ], + "type": "object" + } + }, + "+cond-format-update": { + "properties": { + "additionalProperties": false, + "description": "创建/更新的条件格式属性。", + "properties": { "attrs": { - "type": "array", "description": "规则参数列表。不同 rule_type 下取值结构不同。当 rule_type 为 dataBar 时,attrs 必须包含两个对象,分别定义正值和负值的数据条颜色。", "items": { "oneOf": [ { - "type": "object", + "additionalProperties": false, "description": "数值比较类规则参数。", "properties": { "compare_type": { - "type": "string", + "description": "比较运算符。", "enum": [ "equal", "notEqual", @@ -3267,29 +3209,25 @@ "between", "notBetween" ], - "description": "比较运算符。" + "type": "string" }, "value": { - "type": "string", - "description": "用于比较的值,例如 \"100\"、\"hello\"。between/notBetween 时用逗号分隔两个值。" + "description": "用于比较的值,例如 \"100\"、\"hello\"。between/notBetween 时用逗号分隔两个值。", + "type": "string" } }, "required": [ "compare_type", "value" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "文本包含类规则参数。", "properties": { - "text": { - "type": "string", - "description": "用于匹配的文本内容。" - }, "compare_type": { - "type": "string", + "description": "文本匹配方式。", "enum": [ "beginsWith", "endsWith", @@ -3297,30 +3235,34 @@ "notContains", "is" ], - "description": "文本匹配方式。" + "type": "string" + }, + "text": { + "description": "用于匹配的文本内容。", + "type": "string" } }, "required": [ "compare_type", "text" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "时间段类规则参数。", "properties": { "operator": { - "type": "string", + "description": "与指定时间段的比较关系。", "enum": [ "before", "is", "after" ], - "description": "与指定时间段的比较关系。" + "type": "string" }, "time_period": { - "type": "string", + "description": "时间段类型。", "enum": [ "today", "yesterday", @@ -3333,37 +3275,37 @@ "lastWeek", "nextWeek" ], - "description": "时间段类型。" + "type": "string" } }, "required": [ "operator", "time_period" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "数据条规则参数。", "properties": { - "gradient": { - "type": "boolean", - "description": "是否使用渐变色数据条。" - }, - "value": { - "type": "number", - "description": "阈值或比例值,含义由 value_type 决定。" - }, "color": { - "type": "string", - "description": "主颜色,例如 \"#63BE7B\"。" + "description": "主颜色,例如 \"#63BE7B\"。", + "type": "string" + }, + "gradient": { + "description": "是否使用渐变色数据条。", + "type": "boolean" }, "hide_value": { - "type": "boolean", - "description": "是否隐藏单元格中的原始值,仅显示数据条。" + "description": "是否隐藏单元格中的原始值,仅显示数据条。", + "type": "boolean" + }, + "value": { + "description": "阈值或比例值,含义由 value_type 决定。", + "type": "number" }, "value_type": { - "type": "string", + "description": "阈值类型。", "enum": [ "minValue", "maxValue", @@ -3373,21 +3315,29 @@ "formula", "auto" ], - "description": "阈值类型。" + "type": "string" } }, "required": [ "color", "value_type" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "色阶规则中的单个分段。ColorScaleAttrs 由 2 或 3 个该对象组成,分别表示最小值/中间值/最大值。", "properties": { + "color": { + "description": "该分段对应的颜色。", + "type": "string" + }, + "value": { + "description": "阈值数值,例如百分位或具体数值。", + "type": "number" + }, "value_type": { - "type": "string", + "description": "阈值类型。", "enum": [ "minValue", "maxValue", @@ -3397,93 +3347,89 @@ "formula", "auto" ], - "description": "阈值类型。" - }, - "value": { - "type": "number", - "description": "阈值数值,例如百分位或具体数值。" - }, - "color": { - "type": "string", - "description": "该分段对应的颜色。" + "type": "string" } }, "required": [ "value_type", "color" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "前 N/后 N 规则参数。", "properties": { "is_bottom": { - "type": "boolean", - "description": "是否选取最后 N 个。true 表示最后 N 个,false 表示前 N 个。" + "description": "是否选取最后 N 个。true 表示最后 N 个,false 表示前 N 个。", + "type": "boolean" + }, + "value": { + "description": "N 或百分比数值。", + "type": "number" }, "value_type": { - "type": "string", + "description": "排名方式:percent 表示百分比,sort 表示按条目数。", "enum": [ "percent", "sort" ], - "description": "排名方式:percent 表示百分比,sort 表示按条目数。" - }, - "value": { - "type": "number", - "description": "N 或百分比数值。" + "type": "string" } }, "required": [ "is_bottom", "value_type" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "平均值规则参数。", "properties": { "operator": { - "type": "string", + "description": "与平均值的比较关系。", "enum": [ "greaterThan", "greaterThanOrEqual", "lessThan", "lessThanOrEqual" ], - "description": "与平均值的比较关系。" + "type": "string" } }, "required": [ "operator" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "自定义公式规则参数。", "properties": { "formula": { - "type": "array", + "description": "条件公式列表,例如 [\"=A1>0\"]。", "items": { "type": "string" }, - "description": "条件公式列表,例如 [\"=A1>0\"]。" + "type": "array" } }, "required": [ "formula" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "图标集规则参数。", "properties": { + "hide_value": { + "description": "是否隐藏单元格原始值,仅显示图标。", + "type": "boolean" + }, "icon_type": { - "type": "string", + "description": "图标集类型。其中 3Circles/4Circles/5Circles 为不同颜色的实心圆(如红、黄、绿),用于表示状态等级;5CirclesRatio 为不同填充比例的圆(从空心◯到半圆◑到满圆●),用于表示完成度、进度、比例。当涉及半圆、满圆、空心圆、填充比例、进度等场景时,应选择 5CirclesRatio。", "enum": [ "3Arrows", "3ArrowsGray", @@ -3505,14 +3451,28 @@ "3Mood", "5CirclesRatio" ], - "description": "图标集类型。其中 3Circles/4Circles/5Circles 为不同颜色的实心圆(如红、黄、绿),用于表示状态等级;5CirclesRatio 为不同填充比例的圆(从空心◯到半圆◑到满圆●),用于表示完成度、进度、比例。当涉及半圆、满圆、空心圆、填充比例、进度等场景时,应选择 5CirclesRatio。" + "type": "string" }, - "hide_value": { - "type": "boolean", - "description": "是否隐藏单元格原始值,仅显示图标。" + "operator": { + "description": "与阈值的比较关系。", + "enum": [ + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual" + ], + "type": "string" + }, + "reverse_icons": { + "description": "是否反转图标顺序。", + "type": "boolean" + }, + "value": { + "description": "用于比较的数值,含义由 value_type 决定。", + "type": "number" }, "value_type": { - "type": "string", + "description": "阈值类型。", "enum": [ "minValue", "maxValue", @@ -3522,25 +3482,7 @@ "formula", "auto" ], - "description": "阈值类型。" - }, - "operator": { - "type": "string", - "enum": [ - "greaterThan", - "greaterThanOrEqual", - "lessThan", - "lessThanOrEqual" - ], - "description": "与阈值的比较关系。" - }, - "value": { - "type": "number", - "description": "用于比较的数值,含义由 value_type 决定。" - }, - "reverse_icons": { - "type": "boolean", - "description": "是否反转图标顺序。" + "type": "string" } }, "required": [ @@ -3548,14 +3490,75 @@ "value_type", "operator" ], - "additionalProperties": false + "type": "object" } ] - } + }, + "type": "array" }, "has_ref": { - "type": "boolean", - "description": "可选。是否存在引用,用于标记规则是否依赖其他对象。" + "description": "可选。是否存在引用,用于标记规则是否依赖其他对象。", + "type": "boolean" + }, + "ranges": { + "description": "应用条件格式的 A1 范围列表。每个元素支持:矩形范围 \"A1:A10\"、整行 \"3:6\"、整列 \"C:C\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。例如 [\"A1:A10\",\"C:C\"]。", + "items": { + "type": "string" + }, + "type": "array" + }, + "rule_type": { + "description": "条件格式规则类型。", + "enum": [ + "duplicateValues", + "uniqueValues", + "cellIs", + "containsText", + "timePeriod", + "containsBlanks", + "notContainsBlanks", + "dataBar", + "colorScale", + "rank", + "aboveAverage", + "expression", + "iconSet" + ], + "type": "string" + }, + "style": { + "additionalProperties": false, + "description": "命中规则时应用的单元格样式。", + "properties": { + "back_color": { + "description": "背景色,CSS 十六进制表示,例如 \"#FF0000\"。", + "type": "string" + }, + "font": { + "description": "字体样式:\"bold\"=加粗,\"italic\"=斜体,\"bold italic\"=加粗+斜体。", + "enum": [ + "bold", + "italic", + "bold italic" + ], + "type": "string" + }, + "fore_color": { + "description": "前景色/字体颜色。", + "type": "string" + }, + "text_decoration": { + "description": "文字装饰:none=无,underline=下划线,strikethrough=删除线,underline_strikethrough=下划线+删除线。", + "enum": [ + "none", + "underline", + "strikethrough", + "underline_strikethrough" + ], + "type": "string" + } + }, + "type": "object" } }, "required": [ @@ -3563,65 +3566,63 @@ "ranges", "style" ], - "additionalProperties": false + "type": "object" } }, "+dropdown-set": { "options": { - "description": "列表选项", - "type": "array", + "description": "列表选项(type='list' 时必填)", "items": { "type": "string" - } + }, + "type": "array" } }, "+dropdown-update": { "options": { - "description": "列表选项", - "type": "array", + "description": "列表选项(type='list' 时必填)", "items": { "type": "string" - } + }, + "type": "array" } }, "+filter-create": { "properties": { + "additionalProperties": false, "description": "创建/更新的筛选器属性。", - "type": "object", "properties": { + "filtered_columns": { + "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", + "items": { + "type": "string" + }, + "type": "array" + }, "range": { - "type": "string", - "description": "筛选对象作用的单元格范围(A1 表示法)。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。" + "description": "筛选对象作用的单元格范围(A1 表示法)。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。", + "type": "string" }, "rules": { - "type": "array", "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。update 时传空数组 [] 表示清空所有现有列的规则。", "items": { - "type": "object", + "additionalProperties": false, "description": "单列筛选规则。", "properties": { "column_index": { - "type": "string", - "description": "作用的列索引,例如 \"A\"、\"B\"。" + "description": "作用的列索引,例如 \"A\"、\"B\"。", + "type": "string" }, "conditions": { - "type": "array", "description": "该列上的条件列表。**目前每列仅支持 1 个 condition**(传入多个会被拒绝,请用 multiValue 或单条件表达),数组形式为未来扩展多条件组合预留。", "items": { "oneOf": [ { - "type": "object", + "additionalProperties": false, "description": "文本条件筛选。", "properties": { - "type": { - "type": "string", - "enum": [ - "text" - ], - "description": "条件类型固定为 \"text\"。" - }, "compare_type": { - "type": "string", + "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。", "enum": [ "beginsWith", "doesNotBeginWith", @@ -3632,35 +3633,35 @@ "equals", "notEquals" ], - "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。" + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"text\"。", + "enum": [ + "text" + ], + "type": "string" }, "values": { - "type": "array", "description": "可选。用于比较的文本值列表,只需要提供一个值,例如 [\"华东\"]。", "items": { "type": "string" - } + }, + "type": "array" } }, "required": [ "type", "compare_type" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "数值条件筛选。", "properties": { - "type": { - "type": "string", - "enum": [ - "number" - ], - "description": "条件类型固定为 \"number\"。" - }, "compare_type": { - "type": "string", + "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。", "enum": [ "equal", "notEqual", @@ -3671,10 +3672,16 @@ "between", "notBetween" ], - "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。" + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"number\"。", + "enum": [ + "number" + ], + "type": "string" }, "values": { - "type": "array", "description": "可选。用于比较的数值参数列表。大多数比较运算需要 1 个值(如 [5000]),between/notBetween 需要 2 个值(如 [1000, 5000])。", "items": { "oneOf": [ @@ -3685,82 +3692,73 @@ "type": "string" } ] - } + }, + "type": "array" } }, "required": [ "type", "compare_type" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "颜色条件筛选。", "properties": { - "type": { - "type": "string", - "enum": [ - "color" - ], - "description": "条件类型固定为 \"color\"。" - }, "compare_type": { - "type": "string", + "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。", "enum": [ "backgroundColor", "foregroundColor" ], - "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。" + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"color\"。", + "enum": [ + "color" + ], + "type": "string" }, "value": { - "type": "string", - "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。" + "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。", + "type": "string" } }, "required": [ "type", "compare_type" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "多值条件筛选。", "properties": { - "type": { - "type": "string", - "enum": [ - "multiValue" - ], - "description": "条件类型固定为 \"multiValue\"。" - }, "compare_type": { - "type": "string", + "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。", "enum": [ "equal", "notEqual" ], - "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。" - }, - "values": { - "type": "array", - "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", - "items": { - "type": "string" - } + "type": "string" }, "date_groups": { - "type": "array", "description": "可选。年月日等聚合筛选信息。", "items": { - "type": "object", + "additionalProperties": false, "properties": { - "year": { - "type": "number" - }, - "month": { - "type": "number" + "date_time_grouping": { + "enum": [ + "year", + "month", + "day", + "hour", + "minute", + "second" + ], + "type": "string" }, "day": { "type": "number" @@ -3771,102 +3769,105 @@ "minute": { "type": "number" }, + "month": { + "type": "number" + }, "second": { "type": "number" }, - "date_time_grouping": { - "type": "string", - "enum": [ - "year", - "month", - "day", - "hour", - "minute", - "second" - ] + "year": { + "type": "number" } }, - "additionalProperties": false - } + "type": "object" + }, + "type": "array" + }, + "type": { + "description": "条件类型固定为 \"multiValue\"。", + "enum": [ + "multiValue" + ], + "type": "string" + }, + "values": { + "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", + "items": { + "type": "string" + }, + "type": "array" } }, "required": [ "type", "compare_type" ], - "additionalProperties": false + "type": "object" } ] - } + }, + "type": "array" }, "filtered_rows": { - "type": "array", "description": "可选。由该列条件直接导致被隐藏的行索引列表(0-based),用于调试或回显。", "items": { "type": "number" - } + }, + "type": "array" } }, "required": [ "column_index", "conditions" ], - "additionalProperties": false - } - }, - "filtered_columns": { - "type": "array", - "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", - "items": { - "type": "string" - } + "type": "object" + }, + "type": "array" } }, "required": [ "range", "rules" ], - "additionalProperties": false + "type": "object" } }, "+filter-update": { "properties": { + "additionalProperties": false, "description": "创建/更新的筛选器属性。", - "type": "object", "properties": { + "filtered_columns": { + "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", + "items": { + "type": "string" + }, + "type": "array" + }, "range": { - "type": "string", - "description": "筛选对象作用的单元格范围(A1 表示法)。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。" + "description": "筛选对象作用的单元格范围(A1 表示法)。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。", + "type": "string" }, "rules": { - "type": "array", "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。update 时传空数组 [] 表示清空所有现有列的规则。", "items": { - "type": "object", + "additionalProperties": false, "description": "单列筛选规则。", "properties": { "column_index": { - "type": "string", - "description": "作用的列索引,例如 \"A\"、\"B\"。" + "description": "作用的列索引,例如 \"A\"、\"B\"。", + "type": "string" }, "conditions": { - "type": "array", "description": "该列上的条件列表。**目前每列仅支持 1 个 condition**(传入多个会被拒绝,请用 multiValue 或单条件表达),数组形式为未来扩展多条件组合预留。", "items": { "oneOf": [ { - "type": "object", + "additionalProperties": false, "description": "文本条件筛选。", "properties": { - "type": { - "type": "string", - "enum": [ - "text" - ], - "description": "条件类型固定为 \"text\"。" - }, "compare_type": { - "type": "string", + "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。", "enum": [ "beginsWith", "doesNotBeginWith", @@ -3877,35 +3878,35 @@ "equals", "notEquals" ], - "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。" + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"text\"。", + "enum": [ + "text" + ], + "type": "string" }, "values": { - "type": "array", "description": "可选。用于比较的文本值列表,只需要提供一个值,例如 [\"华东\"]。", "items": { "type": "string" - } + }, + "type": "array" } }, "required": [ "type", "compare_type" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "数值条件筛选。", "properties": { - "type": { - "type": "string", - "enum": [ - "number" - ], - "description": "条件类型固定为 \"number\"。" - }, "compare_type": { - "type": "string", + "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。", "enum": [ "equal", "notEqual", @@ -3916,10 +3917,16 @@ "between", "notBetween" ], - "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。" + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"number\"。", + "enum": [ + "number" + ], + "type": "string" }, "values": { - "type": "array", "description": "可选。用于比较的数值参数列表。大多数比较运算需要 1 个值(如 [5000]),between/notBetween 需要 2 个值(如 [1000, 5000])。", "items": { "oneOf": [ @@ -3930,82 +3937,73 @@ "type": "string" } ] - } + }, + "type": "array" } }, "required": [ "type", "compare_type" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "颜色条件筛选。", "properties": { - "type": { - "type": "string", - "enum": [ - "color" - ], - "description": "条件类型固定为 \"color\"。" - }, "compare_type": { - "type": "string", + "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。", "enum": [ "backgroundColor", "foregroundColor" ], - "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。" + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"color\"。", + "enum": [ + "color" + ], + "type": "string" }, "value": { - "type": "string", - "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。" + "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。", + "type": "string" } }, "required": [ "type", "compare_type" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "多值条件筛选。", "properties": { - "type": { - "type": "string", - "enum": [ - "multiValue" - ], - "description": "条件类型固定为 \"multiValue\"。" - }, "compare_type": { - "type": "string", + "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。", "enum": [ "equal", "notEqual" ], - "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。" - }, - "values": { - "type": "array", - "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", - "items": { - "type": "string" - } + "type": "string" }, "date_groups": { - "type": "array", "description": "可选。年月日等聚合筛选信息。", "items": { - "type": "object", + "additionalProperties": false, "properties": { - "year": { - "type": "number" - }, - "month": { - "type": "number" + "date_time_grouping": { + "enum": [ + "year", + "month", + "day", + "hour", + "minute", + "second" + ], + "type": "string" }, "day": { "type": "number" @@ -4016,106 +4014,105 @@ "minute": { "type": "number" }, + "month": { + "type": "number" + }, "second": { "type": "number" }, - "date_time_grouping": { - "type": "string", - "enum": [ - "year", - "month", - "day", - "hour", - "minute", - "second" - ] + "year": { + "type": "number" } }, - "additionalProperties": false - } + "type": "object" + }, + "type": "array" + }, + "type": { + "description": "条件类型固定为 \"multiValue\"。", + "enum": [ + "multiValue" + ], + "type": "string" + }, + "values": { + "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", + "items": { + "type": "string" + }, + "type": "array" } }, "required": [ "type", "compare_type" ], - "additionalProperties": false + "type": "object" } ] - } + }, + "type": "array" }, "filtered_rows": { - "type": "array", "description": "可选。由该列条件直接导致被隐藏的行索引列表(0-based),用于调试或回显。", "items": { "type": "number" - } + }, + "type": "array" } }, "required": [ "column_index", "conditions" ], - "additionalProperties": false - } - }, - "filtered_columns": { - "type": "array", - "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", - "items": { - "type": "string" - } + "type": "object" + }, + "type": "array" } }, "required": [ "range", "rules" ], - "additionalProperties": false + "type": "object" } }, "+filter-view-create": { "properties": { + "additionalProperties": false, "description": "create / update 的视图属性。create 必须传 range;update 至少传 view_name / range / rules 之一。delete 禁止传该字段。", - "type": "object", "properties": { - "view_name": { - "type": "string", - "description": "可选。视图名称。create 不传时系统自动分配;create / update 传入时若与现有视图重名,会自动以后缀避让,不会抛错。" + "filtered_columns": { + "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", + "items": { + "type": "string" + }, + "type": "array" }, "range": { - "type": "string", - "description": "视图作用的单元格范围(A1 表示法)。create 必填;update 可选——未提供时沿用视图当前 range。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。" + "description": "视图作用的单元格范围(A1 表示法)。create 必填;update 可选——未提供时沿用视图当前 range。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。", + "type": "string" }, "rules": { - "type": "array", "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。结构与 manage_filter_object.properties.rules 完全一致。", "items": { - "type": "object", + "additionalProperties": false, "description": "单列筛选规则。", "properties": { "column_index": { - "type": "string", - "description": "作用的列索引,例如 \"A\"、\"B\"。" + "description": "作用的列索引,例如 \"A\"、\"B\"。", + "type": "string" }, "conditions": { - "type": "array", "description": "该列上的条件列表。**目前每列仅支持 1 个 condition**(传入多个会被拒绝,请用 multiValue 或单条件表达),数组形式为未来扩展多条件组合预留。", "items": { "oneOf": [ { - "type": "object", + "additionalProperties": false, "description": "文本条件筛选。", "properties": { - "type": { - "type": "string", - "enum": [ - "text" - ], - "description": "条件类型固定为 \"text\"。" - }, "compare_type": { - "type": "string", + "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。", "enum": [ "beginsWith", "doesNotBeginWith", @@ -4126,35 +4123,35 @@ "equals", "notEquals" ], - "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。" + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"text\"。", + "enum": [ + "text" + ], + "type": "string" }, "values": { - "type": "array", "description": "可选。用于比较的文本值列表,只需要提供一个值,例如 [\"华东\"]。", "items": { "type": "string" - } + }, + "type": "array" } }, "required": [ "type", "compare_type" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "数值条件筛选。", "properties": { - "type": { - "type": "string", - "enum": [ - "number" - ], - "description": "条件类型固定为 \"number\"。" - }, "compare_type": { - "type": "string", + "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。", "enum": [ "equal", "notEqual", @@ -4165,10 +4162,16 @@ "between", "notBetween" ], - "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。" + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"number\"。", + "enum": [ + "number" + ], + "type": "string" }, "values": { - "type": "array", "description": "可选。用于比较的数值参数列表。大多数比较运算需要 1 个值(如 [5000]),between/notBetween 需要 2 个值(如 [1000, 5000])。", "items": { "oneOf": [ @@ -4179,82 +4182,73 @@ "type": "string" } ] - } + }, + "type": "array" } }, "required": [ "type", "compare_type" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "颜色条件筛选。", "properties": { - "type": { - "type": "string", - "enum": [ - "color" - ], - "description": "条件类型固定为 \"color\"。" - }, "compare_type": { - "type": "string", + "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。", "enum": [ "backgroundColor", "foregroundColor" ], - "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。" + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"color\"。", + "enum": [ + "color" + ], + "type": "string" }, "value": { - "type": "string", - "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。" + "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。", + "type": "string" } }, "required": [ "type", "compare_type" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "多值条件筛选。", "properties": { - "type": { - "type": "string", - "enum": [ - "multiValue" - ], - "description": "条件类型固定为 \"multiValue\"。" - }, "compare_type": { - "type": "string", + "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。", "enum": [ "equal", "notEqual" ], - "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。" - }, - "values": { - "type": "array", - "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", - "items": { - "type": "string" - } + "type": "string" }, "date_groups": { - "type": "array", "description": "可选。年月日等聚合筛选信息。", "items": { - "type": "object", + "additionalProperties": false, "properties": { - "year": { - "type": "number" - }, - "month": { - "type": "number" + "date_time_grouping": { + "enum": [ + "year", + "month", + "day", + "hour", + "minute", + "second" + ], + "type": "string" }, "day": { "type": "number" @@ -4265,102 +4259,105 @@ "minute": { "type": "number" }, + "month": { + "type": "number" + }, "second": { "type": "number" }, - "date_time_grouping": { - "type": "string", - "enum": [ - "year", - "month", - "day", - "hour", - "minute", - "second" - ] + "year": { + "type": "number" } }, - "additionalProperties": false - } + "type": "object" + }, + "type": "array" + }, + "type": { + "description": "条件类型固定为 \"multiValue\"。", + "enum": [ + "multiValue" + ], + "type": "string" + }, + "values": { + "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", + "items": { + "type": "string" + }, + "type": "array" } }, "required": [ "type", "compare_type" ], - "additionalProperties": false + "type": "object" } ] - } + }, + "type": "array" }, "filtered_rows": { - "type": "array", "description": "可选。由该列条件直接导致被隐藏的行索引列表(0-based),用于调试或回显。", "items": { "type": "number" - } + }, + "type": "array" } }, "required": [ "column_index", "conditions" ], - "additionalProperties": false - } + "type": "object" + }, + "type": "array" }, - "filtered_columns": { - "type": "array", - "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", - "items": { - "type": "string" - } + "view_name": { + "description": "可选。视图名称。create 不传时系统自动分配;create / update 传入时若与现有视图重名,会自动以后缀避让,不会抛错。", + "type": "string" } }, - "additionalProperties": false + "type": "object" } }, "+filter-view-update": { "properties": { + "additionalProperties": false, "description": "create / update 的视图属性。create 必须传 range;update 至少传 view_name / range / rules 之一。delete 禁止传该字段。", - "type": "object", "properties": { - "view_name": { - "type": "string", - "description": "可选。视图名称。create 不传时系统自动分配;create / update 传入时若与现有视图重名,会自动以后缀避让,不会抛错。" + "filtered_columns": { + "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", + "items": { + "type": "string" + }, + "type": "array" }, "range": { - "type": "string", - "description": "视图作用的单元格范围(A1 表示法)。create 必填;update 可选——未提供时沿用视图当前 range。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。" + "description": "视图作用的单元格范围(A1 表示法)。create 必填;update 可选——未提供时沿用视图当前 range。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。", + "type": "string" }, "rules": { - "type": "array", "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。结构与 manage_filter_object.properties.rules 完全一致。", "items": { - "type": "object", + "additionalProperties": false, "description": "单列筛选规则。", "properties": { "column_index": { - "type": "string", - "description": "作用的列索引,例如 \"A\"、\"B\"。" + "description": "作用的列索引,例如 \"A\"、\"B\"。", + "type": "string" }, "conditions": { - "type": "array", "description": "该列上的条件列表。**目前每列仅支持 1 个 condition**(传入多个会被拒绝,请用 multiValue 或单条件表达),数组形式为未来扩展多条件组合预留。", "items": { "oneOf": [ { - "type": "object", + "additionalProperties": false, "description": "文本条件筛选。", "properties": { - "type": { - "type": "string", - "enum": [ - "text" - ], - "description": "条件类型固定为 \"text\"。" - }, "compare_type": { - "type": "string", + "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。", "enum": [ "beginsWith", "doesNotBeginWith", @@ -4371,35 +4368,35 @@ "equals", "notEquals" ], - "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。" + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"text\"。", + "enum": [ + "text" + ], + "type": "string" }, "values": { - "type": "array", "description": "可选。用于比较的文本值列表,只需要提供一个值,例如 [\"华东\"]。", "items": { "type": "string" - } + }, + "type": "array" } }, "required": [ "type", "compare_type" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "数值条件筛选。", "properties": { - "type": { - "type": "string", - "enum": [ - "number" - ], - "description": "条件类型固定为 \"number\"。" - }, "compare_type": { - "type": "string", + "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。", "enum": [ "equal", "notEqual", @@ -4410,10 +4407,16 @@ "between", "notBetween" ], - "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。" + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"number\"。", + "enum": [ + "number" + ], + "type": "string" }, "values": { - "type": "array", "description": "可选。用于比较的数值参数列表。大多数比较运算需要 1 个值(如 [5000]),between/notBetween 需要 2 个值(如 [1000, 5000])。", "items": { "oneOf": [ @@ -4424,82 +4427,73 @@ "type": "string" } ] - } + }, + "type": "array" } }, "required": [ "type", "compare_type" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "颜色条件筛选。", "properties": { - "type": { - "type": "string", - "enum": [ - "color" - ], - "description": "条件类型固定为 \"color\"。" - }, "compare_type": { - "type": "string", + "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。", "enum": [ "backgroundColor", "foregroundColor" ], - "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。" + "type": "string" + }, + "type": { + "description": "条件类型固定为 \"color\"。", + "enum": [ + "color" + ], + "type": "string" }, "value": { - "type": "string", - "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。" + "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。", + "type": "string" } }, "required": [ "type", "compare_type" ], - "additionalProperties": false + "type": "object" }, { - "type": "object", + "additionalProperties": false, "description": "多值条件筛选。", "properties": { - "type": { - "type": "string", - "enum": [ - "multiValue" - ], - "description": "条件类型固定为 \"multiValue\"。" - }, "compare_type": { - "type": "string", + "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。", "enum": [ "equal", "notEqual" ], - "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。" - }, - "values": { - "type": "array", - "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", - "items": { - "type": "string" - } + "type": "string" }, "date_groups": { - "type": "array", "description": "可选。年月日等聚合筛选信息。", "items": { - "type": "object", + "additionalProperties": false, "properties": { - "year": { - "type": "number" - }, - "month": { - "type": "number" + "date_time_grouping": { + "enum": [ + "year", + "month", + "day", + "hour", + "minute", + "second" + ], + "type": "string" }, "day": { "type": "number" @@ -4510,148 +4504,137 @@ "minute": { "type": "number" }, + "month": { + "type": "number" + }, "second": { "type": "number" }, - "date_time_grouping": { - "type": "string", - "enum": [ - "year", - "month", - "day", - "hour", - "minute", - "second" - ] + "year": { + "type": "number" } }, - "additionalProperties": false - } + "type": "object" + }, + "type": "array" + }, + "type": { + "description": "条件类型固定为 \"multiValue\"。", + "enum": [ + "multiValue" + ], + "type": "string" + }, + "values": { + "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", + "items": { + "type": "string" + }, + "type": "array" } }, "required": [ "type", "compare_type" ], - "additionalProperties": false + "type": "object" } ] - } + }, + "type": "array" }, "filtered_rows": { - "type": "array", "description": "可选。由该列条件直接导致被隐藏的行索引列表(0-based),用于调试或回显。", "items": { "type": "number" - } + }, + "type": "array" } }, "required": [ "column_index", "conditions" ], - "additionalProperties": false - } + "type": "object" + }, + "type": "array" }, - "filtered_columns": { - "type": "array", - "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", - "items": { - "type": "string" - } + "view_name": { + "description": "可选。视图名称。create 不传时系统自动分配;create / update 传入时若与现有视图重名,会自动以后缀避让,不会抛错。", + "type": "string" } }, - "additionalProperties": false + "type": "object" } }, "+pivot-create": { "properties": { + "additionalProperties": {}, "description": "创建/更新的透视表属性。", - "type": "object", "properties": { - "range": { - "type": "string", - "description": "放置透视表的左上角单元格 A1 地址(例如:'F1')(仅 create 时有效)。可省略——省略后透视表放在新建子表的 A1 位置。" - }, - "source": { - "type": "string", - "description": "源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100')。create 时必填;update 时提供则切换数据源。" + "auto_fit_col": { + "description": "是否自动调整列宽以适应内容", + "type": "boolean" }, - "rows": { - "description": "纵向分组字段(行字段)", - "type": "array", + "calculated_fields": { + "description": "计算字段列表", "items": { - "type": "object", "properties": { - "field": { - "type": "string", - "description": "源数据中的字段名(OOXML 字段引用)" - }, - "display_name": { - "type": "string", - "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用;否则保持源字段原名,透视表产物已足够清晰。" - }, - "sort": { - "type": "object", - "description": "排序配置", - "properties": { - "order": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "description": "排序方向" - }, - "by": { - "type": "string", - "enum": [ - "label", - "value" - ], - "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序" - }, - "value_field": { - "type": "string", - "description": "by='value' 时必填,指定按哪个值字段排序" - } - }, - "required": [ - "order" - ] + "formula": { + "description": "公式表达式,引用其他字段名,例如 \"'Sales' + 'Tax'\"", + "type": "string" }, - "filter": { - "type": "object", - "description": "快速筛选:只显示指定的项目", - "properties": { - "items": { - "type": "array", - "items": { - "type": "string" - }, - "description": "要显示的项目列表(其余项目被隐藏)" - } - }, - "required": [ - "items" - ] + "name": { + "description": "计算字段的显示名称", + "type": "string" }, + "summarize_by": { + "default": "sum", + "description": "计算字段汇总方式(默认 'sum',仅支持 sum/custom 两种):'sum' 表示先对 formula 中引用的各源字段分别求和后再代入公式(formula 是逐字段求和后的标量表达式,例如 \"'Sales'+'Tax'\" 等价于 SUM(Sales)+SUM(Tax));'custom' 表示数组直接传给 formula,要求 formula 自带聚合函数(例如 \"SUM('Sales')+SUM('Tax')\" 或 \"AVERAGE('Sales')\")。需要 count/average/max/min 等其他聚合方式时,请改用 values[] 字段对源字段直接聚合,calculated_fields 不适用。", + "enum": [ + "sum", + "custom" + ], + "type": "string" + } + }, + "required": [ + "name", + "formula" + ], + "type": "object" + }, + "type": "array" + }, + "collapse": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "description": "行字段展开/折叠状态:字段名 -> 要折叠的项目列表", + "type": "object" + }, + "columns": { + "description": "横向分组字段(列字段)", + "items": { + "properties": { "condition_filter": { - "type": "object", "description": "条件筛选:按文本/数值/日期条件筛选", "properties": { + "operator": { + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", + "type": "string" + }, "type": { - "type": "string", + "description": "条件类型", "enum": [ "text", "number", "date" ], - "description": "条件类型" - }, - "operator": { - "type": "string", - "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" + "type": "string" }, "value": { "description": "比较值" @@ -4663,23 +4646,38 @@ "required": [ "type", "operator" - ] + ], + "type": "object" + }, + "display_name": { + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。", + "type": "string" + }, + "field": { + "description": "源数据中的字段名(OOXML 字段引用)", + "type": "string" + }, + "filter": { + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "description": "要显示的项目列表(其余项目被隐藏)", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" }, "group": { - "type": "object", "description": "分组配置", "properties": { - "type": { - "type": "string", - "enum": [ - "date", - "number", - "element" - ], - "description": "分组类型" - }, "date_group_by": { - "type": "string", + "description": "日期分组粒度(type='date' 时必填)", "enum": [ "year", "yearMonth", @@ -4693,116 +4691,101 @@ "hourMinute", "minute" ], - "description": "日期分组粒度(type='date' 时必填)" - }, - "interval": { - "type": "number", - "description": "数值分组间隔(type='number' 时必填)" - }, - "start": { - "type": "number", - "description": "数值分组起始值" + "type": "string" }, "end": { - "type": "number", - "description": "数值分组结束值" + "description": "数值分组结束值", + "type": "number" }, "groups": { - "type": "object", - "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", "additionalProperties": { - "type": "array", "items": { "type": "string" - } - } + }, + "type": "array" + }, + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", + "type": "object" + }, + "interval": { + "description": "数值分组间隔(type='number' 时必填)", + "type": "number" + }, + "start": { + "description": "数值分组起始值", + "type": "number" + }, + "type": { + "description": "分组类型", + "enum": [ + "date", + "number", + "element" + ], + "type": "string" } }, "required": [ "type" - ] - } - }, - "required": [ - "field" - ] - } - }, - "columns": { - "description": "横向分组字段(列字段)", - "type": "array", - "items": { - "type": "object", - "properties": { - "field": { - "type": "string", - "description": "源数据中的字段名(OOXML 字段引用)" - }, - "display_name": { - "type": "string", - "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。" + ], + "type": "object" }, "sort": { - "type": "object", "description": "排序配置", "properties": { - "order": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "description": "排序方向" - }, "by": { - "type": "string", + "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序", "enum": [ "label", "value" ], - "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序" + "type": "string" + }, + "order": { + "description": "排序方向", + "enum": [ + "asc", + "desc" + ], + "type": "string" }, "value_field": { - "type": "string", - "description": "by='value' 时必填,指定按哪个值字段排序" + "description": "by='value' 时必填,指定按哪个值字段排序", + "type": "string" } }, "required": [ "order" - ] - }, - "filter": { - "type": "object", - "description": "快速筛选:只显示指定的项目", - "properties": { - "items": { - "type": "array", - "items": { - "type": "string" - }, - "description": "要显示的项目列表(其余项目被隐藏)" - } - }, - "required": [ - "items" - ] - }, + ], + "type": "object" + } + }, + "required": [ + "field" + ], + "type": "object" + }, + "type": "array" + }, + "filters": { + "description": "筛选区域字段(页字段)", + "items": { + "properties": { "condition_filter": { - "type": "object", "description": "条件筛选:按文本/数值/日期条件筛选", "properties": { + "operator": { + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", + "type": "string" + }, "type": { - "type": "string", + "description": "条件类型", "enum": [ "text", "number", "date" ], - "description": "条件类型" - }, - "operator": { - "type": "string", - "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" + "type": "string" }, "value": { "description": "比较值" @@ -4814,23 +4797,38 @@ "required": [ "type", "operator" - ] + ], + "type": "object" + }, + "display_name": { + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。", + "type": "string" + }, + "field": { + "description": "源数据中的字段名(OOXML 字段引用)", + "type": "string" + }, + "filter": { + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "description": "要显示的项目列表(其余项目被隐藏)", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" }, "group": { - "type": "object", "description": "分组配置", "properties": { - "type": { - "type": "string", - "enum": [ - "date", - "number", - "element" - ], - "description": "分组类型" - }, "date_group_by": { - "type": "string", + "description": "日期分组粒度(type='date' 时必填)", "enum": [ "year", "yearMonth", @@ -4844,87 +4842,80 @@ "hourMinute", "minute" ], - "description": "日期分组粒度(type='date' 时必填)" - }, - "interval": { - "type": "number", - "description": "数值分组间隔(type='number' 时必填)" - }, - "start": { - "type": "number", - "description": "数值分组起始值" + "type": "string" }, "end": { - "type": "number", - "description": "数值分组结束值" + "description": "数值分组结束值", + "type": "number" }, "groups": { - "type": "object", - "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", "additionalProperties": { - "type": "array", "items": { "type": "string" - } - } + }, + "type": "array" + }, + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", + "type": "object" + }, + "interval": { + "description": "数值分组间隔(type='number' 时必填)", + "type": "number" + }, + "start": { + "description": "数值分组起始值", + "type": "number" + }, + "type": { + "description": "分组类型", + "enum": [ + "date", + "number", + "element" + ], + "type": "string" } }, "required": [ "type" - ] + ], + "type": "object" } }, "required": [ "field" - ] - } + ], + "type": "object" + }, + "type": "array" }, - "filters": { - "description": "筛选区域字段(页字段)", - "type": "array", + "range": { + "description": "放置透视表的左上角单元格 A1 地址(例如:'F1')(仅 create 时有效)。可省略——省略后透视表放在新建子表的 A1 位置。", + "type": "string" + }, + "repeat_row_labels": { + "description": "是否显示重复项标签", + "type": "boolean" + }, + "rows": { + "description": "纵向分组字段(行字段)", "items": { - "type": "object", "properties": { - "field": { - "type": "string", - "description": "源数据中的字段名(OOXML 字段引用)" - }, - "display_name": { - "type": "string", - "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。" - }, - "filter": { - "type": "object", - "description": "快速筛选:只显示指定的项目", - "properties": { - "items": { - "type": "array", - "items": { - "type": "string" - }, - "description": "要显示的项目列表(其余项目被隐藏)" - } - }, - "required": [ - "items" - ] - }, "condition_filter": { - "type": "object", "description": "条件筛选:按文本/数值/日期条件筛选", "properties": { + "operator": { + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", + "type": "string" + }, "type": { - "type": "string", + "description": "条件类型", "enum": [ "text", "number", "date" ], - "description": "条件类型" - }, - "operator": { - "type": "string", - "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" + "type": "string" }, "value": { "description": "比较值" @@ -4936,23 +4927,38 @@ "required": [ "type", "operator" - ] + ], + "type": "object" + }, + "display_name": { + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用;否则保持源字段原名,透视表产物已足够清晰。", + "type": "string" + }, + "field": { + "description": "源数据中的字段名(OOXML 字段引用)", + "type": "string" + }, + "filter": { + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "description": "要显示的项目列表(其余项目被隐藏)", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" }, "group": { - "type": "object", "description": "分组配置", "properties": { - "type": { - "type": "string", - "enum": [ - "date", - "number", - "element" - ], - "description": "分组类型" - }, "date_group_by": { - "type": "string", + "description": "日期分组粒度(type='date' 时必填)", "enum": [ "year", "yearMonth", @@ -4966,59 +4972,130 @@ "hourMinute", "minute" ], - "description": "日期分组粒度(type='date' 时必填)" - }, - "interval": { - "type": "number", - "description": "数值分组间隔(type='number' 时必填)" - }, - "start": { - "type": "number", - "description": "数值分组起始值" + "type": "string" }, "end": { - "type": "number", - "description": "数值分组结束值" + "description": "数值分组结束值", + "type": "number" }, "groups": { - "type": "object", - "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", "additionalProperties": { - "type": "array", "items": { "type": "string" - } - } + }, + "type": "array" + }, + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", + "type": "object" + }, + "interval": { + "description": "数值分组间隔(type='number' 时必填)", + "type": "number" + }, + "start": { + "description": "数值分组起始值", + "type": "number" + }, + "type": { + "description": "分组类型", + "enum": [ + "date", + "number", + "element" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "sort": { + "description": "排序配置", + "properties": { + "by": { + "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序", + "enum": [ + "label", + "value" + ], + "type": "string" + }, + "order": { + "description": "排序方向", + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + "value_field": { + "description": "by='value' 时必填,指定按哪个值字段排序", + "type": "string" } }, "required": [ - "type" - ] + "order" + ], + "type": "object" } }, "required": [ "field" - ] - } + ], + "type": "object" + }, + "type": "array" + }, + "show_col_grand_total": { + "description": "是否显示列总计(默认 true)", + "type": "boolean" + }, + "show_row_grand_total": { + "description": "是否显示行总计(默认 true)", + "type": "boolean" + }, + "show_subtotals": { + "description": "是否显示分类小计(默认 true,应用于所有字段)", + "type": "boolean" + }, + "source": { + "description": "源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100')。create 时必填;update 时提供则切换数据源。", + "type": "string" }, "values": { - "minItems": 1, - "type": "array", + "description": "要汇总的字段(至少需要 1 个)", "items": { - "type": "object", "properties": { - "field": { - "type": "string", - "description": "要汇总的源数据字段名" + "base_field": { + "description": "show_data_as 需要基准字段时的字段名", + "type": "string" }, "display_name": { - "type": "string", - "description": "字段在透视表中的显示名(典型场景:把 count(销售额) 的值列头显示为 'GMV')。不传则使用源字段原名。仅在用户明确要求重命名时使用。" + "description": "字段在透视表中的显示名(典型场景:把 count(销售额) 的值列头显示为 'GMV')。不传则使用源字段原名。仅在用户明确要求重命名时使用。", + "type": "string" + }, + "field": { + "description": "要汇总的源数据字段名", + "type": "string" + }, + "show_data_as": { + "description": "值显示方式(默认 'normal')", + "enum": [ + "normal", + "percentOfTotal", + "percentOfCol", + "percentOfRow", + "percentOfParentRow", + "percentOfParentCol", + "index" + ], + "type": "string" }, "summarize_by": { "default": "sum", "description": "汇总函数", - "type": "string", "enum": [ "sum", "count", @@ -5033,184 +5110,90 @@ "varp", "distinct", "median" - ] - }, - "show_data_as": { - "type": "string", - "description": "值显示方式(默认 'normal')", - "enum": [ - "normal", - "percentOfTotal", - "percentOfCol", - "percentOfRow", - "percentOfParentRow", - "percentOfParentCol", - "index" - ] - }, - "base_field": { - "type": "string", - "description": "show_data_as 需要基准字段时的字段名" + ], + "type": "string" } }, "required": [ "field" - ] + ], + "type": "object" }, - "description": "要汇总的字段(至少需要 1 个)" - }, + "minItems": 1, + "type": "array" + } + }, + "type": "object" + } + }, + "+pivot-update": { + "properties": { + "additionalProperties": {}, + "description": "创建/更新的透视表属性。", + "properties": { "auto_fit_col": { - "type": "boolean", - "description": "是否自动调整列宽以适应内容" - }, - "show_row_grand_total": { - "type": "boolean", - "description": "是否显示行总计(默认 true)" - }, - "show_col_grand_total": { - "type": "boolean", - "description": "是否显示列总计(默认 true)" - }, - "show_subtotals": { - "type": "boolean", - "description": "是否显示分类小计(默认 true,应用于所有字段)" - }, - "repeat_row_labels": { - "type": "boolean", - "description": "是否显示重复项标签" + "description": "是否自动调整列宽以适应内容", + "type": "boolean" }, "calculated_fields": { - "type": "array", "description": "计算字段列表", "items": { - "type": "object", "properties": { - "name": { - "type": "string", - "description": "计算字段的显示名称" - }, "formula": { - "type": "string", - "description": "公式表达式,引用其他字段名,例如 \"'Sales' + 'Tax'\"" + "description": "公式表达式,引用其他字段名,例如 \"'Sales' + 'Tax'\"", + "type": "string" + }, + "name": { + "description": "计算字段的显示名称", + "type": "string" }, "summarize_by": { - "type": "string", "default": "sum", "description": "计算字段汇总方式(默认 'sum',仅支持 sum/custom 两种):'sum' 表示先对 formula 中引用的各源字段分别求和后再代入公式(formula 是逐字段求和后的标量表达式,例如 \"'Sales'+'Tax'\" 等价于 SUM(Sales)+SUM(Tax));'custom' 表示数组直接传给 formula,要求 formula 自带聚合函数(例如 \"SUM('Sales')+SUM('Tax')\" 或 \"AVERAGE('Sales')\")。需要 count/average/max/min 等其他聚合方式时,请改用 values[] 字段对源字段直接聚合,calculated_fields 不适用。", "enum": [ "sum", "custom" - ] + ], + "type": "string" } }, "required": [ "name", "formula" - ] - } + ], + "type": "object" + }, + "type": "array" }, "collapse": { - "type": "object", - "description": "行字段展开/折叠状态:字段名 -> 要折叠的项目列表", "additionalProperties": { - "type": "array", "items": { "type": "string" - } - } - } - }, - "additionalProperties": {} - } - }, - "+pivot-update": { - "properties": { - "description": "创建/更新的透视表属性。", - "type": "object", - "properties": { - "range": { - "type": "string", - "description": "放置透视表的左上角单元格 A1 地址(例如:'F1')(仅 create 时有效)。可省略——省略后透视表放在新建子表的 A1 位置。" - }, - "source": { - "type": "string", - "description": "源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100')。create 时必填;update 时提供则切换数据源。" + }, + "type": "array" + }, + "description": "行字段展开/折叠状态:字段名 -> 要折叠的项目列表", + "type": "object" }, - "rows": { - "description": "纵向分组字段(行字段)", - "type": "array", + "columns": { + "description": "横向分组字段(列字段)", "items": { - "type": "object", "properties": { - "field": { - "type": "string", - "description": "源数据中的字段名(OOXML 字段引用)" - }, - "display_name": { - "type": "string", - "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用;否则保持源字段原名,透视表产物已足够清晰。" - }, - "sort": { - "type": "object", - "description": "排序配置", - "properties": { - "order": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "description": "排序方向" - }, - "by": { - "type": "string", - "enum": [ - "label", - "value" - ], - "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序" - }, - "value_field": { - "type": "string", - "description": "by='value' 时必填,指定按哪个值字段排序" - } - }, - "required": [ - "order" - ] - }, - "filter": { - "type": "object", - "description": "快速筛选:只显示指定的项目", - "properties": { - "items": { - "type": "array", - "items": { - "type": "string" - }, - "description": "要显示的项目列表(其余项目被隐藏)" - } - }, - "required": [ - "items" - ] - }, "condition_filter": { - "type": "object", "description": "条件筛选:按文本/数值/日期条件筛选", "properties": { + "operator": { + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", + "type": "string" + }, "type": { - "type": "string", + "description": "条件类型", "enum": [ "text", "number", "date" ], - "description": "条件类型" - }, - "operator": { - "type": "string", - "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" + "type": "string" }, "value": { "description": "比较值" @@ -5222,23 +5205,38 @@ "required": [ "type", "operator" - ] + ], + "type": "object" + }, + "display_name": { + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。", + "type": "string" + }, + "field": { + "description": "源数据中的字段名(OOXML 字段引用)", + "type": "string" + }, + "filter": { + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "description": "要显示的项目列表(其余项目被隐藏)", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" }, "group": { - "type": "object", "description": "分组配置", "properties": { - "type": { - "type": "string", - "enum": [ - "date", - "number", - "element" - ], - "description": "分组类型" - }, "date_group_by": { - "type": "string", + "description": "日期分组粒度(type='date' 时必填)", "enum": [ "year", "yearMonth", @@ -5252,116 +5250,101 @@ "hourMinute", "minute" ], - "description": "日期分组粒度(type='date' 时必填)" - }, - "interval": { - "type": "number", - "description": "数值分组间隔(type='number' 时必填)" - }, - "start": { - "type": "number", - "description": "数值分组起始值" + "type": "string" }, "end": { - "type": "number", - "description": "数值分组结束值" + "description": "数值分组结束值", + "type": "number" }, "groups": { - "type": "object", - "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", "additionalProperties": { - "type": "array", "items": { "type": "string" - } - } + }, + "type": "array" + }, + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", + "type": "object" + }, + "interval": { + "description": "数值分组间隔(type='number' 时必填)", + "type": "number" + }, + "start": { + "description": "数值分组起始值", + "type": "number" + }, + "type": { + "description": "分组类型", + "enum": [ + "date", + "number", + "element" + ], + "type": "string" } }, "required": [ "type" - ] - } - }, - "required": [ - "field" - ] - } - }, - "columns": { - "description": "横向分组字段(列字段)", - "type": "array", - "items": { - "type": "object", - "properties": { - "field": { - "type": "string", - "description": "源数据中的字段名(OOXML 字段引用)" - }, - "display_name": { - "type": "string", - "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。" + ], + "type": "object" }, "sort": { - "type": "object", "description": "排序配置", "properties": { - "order": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "description": "排序方向" - }, "by": { - "type": "string", + "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序", "enum": [ "label", "value" ], - "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序" - }, - "value_field": { - "type": "string", - "description": "by='value' 时必填,指定按哪个值字段排序" - } - }, - "required": [ - "order" - ] - }, - "filter": { - "type": "object", - "description": "快速筛选:只显示指定的项目", - "properties": { - "items": { - "type": "array", - "items": { - "type": "string" - }, - "description": "要显示的项目列表(其余项目被隐藏)" + "type": "string" + }, + "order": { + "description": "排序方向", + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + "value_field": { + "description": "by='value' 时必填,指定按哪个值字段排序", + "type": "string" } }, "required": [ - "items" - ] - }, + "order" + ], + "type": "object" + } + }, + "required": [ + "field" + ], + "type": "object" + }, + "type": "array" + }, + "filters": { + "description": "筛选区域字段(页字段)", + "items": { + "properties": { "condition_filter": { - "type": "object", "description": "条件筛选:按文本/数值/日期条件筛选", "properties": { + "operator": { + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", + "type": "string" + }, "type": { - "type": "string", + "description": "条件类型", "enum": [ "text", "number", "date" ], - "description": "条件类型" - }, - "operator": { - "type": "string", - "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" + "type": "string" }, "value": { "description": "比较值" @@ -5373,23 +5356,38 @@ "required": [ "type", "operator" - ] + ], + "type": "object" + }, + "display_name": { + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。", + "type": "string" + }, + "field": { + "description": "源数据中的字段名(OOXML 字段引用)", + "type": "string" + }, + "filter": { + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "description": "要显示的项目列表(其余项目被隐藏)", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" }, "group": { - "type": "object", "description": "分组配置", "properties": { - "type": { - "type": "string", - "enum": [ - "date", - "number", - "element" - ], - "description": "分组类型" - }, "date_group_by": { - "type": "string", + "description": "日期分组粒度(type='date' 时必填)", "enum": [ "year", "yearMonth", @@ -5403,87 +5401,80 @@ "hourMinute", "minute" ], - "description": "日期分组粒度(type='date' 时必填)" - }, - "interval": { - "type": "number", - "description": "数值分组间隔(type='number' 时必填)" - }, - "start": { - "type": "number", - "description": "数值分组起始值" + "type": "string" }, "end": { - "type": "number", - "description": "数值分组结束值" + "description": "数值分组结束值", + "type": "number" }, "groups": { - "type": "object", - "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", "additionalProperties": { - "type": "array", "items": { "type": "string" - } - } + }, + "type": "array" + }, + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", + "type": "object" + }, + "interval": { + "description": "数值分组间隔(type='number' 时必填)", + "type": "number" + }, + "start": { + "description": "数值分组起始值", + "type": "number" + }, + "type": { + "description": "分组类型", + "enum": [ + "date", + "number", + "element" + ], + "type": "string" } }, "required": [ "type" - ] + ], + "type": "object" } }, "required": [ "field" - ] - } + ], + "type": "object" + }, + "type": "array" }, - "filters": { - "description": "筛选区域字段(页字段)", - "type": "array", + "range": { + "description": "放置透视表的左上角单元格 A1 地址(例如:'F1')(仅 create 时有效)。可省略——省略后透视表放在新建子表的 A1 位置。", + "type": "string" + }, + "repeat_row_labels": { + "description": "是否显示重复项标签", + "type": "boolean" + }, + "rows": { + "description": "纵向分组字段(行字段)", "items": { - "type": "object", "properties": { - "field": { - "type": "string", - "description": "源数据中的字段名(OOXML 字段引用)" - }, - "display_name": { - "type": "string", - "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。" - }, - "filter": { - "type": "object", - "description": "快速筛选:只显示指定的项目", - "properties": { - "items": { - "type": "array", - "items": { - "type": "string" - }, - "description": "要显示的项目列表(其余项目被隐藏)" - } - }, - "required": [ - "items" - ] - }, "condition_filter": { - "type": "object", "description": "条件筛选:按文本/数值/日期条件筛选", "properties": { + "operator": { + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", + "type": "string" + }, "type": { - "type": "string", + "description": "条件类型", "enum": [ "text", "number", "date" ], - "description": "条件类型" - }, - "operator": { - "type": "string", - "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" + "type": "string" }, "value": { "description": "比较值" @@ -5495,23 +5486,38 @@ "required": [ "type", "operator" - ] + ], + "type": "object" + }, + "display_name": { + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用;否则保持源字段原名,透视表产物已足够清晰。", + "type": "string" + }, + "field": { + "description": "源数据中的字段名(OOXML 字段引用)", + "type": "string" + }, + "filter": { + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "description": "要显示的项目列表(其余项目被隐藏)", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" }, "group": { - "type": "object", "description": "分组配置", "properties": { - "type": { - "type": "string", - "enum": [ - "date", - "number", - "element" - ], - "description": "分组类型" - }, "date_group_by": { - "type": "string", + "description": "日期分组粒度(type='date' 时必填)", "enum": [ "year", "yearMonth", @@ -5525,774 +5531,771 @@ "hourMinute", "minute" ], - "description": "日期分组粒度(type='date' 时必填)" - }, - "interval": { - "type": "number", - "description": "数值分组间隔(type='number' 时必填)" - }, - "start": { - "type": "number", - "description": "数值分组起始值" + "type": "string" }, "end": { - "type": "number", - "description": "数值分组结束值" + "description": "数值分组结束值", + "type": "number" }, "groups": { - "type": "object", - "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", "additionalProperties": { - "type": "array", "items": { "type": "string" - } - } + }, + "type": "array" + }, + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", + "type": "object" + }, + "interval": { + "description": "数值分组间隔(type='number' 时必填)", + "type": "number" + }, + "start": { + "description": "数值分组起始值", + "type": "number" + }, + "type": { + "description": "分组类型", + "enum": [ + "date", + "number", + "element" + ], + "type": "string" } }, "required": [ "type" - ] - } - }, - "required": [ - "field" - ] - } - }, - "values": { - "minItems": 1, - "type": "array", - "items": { - "type": "object", - "properties": { - "field": { - "type": "string", - "description": "要汇总的源数据字段名" - }, - "display_name": { - "type": "string", - "description": "字段在透视表中的显示名(典型场景:把 count(销售额) 的值列头显示为 'GMV')。不传则使用源字段原名。仅在用户明确要求重命名时使用。" - }, - "summarize_by": { - "default": "sum", - "description": "汇总函数", - "type": "string", - "enum": [ - "sum", - "count", - "average", - "max", - "min", - "product", - "countNums", - "stdDev", - "stdDevp", - "var", - "varp", - "distinct", - "median" - ] - }, - "show_data_as": { - "type": "string", - "description": "值显示方式(默认 'normal')", - "enum": [ - "normal", - "percentOfTotal", - "percentOfCol", - "percentOfRow", - "percentOfParentRow", - "percentOfParentCol", - "index" - ] + ], + "type": "object" }, - "base_field": { - "type": "string", - "description": "show_data_as 需要基准字段时的字段名" + "sort": { + "description": "排序配置", + "properties": { + "by": { + "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序", + "enum": [ + "label", + "value" + ], + "type": "string" + }, + "order": { + "description": "排序方向", + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + "value_field": { + "description": "by='value' 时必填,指定按哪个值字段排序", + "type": "string" + } + }, + "required": [ + "order" + ], + "type": "object" } }, "required": [ "field" - ] + ], + "type": "object" }, - "description": "要汇总的字段(至少需要 1 个)" + "type": "array" }, - "auto_fit_col": { - "type": "boolean", - "description": "是否自动调整列宽以适应内容" + "show_col_grand_total": { + "description": "是否显示列总计(默认 true)", + "type": "boolean" }, "show_row_grand_total": { - "type": "boolean", - "description": "是否显示行总计(默认 true)" - }, - "show_col_grand_total": { - "type": "boolean", - "description": "是否显示列总计(默认 true)" + "description": "是否显示行总计(默认 true)", + "type": "boolean" }, "show_subtotals": { - "type": "boolean", - "description": "是否显示分类小计(默认 true,应用于所有字段)" + "description": "是否显示分类小计(默认 true,应用于所有字段)", + "type": "boolean" }, - "repeat_row_labels": { - "type": "boolean", - "description": "是否显示重复项标签" + "source": { + "description": "源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100')。create 时必填;update 时提供则切换数据源。", + "type": "string" }, - "calculated_fields": { - "type": "array", - "description": "计算字段列表", + "values": { + "description": "要汇总的字段(至少需要 1 个)", "items": { - "type": "object", "properties": { - "name": { - "type": "string", - "description": "计算字段的显示名称" + "base_field": { + "description": "show_data_as 需要基准字段时的字段名", + "type": "string" }, - "formula": { - "type": "string", - "description": "公式表达式,引用其他字段名,例如 \"'Sales' + 'Tax'\"" + "display_name": { + "description": "字段在透视表中的显示名(典型场景:把 count(销售额) 的值列头显示为 'GMV')。不传则使用源字段原名。仅在用户明确要求重命名时使用。", + "type": "string" + }, + "field": { + "description": "要汇总的源数据字段名", + "type": "string" + }, + "show_data_as": { + "description": "值显示方式(默认 'normal')", + "enum": [ + "normal", + "percentOfTotal", + "percentOfCol", + "percentOfRow", + "percentOfParentRow", + "percentOfParentCol", + "index" + ], + "type": "string" }, "summarize_by": { - "type": "string", "default": "sum", - "description": "计算字段汇总方式(默认 'sum',仅支持 sum/custom 两种):'sum' 表示先对 formula 中引用的各源字段分别求和后再代入公式(formula 是逐字段求和后的标量表达式,例如 \"'Sales'+'Tax'\" 等价于 SUM(Sales)+SUM(Tax));'custom' 表示数组直接传给 formula,要求 formula 自带聚合函数(例如 \"SUM('Sales')+SUM('Tax')\" 或 \"AVERAGE('Sales')\")。需要 count/average/max/min 等其他聚合方式时,请改用 values[] 字段对源字段直接聚合,calculated_fields 不适用。", + "description": "汇总函数", "enum": [ "sum", - "custom" - ] + "count", + "average", + "max", + "min", + "product", + "countNums", + "stdDev", + "stdDevp", + "var", + "varp", + "distinct", + "median" + ], + "type": "string" } }, "required": [ - "name", - "formula" - ] - } - }, - "collapse": { - "type": "object", - "description": "行字段展开/折叠状态:字段名 -> 要折叠的项目列表", - "additionalProperties": { - "type": "array", - "items": { - "type": "string" - } - } + "field" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" } }, - "additionalProperties": {} + "type": "object" } }, "+range-sort": { "sort-keys": { - "type": "array", + "description": "排序条件列表(仅 sort 操作)。支持多级排序,靠前的条件优先级更高。", "items": { - "type": "object", "properties": { - "column": { - "type": "string", - "description": "排序依据的列字母(如 \"C\"、\"D\"),必须在 range 范围内" - }, "ascending": { - "type": "boolean", - "description": "是否升序排序" + "description": "是否升序排序", + "type": "boolean" + }, + "column": { + "description": "排序依据的列字母(如 \"C\"、\"D\"),必须在 range 范围内", + "type": "string" } }, "required": [ "column", "ascending" - ] + ], + "type": "object" }, - "description": "排序条件列表(仅 sort 操作)。支持多级排序,靠前的条件优先级更高。" + "type": "array" } }, "+sparkline-create": { "properties": { + "additionalProperties": false, "description": "创建/更新/部分删除的迷你图属性。delete 时不传 sparklines 即删整组,传则删指定项。", - "type": "object", "properties": { "config": { - "type": "object", + "additionalProperties": false, "description": "迷你图样式配置, 相同 groupId 的迷你图共享相同的样式。", "properties": { - "theme_type": { - "type": "string", - "enum": [ - "pro", - "light", - "soft", - "brand", - "fresh" - ], - "description": "主题类型:pro、light、soft、brand、fresh。" + "axis": { + "additionalProperties": false, + "description": "坐标轴配置,包含坐标轴颜色、是否翻转、是否显示坐标轴。", + "properties": { + "color": { + "description": "坐标轴颜色。", + "type": "string" + }, + "reverse": { + "description": "是否翻转坐标轴方向。", + "type": "boolean" + }, + "visible": { + "description": "是否显示坐标轴。", + "type": "boolean" + } + }, + "type": "object" }, - "non_num_show_as": { - "type": "string", + "contain_hidden_cells": { + "description": "隐藏的单元格数据是否参与绘制。", + "type": "boolean" + }, + "empty_show_as": { + "description": "空单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。", "enum": [ "zero", "gap", "average" ], - "description": "非数字单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。" + "type": "string" }, - "empty_show_as": { - "type": "string", + "extremum_max": { + "additionalProperties": false, + "description": "最大极值配置,包含极值类型、极值。", + "properties": { + "type": { + "description": "极值类型,可选值:individual=单个迷你图极值,group=同组内极值,custom=自定义。", + "enum": [ + "individual", + "group", + "custom" + ], + "type": "string" + }, + "value": { + "description": "当 type='custom' 时生效的具体数值。", + "type": "number" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "extremum_min": { + "additionalProperties": false, + "description": "最小极值配置,包含极值类型、极值。", + "properties": { + "type": { + "description": "极值类型:individual=单个迷你图极值,group=同组内极值,custom=自定义。", + "enum": [ + "individual", + "group", + "custom" + ], + "type": "string" + }, + "value": { + "description": "当 type='custom' 时生效的具体数值。", + "type": "number" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "line_width": { + "description": "折线图线宽,可选值:1=1px,2=2px,3=3px,4=4px。", + "enum": [ + 1, + 2, + 3, + 4 + ], + "type": "number" + }, + "non_num_show_as": { + "description": "非数字单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。", "enum": [ "zero", "gap", "average" ], - "description": "空单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。" - }, - "contain_hidden_cells": { - "type": "boolean", - "description": "隐藏的单元格数据是否参与绘制。" - }, - "series_color": { - "type": "string", - "description": "主系列颜色,例如 \"#4472C4\"。" + "type": "string" }, "points": { - "type": "object", + "additionalProperties": false, "description": "特殊点样式配置,包含高点、低点、标记点、首点、尾点、负点。", "properties": { - "last_point": { - "type": "object", - "description": "尾点配置,最后一个数据点的样式。", - "properties": { - "color": { - "type": "string", - "description": "点的颜色。" - }, - "visible": { - "type": "boolean", - "description": "是否显示该点。" - } - }, - "additionalProperties": false - }, - "negative_point": { - "type": "object", - "description": "负点配置,负数点的样式。", + "first_point": { + "additionalProperties": false, + "description": "首点配置,第一个数据点的样式。", "properties": { "color": { - "type": "string", - "description": "点的颜色。" + "description": "点的颜色。", + "type": "string" }, "visible": { - "type": "boolean", - "description": "是否显示该点。" + "description": "是否显示该点。", + "type": "boolean" } }, - "additionalProperties": false + "type": "object" }, - "markers_point": { - "type": "object", - "description": "标记点配置,所有标记点的样式。", + "high_point": { + "additionalProperties": false, + "description": "高点配置,最高点的样式。", "properties": { "color": { - "type": "string", - "description": "点的颜色。" + "description": "点的颜色。", + "type": "string" }, "visible": { - "type": "boolean", - "description": "是否显示该点。" + "description": "是否显示该点。", + "type": "boolean" } }, - "additionalProperties": false + "type": "object" }, - "first_point": { - "type": "object", - "description": "首点配置,第一个数据点的样式。", + "last_point": { + "additionalProperties": false, + "description": "尾点配置,最后一个数据点的样式。", "properties": { "color": { - "type": "string", - "description": "点的颜色。" + "description": "点的颜色。", + "type": "string" }, "visible": { - "type": "boolean", - "description": "是否显示该点。" + "description": "是否显示该点。", + "type": "boolean" } }, - "additionalProperties": false + "type": "object" }, - "high_point": { - "type": "object", - "description": "高点配置,最高点的样式。", + "low_point": { + "additionalProperties": false, + "description": "低点配置,最低点的样式。", "properties": { "color": { - "type": "string", - "description": "点的颜色。" + "description": "点的颜色。", + "type": "string" }, "visible": { - "type": "boolean", - "description": "是否显示该点。" + "description": "是否显示该点。", + "type": "boolean" } }, - "additionalProperties": false + "type": "object" }, - "low_point": { - "type": "object", - "description": "低点配置,最低点的样式。", + "markers_point": { + "additionalProperties": false, + "description": "标记点配置,所有标记点的样式。", "properties": { "color": { - "type": "string", - "description": "点的颜色。" + "description": "点的颜色。", + "type": "string" }, "visible": { - "type": "boolean", - "description": "是否显示该点。" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "line_width": { - "type": "number", - "enum": [ - 1, - 2, - 3, - 4 - ], - "description": "折线图线宽,可选值:1=1px,2=2px,3=3px,4=4px。" - }, - "type": { - "type": "string", - "enum": [ - "line", - "column", - "win_loss" - ], - "description": "迷你图类型,可选值:line=折线图,column=柱形图,win_loss=盈亏图。" - }, - "axis": { - "type": "object", - "description": "坐标轴配置,包含坐标轴颜色、是否翻转、是否显示坐标轴。", - "properties": { - "color": { - "type": "string", - "description": "坐标轴颜色。" - }, - "reverse": { - "type": "boolean", - "description": "是否翻转坐标轴方向。" - }, - "visible": { - "type": "boolean", - "description": "是否显示坐标轴。" - } - }, - "additionalProperties": false - }, - "show_gradient": { - "type": "boolean", - "description": "是否显示渐变效果。" - }, - "show_radius": { - "type": "boolean", - "description": "是否显示圆角,仅对柱形图和盈亏图生效。" - }, - "extremum_max": { - "type": "object", - "description": "最大极值配置,包含极值类型、极值。", - "properties": { - "type": { - "type": "string", - "enum": [ - "individual", - "group", - "custom" - ], - "description": "极值类型,可选值:individual=单个迷你图极值,group=同组内极值,custom=自定义。" - }, - "value": { - "type": "number", - "description": "当 type='custom' 时生效的具体数值。" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - "extremum_min": { - "type": "object", - "description": "最小极值配置,包含极值类型、极值。", - "properties": { - "type": { - "type": "string", - "enum": [ - "individual", - "group", - "custom" - ], - "description": "极值类型:individual=单个迷你图极值,group=同组内极值,custom=自定义。" + "description": "是否显示该点。", + "type": "boolean" + } + }, + "type": "object" }, - "value": { - "type": "number", - "description": "当 type='custom' 时生效的具体数值。" + "negative_point": { + "additionalProperties": false, + "description": "负点配置,负数点的样式。", + "properties": { + "color": { + "description": "点的颜色。", + "type": "string" + }, + "visible": { + "description": "是否显示该点。", + "type": "boolean" + } + }, + "type": "object" } }, - "required": [ - "type" + "type": "object" + }, + "series_color": { + "description": "主系列颜色,例如 \"#4472C4\"。", + "type": "string" + }, + "show_gradient": { + "description": "是否显示渐变效果。", + "type": "boolean" + }, + "show_radius": { + "description": "是否显示圆角,仅对柱形图和盈亏图生效。", + "type": "boolean" + }, + "theme_type": { + "description": "主题类型:pro、light、soft、brand、fresh。", + "enum": [ + "pro", + "light", + "soft", + "brand", + "fresh" + ], + "type": "string" + }, + "type": { + "description": "迷你图类型,可选值:line=折线图,column=柱形图,win_loss=盈亏图。", + "enum": [ + "line", + "column", + "win_loss" ], - "additionalProperties": false + "type": "string" } }, - "additionalProperties": false + "type": "object" }, "sparklines": { - "type": "array", "description": "迷你图项列表。create 时为待创建项(每项需 position + source/source_range);update 时为待变更/新增项(每项需 sparkline_id;upsert=true 新增时需 position + source);delete 时为待删除项(每项需 sparkline_id)。", "items": { - "type": "object", + "additionalProperties": false, "description": "单个迷你图项。", "properties": { - "sparkline_id": { - "type": "string", - "description": "迷你图 reference_id。create 时可选(不传系统生成);update / delete 时必填。" - }, "position": { - "type": "object", + "additionalProperties": false, "description": "迷你图位置。create / update 时必填;delete 时省略。", "properties": { + "col": { + "description": "列索引,例如 \"A\"、\"B\"", + "type": "string" + }, "row": { - "type": "number", + "description": "行索引(0-based)", "minimum": 0, - "description": "行索引(0-based)" - }, - "col": { - "type": "string", - "description": "列索引,例如 \"A\"、\"B\"" + "type": "number" } }, "required": [ "row", "col" ], - "additionalProperties": false + "type": "object" }, "source": { - "type": "string", - "description": "A1 范围字符串,表示数据来源,例如 \"Sheet1!A2:A10\"。create 必填(与 source_range 二选一);update 仅在改源时传;delete 时省略。" + "description": "A1 范围字符串,表示数据来源,例如 \"Sheet1!A2:A10\"。create 必填(与 source_range 二选一);update 仅在改源时传;delete 时省略。", + "type": "string" }, "source_range": { - "type": "object", + "additionalProperties": false, "description": "结构化数据源范围(与 source 等价)。", "properties": { "range": { - "type": "string", - "description": "数据源的 A1 引用区域" + "description": "数据源的 A1 引用区域", + "type": "string" } }, "required": [ "range" ], - "additionalProperties": false + "type": "object" + }, + "sparkline_id": { + "description": "迷你图 reference_id。create 时可选(不传系统生成);update / delete 时必填。", + "type": "string" } }, - "additionalProperties": false - } + "type": "object" + }, + "type": "array" } }, - "additionalProperties": false + "type": "object" } }, "+sparkline-update": { "properties": { + "additionalProperties": false, "description": "创建/更新/部分删除的迷你图属性。delete 时不传 sparklines 即删整组,传则删指定项。", - "type": "object", "properties": { "config": { - "type": "object", + "additionalProperties": false, "description": "迷你图样式配置, 相同 groupId 的迷你图共享相同的样式。", "properties": { - "theme_type": { - "type": "string", - "enum": [ - "pro", - "light", - "soft", - "brand", - "fresh" - ], - "description": "主题类型:pro、light、soft、brand、fresh。" + "axis": { + "additionalProperties": false, + "description": "坐标轴配置,包含坐标轴颜色、是否翻转、是否显示坐标轴。", + "properties": { + "color": { + "description": "坐标轴颜色。", + "type": "string" + }, + "reverse": { + "description": "是否翻转坐标轴方向。", + "type": "boolean" + }, + "visible": { + "description": "是否显示坐标轴。", + "type": "boolean" + } + }, + "type": "object" }, - "non_num_show_as": { - "type": "string", + "contain_hidden_cells": { + "description": "隐藏的单元格数据是否参与绘制。", + "type": "boolean" + }, + "empty_show_as": { + "description": "空单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。", "enum": [ "zero", "gap", "average" ], - "description": "非数字单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。" + "type": "string" }, - "empty_show_as": { - "type": "string", + "extremum_max": { + "additionalProperties": false, + "description": "最大极值配置,包含极值类型、极值。", + "properties": { + "type": { + "description": "极值类型,可选值:individual=单个迷你图极值,group=同组内极值,custom=自定义。", + "enum": [ + "individual", + "group", + "custom" + ], + "type": "string" + }, + "value": { + "description": "当 type='custom' 时生效的具体数值。", + "type": "number" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "extremum_min": { + "additionalProperties": false, + "description": "最小极值配置,包含极值类型、极值。", + "properties": { + "type": { + "description": "极值类型:individual=单个迷你图极值,group=同组内极值,custom=自定义。", + "enum": [ + "individual", + "group", + "custom" + ], + "type": "string" + }, + "value": { + "description": "当 type='custom' 时生效的具体数值。", + "type": "number" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "line_width": { + "description": "折线图线宽,可选值:1=1px,2=2px,3=3px,4=4px。", + "enum": [ + 1, + 2, + 3, + 4 + ], + "type": "number" + }, + "non_num_show_as": { + "description": "非数字单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。", "enum": [ "zero", "gap", "average" ], - "description": "空单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。" - }, - "contain_hidden_cells": { - "type": "boolean", - "description": "隐藏的单元格数据是否参与绘制。" - }, - "series_color": { - "type": "string", - "description": "主系列颜色,例如 \"#4472C4\"。" + "type": "string" }, "points": { - "type": "object", + "additionalProperties": false, "description": "特殊点样式配置,包含高点、低点、标记点、首点、尾点、负点。", "properties": { - "last_point": { - "type": "object", - "description": "尾点配置,最后一个数据点的样式。", + "first_point": { + "additionalProperties": false, + "description": "首点配置,第一个数据点的样式。", "properties": { "color": { - "type": "string", - "description": "点的颜色。" + "description": "点的颜色。", + "type": "string" }, "visible": { - "type": "boolean", - "description": "是否显示该点。" + "description": "是否显示该点。", + "type": "boolean" } }, - "additionalProperties": false + "type": "object" }, - "negative_point": { - "type": "object", - "description": "负点配置,负数点的样式。", + "high_point": { + "additionalProperties": false, + "description": "高点配置,最高点的样式。", "properties": { "color": { - "type": "string", - "description": "点的颜色。" + "description": "点的颜色。", + "type": "string" }, "visible": { - "type": "boolean", - "description": "是否显示该点。" + "description": "是否显示该点。", + "type": "boolean" } }, - "additionalProperties": false + "type": "object" }, - "markers_point": { - "type": "object", - "description": "标记点配置,所有标记点的样式。", + "last_point": { + "additionalProperties": false, + "description": "尾点配置,最后一个数据点的样式。", "properties": { "color": { - "type": "string", - "description": "点的颜色。" + "description": "点的颜色。", + "type": "string" }, "visible": { - "type": "boolean", - "description": "是否显示该点。" + "description": "是否显示该点。", + "type": "boolean" } }, - "additionalProperties": false + "type": "object" }, - "first_point": { - "type": "object", - "description": "首点配置,第一个数据点的样式。", + "low_point": { + "additionalProperties": false, + "description": "低点配置,最低点的样式。", "properties": { "color": { - "type": "string", - "description": "点的颜色。" + "description": "点的颜色。", + "type": "string" }, "visible": { - "type": "boolean", - "description": "是否显示该点。" + "description": "是否显示该点。", + "type": "boolean" } }, - "additionalProperties": false + "type": "object" }, - "high_point": { - "type": "object", - "description": "高点配置,最高点的样式。", + "markers_point": { + "additionalProperties": false, + "description": "标记点配置,所有标记点的样式。", "properties": { "color": { - "type": "string", - "description": "点的颜色。" + "description": "点的颜色。", + "type": "string" }, "visible": { - "type": "boolean", - "description": "是否显示该点。" + "description": "是否显示该点。", + "type": "boolean" } }, - "additionalProperties": false + "type": "object" }, - "low_point": { - "type": "object", - "description": "低点配置,最低点的样式。", + "negative_point": { + "additionalProperties": false, + "description": "负点配置,负数点的样式。", "properties": { "color": { - "type": "string", - "description": "点的颜色。" + "description": "点的颜色。", + "type": "string" }, "visible": { - "type": "boolean", - "description": "是否显示该点。" + "description": "是否显示该点。", + "type": "boolean" } }, - "additionalProperties": false + "type": "object" } }, - "additionalProperties": false + "type": "object" }, - "line_width": { - "type": "number", + "series_color": { + "description": "主系列颜色,例如 \"#4472C4\"。", + "type": "string" + }, + "show_gradient": { + "description": "是否显示渐变效果。", + "type": "boolean" + }, + "show_radius": { + "description": "是否显示圆角,仅对柱形图和盈亏图生效。", + "type": "boolean" + }, + "theme_type": { + "description": "主题类型:pro、light、soft、brand、fresh。", "enum": [ - 1, - 2, - 3, - 4 + "pro", + "light", + "soft", + "brand", + "fresh" ], - "description": "折线图线宽,可选值:1=1px,2=2px,3=3px,4=4px。" + "type": "string" }, "type": { - "type": "string", + "description": "迷你图类型,可选值:line=折线图,column=柱形图,win_loss=盈亏图。", "enum": [ "line", "column", "win_loss" ], - "description": "迷你图类型,可选值:line=折线图,column=柱形图,win_loss=盈亏图。" - }, - "axis": { - "type": "object", - "description": "坐标轴配置,包含坐标轴颜色、是否翻转、是否显示坐标轴。", - "properties": { - "color": { - "type": "string", - "description": "坐标轴颜色。" - }, - "reverse": { - "type": "boolean", - "description": "是否翻转坐标轴方向。" - }, - "visible": { - "type": "boolean", - "description": "是否显示坐标轴。" - } - }, - "additionalProperties": false - }, - "show_gradient": { - "type": "boolean", - "description": "是否显示渐变效果。" - }, - "show_radius": { - "type": "boolean", - "description": "是否显示圆角,仅对柱形图和盈亏图生效。" - }, - "extremum_max": { - "type": "object", - "description": "最大极值配置,包含极值类型、极值。", - "properties": { - "type": { - "type": "string", - "enum": [ - "individual", - "group", - "custom" - ], - "description": "极值类型,可选值:individual=单个迷你图极值,group=同组内极值,custom=自定义。" - }, - "value": { - "type": "number", - "description": "当 type='custom' 时生效的具体数值。" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - "extremum_min": { - "type": "object", - "description": "最小极值配置,包含极值类型、极值。", - "properties": { - "type": { - "type": "string", - "enum": [ - "individual", - "group", - "custom" - ], - "description": "极值类型:individual=单个迷你图极值,group=同组内极值,custom=自定义。" - }, - "value": { - "type": "number", - "description": "当 type='custom' 时生效的具体数值。" - } - }, - "required": [ - "type" - ], - "additionalProperties": false + "type": "string" } }, - "additionalProperties": false + "type": "object" }, "sparklines": { - "type": "array", "description": "迷你图项列表。create 时为待创建项(每项需 position + source/source_range);update 时为待变更/新增项(每项需 sparkline_id;upsert=true 新增时需 position + source);delete 时为待删除项(每项需 sparkline_id)。", "items": { - "type": "object", + "additionalProperties": false, "description": "单个迷你图项。", "properties": { - "sparkline_id": { - "type": "string", - "description": "迷你图 reference_id。create 时可选(不传系统生成);update / delete 时必填。" - }, "position": { - "type": "object", + "additionalProperties": false, "description": "迷你图位置。create / update 时必填;delete 时省略。", "properties": { + "col": { + "description": "列索引,例如 \"A\"、\"B\"", + "type": "string" + }, "row": { - "type": "number", + "description": "行索引(0-based)", "minimum": 0, - "description": "行索引(0-based)" - }, - "col": { - "type": "string", - "description": "列索引,例如 \"A\"、\"B\"" + "type": "number" } }, "required": [ "row", "col" ], - "additionalProperties": false + "type": "object" }, "source": { - "type": "string", - "description": "A1 范围字符串,表示数据来源,例如 \"Sheet1!A2:A10\"。create 必填(与 source_range 二选一);update 仅在改源时传;delete 时省略。" + "description": "A1 范围字符串,表示数据来源,例如 \"Sheet1!A2:A10\"。create 必填(与 source_range 二选一);update 仅在改源时传;delete 时省略。", + "type": "string" }, "source_range": { - "type": "object", + "additionalProperties": false, "description": "结构化数据源范围(与 source 等价)。", "properties": { "range": { - "type": "string", - "description": "数据源的 A1 引用区域" + "description": "数据源的 A1 引用区域", + "type": "string" } }, "required": [ "range" ], - "additionalProperties": false + "type": "object" + }, + "sparkline_id": { + "description": "迷你图 reference_id。create 时可选(不传系统生成);update / delete 时必填。", + "type": "string" } }, - "additionalProperties": false - } + "type": "object" + }, + "type": "array" } }, - "additionalProperties": false + "type": "object" } } } diff --git a/shortcuts/sheets/lark_sheet_batch_update.go b/shortcuts/sheets/lark_sheet_batch_update.go index abf51868c..d6591bb52 100644 --- a/shortcuts/sheets/lark_sheet_batch_update.go +++ b/shortcuts/sheets/lark_sheet_batch_update.go @@ -332,7 +332,7 @@ var DropdownUpdate = common.Shortcut{ if _, err := validateDropdownRanges(runtime); err != nil { return err } - if _, err := validateDropdownOptions(runtime); err != nil { + if _, err := validateDropdownOptionsColors(runtime); err != nil { return err } return nil diff --git a/shortcuts/sheets/lark_sheet_batch_update_test.go b/shortcuts/sheets/lark_sheet_batch_update_test.go index e7885ab5c..f190c8d1e 100644 --- a/shortcuts/sheets/lark_sheet_batch_update_test.go +++ b/shortcuts/sheets/lark_sheet_batch_update_test.go @@ -188,14 +188,16 @@ func TestCellsBatchClear_Guards(t *testing.T) { // TestDropdownUpdate_BatchPayload verifies the multi-range dropdown // update fans out into a single batch_update with one set_cell_range -// op per range. +// op per range. Also covers --colors / --highlight -> highlight_colors +// / enable_highlight propagation through dropdownBatchInput. func TestDropdownUpdate_BatchPayload(t *testing.T) { t.Parallel() body := parseDryRunBody(t, DropdownUpdate, []string{ "--url", testURL, "--ranges", `["sheet1!A2:A5","sheet1!C2:C5"]`, "--options", `["a","b","c"]`, - "--multiple", + "--colors", `["#FFE699","#bff7d9","#ffb3b3"]`, + "--multiple", "--highlight", }) input := decodeToolInput(t, body, "batch_update") ops, _ := input["operations"].([]interface{}) @@ -222,6 +224,13 @@ func TestDropdownUpdate_BatchPayload(t *testing.T) { if dv["support_multiple_values"] != true { t.Errorf("op[%d] support_multiple_values = %v, want true", i, dv["support_multiple_values"]) } + colors, _ := dv["highlight_colors"].([]interface{}) + if len(colors) != 3 { + t.Errorf("op[%d] highlight_colors length = %d, want 3", i, len(colors)) + } + if dv["enable_highlight"] != true { + t.Errorf("op[%d] enable_highlight = %v, want true", i, dv["enable_highlight"]) + } } } diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index 671fd673d..2cd2eff9a 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -331,14 +331,16 @@ func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[s // ─── shared dropdown helpers ────────────────────────────────────────── -// buildDropdownValidation packs --options / --multiple into the -// data_validation block expected by set_cell_range. Field names match -// the canonical schema: items (not values) for the option list, and -// support_multiple_values (not multiple_values) for multi-select. -// Earlier CLI builds also emitted `colors` and `highlight_options`, -// neither of which exists in the server schema for set_cell_range; both -// were rejected as "unexpected property". The --colors / --highlight -// flags have been removed; only --options + --multiple remain. +// buildDropdownValidation packs --options / --colors / --multiple / --highlight +// into the data_validation block expected by set_cell_range. Field names +// follow the canonical set_cell_range.data_validation schema: +// +// --options -> items (string array) +// --multiple -> support_multiple_values (bool) +// --colors -> highlight_colors (string array, hex) +// --highlight -> enable_highlight (bool) +// +// --colors length must equal --options length when both are set. func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) { options, err := requireJSONArray(runtime, "options") if err != nil { @@ -348,19 +350,42 @@ func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) { "type": "list", "items": options, } + if runtime.Str("colors") != "" { + colors, err := requireJSONArray(runtime, "colors") + if err != nil { + return nil, err + } + if len(colors) != len(options) { + return nil, common.FlagErrorf("--colors length (%d) must equal --options length (%d)", len(colors), len(options)) + } + dv["highlight_colors"] = colors + } if runtime.Bool("multiple") { dv["support_multiple_values"] = true } + if runtime.Bool("highlight") { + dv["enable_highlight"] = true + } return dv, nil } -// validateDropdownOptions checks that --options parses as a JSON array -// and returns its length so callers can size their cells matrix. -func validateDropdownOptions(runtime flagView) (int, error) { +// validateDropdownOptionsColors validates --options is a JSON array and that +// --colors (when set) has matching length. Returns the options length so +// callers can size their cells matrix at Validate time without re-parsing. +func validateDropdownOptionsColors(runtime flagView) (int, error) { options, err := requireJSONArray(runtime, "options") if err != nil { return 0, err } + if runtime.Str("colors") != "" { + colors, err := requireJSONArray(runtime, "colors") + if err != nil { + return 0, err + } + if len(colors) != len(options) { + return 0, common.FlagErrorf("--colors length (%d) must equal --options length (%d)", len(colors), len(options)) + } + } return len(options), nil } diff --git a/shortcuts/sheets/lark_sheet_write_cells_test.go b/shortcuts/sheets/lark_sheet_write_cells_test.go index 2e3543bb9..5e10d1a25 100644 --- a/shortcuts/sheets/lark_sheet_write_cells_test.go +++ b/shortcuts/sheets/lark_sheet_write_cells_test.go @@ -118,11 +118,15 @@ func TestWriteCellsShortcuts_DryRun(t *testing.T) { // 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 +// `highlight_colors` / `enable_highlight` field names (not the legacy +// `colors` / `highlight_options`). func TestDropdownSet_CellsShape(t *testing.T) { t.Parallel() body := parseDryRunBody(t, DropdownSet, []string{ "--url", testURL, "--sheet-id", testSheetID, "--range", "A2:A4", "--options", `["a","b"]`, "--multiple", + "--colors", `["#FFE699","#bff7d9"]`, "--highlight", }) input := decodeToolInput(t, body, "set_cell_range") cells, _ := input["cells"].([]interface{}) @@ -153,6 +157,38 @@ func TestDropdownSet_CellsShape(t *testing.T) { if _, hasLegacy := dv["multiple_values"]; hasLegacy { t.Errorf("row %d data_validation should not emit legacy `multiple_values`", i) } + colors, _ := dv["highlight_colors"].([]interface{}) + if len(colors) != 2 || colors[0] != "#FFE699" || colors[1] != "#bff7d9" { + t.Errorf("row %d data_validation.highlight_colors = %#v, want [\"#FFE699\",\"#bff7d9\"]", i, dv["highlight_colors"]) + } + if dv["enable_highlight"] != true { + t.Errorf("row %d data_validation.enable_highlight = %v, want true", i, dv["enable_highlight"]) + } + if _, hasLegacy := dv["colors"]; hasLegacy { + t.Errorf("row %d data_validation should not emit legacy `colors`", i) + } + if _, hasLegacy := dv["highlight_options"]; hasLegacy { + t.Errorf("row %d data_validation should not emit legacy `highlight_options`", i) + } + } +} + +// TestDropdownSet_ColorsLengthMismatch checks the early Validate-time +// error when --colors length doesn't match --options. +func TestDropdownSet_ColorsLengthMismatch(t *testing.T) { + t.Parallel() + _, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A2:A4", + "--options", `["a","b","c"]`, + "--colors", `["#FFE699","#bff7d9"]`, + "--dry-run", + }) + if err == nil { + t.Fatal("expected --colors length mismatch error, got nil") + } + if !strings.Contains(stderr, "must equal --options length") && !strings.Contains(err.Error(), "must equal --options length") { + t.Errorf("error message missing length-mismatch hint:\nerr=%v\nstderr=%s", err, stderr) } } diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 7b04905a1..ba3160e0b 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -70,7 +70,9 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(如 `["sheet1!A2:A100"]`),每项必须带 sheet 前缀 | | `--options` | string + File + Stdin(复合 JSON) | required | 选项 JSON 数组(如 `["opt1","opt2"]`) | +| `--colors` | string + File + Stdin(简单 JSON) | optional | 颜色数组(与 `--options` 等长) | | `--multiple` | bool | optional | 启用多选 | +| `--highlight` | bool | optional | 选项配色 | ### `+dropdown-delete` diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 6ebe2b154..a4e32f6b2 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -4,7 +4,7 @@ 1. **明确写入边界**:写入前必须能回答"目标 range 的起止行列号是多少?是否落在用户授权范围内?"。除用户明示要修改的区域外,禁止扩张到原数据列以外或新建 Sheet。 2. **完整性断言**:批量写入前先把"预期写入条数"硬编码到代码里(如要填 106 条翻译 → `expected = 106`),写完后回读断言 `actual == expected`。少于预期就继续写,禁止交付半成品。 -3. **回读抽样校验**:写完关键值 / 公式后,用 `+csv-get` 或 `+cells-get` 重新读取写入区域,至少抽样 3-5 个代表性单元格(首 / 中 / 末),核对值与预期一致(与本地脚本计算的预期值对照)。公式特定的"先验证模板再 --copy-to-range / 修完再读回"细则见下方相关章节。 +3. **回读抽样校验**:写完关键值 / 公式后,用 `+csv-get` 或 `+cells-get` 重新读取写入区域,至少抽样 3-5 个代表性单元格(首 / 中 / 末),核对值与预期一致(与 本地脚本 计算的预期值对照)。公式特定的"先验证模板再 copy_to_range / 修完再读回"细则见下方相关章节。 ## 新增列 / 新增行的样式继承(防止视觉风格不一致) @@ -17,7 +17,7 @@ 3. `cell_styles.number_format`(小数位 / 千分位 / 百分比 / 日期格式)—— 漏继承会导致同列数值格式混乱 4. `cell_styles.background_color`(背景色) 5. `border_styles`(四边框) -6. **`merged_cells`(合并范围)**——续写场景必查(高频致命错误):用 `+sheet-info --include merges` 读原数据区域的合并信息。**原行有跨列合并**(如标题行 `A1:G1` 合并)时,新行**必须**用 `+cells-{merge|unmerge}` 工具复制相同合并模式到新行(如续写第 3 个周报块的标题行 `A23:G23` 必须合并)。仅传 cells 数组的 5 类样式不够——合并范围要单独靠 `+cells-{merge|unmerge}` 工具落地(典型反例:续写多周记录表时,新增周次的标题行未合并,视觉上与原前几周风格不一致) +6. **`merged_cells`(合并范围)**——续写场景必查(高频致命错误):用 `+sheet-info --info_type=merged_cells_infos` 读原数据区域的合并信息。**原行有跨列合并**(如标题行 `A1:G1` 合并)时,新行**必须**用 `+cells-{merge|unmerge}` 工具复制相同合并模式到新行(如续写第 3 个周报块的标题行 `A23:G23` 必须合并)。仅传 cells 数组的 5 类样式不够——合并范围要单独靠 `+cells-{merge|unmerge}` 工具落地(典型反例:续写多周记录表时,新增周次的标题行未合并,视觉上与原前几周风格不一致) **采样模板的正确做法**: - 表头新列 → 读相邻表头单元格(如新加 D1 → 读 A1/B1/C1 任一) @@ -44,56 +44,59 @@ ## 使用场景 -写入。为一块单元格区域设置值、公式、批注/备注和/或格式。也支持通过 `rich_text` 中 `type: "embed-image"` 在单元格内嵌入图片(单元格图片)。关键:数组维度必须严格匹配——`cells` 二维数组必须与 `range` 的行列维度完全一致,range 是闭区间,否则会触发 `InvalidCellRangeError`。计算示例:区域 `A1:D3` = 3 行 × 4 列 = `[[r1c1,r1c2,r1c3,r1c4],[r2c1,r2c2,r2c3,r2c4],[r3c1,r3c2,r3c3,r3c4]]`;区域 `A41:N48` = 8 行 × 14 列 = 8 个数组且每个数组 14 个单元;单个单元格 `A1` = `[[cell]]`;单列区域 `B5:B7` = `[[cell1],[cell2],[cell3]]`。空单元请使用 `{}`。**如果填写的区域存在大量重复内容,务必优先使用 `--copy-to-range` 字段复制,可大幅减少 `cells` 长度。** +写入。为一块单元格区域设置值、公式、批注/备注和/或格式。也支持通过 `rich_text` 中 `type: "embed-image"` 在单元格内嵌入图片(单元格图片)。关键:数组维度必须严格匹配——`cells` 二维数组必须与 `range` 的行列维度完全一致,range 是闭区间,否则会触发 `InvalidCellRangeError`。计算示例:区域 `A1:D3` = 3 行 × 4 列 = `[[r1c1,r1c2,r1c3,r1c4],[r2c1,r2c2,r2c3,r2c4],[r3c1,r3c2,r3c3,r3c4]]`;区域 `A41:N48` = 8 行 × 14 列 = 8 个数组且每个数组 14 个单元;单个单元格 `A1` = `[[cell]]`;单列区域 `B5:B7` = `[[cell1],[cell2],[cell3]]`。空单元请使用 `{}`。**如果填写的区域存在大量重复内容,务必优先使用 `copy_to_range` 字段复制,可大幅减少 `cells` 长度。** > **单元格图片 vs 浮动图片**: > - **单元格图片**(本工具):图片嵌入在单元格内部,属于单元格内容,随单元格移动。通过 `rich_text` 中 `type: "embed-image"` 写入。 -> - **浮动图片**:图片悬浮在单元格上方,可自由定位和调整大小,不属于单元格内容。→ 使用 lark-sheets-float-image。 +> - **浮动图片**:图片悬浮在单元格上方,可自由定位和调整大小,不属于单元格内容。→ 使用 lark_sheet_float_image Skill。 高频模式(**必须遵守,禁止逐行写入替代**): -- 整列公式:先在 `H2` 写一个公式,再用 `--copy-to-range "H2:H100"` 或 `--copy-to-range "H:H"` 向下填充。**禁止对每一行单独调用 `+cells-set` 写入相同结构的公式** -- 整列格式:先在 `J1` 写一个带样式的模板单元格,再用 `--copy-to-range "J:J"` -- 首行样式:先在 `A1` 写一个模板单元格,再用 `--copy-to-range "1:1"` -- 用户说”这列 / 整列 / 这行 / 首行 / 向下复制”时,**必须**使用模板单元格 + `--copy-to-range` -- 多区域写入相同格式/公式结构时,优先写一个模板,再用 `--copy-to-range` 复制到所有目标区域 +- 整列公式:先在 `H2` 写一个公式,再用 `copy_to_range: "H2:H100"` 或 `copy_to_range: "H:H"` 向下填充。**禁止对每一行单独调用 `+cells-set` 写入相同结构的公式** +- 整列格式:先在 `J1` 写一个带样式的模板单元格,再用 `copy_to_range: "J:J"` +- 首行样式:先在 `A1` 写一个模板单元格,再用 `copy_to_range: "1:1"` +- 用户说”这列 / 整列 / 这行 / 首行 / 向下复制”时,**必须**使用模板单元格 + `copy_to_range` +- 多区域写入相同格式/公式结构时,优先写一个模板,再用 `copy_to_range` 复制到所有目标区域 -⚠️ **逐行写入公式是常见低效写法**:对每一行单独调用 `+cells-set` 写公式(如 26 次)既慢又易错,且不会自动平移公式引用。正确做法是 1 次模板写入 + 1 次 `--copy-to-range`(公式引用自动平移)。 +⚠️ **逐行写入公式是最常见的致命错误**:对每一行单独调用 `+cells-set` 写入公式(如调用 26 次),会快速耗尽轮次上限导致操作不完整。正确做法是 1 次模板写入 + 1 次 `copy_to_range` = 2 次调用完成。 -💡 **写入公式前先按迁移规则改写**:如果公式来自 Excel 或包含数组场景,先读取并遵循 `lark-sheets-formula-translation` 的规则完成改写,再把最终公式写入 `formula` 字段。 +💡 **写入公式前先按迁移规则改写**:如果公式来自 Excel 或包含数组场景,先读取并遵循 `lark-sheets-formula-translation` skill 的规则完成改写,再把最终公式写入 `formula` 字段。 💡 **内容与样式分离写入(推荐)**:当需要同时写入内容和样式时,`cells` 中每个单元格都带上 `cell_styles` / `border_styles` 会导致入参非常冗长。由于同一区域的样式通常高度重复(如整列统一背景色、统一边框),推荐拆成两步: 1. **先写内容**:`+cells-set` 只传 `value` / `formula`,不带样式,`cells` 入参精简 -2. **再批量刷样式**:对区域中的一个单元格写入目标样式作为模板,再用 `--copy-to-range` 将样式扩展到整列 / 整行 / 整个区域(`--copy-to-range` 会复制值、公式和样式,所以模板单元格应已包含正确的值) +2. **再批量刷样式**:对区域中的一个单元格写入目标样式作为模板,再用 `copy_to_range` 将样式扩展到整列 / 整行 / 整个区域(`copy_to_range` 会复制值、公式和样式,所以模板单元格应已包含正确的值) 示例:要对 A2:A100 写入数据并统一设置蓝色背景 + 边框: ``` Step 1: `+cells-set` — range="A2:A100", cells 只含 value(无样式,入参短) -Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styles(单个模板), --copy-to-range="A2:A100" +Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styles(单个模板), copy_to_range="A2:A100" ``` 这比在 99 个单元格中都重复写样式 JSON 高效得多。 -💡 **大批量数据分批写入(推荐)**:当需要写入大量行(如几十行以上)时,不要试图在一次调用中生成全部 `cells` 数据——`cells` 数组过大会让单次生成的内容过长,容易出错或被截断。应将数据拆分为多批,每批 20-50 行,分多次调用 `+cells-set` 逐批生成并写入(如先写 `A2:D21`,再写 `A22:D41`,依此类推)。每次只生成当前批次的数据,控制单次生成量。 +💡 **大批量数据分批写入(推荐)**:当需要写入大量行(如几十行以上)时,不要试图在一次调用中生成全部 `cells` 数据——`cells` 数组过大会导致模型生成内容过长而超时。应将数据拆分为多批,每批 20-50 行,分多次调用 `+cells-set` 逐批生成并写入(如先写 `A2:D21`,再写 `A22:D41`,依此类推)。每次调用只需生成当前批次的数据,控制单次生成量,避免超时。 注意: - 不要把 `cells` 写成字符串化 JSON -- `+cells-set` 默认即覆盖非空 cell(`--allow-overwrite` 默认 true);若要**保护**非空 cell 不被覆盖,显式传 `--allow-overwrite=false`(遇非空 cell 报错) +- 如果目标区域中已有值、公式或样式需要被覆盖,显式设置 `allow_overwrite=true` - 若目标区域涉及合并单元格,不要向合并区域中的非左上角单元格写入数据;如需写入,应改写合并区域左上角单元格,或先调整/取消合并区域 - **构造 `range` 时行号必须基于逻辑行号**:如果之前通过 `+csv-get` 读取了数据,CSV 中被双引号包裹的多行字段(如 `"2026年3月2日\n星期一"`)是**一个单元格**,不是两行。写入时的行号必须按逻辑记录计算,不能按物理换行符计数,否则 `range` 会整体偏移导致写入到错误位置 -> 用户说"样式和原表一致 / 保持原表格式 / 边框继承"时同理:`cell_styles` 只覆盖字体和对齐、**不含边框**,边框必须用独立 `border_styles` 字段传——完整继承清单见上方「新增列 / 新增行的样式继承」。 +⚠️ **"样式与原表一致"必须包含 `border_styles`(高频致命错误)**:当用户说"样式和原表一致"、"保持原表格式"、"边框继承"等要求时,cells 里的 `cell_styles` **不能只传 `font_size` / `horizontal_alignment` / `vertical_alignment`**——这几项只覆盖字体和对齐,**不包含边框**。边框必须用独立的 `border_styles` 字段传(或在源 cell 用 `+cells-get` 读出来再原样复制)。 +- **反模式**:`cells=[[{cell_styles:{font_size:16, horizontal_alignment:"center", vertical_alignment:"middle"}}]]`(字体+对齐都有,但**新 cell 仍然没边框**,视觉上与原表断裂) +- **正确做法**:`cell_styles` + `border_styles` 一起传,`border_styles` 覆盖 top/bottom/left/right 四条边(或至少 data 区该加的几条),确保视觉连续 +- 特别是**新列/新行**场景,新 cell 底子里本来就没边框,如果不显式传 `border_styles`,copy_to_range 复制的模板也没边框 → 整列/整行无边框 -⚠️ **公式写入必须自己校验结果(后端不会报语法错)**:`+cells-set` 写公式时,即便公式有括号不配对(如 `=IFERROR(VALUE(REGEXEXTRACT(D5, "\d+"))), 0)` 比 IFERROR 多一个 `)`)或用了飞书不支持的函数(如 `GOOGLETRANSLATE` / `CUBEVALUE`),**后端工具也会返回 `updated_cells_count=N, rc=0` 的"成功"**——错误会静默写进单元格显示为 `#VALUE!` / `#NAME?` / `#REF!`。因此: +⚠️ **公式写入必须自己校验结果(后端不会报语法错)**:`+cells-set` 写公式时,即便公式有括号不配对(如 `=IFERROR(VALUE(REGEXEXTRACT(D5, "\d+"))), 0)` 比 IFERROR 多一个 `)`)或函数名拼错(`=UNIQUE(...)` 飞书不支持),**后端工具也会返回 `updated_cells_count=N, rc=0` 的"成功"**——错误会静默写进单元格显示为 `#VALUE!` / `#NAME?` / `#REF!`。因此: 1. **写完立即读回**:`+cells-set` 后紧跟 `+csv-get`(或 `+cells-get`)读目标范围前几行,检查是否出现 `#VALUE!` / `#NAME?` / `#REF!` / `#N/A` / `#DIV/0!` / `#NUM!` -2. **看到 `#` 开头的错误值**立即修公式:`#NAME?` 多半是函数名拼错或用了飞书不支持的函数(如 `GOOGLETRANSLATE` / CUBE 系列;注意 `UNIQUE` / `FILTER` / `SPLIT` 飞书是支持的);`#VALUE!` 多半是类型不匹配或括号错位;`#REF!` 是引用错误;`~CIRCULAR~REF~` 是循环引用(公式引用了自身或会闭环) -3. **`--copy-to-range` 扩展前先验证模板**:模板单元格公式自己都算错,`--copy-to-range` 复制到 100 行就是 100 个错误 -4. **去重 / 筛选函数**:飞书**支持** `UNIQUE` / `FILTER` / `SPLIT`(原生数组函数,详见 `lark-sheets-formula-translation`),可直接用;`DISTINCT` 不是飞书函数,去重用 `UNIQUE`。大数据量去重 / 分组也可用透视表(`+pivot-{create|update|delete}`,值字段聚合方式选 count) +2. **看到 `#` 开头的错误值**立即修公式:`#NAME?` 多半是函数名拼错或飞书不支持(UNIQUE/DISTINCT 等);`#VALUE!` 多半是类型不匹配或括号错位;`#REF!` 是引用错误;`~CIRCULAR~REF~` 是循环引用(公式引用了自身或会闭环) +3. **`copy_to_range` 扩展前先验证模板**:模板单元格公式自己都算错,`copy_to_range` 复制到 100 行就是 100 个错误 +4. **飞书不支持的函数**:`UNIQUE` / `DISTINCT` / `FILTER`(部分)—— 对应"去重"场景改用透视表(`+pivot-{create|update|delete}`,值字段聚合方式选 count) 5. **循环引用预检(高频致命错误)**:写聚合公式(SUM / AVERAGE / COUNT 等)前必须明确**引用范围不包含目标单元格自身或其传递依赖**。典型反例:在 C3 写 `=SUMIF(B:B,LEFT(B3,9)&"*",C:C)`,B 列匹配 B3 前 9 位时 C3 自己也命中,导致 C3 自引用 → `~CIRCULAR~REF~`。修法:用辅助列 / 显式排除自身(`SUMIFS(C:C, B:B, ..., A:A, "<>"&A3)`)/ 缩小范围避开自己 -6. **REGEX 模式覆盖率验证**:公式里的 `REGEXEXTRACT` / `REGEXMATCH` / `REGEXREPLACE` 等正则模式落地前必须用本地脚本在源列上跑一遍命中率统计(`df[col].str.contains(pattern).mean()`);命中率 < 100% 时必须扩展 pattern 或加多分支(IFS / 多个 IFERROR 串联)兜底,**禁止**只覆盖样本前 N 行就交付(典型反例:用 `REGEXEXTRACT(D5,"长(\d+)")` 只匹配带"长"前缀的尺寸文本,对"宽×高"、"×"、"*"等其它分隔符直接漏匹配) +6. **REGEX 模式覆盖率验证**:公式里的 `REGEXEXTRACT` / `REGEXMATCH` / `REGEXREPLACE` 等正则模式落地前必须用 本地脚本 在源列上跑一遍命中率统计(`df[col].str.contains(pattern).mean()`);命中率 < 100% 时必须扩展 pattern 或加多分支(IFS / 多个 IFERROR 串联)兜底,**禁止**只覆盖样本前 N 行就交付(典型反例:用 `REGEXEXTRACT(D5,"长(\d+)")` 只匹配带"长"前缀的尺寸文本,对"宽×高"、"×"、"*"等其它分隔符直接漏匹配) 7. **公式范围与用户指令字面对齐**:用户说"对 F 至 L 列求和"就必须写 `SUM(F2:L2)` 或 `F2+G2+H2+I2+J2+K2+L2`,**不能漏列、多列、错列**。写完用 `+cells-get` 拿回 `formula` 字符串,与用户原话逐字对照(参与求和的列名一致 / 起止列号一致 / 运算符一致),不一致就是违规 -⚠️ **收到 `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)`),必须: +⚠️ **收到 `formula_errors` 反馈后不要只打补丁(高频致命错误)**:`+cells-set` 返回值里若出现 `formula_errors: [{cell, formula, error_type, detail}]`,说明某些 cell 公式编译失败(`error_type=compile_failed` 通常是函数语法错如 `SPLIT(x)[1]` 飞书不支持;`non_formula` 是 `=` 开头但解析不通过)。此时**禁止只聚焦修报错点的局部语法**(如仅把 `[1]` 换成 `INDEX(..,1)`),必须: 1. **重新审视整条公式的完整性**:被 formula_errors 标出的那一行,公式除了下标语法错,还可能有其他先天缺陷(字符清洗不全、IFERROR 兜底漏条件、引用列写错),修完语法错后立即整体复核 2. **同步对称修复所有相似列**:如果同一任务涉及多列相似处理(如"算 H 列面积"用 D 列尺寸、"算 I 列面积"用 E 列尺寸),**修完一列必须把同样的清洗/兜底逻辑同步到所有相似列**,禁止出现 H 列用 `SUBSTITUTE(长)+SUBSTITUTE(高)+SUBSTITUTE(×)` 而 I 列只用 `SUBSTITUTE(×)` 这种不对称处理——会导致一列编译通过有值、另一列编译通过但 IFERROR 全返回空,用户看到的是"数据为空"而非"公式错" @@ -106,7 +109,7 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl - **语义信号**(二选一):用户 prompt 含"合计/汇总/总计/统计/各科平均分/最下面加一行算…/底部总计"等意图词;或上下文明确是"表尾追加一行做聚合" - **结构信号**:新行全行都在做聚合(含 `=SUM/AVERAGE/COUNT/MAX/MIN/SUBTOTAL(...)`,支持 IFERROR 包裹),**不是**单个 cell 算个参考值或每行都算的派生列 -满足上述时,**不要在本 skill 里猜样式**,直接去读 `lark-sheets-visual-standards` 的「场景一 → 1A. 添加汇总行 / 表头行」章节,按那里的样式要点配齐 `font.bold / horizontal_alignment / background_color / border_styles`。 +满足上述时,**不要在本 skill 里猜样式**,直接去读 `lark-sheets-visual-standards` 的"汇总行规范"章节,按那里的规则配齐 `font.bold / horizontal_alignment / background_color / border_styles`。 反例(**不是**汇总行,禁止自动加粗): - 用户说"在 H5 帮我算个 AVERAGE 参考"→ 单 cell 计算 @@ -115,10 +118,35 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl **正确做法**(二选一): -- **做法 A(推荐)**:按上方「内容与样式分离写入」两步法——先用模板单元格 + `--copy-to-range` 铺**完整样式**(`cell_styles` + `border_styles` 都要,不能只铺 border,否则新行字体 / 对齐 / 背景色全裸奔),再单独 `+cells-set` 写 value / formula。汇总行的 `cell_styles` 要点(bold / 背景色 / 上边框)见 `lark-sheets-visual-standards` 的「场景一 → 1A. 添加汇总行 / 表头行」。 -- **做法 B**:一次写入,但每个 cell(含空白格)都显式带 `cell_styles` + `border_styles`,**不能用 `{}`**。 +**做法 A(推荐):两步走——先铺样式、再覆内容** -**判断是不是"新行"**:写入 range 超出 `+csv-get` 返回的 `current_region` 右 / 下边界(如 `current_region=A1:H10`、写 `A11:H11`)即新行,必须按上述做法补边框。 +``` +Step 1: 用模板单元格 + copy_to_range 铺"完整样式"(不是只铺 border)到新区域 + `+cells-set` — range="A11", cells=[[{ + border_styles: {...}, + cell_styles: { /* 按行性质填充:数据行继承数据区样式;汇总行见 lark_sheet_visual_standards */ } + }]], copy_to_range="A11:H11" + +Step 2: 再用 `+cells-set` 单独写具体 value/formula(不再传样式,避免覆盖) + `+cells-set` — range="A11", cells=[[{value: "平均分"}]] + `+cells-set` — range="C11:F11", cells=[[{formula: "=AVERAGE(C2:C10)"}, {formula: "=AVERAGE(D2:D10)"}, ...]] +``` + +⚠️ **Step 1 `cell_styles` 禁止留空**:只铺 border、不铺 `cell_styles`,等于新行从格式上"裸奔"——没字体、没对齐、没背景色。如果新行是汇总行,这意味着 bold 丢失,用户感受"没做样式"。Step 1 的 `cell_styles` 要么继承源区块(`+cells-get` 读相邻已有行样式后复用),要么按汇总行规范(见 `lark-sheets-visual-standards`)配齐。 + +**做法 B:一次写入但每个 cell 都显式带样式** + +``` +`+cells-set` — range="A11:H11", cells=[[ + {value: "平均分", cell_styles: {...}, border_styles: {...}}, + {value: "", cell_styles: {...}, border_styles: {...}}, ← B11 不能是 {},要显式带 border + {formula: "=AVERAGE(C2:C10)", cell_styles: {...}, border_styles: {...}}, + {formula: "=AVERAGE(D2:D10)", cell_styles: {...}, border_styles: {...}}, + ... +]] +``` + +**判断是不是"新行"**:`+csv-get` 返回的 `current_region` 是 `A1:H10`,你要写入的 range 是 `A11:H11`(超出 `current_region` 右/下边界),就是新行——必须按上述做法处理边框。 ## 工具选择 @@ -126,7 +154,7 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl | 场景 | 用这个 shortcut | 原因 | |------|----------------|------| -| 模型手里已经有 CSV 文本(小规模手动构造、从 `+csv-get` 取到后简单加工) | `+csv-put` | 直接传 CSV 文本 + `--start-cell`,不用自己拼二维 cells 数组;必要时自动扩容行列 | +| 模型手里已经有 CSV 文本(小规模手动构造、从 `+csv-get` 取到后简单加工) | `+csv-put` | 直接传 CSV 文本 + start_cell,不用自己拼二维 cells 数组;必要时自动扩容行列 | | 写入含公式、样式、批注、图片、数据校验等任意富写入 | `+cells-set` | 唯一支持完整字段的 shortcut | | 只改已有 cell 的样式,不动 value/formula | `+cells-set-style` | 拍平 10 个样式字段为独立 flag;不触发不必要的值写入 | | 单 cell 嵌入图片 | `+cells-set-image` | 比 `+cells-set` 参数更简短 | @@ -136,7 +164,7 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl ⚠️ `+csv-put` 只写纯值,**不会**携带公式/样式/批注/图片;公式字符串以 `=` 开头会被当作字面量文本落地。如果数据里需要公式或样式,**必须**用 `+cells-set`(或"写值 + 补样式"两步法)。 -⚠️ 大数据回写走"`+csv-get` 按 `--range` 行窗口分批读到本地 + 本地脚本处理 + `+csv-put` 分批回写"。 +⚠️ 大数据回写走"`+csv-get --max-rows N` 分批读到本地 + 本地脚本处理 + `+csv-put` 分批回写"。 ## Shortcuts @@ -159,7 +187,7 @@ _公共四件套 · 系统:`--dry-run`_ | `--range` | string | required | 写入区域(A1 格式) | | `--cells` | string + File + Stdin(复合 JSON) | required | JSON:2D 数组 `[[{cell},...],...]`,维度与 `--range` 完全一致;每个 cell 可含 `value` / `formula` / `cell_styles` / `note` / `rich_text`(含 `type="embed-image"` 单元格嵌图)等,完整字段跑 `--print-schema` | | `--allow-overwrite` | bool | optional | 允许覆盖非空 cell(默认 true);设为 false 时遇非空 cell 报错 | -| `--max-cells` | int | optional | 防爆,默认 50000(隐藏 flag:不在 `--help` 列出,但可正常传入) | +| `--max-cells` | int | optional | 防爆,默认 50000 | | `--copy-to-range` | string | optional | 复制范围(A1 表示法):把 --range 中 --cells 写入的内容(值/公式/样式,取决于实际传入字段)复制到该区域,公式引用自动平移(如 C2=B2 → C3=B3)。适合先写一行/一块模板再扩展填充整列/整区域(如 --range A1:G1 写模板、--copy-to-range A1:G100 填充 100 行)。支持整行 3:6、整列 C:E、到列尾 D3:D、到行尾 D3:3;支持英文逗号分隔多个目标区域,如 C1:D2,E5:F6 | ### `+cells-set-style` @@ -199,7 +227,9 @@ _公共四件套 · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--range` | string | required | 目标范围(A1 格式,如 `A2:A100`) | | `--options` | string + File + Stdin(复合 JSON) | required | 选项 JSON 数组 `["opt1","opt2"]`;最多 500 项,每项 ≤100 字符,不含逗号 | +| `--colors` | string + File + Stdin(简单 JSON) | optional | RGB hex 颜色数组(如 `["#1FB6C1","#F006C2"]`),长度必须与 `--options` 一致 | | `--multiple` | bool | optional | 启用多选;默认 `false` | +| `--highlight` | bool | optional | 选项配色显示;默认 `false` | ### `+csv-put` @@ -213,34 +243,34 @@ _公共四件套 · 系统:`--dry-run`_ ## Schemas -> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。 +> 复合 JSON flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag-name `。先 `--print-schema`(不带 `--flag-name`)会列出该 shortcut 所有可查询的 flag。 ### `+cells-set` `--cells` **顶层字段**: -- `value` (oneOf?) — 静态单元格值(文本、数字、布尔) +- `border_styles` (object?) — 单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top) { bottom?: object, left?: object, right?: object, top?: object } +- `cell_styles` (object?) — 单元格样式属性,包括字体、颜色、对齐方式和数字格式 { background_color?: string, font_color?: string, font_line?: enum, font_size?: number, font_style?: enum, …共 10 项 } +- `data_validation` (object?) — 数据验证配置 { enable_highlight?: boolean, help_text?: string, highlight_colors?: array, items?: array, operator?: enum, …共 9 项 } - `formula` (string?) — 以 '=' 开头的单元格公式(例如:'=SUM(A1:A10)') +- `multiple_values` (array?) — 多值内容,用于支持多选的列表验证单元格 each: { format?: string, value: oneOf } - `note` (string?) — 单元格批注/备注 -- `cell_styles` (object?) — 单元格样式属性,包括字体、颜色、对齐方式和数字格式 { font_color?: string, font_size?: number, font_weight?: enum, font_style?: enum, font_line?: enum, …共 10 项 } -- `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 } -- `data_validation` (object?) — 数据验证配置 { type: enum, items?: array, range?: string, operator?: enum, values?: array, …共 7 项 } +- `rich_text` (array?) — 富文本内容 each: { attachment_name?: string, attachment_token?: string, attachment_uri?: string, file_size?: number, image_height?: number, …共 17 项 } +- `value` (oneOf?) — 静态单元格值(文本、数字、布尔) ### `+cells-set-style` `--border-styles` _单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)_ **顶层字段**: -- `top` (object?) { style?: enum, weight?: enum, color?: string } -- `bottom` (object?) { style?: enum, weight?: enum, color?: string } -- `left` (object?) { style?: enum, weight?: enum, color?: string } -- `right` (object?) { style?: enum, weight?: enum, color?: string } +- `bottom` (object?) { color?: string, style?: enum, weight?: enum } +- `left` (object?) { color?: string, style?: enum, weight?: enum } +- `right` (object?) { color?: string, style?: enum, weight?: enum } +- `top` (object?) { color?: string, style?: enum, weight?: enum } ### `+dropdown-set` `--options` -_列表选项_ +_列表选项(type='list' 时必填)_ **数组项**(类型 string): - 标量:string @@ -265,9 +295,9 @@ _列表选项_ 示例: ```bash -# 纯值(数组形态);默认即覆盖非空 cell,无需显式传 --allow-overwrite +# 纯值(数组形态) lark-cli sheets +cells-set --url "https://example.feishu.cn/sheets/shtXXX" \ - --sheet-name "Sheet1" --range "A1:B2" \ + --sheet-name "Sheet1" --range "A1:B2" --allow-overwrite \ --cells '[[{"value":"name"},{"value":"score"}],[{"value":"alice"},{"value":95}]]' # 富 cell(公式 + 样式,cells 是二维矩阵每元素一个 cell schema) From 2ee2a59dffc9328f4893c93abab55f91dc94bff1 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Mon, 25 May 2026 11:24:53 +0800 Subject: [PATCH 047/114] chore(sheets): re-sync from spec + loosen --colors length check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catches up to sheet-skill-spec's 2026-05-25 base sync (MR !7) after rebasing onto upstream feat/lark-sheets-refactor (12 new upstream commits including the lark-sheets skill refactor + tools-schema migration). Spec changes flowing in: - highlight_colors description loosened: length may be **shorter than** --options (server cycles remaining slots through a built-in 10-color palette); previously the tool errored on any length mismatch. - shortcuts/sheets/data/flag-schemas.json: mass re-mirror — generator now emits `type` before `properties` and adds explicit `additionalProperties: false` on object schemas (cosmetic, no behavior change). - skills/lark-sheets/references/lark-sheets-{batch-update,chart,write-cells}.md: --options gains the type='list' tag; data_validation inline field-count goes 7 → 9 (catches up the highlight schema in the summary); chart position / size marked optional per upstream. Go-side adjustment: - buildDropdownValidation / validateDropdownOptionsColors: change the --colors length check from strict-equal to "must not exceed --options" to match the relaxed schema. - TestDropdownSet_ColorsLengthMismatch -> TestDropdownSet_ColorsLongerThanOptions (now hits the overflow path with 3 colors vs 2 options). - New TestDropdownSet_ColorsShorterAccepted: 2 colors vs 4 options is legal and forwarded as-is. --- shortcuts/sheets/data/flag-schemas.json | 7674 ++++++++--------- shortcuts/sheets/lark_sheet_write_cells.go | 15 +- .../sheets/lark_sheet_write_cells_test.go | 39 +- .../references/lark-sheets-batch-update.md | 2 +- .../references/lark-sheets-chart.md | 4 +- .../references/lark-sheets-write-cells.md | 112 +- 6 files changed, 3921 insertions(+), 3925 deletions(-) diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json index 3d377a1db..f7646942c 100644 --- a/shortcuts/sheets/data/flag-schemas.json +++ b/shortcuts/sheets/data/flag-schemas.json @@ -79,462 +79,393 @@ }, "+cells-batch-set-style": { "border-styles": { + "type": "object", "description": "单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)。", "properties": { - "bottom": { + "top": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } }, - "left": { + "bottom": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } }, - "right": { + "left": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } }, - "top": { + "right": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } } - }, - "type": "object" + } } }, "+cells-set": { "cells": { + "type": "object", "properties": { + "value": { + "description": "静态单元格值(文本、数字、布尔)。公式请优先使用 'formula' 字段;如果误把以 '=' 开头的公式字符串写到这里,工具会按 Excel 语义自动识别为公式入库,但仍应按 'formula' 字段的契约传参。", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + }, + "formula": { + "description": "以 '=' 开头的单元格公式(例如:'=SUM(A1:A10)')。公式必须写在此字段,而不是 'value'。", + "type": "string" + }, + "note": { + "description": "单元格批注/备注", + "type": "string" + }, + "cell_styles": { + "type": "object", + "properties": { + "font_color": { + "description": "字体颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, + "font_size": { + "description": "字体大小(单位:px/像素,例如 10、12、14)", + "type": "number" + }, + "font_weight": { + "description": "字重", + "type": "string", + "enum": [ + "normal", + "bold" + ] + }, + "font_style": { + "description": "字体样式", + "type": "string", + "enum": [ + "normal", + "italic" + ] + }, + "font_line": { + "description": "字体线条样式", + "type": "string", + "enum": [ + "none", + "underline", + "line-through" + ] + }, + "background_color": { + "description": "背景颜色(十六进制,例如 \"#ffffff\")", + "type": "string" + }, + "horizontal_alignment": { + "description": "水平对齐方式", + "type": "string", + "enum": [ + "left", + "center", + "right" + ] + }, + "vertical_alignment": { + "description": "垂直对齐方式", + "type": "string", + "enum": [ + "top", + "middle", + "bottom" + ] + }, + "number_format": { + "description": "数字格式(例如:文本用 \"@\"、数字用 \"0.00\"、货币用 \"$#,##0.00\"、日期用 \"mm/dd/yyyy\")", + "type": "string" + }, + "word_wrap": { + "description": "是否自动换行,默认溢出,可选自动换行或裁剪。", + "type": "string", + "enum": [ + "overflow", + "auto-wrap", + "word-clip" + ] + } + }, + "description": "单元格样式属性,包括字体、颜色、对齐方式和数字格式" + }, "border_styles": { + "type": "object", "description": "单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)。", "properties": { - "bottom": { + "top": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } }, - "left": { + "bottom": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } }, - "right": { + "left": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } }, - "top": { + "right": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } } - }, - "type": "object" - }, - "cell_styles": { - "description": "单元格样式属性,包括字体、颜色、对齐方式和数字格式", - "properties": { - "background_color": { - "description": "背景颜色(十六进制,例如 \"#ffffff\")", - "type": "string" - }, - "font_color": { - "description": "字体颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, - "font_line": { - "description": "字体线条样式", - "enum": [ - "none", - "underline", - "line-through" - ], - "type": "string" - }, - "font_size": { - "description": "字体大小(单位:px/像素,例如 10、12、14)", - "type": "number" - }, - "font_style": { - "description": "字体样式", - "enum": [ - "normal", - "italic" - ], - "type": "string" - }, - "font_weight": { - "description": "字重", - "enum": [ - "normal", - "bold" - ], - "type": "string" - }, - "horizontal_alignment": { - "description": "水平对齐方式", - "enum": [ - "left", - "center", - "right" - ], - "type": "string" - }, - "number_format": { - "description": "数字格式(例如:文本用 \"@\"、数字用 \"0.00\"、货币用 \"$#,##0.00\"、日期用 \"mm/dd/yyyy\")", - "type": "string" - }, - "vertical_alignment": { - "description": "垂直对齐方式", - "enum": [ - "top", - "middle", - "bottom" - ], - "type": "string" - }, - "word_wrap": { - "description": "是否自动换行,默认溢出,可选自动换行或裁剪。", - "enum": [ - "overflow", - "auto-wrap", - "word-clip" - ], - "type": "string" - } - }, - "type": "object" - }, - "data_validation": { - "description": "数据验证配置。设为 null 可清除已有的数据验证。", - "properties": { - "enable_highlight": { - "description": "是否开启下拉选项的胶囊背景色高亮,仅 type='list'/'listFromRange' 生效,默认 false。", - "type": "boolean" - }, - "help_text": { - "description": "验证失败时显示的提示文本", - "type": "string" - }, - "highlight_colors": { - "description": "下拉选项的胶囊背景色数组(十六进制,例如 [\"#FFE699\",\"#bff7d9\",\"#ffb3b3\"]),仅当 enable_highlight=true 时生效。如提供,长度必须严格匹配:type='list' 时等于 items 长度;type='listFromRange' 时等于 range 内单元格数(行优先顺序,如 A1:A10 对应第 1-10 项;A1:B2 顺序为 A1,B1,A2,B2)。长度不匹配工具会报错。不提供时所有选项使用默认色 #bacefd。", - "items": { - "type": "string" - }, - "type": "array" - }, - "items": { - "description": "列表选项(type='list' 时必填)", - "items": { - "type": "string" - }, - "type": "array" - }, - "operator": { - "description": "比较运算符(type='number'/'date'/'textLength' 时必填)", - "enum": [ - "equal", - "notEqual", - "greaterThan", - "greaterThanOrEqual", - "lessThan", - "lessThanOrEqual", - "between", - "notBetween" - ], - "type": "string" - }, - "range": { - "description": "源数据区域(type='listFromRange' 时必填,格式:'SheetName!A1:A10')", - "type": "string" - }, - "support_multiple_values": { - "description": "列表验证是否支持多选(type='list'/'listFromRange' 时可选,默认 false)", - "type": "boolean" - }, - "type": { - "description": "数据验证类型:list(下拉列表)、listFromRange(引用范围下拉列表)、number(数字)、date(日期)、textLength(文本长度)、checkbox(复选框)", - "enum": [ - "list", - "listFromRange", - "number", - "date", - "textLength", - "checkbox" - ], - "type": "string" - }, - "values": { - "description": "比较值(operator 为 'between'/'notBetween' 时需要两个值,其它运算符需要一个值)", - "items": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - }, - "type": "array" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - "formula": { - "description": "以 '=' 开头的单元格公式(例如:'=SUM(A1:A10)')。公式必须写在此字段,而不是 'value'。", - "type": "string" - }, - "multiple_values": { - "description": "多值内容,用于支持多选的列表验证单元格。设置后会忽略 value 和 rich_text 字段。", - "items": { - "properties": { - "format": { - "description": "可选的数字格式(例如 '$#,##0.00')", - "type": "string" - }, - "value": { - "description": "值(文本、数字、布尔)", - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ] - } - }, - "required": [ - "value" - ], - "type": "object" - }, - "type": "array" - }, - "note": { - "description": "单元格批注/备注", - "type": "string" + } }, "rich_text": { "description": "富文本内容。设置后会忽略 value 字段。可包含带样式的文本段 text、超链接 link、@提及 mention、单元格图片 embed-image、附件 attachment。", + "type": "array", "items": { + "type": "object", "properties": { - "attachment_name": { - "description": "附件文件名称(仅 type='attachment' 时使用,配合 attachment_reference_id 使用,创建新附件时必填)", + "type": { + "description": "段类型", + "type": "string", + "enum": [ + "text", + "link", + "mention", + "embed-image", + "attachment" + ] + }, + "text": { + "description": "显示文本", "type": "string" }, - "attachment_token": { - "description": "附件文件 token,通过飞书 Drive 上传获取(仅 type='attachment' 时可选,修改已有附件时可从 get_cell_range 获取)", + "style": { + "description": "文本段样式(仅 type='text' 时生效),结构同 cell_styles", + "type": "object" + }, + "link": { + "description": "超链接地址(仅 type='link' 时必填)", "type": "string" }, - "attachment_uri": { - "description": "附件文件 reference_id(仅 type='attachment' 时使用,与 attachment_token 二选一,如`<|attachment|>:abcdef` 或者 `<|superscript|>:abcdef-<|attachment|>:abcdef`,其中 `abcdef` 为实际的对象 ID,占位符仅用于示意)", + "mention_token": { + "description": "@提及目标的 token,如 userId 或 fileId(仅 type='mention' 时必填)", "type": "string" }, - "file_size": { - "description": "附件文件大小(字节,仅 type='attachment' 时使用)", + "mention_type": { + "description": "@提及类型编号(仅 type='mention' 时可选)", + "type": "number" + }, + "notify": { + "description": "是否发送通知(仅 type='mention' 时可选,默认 true)", + "type": "boolean" + }, + "image_width": { + "description": "图片宽度(像素,仅 type='embed-image' 时使用)", "type": "number" }, "image_height": { @@ -545,2275 +476,2405 @@ "description": "图片名称(仅 type='embed-image' 时使用,创建新图片时必填)", "type": "string" }, - "image_token": { - "description": "图片文件 token(仅 type='embed-image' 时可选,修改已有图片时可从 get_cell_range 获取)", - "type": "string" - }, "image_uri": { "description": "图片文件 reference_id(仅 type='embed-image' 时使用,与 image_token 二选一,如`<|image|>:abcdef` 或者 `<|superscript|>:abcdef-<|image|>:abcdef`,其中 `abcdef` 为实际的对象 ID,占位符仅用于示意)", "type": "string" }, - "image_width": { - "description": "图片宽度(像素,仅 type='embed-image' 时使用)", - "type": "number" + "image_token": { + "description": "图片文件 token(仅 type='embed-image' 时可选,修改已有图片时可从 get_cell_range 获取)", + "type": "string" }, - "link": { - "description": "超链接地址(仅 type='link' 时必填)", + "attachment_token": { + "description": "附件文件 token,通过飞书 Drive 上传获取(仅 type='attachment' 时可选,修改已有附件时可从 get_cell_range 获取)", "type": "string" }, - "mention_token": { - "description": "@提及目标的 token,如 userId 或 fileId(仅 type='mention' 时必填)", + "attachment_uri": { + "description": "附件文件 reference_id(仅 type='attachment' 时使用,与 attachment_token 二选一,如`<|attachment|>:abcdef` 或者 `<|superscript|>:abcdef-<|attachment|>:abcdef`,其中 `abcdef` 为实际的对象 ID,占位符仅用于示意)", "type": "string" }, - "mention_type": { - "description": "@提及类型编号(仅 type='mention' 时可选)", - "type": "number" + "attachment_name": { + "description": "附件文件名称(仅 type='attachment' 时使用,配合 attachment_reference_id 使用,创建新附件时必填)", + "type": "string" }, "mime_type": { "description": "附件 MIME 类型(仅 type='attachment' 时使用,例如 'application/pdf')", "type": "string" }, - "notify": { - "description": "是否发送通知(仅 type='mention' 时可选,默认 true)", - "type": "boolean" - }, - "style": { - "description": "文本段样式(仅 type='text' 时生效),结构同 cell_styles", - "type": "object" - }, - "text": { - "description": "显示文本", - "type": "string" - }, - "type": { - "description": "段类型", - "enum": [ - "text", - "link", - "mention", - "embed-image", - "attachment" - ], - "type": "string" + "file_size": { + "description": "附件文件大小(字节,仅 type='attachment' 时使用)", + "type": "number" } }, "required": [ "type", "text" - ], - "type": "object" - }, - "type": "array" + ] + } }, - "value": { - "description": "静态单元格值(文本、数字、布尔)。公式请优先使用 'formula' 字段;如果误把以 '=' 开头的公式字符串写到这里,工具会按 Excel 语义自动识别为公式入库,但仍应按 'formula' 字段的契约传参。", - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ] - } - }, - "type": "object" - } - }, - "+cells-set-style": { + "multiple_values": { + "description": "多值内容,用于支持多选的列表验证单元格。设置后会忽略 value 和 rich_text 字段。", + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "description": "值(文本、数字、布尔)", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + }, + "format": { + "description": "可选的数字格式(例如 '$#,##0.00')", + "type": "string" + } + }, + "required": [ + "value" + ] + } + }, + "data_validation": { + "description": "数据验证配置。设为 null 可清除已有的数据验证。", + "type": "object", + "properties": { + "type": { + "description": "数据验证类型:list(下拉列表)、listFromRange(引用范围下拉列表)、number(数字)、date(日期)、textLength(文本长度)、checkbox(复选框)", + "type": "string", + "enum": [ + "list", + "listFromRange", + "number", + "date", + "textLength", + "checkbox" + ] + }, + "items": { + "description": "列表选项(type='list' 时必填)", + "type": "array", + "items": { + "type": "string" + } + }, + "range": { + "description": "源数据区域(type='listFromRange' 时必填,格式:'SheetName!A1:A10')", + "type": "string" + }, + "operator": { + "description": "比较运算符(type='number'/'date'/'textLength' 时必填)", + "type": "string", + "enum": [ + "equal", + "notEqual", + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual", + "between", + "notBetween" + ] + }, + "values": { + "description": "比较值(operator 为 'between'/'notBetween' 时需要两个值,其它运算符需要一个值)", + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "support_multiple_values": { + "description": "列表验证是否支持多选(type='list'/'listFromRange' 时可选,默认 false)", + "type": "boolean" + }, + "help_text": { + "description": "验证失败时显示的提示文本", + "type": "string" + }, + "enable_highlight": { + "description": "是否开启下拉选项的胶囊背景色高亮,仅 type='list'/'listFromRange' 生效,默认 false。", + "type": "boolean" + }, + "highlight_colors": { + "description": "下拉选项的胶囊背景色数组(十六进制,例如 [\"#FFE699\",\"#bff7d9\",\"#ffb3b3\"]),仅当 enable_highlight=true 时生效。按顺序对应(type='list' 对应 items;type='listFromRange' 按 range 内单元格行优先顺序,如 A1:A10 对应第 1-10 项;A1:B2 顺序为 A1,B1,A2,B2)。长度可以短于但不能长于;未指定项及不提供该字段时按内置 10 色色板循环补色。", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ] + } + } + } + }, + "+cells-set-style": { "border-styles": { + "type": "object", "description": "单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)。", "properties": { - "bottom": { + "top": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } }, - "left": { + "bottom": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } }, - "right": { + "left": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } }, - "top": { + "right": { + "type": "object", "properties": { - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, "style": { "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", "enum": [ "solid", "dashed", "dotted", "double", "none" - ], - "type": "string" + ] }, "weight": { "description": "边框粗细/线宽", + "type": "string", "enum": [ "thin", "medium", "thick" - ], + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", "type": "string" } - }, - "type": "object" + } } - }, - "type": "object" + } } }, "+chart-create": { "properties": { - "additionalProperties": {}, "description": "创建/更新的图表属性。", + "type": "object", "properties": { - "offset": { - "additionalProperties": false, - "description": "可选。图表在位置基础上的偏移量(像素)。", - "properties": { - "col_offset": { - "description": "列偏移量(像素)", - "type": "number" - }, - "row_offset": { - "description": "行偏移量(像素)", - "type": "number" - } - }, - "type": "object" - }, "position": { - "additionalProperties": false, + "type": "object", "description": "必填。图表在表格中的单元格位置。注意:选择位置时应避免覆盖已有数据的单元格,并确保图表不超出当前表格的行列范围。", "properties": { - "col": { - "description": "列索引,例如 \"A\"、\"B\"", - "type": "string" - }, "row": { - "description": "行索引(0-based)", + "type": "number", "minimum": 0, - "type": "number" + "description": "行索引(0-based)" + }, + "col": { + "type": "string", + "description": "列索引,例如 \"A\"、\"B\"" } }, "required": [ "row", "col" ], - "type": "object" + "additionalProperties": false + }, + "offset": { + "type": "object", + "description": "可选。图表在位置基础上的偏移量(像素)。", + "properties": { + "row_offset": { + "type": "number", + "description": "行偏移量(像素)" + }, + "col_offset": { + "type": "number", + "description": "列偏移量(像素)" + } + }, + "additionalProperties": false }, "size": { - "additionalProperties": false, + "type": "object", "description": "必填。图表大小(像素)。注意:设定大小时应确保图表不超出当前表格的行列范围,并避免覆盖已有数据的单元格。", "properties": { - "height": { - "description": "高度(像素)", + "width": { + "type": "number", "minimum": 10, - "type": "number" + "description": "宽度(像素)" }, - "width": { - "description": "宽度(像素)", + "height": { + "type": "number", "minimum": 10, - "type": "number" + "description": "高度(像素)" } }, "required": [ "width", "height" ], - "type": "object" + "additionalProperties": false }, "snapshot": { + "type": "object", "description": "图表快照配置。更新图表时必须传入完整的图表属性定义,不能只传修改的部分。应先通过 get_chart_objects 获取当前图表快照,修改需要变更的字段后,将完整快照传入。", "properties": { - "data": { - "description": "图表数据配置", + "title": { + "type": "object", + "description": "图表标题配置", "properties": { - "dim1": { - "description": "维度1配置(类别维度)", - "properties": { - "field": { - "description": "字段配置(静态数据时传此参数)", - "properties": { - "name": { - "description": "字段名称", - "type": "string" - }, - "text": { - "description": "字段文本数据", - "type": "string" - }, - "valueType": { - "description": "值类型", - "enum": [ - "number", - "string" - ], - "type": "string" - } - }, - "required": [ - "text" - ], - "type": "object" - }, - "serie": { - "description": "系列配置(非静态数据时传此参数)", - "properties": { - "aggregate": { - "description": "是否汇总相同的类别,默认为true,true 情况下 aggregateType 才生效", - "type": "boolean" - }, - "index": { - "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引", - "type": "number" - }, - "nameRef": { - "description": "可选。维度名称的单元格引用(A1 表示法,如 'Sheet1!A1')。当维度名称不直接来自 refs 首行/首列时,可通过此字段把维度名显式指向目标表头单元格,图表会以该单元格当前值作为类别维度名展示。", - "type": "string" - } - }, - "type": "object" - } - }, - "type": "object" + "text": { + "type": "string", + "description": "标题文本" }, - "dim2": { - "description": "维度2配置(值维度)", - "properties": { - "fields": { - "description": "字段配置数组(静态数据时传此参数)", - "items": { - "properties": { - "name": { - "description": "字段名称", - "type": "string" - }, - "text": { - "description": "字段文本数据", - "type": "string" - }, - "valueType": { - "description": "值类型", - "enum": [ - "number" - ], - "type": "string" - } - }, - "required": [ - "text" - ], - "type": "object" - }, - "type": "array" - }, - "series": { - "description": "系列配置数组(非静态数据时传此参数)", - "items": { - "properties": { - "aggregateType": { - "description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效", - "enum": [ - "sum", - "average", - "count", - "min", - "max", - "median" - ], - "type": "string" - }, - "index": { - "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引", - "type": "number" - }, - "nameRef": { - "description": "可选。系列名称的单元格引用(A1 表示法,如 'Sheet1!C1')。当系列名称不直接来自 refs 首行/首列时,可通过此字段把系列名显式指向目标表头单元格,图表会以该单元格当前值作为系列名(图例/tooltip)展示。", - "type": "string" - } - }, - "type": "object" - }, - "type": "array" - } - }, - "type": "object" - }, - "direction": { - "description": "数据方向,可选值:row(行方向)或column(列方向),常用值为column", + "textAlign": { + "type": "string", + "description": "标题对齐方式", "enum": [ - "row", - "column" - ], - "type": "string" + "left", + "center", + "right" + ] }, - "headerMode": { - "description": "表头模式。inline 表示 refs 首行/首列就是表头;detached 表示 refs 仅覆盖实际数据范围,维度名和系列名需通过 nameRef 显式指定。", + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" + } + }, + "required": [ + "text" + ] + }, + "subTitle": { + "type": "object", + "description": "图表副标题配置", + "properties": { + "text": { + "type": "string", + "description": "副标题文本" + }, + "textAlign": { + "type": "string", + "description": "副标题对齐方式", "enum": [ - "inline", - "detached" - ], - "type": "string" + "left", + "center", + "right" + ] }, - "includeHiddenOrFilter": { - "description": "是否包含隐藏或过滤的数据", - "type": "boolean" + "fontSize": { + "type": "number", + "description": "字体大小" }, - "isStaticData": { - "description": "是否为静态数据", - "type": "boolean" + "bold": { + "type": "boolean", + "description": "是否加粗" }, - "refs": { - "description": "数据源引用范围数组", - "items": { - "properties": { - "value": { - "description": "图表数据源范围,格式为 'SheetName!A1:D100' 的连续区域", - "type": "string" - } - }, - "required": [ - "value" - ], - "type": "object" - }, - "type": "array" + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" } }, - "type": "object" + "required": [ + "text" + ] }, - "legend": { - "oneOf": [ - { - "description": "图例配置", + "style": { + "type": "object", + "description": "图表样式配置", + "properties": { + "background": { + "type": "object", + "description": "背景配置", "properties": { - "bold": { - "description": "是否加粗", - "type": "boolean" + "color": { + "type": "string", + "description": "背景颜色,格式为 #RRGGBB" + } + } + }, + "font": { + "type": "object", + "description": "字体配置", + "properties": { + "size": { + "type": "number", + "description": "字体大小" }, "color": { - "description": "字体颜色,格式为 #RRGGBB", - "type": "string" + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" + } + } + }, + "border": { + "type": "object", + "description": "边框配置", + "properties": { + "color": { + "type": "string", + "description": "边框颜色,格式为 #RRGGBB" }, - "fontSize": { - "description": "字体大小", - "type": "number" + "width": { + "type": "number", + "description": "边框宽度" }, - "italic": { - "description": "是否斜体", - "type": "boolean" + "style": { + "type": "string", + "description": "边框样式", + "enum": [ + "solid", + "dashed", + "dotted" + ] + }, + "radius": { + "type": "number", + "description": "边框圆角" + } + } + }, + "colorTheme": { + "type": "array", + "description": "颜色主题配置。支持两种模式:1. 仅传一个预设主题名称(字符串数组,长度为1);2. 传多个自定义十六进制颜色字符串。预设主题:brandColorSeries@v2、rainbowColorSeries@v2、complementaryColorSeries@v2、converseColorSeries@v2、primaryColorSeries@v2、singleColorSeries-B-@v2、singleColorSeries-W-@v2、singleColorSeries-G-@v2、singleColorSeries-Y-@v2、singleColorSeries-O-@v2、singleColorSeries-R-@v2、singleColorSeries-D-@v2。十六进制格式:#RRGGBB", + "oneOf": [ + { + "minItems": 1, + "maxItems": 1, + "items": { + "type": "string", + "enum": [ + "brandColorSeries@v2", + "rainbowColorSeries@v2", + "complementaryColorSeries@v2", + "converseColorSeries@v2", + "primaryColorSeries@v2", + "singleColorSeries-B-@v2", + "singleColorSeries-W-@v2", + "singleColorSeries-G-@v2", + "singleColorSeries-Y-@v2", + "singleColorSeries-O-@v2", + "singleColorSeries-R-@v2", + "singleColorSeries-D-@v2" + ] + } }, + { + "minItems": 2, + "items": { + "type": "string", + "description": "颜色字符串,十六进制格式:#RRGGBB" + } + } + ] + }, + "colorGradient": { + "type": "boolean", + "description": "是否启用颜色渐变" + } + } + }, + "legend": { + "oneOf": [ + { + "type": "object", + "description": "图例配置", + "properties": { "position": { + "type": "string", "description": "图例位置", "enum": [ "top", "bottom", "left", "right" - ], - "type": "string" + ] }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" }, "underline": { - "description": "是否下划线", - "type": "boolean" + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" } - }, - "type": "object" + } }, { - "description": "false 表示隐藏图例", - "type": "boolean" + "type": "boolean", + "description": "false 表示隐藏图例" } ] }, "plotArea": { + "type": "object", "description": "绘图区域配置", "properties": { - "axes": { - "description": "坐标轴配置数组", - "items": { - "description": "坐标轴配置", - "properties": { - "axisLine": { - "description": "是否显示轴线", - "type": "boolean" - }, - "gridLine": { - "oneOf": [ - { - "description": "网格线配置", - "properties": { - "color": { - "description": "网格线颜色", - "type": "string" - }, - "width": { - "description": "网格线宽度", - "type": "number" - } - }, - "type": "object" - }, - { - "description": "false 表示隐藏网格线", - "type": "boolean" - } - ] - }, - "label": { - "description": "坐标轴标签配置", - "properties": { - "angle": { - "description": "旋转角度,可选值:-90, -45, 0, 45, 90", - "type": "number" - }, - "bold": { - "description": "是否加粗", - "type": "boolean" - }, - "color": { - "description": "字体颜色", - "type": "string" - }, - "fontSize": { - "description": "字体大小", - "type": "number" - }, - "italic": { - "description": "是否斜体", - "type": "boolean" - }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" - }, - "underline": { - "description": "是否下划线", - "type": "boolean" - } - }, - "type": "object" - }, - "max": { - "description": "最大值", - "type": "number" - }, - "min": { - "description": "最小值", - "type": "number" - }, - "position": { - "description": "坐标轴位置", - "enum": [ - "left", - "right", - "bottom" - ], - "type": "string" - }, - "title": { - "description": "坐标轴标题配置", - "properties": { - "bold": { - "description": "是否加粗", - "type": "boolean" - }, - "color": { - "description": "字体颜色", - "type": "string" - }, - "fontSize": { - "description": "字体大小", - "type": "number" - }, - "italic": { - "description": "是否斜体", - "type": "boolean" - }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" - }, - "text": { - "description": "标题文本", - "type": "string" - }, - "underline": { - "description": "是否下划线", - "type": "boolean" - } - }, - "required": [ - "text" - ], - "type": "object" - }, - "type": { - "description": "坐标轴类型", - "enum": [ - "x", - "y", - "angle", - "radius" - ], - "type": "string" - }, - "valueType": { - "description": "是否将横轴数字视为文本,linear 表示将数字视为数值, ordinal 表示将数字视为文本", - "enum": [ - "ordinal", - "linear" - ], - "type": "string" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - "type": "array" - }, "plot": { + "type": "object", "description": "绘图配置", "properties": { - "areas": { - "description": "全系列面积填充配置,面积图、雷达图、组合图生效。", - "properties": { - "color": { - "description": "区域填充颜色", - "type": "string" - } - }, - "type": "object" - }, - "bars": { - "description": "全系列柱状图、条形图、组合图生效。", - "properties": { - "backgroundColor": { - "description": "背景颜色", - "type": "string" - }, - "bar": { - "description": "单个柱子配置数组", - "items": { - "properties": { - "borderColor": { - "description": "边框颜色", - "type": "string" - }, - "borderStyle": { - "description": "边框样式", - "type": "string" - }, - "borderWidth": { - "description": "边框宽度", - "type": "number" - }, - "color": { - "description": "颜色", - "type": "string" - }, - "index": { - "description": "柱子索引", - "type": "number" - } - }, - "required": [ - "index" - ], - "type": "object" - }, - "type": "array" - }, - "borderColor": { - "description": "边框颜色", - "type": "string" - }, - "borderStyle": { - "description": "边框样式", - "enum": [ - "solid", - "dashed", - "dotted" - ], - "type": "string" - }, - "borderWidth": { - "description": "边框宽度", - "type": "number" - }, - "color": { - "description": "柱子颜色", - "type": "string" - }, - "gap": { - "description": "柱子间距比例,0-1之间", - "type": "number" - }, - "width": { - "description": "柱子宽度", - "type": "number" - } - }, - "type": "object" + "type": { + "type": "string", + "description": "图表类型", + "enum": [ + "bar", + "column", + "line", + "area", + "combo", + "pie", + "radar", + "scatter" + ] }, "comboType": { + "type": "string", "description": "组合图表默认类型", "enum": [ "column", "line", "area" - ], - "type": "string" + ] + }, + "yAxisPosition": { + "type": "string", + "description": "Y轴位置", + "enum": [ + "left", + "right" + ] }, "extra": { + "type": "object", "description": "额外配置", "properties": { + "smooth": { + "type": "boolean", + "description": "是否平滑曲线" + }, + "step": { + "type": "boolean", + "description": "是否阶梯图" + }, + "stack": { + "type": "object", + "description": "堆叠配置", + "properties": { + "percentage": { + "type": "boolean", + "description": "是否百分比堆叠" + } + } + }, "radar": { + "type": "object", "description": "雷达图配置", "properties": { - "area": { - "description": "是否填充区域", - "type": "boolean" - }, "shape": { + "type": "string", "description": "雷达图形状", "enum": [ "polygon", "circle" - ], - "type": "string" - } - }, - "type": "object" - }, - "smooth": { - "description": "是否平滑曲线", - "type": "boolean" - }, - "stack": { - "description": "堆叠配置", - "properties": { - "percentage": { - "description": "是否百分比堆叠", - "type": "boolean" + ] + }, + "area": { + "type": "boolean", + "description": "是否填充区域" } - }, - "type": "object" - }, - "step": { - "description": "是否阶梯图", - "type": "boolean" + } } - }, - "type": "object" + } }, - "labels": { - "description": "数据标签配置", + "points": { + "type": "object", + "description": "全系列数据点配置,折线图、面积图、雷达图、散点图、组合图生效。", "properties": { - "bold": { - "description": "是否加粗", - "type": "boolean" - }, - "category": { - "description": "是否显示类别名", - "type": "boolean" - }, "color": { - "description": "字体颜色", - "type": "string" - }, - "fontSize": { - "description": "字体大小", - "type": "number" - }, - "italic": { - "description": "是否斜体", - "type": "boolean" - }, - "percentage": { - "description": "是否显示百分比", - "type": "boolean" + "type": "string", + "description": "数据点颜色" }, - "position": { - "description": "标签位置", + "shape": { + "type": "string", + "description": "数据点形状", "enum": [ - "auto", - "top", - "bottom", - "left", - "right", - "center", - "inside", - "outside" - ], - "type": "string" - }, - "series": { - "description": "是否显示系列名", - "type": "boolean" - }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" + "circle", + "triangle", + "rect", + "diamond", + "square" + ] }, - "underline": { - "description": "是否下划线", - "type": "boolean" + "size": { + "type": "number", + "description": "数据点大小" }, - "value": { - "description": "是否显示值", - "type": "boolean" + "point": { + "type": "array", + "description": "单个数据点配置数组", + "items": { + "type": "object", + "properties": { + "index": { + "type": "number", + "description": "数据点索引" + }, + "color": { + "type": "string", + "description": "颜色" + }, + "shape": { + "type": "string", + "description": "形状" + }, + "size": { + "type": "number", + "description": "大小" + } + }, + "required": [ + "index" + ] + } } - }, - "type": "object" + } }, "lines": { + "type": "object", "description": "全系列线条配置,折线图、面积图、雷达图、组合图生效。", "properties": { "color": { - "description": "线条颜色", - "type": "string" + "type": "string", + "description": "线条颜色" + }, + "width": { + "type": "number", + "description": "线条宽度" + }, + "style": { + "type": "string", + "description": "线条样式", + "enum": [ + "solid", + "dashed", + "dotted" + ] }, "invalidType": { + "type": "string", "description": "无效值处理方式", "enum": [ "break", "zero", "link" - ], - "type": "string" + ] + } + } + }, + "areas": { + "type": "object", + "description": "全系列面积填充配置,面积图、雷达图、组合图生效。", + "properties": { + "color": { + "type": "string", + "description": "区域填充颜色" + } + } + }, + "bars": { + "type": "object", + "description": "全系列柱状图、条形图、组合图生效。", + "properties": { + "color": { + "type": "string", + "description": "柱子颜色" }, - "style": { - "description": "线条样式", + "borderColor": { + "type": "string", + "description": "边框颜色" + }, + "borderWidth": { + "type": "number", + "description": "边框宽度" + }, + "borderStyle": { + "type": "string", + "description": "边框样式", "enum": [ "solid", "dashed", "dotted" - ], - "type": "string" + ] }, "width": { - "description": "线条宽度", - "type": "number" - } - }, - "type": "object" - }, - "points": { - "description": "全系列数据点配置,折线图、面积图、雷达图、散点图、组合图生效。", - "properties": { - "color": { - "description": "数据点颜色", - "type": "string" + "type": "number", + "description": "柱子宽度" }, - "point": { - "description": "单个数据点配置数组", + "gap": { + "type": "number", + "description": "柱子间距比例,0-1之间" + }, + "backgroundColor": { + "type": "string", + "description": "背景颜色" + }, + "bar": { + "type": "array", + "description": "单个柱子配置数组", "items": { + "type": "object", "properties": { - "color": { - "description": "颜色", - "type": "string" - }, "index": { - "description": "数据点索引", - "type": "number" + "type": "number", + "description": "柱子索引" }, - "shape": { - "description": "形状", - "type": "string" + "color": { + "type": "string", + "description": "颜色" }, - "size": { - "description": "大小", - "type": "number" + "borderColor": { + "type": "string", + "description": "边框颜色" + }, + "borderWidth": { + "type": "number", + "description": "边框宽度" + }, + "borderStyle": { + "type": "string", + "description": "边框样式" } }, "required": [ "index" - ], - "type": "object" - }, - "type": "array" - }, - "shape": { - "description": "数据点形状", + ] + } + } + } + }, + "labels": { + "type": "object", + "description": "数据标签配置", + "properties": { + "position": { + "type": "string", + "description": "标签位置", "enum": [ - "circle", - "triangle", - "rect", - "diamond", - "square" - ], - "type": "string" + "auto", + "top", + "bottom", + "left", + "right", + "center", + "inside", + "outside" + ] }, - "size": { - "description": "数据点大小", - "type": "number" + "series": { + "type": "boolean", + "description": "是否显示系列名" + }, + "category": { + "type": "boolean", + "description": "是否显示类别名" + }, + "value": { + "type": "boolean", + "description": "是否显示值" + }, + "percentage": { + "type": "boolean", + "description": "是否显示百分比" + }, + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色" } - }, - "type": "object" + } }, "series": { + "type": "array", "description": "单个系列配置数组", "items": { + "type": "object", "description": "系列配置", "properties": { - "area": { - "description": "区域填充配置,配置项同 plotArea.areas", - "type": "object" - }, - "bars": { - "description": "柱状图配置,配置项同 plotArea.bars", - "type": "object" + "index": { + "type": "number", + "description": "数据列索引,与 dim2.series[].index 值一致,从 1 开始(1 通常对应首列标签维度,2+ 对应后续数据维度列)" }, "comboType": { + "type": "string", "description": "组合图下该系列的图表类型,仅在 type 为 combo 时生效", "enum": [ "column", "line", "area" - ], - "type": "string" + ] }, - "index": { - "description": "数据列索引,与 dim2.series[].index 值一致,从 1 开始(1 通常对应首列标签维度,2+ 对应后续数据维度列)", - "type": "number" + "yAxisPosition": { + "type": "string", + "description": "Y轴位置", + "enum": [ + "left", + "right" + ] }, - "labels": { - "description": "数据标签配置", - "type": "object" + "points": { + "type": "object", + "description": "数据点配置,配置项同 plotArea.points" }, "line": { - "description": "线条配置,配置项同 plotArea.lines", - "type": "object" + "type": "object", + "description": "线条配置,配置项同 plotArea.lines" }, - "points": { - "description": "数据点配置,配置项同 plotArea.points", - "type": "object" + "area": { + "type": "object", + "description": "区域填充配置,配置项同 plotArea.areas" + }, + "bars": { + "type": "object", + "description": "柱状图配置,配置项同 plotArea.bars" + }, + "labels": { + "type": "object", + "description": "数据标签配置" }, "sectors": { + "type": "object", "description": "扇区配置(饼图)", "properties": { "borderColor": { - "description": "边框颜色", - "type": "string" + "type": "string", + "description": "边框颜色" }, "innerRadius": { - "description": "内半径比例,0-1之间", - "type": "number" + "type": "number", + "description": "内半径比例,0-1之间" }, "offsetRadius": { - "description": "偏移半径比例", - "type": "number" + "type": "number", + "description": "偏移半径比例" + }, + "startAngle": { + "type": "number", + "description": "起始角度,0-359" }, "sector": { + "type": "array", "description": "单个扇区配置数组", "items": { + "type": "object", "properties": { - "borderColor": { - "description": "边框颜色", - "type": "string" - }, - "color": { - "description": "颜色", - "type": "string" - }, "index": { - "description": "扇区索引", - "type": "number" + "type": "number", + "description": "扇区索引" + }, + "borderColor": { + "type": "string", + "description": "边框颜色" }, "offsetRadius": { - "description": "偏移半径", - "type": "number" + "type": "number", + "description": "偏移半径" + }, + "color": { + "type": "string", + "description": "颜色" } }, "required": [ "index" - ], - "type": "object" - }, - "type": "array" - }, - "startAngle": { - "description": "起始角度,0-359", - "type": "number" + ] + } } - }, - "type": "object" - }, - "yAxisPosition": { - "description": "Y轴位置", - "enum": [ - "left", - "right" - ], - "type": "string" + } } }, "required": [ "index" - ], - "type": "object" - }, - "type": "array" - }, - "type": { - "description": "图表类型", - "enum": [ - "bar", - "column", - "line", - "area", - "combo", - "pie", - "radar", - "scatter" - ], - "type": "string" - }, - "yAxisPosition": { - "description": "Y轴位置", - "enum": [ - "left", - "right" - ], - "type": "string" + ] + } } }, "required": [ "type" - ], - "type": "object" - } - }, - "type": "object" - }, - "style": { - "description": "图表样式配置", - "properties": { - "background": { - "description": "背景配置", - "properties": { - "color": { - "description": "背景颜色,格式为 #RRGGBB", - "type": "string" - } - }, - "type": "object" - }, - "border": { - "description": "边框配置", - "properties": { - "color": { - "description": "边框颜色,格式为 #RRGGBB", - "type": "string" - }, - "radius": { - "description": "边框圆角", - "type": "number" - }, - "style": { - "description": "边框样式", - "enum": [ - "solid", - "dashed", - "dotted" - ], - "type": "string" - }, - "width": { - "description": "边框宽度", - "type": "number" - } - }, - "type": "object" - }, - "colorGradient": { - "description": "是否启用颜色渐变", - "type": "boolean" + ] }, - "colorTheme": { - "description": "颜色主题配置。支持两种模式:1. 仅传一个预设主题名称(字符串数组,长度为1);2. 传多个自定义十六进制颜色字符串。预设主题:brandColorSeries@v2、rainbowColorSeries@v2、complementaryColorSeries@v2、converseColorSeries@v2、primaryColorSeries@v2、singleColorSeries-B-@v2、singleColorSeries-W-@v2、singleColorSeries-G-@v2、singleColorSeries-Y-@v2、singleColorSeries-O-@v2、singleColorSeries-R-@v2、singleColorSeries-D-@v2。十六进制格式:#RRGGBB", - "oneOf": [ - { - "items": { + "axes": { + "type": "array", + "description": "坐标轴配置数组", + "items": { + "type": "object", + "description": "坐标轴配置", + "properties": { + "type": { + "type": "string", + "description": "坐标轴类型", "enum": [ - "brandColorSeries@v2", - "rainbowColorSeries@v2", - "complementaryColorSeries@v2", - "converseColorSeries@v2", - "primaryColorSeries@v2", - "singleColorSeries-B-@v2", - "singleColorSeries-W-@v2", - "singleColorSeries-G-@v2", - "singleColorSeries-Y-@v2", - "singleColorSeries-O-@v2", - "singleColorSeries-R-@v2", - "singleColorSeries-D-@v2" - ], - "type": "string" + "x", + "y", + "angle", + "radius" + ] }, - "maxItems": 1, - "minItems": 1 - }, - { - "items": { - "description": "颜色字符串,十六进制格式:#RRGGBB", - "type": "string" + "position": { + "type": "string", + "description": "坐标轴位置", + "enum": [ + "left", + "right", + "bottom" + ] }, - "minItems": 2 - } - ], - "type": "array" - }, - "font": { - "description": "字体配置", - "properties": { - "color": { - "description": "字体颜色,格式为 #RRGGBB", - "type": "string" - }, - "size": { - "description": "字体大小", - "type": "number" - } - }, - "type": "object" - } - }, - "type": "object" - }, - "subTitle": { - "description": "图表副标题配置", - "properties": { - "bold": { - "description": "是否加粗", - "type": "boolean" - }, - "color": { - "description": "字体颜色,格式为 #RRGGBB", - "type": "string" - }, - "fontSize": { - "description": "字体大小", - "type": "number" - }, - "italic": { - "description": "是否斜体", - "type": "boolean" - }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" - }, - "text": { - "description": "副标题文本", - "type": "string" - }, - "textAlign": { - "description": "副标题对齐方式", - "enum": [ - "left", - "center", - "right" - ], - "type": "string" - }, - "underline": { - "description": "是否下划线", - "type": "boolean" - } - }, - "required": [ - "text" - ], - "type": "object" + "max": { + "type": "number", + "description": "最大值" + }, + "min": { + "type": "number", + "description": "最小值" + }, + "valueType": { + "type": "string", + "description": "是否将横轴数字视为文本,linear 表示将数字视为数值, ordinal 表示将数字视为文本", + "enum": [ + "ordinal", + "linear" + ] + }, + "title": { + "type": "object", + "description": "坐标轴标题配置", + "properties": { + "text": { + "type": "string", + "description": "标题文本" + }, + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色" + } + }, + "required": [ + "text" + ] + }, + "label": { + "type": "object", + "description": "坐标轴标签配置", + "properties": { + "angle": { + "type": "number", + "description": "旋转角度,可选值:-90, -45, 0, 45, 90" + }, + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色" + } + } + }, + "axisLine": { + "type": "boolean", + "description": "是否显示轴线" + }, + "gridLine": { + "oneOf": [ + { + "type": "object", + "description": "网格线配置", + "properties": { + "width": { + "type": "number", + "description": "网格线宽度" + }, + "color": { + "type": "string", + "description": "网格线颜色" + } + } + }, + { + "type": "boolean", + "description": "false 表示隐藏网格线" + } + ] + } + }, + "required": [ + "type" + ] + } + } + } }, - "title": { - "description": "图表标题配置", + "data": { + "type": "object", + "description": "图表数据配置", "properties": { - "bold": { - "description": "是否加粗", - "type": "boolean" - }, - "color": { - "description": "字体颜色,格式为 #RRGGBB", - "type": "string" + "isStaticData": { + "type": "boolean", + "description": "是否为静态数据" }, - "fontSize": { - "description": "字体大小", - "type": "number" + "includeHiddenOrFilter": { + "type": "boolean", + "description": "是否包含隐藏或过滤的数据" }, - "italic": { - "description": "是否斜体", - "type": "boolean" + "direction": { + "type": "string", + "description": "数据方向,可选值:row(行方向)或column(列方向),常用值为column", + "enum": [ + "row", + "column" + ] }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" + "headerMode": { + "type": "string", + "description": "表头模式。inline 表示 refs 首行/首列就是表头;detached 表示 refs 仅覆盖实际数据范围,维度名和系列名需通过 nameRef 显式指定。", + "enum": [ + "inline", + "detached" + ] }, - "text": { - "description": "标题文本", - "type": "string" + "refs": { + "type": "array", + "description": "数据源引用范围数组", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "图表数据源范围,格式为 'SheetName!A1:D100' 的连续区域" + } + }, + "required": [ + "value" + ] + } }, - "textAlign": { - "description": "标题对齐方式", - "enum": [ - "left", - "center", - "right" - ], - "type": "string" + "dim1": { + "type": "object", + "description": "维度1配置(类别维度)", + "properties": { + "serie": { + "type": "object", + "description": "系列配置(非静态数据时传此参数)", + "properties": { + "index": { + "type": "number", + "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引" + }, + "aggregate": { + "type": "boolean", + "description": "是否汇总相同的类别,默认为true,true 情况下 aggregateType 才生效" + }, + "nameRef": { + "type": "string", + "description": "可选。维度名称的单元格引用(A1 表示法,如 'Sheet1!A1')。当维度名称不直接来自 refs 首行/首列时,可通过此字段把维度名显式指向目标表头单元格,图表会以该单元格当前值作为类别维度名展示。" + } + } + }, + "field": { + "type": "object", + "description": "字段配置(静态数据时传此参数)", + "properties": { + "valueType": { + "type": "string", + "description": "值类型", + "enum": [ + "number", + "string" + ] + }, + "name": { + "type": "string", + "description": "字段名称" + }, + "text": { + "type": "string", + "description": "字段文本数据" + } + }, + "required": [ + "text" + ] + } + } }, - "underline": { - "description": "是否下划线", - "type": "boolean" + "dim2": { + "type": "object", + "description": "维度2配置(值维度)", + "properties": { + "series": { + "type": "array", + "description": "系列配置数组(非静态数据时传此参数)", + "items": { + "type": "object", + "properties": { + "index": { + "type": "number", + "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引" + }, + "aggregateType": { + "type": "string", + "description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效", + "enum": [ + "sum", + "average", + "count", + "min", + "max", + "median" + ] + }, + "nameRef": { + "type": "string", + "description": "可选。系列名称的单元格引用(A1 表示法,如 'Sheet1!C1')。当系列名称不直接来自 refs 首行/首列时,可通过此字段把系列名显式指向目标表头单元格,图表会以该单元格当前值作为系列名(图例/tooltip)展示。" + } + } + } + }, + "fields": { + "type": "array", + "description": "字段配置数组(静态数据时传此参数)", + "items": { + "type": "object", + "properties": { + "valueType": { + "type": "string", + "description": "值类型", + "enum": [ + "number" + ] + }, + "name": { + "type": "string", + "description": "字段名称" + }, + "text": { + "type": "string", + "description": "字段文本数据" + } + }, + "required": [ + "text" + ] + } + } + } } - }, - "required": [ - "text" - ], - "type": "object" + } } - }, - "type": "object" + } } }, - "type": "object" + "additionalProperties": {} } }, "+chart-update": { "properties": { - "additionalProperties": {}, "description": "创建/更新的图表属性。", + "type": "object", "properties": { - "offset": { - "additionalProperties": false, - "description": "可选。图表在位置基础上的偏移量(像素)。", - "properties": { - "col_offset": { - "description": "列偏移量(像素)", - "type": "number" - }, - "row_offset": { - "description": "行偏移量(像素)", - "type": "number" - } - }, - "type": "object" - }, "position": { - "additionalProperties": false, + "type": "object", "description": "必填。图表在表格中的单元格位置。注意:选择位置时应避免覆盖已有数据的单元格,并确保图表不超出当前表格的行列范围。", "properties": { - "col": { - "description": "列索引,例如 \"A\"、\"B\"", - "type": "string" - }, "row": { - "description": "行索引(0-based)", + "type": "number", "minimum": 0, - "type": "number" + "description": "行索引(0-based)" + }, + "col": { + "type": "string", + "description": "列索引,例如 \"A\"、\"B\"" } }, "required": [ "row", "col" ], - "type": "object" + "additionalProperties": false + }, + "offset": { + "type": "object", + "description": "可选。图表在位置基础上的偏移量(像素)。", + "properties": { + "row_offset": { + "type": "number", + "description": "行偏移量(像素)" + }, + "col_offset": { + "type": "number", + "description": "列偏移量(像素)" + } + }, + "additionalProperties": false }, "size": { - "additionalProperties": false, + "type": "object", "description": "必填。图表大小(像素)。注意:设定大小时应确保图表不超出当前表格的行列范围,并避免覆盖已有数据的单元格。", "properties": { - "height": { - "description": "高度(像素)", + "width": { + "type": "number", "minimum": 10, - "type": "number" + "description": "宽度(像素)" }, - "width": { - "description": "宽度(像素)", + "height": { + "type": "number", "minimum": 10, - "type": "number" + "description": "高度(像素)" } }, "required": [ "width", "height" ], - "type": "object" + "additionalProperties": false }, "snapshot": { + "type": "object", "description": "图表快照配置。更新图表时必须传入完整的图表属性定义,不能只传修改的部分。应先通过 get_chart_objects 获取当前图表快照,修改需要变更的字段后,将完整快照传入。", "properties": { - "data": { - "description": "图表数据配置", + "title": { + "type": "object", + "description": "图表标题配置", "properties": { - "dim1": { - "description": "维度1配置(类别维度)", - "properties": { - "field": { - "description": "字段配置(静态数据时传此参数)", - "properties": { - "name": { - "description": "字段名称", - "type": "string" - }, - "text": { - "description": "字段文本数据", - "type": "string" - }, - "valueType": { - "description": "值类型", - "enum": [ - "number", - "string" - ], - "type": "string" - } - }, - "required": [ - "text" - ], - "type": "object" - }, - "serie": { - "description": "系列配置(非静态数据时传此参数)", - "properties": { - "aggregate": { - "description": "是否汇总相同的类别,默认为true,true 情况下 aggregateType 才生效", - "type": "boolean" - }, - "index": { - "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引", - "type": "number" - }, - "nameRef": { - "description": "可选。维度名称的单元格引用(A1 表示法,如 'Sheet1!A1')。当维度名称不直接来自 refs 首行/首列时,可通过此字段把维度名显式指向目标表头单元格,图表会以该单元格当前值作为类别维度名展示。", - "type": "string" - } - }, - "type": "object" - } - }, - "type": "object" - }, - "dim2": { - "description": "维度2配置(值维度)", - "properties": { - "fields": { - "description": "字段配置数组(静态数据时传此参数)", - "items": { - "properties": { - "name": { - "description": "字段名称", - "type": "string" - }, - "text": { - "description": "字段文本数据", - "type": "string" - }, - "valueType": { - "description": "值类型", - "enum": [ - "number" - ], - "type": "string" - } - }, - "required": [ - "text" - ], - "type": "object" - }, - "type": "array" - }, - "series": { - "description": "系列配置数组(非静态数据时传此参数)", - "items": { - "properties": { - "aggregateType": { - "description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效", - "enum": [ - "sum", - "average", - "count", - "min", - "max", - "median" - ], - "type": "string" - }, - "index": { - "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引", - "type": "number" - }, - "nameRef": { - "description": "可选。系列名称的单元格引用(A1 表示法,如 'Sheet1!C1')。当系列名称不直接来自 refs 首行/首列时,可通过此字段把系列名显式指向目标表头单元格,图表会以该单元格当前值作为系列名(图例/tooltip)展示。", - "type": "string" - } - }, - "type": "object" - }, - "type": "array" - } - }, - "type": "object" + "text": { + "type": "string", + "description": "标题文本" }, - "direction": { - "description": "数据方向,可选值:row(行方向)或column(列方向),常用值为column", + "textAlign": { + "type": "string", + "description": "标题对齐方式", "enum": [ - "row", - "column" - ], - "type": "string" + "left", + "center", + "right" + ] }, - "headerMode": { - "description": "表头模式。inline 表示 refs 首行/首列就是表头;detached 表示 refs 仅覆盖实际数据范围,维度名和系列名需通过 nameRef 显式指定。", + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" + } + }, + "required": [ + "text" + ] + }, + "subTitle": { + "type": "object", + "description": "图表副标题配置", + "properties": { + "text": { + "type": "string", + "description": "副标题文本" + }, + "textAlign": { + "type": "string", + "description": "副标题对齐方式", "enum": [ - "inline", - "detached" - ], - "type": "string" + "left", + "center", + "right" + ] }, - "includeHiddenOrFilter": { - "description": "是否包含隐藏或过滤的数据", - "type": "boolean" + "fontSize": { + "type": "number", + "description": "字体大小" }, - "isStaticData": { - "description": "是否为静态数据", - "type": "boolean" + "bold": { + "type": "boolean", + "description": "是否加粗" }, - "refs": { - "description": "数据源引用范围数组", - "items": { - "properties": { - "value": { - "description": "图表数据源范围,格式为 'SheetName!A1:D100' 的连续区域", - "type": "string" - } - }, - "required": [ - "value" - ], - "type": "object" - }, - "type": "array" + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" } }, - "type": "object" + "required": [ + "text" + ] }, - "legend": { - "oneOf": [ - { - "description": "图例配置", + "style": { + "type": "object", + "description": "图表样式配置", + "properties": { + "background": { + "type": "object", + "description": "背景配置", "properties": { - "bold": { - "description": "是否加粗", - "type": "boolean" + "color": { + "type": "string", + "description": "背景颜色,格式为 #RRGGBB" + } + } + }, + "font": { + "type": "object", + "description": "字体配置", + "properties": { + "size": { + "type": "number", + "description": "字体大小" }, "color": { - "description": "字体颜色,格式为 #RRGGBB", - "type": "string" + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" + } + } + }, + "border": { + "type": "object", + "description": "边框配置", + "properties": { + "color": { + "type": "string", + "description": "边框颜色,格式为 #RRGGBB" }, - "fontSize": { - "description": "字体大小", - "type": "number" + "width": { + "type": "number", + "description": "边框宽度" }, - "italic": { - "description": "是否斜体", - "type": "boolean" + "style": { + "type": "string", + "description": "边框样式", + "enum": [ + "solid", + "dashed", + "dotted" + ] + }, + "radius": { + "type": "number", + "description": "边框圆角" + } + } + }, + "colorTheme": { + "type": "array", + "description": "颜色主题配置。支持两种模式:1. 仅传一个预设主题名称(字符串数组,长度为1);2. 传多个自定义十六进制颜色字符串。预设主题:brandColorSeries@v2、rainbowColorSeries@v2、complementaryColorSeries@v2、converseColorSeries@v2、primaryColorSeries@v2、singleColorSeries-B-@v2、singleColorSeries-W-@v2、singleColorSeries-G-@v2、singleColorSeries-Y-@v2、singleColorSeries-O-@v2、singleColorSeries-R-@v2、singleColorSeries-D-@v2。十六进制格式:#RRGGBB", + "oneOf": [ + { + "minItems": 1, + "maxItems": 1, + "items": { + "type": "string", + "enum": [ + "brandColorSeries@v2", + "rainbowColorSeries@v2", + "complementaryColorSeries@v2", + "converseColorSeries@v2", + "primaryColorSeries@v2", + "singleColorSeries-B-@v2", + "singleColorSeries-W-@v2", + "singleColorSeries-G-@v2", + "singleColorSeries-Y-@v2", + "singleColorSeries-O-@v2", + "singleColorSeries-R-@v2", + "singleColorSeries-D-@v2" + ] + } }, + { + "minItems": 2, + "items": { + "type": "string", + "description": "颜色字符串,十六进制格式:#RRGGBB" + } + } + ] + }, + "colorGradient": { + "type": "boolean", + "description": "是否启用颜色渐变" + } + } + }, + "legend": { + "oneOf": [ + { + "type": "object", + "description": "图例配置", + "properties": { "position": { + "type": "string", "description": "图例位置", "enum": [ "top", "bottom", "left", "right" - ], - "type": "string" + ] }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" }, "underline": { - "description": "是否下划线", - "type": "boolean" + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" } - }, - "type": "object" + } }, { - "description": "false 表示隐藏图例", - "type": "boolean" + "type": "boolean", + "description": "false 表示隐藏图例" } ] }, "plotArea": { + "type": "object", "description": "绘图区域配置", "properties": { - "axes": { - "description": "坐标轴配置数组", - "items": { - "description": "坐标轴配置", - "properties": { - "axisLine": { - "description": "是否显示轴线", - "type": "boolean" - }, - "gridLine": { - "oneOf": [ - { - "description": "网格线配置", - "properties": { - "color": { - "description": "网格线颜色", - "type": "string" - }, - "width": { - "description": "网格线宽度", - "type": "number" - } - }, - "type": "object" - }, - { - "description": "false 表示隐藏网格线", - "type": "boolean" - } - ] - }, - "label": { - "description": "坐标轴标签配置", - "properties": { - "angle": { - "description": "旋转角度,可选值:-90, -45, 0, 45, 90", - "type": "number" - }, - "bold": { - "description": "是否加粗", - "type": "boolean" - }, - "color": { - "description": "字体颜色", - "type": "string" - }, - "fontSize": { - "description": "字体大小", - "type": "number" - }, - "italic": { - "description": "是否斜体", - "type": "boolean" - }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" - }, - "underline": { - "description": "是否下划线", - "type": "boolean" - } - }, - "type": "object" - }, - "max": { - "description": "最大值", - "type": "number" - }, - "min": { - "description": "最小值", - "type": "number" - }, - "position": { - "description": "坐标轴位置", - "enum": [ - "left", - "right", - "bottom" - ], - "type": "string" - }, - "title": { - "description": "坐标轴标题配置", - "properties": { - "bold": { - "description": "是否加粗", - "type": "boolean" - }, - "color": { - "description": "字体颜色", - "type": "string" - }, - "fontSize": { - "description": "字体大小", - "type": "number" - }, - "italic": { - "description": "是否斜体", - "type": "boolean" - }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" - }, - "text": { - "description": "标题文本", - "type": "string" - }, - "underline": { - "description": "是否下划线", - "type": "boolean" - } - }, - "required": [ - "text" - ], - "type": "object" - }, - "type": { - "description": "坐标轴类型", - "enum": [ - "x", - "y", - "angle", - "radius" - ], - "type": "string" - }, - "valueType": { - "description": "是否将横轴数字视为文本,linear 表示将数字视为数值, ordinal 表示将数字视为文本", - "enum": [ - "ordinal", - "linear" - ], - "type": "string" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - "type": "array" - }, "plot": { + "type": "object", "description": "绘图配置", "properties": { - "areas": { - "description": "全系列面积填充配置,面积图、雷达图、组合图生效。", - "properties": { - "color": { - "description": "区域填充颜色", - "type": "string" - } - }, - "type": "object" - }, - "bars": { - "description": "全系列柱状图、条形图、组合图生效。", - "properties": { - "backgroundColor": { - "description": "背景颜色", - "type": "string" - }, - "bar": { - "description": "单个柱子配置数组", - "items": { - "properties": { - "borderColor": { - "description": "边框颜色", - "type": "string" - }, - "borderStyle": { - "description": "边框样式", - "type": "string" - }, - "borderWidth": { - "description": "边框宽度", - "type": "number" - }, - "color": { - "description": "颜色", - "type": "string" - }, - "index": { - "description": "柱子索引", - "type": "number" - } - }, - "required": [ - "index" - ], - "type": "object" - }, - "type": "array" - }, - "borderColor": { - "description": "边框颜色", - "type": "string" - }, - "borderStyle": { - "description": "边框样式", - "enum": [ - "solid", - "dashed", - "dotted" - ], - "type": "string" - }, - "borderWidth": { - "description": "边框宽度", - "type": "number" - }, - "color": { - "description": "柱子颜色", - "type": "string" - }, - "gap": { - "description": "柱子间距比例,0-1之间", - "type": "number" - }, - "width": { - "description": "柱子宽度", - "type": "number" - } - }, - "type": "object" + "type": { + "type": "string", + "description": "图表类型", + "enum": [ + "bar", + "column", + "line", + "area", + "combo", + "pie", + "radar", + "scatter" + ] }, "comboType": { + "type": "string", "description": "组合图表默认类型", "enum": [ "column", "line", "area" - ], - "type": "string" + ] + }, + "yAxisPosition": { + "type": "string", + "description": "Y轴位置", + "enum": [ + "left", + "right" + ] }, "extra": { + "type": "object", "description": "额外配置", "properties": { + "smooth": { + "type": "boolean", + "description": "是否平滑曲线" + }, + "step": { + "type": "boolean", + "description": "是否阶梯图" + }, + "stack": { + "type": "object", + "description": "堆叠配置", + "properties": { + "percentage": { + "type": "boolean", + "description": "是否百分比堆叠" + } + } + }, "radar": { + "type": "object", "description": "雷达图配置", "properties": { - "area": { - "description": "是否填充区域", - "type": "boolean" - }, "shape": { + "type": "string", "description": "雷达图形状", "enum": [ "polygon", "circle" - ], - "type": "string" - } - }, - "type": "object" - }, - "smooth": { - "description": "是否平滑曲线", - "type": "boolean" - }, - "stack": { - "description": "堆叠配置", - "properties": { - "percentage": { - "description": "是否百分比堆叠", - "type": "boolean" + ] + }, + "area": { + "type": "boolean", + "description": "是否填充区域" } - }, - "type": "object" - }, - "step": { - "description": "是否阶梯图", - "type": "boolean" + } } - }, - "type": "object" + } }, - "labels": { - "description": "数据标签配置", + "points": { + "type": "object", + "description": "全系列数据点配置,折线图、面积图、雷达图、散点图、组合图生效。", "properties": { - "bold": { - "description": "是否加粗", - "type": "boolean" - }, - "category": { - "description": "是否显示类别名", - "type": "boolean" - }, "color": { - "description": "字体颜色", - "type": "string" - }, - "fontSize": { - "description": "字体大小", - "type": "number" - }, - "italic": { - "description": "是否斜体", - "type": "boolean" - }, - "percentage": { - "description": "是否显示百分比", - "type": "boolean" + "type": "string", + "description": "数据点颜色" }, - "position": { - "description": "标签位置", + "shape": { + "type": "string", + "description": "数据点形状", "enum": [ - "auto", - "top", - "bottom", - "left", - "right", - "center", - "inside", - "outside" - ], - "type": "string" - }, - "series": { - "description": "是否显示系列名", - "type": "boolean" - }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" + "circle", + "triangle", + "rect", + "diamond", + "square" + ] }, - "underline": { - "description": "是否下划线", - "type": "boolean" + "size": { + "type": "number", + "description": "数据点大小" }, - "value": { - "description": "是否显示值", - "type": "boolean" + "point": { + "type": "array", + "description": "单个数据点配置数组", + "items": { + "type": "object", + "properties": { + "index": { + "type": "number", + "description": "数据点索引" + }, + "color": { + "type": "string", + "description": "颜色" + }, + "shape": { + "type": "string", + "description": "形状" + }, + "size": { + "type": "number", + "description": "大小" + } + }, + "required": [ + "index" + ] + } } - }, - "type": "object" + } }, "lines": { + "type": "object", "description": "全系列线条配置,折线图、面积图、雷达图、组合图生效。", "properties": { "color": { - "description": "线条颜色", - "type": "string" + "type": "string", + "description": "线条颜色" }, - "invalidType": { - "description": "无效值处理方式", - "enum": [ - "break", - "zero", - "link" - ], - "type": "string" + "width": { + "type": "number", + "description": "线条宽度" }, "style": { + "type": "string", "description": "线条样式", "enum": [ "solid", "dashed", "dotted" - ], - "type": "string" + ] }, - "width": { - "description": "线条宽度", - "type": "number" - } - }, - "type": "object" - }, - "points": { - "description": "全系列数据点配置,折线图、面积图、雷达图、散点图、组合图生效。", + "invalidType": { + "type": "string", + "description": "无效值处理方式", + "enum": [ + "break", + "zero", + "link" + ] + } + } + }, + "areas": { + "type": "object", + "description": "全系列面积填充配置,面积图、雷达图、组合图生效。", "properties": { "color": { - "description": "数据点颜色", - "type": "string" + "type": "string", + "description": "区域填充颜色" + } + } + }, + "bars": { + "type": "object", + "description": "全系列柱状图、条形图、组合图生效。", + "properties": { + "color": { + "type": "string", + "description": "柱子颜色" }, - "point": { - "description": "单个数据点配置数组", + "borderColor": { + "type": "string", + "description": "边框颜色" + }, + "borderWidth": { + "type": "number", + "description": "边框宽度" + }, + "borderStyle": { + "type": "string", + "description": "边框样式", + "enum": [ + "solid", + "dashed", + "dotted" + ] + }, + "width": { + "type": "number", + "description": "柱子宽度" + }, + "gap": { + "type": "number", + "description": "柱子间距比例,0-1之间" + }, + "backgroundColor": { + "type": "string", + "description": "背景颜色" + }, + "bar": { + "type": "array", + "description": "单个柱子配置数组", "items": { + "type": "object", "properties": { + "index": { + "type": "number", + "description": "柱子索引" + }, "color": { - "description": "颜色", - "type": "string" + "type": "string", + "description": "颜色" }, - "index": { - "description": "数据点索引", - "type": "number" + "borderColor": { + "type": "string", + "description": "边框颜色" }, - "shape": { - "description": "形状", - "type": "string" + "borderWidth": { + "type": "number", + "description": "边框宽度" }, - "size": { - "description": "大小", - "type": "number" + "borderStyle": { + "type": "string", + "description": "边框样式" } }, "required": [ "index" - ], - "type": "object" - }, - "type": "array" - }, - "shape": { - "description": "数据点形状", + ] + } + } + } + }, + "labels": { + "type": "object", + "description": "数据标签配置", + "properties": { + "position": { + "type": "string", + "description": "标签位置", "enum": [ - "circle", - "triangle", - "rect", - "diamond", - "square" - ], - "type": "string" + "auto", + "top", + "bottom", + "left", + "right", + "center", + "inside", + "outside" + ] }, - "size": { - "description": "数据点大小", - "type": "number" + "series": { + "type": "boolean", + "description": "是否显示系列名" + }, + "category": { + "type": "boolean", + "description": "是否显示类别名" + }, + "value": { + "type": "boolean", + "description": "是否显示值" + }, + "percentage": { + "type": "boolean", + "description": "是否显示百分比" + }, + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色" } - }, - "type": "object" + } }, "series": { + "type": "array", "description": "单个系列配置数组", "items": { + "type": "object", "description": "系列配置", "properties": { - "area": { - "description": "区域填充配置,配置项同 plotArea.areas", - "type": "object" - }, - "bars": { - "description": "柱状图配置,配置项同 plotArea.bars", - "type": "object" + "index": { + "type": "number", + "description": "数据列索引,与 dim2.series[].index 值一致,从 1 开始(1 通常对应首列标签维度,2+ 对应后续数据维度列)" }, "comboType": { + "type": "string", "description": "组合图下该系列的图表类型,仅在 type 为 combo 时生效", "enum": [ "column", "line", "area" - ], - "type": "string" + ] }, - "index": { - "description": "数据列索引,与 dim2.series[].index 值一致,从 1 开始(1 通常对应首列标签维度,2+ 对应后续数据维度列)", - "type": "number" + "yAxisPosition": { + "type": "string", + "description": "Y轴位置", + "enum": [ + "left", + "right" + ] }, - "labels": { - "description": "数据标签配置", - "type": "object" + "points": { + "type": "object", + "description": "数据点配置,配置项同 plotArea.points" }, "line": { - "description": "线条配置,配置项同 plotArea.lines", - "type": "object" + "type": "object", + "description": "线条配置,配置项同 plotArea.lines" }, - "points": { - "description": "数据点配置,配置项同 plotArea.points", - "type": "object" + "area": { + "type": "object", + "description": "区域填充配置,配置项同 plotArea.areas" + }, + "bars": { + "type": "object", + "description": "柱状图配置,配置项同 plotArea.bars" + }, + "labels": { + "type": "object", + "description": "数据标签配置" }, "sectors": { + "type": "object", "description": "扇区配置(饼图)", "properties": { "borderColor": { - "description": "边框颜色", - "type": "string" + "type": "string", + "description": "边框颜色" }, "innerRadius": { - "description": "内半径比例,0-1之间", - "type": "number" + "type": "number", + "description": "内半径比例,0-1之间" }, "offsetRadius": { - "description": "偏移半径比例", - "type": "number" + "type": "number", + "description": "偏移半径比例" + }, + "startAngle": { + "type": "number", + "description": "起始角度,0-359" }, "sector": { + "type": "array", "description": "单个扇区配置数组", "items": { + "type": "object", "properties": { - "borderColor": { - "description": "边框颜色", - "type": "string" - }, - "color": { - "description": "颜色", - "type": "string" - }, "index": { - "description": "扇区索引", - "type": "number" + "type": "number", + "description": "扇区索引" + }, + "borderColor": { + "type": "string", + "description": "边框颜色" }, "offsetRadius": { - "description": "偏移半径", - "type": "number" + "type": "number", + "description": "偏移半径" + }, + "color": { + "type": "string", + "description": "颜色" } }, "required": [ "index" - ], - "type": "object" - }, - "type": "array" - }, - "startAngle": { - "description": "起始角度,0-359", - "type": "number" + ] + } } - }, - "type": "object" - }, - "yAxisPosition": { - "description": "Y轴位置", - "enum": [ - "left", - "right" - ], - "type": "string" + } } }, "required": [ "index" - ], - "type": "object" - }, - "type": "array" - }, - "type": { - "description": "图表类型", - "enum": [ - "bar", - "column", - "line", - "area", - "combo", - "pie", - "radar", - "scatter" - ], - "type": "string" - }, - "yAxisPosition": { - "description": "Y轴位置", - "enum": [ - "left", - "right" - ], - "type": "string" + ] + } } }, "required": [ "type" - ], - "type": "object" - } - }, - "type": "object" - }, - "style": { - "description": "图表样式配置", - "properties": { - "background": { - "description": "背景配置", - "properties": { - "color": { - "description": "背景颜色,格式为 #RRGGBB", - "type": "string" - } - }, - "type": "object" - }, - "border": { - "description": "边框配置", - "properties": { - "color": { - "description": "边框颜色,格式为 #RRGGBB", - "type": "string" - }, - "radius": { - "description": "边框圆角", - "type": "number" - }, - "style": { - "description": "边框样式", - "enum": [ - "solid", - "dashed", - "dotted" - ], - "type": "string" - }, - "width": { - "description": "边框宽度", - "type": "number" - } - }, - "type": "object" - }, - "colorGradient": { - "description": "是否启用颜色渐变", - "type": "boolean" + ] }, - "colorTheme": { - "description": "颜色主题配置。支持两种模式:1. 仅传一个预设主题名称(字符串数组,长度为1);2. 传多个自定义十六进制颜色字符串。预设主题:brandColorSeries@v2、rainbowColorSeries@v2、complementaryColorSeries@v2、converseColorSeries@v2、primaryColorSeries@v2、singleColorSeries-B-@v2、singleColorSeries-W-@v2、singleColorSeries-G-@v2、singleColorSeries-Y-@v2、singleColorSeries-O-@v2、singleColorSeries-R-@v2、singleColorSeries-D-@v2。十六进制格式:#RRGGBB", - "oneOf": [ - { - "items": { + "axes": { + "type": "array", + "description": "坐标轴配置数组", + "items": { + "type": "object", + "description": "坐标轴配置", + "properties": { + "type": { + "type": "string", + "description": "坐标轴类型", "enum": [ - "brandColorSeries@v2", - "rainbowColorSeries@v2", - "complementaryColorSeries@v2", - "converseColorSeries@v2", - "primaryColorSeries@v2", - "singleColorSeries-B-@v2", - "singleColorSeries-W-@v2", - "singleColorSeries-G-@v2", - "singleColorSeries-Y-@v2", - "singleColorSeries-O-@v2", - "singleColorSeries-R-@v2", - "singleColorSeries-D-@v2" - ], - "type": "string" + "x", + "y", + "angle", + "radius" + ] }, - "maxItems": 1, - "minItems": 1 - }, - { - "items": { - "description": "颜色字符串,十六进制格式:#RRGGBB", - "type": "string" + "position": { + "type": "string", + "description": "坐标轴位置", + "enum": [ + "left", + "right", + "bottom" + ] }, - "minItems": 2 - } - ], - "type": "array" - }, - "font": { - "description": "字体配置", - "properties": { - "color": { - "description": "字体颜色,格式为 #RRGGBB", - "type": "string" + "max": { + "type": "number", + "description": "最大值" + }, + "min": { + "type": "number", + "description": "最小值" + }, + "valueType": { + "type": "string", + "description": "是否将横轴数字视为文本,linear 表示将数字视为数值, ordinal 表示将数字视为文本", + "enum": [ + "ordinal", + "linear" + ] + }, + "title": { + "type": "object", + "description": "坐标轴标题配置", + "properties": { + "text": { + "type": "string", + "description": "标题文本" + }, + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色" + } + }, + "required": [ + "text" + ] + }, + "label": { + "type": "object", + "description": "坐标轴标签配置", + "properties": { + "angle": { + "type": "number", + "description": "旋转角度,可选值:-90, -45, 0, 45, 90" + }, + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色" + } + } + }, + "axisLine": { + "type": "boolean", + "description": "是否显示轴线" + }, + "gridLine": { + "oneOf": [ + { + "type": "object", + "description": "网格线配置", + "properties": { + "width": { + "type": "number", + "description": "网格线宽度" + }, + "color": { + "type": "string", + "description": "网格线颜色" + } + } + }, + { + "type": "boolean", + "description": "false 表示隐藏网格线" + } + ] + } }, - "size": { - "description": "字体大小", - "type": "number" - } - }, - "type": "object" + "required": [ + "type" + ] + } } - }, - "type": "object" + } }, - "subTitle": { - "description": "图表副标题配置", + "data": { + "type": "object", + "description": "图表数据配置", "properties": { - "bold": { - "description": "是否加粗", - "type": "boolean" - }, - "color": { - "description": "字体颜色,格式为 #RRGGBB", - "type": "string" - }, - "fontSize": { - "description": "字体大小", - "type": "number" - }, - "italic": { - "description": "是否斜体", - "type": "boolean" - }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" + "isStaticData": { + "type": "boolean", + "description": "是否为静态数据" }, - "text": { - "description": "副标题文本", - "type": "string" + "includeHiddenOrFilter": { + "type": "boolean", + "description": "是否包含隐藏或过滤的数据" }, - "textAlign": { - "description": "副标题对齐方式", + "direction": { + "type": "string", + "description": "数据方向,可选值:row(行方向)或column(列方向),常用值为column", "enum": [ - "left", - "center", - "right" - ], - "type": "string" - }, - "underline": { - "description": "是否下划线", - "type": "boolean" - } - }, - "required": [ - "text" - ], - "type": "object" - }, - "title": { - "description": "图表标题配置", - "properties": { - "bold": { - "description": "是否加粗", - "type": "boolean" - }, - "color": { - "description": "字体颜色,格式为 #RRGGBB", - "type": "string" - }, - "fontSize": { - "description": "字体大小", - "type": "number" - }, - "italic": { - "description": "是否斜体", - "type": "boolean" + "row", + "column" + ] }, - "strikethrough": { - "description": "是否删除线", - "type": "boolean" + "headerMode": { + "type": "string", + "description": "表头模式。inline 表示 refs 首行/首列就是表头;detached 表示 refs 仅覆盖实际数据范围,维度名和系列名需通过 nameRef 显式指定。", + "enum": [ + "inline", + "detached" + ] }, - "text": { - "description": "标题文本", - "type": "string" + "refs": { + "type": "array", + "description": "数据源引用范围数组", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "图表数据源范围,格式为 'SheetName!A1:D100' 的连续区域" + } + }, + "required": [ + "value" + ] + } }, - "textAlign": { - "description": "标题对齐方式", - "enum": [ - "left", - "center", - "right" - ], - "type": "string" + "dim1": { + "type": "object", + "description": "维度1配置(类别维度)", + "properties": { + "serie": { + "type": "object", + "description": "系列配置(非静态数据时传此参数)", + "properties": { + "index": { + "type": "number", + "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引" + }, + "aggregate": { + "type": "boolean", + "description": "是否汇总相同的类别,默认为true,true 情况下 aggregateType 才生效" + }, + "nameRef": { + "type": "string", + "description": "可选。维度名称的单元格引用(A1 表示法,如 'Sheet1!A1')。当维度名称不直接来自 refs 首行/首列时,可通过此字段把维度名显式指向目标表头单元格,图表会以该单元格当前值作为类别维度名展示。" + } + } + }, + "field": { + "type": "object", + "description": "字段配置(静态数据时传此参数)", + "properties": { + "valueType": { + "type": "string", + "description": "值类型", + "enum": [ + "number", + "string" + ] + }, + "name": { + "type": "string", + "description": "字段名称" + }, + "text": { + "type": "string", + "description": "字段文本数据" + } + }, + "required": [ + "text" + ] + } + } }, - "underline": { - "description": "是否下划线", - "type": "boolean" + "dim2": { + "type": "object", + "description": "维度2配置(值维度)", + "properties": { + "series": { + "type": "array", + "description": "系列配置数组(非静态数据时传此参数)", + "items": { + "type": "object", + "properties": { + "index": { + "type": "number", + "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引" + }, + "aggregateType": { + "type": "string", + "description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效", + "enum": [ + "sum", + "average", + "count", + "min", + "max", + "median" + ] + }, + "nameRef": { + "type": "string", + "description": "可选。系列名称的单元格引用(A1 表示法,如 'Sheet1!C1')。当系列名称不直接来自 refs 首行/首列时,可通过此字段把系列名显式指向目标表头单元格,图表会以该单元格当前值作为系列名(图例/tooltip)展示。" + } + } + } + }, + "fields": { + "type": "array", + "description": "字段配置数组(静态数据时传此参数)", + "items": { + "type": "object", + "properties": { + "valueType": { + "type": "string", + "description": "值类型", + "enum": [ + "number" + ] + }, + "name": { + "type": "string", + "description": "字段名称" + }, + "text": { + "type": "string", + "description": "字段文本数据" + } + }, + "required": [ + "text" + ] + } + } + } } - }, - "required": [ - "text" - ], - "type": "object" + } } - }, - "type": "object" + } } }, - "type": "object" + "additionalProperties": {} } }, "+cond-format-create": { "properties": { - "additionalProperties": false, "description": "创建/更新的条件格式属性。", + "type": "object", "properties": { + "rule_type": { + "type": "string", + "enum": [ + "duplicateValues", + "uniqueValues", + "cellIs", + "containsText", + "timePeriod", + "containsBlanks", + "notContainsBlanks", + "dataBar", + "colorScale", + "rank", + "aboveAverage", + "expression", + "iconSet" + ], + "description": "条件格式规则类型。" + }, + "ranges": { + "type": "array", + "description": "应用条件格式的 A1 范围列表。每个元素支持:矩形范围 \"A1:A10\"、整行 \"3:6\"、整列 \"C:C\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。例如 [\"A1:A10\",\"C:C\"]。", + "items": { + "type": "string" + } + }, + "style": { + "type": "object", + "description": "命中规则时应用的单元格样式。", + "properties": { + "back_color": { + "type": "string", + "description": "背景色,CSS 十六进制表示,例如 \"#FF0000\"。" + }, + "fore_color": { + "type": "string", + "description": "前景色/字体颜色。" + }, + "text_decoration": { + "type": "string", + "enum": [ + "none", + "underline", + "strikethrough", + "underline_strikethrough" + ], + "description": "文字装饰:none=无,underline=下划线,strikethrough=删除线,underline_strikethrough=下划线+删除线。" + }, + "font": { + "type": "string", + "enum": [ + "bold", + "italic", + "bold italic" + ], + "description": "字体样式:\"bold\"=加粗,\"italic\"=斜体,\"bold italic\"=加粗+斜体。" + } + }, + "additionalProperties": false + }, "attrs": { + "type": "array", "description": "规则参数列表。不同 rule_type 下取值结构不同。当 rule_type 为 dataBar 时,attrs 必须包含两个对象,分别定义正值和负值的数据条颜色。", "items": { "oneOf": [ { - "additionalProperties": false, + "type": "object", "description": "数值比较类规则参数。", "properties": { "compare_type": { - "description": "比较运算符。", + "type": "string", "enum": [ "equal", "notEqual", @@ -2824,25 +2885,29 @@ "between", "notBetween" ], - "type": "string" + "description": "比较运算符。" }, "value": { - "description": "用于比较的值,例如 \"100\"、\"hello\"。between/notBetween 时用逗号分隔两个值。", - "type": "string" + "type": "string", + "description": "用于比较的值,例如 \"100\"、\"hello\"。between/notBetween 时用逗号分隔两个值。" } }, "required": [ "compare_type", "value" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "文本包含类规则参数。", "properties": { + "text": { + "type": "string", + "description": "用于匹配的文本内容。" + }, "compare_type": { - "description": "文本匹配方式。", + "type": "string", "enum": [ "beginsWith", "endsWith", @@ -2850,34 +2915,30 @@ "notContains", "is" ], - "type": "string" - }, - "text": { - "description": "用于匹配的文本内容。", - "type": "string" + "description": "文本匹配方式。" } }, "required": [ "compare_type", "text" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "时间段类规则参数。", "properties": { "operator": { - "description": "与指定时间段的比较关系。", + "type": "string", "enum": [ "before", "is", "after" ], - "type": "string" + "description": "与指定时间段的比较关系。" }, "time_period": { - "description": "时间段类型。", + "type": "string", "enum": [ "today", "yesterday", @@ -2890,37 +2951,37 @@ "lastWeek", "nextWeek" ], - "type": "string" + "description": "时间段类型。" } }, "required": [ "operator", "time_period" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "数据条规则参数。", "properties": { - "color": { - "description": "主颜色,例如 \"#63BE7B\"。", - "type": "string" - }, "gradient": { - "description": "是否使用渐变色数据条。", - "type": "boolean" - }, - "hide_value": { - "description": "是否隐藏单元格中的原始值,仅显示数据条。", - "type": "boolean" + "type": "boolean", + "description": "是否使用渐变色数据条。" }, "value": { - "description": "阈值或比例值,含义由 value_type 决定。", - "type": "number" + "type": "number", + "description": "阈值或比例值,含义由 value_type 决定。" + }, + "color": { + "type": "string", + "description": "主颜色,例如 \"#63BE7B\"。" + }, + "hide_value": { + "type": "boolean", + "description": "是否隐藏单元格中的原始值,仅显示数据条。" }, "value_type": { - "description": "阈值类型。", + "type": "string", "enum": [ "minValue", "maxValue", @@ -2930,29 +2991,21 @@ "formula", "auto" ], - "type": "string" + "description": "阈值类型。" } }, "required": [ "color", "value_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "色阶规则中的单个分段。ColorScaleAttrs 由 2 或 3 个该对象组成,分别表示最小值/中间值/最大值。", "properties": { - "color": { - "description": "该分段对应的颜色。", - "type": "string" - }, - "value": { - "description": "阈值数值,例如百分位或具体数值。", - "type": "number" - }, "value_type": { - "description": "阈值类型。", + "type": "string", "enum": [ "minValue", "maxValue", @@ -2962,89 +3015,93 @@ "formula", "auto" ], - "type": "string" + "description": "阈值类型。" + }, + "value": { + "type": "number", + "description": "阈值数值,例如百分位或具体数值。" + }, + "color": { + "type": "string", + "description": "该分段对应的颜色。" } }, "required": [ "value_type", "color" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "前 N/后 N 规则参数。", "properties": { "is_bottom": { - "description": "是否选取最后 N 个。true 表示最后 N 个,false 表示前 N 个。", - "type": "boolean" - }, - "value": { - "description": "N 或百分比数值。", - "type": "number" + "type": "boolean", + "description": "是否选取最后 N 个。true 表示最后 N 个,false 表示前 N 个。" }, "value_type": { - "description": "排名方式:percent 表示百分比,sort 表示按条目数。", + "type": "string", "enum": [ "percent", "sort" ], - "type": "string" + "description": "排名方式:percent 表示百分比,sort 表示按条目数。" + }, + "value": { + "type": "number", + "description": "N 或百分比数值。" } }, "required": [ "is_bottom", "value_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "平均值规则参数。", "properties": { "operator": { - "description": "与平均值的比较关系。", + "type": "string", "enum": [ "greaterThan", "greaterThanOrEqual", "lessThan", "lessThanOrEqual" ], - "type": "string" + "description": "与平均值的比较关系。" } }, "required": [ "operator" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "自定义公式规则参数。", "properties": { "formula": { - "description": "条件公式列表,例如 [\"=A1>0\"]。", + "type": "array", "items": { "type": "string" }, - "type": "array" + "description": "条件公式列表,例如 [\"=A1>0\"]。" } }, "required": [ "formula" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "图标集规则参数。", "properties": { - "hide_value": { - "description": "是否隐藏单元格原始值,仅显示图标。", - "type": "boolean" - }, "icon_type": { - "description": "图标集类型。其中 3Circles/4Circles/5Circles 为不同颜色的实心圆(如红、黄、绿),用于表示状态等级;5CirclesRatio 为不同填充比例的圆(从空心◯到半圆◑到满圆●),用于表示完成度、进度、比例。当涉及半圆、满圆、空心圆、填充比例、进度等场景时,应选择 5CirclesRatio。", + "type": "string", "enum": [ "3Arrows", "3ArrowsGray", @@ -3066,28 +3123,14 @@ "3Mood", "5CirclesRatio" ], - "type": "string" - }, - "operator": { - "description": "与阈值的比较关系。", - "enum": [ - "greaterThan", - "greaterThanOrEqual", - "lessThan", - "lessThanOrEqual" - ], - "type": "string" - }, - "reverse_icons": { - "description": "是否反转图标顺序。", - "type": "boolean" + "description": "图标集类型。其中 3Circles/4Circles/5Circles 为不同颜色的实心圆(如红、黄、绿),用于表示状态等级;5CirclesRatio 为不同填充比例的圆(从空心◯到半圆◑到满圆●),用于表示完成度、进度、比例。当涉及半圆、满圆、空心圆、填充比例、进度等场景时,应选择 5CirclesRatio。" }, - "value": { - "description": "用于比较的数值,含义由 value_type 决定。", - "type": "number" + "hide_value": { + "type": "boolean", + "description": "是否隐藏单元格原始值,仅显示图标。" }, "value_type": { - "description": "阈值类型。", + "type": "string", "enum": [ "minValue", "maxValue", @@ -3097,7 +3140,25 @@ "formula", "auto" ], - "type": "string" + "description": "阈值类型。" + }, + "operator": { + "type": "string", + "enum": [ + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual" + ], + "description": "与阈值的比较关系。" + }, + "value": { + "type": "number", + "description": "用于比较的数值,含义由 value_type 决定。" + }, + "reverse_icons": { + "type": "boolean", + "description": "是否反转图标顺序。" } }, "required": [ @@ -3105,25 +3166,31 @@ "value_type", "operator" ], - "type": "object" + "additionalProperties": false } ] - }, - "type": "array" + } }, "has_ref": { - "description": "可选。是否存在引用,用于标记规则是否依赖其他对象。", - "type": "boolean" - }, - "ranges": { - "description": "应用条件格式的 A1 范围列表。每个元素支持:矩形范围 \"A1:A10\"、整行 \"3:6\"、整列 \"C:C\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。例如 [\"A1:A10\",\"C:C\"]。", - "items": { - "type": "string" - }, - "type": "array" - }, + "type": "boolean", + "description": "可选。是否存在引用,用于标记规则是否依赖其他对象。" + } + }, + "required": [ + "rule_type", + "ranges", + "style" + ], + "additionalProperties": false + } + }, + "+cond-format-update": { + "properties": { + "description": "创建/更新的条件格式属性。", + "type": "object", + "properties": { "rule_type": { - "description": "条件格式规则类型。", + "type": "string", "enum": [ "duplicateValues", "uniqueValues", @@ -3139,66 +3206,60 @@ "expression", "iconSet" ], - "type": "string" + "description": "条件格式规则类型。" + }, + "ranges": { + "type": "array", + "description": "应用条件格式的 A1 范围列表。每个元素支持:矩形范围 \"A1:A10\"、整行 \"3:6\"、整列 \"C:C\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。例如 [\"A1:A10\",\"C:C\"]。", + "items": { + "type": "string" + } }, "style": { - "additionalProperties": false, - "description": "命中规则时应用的单元格样式。", - "properties": { - "back_color": { - "description": "背景色,CSS 十六进制表示,例如 \"#FF0000\"。", - "type": "string" - }, - "font": { - "description": "字体样式:\"bold\"=加粗,\"italic\"=斜体,\"bold italic\"=加粗+斜体。", - "enum": [ - "bold", - "italic", - "bold italic" - ], - "type": "string" + "type": "object", + "description": "命中规则时应用的单元格样式。", + "properties": { + "back_color": { + "type": "string", + "description": "背景色,CSS 十六进制表示,例如 \"#FF0000\"。" }, "fore_color": { - "description": "前景色/字体颜色。", - "type": "string" + "type": "string", + "description": "前景色/字体颜色。" }, "text_decoration": { - "description": "文字装饰:none=无,underline=下划线,strikethrough=删除线,underline_strikethrough=下划线+删除线。", + "type": "string", "enum": [ "none", "underline", "strikethrough", "underline_strikethrough" ], - "type": "string" + "description": "文字装饰:none=无,underline=下划线,strikethrough=删除线,underline_strikethrough=下划线+删除线。" + }, + "font": { + "type": "string", + "enum": [ + "bold", + "italic", + "bold italic" + ], + "description": "字体样式:\"bold\"=加粗,\"italic\"=斜体,\"bold italic\"=加粗+斜体。" } }, - "type": "object" - } - }, - "required": [ - "rule_type", - "ranges", - "style" - ], - "type": "object" - } - }, - "+cond-format-update": { - "properties": { - "additionalProperties": false, - "description": "创建/更新的条件格式属性。", - "properties": { + "additionalProperties": false + }, "attrs": { + "type": "array", "description": "规则参数列表。不同 rule_type 下取值结构不同。当 rule_type 为 dataBar 时,attrs 必须包含两个对象,分别定义正值和负值的数据条颜色。", "items": { "oneOf": [ { - "additionalProperties": false, + "type": "object", "description": "数值比较类规则参数。", "properties": { "compare_type": { - "description": "比较运算符。", + "type": "string", "enum": [ "equal", "notEqual", @@ -3209,25 +3270,29 @@ "between", "notBetween" ], - "type": "string" + "description": "比较运算符。" }, "value": { - "description": "用于比较的值,例如 \"100\"、\"hello\"。between/notBetween 时用逗号分隔两个值。", - "type": "string" + "type": "string", + "description": "用于比较的值,例如 \"100\"、\"hello\"。between/notBetween 时用逗号分隔两个值。" } }, "required": [ "compare_type", "value" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "文本包含类规则参数。", "properties": { + "text": { + "type": "string", + "description": "用于匹配的文本内容。" + }, "compare_type": { - "description": "文本匹配方式。", + "type": "string", "enum": [ "beginsWith", "endsWith", @@ -3235,34 +3300,30 @@ "notContains", "is" ], - "type": "string" - }, - "text": { - "description": "用于匹配的文本内容。", - "type": "string" + "description": "文本匹配方式。" } }, "required": [ "compare_type", "text" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "时间段类规则参数。", "properties": { "operator": { - "description": "与指定时间段的比较关系。", + "type": "string", "enum": [ "before", "is", "after" ], - "type": "string" + "description": "与指定时间段的比较关系。" }, "time_period": { - "description": "时间段类型。", + "type": "string", "enum": [ "today", "yesterday", @@ -3275,37 +3336,37 @@ "lastWeek", "nextWeek" ], - "type": "string" + "description": "时间段类型。" } }, "required": [ "operator", "time_period" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "数据条规则参数。", "properties": { - "color": { - "description": "主颜色,例如 \"#63BE7B\"。", - "type": "string" - }, "gradient": { - "description": "是否使用渐变色数据条。", - "type": "boolean" - }, - "hide_value": { - "description": "是否隐藏单元格中的原始值,仅显示数据条。", - "type": "boolean" + "type": "boolean", + "description": "是否使用渐变色数据条。" }, "value": { - "description": "阈值或比例值,含义由 value_type 决定。", - "type": "number" + "type": "number", + "description": "阈值或比例值,含义由 value_type 决定。" + }, + "color": { + "type": "string", + "description": "主颜色,例如 \"#63BE7B\"。" + }, + "hide_value": { + "type": "boolean", + "description": "是否隐藏单元格中的原始值,仅显示数据条。" }, "value_type": { - "description": "阈值类型。", + "type": "string", "enum": [ "minValue", "maxValue", @@ -3315,29 +3376,21 @@ "formula", "auto" ], - "type": "string" + "description": "阈值类型。" } }, "required": [ "color", "value_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "色阶规则中的单个分段。ColorScaleAttrs 由 2 或 3 个该对象组成,分别表示最小值/中间值/最大值。", "properties": { - "color": { - "description": "该分段对应的颜色。", - "type": "string" - }, - "value": { - "description": "阈值数值,例如百分位或具体数值。", - "type": "number" - }, "value_type": { - "description": "阈值类型。", + "type": "string", "enum": [ "minValue", "maxValue", @@ -3347,89 +3400,93 @@ "formula", "auto" ], - "type": "string" + "description": "阈值类型。" + }, + "value": { + "type": "number", + "description": "阈值数值,例如百分位或具体数值。" + }, + "color": { + "type": "string", + "description": "该分段对应的颜色。" } }, "required": [ "value_type", "color" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "前 N/后 N 规则参数。", "properties": { "is_bottom": { - "description": "是否选取最后 N 个。true 表示最后 N 个,false 表示前 N 个。", - "type": "boolean" - }, - "value": { - "description": "N 或百分比数值。", - "type": "number" + "type": "boolean", + "description": "是否选取最后 N 个。true 表示最后 N 个,false 表示前 N 个。" }, "value_type": { - "description": "排名方式:percent 表示百分比,sort 表示按条目数。", + "type": "string", "enum": [ "percent", "sort" ], - "type": "string" + "description": "排名方式:percent 表示百分比,sort 表示按条目数。" + }, + "value": { + "type": "number", + "description": "N 或百分比数值。" } }, "required": [ "is_bottom", "value_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "平均值规则参数。", "properties": { "operator": { - "description": "与平均值的比较关系。", + "type": "string", "enum": [ "greaterThan", "greaterThanOrEqual", "lessThan", "lessThanOrEqual" ], - "type": "string" + "description": "与平均值的比较关系。" } }, "required": [ "operator" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "自定义公式规则参数。", "properties": { "formula": { - "description": "条件公式列表,例如 [\"=A1>0\"]。", + "type": "array", "items": { "type": "string" }, - "type": "array" + "description": "条件公式列表,例如 [\"=A1>0\"]。" } }, "required": [ "formula" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "图标集规则参数。", "properties": { - "hide_value": { - "description": "是否隐藏单元格原始值,仅显示图标。", - "type": "boolean" - }, "icon_type": { - "description": "图标集类型。其中 3Circles/4Circles/5Circles 为不同颜色的实心圆(如红、黄、绿),用于表示状态等级;5CirclesRatio 为不同填充比例的圆(从空心◯到半圆◑到满圆●),用于表示完成度、进度、比例。当涉及半圆、满圆、空心圆、填充比例、进度等场景时,应选择 5CirclesRatio。", + "type": "string", "enum": [ "3Arrows", "3ArrowsGray", @@ -3451,28 +3508,14 @@ "3Mood", "5CirclesRatio" ], - "type": "string" - }, - "operator": { - "description": "与阈值的比较关系。", - "enum": [ - "greaterThan", - "greaterThanOrEqual", - "lessThan", - "lessThanOrEqual" - ], - "type": "string" - }, - "reverse_icons": { - "description": "是否反转图标顺序。", - "type": "boolean" + "description": "图标集类型。其中 3Circles/4Circles/5Circles 为不同颜色的实心圆(如红、黄、绿),用于表示状态等级;5CirclesRatio 为不同填充比例的圆(从空心◯到半圆◑到满圆●),用于表示完成度、进度、比例。当涉及半圆、满圆、空心圆、填充比例、进度等场景时,应选择 5CirclesRatio。" }, - "value": { - "description": "用于比较的数值,含义由 value_type 决定。", - "type": "number" + "hide_value": { + "type": "boolean", + "description": "是否隐藏单元格原始值,仅显示图标。" }, "value_type": { - "description": "阈值类型。", + "type": "string", "enum": [ "minValue", "maxValue", @@ -3482,7 +3525,25 @@ "formula", "auto" ], - "type": "string" + "description": "阈值类型。" + }, + "operator": { + "type": "string", + "enum": [ + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual" + ], + "description": "与阈值的比较关系。" + }, + "value": { + "type": "number", + "description": "用于比较的数值,含义由 value_type 决定。" + }, + "reverse_icons": { + "type": "boolean", + "description": "是否反转图标顺序。" } }, "required": [ @@ -3490,75 +3551,14 @@ "value_type", "operator" ], - "type": "object" + "additionalProperties": false } ] - }, - "type": "array" + } }, "has_ref": { - "description": "可选。是否存在引用,用于标记规则是否依赖其他对象。", - "type": "boolean" - }, - "ranges": { - "description": "应用条件格式的 A1 范围列表。每个元素支持:矩形范围 \"A1:A10\"、整行 \"3:6\"、整列 \"C:C\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。例如 [\"A1:A10\",\"C:C\"]。", - "items": { - "type": "string" - }, - "type": "array" - }, - "rule_type": { - "description": "条件格式规则类型。", - "enum": [ - "duplicateValues", - "uniqueValues", - "cellIs", - "containsText", - "timePeriod", - "containsBlanks", - "notContainsBlanks", - "dataBar", - "colorScale", - "rank", - "aboveAverage", - "expression", - "iconSet" - ], - "type": "string" - }, - "style": { - "additionalProperties": false, - "description": "命中规则时应用的单元格样式。", - "properties": { - "back_color": { - "description": "背景色,CSS 十六进制表示,例如 \"#FF0000\"。", - "type": "string" - }, - "font": { - "description": "字体样式:\"bold\"=加粗,\"italic\"=斜体,\"bold italic\"=加粗+斜体。", - "enum": [ - "bold", - "italic", - "bold italic" - ], - "type": "string" - }, - "fore_color": { - "description": "前景色/字体颜色。", - "type": "string" - }, - "text_decoration": { - "description": "文字装饰:none=无,underline=下划线,strikethrough=删除线,underline_strikethrough=下划线+删除线。", - "enum": [ - "none", - "underline", - "strikethrough", - "underline_strikethrough" - ], - "type": "string" - } - }, - "type": "object" + "type": "boolean", + "description": "可选。是否存在引用,用于标记规则是否依赖其他对象。" } }, "required": [ @@ -3566,63 +3566,65 @@ "ranges", "style" ], - "type": "object" + "additionalProperties": false } }, "+dropdown-set": { "options": { "description": "列表选项(type='list' 时必填)", + "type": "array", "items": { "type": "string" - }, - "type": "array" + } } }, "+dropdown-update": { "options": { "description": "列表选项(type='list' 时必填)", + "type": "array", "items": { "type": "string" - }, - "type": "array" + } } }, "+filter-create": { "properties": { - "additionalProperties": false, "description": "创建/更新的筛选器属性。", + "type": "object", "properties": { - "filtered_columns": { - "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", - "items": { - "type": "string" - }, - "type": "array" - }, "range": { - "description": "筛选对象作用的单元格范围(A1 表示法)。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。", - "type": "string" + "type": "string", + "description": "筛选对象作用的单元格范围(A1 表示法)。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。" }, "rules": { + "type": "array", "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。update 时传空数组 [] 表示清空所有现有列的规则。", "items": { - "additionalProperties": false, + "type": "object", "description": "单列筛选规则。", "properties": { "column_index": { - "description": "作用的列索引,例如 \"A\"、\"B\"。", - "type": "string" + "type": "string", + "description": "作用的列索引,例如 \"A\"、\"B\"。" }, "conditions": { + "type": "array", "description": "该列上的条件列表。**目前每列仅支持 1 个 condition**(传入多个会被拒绝,请用 multiValue 或单条件表达),数组形式为未来扩展多条件组合预留。", "items": { "oneOf": [ { - "additionalProperties": false, + "type": "object", "description": "文本条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "条件类型固定为 \"text\"。" + }, "compare_type": { - "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。", + "type": "string", "enum": [ "beginsWith", "doesNotBeginWith", @@ -3633,35 +3635,35 @@ "equals", "notEquals" ], - "type": "string" - }, - "type": { - "description": "条件类型固定为 \"text\"。", - "enum": [ - "text" - ], - "type": "string" + "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。" }, "values": { + "type": "array", "description": "可选。用于比较的文本值列表,只需要提供一个值,例如 [\"华东\"]。", "items": { "type": "string" - }, - "type": "array" + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "数值条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "number" + ], + "description": "条件类型固定为 \"number\"。" + }, "compare_type": { - "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。", + "type": "string", "enum": [ "equal", "notEqual", @@ -3672,16 +3674,10 @@ "between", "notBetween" ], - "type": "string" - }, - "type": { - "description": "条件类型固定为 \"number\"。", - "enum": [ - "number" - ], - "type": "string" + "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。" }, "values": { + "type": "array", "description": "可选。用于比较的数值参数列表。大多数比较运算需要 1 个值(如 [5000]),between/notBetween 需要 2 个值(如 [1000, 5000])。", "items": { "oneOf": [ @@ -3692,73 +3688,82 @@ "type": "string" } ] - }, - "type": "array" + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "颜色条件筛选。", "properties": { - "compare_type": { - "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。", + "type": { + "type": "string", "enum": [ - "backgroundColor", - "foregroundColor" + "color" ], - "type": "string" + "description": "条件类型固定为 \"color\"。" }, - "type": { - "description": "条件类型固定为 \"color\"。", + "compare_type": { + "type": "string", "enum": [ - "color" + "backgroundColor", + "foregroundColor" ], - "type": "string" + "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。" }, "value": { - "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。", - "type": "string" + "type": "string", + "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。" } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "多值条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "multiValue" + ], + "description": "条件类型固定为 \"multiValue\"。" + }, "compare_type": { - "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。", + "type": "string", "enum": [ "equal", "notEqual" ], - "type": "string" + "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。" + }, + "values": { + "type": "array", + "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", + "items": { + "type": "string" + } }, "date_groups": { + "type": "array", "description": "可选。年月日等聚合筛选信息。", "items": { - "additionalProperties": false, + "type": "object", "properties": { - "date_time_grouping": { - "enum": [ - "year", - "month", - "day", - "hour", - "minute", - "second" - ], - "type": "string" + "year": { + "type": "number" + }, + "month": { + "type": "number" }, "day": { "type": "number" @@ -3769,105 +3774,102 @@ "minute": { "type": "number" }, - "month": { - "type": "number" - }, "second": { "type": "number" }, - "year": { - "type": "number" - } - }, - "type": "object" - }, - "type": "array" - }, - "type": { - "description": "条件类型固定为 \"multiValue\"。", - "enum": [ - "multiValue" - ], - "type": "string" - }, - "values": { - "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", - "items": { - "type": "string" - }, - "type": "array" + "date_time_grouping": { + "type": "string", + "enum": [ + "year", + "month", + "day", + "hour", + "minute", + "second" + ] + } + }, + "additionalProperties": false + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false } ] - }, - "type": "array" + } }, "filtered_rows": { + "type": "array", "description": "可选。由该列条件直接导致被隐藏的行索引列表(0-based),用于调试或回显。", "items": { "type": "number" - }, - "type": "array" + } } }, "required": [ "column_index", "conditions" ], - "type": "object" - }, - "type": "array" + "additionalProperties": false + } + }, + "filtered_columns": { + "type": "array", + "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", + "items": { + "type": "string" + } } }, "required": [ "range", "rules" ], - "type": "object" + "additionalProperties": false } }, "+filter-update": { "properties": { - "additionalProperties": false, "description": "创建/更新的筛选器属性。", + "type": "object", "properties": { - "filtered_columns": { - "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", - "items": { - "type": "string" - }, - "type": "array" - }, "range": { - "description": "筛选对象作用的单元格范围(A1 表示法)。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。", - "type": "string" + "type": "string", + "description": "筛选对象作用的单元格范围(A1 表示法)。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。" }, "rules": { + "type": "array", "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。update 时传空数组 [] 表示清空所有现有列的规则。", "items": { - "additionalProperties": false, + "type": "object", "description": "单列筛选规则。", "properties": { "column_index": { - "description": "作用的列索引,例如 \"A\"、\"B\"。", - "type": "string" + "type": "string", + "description": "作用的列索引,例如 \"A\"、\"B\"。" }, "conditions": { + "type": "array", "description": "该列上的条件列表。**目前每列仅支持 1 个 condition**(传入多个会被拒绝,请用 multiValue 或单条件表达),数组形式为未来扩展多条件组合预留。", "items": { "oneOf": [ { - "additionalProperties": false, + "type": "object", "description": "文本条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "条件类型固定为 \"text\"。" + }, "compare_type": { - "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。", + "type": "string", "enum": [ "beginsWith", "doesNotBeginWith", @@ -3878,35 +3880,35 @@ "equals", "notEquals" ], - "type": "string" - }, - "type": { - "description": "条件类型固定为 \"text\"。", - "enum": [ - "text" - ], - "type": "string" + "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。" }, "values": { + "type": "array", "description": "可选。用于比较的文本值列表,只需要提供一个值,例如 [\"华东\"]。", "items": { "type": "string" - }, - "type": "array" + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "数值条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "number" + ], + "description": "条件类型固定为 \"number\"。" + }, "compare_type": { - "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。", + "type": "string", "enum": [ "equal", "notEqual", @@ -3917,16 +3919,10 @@ "between", "notBetween" ], - "type": "string" - }, - "type": { - "description": "条件类型固定为 \"number\"。", - "enum": [ - "number" - ], - "type": "string" + "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。" }, "values": { + "type": "array", "description": "可选。用于比较的数值参数列表。大多数比较运算需要 1 个值(如 [5000]),between/notBetween 需要 2 个值(如 [1000, 5000])。", "items": { "oneOf": [ @@ -3937,73 +3933,82 @@ "type": "string" } ] - }, - "type": "array" + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "颜色条件筛选。", "properties": { - "compare_type": { - "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。", + "type": { + "type": "string", "enum": [ - "backgroundColor", - "foregroundColor" + "color" ], - "type": "string" + "description": "条件类型固定为 \"color\"。" }, - "type": { - "description": "条件类型固定为 \"color\"。", + "compare_type": { + "type": "string", "enum": [ - "color" + "backgroundColor", + "foregroundColor" ], - "type": "string" + "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。" }, "value": { - "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。", - "type": "string" + "type": "string", + "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。" } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "多值条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "multiValue" + ], + "description": "条件类型固定为 \"multiValue\"。" + }, "compare_type": { - "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。", + "type": "string", "enum": [ "equal", "notEqual" ], - "type": "string" + "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。" + }, + "values": { + "type": "array", + "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", + "items": { + "type": "string" + } }, "date_groups": { + "type": "array", "description": "可选。年月日等聚合筛选信息。", "items": { - "additionalProperties": false, + "type": "object", "properties": { - "date_time_grouping": { - "enum": [ - "year", - "month", - "day", - "hour", - "minute", - "second" - ], - "type": "string" + "year": { + "type": "number" + }, + "month": { + "type": "number" }, "day": { "type": "number" @@ -4014,105 +4019,106 @@ "minute": { "type": "number" }, - "month": { - "type": "number" - }, "second": { "type": "number" }, - "year": { - "type": "number" + "date_time_grouping": { + "type": "string", + "enum": [ + "year", + "month", + "day", + "hour", + "minute", + "second" + ] } }, - "type": "object" - }, - "type": "array" - }, - "type": { - "description": "条件类型固定为 \"multiValue\"。", - "enum": [ - "multiValue" - ], - "type": "string" - }, - "values": { - "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", - "items": { - "type": "string" - }, - "type": "array" + "additionalProperties": false + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false } ] - }, - "type": "array" + } }, "filtered_rows": { + "type": "array", "description": "可选。由该列条件直接导致被隐藏的行索引列表(0-based),用于调试或回显。", "items": { "type": "number" - }, - "type": "array" + } } }, "required": [ "column_index", "conditions" ], - "type": "object" - }, - "type": "array" + "additionalProperties": false + } + }, + "filtered_columns": { + "type": "array", + "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", + "items": { + "type": "string" + } } }, "required": [ "range", "rules" ], - "type": "object" + "additionalProperties": false } }, "+filter-view-create": { "properties": { - "additionalProperties": false, "description": "create / update 的视图属性。create 必须传 range;update 至少传 view_name / range / rules 之一。delete 禁止传该字段。", + "type": "object", "properties": { - "filtered_columns": { - "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", - "items": { - "type": "string" - }, - "type": "array" + "view_name": { + "type": "string", + "description": "可选。视图名称。create 不传时系统自动分配;create / update 传入时若与现有视图重名,会自动以后缀避让,不会抛错。" }, "range": { - "description": "视图作用的单元格范围(A1 表示法)。create 必填;update 可选——未提供时沿用视图当前 range。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。", - "type": "string" + "type": "string", + "description": "视图作用的单元格范围(A1 表示法)。create 必填;update 可选——未提供时沿用视图当前 range。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。" }, "rules": { + "type": "array", "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。结构与 manage_filter_object.properties.rules 完全一致。", "items": { - "additionalProperties": false, + "type": "object", "description": "单列筛选规则。", "properties": { "column_index": { - "description": "作用的列索引,例如 \"A\"、\"B\"。", - "type": "string" + "type": "string", + "description": "作用的列索引,例如 \"A\"、\"B\"。" }, "conditions": { + "type": "array", "description": "该列上的条件列表。**目前每列仅支持 1 个 condition**(传入多个会被拒绝,请用 multiValue 或单条件表达),数组形式为未来扩展多条件组合预留。", "items": { "oneOf": [ { - "additionalProperties": false, + "type": "object", "description": "文本条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "条件类型固定为 \"text\"。" + }, "compare_type": { - "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。", + "type": "string", "enum": [ "beginsWith", "doesNotBeginWith", @@ -4123,35 +4129,35 @@ "equals", "notEquals" ], - "type": "string" - }, - "type": { - "description": "条件类型固定为 \"text\"。", - "enum": [ - "text" - ], - "type": "string" + "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。" }, "values": { + "type": "array", "description": "可选。用于比较的文本值列表,只需要提供一个值,例如 [\"华东\"]。", "items": { "type": "string" - }, - "type": "array" + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "数值条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "number" + ], + "description": "条件类型固定为 \"number\"。" + }, "compare_type": { - "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。", + "type": "string", "enum": [ "equal", "notEqual", @@ -4162,16 +4168,10 @@ "between", "notBetween" ], - "type": "string" - }, - "type": { - "description": "条件类型固定为 \"number\"。", - "enum": [ - "number" - ], - "type": "string" + "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。" }, "values": { + "type": "array", "description": "可选。用于比较的数值参数列表。大多数比较运算需要 1 个值(如 [5000]),between/notBetween 需要 2 个值(如 [1000, 5000])。", "items": { "oneOf": [ @@ -4182,73 +4182,82 @@ "type": "string" } ] - }, - "type": "array" + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "颜色条件筛选。", "properties": { - "compare_type": { - "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。", + "type": { + "type": "string", "enum": [ - "backgroundColor", - "foregroundColor" + "color" ], - "type": "string" + "description": "条件类型固定为 \"color\"。" }, - "type": { - "description": "条件类型固定为 \"color\"。", + "compare_type": { + "type": "string", "enum": [ - "color" + "backgroundColor", + "foregroundColor" ], - "type": "string" + "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。" }, "value": { - "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。", - "type": "string" + "type": "string", + "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。" } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "多值条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "multiValue" + ], + "description": "条件类型固定为 \"multiValue\"。" + }, "compare_type": { - "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。", + "type": "string", "enum": [ "equal", "notEqual" ], - "type": "string" + "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。" + }, + "values": { + "type": "array", + "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", + "items": { + "type": "string" + } }, "date_groups": { + "type": "array", "description": "可选。年月日等聚合筛选信息。", "items": { - "additionalProperties": false, + "type": "object", "properties": { - "date_time_grouping": { - "enum": [ - "year", - "month", - "day", - "hour", - "minute", - "second" - ], - "type": "string" + "year": { + "type": "number" + }, + "month": { + "type": "number" }, "day": { "type": "number" @@ -4259,105 +4268,102 @@ "minute": { "type": "number" }, - "month": { - "type": "number" - }, "second": { "type": "number" }, - "year": { - "type": "number" + "date_time_grouping": { + "type": "string", + "enum": [ + "year", + "month", + "day", + "hour", + "minute", + "second" + ] } }, - "type": "object" - }, - "type": "array" - }, - "type": { - "description": "条件类型固定为 \"multiValue\"。", - "enum": [ - "multiValue" - ], - "type": "string" - }, - "values": { - "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", - "items": { - "type": "string" - }, - "type": "array" + "additionalProperties": false + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false } ] - }, - "type": "array" + } }, "filtered_rows": { + "type": "array", "description": "可选。由该列条件直接导致被隐藏的行索引列表(0-based),用于调试或回显。", "items": { "type": "number" - }, - "type": "array" + } } }, "required": [ "column_index", "conditions" ], - "type": "object" - }, - "type": "array" + "additionalProperties": false + } }, - "view_name": { - "description": "可选。视图名称。create 不传时系统自动分配;create / update 传入时若与现有视图重名,会自动以后缀避让,不会抛错。", - "type": "string" + "filtered_columns": { + "type": "array", + "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", + "items": { + "type": "string" + } } }, - "type": "object" + "additionalProperties": false } }, "+filter-view-update": { "properties": { - "additionalProperties": false, "description": "create / update 的视图属性。create 必须传 range;update 至少传 view_name / range / rules 之一。delete 禁止传该字段。", + "type": "object", "properties": { - "filtered_columns": { - "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", - "items": { - "type": "string" - }, - "type": "array" + "view_name": { + "type": "string", + "description": "可选。视图名称。create 不传时系统自动分配;create / update 传入时若与现有视图重名,会自动以后缀避让,不会抛错。" }, "range": { - "description": "视图作用的单元格范围(A1 表示法)。create 必填;update 可选——未提供时沿用视图当前 range。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。", - "type": "string" + "type": "string", + "description": "视图作用的单元格范围(A1 表示法)。create 必填;update 可选——未提供时沿用视图当前 range。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。" }, "rules": { + "type": "array", "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。结构与 manage_filter_object.properties.rules 完全一致。", "items": { - "additionalProperties": false, + "type": "object", "description": "单列筛选规则。", "properties": { "column_index": { - "description": "作用的列索引,例如 \"A\"、\"B\"。", - "type": "string" + "type": "string", + "description": "作用的列索引,例如 \"A\"、\"B\"。" }, "conditions": { + "type": "array", "description": "该列上的条件列表。**目前每列仅支持 1 个 condition**(传入多个会被拒绝,请用 multiValue 或单条件表达),数组形式为未来扩展多条件组合预留。", "items": { "oneOf": [ { - "additionalProperties": false, + "type": "object", "description": "文本条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "条件类型固定为 \"text\"。" + }, "compare_type": { - "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。", + "type": "string", "enum": [ "beginsWith", "doesNotBeginWith", @@ -4366,37 +4372,37 @@ "contains", "doesNotContain", "equals", - "notEquals" - ], - "type": "string" - }, - "type": { - "description": "条件类型固定为 \"text\"。", - "enum": [ - "text" + "notEquals" ], - "type": "string" + "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。" }, "values": { + "type": "array", "description": "可选。用于比较的文本值列表,只需要提供一个值,例如 [\"华东\"]。", "items": { "type": "string" - }, - "type": "array" + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "数值条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "number" + ], + "description": "条件类型固定为 \"number\"。" + }, "compare_type": { - "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。", + "type": "string", "enum": [ "equal", "notEqual", @@ -4407,16 +4413,10 @@ "between", "notBetween" ], - "type": "string" - }, - "type": { - "description": "条件类型固定为 \"number\"。", - "enum": [ - "number" - ], - "type": "string" + "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。" }, "values": { + "type": "array", "description": "可选。用于比较的数值参数列表。大多数比较运算需要 1 个值(如 [5000]),between/notBetween 需要 2 个值(如 [1000, 5000])。", "items": { "oneOf": [ @@ -4427,73 +4427,82 @@ "type": "string" } ] - }, - "type": "array" + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "颜色条件筛选。", "properties": { - "compare_type": { - "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。", + "type": { + "type": "string", "enum": [ - "backgroundColor", - "foregroundColor" + "color" ], - "type": "string" + "description": "条件类型固定为 \"color\"。" }, - "type": { - "description": "条件类型固定为 \"color\"。", + "compare_type": { + "type": "string", "enum": [ - "color" + "backgroundColor", + "foregroundColor" ], - "type": "string" + "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。" }, "value": { - "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。", - "type": "string" + "type": "string", + "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。" } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false }, { - "additionalProperties": false, + "type": "object", "description": "多值条件筛选。", "properties": { + "type": { + "type": "string", + "enum": [ + "multiValue" + ], + "description": "条件类型固定为 \"multiValue\"。" + }, "compare_type": { - "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。", + "type": "string", "enum": [ "equal", "notEqual" ], - "type": "string" + "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。" + }, + "values": { + "type": "array", + "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", + "items": { + "type": "string" + } }, "date_groups": { + "type": "array", "description": "可选。年月日等聚合筛选信息。", "items": { - "additionalProperties": false, + "type": "object", "properties": { - "date_time_grouping": { - "enum": [ - "year", - "month", - "day", - "hour", - "minute", - "second" - ], - "type": "string" + "year": { + "type": "number" + }, + "month": { + "type": "number" }, "day": { "type": "number" @@ -4504,288 +4513,148 @@ "minute": { "type": "number" }, - "month": { - "type": "number" - }, "second": { "type": "number" }, - "year": { - "type": "number" + "date_time_grouping": { + "type": "string", + "enum": [ + "year", + "month", + "day", + "hour", + "minute", + "second" + ] } }, - "type": "object" - }, - "type": "array" - }, - "type": { - "description": "条件类型固定为 \"multiValue\"。", - "enum": [ - "multiValue" - ], - "type": "string" - }, - "values": { - "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", - "items": { - "type": "string" - }, - "type": "array" + "additionalProperties": false + } } }, "required": [ "type", "compare_type" ], - "type": "object" + "additionalProperties": false } ] - }, - "type": "array" + } }, "filtered_rows": { + "type": "array", "description": "可选。由该列条件直接导致被隐藏的行索引列表(0-based),用于调试或回显。", "items": { "type": "number" - }, - "type": "array" + } } }, "required": [ "column_index", "conditions" ], - "type": "object" - }, - "type": "array" + "additionalProperties": false + } }, - "view_name": { - "description": "可选。视图名称。create 不传时系统自动分配;create / update 传入时若与现有视图重名,会自动以后缀避让,不会抛错。", - "type": "string" + "filtered_columns": { + "type": "array", + "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", + "items": { + "type": "string" + } } }, - "type": "object" + "additionalProperties": false } }, "+pivot-create": { "properties": { - "additionalProperties": {}, "description": "创建/更新的透视表属性。", + "type": "object", "properties": { - "auto_fit_col": { - "description": "是否自动调整列宽以适应内容", - "type": "boolean" - }, - "calculated_fields": { - "description": "计算字段列表", - "items": { - "properties": { - "formula": { - "description": "公式表达式,引用其他字段名,例如 \"'Sales' + 'Tax'\"", - "type": "string" - }, - "name": { - "description": "计算字段的显示名称", - "type": "string" - }, - "summarize_by": { - "default": "sum", - "description": "计算字段汇总方式(默认 'sum',仅支持 sum/custom 两种):'sum' 表示先对 formula 中引用的各源字段分别求和后再代入公式(formula 是逐字段求和后的标量表达式,例如 \"'Sales'+'Tax'\" 等价于 SUM(Sales)+SUM(Tax));'custom' 表示数组直接传给 formula,要求 formula 自带聚合函数(例如 \"SUM('Sales')+SUM('Tax')\" 或 \"AVERAGE('Sales')\")。需要 count/average/max/min 等其他聚合方式时,请改用 values[] 字段对源字段直接聚合,calculated_fields 不适用。", - "enum": [ - "sum", - "custom" - ], - "type": "string" - } - }, - "required": [ - "name", - "formula" - ], - "type": "object" - }, - "type": "array" + "range": { + "type": "string", + "description": "放置透视表的左上角单元格 A1 地址(例如:'F1')(仅 create 时有效)。可省略——省略后透视表放在新建子表的 A1 位置。" }, - "collapse": { - "additionalProperties": { - "items": { - "type": "string" - }, - "type": "array" - }, - "description": "行字段展开/折叠状态:字段名 -> 要折叠的项目列表", - "type": "object" + "source": { + "type": "string", + "description": "源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100')。create 时必填;update 时提供则切换数据源。" }, - "columns": { - "description": "横向分组字段(列字段)", + "rows": { + "description": "纵向分组字段(行字段)", + "type": "array", "items": { + "type": "object", "properties": { - "condition_filter": { - "description": "条件筛选:按文本/数值/日期条件筛选", - "properties": { - "operator": { - "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", - "type": "string" - }, - "type": { - "description": "条件类型", - "enum": [ - "text", - "number", - "date" - ], - "type": "string" - }, - "value": { - "description": "比较值" - }, - "value2": { - "description": "'between'/'notBetween' 的第二个边界值" - } - }, - "required": [ - "type", - "operator" - ], - "type": "object" - }, - "display_name": { - "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。", - "type": "string" - }, "field": { - "description": "源数据中的字段名(OOXML 字段引用)", - "type": "string" + "type": "string", + "description": "源数据中的字段名(OOXML 字段引用)" }, - "filter": { - "description": "快速筛选:只显示指定的项目", - "properties": { - "items": { - "description": "要显示的项目列表(其余项目被隐藏)", - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "items" - ], - "type": "object" + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用;否则保持源字段原名,透视表产物已足够清晰。" }, - "group": { - "description": "分组配置", + "sort": { + "type": "object", + "description": "排序配置", "properties": { - "date_group_by": { - "description": "日期分组粒度(type='date' 时必填)", + "order": { + "type": "string", "enum": [ - "year", - "yearMonth", - "yearQuarter", - "yearMonthDate", - "quarter", - "month", - "monthDate", - "date", - "hour", - "hourMinute", - "minute" + "asc", + "desc" ], - "type": "string" - }, - "end": { - "description": "数值分组结束值", - "type": "number" - }, - "groups": { - "additionalProperties": { - "items": { - "type": "string" - }, - "type": "array" - }, - "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", - "type": "object" - }, - "interval": { - "description": "数值分组间隔(type='number' 时必填)", - "type": "number" - }, - "start": { - "description": "数值分组起始值", - "type": "number" + "description": "排序方向" }, - "type": { - "description": "分组类型", - "enum": [ - "date", - "number", - "element" - ], - "type": "string" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - "sort": { - "description": "排序配置", - "properties": { "by": { - "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序", + "type": "string", "enum": [ "label", "value" ], - "type": "string" - }, - "order": { - "description": "排序方向", - "enum": [ - "asc", - "desc" - ], - "type": "string" + "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序" }, "value_field": { - "description": "by='value' 时必填,指定按哪个值字段排序", - "type": "string" + "type": "string", + "description": "by='value' 时必填,指定按哪个值字段排序" } }, "required": [ "order" - ], - "type": "object" - } - }, - "required": [ - "field" - ], - "type": "object" - }, - "type": "array" - }, - "filters": { - "description": "筛选区域字段(页字段)", - "items": { - "properties": { + ] + }, + "filter": { + "type": "object", + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + }, + "description": "要显示的项目列表(其余项目被隐藏)" + } + }, + "required": [ + "items" + ] + }, "condition_filter": { + "type": "object", "description": "条件筛选:按文本/数值/日期条件筛选", "properties": { - "operator": { - "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", - "type": "string" - }, "type": { - "description": "条件类型", + "type": "string", "enum": [ "text", "number", "date" ], - "type": "string" + "description": "条件类型" + }, + "operator": { + "type": "string", + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" }, "value": { "description": "比较值" @@ -4797,38 +4666,23 @@ "required": [ "type", "operator" - ], - "type": "object" - }, - "display_name": { - "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。", - "type": "string" - }, - "field": { - "description": "源数据中的字段名(OOXML 字段引用)", - "type": "string" - }, - "filter": { - "description": "快速筛选:只显示指定的项目", - "properties": { - "items": { - "description": "要显示的项目列表(其余项目被隐藏)", - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "items" - ], - "type": "object" + ] }, "group": { + "type": "object", "description": "分组配置", "properties": { + "type": { + "type": "string", + "enum": [ + "date", + "number", + "element" + ], + "description": "分组类型" + }, "date_group_by": { - "description": "日期分组粒度(type='date' 时必填)", + "type": "string", "enum": [ "year", "yearMonth", @@ -4842,123 +4696,144 @@ "hourMinute", "minute" ], - "type": "string" + "description": "日期分组粒度(type='date' 时必填)" + }, + "interval": { + "type": "number", + "description": "数值分组间隔(type='number' 时必填)" + }, + "start": { + "type": "number", + "description": "数值分组起始值" }, "end": { - "description": "数值分组结束值", - "type": "number" + "type": "number", + "description": "数值分组结束值" }, "groups": { + "type": "object", + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", "additionalProperties": { + "type": "array", "items": { "type": "string" - }, - "type": "array" - }, - "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", - "type": "object" - }, - "interval": { - "description": "数值分组间隔(type='number' 时必填)", - "type": "number" - }, - "start": { - "description": "数值分组起始值", - "type": "number" - }, - "type": { - "description": "分组类型", - "enum": [ - "date", - "number", - "element" - ], - "type": "string" + } + } } }, "required": [ "type" - ], - "type": "object" + ] } }, "required": [ "field" - ], - "type": "object" - }, - "type": "array" - }, - "range": { - "description": "放置透视表的左上角单元格 A1 地址(例如:'F1')(仅 create 时有效)。可省略——省略后透视表放在新建子表的 A1 位置。", - "type": "string" - }, - "repeat_row_labels": { - "description": "是否显示重复项标签", - "type": "boolean" + ] + } }, - "rows": { - "description": "纵向分组字段(行字段)", + "columns": { + "description": "横向分组字段(列字段)", + "type": "array", "items": { + "type": "object", "properties": { - "condition_filter": { - "description": "条件筛选:按文本/数值/日期条件筛选", + "field": { + "type": "string", + "description": "源数据中的字段名(OOXML 字段引用)" + }, + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。" + }, + "sort": { + "type": "object", + "description": "排序配置", "properties": { - "operator": { - "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", - "type": "string" - }, - "type": { - "description": "条件类型", + "order": { + "type": "string", "enum": [ - "text", - "number", - "date" + "asc", + "desc" ], - "type": "string" + "description": "排序方向" }, - "value": { - "description": "比较值" + "by": { + "type": "string", + "enum": [ + "label", + "value" + ], + "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序" }, - "value2": { - "description": "'between'/'notBetween' 的第二个边界值" + "value_field": { + "type": "string", + "description": "by='value' 时必填,指定按哪个值字段排序" } }, "required": [ - "type", - "operator" - ], - "type": "object" - }, - "display_name": { - "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用;否则保持源字段原名,透视表产物已足够清晰。", - "type": "string" - }, - "field": { - "description": "源数据中的字段名(OOXML 字段引用)", - "type": "string" + "order" + ] }, "filter": { + "type": "object", "description": "快速筛选:只显示指定的项目", "properties": { "items": { - "description": "要显示的项目列表(其余项目被隐藏)", + "type": "array", "items": { "type": "string" }, - "type": "array" + "description": "要显示的项目列表(其余项目被隐藏)" } }, "required": [ "items" - ], - "type": "object" + ] + }, + "condition_filter": { + "type": "object", + "description": "条件筛选:按文本/数值/日期条件筛选", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "number", + "date" + ], + "description": "条件类型" + }, + "operator": { + "type": "string", + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" + }, + "value": { + "description": "比较值" + }, + "value2": { + "description": "'between'/'notBetween' 的第二个边界值" + } + }, + "required": [ + "type", + "operator" + ] }, "group": { + "type": "object", "description": "分组配置", "properties": { + "type": { + "type": "string", + "enum": [ + "date", + "number", + "element" + ], + "description": "分组类型" + }, "date_group_by": { - "description": "日期分组粒度(type='date' 时必填)", + "type": "string", "enum": [ "year", "yearMonth", @@ -4972,130 +4847,181 @@ "hourMinute", "minute" ], - "type": "string" + "description": "日期分组粒度(type='date' 时必填)" + }, + "interval": { + "type": "number", + "description": "数值分组间隔(type='number' 时必填)" + }, + "start": { + "type": "number", + "description": "数值分组起始值" }, "end": { - "description": "数值分组结束值", - "type": "number" + "type": "number", + "description": "数值分组结束值" }, "groups": { + "type": "object", + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", "additionalProperties": { + "type": "array", "items": { "type": "string" - }, - "type": "array" + } + } + } + }, + "required": [ + "type" + ] + } + }, + "required": [ + "field" + ] + } + }, + "filters": { + "description": "筛选区域字段(页字段)", + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "源数据中的字段名(OOXML 字段引用)" + }, + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。" + }, + "filter": { + "type": "object", + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" }, - "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", - "type": "object" - }, - "interval": { - "description": "数值分组间隔(type='number' 时必填)", - "type": "number" - }, - "start": { - "description": "数值分组起始值", - "type": "number" - }, + "description": "要显示的项目列表(其余项目被隐藏)" + } + }, + "required": [ + "items" + ] + }, + "condition_filter": { + "type": "object", + "description": "条件筛选:按文本/数值/日期条件筛选", + "properties": { "type": { - "description": "分组类型", + "type": "string", "enum": [ - "date", + "text", "number", - "element" + "date" ], - "type": "string" + "description": "条件类型" + }, + "operator": { + "type": "string", + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" + }, + "value": { + "description": "比较值" + }, + "value2": { + "description": "'between'/'notBetween' 的第二个边界值" } }, "required": [ - "type" - ], - "type": "object" + "type", + "operator" + ] }, - "sort": { - "description": "排序配置", + "group": { + "type": "object", + "description": "分组配置", "properties": { - "by": { - "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序", + "type": { + "type": "string", "enum": [ - "label", - "value" + "date", + "number", + "element" + ], + "description": "分组类型" + }, + "date_group_by": { + "type": "string", + "enum": [ + "year", + "yearMonth", + "yearQuarter", + "yearMonthDate", + "quarter", + "month", + "monthDate", + "date", + "hour", + "hourMinute", + "minute" ], - "type": "string" + "description": "日期分组粒度(type='date' 时必填)" }, - "order": { - "description": "排序方向", - "enum": [ - "asc", - "desc" - ], - "type": "string" + "interval": { + "type": "number", + "description": "数值分组间隔(type='number' 时必填)" }, - "value_field": { - "description": "by='value' 时必填,指定按哪个值字段排序", - "type": "string" + "start": { + "type": "number", + "description": "数值分组起始值" + }, + "end": { + "type": "number", + "description": "数值分组结束值" + }, + "groups": { + "type": "object", + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } } }, "required": [ - "order" - ], - "type": "object" + "type" + ] } }, "required": [ "field" - ], - "type": "object" - }, - "type": "array" - }, - "show_col_grand_total": { - "description": "是否显示列总计(默认 true)", - "type": "boolean" - }, - "show_row_grand_total": { - "description": "是否显示行总计(默认 true)", - "type": "boolean" - }, - "show_subtotals": { - "description": "是否显示分类小计(默认 true,应用于所有字段)", - "type": "boolean" - }, - "source": { - "description": "源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100')。create 时必填;update 时提供则切换数据源。", - "type": "string" + ] + } }, "values": { - "description": "要汇总的字段(至少需要 1 个)", + "minItems": 1, + "type": "array", "items": { + "type": "object", "properties": { - "base_field": { - "description": "show_data_as 需要基准字段时的字段名", - "type": "string" - }, - "display_name": { - "description": "字段在透视表中的显示名(典型场景:把 count(销售额) 的值列头显示为 'GMV')。不传则使用源字段原名。仅在用户明确要求重命名时使用。", - "type": "string" - }, "field": { - "description": "要汇总的源数据字段名", - "type": "string" + "type": "string", + "description": "要汇总的源数据字段名" }, - "show_data_as": { - "description": "值显示方式(默认 'normal')", - "enum": [ - "normal", - "percentOfTotal", - "percentOfCol", - "percentOfRow", - "percentOfParentRow", - "percentOfParentCol", - "index" - ], - "type": "string" + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名(典型场景:把 count(销售额) 的值列头显示为 'GMV')。不传则使用源字段原名。仅在用户明确要求重命名时使用。" }, "summarize_by": { "default": "sum", "description": "汇总函数", + "type": "string", "enum": [ "sum", "count", @@ -5110,133 +5036,212 @@ "varp", "distinct", "median" - ], - "type": "string" + ] + }, + "show_data_as": { + "type": "string", + "description": "值显示方式(默认 'normal')", + "enum": [ + "normal", + "percentOfTotal", + "percentOfCol", + "percentOfRow", + "percentOfParentRow", + "percentOfParentCol", + "index" + ] + }, + "base_field": { + "type": "string", + "description": "show_data_as 需要基准字段时的字段名" } }, "required": [ "field" - ], - "type": "object" + ] }, - "minItems": 1, - "type": "array" - } - }, - "type": "object" - } - }, - "+pivot-update": { - "properties": { - "additionalProperties": {}, - "description": "创建/更新的透视表属性。", - "properties": { + "description": "要汇总的字段(至少需要 1 个)" + }, "auto_fit_col": { - "description": "是否自动调整列宽以适应内容", - "type": "boolean" + "type": "boolean", + "description": "是否自动调整列宽以适应内容" + }, + "show_row_grand_total": { + "type": "boolean", + "description": "是否显示行总计(默认 true)" + }, + "show_col_grand_total": { + "type": "boolean", + "description": "是否显示列总计(默认 true)" + }, + "show_subtotals": { + "type": "boolean", + "description": "是否显示分类小计(默认 true,应用于所有字段)" + }, + "repeat_row_labels": { + "type": "boolean", + "description": "是否显示重复项标签" }, "calculated_fields": { + "type": "array", "description": "计算字段列表", "items": { + "type": "object", "properties": { - "formula": { - "description": "公式表达式,引用其他字段名,例如 \"'Sales' + 'Tax'\"", - "type": "string" - }, "name": { - "description": "计算字段的显示名称", - "type": "string" + "type": "string", + "description": "计算字段的显示名称" + }, + "formula": { + "type": "string", + "description": "公式表达式,引用其他字段名,例如 \"'Sales' + 'Tax'\"" }, "summarize_by": { + "type": "string", "default": "sum", "description": "计算字段汇总方式(默认 'sum',仅支持 sum/custom 两种):'sum' 表示先对 formula 中引用的各源字段分别求和后再代入公式(formula 是逐字段求和后的标量表达式,例如 \"'Sales'+'Tax'\" 等价于 SUM(Sales)+SUM(Tax));'custom' 表示数组直接传给 formula,要求 formula 自带聚合函数(例如 \"SUM('Sales')+SUM('Tax')\" 或 \"AVERAGE('Sales')\")。需要 count/average/max/min 等其他聚合方式时,请改用 values[] 字段对源字段直接聚合,calculated_fields 不适用。", "enum": [ "sum", "custom" - ], - "type": "string" + ] } }, "required": [ "name", "formula" - ], - "type": "object" - }, - "type": "array" + ] + } }, "collapse": { + "type": "object", + "description": "行字段展开/折叠状态:字段名 -> 要折叠的项目列表", "additionalProperties": { + "type": "array", "items": { "type": "string" - }, - "type": "array" - }, - "description": "行字段展开/折叠状态:字段名 -> 要折叠的项目列表", - "type": "object" + } + } + } + }, + "additionalProperties": {} + } + }, + "+pivot-update": { + "properties": { + "description": "创建/更新的透视表属性。", + "type": "object", + "properties": { + "range": { + "type": "string", + "description": "放置透视表的左上角单元格 A1 地址(例如:'F1')(仅 create 时有效)。可省略——省略后透视表放在新建子表的 A1 位置。" }, - "columns": { - "description": "横向分组字段(列字段)", + "source": { + "type": "string", + "description": "源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100')。create 时必填;update 时提供则切换数据源。" + }, + "rows": { + "description": "纵向分组字段(行字段)", + "type": "array", "items": { + "type": "object", "properties": { - "condition_filter": { - "description": "条件筛选:按文本/数值/日期条件筛选", + "field": { + "type": "string", + "description": "源数据中的字段名(OOXML 字段引用)" + }, + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用;否则保持源字段原名,透视表产物已足够清晰。" + }, + "sort": { + "type": "object", + "description": "排序配置", "properties": { - "operator": { - "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", - "type": "string" - }, - "type": { - "description": "条件类型", + "order": { + "type": "string", "enum": [ - "text", - "number", - "date" + "asc", + "desc" ], - "type": "string" + "description": "排序方向" }, - "value": { - "description": "比较值" + "by": { + "type": "string", + "enum": [ + "label", + "value" + ], + "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序" }, - "value2": { - "description": "'between'/'notBetween' 的第二个边界值" + "value_field": { + "type": "string", + "description": "by='value' 时必填,指定按哪个值字段排序" } }, "required": [ - "type", - "operator" - ], - "type": "object" - }, - "display_name": { - "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。", - "type": "string" - }, - "field": { - "description": "源数据中的字段名(OOXML 字段引用)", - "type": "string" + "order" + ] }, "filter": { + "type": "object", "description": "快速筛选:只显示指定的项目", "properties": { "items": { - "description": "要显示的项目列表(其余项目被隐藏)", + "type": "array", "items": { "type": "string" }, - "type": "array" + "description": "要显示的项目列表(其余项目被隐藏)" } }, "required": [ "items" - ], - "type": "object" + ] + }, + "condition_filter": { + "type": "object", + "description": "条件筛选:按文本/数值/日期条件筛选", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "number", + "date" + ], + "description": "条件类型" + }, + "operator": { + "type": "string", + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" + }, + "value": { + "description": "比较值" + }, + "value2": { + "description": "'between'/'notBetween' 的第二个边界值" + } + }, + "required": [ + "type", + "operator" + ] }, "group": { + "type": "object", "description": "分组配置", "properties": { + "type": { + "type": "string", + "enum": [ + "date", + "number", + "element" + ], + "description": "分组类型" + }, "date_group_by": { - "description": "日期分组粒度(type='date' 时必填)", + "type": "string", "enum": [ "year", "yearMonth", @@ -5250,101 +5255,116 @@ "hourMinute", "minute" ], - "type": "string" + "description": "日期分组粒度(type='date' 时必填)" + }, + "interval": { + "type": "number", + "description": "数值分组间隔(type='number' 时必填)" + }, + "start": { + "type": "number", + "description": "数值分组起始值" }, "end": { - "description": "数值分组结束值", - "type": "number" + "type": "number", + "description": "数值分组结束值" }, "groups": { + "type": "object", + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", "additionalProperties": { + "type": "array", "items": { "type": "string" - }, - "type": "array" - }, - "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", - "type": "object" - }, - "interval": { - "description": "数值分组间隔(type='number' 时必填)", - "type": "number" - }, - "start": { - "description": "数值分组起始值", - "type": "number" - }, - "type": { - "description": "分组类型", - "enum": [ - "date", - "number", - "element" - ], - "type": "string" + } + } } }, "required": [ "type" - ], - "type": "object" + ] + } + }, + "required": [ + "field" + ] + } + }, + "columns": { + "description": "横向分组字段(列字段)", + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "源数据中的字段名(OOXML 字段引用)" + }, + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。" }, "sort": { + "type": "object", "description": "排序配置", "properties": { - "by": { - "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序", - "enum": [ - "label", - "value" - ], - "type": "string" - }, "order": { - "description": "排序方向", + "type": "string", "enum": [ "asc", "desc" ], - "type": "string" + "description": "排序方向" + }, + "by": { + "type": "string", + "enum": [ + "label", + "value" + ], + "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序" }, "value_field": { - "description": "by='value' 时必填,指定按哪个值字段排序", - "type": "string" + "type": "string", + "description": "by='value' 时必填,指定按哪个值字段排序" } }, "required": [ "order" - ], - "type": "object" - } - }, - "required": [ - "field" - ], - "type": "object" - }, - "type": "array" - }, - "filters": { - "description": "筛选区域字段(页字段)", - "items": { - "properties": { + ] + }, + "filter": { + "type": "object", + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + }, + "description": "要显示的项目列表(其余项目被隐藏)" + } + }, + "required": [ + "items" + ] + }, "condition_filter": { + "type": "object", "description": "条件筛选:按文本/数值/日期条件筛选", "properties": { - "operator": { - "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", - "type": "string" - }, "type": { - "description": "条件类型", + "type": "string", "enum": [ "text", "number", "date" ], - "type": "string" + "description": "条件类型" + }, + "operator": { + "type": "string", + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" }, "value": { "description": "比较值" @@ -5356,38 +5376,23 @@ "required": [ "type", "operator" - ], - "type": "object" - }, - "display_name": { - "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。", - "type": "string" - }, - "field": { - "description": "源数据中的字段名(OOXML 字段引用)", - "type": "string" - }, - "filter": { - "description": "快速筛选:只显示指定的项目", - "properties": { - "items": { - "description": "要显示的项目列表(其余项目被隐藏)", - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "items" - ], - "type": "object" + ] }, "group": { + "type": "object", "description": "分组配置", "properties": { + "type": { + "type": "string", + "enum": [ + "date", + "number", + "element" + ], + "description": "分组类型" + }, "date_group_by": { - "description": "日期分组粒度(type='date' 时必填)", + "type": "string", "enum": [ "year", "yearMonth", @@ -5401,80 +5406,87 @@ "hourMinute", "minute" ], - "type": "string" + "description": "日期分组粒度(type='date' 时必填)" + }, + "interval": { + "type": "number", + "description": "数值分组间隔(type='number' 时必填)" + }, + "start": { + "type": "number", + "description": "数值分组起始值" }, "end": { - "description": "数值分组结束值", - "type": "number" + "type": "number", + "description": "数值分组结束值" }, "groups": { + "type": "object", + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", "additionalProperties": { + "type": "array", "items": { "type": "string" - }, - "type": "array" - }, - "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", - "type": "object" - }, - "interval": { - "description": "数值分组间隔(type='number' 时必填)", - "type": "number" - }, - "start": { - "description": "数值分组起始值", - "type": "number" - }, - "type": { - "description": "分组类型", - "enum": [ - "date", - "number", - "element" - ], - "type": "string" + } + } } }, "required": [ "type" - ], - "type": "object" + ] } }, "required": [ "field" - ], - "type": "object" - }, - "type": "array" - }, - "range": { - "description": "放置透视表的左上角单元格 A1 地址(例如:'F1')(仅 create 时有效)。可省略——省略后透视表放在新建子表的 A1 位置。", - "type": "string" - }, - "repeat_row_labels": { - "description": "是否显示重复项标签", - "type": "boolean" + ] + } }, - "rows": { - "description": "纵向分组字段(行字段)", + "filters": { + "description": "筛选区域字段(页字段)", + "type": "array", "items": { + "type": "object", "properties": { + "field": { + "type": "string", + "description": "源数据中的字段名(OOXML 字段引用)" + }, + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。" + }, + "filter": { + "type": "object", + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + }, + "description": "要显示的项目列表(其余项目被隐藏)" + } + }, + "required": [ + "items" + ] + }, "condition_filter": { + "type": "object", "description": "条件筛选:按文本/数值/日期条件筛选", "properties": { - "operator": { - "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month", - "type": "string" - }, "type": { - "description": "条件类型", + "type": "string", "enum": [ "text", "number", "date" ], - "type": "string" + "description": "条件类型" + }, + "operator": { + "type": "string", + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" }, "value": { "description": "比较值" @@ -5486,38 +5498,23 @@ "required": [ "type", "operator" - ], - "type": "object" - }, - "display_name": { - "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用;否则保持源字段原名,透视表产物已足够清晰。", - "type": "string" - }, - "field": { - "description": "源数据中的字段名(OOXML 字段引用)", - "type": "string" - }, - "filter": { - "description": "快速筛选:只显示指定的项目", - "properties": { - "items": { - "description": "要显示的项目列表(其余项目被隐藏)", - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "items" - ], - "type": "object" + ] }, "group": { + "type": "object", "description": "分组配置", "properties": { + "type": { + "type": "string", + "enum": [ + "date", + "number", + "element" + ], + "description": "分组类型" + }, "date_group_by": { - "description": "日期分组粒度(type='date' 时必填)", + "type": "string", "enum": [ "year", "yearMonth", @@ -5531,130 +5528,59 @@ "hourMinute", "minute" ], - "type": "string" + "description": "日期分组粒度(type='date' 时必填)" + }, + "interval": { + "type": "number", + "description": "数值分组间隔(type='number' 时必填)" + }, + "start": { + "type": "number", + "description": "数值分组起始值" }, "end": { - "description": "数值分组结束值", - "type": "number" + "type": "number", + "description": "数值分组结束值" }, "groups": { + "type": "object", + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", "additionalProperties": { + "type": "array", "items": { "type": "string" - }, - "type": "array" - }, - "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", - "type": "object" - }, - "interval": { - "description": "数值分组间隔(type='number' 时必填)", - "type": "number" - }, - "start": { - "description": "数值分组起始值", - "type": "number" - }, - "type": { - "description": "分组类型", - "enum": [ - "date", - "number", - "element" - ], - "type": "string" + } + } } }, "required": [ "type" - ], - "type": "object" - }, - "sort": { - "description": "排序配置", - "properties": { - "by": { - "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序", - "enum": [ - "label", - "value" - ], - "type": "string" - }, - "order": { - "description": "排序方向", - "enum": [ - "asc", - "desc" - ], - "type": "string" - }, - "value_field": { - "description": "by='value' 时必填,指定按哪个值字段排序", - "type": "string" - } - }, - "required": [ - "order" - ], - "type": "object" + ] } }, "required": [ "field" - ], - "type": "object" - }, - "type": "array" - }, - "show_col_grand_total": { - "description": "是否显示列总计(默认 true)", - "type": "boolean" - }, - "show_row_grand_total": { - "description": "是否显示行总计(默认 true)", - "type": "boolean" - }, - "show_subtotals": { - "description": "是否显示分类小计(默认 true,应用于所有字段)", - "type": "boolean" - }, - "source": { - "description": "源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100')。create 时必填;update 时提供则切换数据源。", - "type": "string" + ] + } }, "values": { - "description": "要汇总的字段(至少需要 1 个)", + "minItems": 1, + "type": "array", "items": { - "properties": { - "base_field": { - "description": "show_data_as 需要基准字段时的字段名", - "type": "string" - }, - "display_name": { - "description": "字段在透视表中的显示名(典型场景:把 count(销售额) 的值列头显示为 'GMV')。不传则使用源字段原名。仅在用户明确要求重命名时使用。", - "type": "string" - }, + "type": "object", + "properties": { "field": { - "description": "要汇总的源数据字段名", - "type": "string" + "type": "string", + "description": "要汇总的源数据字段名" }, - "show_data_as": { - "description": "值显示方式(默认 'normal')", - "enum": [ - "normal", - "percentOfTotal", - "percentOfCol", - "percentOfRow", - "percentOfParentRow", - "percentOfParentCol", - "index" - ], - "type": "string" + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名(典型场景:把 count(销售额) 的值列头显示为 'GMV')。不传则使用源字段原名。仅在用户明确要求重命名时使用。" }, "summarize_by": { "default": "sum", "description": "汇总函数", + "type": "string", "enum": [ "sum", "count", @@ -5669,633 +5595,707 @@ "varp", "distinct", "median" - ], - "type": "string" + ] + }, + "show_data_as": { + "type": "string", + "description": "值显示方式(默认 'normal')", + "enum": [ + "normal", + "percentOfTotal", + "percentOfCol", + "percentOfRow", + "percentOfParentRow", + "percentOfParentCol", + "index" + ] + }, + "base_field": { + "type": "string", + "description": "show_data_as 需要基准字段时的字段名" } }, "required": [ "field" - ], - "type": "object" + ] }, - "minItems": 1, - "type": "array" + "description": "要汇总的字段(至少需要 1 个)" + }, + "auto_fit_col": { + "type": "boolean", + "description": "是否自动调整列宽以适应内容" + }, + "show_row_grand_total": { + "type": "boolean", + "description": "是否显示行总计(默认 true)" + }, + "show_col_grand_total": { + "type": "boolean", + "description": "是否显示列总计(默认 true)" + }, + "show_subtotals": { + "type": "boolean", + "description": "是否显示分类小计(默认 true,应用于所有字段)" + }, + "repeat_row_labels": { + "type": "boolean", + "description": "是否显示重复项标签" + }, + "calculated_fields": { + "type": "array", + "description": "计算字段列表", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "计算字段的显示名称" + }, + "formula": { + "type": "string", + "description": "公式表达式,引用其他字段名,例如 \"'Sales' + 'Tax'\"" + }, + "summarize_by": { + "type": "string", + "default": "sum", + "description": "计算字段汇总方式(默认 'sum',仅支持 sum/custom 两种):'sum' 表示先对 formula 中引用的各源字段分别求和后再代入公式(formula 是逐字段求和后的标量表达式,例如 \"'Sales'+'Tax'\" 等价于 SUM(Sales)+SUM(Tax));'custom' 表示数组直接传给 formula,要求 formula 自带聚合函数(例如 \"SUM('Sales')+SUM('Tax')\" 或 \"AVERAGE('Sales')\")。需要 count/average/max/min 等其他聚合方式时,请改用 values[] 字段对源字段直接聚合,calculated_fields 不适用。", + "enum": [ + "sum", + "custom" + ] + } + }, + "required": [ + "name", + "formula" + ] + } + }, + "collapse": { + "type": "object", + "description": "行字段展开/折叠状态:字段名 -> 要折叠的项目列表", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } } }, - "type": "object" + "additionalProperties": {} } }, "+range-sort": { "sort-keys": { - "description": "排序条件列表(仅 sort 操作)。支持多级排序,靠前的条件优先级更高。", + "type": "array", "items": { + "type": "object", "properties": { - "ascending": { - "description": "是否升序排序", - "type": "boolean" - }, "column": { - "description": "排序依据的列字母(如 \"C\"、\"D\"),必须在 range 范围内", - "type": "string" + "type": "string", + "description": "排序依据的列字母(如 \"C\"、\"D\"),必须在 range 范围内" + }, + "ascending": { + "type": "boolean", + "description": "是否升序排序" } }, "required": [ "column", "ascending" - ], - "type": "object" + ] }, - "type": "array" + "description": "排序条件列表(仅 sort 操作)。支持多级排序,靠前的条件优先级更高。" } }, "+sparkline-create": { "properties": { - "additionalProperties": false, "description": "创建/更新/部分删除的迷你图属性。delete 时不传 sparklines 即删整组,传则删指定项。", + "type": "object", "properties": { "config": { - "additionalProperties": false, + "type": "object", "description": "迷你图样式配置, 相同 groupId 的迷你图共享相同的样式。", "properties": { - "axis": { - "additionalProperties": false, - "description": "坐标轴配置,包含坐标轴颜色、是否翻转、是否显示坐标轴。", - "properties": { - "color": { - "description": "坐标轴颜色。", - "type": "string" - }, - "reverse": { - "description": "是否翻转坐标轴方向。", - "type": "boolean" - }, - "visible": { - "description": "是否显示坐标轴。", - "type": "boolean" - } - }, - "type": "object" - }, - "contain_hidden_cells": { - "description": "隐藏的单元格数据是否参与绘制。", - "type": "boolean" + "theme_type": { + "type": "string", + "enum": [ + "pro", + "light", + "soft", + "brand", + "fresh" + ], + "description": "主题类型:pro、light、soft、brand、fresh。" }, - "empty_show_as": { - "description": "空单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。", + "non_num_show_as": { + "type": "string", "enum": [ "zero", "gap", "average" ], - "type": "string" - }, - "extremum_max": { - "additionalProperties": false, - "description": "最大极值配置,包含极值类型、极值。", - "properties": { - "type": { - "description": "极值类型,可选值:individual=单个迷你图极值,group=同组内极值,custom=自定义。", - "enum": [ - "individual", - "group", - "custom" - ], - "type": "string" - }, - "value": { - "description": "当 type='custom' 时生效的具体数值。", - "type": "number" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - "extremum_min": { - "additionalProperties": false, - "description": "最小极值配置,包含极值类型、极值。", - "properties": { - "type": { - "description": "极值类型:individual=单个迷你图极值,group=同组内极值,custom=自定义。", - "enum": [ - "individual", - "group", - "custom" - ], - "type": "string" - }, - "value": { - "description": "当 type='custom' 时生效的具体数值。", - "type": "number" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - "line_width": { - "description": "折线图线宽,可选值:1=1px,2=2px,3=3px,4=4px。", - "enum": [ - 1, - 2, - 3, - 4 - ], - "type": "number" + "description": "非数字单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。" }, - "non_num_show_as": { - "description": "非数字单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。", + "empty_show_as": { + "type": "string", "enum": [ "zero", "gap", "average" ], - "type": "string" + "description": "空单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。" + }, + "contain_hidden_cells": { + "type": "boolean", + "description": "隐藏的单元格数据是否参与绘制。" + }, + "series_color": { + "type": "string", + "description": "主系列颜色,例如 \"#4472C4\"。" }, "points": { - "additionalProperties": false, + "type": "object", "description": "特殊点样式配置,包含高点、低点、标记点、首点、尾点、负点。", "properties": { - "first_point": { - "additionalProperties": false, - "description": "首点配置,第一个数据点的样式。", + "last_point": { + "type": "object", + "description": "尾点配置,最后一个数据点的样式。", "properties": { "color": { - "description": "点的颜色。", - "type": "string" + "type": "string", + "description": "点的颜色。" }, "visible": { - "description": "是否显示该点。", - "type": "boolean" + "type": "boolean", + "description": "是否显示该点。" } }, - "type": "object" + "additionalProperties": false }, - "high_point": { - "additionalProperties": false, - "description": "高点配置,最高点的样式。", + "negative_point": { + "type": "object", + "description": "负点配置,负数点的样式。", "properties": { "color": { - "description": "点的颜色。", - "type": "string" + "type": "string", + "description": "点的颜色。" }, "visible": { - "description": "是否显示该点。", - "type": "boolean" + "type": "boolean", + "description": "是否显示该点。" } }, - "type": "object" + "additionalProperties": false }, - "last_point": { - "additionalProperties": false, - "description": "尾点配置,最后一个数据点的样式。", + "markers_point": { + "type": "object", + "description": "标记点配置,所有标记点的样式。", "properties": { "color": { - "description": "点的颜色。", - "type": "string" + "type": "string", + "description": "点的颜色。" }, "visible": { - "description": "是否显示该点。", - "type": "boolean" + "type": "boolean", + "description": "是否显示该点。" } }, - "type": "object" + "additionalProperties": false }, - "low_point": { - "additionalProperties": false, - "description": "低点配置,最低点的样式。", + "first_point": { + "type": "object", + "description": "首点配置,第一个数据点的样式。", "properties": { "color": { - "description": "点的颜色。", - "type": "string" + "type": "string", + "description": "点的颜色。" }, "visible": { - "description": "是否显示该点。", - "type": "boolean" + "type": "boolean", + "description": "是否显示该点。" } }, - "type": "object" + "additionalProperties": false }, - "markers_point": { - "additionalProperties": false, - "description": "标记点配置,所有标记点的样式。", + "high_point": { + "type": "object", + "description": "高点配置,最高点的样式。", "properties": { "color": { - "description": "点的颜色。", - "type": "string" + "type": "string", + "description": "点的颜色。" + }, + "visible": { + "type": "boolean", + "description": "是否显示该点。" + } + }, + "additionalProperties": false + }, + "low_point": { + "type": "object", + "description": "低点配置,最低点的样式。", + "properties": { + "color": { + "type": "string", + "description": "点的颜色。" }, "visible": { - "description": "是否显示该点。", - "type": "boolean" + "type": "boolean", + "description": "是否显示该点。" } }, - "type": "object" + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "line_width": { + "type": "number", + "enum": [ + 1, + 2, + 3, + 4 + ], + "description": "折线图线宽,可选值:1=1px,2=2px,3=3px,4=4px。" + }, + "type": { + "type": "string", + "enum": [ + "line", + "column", + "win_loss" + ], + "description": "迷你图类型,可选值:line=折线图,column=柱形图,win_loss=盈亏图。" + }, + "axis": { + "type": "object", + "description": "坐标轴配置,包含坐标轴颜色、是否翻转、是否显示坐标轴。", + "properties": { + "color": { + "type": "string", + "description": "坐标轴颜色。" + }, + "reverse": { + "type": "boolean", + "description": "是否翻转坐标轴方向。" + }, + "visible": { + "type": "boolean", + "description": "是否显示坐标轴。" + } + }, + "additionalProperties": false + }, + "show_gradient": { + "type": "boolean", + "description": "是否显示渐变效果。" + }, + "show_radius": { + "type": "boolean", + "description": "是否显示圆角,仅对柱形图和盈亏图生效。" + }, + "extremum_max": { + "type": "object", + "description": "最大极值配置,包含极值类型、极值。", + "properties": { + "type": { + "type": "string", + "enum": [ + "individual", + "group", + "custom" + ], + "description": "极值类型,可选值:individual=单个迷你图极值,group=同组内极值,custom=自定义。" + }, + "value": { + "type": "number", + "description": "当 type='custom' 时生效的具体数值。" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "extremum_min": { + "type": "object", + "description": "最小极值配置,包含极值类型、极值。", + "properties": { + "type": { + "type": "string", + "enum": [ + "individual", + "group", + "custom" + ], + "description": "极值类型:individual=单个迷你图极值,group=同组内极值,custom=自定义。" }, - "negative_point": { - "additionalProperties": false, - "description": "负点配置,负数点的样式。", - "properties": { - "color": { - "description": "点的颜色。", - "type": "string" - }, - "visible": { - "description": "是否显示该点。", - "type": "boolean" - } - }, - "type": "object" + "value": { + "type": "number", + "description": "当 type='custom' 时生效的具体数值。" } }, - "type": "object" - }, - "series_color": { - "description": "主系列颜色,例如 \"#4472C4\"。", - "type": "string" - }, - "show_gradient": { - "description": "是否显示渐变效果。", - "type": "boolean" - }, - "show_radius": { - "description": "是否显示圆角,仅对柱形图和盈亏图生效。", - "type": "boolean" - }, - "theme_type": { - "description": "主题类型:pro、light、soft、brand、fresh。", - "enum": [ - "pro", - "light", - "soft", - "brand", - "fresh" - ], - "type": "string" - }, - "type": { - "description": "迷你图类型,可选值:line=折线图,column=柱形图,win_loss=盈亏图。", - "enum": [ - "line", - "column", - "win_loss" + "required": [ + "type" ], - "type": "string" + "additionalProperties": false } }, - "type": "object" + "additionalProperties": false }, "sparklines": { + "type": "array", "description": "迷你图项列表。create 时为待创建项(每项需 position + source/source_range);update 时为待变更/新增项(每项需 sparkline_id;upsert=true 新增时需 position + source);delete 时为待删除项(每项需 sparkline_id)。", "items": { - "additionalProperties": false, + "type": "object", "description": "单个迷你图项。", "properties": { + "sparkline_id": { + "type": "string", + "description": "迷你图 reference_id。create 时可选(不传系统生成);update / delete 时必填。" + }, "position": { - "additionalProperties": false, + "type": "object", "description": "迷你图位置。create / update 时必填;delete 时省略。", "properties": { - "col": { - "description": "列索引,例如 \"A\"、\"B\"", - "type": "string" - }, "row": { - "description": "行索引(0-based)", + "type": "number", "minimum": 0, - "type": "number" + "description": "行索引(0-based)" + }, + "col": { + "type": "string", + "description": "列索引,例如 \"A\"、\"B\"" } }, "required": [ "row", "col" ], - "type": "object" + "additionalProperties": false }, "source": { - "description": "A1 范围字符串,表示数据来源,例如 \"Sheet1!A2:A10\"。create 必填(与 source_range 二选一);update 仅在改源时传;delete 时省略。", - "type": "string" + "type": "string", + "description": "A1 范围字符串,表示数据来源,例如 \"Sheet1!A2:A10\"。create 必填(与 source_range 二选一);update 仅在改源时传;delete 时省略。" }, "source_range": { - "additionalProperties": false, + "type": "object", "description": "结构化数据源范围(与 source 等价)。", "properties": { "range": { - "description": "数据源的 A1 引用区域", - "type": "string" + "type": "string", + "description": "数据源的 A1 引用区域" } }, "required": [ "range" ], - "type": "object" - }, - "sparkline_id": { - "description": "迷你图 reference_id。create 时可选(不传系统生成);update / delete 时必填。", - "type": "string" + "additionalProperties": false } }, - "type": "object" - }, - "type": "array" + "additionalProperties": false + } } }, - "type": "object" + "additionalProperties": false } }, "+sparkline-update": { "properties": { - "additionalProperties": false, "description": "创建/更新/部分删除的迷你图属性。delete 时不传 sparklines 即删整组,传则删指定项。", + "type": "object", "properties": { "config": { - "additionalProperties": false, + "type": "object", "description": "迷你图样式配置, 相同 groupId 的迷你图共享相同的样式。", "properties": { - "axis": { - "additionalProperties": false, - "description": "坐标轴配置,包含坐标轴颜色、是否翻转、是否显示坐标轴。", - "properties": { - "color": { - "description": "坐标轴颜色。", - "type": "string" - }, - "reverse": { - "description": "是否翻转坐标轴方向。", - "type": "boolean" - }, - "visible": { - "description": "是否显示坐标轴。", - "type": "boolean" - } - }, - "type": "object" - }, - "contain_hidden_cells": { - "description": "隐藏的单元格数据是否参与绘制。", - "type": "boolean" + "theme_type": { + "type": "string", + "enum": [ + "pro", + "light", + "soft", + "brand", + "fresh" + ], + "description": "主题类型:pro、light、soft、brand、fresh。" }, - "empty_show_as": { - "description": "空单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。", + "non_num_show_as": { + "type": "string", "enum": [ "zero", "gap", "average" ], - "type": "string" - }, - "extremum_max": { - "additionalProperties": false, - "description": "最大极值配置,包含极值类型、极值。", - "properties": { - "type": { - "description": "极值类型,可选值:individual=单个迷你图极值,group=同组内极值,custom=自定义。", - "enum": [ - "individual", - "group", - "custom" - ], - "type": "string" - }, - "value": { - "description": "当 type='custom' 时生效的具体数值。", - "type": "number" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - "extremum_min": { - "additionalProperties": false, - "description": "最小极值配置,包含极值类型、极值。", - "properties": { - "type": { - "description": "极值类型:individual=单个迷你图极值,group=同组内极值,custom=自定义。", - "enum": [ - "individual", - "group", - "custom" - ], - "type": "string" - }, - "value": { - "description": "当 type='custom' 时生效的具体数值。", - "type": "number" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - "line_width": { - "description": "折线图线宽,可选值:1=1px,2=2px,3=3px,4=4px。", - "enum": [ - 1, - 2, - 3, - 4 - ], - "type": "number" + "description": "非数字单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。" }, - "non_num_show_as": { - "description": "非数字单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。", + "empty_show_as": { + "type": "string", "enum": [ "zero", "gap", "average" ], - "type": "string" + "description": "空单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。" + }, + "contain_hidden_cells": { + "type": "boolean", + "description": "隐藏的单元格数据是否参与绘制。" + }, + "series_color": { + "type": "string", + "description": "主系列颜色,例如 \"#4472C4\"。" }, "points": { - "additionalProperties": false, + "type": "object", "description": "特殊点样式配置,包含高点、低点、标记点、首点、尾点、负点。", "properties": { - "first_point": { - "additionalProperties": false, - "description": "首点配置,第一个数据点的样式。", + "last_point": { + "type": "object", + "description": "尾点配置,最后一个数据点的样式。", "properties": { "color": { - "description": "点的颜色。", - "type": "string" + "type": "string", + "description": "点的颜色。" }, "visible": { - "description": "是否显示该点。", - "type": "boolean" + "type": "boolean", + "description": "是否显示该点。" } }, - "type": "object" + "additionalProperties": false }, - "high_point": { - "additionalProperties": false, - "description": "高点配置,最高点的样式。", + "negative_point": { + "type": "object", + "description": "负点配置,负数点的样式。", "properties": { "color": { - "description": "点的颜色。", - "type": "string" + "type": "string", + "description": "点的颜色。" }, "visible": { - "description": "是否显示该点。", - "type": "boolean" + "type": "boolean", + "description": "是否显示该点。" } }, - "type": "object" + "additionalProperties": false }, - "last_point": { - "additionalProperties": false, - "description": "尾点配置,最后一个数据点的样式。", + "markers_point": { + "type": "object", + "description": "标记点配置,所有标记点的样式。", "properties": { "color": { - "description": "点的颜色。", - "type": "string" + "type": "string", + "description": "点的颜色。" }, "visible": { - "description": "是否显示该点。", - "type": "boolean" + "type": "boolean", + "description": "是否显示该点。" } }, - "type": "object" + "additionalProperties": false }, - "low_point": { - "additionalProperties": false, - "description": "低点配置,最低点的样式。", + "first_point": { + "type": "object", + "description": "首点配置,第一个数据点的样式。", "properties": { "color": { - "description": "点的颜色。", - "type": "string" + "type": "string", + "description": "点的颜色。" }, "visible": { - "description": "是否显示该点。", - "type": "boolean" + "type": "boolean", + "description": "是否显示该点。" } }, - "type": "object" + "additionalProperties": false }, - "markers_point": { - "additionalProperties": false, - "description": "标记点配置,所有标记点的样式。", + "high_point": { + "type": "object", + "description": "高点配置,最高点的样式。", "properties": { "color": { - "description": "点的颜色。", - "type": "string" + "type": "string", + "description": "点的颜色。" }, "visible": { - "description": "是否显示该点。", - "type": "boolean" + "type": "boolean", + "description": "是否显示该点。" } }, - "type": "object" + "additionalProperties": false }, - "negative_point": { - "additionalProperties": false, - "description": "负点配置,负数点的样式。", + "low_point": { + "type": "object", + "description": "低点配置,最低点的样式。", "properties": { "color": { - "description": "点的颜色。", - "type": "string" + "type": "string", + "description": "点的颜色。" }, "visible": { - "description": "是否显示该点。", - "type": "boolean" + "type": "boolean", + "description": "是否显示该点。" } }, - "type": "object" + "additionalProperties": false } }, - "type": "object" - }, - "series_color": { - "description": "主系列颜色,例如 \"#4472C4\"。", - "type": "string" - }, - "show_gradient": { - "description": "是否显示渐变效果。", - "type": "boolean" - }, - "show_radius": { - "description": "是否显示圆角,仅对柱形图和盈亏图生效。", - "type": "boolean" + "additionalProperties": false }, - "theme_type": { - "description": "主题类型:pro、light、soft、brand、fresh。", + "line_width": { + "type": "number", "enum": [ - "pro", - "light", - "soft", - "brand", - "fresh" + 1, + 2, + 3, + 4 ], - "type": "string" + "description": "折线图线宽,可选值:1=1px,2=2px,3=3px,4=4px。" }, "type": { - "description": "迷你图类型,可选值:line=折线图,column=柱形图,win_loss=盈亏图。", + "type": "string", "enum": [ "line", "column", "win_loss" ], - "type": "string" + "description": "迷你图类型,可选值:line=折线图,column=柱形图,win_loss=盈亏图。" + }, + "axis": { + "type": "object", + "description": "坐标轴配置,包含坐标轴颜色、是否翻转、是否显示坐标轴。", + "properties": { + "color": { + "type": "string", + "description": "坐标轴颜色。" + }, + "reverse": { + "type": "boolean", + "description": "是否翻转坐标轴方向。" + }, + "visible": { + "type": "boolean", + "description": "是否显示坐标轴。" + } + }, + "additionalProperties": false + }, + "show_gradient": { + "type": "boolean", + "description": "是否显示渐变效果。" + }, + "show_radius": { + "type": "boolean", + "description": "是否显示圆角,仅对柱形图和盈亏图生效。" + }, + "extremum_max": { + "type": "object", + "description": "最大极值配置,包含极值类型、极值。", + "properties": { + "type": { + "type": "string", + "enum": [ + "individual", + "group", + "custom" + ], + "description": "极值类型,可选值:individual=单个迷你图极值,group=同组内极值,custom=自定义。" + }, + "value": { + "type": "number", + "description": "当 type='custom' 时生效的具体数值。" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "extremum_min": { + "type": "object", + "description": "最小极值配置,包含极值类型、极值。", + "properties": { + "type": { + "type": "string", + "enum": [ + "individual", + "group", + "custom" + ], + "description": "极值类型:individual=单个迷你图极值,group=同组内极值,custom=自定义。" + }, + "value": { + "type": "number", + "description": "当 type='custom' 时生效的具体数值。" + } + }, + "required": [ + "type" + ], + "additionalProperties": false } }, - "type": "object" + "additionalProperties": false }, "sparklines": { + "type": "array", "description": "迷你图项列表。create 时为待创建项(每项需 position + source/source_range);update 时为待变更/新增项(每项需 sparkline_id;upsert=true 新增时需 position + source);delete 时为待删除项(每项需 sparkline_id)。", "items": { - "additionalProperties": false, + "type": "object", "description": "单个迷你图项。", "properties": { + "sparkline_id": { + "type": "string", + "description": "迷你图 reference_id。create 时可选(不传系统生成);update / delete 时必填。" + }, "position": { - "additionalProperties": false, + "type": "object", "description": "迷你图位置。create / update 时必填;delete 时省略。", "properties": { - "col": { - "description": "列索引,例如 \"A\"、\"B\"", - "type": "string" - }, "row": { - "description": "行索引(0-based)", + "type": "number", "minimum": 0, - "type": "number" + "description": "行索引(0-based)" + }, + "col": { + "type": "string", + "description": "列索引,例如 \"A\"、\"B\"" } }, "required": [ "row", "col" ], - "type": "object" + "additionalProperties": false }, "source": { - "description": "A1 范围字符串,表示数据来源,例如 \"Sheet1!A2:A10\"。create 必填(与 source_range 二选一);update 仅在改源时传;delete 时省略。", - "type": "string" + "type": "string", + "description": "A1 范围字符串,表示数据来源,例如 \"Sheet1!A2:A10\"。create 必填(与 source_range 二选一);update 仅在改源时传;delete 时省略。" }, "source_range": { - "additionalProperties": false, + "type": "object", "description": "结构化数据源范围(与 source 等价)。", "properties": { "range": { - "description": "数据源的 A1 引用区域", - "type": "string" + "type": "string", + "description": "数据源的 A1 引用区域" } }, "required": [ "range" ], - "type": "object" - }, - "sparkline_id": { - "description": "迷你图 reference_id。create 时可选(不传系统生成);update / delete 时必填。", - "type": "string" + "additionalProperties": false } }, - "type": "object" - }, - "type": "array" + "additionalProperties": false + } } }, - "type": "object" + "additionalProperties": false } } } diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index 2cd2eff9a..c44970e8d 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -340,7 +340,8 @@ func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[s // --colors -> highlight_colors (string array, hex) // --highlight -> enable_highlight (bool) // -// --colors length must equal --options length when both are set. +// --colors length may be shorter than --options (server cycles remaining +// options through a built-in 10-color palette) but must not exceed it. func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) { options, err := requireJSONArray(runtime, "options") if err != nil { @@ -355,8 +356,8 @@ func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) { if err != nil { return nil, err } - if len(colors) != len(options) { - return nil, common.FlagErrorf("--colors length (%d) must equal --options length (%d)", len(colors), len(options)) + if len(colors) > len(options) { + return nil, common.FlagErrorf("--colors length (%d) must not exceed --options length (%d)", len(colors), len(options)) } dv["highlight_colors"] = colors } @@ -370,8 +371,8 @@ func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) { } // validateDropdownOptionsColors validates --options is a JSON array and that -// --colors (when set) has matching length. Returns the options length so -// callers can size their cells matrix at Validate time without re-parsing. +// --colors (when set) is no longer than --options. Returns the options length +// so callers can size their cells matrix at Validate time without re-parsing. func validateDropdownOptionsColors(runtime flagView) (int, error) { options, err := requireJSONArray(runtime, "options") if err != nil { @@ -382,8 +383,8 @@ func validateDropdownOptionsColors(runtime flagView) (int, error) { if err != nil { return 0, err } - if len(colors) != len(options) { - return 0, common.FlagErrorf("--colors length (%d) must equal --options length (%d)", len(colors), len(options)) + if len(colors) > len(options) { + return 0, common.FlagErrorf("--colors length (%d) must not exceed --options length (%d)", len(colors), len(options)) } } return len(options), nil diff --git a/shortcuts/sheets/lark_sheet_write_cells_test.go b/shortcuts/sheets/lark_sheet_write_cells_test.go index 5e10d1a25..aef33e272 100644 --- a/shortcuts/sheets/lark_sheet_write_cells_test.go +++ b/shortcuts/sheets/lark_sheet_write_cells_test.go @@ -173,22 +173,45 @@ func TestDropdownSet_CellsShape(t *testing.T) { } } -// TestDropdownSet_ColorsLengthMismatch checks the early Validate-time -// error when --colors length doesn't match --options. -func TestDropdownSet_ColorsLengthMismatch(t *testing.T) { +// TestDropdownSet_ColorsLongerThanOptions checks the early Validate-time +// error when --colors length exceeds --options. Equal-or-shorter lengths +// are accepted (server cycles the rest through a built-in palette). +func TestDropdownSet_ColorsLongerThanOptions(t *testing.T) { t.Parallel() _, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{ "--url", testURL, "--sheet-id", testSheetID, "--range", "A2:A4", - "--options", `["a","b","c"]`, - "--colors", `["#FFE699","#bff7d9"]`, + "--options", `["a","b"]`, + "--colors", `["#FFE699","#bff7d9","#ffb3b3"]`, "--dry-run", }) if err == nil { - t.Fatal("expected --colors length mismatch error, got nil") + t.Fatal("expected --colors length error, got nil") + } + if !strings.Contains(stderr, "must not exceed --options length") && !strings.Contains(err.Error(), "must not exceed --options length") { + t.Errorf("error message missing length-overflow hint:\nerr=%v\nstderr=%s", err, stderr) } - if !strings.Contains(stderr, "must equal --options length") && !strings.Contains(err.Error(), "must equal --options length") { - t.Errorf("error message missing length-mismatch hint:\nerr=%v\nstderr=%s", err, stderr) +} + +// TestDropdownSet_ColorsShorterAccepted verifies the partial-colors case: +// fewer colors than options is legal — array is forwarded as-is and the +// server fills remaining slots from its default palette. +func TestDropdownSet_ColorsShorterAccepted(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, DropdownSet, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A2:A4", + "--options", `["a","b","c","d"]`, + "--colors", `["#FFE699","#bff7d9"]`, + }) + input := decodeToolInput(t, body, "set_cell_range") + cells, _ := input["cells"].([]interface{}) + row0, _ := cells[0].([]interface{}) + cell, _ := row0[0].(map[string]interface{}) + dv, _ := cell["data_validation"].(map[string]interface{}) + colors, _ := dv["highlight_colors"].([]interface{}) + if len(colors) != 2 { + t.Errorf("highlight_colors length = %d, want 2 (forwarded as-is)", len(colors)) } } diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index ba3160e0b..97fcbc569 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -115,7 +115,7 @@ _单元格边框配置,含 top/bottom/left/right 四个方向,每个方向 ### `+dropdown-update` `--options` -_列表选项_ +_列表选项(type='list' 时必填)_ **数组项**(类型 string): - 标量:string diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md index b477e7742..d83555a3f 100644 --- a/skills/lark-sheets/references/lark-sheets-chart.md +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -147,9 +147,9 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ _创建/更新的图表属性_ **顶层字段**: -- `position` (object) — 必填 { row: number, col: string } +- `position` (object?) — 必填 { row: number, col: string } - `offset` (object?) — 可选 { row_offset?: number, col_offset?: number } -- `size` (object) — 必填 { width: number, height: number } +- `size` (object?) — 必填 { width: number, height: number } - `snapshot` (object?) — 图表快照配置 { title?: object, subTitle?: object, style?: object, legend?: oneOf, plotArea?: object, …共 6 项 } ## Examples diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index a4e32f6b2..4608bdeaf 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -4,7 +4,7 @@ 1. **明确写入边界**:写入前必须能回答"目标 range 的起止行列号是多少?是否落在用户授权范围内?"。除用户明示要修改的区域外,禁止扩张到原数据列以外或新建 Sheet。 2. **完整性断言**:批量写入前先把"预期写入条数"硬编码到代码里(如要填 106 条翻译 → `expected = 106`),写完后回读断言 `actual == expected`。少于预期就继续写,禁止交付半成品。 -3. **回读抽样校验**:写完关键值 / 公式后,用 `+csv-get` 或 `+cells-get` 重新读取写入区域,至少抽样 3-5 个代表性单元格(首 / 中 / 末),核对值与预期一致(与 本地脚本 计算的预期值对照)。公式特定的"先验证模板再 copy_to_range / 修完再读回"细则见下方相关章节。 +3. **回读抽样校验**:写完关键值 / 公式后,用 `+csv-get` 或 `+cells-get` 重新读取写入区域,至少抽样 3-5 个代表性单元格(首 / 中 / 末),核对值与预期一致(与本地脚本计算的预期值对照)。公式特定的"先验证模板再 --copy-to-range / 修完再读回"细则见下方相关章节。 ## 新增列 / 新增行的样式继承(防止视觉风格不一致) @@ -17,7 +17,7 @@ 3. `cell_styles.number_format`(小数位 / 千分位 / 百分比 / 日期格式)—— 漏继承会导致同列数值格式混乱 4. `cell_styles.background_color`(背景色) 5. `border_styles`(四边框) -6. **`merged_cells`(合并范围)**——续写场景必查(高频致命错误):用 `+sheet-info --info_type=merged_cells_infos` 读原数据区域的合并信息。**原行有跨列合并**(如标题行 `A1:G1` 合并)时,新行**必须**用 `+cells-{merge|unmerge}` 工具复制相同合并模式到新行(如续写第 3 个周报块的标题行 `A23:G23` 必须合并)。仅传 cells 数组的 5 类样式不够——合并范围要单独靠 `+cells-{merge|unmerge}` 工具落地(典型反例:续写多周记录表时,新增周次的标题行未合并,视觉上与原前几周风格不一致) +6. **`merged_cells`(合并范围)**——续写场景必查(高频致命错误):用 `+sheet-info --include merges` 读原数据区域的合并信息。**原行有跨列合并**(如标题行 `A1:G1` 合并)时,新行**必须**用 `+cells-{merge|unmerge}` 工具复制相同合并模式到新行(如续写第 3 个周报块的标题行 `A23:G23` 必须合并)。仅传 cells 数组的 5 类样式不够——合并范围要单独靠 `+cells-{merge|unmerge}` 工具落地(典型反例:续写多周记录表时,新增周次的标题行未合并,视觉上与原前几周风格不一致) **采样模板的正确做法**: - 表头新列 → 读相邻表头单元格(如新加 D1 → 读 A1/B1/C1 任一) @@ -44,59 +44,56 @@ ## 使用场景 -写入。为一块单元格区域设置值、公式、批注/备注和/或格式。也支持通过 `rich_text` 中 `type: "embed-image"` 在单元格内嵌入图片(单元格图片)。关键:数组维度必须严格匹配——`cells` 二维数组必须与 `range` 的行列维度完全一致,range 是闭区间,否则会触发 `InvalidCellRangeError`。计算示例:区域 `A1:D3` = 3 行 × 4 列 = `[[r1c1,r1c2,r1c3,r1c4],[r2c1,r2c2,r2c3,r2c4],[r3c1,r3c2,r3c3,r3c4]]`;区域 `A41:N48` = 8 行 × 14 列 = 8 个数组且每个数组 14 个单元;单个单元格 `A1` = `[[cell]]`;单列区域 `B5:B7` = `[[cell1],[cell2],[cell3]]`。空单元请使用 `{}`。**如果填写的区域存在大量重复内容,务必优先使用 `copy_to_range` 字段复制,可大幅减少 `cells` 长度。** +写入。为一块单元格区域设置值、公式、批注/备注和/或格式。也支持通过 `rich_text` 中 `type: "embed-image"` 在单元格内嵌入图片(单元格图片)。关键:数组维度必须严格匹配——`cells` 二维数组必须与 `range` 的行列维度完全一致,range 是闭区间,否则会触发 `InvalidCellRangeError`。计算示例:区域 `A1:D3` = 3 行 × 4 列 = `[[r1c1,r1c2,r1c3,r1c4],[r2c1,r2c2,r2c3,r2c4],[r3c1,r3c2,r3c3,r3c4]]`;区域 `A41:N48` = 8 行 × 14 列 = 8 个数组且每个数组 14 个单元;单个单元格 `A1` = `[[cell]]`;单列区域 `B5:B7` = `[[cell1],[cell2],[cell3]]`。空单元请使用 `{}`。**如果填写的区域存在大量重复内容,务必优先使用 `--copy-to-range` 字段复制,可大幅减少 `cells` 长度。** > **单元格图片 vs 浮动图片**: > - **单元格图片**(本工具):图片嵌入在单元格内部,属于单元格内容,随单元格移动。通过 `rich_text` 中 `type: "embed-image"` 写入。 -> - **浮动图片**:图片悬浮在单元格上方,可自由定位和调整大小,不属于单元格内容。→ 使用 lark_sheet_float_image Skill。 +> - **浮动图片**:图片悬浮在单元格上方,可自由定位和调整大小,不属于单元格内容。→ 使用 lark-sheets-float-image。 高频模式(**必须遵守,禁止逐行写入替代**): -- 整列公式:先在 `H2` 写一个公式,再用 `copy_to_range: "H2:H100"` 或 `copy_to_range: "H:H"` 向下填充。**禁止对每一行单独调用 `+cells-set` 写入相同结构的公式** -- 整列格式:先在 `J1` 写一个带样式的模板单元格,再用 `copy_to_range: "J:J"` -- 首行样式:先在 `A1` 写一个模板单元格,再用 `copy_to_range: "1:1"` -- 用户说”这列 / 整列 / 这行 / 首行 / 向下复制”时,**必须**使用模板单元格 + `copy_to_range` -- 多区域写入相同格式/公式结构时,优先写一个模板,再用 `copy_to_range` 复制到所有目标区域 +- 整列公式:先在 `H2` 写一个公式,再用 `--copy-to-range "H2:H100"` 或 `--copy-to-range "H:H"` 向下填充。**禁止对每一行单独调用 `+cells-set` 写入相同结构的公式** +- 整列格式:先在 `J1` 写一个带样式的模板单元格,再用 `--copy-to-range "J:J"` +- 首行样式:先在 `A1` 写一个模板单元格,再用 `--copy-to-range "1:1"` +- 用户说”这列 / 整列 / 这行 / 首行 / 向下复制”时,**必须**使用模板单元格 + `--copy-to-range` +- 多区域写入相同格式/公式结构时,优先写一个模板,再用 `--copy-to-range` 复制到所有目标区域 -⚠️ **逐行写入公式是最常见的致命错误**:对每一行单独调用 `+cells-set` 写入公式(如调用 26 次),会快速耗尽轮次上限导致操作不完整。正确做法是 1 次模板写入 + 1 次 `copy_to_range` = 2 次调用完成。 +⚠️ **逐行写入公式是常见低效写法**:对每一行单独调用 `+cells-set` 写公式(如 26 次)既慢又易错,且不会自动平移公式引用。正确做法是 1 次模板写入 + 1 次 `--copy-to-range`(公式引用自动平移)。 -💡 **写入公式前先按迁移规则改写**:如果公式来自 Excel 或包含数组场景,先读取并遵循 `lark-sheets-formula-translation` skill 的规则完成改写,再把最终公式写入 `formula` 字段。 +💡 **写入公式前先按迁移规则改写**:如果公式来自 Excel 或包含数组场景,先读取并遵循 `lark-sheets-formula-translation` 的规则完成改写,再把最终公式写入 `formula` 字段。 💡 **内容与样式分离写入(推荐)**:当需要同时写入内容和样式时,`cells` 中每个单元格都带上 `cell_styles` / `border_styles` 会导致入参非常冗长。由于同一区域的样式通常高度重复(如整列统一背景色、统一边框),推荐拆成两步: 1. **先写内容**:`+cells-set` 只传 `value` / `formula`,不带样式,`cells` 入参精简 -2. **再批量刷样式**:对区域中的一个单元格写入目标样式作为模板,再用 `copy_to_range` 将样式扩展到整列 / 整行 / 整个区域(`copy_to_range` 会复制值、公式和样式,所以模板单元格应已包含正确的值) +2. **再批量刷样式**:对区域中的一个单元格写入目标样式作为模板,再用 `--copy-to-range` 将样式扩展到整列 / 整行 / 整个区域(`--copy-to-range` 会复制值、公式和样式,所以模板单元格应已包含正确的值) 示例:要对 A2:A100 写入数据并统一设置蓝色背景 + 边框: ``` Step 1: `+cells-set` — range="A2:A100", cells 只含 value(无样式,入参短) -Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styles(单个模板), copy_to_range="A2:A100" +Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styles(单个模板), --copy-to-range="A2:A100" ``` 这比在 99 个单元格中都重复写样式 JSON 高效得多。 -💡 **大批量数据分批写入(推荐)**:当需要写入大量行(如几十行以上)时,不要试图在一次调用中生成全部 `cells` 数据——`cells` 数组过大会导致模型生成内容过长而超时。应将数据拆分为多批,每批 20-50 行,分多次调用 `+cells-set` 逐批生成并写入(如先写 `A2:D21`,再写 `A22:D41`,依此类推)。每次调用只需生成当前批次的数据,控制单次生成量,避免超时。 +💡 **大批量数据分批写入(推荐)**:当需要写入大量行(如几十行以上)时,不要试图在一次调用中生成全部 `cells` 数据——`cells` 数组过大会让单次生成的内容过长,容易出错或被截断。应将数据拆分为多批,每批 20-50 行,分多次调用 `+cells-set` 逐批生成并写入(如先写 `A2:D21`,再写 `A22:D41`,依此类推)。每次只生成当前批次的数据,控制单次生成量。 注意: - 不要把 `cells` 写成字符串化 JSON -- 如果目标区域中已有值、公式或样式需要被覆盖,显式设置 `allow_overwrite=true` +- `+cells-set` 默认即覆盖非空 cell(`--allow-overwrite` 默认 true);若要**保护**非空 cell 不被覆盖,显式传 `--allow-overwrite=false`(遇非空 cell 报错) - 若目标区域涉及合并单元格,不要向合并区域中的非左上角单元格写入数据;如需写入,应改写合并区域左上角单元格,或先调整/取消合并区域 - **构造 `range` 时行号必须基于逻辑行号**:如果之前通过 `+csv-get` 读取了数据,CSV 中被双引号包裹的多行字段(如 `"2026年3月2日\n星期一"`)是**一个单元格**,不是两行。写入时的行号必须按逻辑记录计算,不能按物理换行符计数,否则 `range` 会整体偏移导致写入到错误位置 -⚠️ **"样式与原表一致"必须包含 `border_styles`(高频致命错误)**:当用户说"样式和原表一致"、"保持原表格式"、"边框继承"等要求时,cells 里的 `cell_styles` **不能只传 `font_size` / `horizontal_alignment` / `vertical_alignment`**——这几项只覆盖字体和对齐,**不包含边框**。边框必须用独立的 `border_styles` 字段传(或在源 cell 用 `+cells-get` 读出来再原样复制)。 -- **反模式**:`cells=[[{cell_styles:{font_size:16, horizontal_alignment:"center", vertical_alignment:"middle"}}]]`(字体+对齐都有,但**新 cell 仍然没边框**,视觉上与原表断裂) -- **正确做法**:`cell_styles` + `border_styles` 一起传,`border_styles` 覆盖 top/bottom/left/right 四条边(或至少 data 区该加的几条),确保视觉连续 -- 特别是**新列/新行**场景,新 cell 底子里本来就没边框,如果不显式传 `border_styles`,copy_to_range 复制的模板也没边框 → 整列/整行无边框 +> 用户说"样式和原表一致 / 保持原表格式 / 边框继承"时同理:`cell_styles` 只覆盖字体和对齐、**不含边框**,边框必须用独立 `border_styles` 字段传——完整继承清单见上方「新增列 / 新增行的样式继承」。 -⚠️ **公式写入必须自己校验结果(后端不会报语法错)**:`+cells-set` 写公式时,即便公式有括号不配对(如 `=IFERROR(VALUE(REGEXEXTRACT(D5, "\d+"))), 0)` 比 IFERROR 多一个 `)`)或函数名拼错(`=UNIQUE(...)` 飞书不支持),**后端工具也会返回 `updated_cells_count=N, rc=0` 的"成功"**——错误会静默写进单元格显示为 `#VALUE!` / `#NAME?` / `#REF!`。因此: +⚠️ **公式写入必须自己校验结果(后端不会报语法错)**:`+cells-set` 写公式时,即便公式有括号不配对(如 `=IFERROR(VALUE(REGEXEXTRACT(D5, "\d+"))), 0)` 比 IFERROR 多一个 `)`)或用了飞书不支持的函数(如 `GOOGLETRANSLATE` / `CUBEVALUE`),**后端工具也会返回 `updated_cells_count=N, rc=0` 的"成功"**——错误会静默写进单元格显示为 `#VALUE!` / `#NAME?` / `#REF!`。因此: 1. **写完立即读回**:`+cells-set` 后紧跟 `+csv-get`(或 `+cells-get`)读目标范围前几行,检查是否出现 `#VALUE!` / `#NAME?` / `#REF!` / `#N/A` / `#DIV/0!` / `#NUM!` -2. **看到 `#` 开头的错误值**立即修公式:`#NAME?` 多半是函数名拼错或飞书不支持(UNIQUE/DISTINCT 等);`#VALUE!` 多半是类型不匹配或括号错位;`#REF!` 是引用错误;`~CIRCULAR~REF~` 是循环引用(公式引用了自身或会闭环) -3. **`copy_to_range` 扩展前先验证模板**:模板单元格公式自己都算错,`copy_to_range` 复制到 100 行就是 100 个错误 -4. **飞书不支持的函数**:`UNIQUE` / `DISTINCT` / `FILTER`(部分)—— 对应"去重"场景改用透视表(`+pivot-{create|update|delete}`,值字段聚合方式选 count) +2. **看到 `#` 开头的错误值**立即修公式:`#NAME?` 多半是函数名拼错或用了飞书不支持的函数(如 `GOOGLETRANSLATE` / CUBE 系列;注意 `UNIQUE` / `FILTER` / `SPLIT` 飞书是支持的);`#VALUE!` 多半是类型不匹配或括号错位;`#REF!` 是引用错误;`~CIRCULAR~REF~` 是循环引用(公式引用了自身或会闭环) +3. **`--copy-to-range` 扩展前先验证模板**:模板单元格公式自己都算错,`--copy-to-range` 复制到 100 行就是 100 个错误 +4. **去重 / 筛选函数**:飞书**支持** `UNIQUE` / `FILTER` / `SPLIT`(原生数组函数,详见 `lark-sheets-formula-translation`),可直接用;`DISTINCT` 不是飞书函数,去重用 `UNIQUE`。大数据量去重 / 分组也可用透视表(`+pivot-{create|update|delete}`,值字段聚合方式选 count) 5. **循环引用预检(高频致命错误)**:写聚合公式(SUM / AVERAGE / COUNT 等)前必须明确**引用范围不包含目标单元格自身或其传递依赖**。典型反例:在 C3 写 `=SUMIF(B:B,LEFT(B3,9)&"*",C:C)`,B 列匹配 B3 前 9 位时 C3 自己也命中,导致 C3 自引用 → `~CIRCULAR~REF~`。修法:用辅助列 / 显式排除自身(`SUMIFS(C:C, B:B, ..., A:A, "<>"&A3)`)/ 缩小范围避开自己 -6. **REGEX 模式覆盖率验证**:公式里的 `REGEXEXTRACT` / `REGEXMATCH` / `REGEXREPLACE` 等正则模式落地前必须用 本地脚本 在源列上跑一遍命中率统计(`df[col].str.contains(pattern).mean()`);命中率 < 100% 时必须扩展 pattern 或加多分支(IFS / 多个 IFERROR 串联)兜底,**禁止**只覆盖样本前 N 行就交付(典型反例:用 `REGEXEXTRACT(D5,"长(\d+)")` 只匹配带"长"前缀的尺寸文本,对"宽×高"、"×"、"*"等其它分隔符直接漏匹配) +6. **REGEX 模式覆盖率验证**:公式里的 `REGEXEXTRACT` / `REGEXMATCH` / `REGEXREPLACE` 等正则模式落地前必须用本地脚本在源列上跑一遍命中率统计(`df[col].str.contains(pattern).mean()`);命中率 < 100% 时必须扩展 pattern 或加多分支(IFS / 多个 IFERROR 串联)兜底,**禁止**只覆盖样本前 N 行就交付(典型反例:用 `REGEXEXTRACT(D5,"长(\d+)")` 只匹配带"长"前缀的尺寸文本,对"宽×高"、"×"、"*"等其它分隔符直接漏匹配) 7. **公式范围与用户指令字面对齐**:用户说"对 F 至 L 列求和"就必须写 `SUM(F2:L2)` 或 `F2+G2+H2+I2+J2+K2+L2`,**不能漏列、多列、错列**。写完用 `+cells-get` 拿回 `formula` 字符串,与用户原话逐字对照(参与求和的列名一致 / 起止列号一致 / 运算符一致),不一致就是违规 -⚠️ **收到 `formula_errors` 反馈后不要只打补丁(高频致命错误)**:`+cells-set` 返回值里若出现 `formula_errors: [{cell, formula, error_type, detail}]`,说明某些 cell 公式编译失败(`error_type=compile_failed` 通常是函数语法错如 `SPLIT(x)[1]` 飞书不支持;`non_formula` 是 `=` 开头但解析不通过)。此时**禁止只聚焦修报错点的局部语法**(如仅把 `[1]` 换成 `INDEX(..,1)`),必须: +⚠️ **收到 `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 兜底漏条件、引用列写错),修完语法错后立即整体复核 2. **同步对称修复所有相似列**:如果同一任务涉及多列相似处理(如"算 H 列面积"用 D 列尺寸、"算 I 列面积"用 E 列尺寸),**修完一列必须把同样的清洗/兜底逻辑同步到所有相似列**,禁止出现 H 列用 `SUBSTITUTE(长)+SUBSTITUTE(高)+SUBSTITUTE(×)` 而 I 列只用 `SUBSTITUTE(×)` 这种不对称处理——会导致一列编译通过有值、另一列编译通过但 IFERROR 全返回空,用户看到的是"数据为空"而非"公式错" @@ -109,7 +106,7 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl - **语义信号**(二选一):用户 prompt 含"合计/汇总/总计/统计/各科平均分/最下面加一行算…/底部总计"等意图词;或上下文明确是"表尾追加一行做聚合" - **结构信号**:新行全行都在做聚合(含 `=SUM/AVERAGE/COUNT/MAX/MIN/SUBTOTAL(...)`,支持 IFERROR 包裹),**不是**单个 cell 算个参考值或每行都算的派生列 -满足上述时,**不要在本 skill 里猜样式**,直接去读 `lark-sheets-visual-standards` 的"汇总行规范"章节,按那里的规则配齐 `font.bold / horizontal_alignment / background_color / border_styles`。 +满足上述时,**不要在本 skill 里猜样式**,直接去读 `lark-sheets-visual-standards` 的「场景一 → 1A. 添加汇总行 / 表头行」章节,按那里的样式要点配齐 `font.bold / horizontal_alignment / background_color / border_styles`。 反例(**不是**汇总行,禁止自动加粗): - 用户说"在 H5 帮我算个 AVERAGE 参考"→ 单 cell 计算 @@ -118,35 +115,10 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl **正确做法**(二选一): -**做法 A(推荐):两步走——先铺样式、再覆内容** +- **做法 A(推荐)**:按上方「内容与样式分离写入」两步法——先用模板单元格 + `--copy-to-range` 铺**完整样式**(`cell_styles` + `border_styles` 都要,不能只铺 border,否则新行字体 / 对齐 / 背景色全裸奔),再单独 `+cells-set` 写 value / formula。汇总行的 `cell_styles` 要点(bold / 背景色 / 上边框)见 `lark-sheets-visual-standards` 的「场景一 → 1A. 添加汇总行 / 表头行」。 +- **做法 B**:一次写入,但每个 cell(含空白格)都显式带 `cell_styles` + `border_styles`,**不能用 `{}`**。 -``` -Step 1: 用模板单元格 + copy_to_range 铺"完整样式"(不是只铺 border)到新区域 - `+cells-set` — range="A11", cells=[[{ - border_styles: {...}, - cell_styles: { /* 按行性质填充:数据行继承数据区样式;汇总行见 lark_sheet_visual_standards */ } - }]], copy_to_range="A11:H11" - -Step 2: 再用 `+cells-set` 单独写具体 value/formula(不再传样式,避免覆盖) - `+cells-set` — range="A11", cells=[[{value: "平均分"}]] - `+cells-set` — range="C11:F11", cells=[[{formula: "=AVERAGE(C2:C10)"}, {formula: "=AVERAGE(D2:D10)"}, ...]] -``` - -⚠️ **Step 1 `cell_styles` 禁止留空**:只铺 border、不铺 `cell_styles`,等于新行从格式上"裸奔"——没字体、没对齐、没背景色。如果新行是汇总行,这意味着 bold 丢失,用户感受"没做样式"。Step 1 的 `cell_styles` 要么继承源区块(`+cells-get` 读相邻已有行样式后复用),要么按汇总行规范(见 `lark-sheets-visual-standards`)配齐。 - -**做法 B:一次写入但每个 cell 都显式带样式** - -``` -`+cells-set` — range="A11:H11", cells=[[ - {value: "平均分", cell_styles: {...}, border_styles: {...}}, - {value: "", cell_styles: {...}, border_styles: {...}}, ← B11 不能是 {},要显式带 border - {formula: "=AVERAGE(C2:C10)", cell_styles: {...}, border_styles: {...}}, - {formula: "=AVERAGE(D2:D10)", cell_styles: {...}, border_styles: {...}}, - ... -]] -``` - -**判断是不是"新行"**:`+csv-get` 返回的 `current_region` 是 `A1:H10`,你要写入的 range 是 `A11:H11`(超出 `current_region` 右/下边界),就是新行——必须按上述做法处理边框。 +**判断是不是"新行"**:写入 range 超出 `+csv-get` 返回的 `current_region` 右 / 下边界(如 `current_region=A1:H10`、写 `A11:H11`)即新行,必须按上述做法补边框。 ## 工具选择 @@ -154,7 +126,7 @@ Step 2: 再用 `+cells-set` 单独写具体 value/formula(不再传样式, | 场景 | 用这个 shortcut | 原因 | |------|----------------|------| -| 模型手里已经有 CSV 文本(小规模手动构造、从 `+csv-get` 取到后简单加工) | `+csv-put` | 直接传 CSV 文本 + start_cell,不用自己拼二维 cells 数组;必要时自动扩容行列 | +| 模型手里已经有 CSV 文本(小规模手动构造、从 `+csv-get` 取到后简单加工) | `+csv-put` | 直接传 CSV 文本 + `--start-cell`,不用自己拼二维 cells 数组;必要时自动扩容行列 | | 写入含公式、样式、批注、图片、数据校验等任意富写入 | `+cells-set` | 唯一支持完整字段的 shortcut | | 只改已有 cell 的样式,不动 value/formula | `+cells-set-style` | 拍平 10 个样式字段为独立 flag;不触发不必要的值写入 | | 单 cell 嵌入图片 | `+cells-set-image` | 比 `+cells-set` 参数更简短 | @@ -164,7 +136,7 @@ Step 2: 再用 `+cells-set` 单独写具体 value/formula(不再传样式, ⚠️ `+csv-put` 只写纯值,**不会**携带公式/样式/批注/图片;公式字符串以 `=` 开头会被当作字面量文本落地。如果数据里需要公式或样式,**必须**用 `+cells-set`(或"写值 + 补样式"两步法)。 -⚠️ 大数据回写走"`+csv-get --max-rows N` 分批读到本地 + 本地脚本处理 + `+csv-put` 分批回写"。 +⚠️ 大数据回写走"`+csv-get` 按 `--range` 行窗口分批读到本地 + 本地脚本处理 + `+csv-put` 分批回写"。 ## Shortcuts @@ -187,7 +159,7 @@ _公共四件套 · 系统:`--dry-run`_ | `--range` | string | required | 写入区域(A1 格式) | | `--cells` | string + File + Stdin(复合 JSON) | required | JSON:2D 数组 `[[{cell},...],...]`,维度与 `--range` 完全一致;每个 cell 可含 `value` / `formula` / `cell_styles` / `note` / `rich_text`(含 `type="embed-image"` 单元格嵌图)等,完整字段跑 `--print-schema` | | `--allow-overwrite` | bool | optional | 允许覆盖非空 cell(默认 true);设为 false 时遇非空 cell 报错 | -| `--max-cells` | int | optional | 防爆,默认 50000 | +| `--max-cells` | int | optional | 防爆,默认 50000(隐藏 flag:不在 `--help` 列出,但可正常传入) | | `--copy-to-range` | string | optional | 复制范围(A1 表示法):把 --range 中 --cells 写入的内容(值/公式/样式,取决于实际传入字段)复制到该区域,公式引用自动平移(如 C2=B2 → C3=B3)。适合先写一行/一块模板再扩展填充整列/整区域(如 --range A1:G1 写模板、--copy-to-range A1:G100 填充 100 行)。支持整行 3:6、整列 C:E、到列尾 D3:D、到行尾 D3:3;支持英文逗号分隔多个目标区域,如 C1:D2,E5:F6 | ### `+cells-set-style` @@ -243,30 +215,30 @@ _公共四件套 · 系统:`--dry-run`_ ## Schemas -> 复合 JSON flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys`)的字段速查:只列顶层字段 + 一层嵌套结构。深层结构看 `## Examples` 段的真实示例;要拿完整 JSON Schema 跑 `lark-cli sheets --print-schema --flag-name `。先 `--print-schema`(不带 `--flag-name`)会列出该 shortcut 所有可查询的 flag。 +> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。 ### `+cells-set` `--cells` **顶层字段**: -- `border_styles` (object?) — 单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top) { bottom?: object, left?: object, right?: object, top?: object } -- `cell_styles` (object?) — 单元格样式属性,包括字体、颜色、对齐方式和数字格式 { background_color?: string, font_color?: string, font_line?: enum, font_size?: number, font_style?: enum, …共 10 项 } -- `data_validation` (object?) — 数据验证配置 { enable_highlight?: boolean, help_text?: string, highlight_colors?: array, items?: array, operator?: enum, …共 9 项 } +- `value` (oneOf?) — 静态单元格值(文本、数字、布尔) - `formula` (string?) — 以 '=' 开头的单元格公式(例如:'=SUM(A1:A10)') -- `multiple_values` (array?) — 多值内容,用于支持多选的列表验证单元格 each: { format?: string, value: oneOf } - `note` (string?) — 单元格批注/备注 -- `rich_text` (array?) — 富文本内容 each: { attachment_name?: string, attachment_token?: string, attachment_uri?: string, file_size?: number, image_height?: number, …共 17 项 } -- `value` (oneOf?) — 静态单元格值(文本、数字、布尔) +- `cell_styles` (object?) — 单元格样式属性,包括字体、颜色、对齐方式和数字格式 { font_color?: string, font_size?: number, font_weight?: enum, font_style?: enum, font_line?: enum, …共 10 项 } +- `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 } +- `data_validation` (object?) — 数据验证配置 { type: enum, items?: array, range?: string, operator?: enum, values?: array, …共 9 项 } ### `+cells-set-style` `--border-styles` _单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)_ **顶层字段**: -- `bottom` (object?) { color?: string, style?: enum, weight?: enum } -- `left` (object?) { color?: string, style?: enum, weight?: enum } -- `right` (object?) { color?: string, style?: enum, weight?: enum } -- `top` (object?) { color?: string, style?: enum, weight?: enum } +- `top` (object?) { style?: enum, weight?: enum, color?: string } +- `bottom` (object?) { style?: enum, weight?: enum, color?: string } +- `left` (object?) { style?: enum, weight?: enum, color?: string } +- `right` (object?) { style?: enum, weight?: enum, color?: string } ### `+dropdown-set` `--options` @@ -295,9 +267,9 @@ _列表选项(type='list' 时必填)_ 示例: ```bash -# 纯值(数组形态) +# 纯值(数组形态);默认即覆盖非空 cell,无需显式传 --allow-overwrite lark-cli sheets +cells-set --url "https://example.feishu.cn/sheets/shtXXX" \ - --sheet-name "Sheet1" --range "A1:B2" --allow-overwrite \ + --sheet-name "Sheet1" --range "A1:B2" \ --cells '[[{"value":"name"},{"value":"score"}],[{"value":"alice"},{"value":95}]]' # 富 cell(公式 + 样式,cells 是二维矩阵每元素一个 cell schema) From 09c02e86578bc826c9cf09ca6c9217f09f854cf1 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Mon, 25 May 2026 11:46:26 +0800 Subject: [PATCH 048/114] docs(sheets): sync dropdown --colors/--highlight clarification from spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors sheet-skill-spec MR !7 changes: - skills/lark-sheets/references/lark-sheets-write-cells.md: new "Dropdown 配色" section explaining how --colors (→ data_validation.highlight_colors) and --highlight (→ data_validation.enable_highlight) compose — length rule (shorter ok, longer rejected), --highlight gating, palette fallback behavior, minimal +dropdown-set example. - skills/lark-sheets/references/lark-sheets-batch-update.md: one-line pointer to the write_cells section for +dropdown-update / -delete (same rules). - shortcuts/sheets/data/flag-defs.json: --colors / --highlight `desc` fields gain the long-form server-field / length-rule descriptions used by `--help`. No Go-side change — earlier commit 538eb2e already loosened the buildDropdownValidation length check to "must not exceed"; this PR step just makes the docs / `--help` text catch up. --- shortcuts/sheets/data/flag-defs.json | 8 ++--- .../references/lark-sheets-batch-update.md | 6 ++-- .../references/lark-sheets-write-cells.md | 33 +++++++++++++++++-- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index d8687e227..713456e67 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -1879,7 +1879,7 @@ "kind": "own", "type": "string", "required": "optional", - "desc": "RGB hex color array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`); length must equal `--options`", + "desc": "RGB hex pill colors for dropdown options (e.g. `[\"#1FB6C1\",\"#F006C2\"]`); maps to server `data_validation.highlight_colors`. Length may be **shorter than** `--options` (server cycles remaining slots through a built-in 10-color palette) but **must not exceed** it. Only takes effect when `--highlight` is also set.", "input": [ "file", "stdin" @@ -1897,7 +1897,7 @@ "kind": "own", "type": "bool", "required": "optional", - "desc": "Color-highlight options; default `false`" + "desc": "Enable pill-background color highlighting for dropdown options; default `false`. Maps to server `data_validation.enable_highlight`. When `true`, colors come from `--colors` in order; options beyond `--colors` length cycle through a built-in 10-color palette." }, { "name": "dry-run", @@ -2807,7 +2807,7 @@ "kind": "own", "type": "string", "required": "optional", - "desc": "Color array (same length as `--options`)", + "desc": "RGB hex pill colors for dropdown options (e.g. `[\"#1FB6C1\",\"#F006C2\"]`); maps to server `data_validation.highlight_colors`. Length may be **shorter than** `--options` (remaining slots cycle through a built-in palette) but **must not exceed** it. Only takes effect when `--highlight` is also set.", "input": [ "file", "stdin" @@ -2825,7 +2825,7 @@ "kind": "own", "type": "bool", "required": "optional", - "desc": "Color-highlight options" + "desc": "Enable pill-background color highlighting for dropdown options; default `false`. Maps to server `data_validation.enable_highlight`. Works with `--colors`; if `--colors` is omitted, all options cycle through a built-in 10-color palette." }, { "name": "dry-run", diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 97fcbc569..3a7898a0f 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -22,6 +22,8 @@ 当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求——`+batch-update` 是原子提交(要么全成功要么整批回滚);逐个调用非原子,中途失败会留下半成品。 +**`+dropdown-update` / `+dropdown-delete` 的配色规则**(`--colors` 长度可短于 `--options` 但不能长于、必须配 `--highlight=true` 才生效、不传按内置 10 色色板循环补色)见 [`lark-sheets-write-cells`](./lark-sheets-write-cells.md) 的「Dropdown 配色」节,本 skill 不重复。 + ## Shortcuts | Shortcut | Risk | 分组 | @@ -70,9 +72,9 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(如 `["sheet1!A2:A100"]`),每项必须带 sheet 前缀 | | `--options` | string + File + Stdin(复合 JSON) | required | 选项 JSON 数组(如 `["opt1","opt2"]`) | -| `--colors` | string + File + Stdin(简单 JSON) | optional | 颜色数组(与 `--options` 等长) | +| `--colors` | string + File + Stdin(简单 JSON) | optional | 下拉选项的胶囊背景色,RGB hex 数组(如 `["#1FB6C1","#F006C2"]`)。映射到 server `data_validation.highlight_colors`。长度可以**短于** `--options`(剩余项按内置色板补色)但**不能长于**。仅当 `--highlight` 也传时才生效。 | | `--multiple` | bool | optional | 启用多选 | -| `--highlight` | bool | optional | 选项配色 | +| `--highlight` | bool | optional | 开启下拉选项的胶囊背景色高亮;默认 `false`。映射到 server `data_validation.enable_highlight`。需配合 `--colors` 使用——不传 `--colors` 时全部选项按内置 10 色色板循环。 | ### `+dropdown-delete` diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 4608bdeaf..4849801f0 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -120,6 +120,35 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl **判断是不是"新行"**:写入 range 超出 `+csv-get` 返回的 `current_region` 右 / 下边界(如 `current_region=A1:H10`、写 `A11:H11`)即新行,必须按上述做法补边框。 +## Dropdown 配色(`+dropdown-set` / `+dropdown-update`) + +下拉的胶囊背景色高亮由两个 flag 控制,分别映射到 server `data_validation` 的两个字段: + +| CLI flag | server 字段 | 类型 | 含义 | +|---|---|---|---| +| `--highlight` | `enable_highlight` | bool | 总开关;为 `false`(默认)时所有选项无背景色 | +| `--colors` | `highlight_colors` | string[] | RGB hex 数组,如 `["#1FB6C1","#F006C2"]` | + +规则: + +- **`--colors` 长度可以短于 `--options`**(剩余项 server 按内置 10 色色板循环补色),**但不能长于**——CLI 端 Validate 阶段会拦截长于的情况。 +- **`--colors` 仅在 `--highlight=true` 时生效**——单独传 `--colors` 而不传 `--highlight`,server 不会显示任何背景色。 +- 完全不传两者,下拉就是纯文本选项(无配色)。 +- 想让所有选项都有颜色但不指定具体色——只传 `--highlight`,server 用内置色板循环。 + +最小用例(4 个 options 配 3 个 colors —— 前 3 个用指定色,第 4 个按内置色板补色): + +``` +lark-cli sheets +dropdown-set \ + --url https://... --sheet-id \ + --range A2:A100 \ + --options '["待开始","进行中","已完成","已取消"]' \ + --colors '["#bff7d9","#FFE699","#bacefd"]' \ + --highlight +``` + +`+dropdown-update`(多 range 批量更新)的 `--colors` / `--highlight` 行为完全一致;只是 range 由单值变成 JSON 数组(每项带 sheet 前缀),同一份配色应用到所有 range。 + ## 工具选择 本 skill 提供以下 CLI shortcut,按数据来源 + 内容形态选: @@ -199,9 +228,9 @@ _公共四件套 · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--range` | string | required | 目标范围(A1 格式,如 `A2:A100`) | | `--options` | string + File + Stdin(复合 JSON) | required | 选项 JSON 数组 `["opt1","opt2"]`;最多 500 项,每项 ≤100 字符,不含逗号 | -| `--colors` | string + File + Stdin(简单 JSON) | optional | RGB hex 颜色数组(如 `["#1FB6C1","#F006C2"]`),长度必须与 `--options` 一致 | +| `--colors` | string + File + Stdin(简单 JSON) | optional | 下拉选项的胶囊背景色,RGB hex 数组,如 `["#1FB6C1","#F006C2"]`。映射到 server `data_validation.highlight_colors`。长度可以**短于** `--options`(剩余项 server 按内置 10 色色板循环补色),但**不能长于**。仅当 `--highlight` 也传时才生效;单独传本 flag 不显示高亮色。 | | `--multiple` | bool | optional | 启用多选;默认 `false` | -| `--highlight` | bool | optional | 选项配色显示;默认 `false` | +| `--highlight` | bool | optional | 开启下拉选项的胶囊背景色高亮;默认 `false`。映射到 server `data_validation.enable_highlight`。不传或为 `false` 时所有选项无背景色;为 `true` 时按 `--colors` 顺序上色,未在 `--colors` 中提供的选项使用内置 10 色色板循环补色。 | ### `+csv-put` From f0d218f7ea5d1a5640a9c6b1c3fb2429e1deec2e Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Mon, 25 May 2026 14:23:08 +0800 Subject: [PATCH 049/114] feat(sheets): +dropdown-set/-update --source-range for listFromRange mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously +dropdown-set / +dropdown-update only emitted data_validation.type=list — agents wanting listFromRange (dropdown options sourced from existing cells, kept in sync with that range) had to drop down to +cells-set and hand-build a data_validation map. The flag now exposes it natively as --source-range, paired with --options under XOR. CLI changes: - shortcuts/sheets/lark_sheet_write_cells.go: * new dropdownTypeAndItems(runtime) — central XOR resolver: rejects 0 or 2 of {--options, --source-range}, returns (sourceSize, partial dv with type+items|range filled in). Source size = options length for list mode, rangeDimensions(--source-range) cell count for listFromRange. * buildDropdownValidation rewritten to call the resolver, then layer --colors / --multiple / --highlight on top — semantics unchanged for callers, just two modes instead of one. * validateDropdownOptions / -Colors renamed to validateDropdownSourceOrOptions so the XOR + length check fires at +dropdown-update Validate time too. * --colors length error message generalized: "must not exceed dropdown source size (N)" (covers both modes). - shortcuts/sheets/lark_sheet_batch_update.go: rename call site. - shortcuts/sheets/lark_sheet_write_cells_test.go: 4 new tests — ListFromRange (happy path: range + items absent + colors + highlight all emit), ListFromRange_ColorsLongerThanCells (overflow against T1:T3 cell count), XorBothSet, XorNeitherSet. Updated the existing ColorsLongerThanOptions assertion to match the new "source size" wording. Spec-driven changes (synced via npm run sync:cli from sheet-skill-spec MR !7 2c298b6): - shortcuts/sheets/data/flag-defs.json: --options Required flips to xor on +dropdown-set/-update; new --source-range row gains long-form description pointing at server data_validation.range + the XOR semantics. - skills/lark-sheets/references/lark-sheets-write-cells.md: "Dropdown 配色" section reorganized into "Dropdown 选项 + 配色" — XOR comparison table (list vs listFromRange), shared config flag table (--highlight / --colors), explicit length rule covering both modes, side-by-side minimal examples, server-range-normalization gotcha callout. - skills/lark-sheets/references/lark-sheets-batch-update.md pointer updated to mention both modes + that +dropdown-delete is unaffected. PPE smoke (ppe_lark_cli_sheet) on UFJxszjrZhZ1LVtc9FdcICSbn6b C column: - +cells-set C1 → "性别" (bold + centered): updated_cells_count=1 - +dropdown-set --range C2:C21 --source-range "Sheet1!T1:T3" --colors '["#cce8ff","#ffd6e7","#e6e6e6"]' --highlight: updated_cells_count=20 - read-back: data_validation.type=listFromRange + range=$T$1:$T$3 (server normalizes the prefix away on storage; highlight_colors / enable_highlight not echoed by get_cell_ranges, see byted-sheet read projection TODO). - error-path replay (both XOR violations + colors > source-size) all rejected at Validate stage with the expected messages. --- shortcuts/sheets/data/flag-defs.json | 18 +++- shortcuts/sheets/lark_sheet_batch_update.go | 2 +- shortcuts/sheets/lark_sheet_write_cells.go | 83 +++++++++++----- .../sheets/lark_sheet_write_cells_test.go | 99 ++++++++++++++++++- .../references/lark-sheets-batch-update.md | 5 +- .../references/lark-sheets-write-cells.md | 39 ++++++-- 6 files changed, 208 insertions(+), 38 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index 713456e67..91781be7c 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -1867,7 +1867,7 @@ "name": "options", "kind": "own", "type": "string", - "required": "required", + "required": "xor", "desc": "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`; up to 500 items, each ≤100 chars, no commas", "input": [ "file", @@ -1899,6 +1899,13 @@ "required": "optional", "desc": "Enable pill-background color highlighting for dropdown options; default `false`. Maps to server `data_validation.enable_highlight`. When `true`, colors come from `--colors` in order; options beyond `--colors` length cycle through a built-in 10-color palette." }, + { + "name": "source-range", + "kind": "own", + "type": "string", + "required": "xor", + "desc": "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `Sheet1!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same." + }, { "name": "dry-run", "kind": "system", @@ -2795,7 +2802,7 @@ "name": "options", "kind": "own", "type": "string", - "required": "required", + "required": "xor", "desc": "Options as a JSON array (e.g. `[\"opt1\",\"opt2\"]`)", "input": [ "file", @@ -2827,6 +2834,13 @@ "required": "optional", "desc": "Enable pill-background color highlighting for dropdown options; default `false`. Maps to server `data_validation.enable_highlight`. Works with `--colors`; if `--colors` is omitted, all options cycle through a built-in 10-color palette." }, + { + "name": "source-range", + "kind": "own", + "type": "string", + "required": "xor", + "desc": "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `Sheet1!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same." + }, { "name": "dry-run", "kind": "system", diff --git a/shortcuts/sheets/lark_sheet_batch_update.go b/shortcuts/sheets/lark_sheet_batch_update.go index d6591bb52..6cef40721 100644 --- a/shortcuts/sheets/lark_sheet_batch_update.go +++ b/shortcuts/sheets/lark_sheet_batch_update.go @@ -332,7 +332,7 @@ var DropdownUpdate = common.Shortcut{ if _, err := validateDropdownRanges(runtime); err != nil { return err } - if _, err := validateDropdownOptionsColors(runtime); err != nil { + if _, err := validateDropdownSourceOrOptions(runtime); err != nil { return err } return nil diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index c44970e8d..0d451890f 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -331,33 +331,33 @@ func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[s // ─── shared dropdown helpers ────────────────────────────────────────── -// buildDropdownValidation packs --options / --colors / --multiple / --highlight -// into the data_validation block expected by set_cell_range. Field names -// follow the canonical set_cell_range.data_validation schema: +// buildDropdownValidation packs --options or --source-range plus --colors / +// --multiple / --highlight into the data_validation block expected by +// set_cell_range. Field names follow the canonical +// set_cell_range.data_validation schema: // -// --options -> items (string array) -// --multiple -> support_multiple_values (bool) -// --colors -> highlight_colors (string array, hex) -// --highlight -> enable_highlight (bool) +// --options -> {type: "list", items: } +// --source-range -> {type: "listFromRange", range: } +// --multiple -> support_multiple_values (bool) +// --colors -> highlight_colors (string array, hex) +// --highlight -> enable_highlight (bool) // -// --colors length may be shorter than --options (server cycles remaining -// options through a built-in 10-color palette) but must not exceed it. +// --options and --source-range are XOR (caller must pass exactly one). +// --colors length may be shorter than the source size (options length or +// source-range cell count) — server cycles remaining slots through a +// built-in 10-color palette — but must not exceed it. func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) { - options, err := requireJSONArray(runtime, "options") + sourceSize, dv, err := dropdownTypeAndItems(runtime) if err != nil { return nil, err } - dv := map[string]interface{}{ - "type": "list", - "items": options, - } if runtime.Str("colors") != "" { colors, err := requireJSONArray(runtime, "colors") if err != nil { return nil, err } - if len(colors) > len(options) { - return nil, common.FlagErrorf("--colors length (%d) must not exceed --options length (%d)", len(colors), len(options)) + if len(colors) > sourceSize { + return nil, common.FlagErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize) } dv["highlight_colors"] = colors } @@ -370,11 +370,46 @@ func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) { return dv, nil } -// validateDropdownOptionsColors validates --options is a JSON array and that -// --colors (when set) is no longer than --options. Returns the options length -// so callers can size their cells matrix at Validate time without re-parsing. -func validateDropdownOptionsColors(runtime flagView) (int, error) { - options, err := requireJSONArray(runtime, "options") +// dropdownTypeAndItems resolves the XOR between --options and --source-range +// and returns (sourceSize, partial dv with type+items|range set). sourceSize +// is the option count for `list` mode or the source-range cell count for +// `listFromRange` mode — used to validate --colors length. +func dropdownTypeAndItems(runtime flagView) (int, map[string]interface{}, error) { + optsRaw := runtime.Str("options") + sourceRange := strings.TrimSpace(runtime.Str("source-range")) + switch { + case optsRaw != "" && sourceRange != "": + return 0, nil, common.FlagErrorf("--options and --source-range are mutually exclusive; pass exactly one") + case optsRaw == "" && sourceRange == "": + return 0, nil, common.FlagErrorf("one of --options (inline list) or --source-range (listFromRange) is required") + case optsRaw != "": + options, err := requireJSONArray(runtime, "options") + if err != nil { + return 0, nil, err + } + return len(options), map[string]interface{}{ + "type": "list", + "items": options, + }, nil + default: // sourceRange != "" + rows, cols, err := rangeDimensions(sourceRange) + if err != nil { + return 0, nil, common.FlagErrorf("--source-range %q: %v", sourceRange, err) + } + return rows * cols, map[string]interface{}{ + "type": "listFromRange", + "range": sourceRange, + }, nil + } +} + +// validateDropdownSourceOrOptions runs the XOR + --colors length check at +// Validate time so +dropdown-update / +dropdown-delete can fail fast without +// reaching the body-build step. Returns the dropdown source size (options +// length for list mode, source-range cell count for listFromRange) so +// callers can size their cells matrix. +func validateDropdownSourceOrOptions(runtime flagView) (int, error) { + sourceSize, _, err := dropdownTypeAndItems(runtime) if err != nil { return 0, err } @@ -383,11 +418,11 @@ func validateDropdownOptionsColors(runtime flagView) (int, error) { if err != nil { return 0, err } - if len(colors) > len(options) { - return 0, common.FlagErrorf("--colors length (%d) must not exceed --options length (%d)", len(colors), len(options)) + if len(colors) > sourceSize { + return 0, common.FlagErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize) } } - return len(options), nil + return sourceSize, nil } // ─── range parsing helpers ──────────────────────────────────────────── diff --git a/shortcuts/sheets/lark_sheet_write_cells_test.go b/shortcuts/sheets/lark_sheet_write_cells_test.go index aef33e272..df350a19c 100644 --- a/shortcuts/sheets/lark_sheet_write_cells_test.go +++ b/shortcuts/sheets/lark_sheet_write_cells_test.go @@ -174,8 +174,9 @@ func TestDropdownSet_CellsShape(t *testing.T) { } // TestDropdownSet_ColorsLongerThanOptions checks the early Validate-time -// error when --colors length exceeds --options. Equal-or-shorter lengths -// are accepted (server cycles the rest through a built-in palette). +// error when --colors length exceeds the dropdown source size (options +// length in list mode). Equal-or-shorter lengths are accepted (server +// cycles the rest through a built-in palette). func TestDropdownSet_ColorsLongerThanOptions(t *testing.T) { t.Parallel() _, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{ @@ -188,7 +189,7 @@ func TestDropdownSet_ColorsLongerThanOptions(t *testing.T) { if err == nil { t.Fatal("expected --colors length error, got nil") } - if !strings.Contains(stderr, "must not exceed --options length") && !strings.Contains(err.Error(), "must not exceed --options length") { + if !strings.Contains(stderr, "must not exceed dropdown source size") && !strings.Contains(err.Error(), "must not exceed dropdown source size") { t.Errorf("error message missing length-overflow hint:\nerr=%v\nstderr=%s", err, stderr) } } @@ -215,6 +216,98 @@ func TestDropdownSet_ColorsShorterAccepted(t *testing.T) { } } +// TestDropdownSet_ListFromRange verifies --source-range emits +// data_validation.type=listFromRange + data_validation.range, paired with +// --colors / --highlight propagating to highlight_colors / enable_highlight. +func TestDropdownSet_ListFromRange(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, DropdownSet, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "B2:B21", + "--source-range", "Sheet1!T1:T3", + "--colors", `["#cce8ff","#ffd6e7","#e6e6e6"]`, + "--highlight", + }) + input := decodeToolInput(t, body, "set_cell_range") + cells, _ := input["cells"].([]interface{}) + row0, _ := cells[0].([]interface{}) + cell, _ := row0[0].(map[string]interface{}) + dv, _ := cell["data_validation"].(map[string]interface{}) + if dv["type"] != "listFromRange" { + t.Errorf("data_validation.type = %v, want listFromRange", dv["type"]) + } + if dv["range"] != "Sheet1!T1:T3" { + t.Errorf("data_validation.range = %v, want Sheet1!T1:T3 (verbatim, server normalizes)", dv["range"]) + } + if _, hasItems := dv["items"]; hasItems { + t.Errorf("listFromRange mode should not emit `items`: %#v", dv) + } + if dv["enable_highlight"] != true { + t.Errorf("data_validation.enable_highlight = %v, want true", dv["enable_highlight"]) + } + colors, _ := dv["highlight_colors"].([]interface{}) + if len(colors) != 3 { + t.Errorf("highlight_colors length = %d, want 3", len(colors)) + } +} + +// TestDropdownSet_ListFromRange_ColorsLongerThanCells rejects --colors +// longer than the source range cell count (T1:T3 has 3 cells, 4 colors +// must be refused). +func TestDropdownSet_ListFromRange_ColorsLongerThanCells(t *testing.T) { + t.Parallel() + _, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "B2:B21", + "--source-range", "Sheet1!T1:T3", + "--colors", `["#a","#b","#c","#d"]`, + "--highlight", + "--dry-run", + }) + if err == nil { + t.Fatal("expected --colors length error, got nil") + } + if !strings.Contains(stderr, "must not exceed dropdown source size") && !strings.Contains(err.Error(), "must not exceed dropdown source size") { + t.Errorf("error message missing source-size hint:\nerr=%v\nstderr=%s", err, stderr) + } +} + +// TestDropdownSet_XorBothSet rejects passing both --options and +// --source-range. +func TestDropdownSet_XorBothSet(t *testing.T) { + t.Parallel() + _, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "B2:B21", + "--options", `["a","b"]`, + "--source-range", "Sheet1!T1:T3", + "--dry-run", + }) + if err == nil { + t.Fatal("expected XOR error, got nil") + } + if !strings.Contains(stderr, "mutually exclusive") && !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("error message missing XOR hint:\nerr=%v\nstderr=%s", err, stderr) + } +} + +// TestDropdownSet_XorNeitherSet rejects passing neither --options nor +// --source-range. +func TestDropdownSet_XorNeitherSet(t *testing.T) { + t.Parallel() + _, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "B2:B21", + "--dry-run", + }) + if err == nil { + t.Fatal("expected required-one error, got nil") + } + if !strings.Contains(stderr, "one of --options") && !strings.Contains(err.Error(), "one of --options") { + t.Errorf("error message missing required-one hint:\nerr=%v\nstderr=%s", err, stderr) + } +} + // TestCellsSetStyle_FlatFlags verifies that the 11 flat style flags + // --border-styles compose into cell_styles + border_styles per cell. func TestCellsSetStyle_FlatFlags(t *testing.T) { diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 3a7898a0f..1438ac901 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -22,7 +22,7 @@ 当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求——`+batch-update` 是原子提交(要么全成功要么整批回滚);逐个调用非原子,中途失败会留下半成品。 -**`+dropdown-update` / `+dropdown-delete` 的配色规则**(`--colors` 长度可短于 `--options` 但不能长于、必须配 `--highlight=true` 才生效、不传按内置 10 色色板循环补色)见 [`lark-sheets-write-cells`](./lark-sheets-write-cells.md) 的「Dropdown 配色」节,本 skill 不重复。 +**`+dropdown-update` 的选项模式(`--options` / `--source-range` XOR,对应 server `type=list` / `type=listFromRange`)+ 配色规则**(`--colors` 长度可短不能长、必须配 `--highlight=true` 才生效、不传按内置 10 色色板循环补色)见 [`lark-sheets-write-cells`](./lark-sheets-write-cells.md) 的「Dropdown 选项 + 配色」节,本 skill 不重复。`+dropdown-delete` 不涉及这些 flag。 ## Shortcuts @@ -71,10 +71,11 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(如 `["sheet1!A2:A100"]`),每项必须带 sheet 前缀 | -| `--options` | string + File + Stdin(复合 JSON) | required | 选项 JSON 数组(如 `["opt1","opt2"]`) | +| `--options` | string + File + Stdin(复合 JSON) | xor | 选项 JSON 数组(如 `["opt1","opt2"]`) | | `--colors` | string + File + Stdin(简单 JSON) | optional | 下拉选项的胶囊背景色,RGB hex 数组(如 `["#1FB6C1","#F006C2"]`)。映射到 server `data_validation.highlight_colors`。长度可以**短于** `--options`(剩余项按内置色板补色)但**不能长于**。仅当 `--highlight` 也传时才生效。 | | `--multiple` | bool | optional | 启用多选 | | `--highlight` | bool | optional | 开启下拉选项的胶囊背景色高亮;默认 `false`。映射到 server `data_validation.enable_highlight`。需配合 `--colors` 使用——不传 `--colors` 时全部选项按内置 10 色色板循环。 | +| `--source-range` | string | xor | listFromRange 模式的下拉源 range,A1 表示法 + sheet 前缀(如 `Sheet1!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表(type=list),传本 flag 走 range 引用(type=listFromRange)。`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。 | ### `+dropdown-delete` diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 4849801f0..5a7d3912f 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -120,9 +120,20 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl **判断是不是"新行"**:写入 range 超出 `+csv-get` 返回的 `current_region` 右 / 下边界(如 `current_region=A1:H10`、写 `A11:H11`)即新行,必须按上述做法补边框。 -## Dropdown 配色(`+dropdown-set` / `+dropdown-update`) +## Dropdown 选项 + 配色(`+dropdown-set` / `+dropdown-update`) -下拉的胶囊背景色高亮由两个 flag 控制,分别映射到 server `data_validation` 的两个字段: +### 两种选项模式(XOR) + +| 模式 | flag | server `data_validation.type` | 适用场景 | +|---|---|---|---| +| inline 列表 | `--options '["a","b","c"]'` | `list` | 固定选项集,写死在命令里 | +| 引用 range | `--source-range 'Sheet1!T1:T3'` | `listFromRange` | 选项来源是已有单元格,跟数据动态同步;适合维护一张「枚举值」列后多处引用 | + +**`--options` 和 `--source-range` 必须二选一传一个**(CLI 端 Validate 阶段拦截 0 个或 2 个的情况)。`--source-range` 用 A1 + sheet 前缀写法,跟目标 `--range` 可以是同 sheet 也可以是其它 sheet(如 `Refs!A1:A10`)。 + +### 配色(两种模式通用) + +下拉的胶囊背景色高亮由另外两个 flag 控制,跟选项模式正交,映射到 server `data_validation` 的两个字段: | CLI flag | server 字段 | 类型 | 含义 | |---|---|---|---| @@ -131,12 +142,14 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl 规则: -- **`--colors` 长度可以短于 `--options`**(剩余项 server 按内置 10 色色板循环补色),**但不能长于**——CLI 端 Validate 阶段会拦截长于的情况。 +- **`--colors` 长度可以短**:list 模式下 ≤ `--options` 长度,listFromRange 模式下 ≤ `--source-range` 的单元格数(剩余项 server 按内置 10 色色板循环补色),**但不能长于**——CLI 端 Validate 阶段会拦截长于的情况。 - **`--colors` 仅在 `--highlight=true` 时生效**——单独传 `--colors` 而不传 `--highlight`,server 不会显示任何背景色。 - 完全不传两者,下拉就是纯文本选项(无配色)。 - 想让所有选项都有颜色但不指定具体色——只传 `--highlight`,server 用内置色板循环。 -最小用例(4 个 options 配 3 个 colors —— 前 3 个用指定色,第 4 个按内置色板补色): +### 最小用例 + +**list 模式**(4 个 options 配 3 个 colors —— 前 3 个用指定色,第 4 个按内置色板补色): ``` lark-cli sheets +dropdown-set \ @@ -147,7 +160,20 @@ lark-cli sheets +dropdown-set \ --highlight ``` -`+dropdown-update`(多 range 批量更新)的 `--colors` / `--highlight` 行为完全一致;只是 range 由单值变成 JSON 数组(每项带 sheet 前缀),同一份配色应用到所有 range。 +**listFromRange 模式**(源 range 在同 sheet 的 T1:T3,3 个 options 配 3 个 colors): + +``` +lark-cli sheets +dropdown-set \ + --url https://... --sheet-id \ + --range B2:B21 \ + --source-range 'Sheet1!T1:T3' \ + --colors '["#cce8ff","#ffd6e7","#e6e6e6"]' \ + --highlight +``` + +> ⚠️ **`--source-range` 必须带 sheet 前缀**(即使跟 `--range` 同 sheet),server 会自动把它规范化成 `$T$1:$T$3`(绝对引用)落库;回读时 sheet 前缀已被剥掉,agent 拿 read 结果再回写需要自己补前缀。 + +`+dropdown-update`(多 range 批量更新)的所有 flag 语义与 `+dropdown-set` 完全一致;只是目标 `--ranges` 由单值变成 JSON 数组(每项带 sheet 前缀),同一份选项 + 配色应用到所有 range。 ## 工具选择 @@ -227,10 +253,11 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--range` | string | required | 目标范围(A1 格式,如 `A2:A100`) | -| `--options` | string + File + Stdin(复合 JSON) | required | 选项 JSON 数组 `["opt1","opt2"]`;最多 500 项,每项 ≤100 字符,不含逗号 | +| `--options` | string + File + Stdin(复合 JSON) | xor | 选项 JSON 数组 `["opt1","opt2"]`;最多 500 项,每项 ≤100 字符,不含逗号 | | `--colors` | string + File + Stdin(简单 JSON) | optional | 下拉选项的胶囊背景色,RGB hex 数组,如 `["#1FB6C1","#F006C2"]`。映射到 server `data_validation.highlight_colors`。长度可以**短于** `--options`(剩余项 server 按内置 10 色色板循环补色),但**不能长于**。仅当 `--highlight` 也传时才生效;单独传本 flag 不显示高亮色。 | | `--multiple` | bool | optional | 启用多选;默认 `false` | | `--highlight` | bool | optional | 开启下拉选项的胶囊背景色高亮;默认 `false`。映射到 server `data_validation.enable_highlight`。不传或为 `false` 时所有选项无背景色;为 `true` 时按 `--colors` 顺序上色,未在 `--colors` 中提供的选项使用内置 10 色色板循环补色。 | +| `--source-range` | string | xor | listFromRange 模式的下拉源 range,A1 表示法 + sheet 前缀(如 `Sheet1!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表(type=list),传本 flag 走 range 引用(type=listFromRange)。`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。 | ### `+csv-put` From 38ef6ad51ec756cf089acc1c1501fb7fd0e588a9 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Mon, 25 May 2026 14:28:07 +0800 Subject: [PATCH 050/114] =?UTF-8?q?docs(sheets):=20sync=20agent-voice=20re?= =?UTF-8?q?write=20of=20Dropdown=20=E9=80=89=E9=A1=B9+=E9=85=8D=E8=89=B2?= =?UTF-8?q?=20from=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors sheet-skill-spec MR !7 60df610 — narrative now describes how the flags interact (XOR, colors length rule, highlight gating, sheet-prefix read-back gotcha) without exposing the underlying data_validation field names or server-side normalization details that agents don't act on. No Go-side change, no shortcut behavior change. --- .../references/lark-sheets-batch-update.md | 2 +- .../references/lark-sheets-write-cells.md | 40 +++++++++---------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 1438ac901..e6ab669e4 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -22,7 +22,7 @@ 当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求——`+batch-update` 是原子提交(要么全成功要么整批回滚);逐个调用非原子,中途失败会留下半成品。 -**`+dropdown-update` 的选项模式(`--options` / `--source-range` XOR,对应 server `type=list` / `type=listFromRange`)+ 配色规则**(`--colors` 长度可短不能长、必须配 `--highlight=true` 才生效、不传按内置 10 色色板循环补色)见 [`lark-sheets-write-cells`](./lark-sheets-write-cells.md) 的「Dropdown 选项 + 配色」节,本 skill 不重复。`+dropdown-delete` 不涉及这些 flag。 +**`+dropdown-update` 的选项模式(`--options` / `--source-range` 二选一)+ 配色规则**(`--colors` 长度可短不能长、必须配 `--highlight=true` 才生效、不传按内置 10 色色板循环补色)见 [`lark-sheets-write-cells`](./lark-sheets-write-cells.md) 的「Dropdown 选项 + 配色」节,本 skill 不重复。`+dropdown-delete` 不涉及这些 flag。 ## Shortcuts diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 5a7d3912f..2bb12a8de 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -122,34 +122,32 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl ## Dropdown 选项 + 配色(`+dropdown-set` / `+dropdown-update`) -### 两种选项模式(XOR) +### 选项怎么来:`--options` 与 `--source-range` 二选一 -| 模式 | flag | server `data_validation.type` | 适用场景 | -|---|---|---|---| -| inline 列表 | `--options '["a","b","c"]'` | `list` | 固定选项集,写死在命令里 | -| 引用 range | `--source-range 'Sheet1!T1:T3'` | `listFromRange` | 选项来源是已有单元格,跟数据动态同步;适合维护一张「枚举值」列后多处引用 | +| flag | 选项来源 | 适用场景 | +|---|---|---| +| `--options '["a","b","c"]'` | 写在命令里的固定列表 | 选项集是常量、不需要事后维护 | +| `--source-range 'Sheet1!T1:T3'` | 已有单元格里的值 | 选项要跟数据动态同步;想维护一张「枚举值」列后多处引用 | -**`--options` 和 `--source-range` 必须二选一传一个**(CLI 端 Validate 阶段拦截 0 个或 2 个的情况)。`--source-range` 用 A1 + sheet 前缀写法,跟目标 `--range` 可以是同 sheet 也可以是其它 sheet(如 `Refs!A1:A10`)。 +两个 flag **必须传一个、且只能传一个**——同时传或都不传,CLI 会立刻报错。`--source-range` 用 A1 + sheet 前缀写法(如 `Sheet1!T1:T3`),可以指同 sheet 也可以指其它 sheet(如 `Refs!A1:A10`)。 -### 配色(两种模式通用) +### 配色:`--colors` 与 `--highlight`(与选项模式无关) -下拉的胶囊背景色高亮由另外两个 flag 控制,跟选项模式正交,映射到 server `data_validation` 的两个字段: +- `--highlight`:开下拉选项的胶囊背景色总开关;不传默认 `false`、无配色。 +- `--colors '["#hex","#hex",...]'`:每个选项对应一种胶囊背景色;只在 `--highlight` 也传时生效。 -| CLI flag | server 字段 | 类型 | 含义 | -|---|---|---|---| -| `--highlight` | `enable_highlight` | bool | 总开关;为 `false`(默认)时所有选项无背景色 | -| `--colors` | `highlight_colors` | string[] | RGB hex 数组,如 `["#1FB6C1","#F006C2"]` | +长度规则: +- `--colors` 长度**可以短于**选项数(list 模式短于 `--options` 长度,listFromRange 模式短于 `--source-range` 的单元格数),未指定的选项按内置 10 色色板循环补色; +- `--colors` 长度**不能长于**选项数——CLI 端 Validate 阶段就会拦截,错误信息形如 `--colors length (4) must not exceed dropdown source size (3)`。 -规则: - -- **`--colors` 长度可以短**:list 模式下 ≤ `--options` 长度,listFromRange 模式下 ≤ `--source-range` 的单元格数(剩余项 server 按内置 10 色色板循环补色),**但不能长于**——CLI 端 Validate 阶段会拦截长于的情况。 -- **`--colors` 仅在 `--highlight=true` 时生效**——单独传 `--colors` 而不传 `--highlight`,server 不会显示任何背景色。 -- 完全不传两者,下拉就是纯文本选项(无配色)。 -- 想让所有选项都有颜色但不指定具体色——只传 `--highlight`,server 用内置色板循环。 +排列组合: +- 想要纯文本下拉、无配色 → 都不传 `--highlight` / `--colors`; +- 想要每个选项指定确切颜色 → 同时传 `--highlight` 和 `--colors`; +- 想要所有选项都有颜色但不指定具体色 → 只传 `--highlight`,颜色按内置色板循环。 ### 最小用例 -**list 模式**(4 个 options 配 3 个 colors —— 前 3 个用指定色,第 4 个按内置色板补色): +**`--options` 模式**(4 个选项配 3 个颜色——前 3 个指定色,第 4 个按内置色板补): ``` lark-cli sheets +dropdown-set \ @@ -160,7 +158,7 @@ lark-cli sheets +dropdown-set \ --highlight ``` -**listFromRange 模式**(源 range 在同 sheet 的 T1:T3,3 个 options 配 3 个 colors): +**`--source-range` 模式**(先在 Sheet1!T1:T3 维护「男/女/保密」三行,再让 B2:B21 引用它): ``` lark-cli sheets +dropdown-set \ @@ -171,7 +169,7 @@ lark-cli sheets +dropdown-set \ --highlight ``` -> ⚠️ **`--source-range` 必须带 sheet 前缀**(即使跟 `--range` 同 sheet),server 会自动把它规范化成 `$T$1:$T$3`(绝对引用)落库;回读时 sheet 前缀已被剥掉,agent 拿 read 结果再回写需要自己补前缀。 +> ⚠️ **`--source-range` 必须带 sheet 前缀**(即使跟 `--range` 同 sheet)。注意一个坑:回读这种 listFromRange 下拉单元格时,`data_validation.range` 看起来不带 sheet 前缀(形如 `$T$1:$T$3`),如果要把读出来的 range 反过来写回 `--source-range`,**必须自己重新补上 sheet 前缀**,否则会被拒。 `+dropdown-update`(多 range 批量更新)的所有 flag 语义与 `+dropdown-set` 完全一致;只是目标 `--ranges` 由单值变成 JSON 数组(每项带 sheet 前缀),同一份选项 + 配色应用到所有 range。 From fa503fa47fd78e358b0928c04ec2e4c6c79f03a8 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Mon, 25 May 2026 14:35:43 +0800 Subject: [PATCH 051/114] chore(sheets): restore --colors in parseJSONFlag docstring example list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier commit 49104ec swapped --colors out of parseJSONFlag's "Used by" example list when it deleted the flag (item #2 there removed --colors / --highlight from +dropdown-set/-update). Subsequent commits 8672d8e / 538eb2e / fb90c8b reinstated --colors (and added --source-range) but did not roll back this docstring tweak — leaving an orphan reference to --properties where --colors used to be. This restores the example list to its pre-49104ec form so the docstring matches what the helper actually services on this branch's HEAD. Pure docstring change — function behavior unaffected, no test movement. --- shortcuts/sheets/helpers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go index 79b7223a3..64206b9e2 100644 --- a/shortcuts/sheets/helpers.go +++ b/shortcuts/sheets/helpers.go @@ -156,7 +156,7 @@ func sheetSelectorPlaceholder(sheetID, sheetName string) string { // parseJSONFlag parses a JSON string from a flag value. Returns nil when the // flag is empty (caller decides if that's acceptable). Used by --data / -// --style / --options / --ranges / --properties and friends. +// --style / --options / --ranges / --colors and friends. func parseJSONFlag(runtime flagView, name string) (interface{}, error) { raw := strings.TrimSpace(runtime.Str(name)) if raw == "" { From f0dea38aeb146a3ee517bd7f795329230be32f72 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Mon, 25 May 2026 14:41:59 +0800 Subject: [PATCH 052/114] fix(sheets): post-rebase test fixups after dropping superseded fix #1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two test fallouts from rebasing onto upstream 4be06c8 (which independently re-fixed +workbook-create and +dim-move with a more thorough approach): - shortcuts/sheets/lark_sheet_workbook_test.go: our PR's earlier TestWorkbookCreate_DryRun "with headers and data → 2-step plan" subtest asserted the expedient sheet_name="Sheet1" / no-sheet_id wire body that matched our dropped fix #1 implementation. Upstream's fix #1 resolves the workbook's first sheet via get_workbook_structure and fills with the real sheet_id instead. Reset this file to upstream's version — our superseded assertions disappear, upstream's tests cover the new wire shape. - shortcuts/sheets/execute_paths_test.go: TestExecute_RangeSort fixture still used the legacy {col, order} sort-key shape because the rebase resolution picked the upstream version of this file wholesale (it contained other unrelated changes). Re-apply just the one fixture update to {column, ascending} so fix #5's CLI-side rejection logic exercises a valid input — server-side sort_conditions has required fields `column` (string) and `ascending` (bool); the historical {col, order} vocabulary was never accepted. go build ./... + go test ./shortcuts/sheets/... -count=1 both green. --- shortcuts/sheets/execute_paths_test.go | 2 +- shortcuts/sheets/lark_sheet_workbook_test.go | 9 --------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/shortcuts/sheets/execute_paths_test.go b/shortcuts/sheets/execute_paths_test.go index fbe55c76c..8b08aaed2 100644 --- a/shortcuts/sheets/execute_paths_test.go +++ b/shortcuts/sheets/execute_paths_test.go @@ -398,7 +398,7 @@ func TestExecute_RangeSort(t *testing.T) { "--url", testURL, "--sheet-id", testSheetID, "--range", "A1:D50", "--has-header", - "--sort-keys", `[{"col":"B","order":"asc"}]`, + "--sort-keys", `[{"column":"B","ascending":true}]`, }, stub) if err != nil { t.Fatalf("execute failed: %v", err) diff --git a/shortcuts/sheets/lark_sheet_workbook_test.go b/shortcuts/sheets/lark_sheet_workbook_test.go index 5298d41c0..f7b257fc2 100644 --- a/shortcuts/sheets/lark_sheet_workbook_test.go +++ b/shortcuts/sheets/lark_sheet_workbook_test.go @@ -317,15 +317,6 @@ func TestWorkbookCreate_DryRun(t *testing.T) { if input["range"] != "A1:B3" { t.Errorf("fill range = %v, want A1:B3 (1 header + 2 data rows × 2 cols)", input["range"]) } - // New workbook → fill targets the default sheet by name (no - // extra get_workbook_structure call is needed to learn the - // auto-generated sheet_id). - if input["sheet_name"] != "Sheet1" { - t.Errorf("fill sheet_name = %v, want Sheet1", input["sheet_name"]) - } - if _, hasID := input["sheet_id"]; hasID { - t.Errorf("fill sheet_id should be omitted (server rejects empty); got %v", input["sheet_id"]) - } }) } From 1a2d2d04bebb5c3e0e82263d6d33974d2384477b Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Mon, 25 May 2026 17:40:17 +0800 Subject: [PATCH 053/114] feat(sheets): +dropdown --highlight tri-state via Changed() for opt-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server-side default for data_validation.enable_highlight flipped from false to true (aligning with the UI behavior). With the previous code path if runtime.Bool("highlight") { dv["enable_highlight"] = true } omitting --highlight and passing --highlight=false both produced the same "enable_highlight key absent" body, leaving CLI users with no way to opt out of the (now-default) highlighting. Switch to runtime.Changed() so the translator can distinguish all three input shapes: - omitted -> no enable_highlight key (server applies default=true) - --highlight=true -> enable_highlight: true (explicit no-op vs default) - --highlight=false -> enable_highlight: false (the only opt-out path) flagView already exposes Changed() and mapFlagView (the +batch-update sub-op adapter) implements it via raw-key presence — same pattern other translators use for "Changed-only" branching (e.g. omit target_index unless --index was set), so no interface surface change is needed. Test coverage: - TestDropdownSet_HighlightTriState pins all four shapes (omit / presence form / explicit true / explicit false) and asserts the enable_highlight key's presence/value - TestBatchOp_BodyMatchesStandalone adds a --highlight=false sub-op case so the batch sub-op path produces a body byte-identical to the standalone +dropdown-set --highlight=false body --- shortcuts/sheets/batch_op_contract_test.go | 10 +++ shortcuts/sheets/lark_sheet_write_cells.go | 13 +++- .../sheets/lark_sheet_write_cells_test.go | 64 +++++++++++++++++++ 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/shortcuts/sheets/batch_op_contract_test.go b/shortcuts/sheets/batch_op_contract_test.go index 58d1d7847..4e73a11a8 100644 --- a/shortcuts/sheets/batch_op_contract_test.go +++ b/shortcuts/sheets/batch_op_contract_test.go @@ -183,6 +183,16 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) { args: []string{"--sheet-id", "sh1", "--range", "A2:A4", "--options", `["x","y"]`, "--multiple"}, subInput: `{"sheet-id":"sh1","range":"A2:A4","options":["x","y"],"multiple":true}`, }, + { + // --highlight=false explicitly opts out of the server's new + // enable_highlight=true default. Covers the tri-state Changed() + // branch in buildDropdownValidation: standalone reads the cobra + // "Changed" bit; sub-op reads the key's presence in the map. + shortcut: "+dropdown-set", + sc: DropdownSet, + args: []string{"--sheet-id", "sh1", "--range", "A2:A4", "--options", `["x","y"]`, "--highlight=false"}, + subInput: `{"sheet-id":"sh1","range":"A2:A4","options":["x","y"],"highlight":false}`, + }, { shortcut: "+chart-create", sc: ChartCreate, diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index 0d451890f..f2e545abd 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -340,12 +340,19 @@ func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[s // --source-range -> {type: "listFromRange", range: } // --multiple -> support_multiple_values (bool) // --colors -> highlight_colors (string array, hex) -// --highlight -> enable_highlight (bool) +// --highlight -> enable_highlight (bool, tri-state via Changed) // // --options and --source-range are XOR (caller must pass exactly one). // --colors length may be shorter than the source size (options length or // source-range cell count) — server cycles remaining slots through a // built-in 10-color palette — but must not exceed it. +// +// --highlight is tri-state: omitted leaves enable_highlight off the body so the +// server's new default (true) applies; --highlight=true stamps an explicit true; +// --highlight=false stamps false to turn the highlight off. Using Changed() lets +// us distinguish "not passed" from "explicit false" — required because the +// server-side default flipped from false to true and a plain cobra Bool can no +// longer carry the opt-out signal. func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) { sourceSize, dv, err := dropdownTypeAndItems(runtime) if err != nil { @@ -364,8 +371,8 @@ func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) { if runtime.Bool("multiple") { dv["support_multiple_values"] = true } - if runtime.Bool("highlight") { - dv["enable_highlight"] = true + if runtime.Changed("highlight") { + dv["enable_highlight"] = runtime.Bool("highlight") } return dv, nil } diff --git a/shortcuts/sheets/lark_sheet_write_cells_test.go b/shortcuts/sheets/lark_sheet_write_cells_test.go index df350a19c..24f8dce5e 100644 --- a/shortcuts/sheets/lark_sheet_write_cells_test.go +++ b/shortcuts/sheets/lark_sheet_write_cells_test.go @@ -173,6 +173,70 @@ func TestDropdownSet_CellsShape(t *testing.T) { } } +// TestDropdownSet_HighlightTriState pins down the tri-state semantics of +// --highlight after the server flipped enable_highlight's default from false +// to true. The translator uses runtime.Changed() to tell "user did not pass +// the flag" apart from "user passed --highlight=false": +// +// - omitted → no enable_highlight key in body (server applies its +// new default = true) +// - --highlight → enable_highlight=true (presence-only cobra form) +// - --highlight=true → enable_highlight=true (explicit form) +// - --highlight=false → enable_highlight=false (the only way to opt out; +// the documented "plain dropdown" path) +func TestDropdownSet_HighlightTriState(t *testing.T) { + t.Parallel() + cases := []struct { + name string + args []string + wantKey bool + wantValue bool + }{ + { + name: "omitted leaves enable_highlight off the body", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A2", "--options", `["a","b"]`}, + wantKey: false, + }, + { + name: "presence form (--highlight) stamps true", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A2", "--options", `["a","b"]`, "--highlight"}, + wantKey: true, + wantValue: true, + }, + { + name: "explicit --highlight=true stamps true", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A2", "--options", `["a","b"]`, "--highlight=true"}, + wantKey: true, + wantValue: true, + }, + { + name: "explicit --highlight=false stamps false (the opt-out path)", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A2", "--options", `["a","b"]`, "--highlight=false"}, + wantKey: true, + wantValue: false, + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, DropdownSet, tc.args) + input := decodeToolInput(t, body, "set_cell_range") + cells, _ := input["cells"].([]interface{}) + row0, _ := cells[0].([]interface{}) + cell, _ := row0[0].(map[string]interface{}) + dv, _ := cell["data_validation"].(map[string]interface{}) + got, has := dv["enable_highlight"] + if has != tc.wantKey { + t.Fatalf("enable_highlight key present = %v, want %v (dv = %#v)", has, tc.wantKey, dv) + } + if tc.wantKey && got != tc.wantValue { + t.Errorf("enable_highlight = %v (%T), want %v", got, got, tc.wantValue) + } + }) + } +} + // TestDropdownSet_ColorsLongerThanOptions checks the early Validate-time // error when --colors length exceeds the dropdown source size (options // length in list mode). Equal-or-shorter lengths are accepted (server From ff78ff40d8ff9dc880da0fc28a1f073c43bfd290 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Mon, 25 May 2026 17:40:40 +0800 Subject: [PATCH 054/114] chore(sheets): sync +dropdown flag desc + write-cells narrative from spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror sheet-skill-spec generated/ into shortcuts/sheets/data/ and skills/lark-sheets/ for the +dropdown-set / +dropdown-update path. No hand edits in this repo. The +dropdown flag desc and the Dropdown 配色 narrative now match the server-side enable_highlight default flip (true) and the tri-state --highlight semantics introduced in the sibling commit: * --highlight desc: 不传 = 开(按内置 10 色色板循环上色), --highlight=false 关闭得到纯白下拉 * --colors desc: 单独传即生效(高亮默认开),--highlight=false 时忽略 * write-cells reference: 三种意图三条线(默认色板 / 指定颜色 / 纯白下拉)+ 新增 --highlight=false 示例 Source upstream: sheet-skill-spec MR !8. --- shortcuts/sheets/data/flag-defs.json | 8 +-- shortcuts/sheets/data/flag-schemas.json | 10 ++-- .../references/lark-sheets-batch-update.md | 4 +- .../references/lark-sheets-write-cells.md | 53 ++++++++++++------- 4 files changed, 47 insertions(+), 28 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index 91781be7c..45e0dd56c 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -1879,7 +1879,7 @@ "kind": "own", "type": "string", "required": "optional", - "desc": "RGB hex pill colors for dropdown options (e.g. `[\"#1FB6C1\",\"#F006C2\"]`); maps to server `data_validation.highlight_colors`. Length may be **shorter than** `--options` (server cycles remaining slots through a built-in 10-color palette) but **must not exceed** it. Only takes effect when `--highlight` is also set.", + "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": [ "file", "stdin" @@ -1897,7 +1897,7 @@ "kind": "own", "type": "bool", "required": "optional", - "desc": "Enable pill-background color highlighting for dropdown options; default `false`. Maps to server `data_validation.enable_highlight`. When `true`, colors come from `--colors` in order; options beyond `--colors` length cycle through a built-in 10-color palette." + "desc": "Pill-highlight switch. **Omitted = ON** (options cycle through a 10-color palette). Pass `--highlight=false` for a plain dropdown. Override colors via `--colors`." }, { "name": "source-range", @@ -2814,7 +2814,7 @@ "kind": "own", "type": "string", "required": "optional", - "desc": "RGB hex pill colors for dropdown options (e.g. `[\"#1FB6C1\",\"#F006C2\"]`); maps to server `data_validation.highlight_colors`. Length may be **shorter than** `--options` (remaining slots cycle through a built-in palette) but **must not exceed** it. Only takes effect when `--highlight` is also set.", + "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": [ "file", "stdin" @@ -2832,7 +2832,7 @@ "kind": "own", "type": "bool", "required": "optional", - "desc": "Enable pill-background color highlighting for dropdown options; default `false`. Maps to server `data_validation.enable_highlight`. Works with `--colors`; if `--colors` is omitted, all options cycle through a built-in 10-color palette." + "desc": "Pill-highlight switch. **Omitted = ON** (options cycle through a 10-color palette). Pass `--highlight=false` for a plain dropdown. Override colors via `--colors`." }, { "name": "source-range", diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json index f7646942c..485cffd88 100644 --- a/shortcuts/sheets/data/flag-schemas.json +++ b/shortcuts/sheets/data/flag-schemas.json @@ -224,8 +224,9 @@ "type": "string" }, "note": { - "description": "单元格批注/备注", - "type": "string" + "description": "单元格批注/备注。设为 null 可清除已有的批注。", + "type": "string", + "nullable": true }, "cell_styles": { "type": "object", @@ -544,6 +545,7 @@ "data_validation": { "description": "数据验证配置。设为 null 可清除已有的数据验证。", "type": "object", + "nullable": true, "properties": { "type": { "description": "数据验证类型:list(下拉列表)、listFromRange(引用范围下拉列表)、number(数字)、date(日期)、textLength(文本长度)、checkbox(复选框)", @@ -605,11 +607,11 @@ "type": "string" }, "enable_highlight": { - "description": "是否开启下拉选项的胶囊背景色高亮,仅 type='list'/'listFromRange' 生效,默认 false。", + "description": "是否开启下拉选项的胶囊背景色高亮,仅 type='list'/'listFromRange' 生效。默认 true,自动按内置 10 色色板循环上色。仅当用户明确要求纯色下拉时才传 false。当用户要求按下拉项分别染色时,用本字段 + highlight_colors 一步搞定即可,不要走条件格式(条件格式是染整格背景,语义不符)。", "type": "boolean" }, "highlight_colors": { - "description": "下拉选项的胶囊背景色数组(十六进制,例如 [\"#FFE699\",\"#bff7d9\",\"#ffb3b3\"]),仅当 enable_highlight=true 时生效。按顺序对应(type='list' 对应 items;type='listFromRange' 按 range 内单元格行优先顺序,如 A1:A10 对应第 1-10 项;A1:B2 顺序为 A1,B1,A2,B2)。长度可以短于但不能长于;未指定项及不提供该字段时按内置 10 色色板循环补色。", + "description": "下拉选项的胶囊背景色数组(十六进制,例如 [\"#FFE699\",\"#bff7d9\",\"#ffb3b3\"])。按顺序对应(type='list' 对应 items;type='listFromRange' 按 range 内单元格行优先顺序,如 A1:A10 对应第 1-10 项;A1:B2 顺序为 A1,B1,A2,B2)。长度可以短于但不能长于;未指定项及不提供该字段时按内置 10 色色板循环补色。当 enable_highlight=false 时本字段被忽略。", "type": "array", "items": { "type": "string" diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index e6ab669e4..15c7fe243 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -72,9 +72,9 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(如 `["sheet1!A2:A100"]`),每项必须带 sheet 前缀 | | `--options` | string + File + Stdin(复合 JSON) | xor | 选项 JSON 数组(如 `["opt1","opt2"]`) | -| `--colors` | string + File + Stdin(简单 JSON) | optional | 下拉选项的胶囊背景色,RGB hex 数组(如 `["#1FB6C1","#F006C2"]`)。映射到 server `data_validation.highlight_colors`。长度可以**短于** `--options`(剩余项按内置色板补色)但**不能长于**。仅当 `--highlight` 也传时才生效。 | +| `--colors` | string + File + Stdin(简单 JSON) | optional | 下拉胶囊背景色,RGB hex 数组(如 `["#1FB6C1","#F006C2"]`)。长度可短不可长——超长 Validate 拦截(`--colors length (N) must not exceed dropdown source size (M)`),未指定项按内置 10 色色板循环补色。**单独传即生效**;`--highlight=false` 时被忽略。 | | `--multiple` | bool | optional | 启用多选 | -| `--highlight` | bool | optional | 开启下拉选项的胶囊背景色高亮;默认 `false`。映射到 server `data_validation.enable_highlight`。需配合 `--colors` 使用——不传 `--colors` 时全部选项按内置 10 色色板循环。 | +| `--highlight` | bool | optional | 下拉胶囊背景色高亮开关。**不传 = 开**(按内置 10 色色板循环上色);`--highlight=false` 关闭得到纯白下拉。配色用 `--colors` 覆盖。 | | `--source-range` | string | xor | listFromRange 模式的下拉源 range,A1 表示法 + sheet 前缀(如 `Sheet1!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表(type=list),传本 flag 走 range 引用(type=listFromRange)。`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。 | ### `+dropdown-delete` diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 2bb12a8de..9cc5cbea9 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -131,42 +131,59 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl 两个 flag **必须传一个、且只能传一个**——同时传或都不传,CLI 会立刻报错。`--source-range` 用 A1 + sheet 前缀写法(如 `Sheet1!T1:T3`),可以指同 sheet 也可以指其它 sheet(如 `Refs!A1:A10`)。 -### 配色:`--colors` 与 `--highlight`(与选项模式无关) +### 配色:默认即上色,三种意图三条线 -- `--highlight`:开下拉选项的胶囊背景色总开关;不传默认 `false`、无配色。 -- `--colors '["#hex","#hex",...]'`:每个选项对应一种胶囊背景色;只在 `--highlight` 也传时生效。 +下拉**默认带胶囊高亮**——什么 flag 都不传时,所有选项按内置 10 色色板循环上色,跟 UI 手动配下拉的默认行为对齐。三种意图: -长度规则: -- `--colors` 长度**可以短于**选项数(list 模式短于 `--options` 长度,listFromRange 模式短于 `--source-range` 的单元格数),未指定的选项按内置 10 色色板循环补色; -- `--colors` 长度**不能长于**选项数——CLI 端 Validate 阶段就会拦截,错误信息形如 `--colors length (4) must not exceed dropdown source size (3)`。 +| 想要的效果 | 怎么传 | +|---|---| +| 默认色板循环上色 | 都不传 `--highlight` / `--colors` | +| 按选项指定具体颜色 | 只传 `--colors '["#hex",...]'`(不需要再传 `--highlight`) | +| 纯白下拉、不要高亮 | 传 `--highlight=false`(注意 `=false` 不能省,单写 `--highlight` 在 cobra 里等价于 true) | + +`--colors` 长度**可以短于**选项数(list 模式短于 `--options` 长度,listFromRange 模式短于 `--source-range` 的单元格数),未指定的选项按内置色板循环补色;但**不能长于**——CLI 在 Validate 阶段就会拦截,错误形如 `--colors length (4) must not exceed dropdown source size (3)`。 -排列组合: -- 想要纯文本下拉、无配色 → 都不传 `--highlight` / `--colors`; -- 想要每个选项指定确切颜色 → 同时传 `--highlight` 和 `--colors`; -- 想要所有选项都有颜色但不指定具体色 → 只传 `--highlight`,颜色按内置色板循环。 +当 `--highlight=false` 显式关闭高亮时,`--colors` 即使传了也会被忽略(语义自相矛盾,但不报错)。 ### 最小用例 -**`--options` 模式**(4 个选项配 3 个颜色——前 3 个指定色,第 4 个按内置色板补): +**`--options` 模式 — 默认色板(最常见)**: + +``` +lark-cli sheets +dropdown-set \ + --url https://... --sheet-id \ + --range A2:A100 \ + --options '["待开始","进行中","已完成","已取消"]' +``` + +**`--options` 模式 — 指定颜色**(4 个选项配 3 个颜色,第 4 个按色板补): ``` lark-cli sheets +dropdown-set \ --url https://... --sheet-id \ --range A2:A100 \ --options '["待开始","进行中","已完成","已取消"]' \ - --colors '["#bff7d9","#FFE699","#bacefd"]' \ - --highlight + --colors '["#bff7d9","#FFE699","#bacefd"]' ``` -**`--source-range` 模式**(先在 Sheet1!T1:T3 维护「男/女/保密」三行,再让 B2:B21 引用它): +**`--source-range` 模式**(先在 `Sheet1!T1:T3` 维护「男/女/保密」三行,再让 `B2:B21` 引用它): ``` lark-cli sheets +dropdown-set \ --url https://... --sheet-id \ --range B2:B21 \ --source-range 'Sheet1!T1:T3' \ - --colors '["#cce8ff","#ffd6e7","#e6e6e6"]' \ - --highlight + --colors '["#cce8ff","#ffd6e7","#e6e6e6"]' +``` + +**纯白下拉**(明确告诉用户"不要彩色"时才用): + +``` +lark-cli sheets +dropdown-set \ + --url https://... --sheet-id \ + --range A2:A100 \ + --options '["低","中","高"]' \ + --highlight=false ``` > ⚠️ **`--source-range` 必须带 sheet 前缀**(即使跟 `--range` 同 sheet)。注意一个坑:回读这种 listFromRange 下拉单元格时,`data_validation.range` 看起来不带 sheet 前缀(形如 `$T$1:$T$3`),如果要把读出来的 range 反过来写回 `--source-range`,**必须自己重新补上 sheet 前缀**,否则会被拒。 @@ -252,9 +269,9 @@ _公共四件套 · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--range` | string | required | 目标范围(A1 格式,如 `A2:A100`) | | `--options` | string + File + Stdin(复合 JSON) | xor | 选项 JSON 数组 `["opt1","opt2"]`;最多 500 项,每项 ≤100 字符,不含逗号 | -| `--colors` | string + File + Stdin(简单 JSON) | optional | 下拉选项的胶囊背景色,RGB hex 数组,如 `["#1FB6C1","#F006C2"]`。映射到 server `data_validation.highlight_colors`。长度可以**短于** `--options`(剩余项 server 按内置 10 色色板循环补色),但**不能长于**。仅当 `--highlight` 也传时才生效;单独传本 flag 不显示高亮色。 | +| `--colors` | string + File + Stdin(简单 JSON) | optional | 下拉胶囊背景色,RGB hex 数组(如 `["#1FB6C1","#F006C2"]`)。长度可短不可长——超长 Validate 拦截(`--colors length (N) must not exceed dropdown source size (M)`),未指定项按内置 10 色色板循环补色。**单独传即生效**;`--highlight=false` 时被忽略。 | | `--multiple` | bool | optional | 启用多选;默认 `false` | -| `--highlight` | bool | optional | 开启下拉选项的胶囊背景色高亮;默认 `false`。映射到 server `data_validation.enable_highlight`。不传或为 `false` 时所有选项无背景色;为 `true` 时按 `--colors` 顺序上色,未在 `--colors` 中提供的选项使用内置 10 色色板循环补色。 | +| `--highlight` | bool | optional | 下拉胶囊背景色高亮开关。**不传 = 开**(按内置 10 色色板循环上色);`--highlight=false` 关闭得到纯白下拉。配色用 `--colors` 覆盖。 | | `--source-range` | string | xor | listFromRange 模式的下拉源 range,A1 表示法 + sheet 前缀(如 `Sheet1!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表(type=list),传本 flag 走 range 引用(type=listFromRange)。`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。 | ### `+csv-put` From dece428487443266d00858cf4d482c3147d0ac6e Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Mon, 25 May 2026 19:20:33 +0800 Subject: [PATCH 055/114] fix(sheets): validate +cells-set-image --image path in Validate The unsafe-path check only ran at Execute (via FileIO.Stat), so --dry-run printed a misleading success preview for an absolute / out-of-cwd --image path that a real run would then reject. Move the path-safety check into Validate (validate.SafeLocalFlagPath), so --dry-run and Execute fail identically and both name the real --image flag. File existence stays deferred to Execute, so legitimate relative paths still preview cleanly. Add TestCellsSetImage_DryRunRejectsUnsafePath. --- shortcuts/sheets/lark_sheet_write_cells.go | 13 ++++++++++++- shortcuts/sheets/lark_sheet_write_cells_test.go | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index f2e545abd..b3233c8e3 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -14,6 +14,8 @@ import ( "strconv" "strings" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -570,9 +572,18 @@ var CellsSetImage = common.Shortcut{ if rows != 1 || cols != 1 { return common.FlagErrorf("--range %q must be exactly one cell (got %d×%d)", r, rows, cols) } - if strings.TrimSpace(runtime.Str("image")) == "" { + imgPath := strings.TrimSpace(runtime.Str("image")) + if imgPath == "" { return common.FlagErrorf("--image is required") } + // Validate path safety here (not just at Execute) so --dry-run also + // rejects unsafe paths instead of giving a false-positive preview. + // SafeLocalFlagPath checks path safety only (abs/traversal/outside-cwd), + // not existence, so legitimate relative paths still dry-run cleanly; + // the Execute-time Stat below still reports a missing/unreadable file. + if _, err := validate.SafeLocalFlagPath("--image", imgPath); err != nil { + return output.ErrValidation("%s", err) + } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { diff --git a/shortcuts/sheets/lark_sheet_write_cells_test.go b/shortcuts/sheets/lark_sheet_write_cells_test.go index 24f8dce5e..cc3b220bf 100644 --- a/shortcuts/sheets/lark_sheet_write_cells_test.go +++ b/shortcuts/sheets/lark_sheet_write_cells_test.go @@ -488,6 +488,20 @@ func TestCellsSetImage_RangeMustBeSingleCell(t *testing.T) { } } +// TestCellsSetImage_DryRunRejectsUnsafePath guards that an unsafe --image path +// (e.g. an absolute path) is rejected during Validate, so --dry-run fails the +// same way as a real run instead of printing a misleading success preview. +func TestCellsSetImage_DryRunRejectsUnsafePath(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, CellsSetImage, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1", "--image", "/etc/hosts", "--dry-run", + }) + if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must be a relative path") { + t.Errorf("expected unsafe-path guard during dry-run; got=%s|%s|%v", stdout, stderr, err) + } +} + // TestRangeDimensions exercises the A1 parser's corner cases used by // cells-set-style / dropdown-set / rows-resize / cols-resize. func TestRangeDimensions(t *testing.T) { From 5327e9390d63026d7f96aae4d11ecfc0c240049c Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Mon, 25 May 2026 19:20:50 +0800 Subject: [PATCH 056/114] feat(sheets): support local --image in +float-image-create +float-image-create now accepts a local file via --image (XOR with --image-token / --image-uri): the CLI uploads it as a sheet_image and embeds the returned file_token, removing the previous "upload elsewhere to get a token first" workaround. Path safety is checked in Validate, --dry-run previews the extra upload step, and +batch-update rejects --image (no upload phase). +float-image-update is unchanged (it does not register --image). Also syncs the lark-sheets skill docs/flag-defs from sheet-skill-spec: the new --image flag, partial-merge / border-per-side / bare sheet-prefix clarifications, and refreshed dropdown --colors/--highlight descriptions (already pending in the source Base table). --- shortcuts/sheets/batch_op_dispatch.go | 20 ++- shortcuts/sheets/data/flag-defs.json | 7 + shortcuts/sheets/lark_sheet_object_crud.go | 126 +++++++++++++++--- .../references/lark-sheets-float-image.md | 27 ++-- .../references/lark-sheets-write-cells.md | 6 + 5 files changed, 155 insertions(+), 31 deletions(-) diff --git a/shortcuts/sheets/batch_op_dispatch.go b/shortcuts/sheets/batch_op_dispatch.go index 2fde04342..672b48d5b 100644 --- a/shortcuts/sheets/batch_op_dispatch.go +++ b/shortcuts/sheets/batch_op_dispatch.go @@ -164,14 +164,30 @@ var batchOpDispatch = map[string]batchOpMapping{ "+sparkline-delete": {"manage_sparkline_object", objDeleteTranslate(sparklineSpec)}, "+float-image-create": {"manage_float_image_object", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { - return floatImageWriteInput(fv, token, sid, sname, "create", false) + if err := rejectLocalImageInBatch(fv); err != nil { + return nil, err + } + return floatImageWriteInput(fv, token, sid, sname, "create", false, "") }}, "+float-image-update": {"manage_float_image_object", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { - return floatImageWriteInput(fv, token, sid, sname, "update", true) + if err := rejectLocalImageInBatch(fv); err != nil { + return nil, err + } + return floatImageWriteInput(fv, token, sid, sname, "update", true, "") }}, "+float-image-delete": {"manage_float_image_object", objDeleteTranslate(floatImageDeleteSpec)}, } +// 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. +func rejectLocalImageInBatch(fv flagView) error { + if strings.TrimSpace(fv.Str("image")) != "" { + return common.FlagErrorf("--image (local upload) is not supported inside +batch-update; pass --image-token or --image-uri instead") + } + return nil +} + // sheetMoveBatchInput translates +sheet-move inside a batch. Unlike the // standalone shortcut it cannot issue the get_workbook_structure read that // auto-derives sheet_id / source_index, so both must be supplied explicitly. diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index 45e0dd56c..979cf5ef7 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -4475,6 +4475,13 @@ "required": "optional", "desc": "Image z-index controlling stacking order" }, + { + "name": "image", + "kind": "own", + "type": "string", + "required": "xor", + "desc": "Local image path; the CLI uploads it as a sheet_image and uses the returned file_token (XOR with --image-token / --image-uri)" + }, { "name": "dry-run", "kind": "system", diff --git a/shortcuts/sheets/lark_sheet_object_crud.go b/shortcuts/sheets/lark_sheet_object_crud.go index 44e5f8839..781fd3b8d 100644 --- a/shortcuts/sheets/lark_sheet_object_crud.go +++ b/shortcuts/sheets/lark_sheet_object_crud.go @@ -5,8 +5,11 @@ package sheets import ( "context" + "path/filepath" "strings" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -408,21 +411,47 @@ var SparklineDelete = newObjectDeleteShortcut(sparklineSpec) // the tool's properties is composed entirely from the position / size / // offset / image_token / image_uri / z_index flat flags. -// floatImageProperties assembles the tool's properties object from the -// 10 flat flags. Caller is responsible for marking required flags via -// cobra Required:true; this function only enforces the image_token XOR -// image_uri pair (one must be set). -func floatImageProperties(runtime flagView) (map[string]interface{}, error) { +// floatImageUploadPlaceholder is the stand-in image_token shown in +// Validate/DryRun for the --image (local upload) path, before the real +// file_token is known. Execute replaces it with the uploaded token. +const floatImageUploadPlaceholder = "" + +// floatImageName resolves the image name: explicit --image-name wins, +// otherwise fall back to the basename of a local --image path. +func floatImageName(runtime flagView) string { + if n := strings.TrimSpace(runtime.Str("image-name")); n != "" { + return n + } + if img := strings.TrimSpace(runtime.Str("image")); img != "" { + return filepath.Base(img) + } + return "" +} + +// floatImageProperties assembles the tool's properties object from the flat +// flags. Caller marks required flags via cobra Required:true; this function +// enforces the image source XOR: exactly one of --image / --image-token / +// --image-uri must be set. uploadedImageToken, when non-empty, is the +// file_token obtained by uploading a local --image (Execute only); in +// Validate/DryRun it is "" and a placeholder token stands in. +func floatImageProperties(runtime flagView, uploadedImageToken string) (map[string]interface{}, error) { + img := strings.TrimSpace(runtime.Str("image")) token := strings.TrimSpace(runtime.Str("image-token")) uri := strings.TrimSpace(runtime.Str("image-uri")) - if token == "" && uri == "" { - return nil, common.FlagErrorf("either --image-token or --image-uri is required") + set := 0 + for _, v := range []string{img, token, uri} { + if v != "" { + set++ + } + } + if set == 0 { + return nil, common.FlagErrorf("one of --image, --image-token, or --image-uri is required") } - if token != "" && uri != "" { - return nil, common.FlagErrorf("--image-token and --image-uri are mutually exclusive") + if set > 1 { + return nil, common.FlagErrorf("--image, --image-token, and --image-uri are mutually exclusive") } props := map[string]interface{}{ - "image_name": strings.TrimSpace(runtime.Str("image-name")), + "image_name": floatImageName(runtime), "position": map[string]interface{}{ "row": runtime.Int("position-row"), "col": strings.TrimSpace(runtime.Str("position-col")), @@ -432,9 +461,21 @@ func floatImageProperties(runtime flagView) (map[string]interface{}, error) { "height": runtime.Int("size-height"), }, } - if token != "" { + switch { + case img != "": + // Local file: validate path safety here so --dry-run also rejects + // unsafe paths; Execute uploads it and passes the real token in. + if _, err := validate.SafeLocalFlagPath("--image", img); err != nil { + return nil, output.ErrValidation("%s", err) + } + if uploadedImageToken != "" { + props["image_token"] = uploadedImageToken + } else { + props["image_token"] = floatImageUploadPlaceholder + } + case token != "": props["image_token"] = token - } else { + default: props["image_uri"] = uri } if runtime.Changed("offset-row") || runtime.Changed("offset-col") { @@ -475,13 +516,33 @@ func newFloatImageWriteShortcut(command, description, op string, withIDFlag, isH } sheetID := strings.TrimSpace(runtime.Str("sheet-id")) sheetName := strings.TrimSpace(runtime.Str("sheet-name")) - _, err = floatImageWriteInput(runtime, token, sheetID, sheetName, op, withIDFlag) + // uploadedImageToken="": Validate never uploads; floatImageProperties + // still validates the --image path and the source XOR. + _, err = floatImageWriteInput(runtime, token, sheetID, sheetName, op, withIDFlag, "") return err }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - input, _ := floatImageWriteInput(runtime, token, sheetID, sheetName, op, withIDFlag) + input, _ := floatImageWriteInput(runtime, token, sheetID, sheetName, op, withIDFlag, "") + // With a local --image, Execute first uploads the file; surface that + // extra step in the preview (mirrors +cells-set-image's dry-run). + if img := strings.TrimSpace(runtime.Str("image")); img != "" { + manageBody, _ := buildToolBody("manage_float_image_object", input) + return common.NewDryRunAPI(). + POST("/open-apis/drive/v1/medias/upload_all"). + Desc("upload local image to drive (parent_type=sheet_image)"). + Body(map[string]interface{}{ + "file_name": floatImageName(runtime), + "parent_type": "sheet_image", + "parent_node": token, + "size": "", + "file": "@" + img, + }). + POST(toolInvokePath(token, ToolKindWrite)). + Desc("create float image referencing the uploaded file_token"). + Body(manageBody) + } return invokeToolDryRun(token, ToolKindWrite, "manage_float_image_object", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { @@ -493,7 +554,14 @@ func newFloatImageWriteShortcut(command, description, op string, withIDFlag, isH if err != nil { return err } - input, err := floatImageWriteInput(runtime, token, sheetID, sheetName, op, withIDFlag) + // If a local --image was given, upload it first (parent_type= + // sheet_image) and embed the returned file_token; otherwise this + // returns "" and the token/uri flags are used as-is. + uploadedImageToken, err := uploadFloatImageIfLocal(runtime, token) + if err != nil { + return err + } + input, err := floatImageWriteInput(runtime, token, sheetID, sheetName, op, withIDFlag, uploadedImageToken) if err != nil { return err } @@ -507,14 +575,36 @@ func newFloatImageWriteShortcut(command, description, op string, withIDFlag, isH } } -func floatImageWriteInput(runtime flagView, token, sheetID, sheetName, op string, withIDFlag bool) (map[string]interface{}, error) { +// uploadFloatImageIfLocal uploads a local --image (when set) as a sheet_image +// and returns its file_token. Returns ("", nil) when --image is not set (the +// token/uri source flags are used instead, e.g. on +float-image-update which +// does not register --image). +func uploadFloatImageIfLocal(runtime *common.RuntimeContext, spreadsheetToken string) (string, error) { + img := strings.TrimSpace(runtime.Str("image")) + if img == "" { + return "", nil + } + info, err := runtime.FileIO().Stat(img) + if err != nil { + return "", common.WrapInputStatError(err) + } + return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ + FilePath: img, + FileName: floatImageName(runtime), + FileSize: info.Size(), + ParentType: "sheet_image", + ParentNode: &spreadsheetToken, + }) +} + +func floatImageWriteInput(runtime flagView, token, sheetID, sheetName, op string, withIDFlag bool, uploadedImageToken string) (map[string]interface{}, error) { if err := requireSheetSelector(sheetID, sheetName); err != nil { return nil, err } if withIDFlag && strings.TrimSpace(runtime.Str("float-image-id")) == "" { return nil, common.FlagErrorf("--float-image-id is required") } - props, err := floatImageProperties(runtime) + props, err := floatImageProperties(runtime, uploadedImageToken) if err != nil { return nil, err } @@ -532,7 +622,7 @@ func floatImageWriteInput(runtime flagView, token, sheetID, sheetName, op string var FloatImageCreate = newFloatImageWriteShortcut( "+float-image-create", - "Create a floating image (referenced by --image-token or --image-uri).", + "Create a floating image (from a local --image path, or an existing --image-token / --image-uri).", "create", false, false, ) var FloatImageUpdate = newFloatImageWriteShortcut( diff --git a/skills/lark-sheets/references/lark-sheets-float-image.md b/skills/lark-sheets/references/lark-sheets-float-image.md index ee8f92fb6..2ddf06479 100644 --- a/skills/lark-sheets/references/lark-sheets-float-image.md +++ b/skills/lark-sheets/references/lark-sheets-float-image.md @@ -24,14 +24,13 @@ - **图片位置参数要精确**:锚点单元格的行列索引和偏移量决定了图片位置,设置不当会导致图片遮挡数据 - **创建后必须验证**:调用 `+float-image-list` 确认图片位置和大小正确 -`--image-uri` 与 `--image-token` 是「指定**已有**图片资源」的两种等价方式(XOR,create 时必给其一): -- `--image-token`:图片 fileToken。常见来源是 `+float-image-list` 返回的 `image_token`(适合"换皮不换位置"等复用已有图片的场景)。 -- `--image-uri`:图片上传句柄(image URI),由系统自动转 fileToken。 -- update 时**仅在需要替换图片本身时**才传新的 `--image-uri` / `--image-token`,不传则保留原图。 +图片来源有三种方式,`+float-image-create` 上三者 **XOR、必给其一**(`--image` / `--image-token` / `--image-uri`): -⚠️ **本 shortcut 不接受本地图片文件路径**——只能引用已存在的图片资源。若手上只有本地新图(如 `logo.png`): -- **可接受单元格内嵌图** → 直接用 `+cells-set-image --image <本地路径>`(见 `lark-sheets-write-cells`,它支持本地路径)。 -- **必须是浮动图片** → 需先把本地图片上传到飞书拿到 file_token(上传步骤不在本 skill 内,例如经云空间),再把该 token 传给 `--image-token`。 +- **`--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` 仅 `+float-image-create` 支持**。`+float-image-update` 换图仍只接受 `--image-token` / `--image-uri`(patch 模式:不传则保留原图);要在 update 里换一张本地新图,先用 `+cells-set-image` 上传到任意临时单元格、从返回取 `file_token`,再把它传给 update 的 `--image-token`。 ## Shortcuts @@ -68,6 +67,7 @@ _公共四件套 · 系统:`--dry-run`_ | `--offset-row` | int | optional | 在 `--position-row` 基础上的行内偏移(像素) | | `--offset-col` | int | optional | 在 `--position-col` 基础上的列内偏移(像素) | | `--z-index` | int | optional | 图片 Z 轴层级,控制重叠顺序 | +| `--image` | string | xor | 本地图片路径(PNG/JPEG 等);CLI 自动上传为 sheet_image 并用返回的 file_token,省去手动拿 token(与 --image-token / --image-uri 三选一) | ### `+float-image-update` @@ -107,10 +107,15 @@ lark-cli sheets +float-image-list --url "..." --sheet-id "$SID" ### `+float-image-create` -所有字段拍平为独立 flag:`--image-name` / `--image-token` 或 `--image-uri`(XOR) / `--position-{row,col}` / `--size-{width,height}` / `--offset-{row,col}` / `--z-index`。 +所有字段拍平为独立 flag:图片来源 `--image` / `--image-token` / `--image-uri`(三选一 XOR)/ `--image-name` / `--position-{row,col}` / `--size-{width,height}` / `--offset-{row,col}` / `--z-index`。 ```bash -# 用 file_token(从 +float-image-list 返回的 image_token 或独立上传得到) +# 首选:直接给本地图片路径,CLI 自动上传(无需手动拿 token) +lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \ + --image ./logo.png \ + --position-row 2 --position-col B --size-width 300 --size-height 200 --z-index 1 + +# 用已有 file_token(从 +float-image-list 的 image_token 或 +cells-set-image 返回的 file_token) 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 @@ -145,6 +150,6 @@ lark-cli sheets +float-image-delete --url "..." --sheet-id "$SID" --float-image- ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`+float-image-create` 校验 `--image-name` 非空,`--image-token` 与 `--image-uri` 互斥且至少一个非空,`--position-row/col` 与 `--size-width/height` 必填且为合法整数;`+float-image-update` 必须 `--float-image-id`,其余 `--image-name` / `--image-token` / `--image-uri` / `--position-*` / `--size-*` / `--offset-*` / `--z-index` 至少传 1 个(patch 模式:未传字段保持原值);`+float-image-delete` 强制 `--yes` 或 `--dry-run`。 -- `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 float_image 请求模板"。 +- `Validate`:XOR 公共四件套;`+float-image-create` 要求 `--image` / `--image-token` / `--image-uri` **恰好给一个**,`--position-row/col` 与 `--size-width/height` 必填且为合法整数;传 `--image` 时还会校验路径安全(绝对路径 / 越出工作目录会被拒,`--dry-run` 同样拦)。`+float-image-update` 必须 `--float-image-id`,其余字段至少传 1 个(patch 模式:未传字段保持原值,换图只接受 `--image-token` / `--image-uri`);`+float-image-delete` 强制 `--yes` 或 `--dry-run`。 +- `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 float_image 请求模板";传 `--image` 时会多打印一步本地图片上传(`POST /open-apis/drive/v1/medias/upload_all`,`parent_type=sheet_image`)。 - `Execute`:写后调用 `+float-image-list --float-image-id ` 回读,envelope.meta.verification 给出新位置 / 尺寸对比。 diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 9cc5cbea9..7aa0f99c9 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -73,6 +73,10 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl ``` 这比在 99 个单元格中都重复写样式 JSON 高效得多。 +💡 **样式更新是「部分合并」,不是整体覆盖**:`+cells-set-style` / `+cells-batch-set-style`(以及 `+cells-set` 的 `cell_styles` / `border_styles`)只改你**显式传入**的样式属性,未传的属性保留原值。两个实用推论: +- **可分层叠加**:对同一区域先刷字体色、再单独刷背景色、再单独刷边框,后一步不会清掉前一步——美化已有区域时无需一次带齐所有字段,可拆成多次窄调用。 +- **`border_styles` 按边合并**:只传 `{"top":{...}}` 只更新上边框,`bottom` / `left` / `right` 保留原状;不必为了「只改一条边」而把四边全部重传。(例外见上方「新增行的边框/样式禁止用 `{}` 跳过」:**全新行**底子里没有边框,仍需把要显示的边都显式传出。) + 💡 **大批量数据分批写入(推荐)**:当需要写入大量行(如几十行以上)时,不要试图在一次调用中生成全部 `cells` 数据——`cells` 数组过大会让单次生成的内容过长,容易出错或被截断。应将数据拆分为多批,每批 20-50 行,分多次调用 `+cells-set` 逐批生成并写入(如先写 `A2:D21`,再写 `A22:D41`,依此类推)。每次只生成当前批次的数据,控制单次生成量。 注意: @@ -187,6 +191,8 @@ 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`。 `+dropdown-update`(多 range 批量更新)的所有 flag 语义与 `+dropdown-set` 完全一致;只是目标 `--ranges` 由单值变成 JSON 数组(每项带 sheet 前缀),同一份选项 + 配色应用到所有 range。 From 12b94746bb53849460d7de12ed82f70148e4162a Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Mon, 25 May 2026 21:08:58 +0800 Subject: [PATCH 057/114] fix(sheets): +dropdown-get accepts --sheet-id/--sheet-name + bare --range Align +dropdown-get with its get_cell_ranges siblings (+cells-get / +csv-get): sheet selection is now via --sheet-id / --sheet-name (XOR) and --range is a bare A1 reference. The previous shape required the sheet prefix inside --range (e.g. "Sheet1!A2:A100") and was the odd one out among the read-data wrappers; callers pasting the sheet-id form straight from the URL hit a misleading "sheet not found, sheetId: , sheetName: " error because the prefix was unconditionally treated as sheet_name. Flag schema + skill reference regenerated from the upstream Lark Base Shortcut-flags table. --- shortcuts/sheets/data/flag-defs.json | 16 +++++++- shortcuts/sheets/lark_sheet_read_data.go | 38 ++++++++++--------- shortcuts/sheets/lark_sheet_read_data_test.go | 37 ++++++++++++++---- .../references/lark-sheets-read-data.md | 4 +- 4 files changed, 67 insertions(+), 28 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index 979cf5ef7..4e6bde65b 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -1238,12 +1238,26 @@ "required": "xor", "desc": "Spreadsheet token (XOR with `--url`)" }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, { "name": "range", "kind": "own", "type": "string", "required": "required", - "desc": "Target range (A1 notation; must include the sheet prefix, e.g. `sheet1!A2:A100`)" + "desc": "Target range in A1 notation, e.g. `A2:A100` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)" }, { "name": "dry-run", diff --git a/shortcuts/sheets/lark_sheet_read_data.go b/shortcuts/sheets/lark_sheet_read_data.go index 89a4b49db..bffe18be1 100644 --- a/shortcuts/sheets/lark_sheet_read_data.go +++ b/shortcuts/sheets/lark_sheet_read_data.go @@ -215,16 +215,16 @@ func stripRowPrefixFromCsvOutput(out interface{}) interface{} { } // DropdownGet wraps get_cell_ranges scoped to data_validation: read the -// dropdown configuration on a range. The CLI accepts the range in the -// sheet-prefixed form (e.g. "sheet1!A2:A100") for convenience; the -// prefix is split client-side into sheet_name + bare A1 because the -// get_cell_ranges tool wants sheet selector and ranges as separate -// fields (ranges with the "sheet!" prefix gets the empty-sheet_id -// rejection from the server). +// dropdown configuration on a range. Aligned with its sibling +cells-get +// — sheet selection is via --sheet-id / --sheet-name (XOR), and --range +// is a bare A1 reference. The earlier "must include a sheet prefix" +// shape was the odd one out among the get_cell_ranges wrappers and made +// callers treat the prefix as either name or id; folding it into the +// canonical --sheet-id selector removes that ambiguity. var DropdownGet = common.Shortcut{ Service: "sheets", Command: "+dropdown-get", - Description: "Read the dropdown / data-validation configuration on a sheet-prefixed range.", + Description: "Read the dropdown / data-validation configuration on a range.", Risk: "read", Scopes: []string{"sheets:spreadsheet:read"}, AuthTypes: []string{"user", "bot"}, @@ -234,24 +234,29 @@ var DropdownGet = common.Shortcut{ if _, err := resolveSpreadsheetToken(runtime); err != nil { return err } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } if strings.TrimSpace(runtime.Str("range")) == "" { return common.FlagErrorf("--range is required") } - if !strings.Contains(runtime.Str("range"), "!") { - return common.FlagErrorf("--range must include a sheet prefix (e.g. sheet1!A2:A100)") - } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) - return invokeToolDryRun(token, ToolKindRead, "get_cell_ranges", dropdownGetInput(runtime, token)) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindRead, "get_cell_ranges", dropdownGetInput(runtime, token, sheetID, sheetName)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindRead, "get_cell_ranges", dropdownGetInput(runtime, token)) + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindRead, "get_cell_ranges", dropdownGetInput(runtime, token, sheetID, sheetName)) if err != nil { return err } @@ -260,16 +265,13 @@ var DropdownGet = common.Shortcut{ }, } -func dropdownGetInput(runtime *common.RuntimeContext, token string) map[string]interface{} { - // Validate already enforced the "Sheet!range" prefix, so the - // split error path can't be reached here in practice. - sheetName, bareRange, _ := splitSheetPrefixedRange(strings.TrimSpace(runtime.Str("range"))) +func dropdownGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { input := map[string]interface{}{ "excel_id": token, - "ranges": []string{bareRange}, + "ranges": []string{strings.TrimSpace(runtime.Str("range"))}, "include_styles": false, "value_render_option": "formatted_value", } - sheetSelectorForToolInput(input, "", sheetName) + sheetSelectorForToolInput(input, sheetID, sheetName) return input } diff --git a/shortcuts/sheets/lark_sheet_read_data_test.go b/shortcuts/sheets/lark_sheet_read_data_test.go index 78233f795..1d1babd2a 100644 --- a/shortcuts/sheets/lark_sheet_read_data_test.go +++ b/shortcuts/sheets/lark_sheet_read_data_test.go @@ -48,14 +48,30 @@ func TestReadDataShortcuts_DryRun(t *testing.T) { }, }, { - name: "+dropdown-get range with sheet prefix only", + // Canonical form: --sheet-id + bare --range. Aligned with + // +cells-get / +csv-get; before the e2e BUG-019 fix this + // shortcut was the odd one out (range-prefix required). + name: "+dropdown-get with --sheet-id", sc: DropdownGet, - args: []string{"--url", testURL, "--range", "sheet1!A2:A100"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "C2:C6"}, toolName: "get_cell_ranges", wantInput: map[string]interface{}{ "excel_id": testToken, - "sheet_name": "sheet1", - "ranges": []interface{}{"A2:A100"}, + "sheet_id": testSheetID, + "ranges": []interface{}{"C2:C6"}, + "include_styles": false, + "value_render_option": "formatted_value", + }, + }, + { + name: "+dropdown-get with --sheet-name", + sc: DropdownGet, + args: []string{"--url", testURL, "--sheet-name", "Sheet1", "--range", "C2:C6"}, + toolName: "get_cell_ranges", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_name": "Sheet1", + "ranges": []interface{}{"C2:C6"}, "include_styles": false, "value_render_option": "formatted_value", }, @@ -72,7 +88,12 @@ func TestReadDataShortcuts_DryRun(t *testing.T) { } } -func TestDropdownGet_RequiresSheetPrefix(t *testing.T) { +// TestDropdownGet_RequiresSheetSelector locks the +cells-get-style +// selector contract: at least one of --sheet-id / --sheet-name must be +// supplied. Before BUG-019 fix this shortcut required a "Sheet!A1" +// prefix inside --range instead; the canonical selector pair is what +// every other get_cell_ranges wrapper uses. +func TestDropdownGet_RequiresSheetSelector(t *testing.T) { t.Parallel() stdout, stderr, err := runShortcutCapturingErr(t, DropdownGet, []string{ "--url", testURL, "--range", "A2:A100", "--dry-run", @@ -80,8 +101,9 @@ func TestDropdownGet_RequiresSheetPrefix(t *testing.T) { if err == nil { t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) } - if !strings.Contains(stdout+stderr+err.Error(), "must include a sheet prefix") { - t.Errorf("expected sheet-prefix guard; got=%s|%s|%v", stdout, stderr, err) + combined := stdout + stderr + err.Error() + if !strings.Contains(combined, "sheet-id") && !strings.Contains(combined, "sheet-name") { + t.Errorf("expected --sheet-id/--sheet-name guard; got=%s|%s|%v", stdout, stderr, err) } } @@ -96,6 +118,7 @@ func TestReadData_RequiresRange(t *testing.T) { }{ {"+cells-get", CellsGet}, {"+csv-get", CsvGet}, + {"+dropdown-get", DropdownGet}, } for _, c := range cases { c := c diff --git a/skills/lark-sheets/references/lark-sheets-read-data.md b/skills/lark-sheets/references/lark-sheets-read-data.md index e7cb0a5e4..a3fd6ce97 100644 --- a/skills/lark-sheets/references/lark-sheets-read-data.md +++ b/skills/lark-sheets/references/lark-sheets-read-data.md @@ -97,11 +97,11 @@ _公共四件套 · 系统:`--dry-run`_ ### `+dropdown-get` -_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ +_公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | required | 目标范围(A1 格式,必须带 sheet 前缀,如 `sheet1!A2:A100`) | +| `--range` | string | required | A1 范围,如 `A2:A100`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet) | ### `+csv-get` From 9c447e735b40a081b58b12930e3b09fdc64d0d6d Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Mon, 25 May 2026 21:24:30 +0800 Subject: [PATCH 058/114] fix(sheets): drop Sheet1! prefix from +cells-get / +csv-get / +csv-put flag examples Server tools-schema.json for get_cell_ranges, get_range_as_csv and set_range_from_csv does not accept a sheet prefix on --range / --start-cell; the sheet is selected via --sheet-id / --sheet-name. +csv-put --start-cell also now states it must be a single cell (no range notation). Synced from spec repo. --- shortcuts/sheets/data/flag-defs.json | 6 +++--- skills/lark-sheets/references/lark-sheets-read-data.md | 4 ++-- skills/lark-sheets/references/lark-sheets-write-cells.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index 4e6bde65b..7e1d95d75 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -1180,7 +1180,7 @@ "kind": "own", "type": "string", "required": "required", - "desc": "A1 range, e.g. `Sheet1!A1:F10`" + "desc": "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)" }, { "name": "include", @@ -1304,7 +1304,7 @@ "kind": "own", "type": "string", "required": "required", - "desc": "A1 range, e.g. `Sheet1!A1:F30`" + "desc": "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)" }, { "name": "value-render-option", @@ -1965,7 +1965,7 @@ "kind": "own", "type": "string", "required": "required", - "desc": "Top-left A1 anchor (e.g. `Sheet1!A1`); the bottom-right is inferred from CSV row/column counts", + "desc": "Top-left A1 anchor (e.g. `A1`, `B5`; no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet); must be a single cell, range notation not accepted; the bottom-right is inferred from CSV row/column counts", "default": "A1" }, { diff --git a/skills/lark-sheets/references/lark-sheets-read-data.md b/skills/lark-sheets/references/lark-sheets-read-data.md index a3fd6ce97..fa1e32de8 100644 --- a/skills/lark-sheets/references/lark-sheets-read-data.md +++ b/skills/lark-sheets/references/lark-sheets-read-data.md @@ -90,7 +90,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | required | A1 范围,如 `Sheet1!A1:F10` | +| `--range` | string | required | A1 范围,如 `A1:F10`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet) | | `--include` | string_slice | optional | 要返回的信息类别,逗号分隔多个(可选值:`value` / `formula` / `style` / `comment` / `data_validation`) | | `--max-chars` | int | optional | 防爆,默认 200000(隐藏 flag:不在 `--help` 列出,但可正常传入) | | `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` | @@ -109,7 +109,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--range` | string | required | A1 范围,如 `Sheet1!A1:F30` | +| `--range` | string | required | A1 范围,如 `A1:F30`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet) | | `--value-render-option` | string | optional | 单元格取值模式(可选值:`formatted_value` / `raw_value` / `formula`)(默认 `formatted_value`) | | `--max-chars` | int | optional | 防爆,默认 200000(隐藏 flag:不在 `--help` 列出,但可正常传入) | | `--include-row-prefix` | bool | optional | 是否在每行前加 `[row=N]` 前缀,默认 `true` | diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 7aa0f99c9..ab8d41af3 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -286,7 +286,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--start-cell` | string | required | 目标区域起点 A1(如 `Sheet1!A1`);终点按 CSV 实际行列数自动推断 | +| `--start-cell` | string | required | 目标区域起点 A1(如 `A1`、`B5`,不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet);必须是单个单元格,不接受范围写法;终点按 CSV 实际行列数自动推断 | | `--csv` | string + File + Stdin(非 JSON 文本) | required | RFC 4180 CSV 文本;只写纯值,不带公式/样式/批注 | | `--allow-overwrite` | bool | optional | 允许覆盖(默认 true);设为 false 时若目标非空报错 | From 6cadbe807af54ee797e5d8ffd8ae801073606de5 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Mon, 25 May 2026 21:56:25 +0800 Subject: [PATCH 059/114] =?UTF-8?q?feat:=20=E6=8A=8A=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E5=8F=98=E9=87=8F=E6=8F=90=E4=BA=A4=E4=B8=8A=E5=8E=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cmdutil/secheader.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/cmdutil/secheader.go b/internal/cmdutil/secheader.go index f93713f4a..202dcf255 100644 --- a/internal/cmdutil/secheader.go +++ b/internal/cmdutil/secheader.go @@ -50,6 +50,8 @@ func BaseSecurityHeaders() http.Header { h.Set(HeaderVersion, build.Version) h.Set(HeaderBuild, DetectBuildKind()) h.Set(HeaderUserAgent, UserAgentValue()) + h.Set("x-tt-env", "ppe_lark_cli_sheet") + h.Set("x-use-ppe", "1") return h } From 5880d070e206a159a045ed75a5a1d65d7800d794 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Mon, 25 May 2026 22:03:17 +0800 Subject: [PATCH 060/114] fix(sheets): clarify batch --ranges prefix must be sheet display name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E2E test cases repeatedly trip on this: $ lark-cli sheets +cells-batch-set-style \ --ranges '["7f8fba!A2:B3","7f8fba!C2:D3"]' --font-color '#3366FF' ... → tool "batch_update" failed: [900015206] sheet "7f8fba" not found. Available sheets: [{id: "7f8fba", name: "Sheet1"}] Callers paste the hex sheet-id (e.g. "7f8fba") from a spreadsheet URL / +sheet-create response straight into the --ranges sheet prefix. The four batch shortcuts (+cells-batch-set-style / +cells-batch-clear / +dropdown-update / +dropdown-delete) fan each range out into a batch_update sub-op (set_cell_range / clear_cell_range) and pass the prefix through as sheet_name; the server only matches sheet_name literally, so the lookup fails. The set_cell_range tool schema is explicit: sheet_id is the reference_id and "must be correct or it errors"; sheet_name is the display name. CLI can't disambiguate purely from the literal because users can rename sheets to anything (including six-char hex strings). Cleanest fix is at the source: each batch shortcut's --ranges flag description now states explicitly that the prefix must be the sheet display name and that the sheet reference_id is rejected, so agents reading the reference don't try the id form in the first place. No Go changes; these files are regenerated from the upstream Lark Base Shortcut-flags table via the sheet-skill-spec sync chain. --- shortcuts/sheets/data/flag-defs.json | 8 ++++---- skills/lark-sheets/references/lark-sheets-batch-update.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index 7e1d95d75..41b958f9f 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -2660,7 +2660,7 @@ "kind": "own", "type": "string", "required": "required", - "desc": "Target ranges as a JSON array (e.g. `[\"sheet1!A1:B2\",\"sheet1!D1:D10\"]`); each item must include the sheet prefix; the same style is applied to all ranges", + "desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", "input": [ "file", "stdin" @@ -2806,7 +2806,7 @@ "kind": "own", "type": "string", "required": "required", - "desc": "Target ranges as a JSON array (e.g. `[\"sheet1!A2:A100\"]`); each item must include the sheet prefix", + "desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", "input": [ "file", "stdin" @@ -2886,7 +2886,7 @@ "kind": "own", "type": "string", "required": "required", - "desc": "Target ranges as a JSON array (up to 100 items; each must include the sheet prefix)", + "desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", "input": [ "file", "stdin" @@ -2930,7 +2930,7 @@ "kind": "own", "type": "string", "required": "required", - "desc": "Target ranges as a JSON array (e.g. `[\"sheet1!A1:B2\",\"sheet1!D1:D10\"]`); each item must include the sheet prefix; the same scope is cleared from every range", + "desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", "input": [ "file", "stdin" diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 15c7fe243..2e4f4ced2 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -51,7 +51,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["sheet1!A1:B2","sheet1!D1:D10"]`);所有 range 应用同一组 style | +| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["Sheet1!A1:B2","Sheet2!D1:D10"]`);前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id;支持跨 sheet;所有 range 应用同一组 style | | `--background-color` | string | optional | 背景颜色(十六进制,如 `#ffffff`) | | `--font-color` | string | optional | 字体颜色(十六进制,如 `#000000`) | | `--font-size` | float64 | optional | 字体大小(px,例:10、12、14) | @@ -70,7 +70,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(如 `["sheet1!A2:A100"]`),每项必须带 sheet 前缀 | +| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(如 `["Sheet1!A2:A100","Sheet1!C2:C100"]`),每项必须带 sheet 前缀;前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id | | `--options` | string + File + Stdin(复合 JSON) | xor | 选项 JSON 数组(如 `["opt1","opt2"]`) | | `--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 +83,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(最多 100 个,每项必须带 sheet 前缀) | +| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(最多 100 个,如 `["Sheet1!E2:E6"]`),每项必须带 sheet 前缀;前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id | ### `+cells-batch-clear` @@ -91,7 +91,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["sheet1!A1:B2","sheet1!D1:D10"]`);对所有 range 执行同一 scope 的清除 | +| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["Sheet1!A2:Z1000","Sheet2!A2:Z1000"]`);前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id;支持跨 sheet;对所有 range 执行同一 scope 的清除 | | `--scope` | string | optional | 清除范围 enum:`content`(默认,仅清内容)/ `formats`(仅清格式)/ `all`(清内容 + 格式)(可选值:`content` / `formats` / `all`) | ## Schemas From 6b3c0b5556e0b082d689de1bf2d1f8e42f0002b0 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Tue, 26 May 2026 12:50:52 +0800 Subject: [PATCH 061/114] docs(sheets): sync lark-sheets skill docs from upstream spec - SKILL.md: clarify --url only resolves /sheets/ and /spreadsheets/ links; /wiki/ links must be resolved via wiki +node-get first (confirm obj_type=sheet, use obj_token) - formula-translation: document IMPORTRANGE cross-workbook limits (max 5-level nesting, 100 refs per sheet) - write-cells: document rich_text cells for hyperlinks, @mentions and @docs --- skills/lark-sheets/SKILL.md | 2 ++ .../references/lark-sheets-formula-translation.md | 9 +++++++++ .../references/lark-sheets-write-cells.md | 12 ++++++++++++ 3 files changed, 23 insertions(+) diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index 922fbb9ec..5096c9364 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -77,6 +77,8 @@ metadata: **公共四件套** = `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`,分成两组 XOR,**每组都必须给且只能给一个**(XOR = 二选一必填,不是"可选"): 1. **spreadsheet 定位(必填)**:`--url` 与 `--spreadsheet-token` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --url or --spreadsheet-token`;两个都给 → 互斥冲突。 + - **`--url` 只解析 `/sheets/` 与 `/spreadsheets/` 两种链接**(从路径里抽出 token;也可以直接把裸 token 传给 `--spreadsheet-token`)。其它形态的链接不会被解析成表格 token。 + - ⚠️ **`/wiki/` 知识库链接不能直接当表格定位用**:wiki 链接背后可能是电子表格,也可能是文档 / 多维表格等其它类型,`--url` **不会**自动把 wiki token 解析成 spreadsheet token,直接传会失败。必须先把它解析成真实文档 token —— `lark-cli wiki +node-get --node-token ""`,确认返回的 `obj_type` 为 `sheet` 后,取其 `obj_token` 作为 `--spreadsheet-token` 传入(解析细节见 [`../lark-wiki/SKILL.md`](../lark-wiki/SKILL.md))。 - **例外**:`+workbook-create` 是新建一个还不存在的表格,**不接受任何 spreadsheet / sheet 定位 flag**(只有 `--title` / `--folder-token` / `--headers` / `--values`)。 2. **sheet 定位(公共四件套 shortcut 必填)**:`--sheet-id` 与 `--sheet-name` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --sheet-id or --sheet-name`。 - ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:即使写了 `--range "Sheet1!A1:B2"`,仍**必须**额外传 `--sheet-id` 或 `--sheet-name`,否则照样报上面的错。 diff --git a/skills/lark-sheets/references/lark-sheets-formula-translation.md b/skills/lark-sheets/references/lark-sheets-formula-translation.md index 1145b0574..b2800ea2c 100644 --- a/skills/lark-sheets/references/lark-sheets-formula-translation.md +++ b/skills/lark-sheets/references/lark-sheets-formula-translation.md @@ -84,6 +84,15 @@ > **注意:`SWITCH` 在飞书里被当作原生数组函数处理,这与 Excel 行为不同,不需要额外包 `ARRAYFORMULA`。** +## IMPORTRANGE 跨工作簿引用限制 + +用 `IMPORTRANGE` 跨电子表格引用数据时有两条硬上限: + +- **嵌套最多 5 层**:被引用的表里若又用 `IMPORTRANGE` 继续引下一张表,整条引用链最多 5 层。 +- **每个工作表最多 100 个 `IMPORTRANGE` 引用**。 + +超限会让引用失效或报错。设计大量跨表汇总前先估算引用数,必要时先把数据落地到本表再计算。 + ## INDEX / OFFSET / COLUMN / ROW / MATCH 是高风险函数 这组函数容易让人误以为会自动把多值铺开,但在飞书里不能这样假设。 diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index ab8d41af3..c56bd7d84 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -124,6 +124,18 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl **判断是不是"新行"**:写入 range 超出 `+csv-get` 返回的 `current_region` 右 / 下边界(如 `current_region=A1:H10`、写 `A11:H11`)即新行,必须按上述做法补边框。 +## 富文本单元格:超链接 / @人 / @文档(`rich_text`) + +带显示文本的超链接、@人、@文档这类富内容**必须**走 `+cells-set` 的 `rich_text` 字段(`cells[].rich_text` 数组,每段一个对象、带 `type`),**不能**直接传普通字符串——纯字符串只会被当作纯文本存进单元格。完整字段跑 `lark-cli sheets +cells-set --print-schema --flag-name cells`,常用段类型: + +- **超链接(带显示文本)**:`{"type":"link","text":"飞书","link":"https://www.feishu.cn"}`。纯 URL 不需要 `rich_text`,直接写普通字符串即可。 +- **@人**:`{"type":"mention","mention_token":"","notify":false}`。**仅支持同租户用户,单次写入最多 50 人。** `notify` **默认 `true`**(会给被 @ 的人发通知),不想发务必显式传 `false`。 +- **@文档**:同样 `"type":"mention"`,`mention_token` 传文档 token(如 `shtXXX`)。 + +`mention_type`(类型编号)等可选字段以 `--print-schema` 输出为准。 + +> ⚠️ `rich_text` 一旦设置会**忽略**同一 cell 的 `value`;它与 `formula` / `multiple_values` 三者只能选其一作为内容字段(可叠加 `cell_styles` / `note` 等)。 + ## Dropdown 选项 + 配色(`+dropdown-set` / `+dropdown-update`) ### 选项怎么来:`--options` 与 `--source-range` 二选一 From a0d6472e9fb4abf614c7ab7e00a5c4198cdeef83 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Tue, 26 May 2026 14:48:12 +0800 Subject: [PATCH 062/114] =?UTF-8?q?feat:=20=E5=90=8C=E6=AD=A5=20tools-sche?= =?UTF-8?q?ma.json=20=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shortcuts/sheets/data/flag-schemas.json | 14 +++++++++++--- .../references/lark-sheets-batch-update.md | 2 +- skills/lark-sheets/references/lark-sheets-chart.md | 4 ++-- .../references/lark-sheets-write-cells.md | 2 +- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json index 485cffd88..c4d5e5987 100644 --- a/shortcuts/sheets/data/flag-schemas.json +++ b/shortcuts/sheets/data/flag-schemas.json @@ -560,7 +560,7 @@ ] }, "items": { - "description": "列表选项(type='list' 时必填)", + "description": "列表选项", "type": "array", "items": { "type": "string" @@ -1772,6 +1772,10 @@ } } }, + "required": [ + "position", + "size" + ], "additionalProperties": {} } }, @@ -2798,6 +2802,10 @@ } } }, + "required": [ + "position", + "size" + ], "additionalProperties": {} } }, @@ -3573,7 +3581,7 @@ }, "+dropdown-set": { "options": { - "description": "列表选项(type='list' 时必填)", + "description": "列表选项", "type": "array", "items": { "type": "string" @@ -3582,7 +3590,7 @@ }, "+dropdown-update": { "options": { - "description": "列表选项(type='list' 时必填)", + "description": "列表选项", "type": "array", "items": { "type": "string" diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 2e4f4ced2..6e29475de 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -118,7 +118,7 @@ _单元格边框配置,含 top/bottom/left/right 四个方向,每个方向 ### `+dropdown-update` `--options` -_列表选项(type='list' 时必填)_ +_列表选项_ **数组项**(类型 string): - 标量:string diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md index d83555a3f..b477e7742 100644 --- a/skills/lark-sheets/references/lark-sheets-chart.md +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -147,9 +147,9 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ _创建/更新的图表属性_ **顶层字段**: -- `position` (object?) — 必填 { row: number, col: string } +- `position` (object) — 必填 { row: number, col: string } - `offset` (object?) — 可选 { row_offset?: number, col_offset?: number } -- `size` (object?) — 必填 { width: number, height: number } +- `size` (object) — 必填 { width: number, height: number } - `snapshot` (object?) — 图表快照配置 { title?: object, subTitle?: object, style?: object, legend?: oneOf, plotArea?: object, …共 6 项 } ## Examples diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index c56bd7d84..45efec525 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -331,7 +331,7 @@ _单元格边框配置,含 top/bottom/left/right 四个方向,每个方向 ### `+dropdown-set` `--options` -_列表选项(type='list' 时必填)_ +_列表选项_ **数组项**(类型 string): - 标量:string From bb7ccaedf960688b29d76ce39aef077189c36e83 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Tue, 26 May 2026 14:52:58 +0800 Subject: [PATCH 063/114] fix(sheets): warn when +dropdown source-range exceeds 2000 cells with highlight on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit byted-sheet's ListFromRangeValidation.checkOptionsValid() sets isOptionError=true when shouldHighlightValidData is on and the source range exceeds LIST_WITH_COLOR_MAX_COUNT (2000 cells) — the highlight + large source combo is unsupported. CLI previously had no signal for this, so users only learned by seeing the dropdown render as option-error in the workbook. Add a Validate-phase stderr warning in +dropdown-set and +dropdown-update when --source-range covers >2000 cells unless --highlight=false. Soft warning, never blocks the request. Inline --options is not subject to this limit — server enforces no count or per-item length cap on inline lists, so no warning fires there. --- shortcuts/sheets/lark_sheet_batch_update.go | 1 + shortcuts/sheets/lark_sheet_write_cells.go | 46 ++++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/shortcuts/sheets/lark_sheet_batch_update.go b/shortcuts/sheets/lark_sheet_batch_update.go index 6cef40721..223ab08dd 100644 --- a/shortcuts/sheets/lark_sheet_batch_update.go +++ b/shortcuts/sheets/lark_sheet_batch_update.go @@ -335,6 +335,7 @@ var DropdownUpdate = common.Shortcut{ if _, err := validateDropdownSourceOrOptions(runtime); err != nil { return err } + warnDropdownSourceRangeHighlight(runtime) return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index b3233c8e3..9f0930930 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -271,7 +271,13 @@ var DropdownSet = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+dropdown-set"), - Validate: validateViaInput(dropdownSetInput), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validateViaInput(dropdownSetInput)(ctx, runtime); err != nil { + return err + } + warnDropdownSourceRangeHighlight(runtime) + return nil + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) @@ -434,6 +440,44 @@ func validateDropdownSourceOrOptions(runtime flagView) (int, error) { return sourceSize, nil } +// dropdownSourceRangeHighlightLimit is the cell-count cap above which the +// server marks the dropdown's options as invalid when highlight is on. +// Source: byted-sheet core LIST_WITH_COLOR_MAX_COUNT +// (sheet-packages/.../dataValidation/list/ListFromRangeValidation.ts:49). +// Beyond this, ListFromRangeValidation.checkOptionsValid() sets +// isOptionError=true (highlight + range > 2000 is an unsupported combo). +const dropdownSourceRangeHighlightLimit = 2000 + +// warnDropdownSourceRangeHighlight emits a soft stderr warning when the user +// targets a --source-range larger than dropdownSourceRangeHighlightLimit while +// highlight is on (the server-side default and the most common path). +// Inline --options is not subject to this limit (server has no inline count +// or per-item length cap; only the listFromRange + highlight combo is). +// Validate phase only — never blocks the request. Caller must already have +// confirmed the source-or-options validation passed. +func warnDropdownSourceRangeHighlight(runtime *common.RuntimeContext) { + sourceRange := strings.TrimSpace(runtime.Str("source-range")) + if sourceRange == "" { + return // inline --options mode — no server-side size cap applies + } + // highlight is tri-state: omitted = ON (server default), --highlight=true + // = ON, --highlight=false = OFF. Only the OFF case avoids the warning. + if runtime.Changed("highlight") && !runtime.Bool("highlight") { + return + } + rows, cols, err := rangeDimensions(sourceRange) + if err != nil { + return // already errored upstream; don't double-report + } + cellCount := rows * cols + if cellCount <= dropdownSourceRangeHighlightLimit { + return + } + fmt.Fprintf(runtime.IO().ErrOut, + "warning: --source-range covers %d cells; server marks the dropdown as option-error when highlight is on and the source exceeds %d cells. Pass --highlight=false to suppress this.\n", + cellCount, dropdownSourceRangeHighlightLimit) +} + // ─── range parsing helpers ──────────────────────────────────────────── // rangeDimensions parses an A1 range like "A1:C5" / "A1" / "sheet1!B2:D10" From 930c9c77a813725158a97b539fa425c174c5182f Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Tue, 26 May 2026 15:16:27 +0800 Subject: [PATCH 064/114] =?UTF-8?q?docs(sheets):=20sync=20lark-sheets=20sk?= =?UTF-8?q?ill=20from=20spec=20=E2=80=94=20dropdown=20flag=20descs=20refle?= =?UTF-8?q?ct=20server=20reality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls sheet-skill-spec canonical-spec → generated → consumers chain for dropdown flag desc corrections committed upstream (Shortcut-flags base table rows for +dropdown-set / +dropdown-update --options and --source-range). Aligns flag descs with byted-sheet behavior: - --options: dropped fabricated "≤500 items, each ≤100 chars, no commas" promise. byted-sheet ListOfItemValidation enforces none of these. - --source-range: appended note about the only real cap — LIST_WITH_COLOR_MAX_COUNT=2000 when --highlight is on (server flags the dropdown as option-error beyond that; CLI warns at Validate time per bb7ccae). Also picks up an unrelated upstream tools-schema.json drift (chart float block schema + data_validation.items description tweak) that surfaced via npm run check:tool-schemas; bundling keeps the spec sync gate green. --- shortcuts/sheets/data/flag-defs.json | 8 ++++---- shortcuts/sheets/data/flag-schemas.json | 14 +++----------- .../references/lark-sheets-batch-update.md | 6 +++--- skills/lark-sheets/references/lark-sheets-chart.md | 4 ++-- .../references/lark-sheets-write-cells.md | 6 +++--- 5 files changed, 15 insertions(+), 23 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index 41b958f9f..ed0582885 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -1882,7 +1882,7 @@ "kind": "own", "type": "string", "required": "xor", - "desc": "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`; up to 500 items, each ≤100 chars, no commas", + "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": [ "file", "stdin" @@ -1918,7 +1918,7 @@ "kind": "own", "type": "string", "required": "xor", - "desc": "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `Sheet1!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same." + "desc": "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `Sheet1!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress." }, { "name": "dry-run", @@ -2817,7 +2817,7 @@ "kind": "own", "type": "string", "required": "xor", - "desc": "Options as a JSON array (e.g. `[\"opt1\",\"opt2\"]`)", + "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": [ "file", "stdin" @@ -2853,7 +2853,7 @@ "kind": "own", "type": "string", "required": "xor", - "desc": "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `Sheet1!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same." + "desc": "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `Sheet1!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress." }, { "name": "dry-run", diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json index c4d5e5987..485cffd88 100644 --- a/shortcuts/sheets/data/flag-schemas.json +++ b/shortcuts/sheets/data/flag-schemas.json @@ -560,7 +560,7 @@ ] }, "items": { - "description": "列表选项", + "description": "列表选项(type='list' 时必填)", "type": "array", "items": { "type": "string" @@ -1772,10 +1772,6 @@ } } }, - "required": [ - "position", - "size" - ], "additionalProperties": {} } }, @@ -2802,10 +2798,6 @@ } } }, - "required": [ - "position", - "size" - ], "additionalProperties": {} } }, @@ -3581,7 +3573,7 @@ }, "+dropdown-set": { "options": { - "description": "列表选项", + "description": "列表选项(type='list' 时必填)", "type": "array", "items": { "type": "string" @@ -3590,7 +3582,7 @@ }, "+dropdown-update": { "options": { - "description": "列表选项", + "description": "列表选项(type='list' 时必填)", "type": "array", "items": { "type": "string" diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 6e29475de..1518bb046 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -71,11 +71,11 @@ _公共: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 | -| `--options` | string + File + Stdin(复合 JSON) | xor | 选项 JSON 数组(如 `["opt1","opt2"]`) | +| `--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 | 启用多选 | | `--highlight` | bool | optional | 下拉胶囊背景色高亮开关。**不传 = 开**(按内置 10 色色板循环上色);`--highlight=false` 关闭得到纯白下拉。配色用 `--colors` 覆盖。 | -| `--source-range` | string | xor | listFromRange 模式的下拉源 range,A1 表示法 + sheet 前缀(如 `Sheet1!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表(type=list),传本 flag 走 range 引用(type=listFromRange)。`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。 | +| `--source-range` | string | xor | listFromRange 模式的下拉源 range,A1 表示法 + sheet 前缀(如 `Sheet1!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表(type=list),传本 flag 走 range 引用(type=listFromRange)。`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。当 `--highlight` 开启且 source 覆盖单元格数超过 2000 时,服务端会将该下拉判为 option-error(这是不支持的组合);CLI 会向 stderr 输出 warning。如需取消,传 `--highlight=false`。 | ### `+dropdown-delete` @@ -118,7 +118,7 @@ _单元格边框配置,含 top/bottom/left/right 四个方向,每个方向 ### `+dropdown-update` `--options` -_列表选项_ +_列表选项(type='list' 时必填)_ **数组项**(类型 string): - 标量:string diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md index b477e7742..d83555a3f 100644 --- a/skills/lark-sheets/references/lark-sheets-chart.md +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -147,9 +147,9 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ _创建/更新的图表属性_ **顶层字段**: -- `position` (object) — 必填 { row: number, col: string } +- `position` (object?) — 必填 { row: number, col: string } - `offset` (object?) — 可选 { row_offset?: number, col_offset?: number } -- `size` (object) — 必填 { width: number, height: number } +- `size` (object?) — 必填 { width: number, height: number } - `snapshot` (object?) — 图表快照配置 { title?: object, subTitle?: object, style?: object, legend?: oneOf, plotArea?: object, …共 6 项 } ## Examples diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 45efec525..4c05182b5 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -286,11 +286,11 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--range` | string | required | 目标范围(A1 格式,如 `A2:A100`) | -| `--options` | string + File + Stdin(复合 JSON) | xor | 选项 JSON 数组 `["opt1","opt2"]`;最多 500 项,每项 ≤100 字符,不含逗号 | +| `--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 | 启用多选;默认 `false` | | `--highlight` | bool | optional | 下拉胶囊背景色高亮开关。**不传 = 开**(按内置 10 色色板循环上色);`--highlight=false` 关闭得到纯白下拉。配色用 `--colors` 覆盖。 | -| `--source-range` | string | xor | listFromRange 模式的下拉源 range,A1 表示法 + sheet 前缀(如 `Sheet1!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表(type=list),传本 flag 走 range 引用(type=listFromRange)。`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。 | +| `--source-range` | string | xor | listFromRange 模式的下拉源 range,A1 表示法 + sheet 前缀(如 `Sheet1!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表(type=list),传本 flag 走 range 引用(type=listFromRange)。`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。当 `--highlight` 开启且 source 覆盖单元格数超过 2000 时,服务端会将该下拉判为 option-error(这是不支持的组合);CLI 会向 stderr 输出 warning。如需取消,传 `--highlight=false`。 | ### `+csv-put` @@ -331,7 +331,7 @@ _单元格边框配置,含 top/bottom/left/right 四个方向,每个方向 ### `+dropdown-set` `--options` -_列表选项_ +_列表选项(type='list' 时必填)_ **数组项**(类型 string): - 标量:string From 08d025945e92a7080408435e0fbc15195bdc957f Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Tue, 26 May 2026 15:31:40 +0800 Subject: [PATCH 065/114] revert(sheets): drop tools-schema drift mirror from previous spec sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 930c9c7 顺带 sync 了 spec 的 tools-schema bundling — 跟那条 commit 一起 误带进来 chart float block required 和 data_validation.items 描述微调, 这两处其实是上游 sheet-ai-skills 还在 pending 的 revert。 配套 sheet-skill-spec 的 revert commit (a3aa9f2 on fix/dropdown-flag-desc-real-limits / !11),重跑 sync:consumers 拉回 正确的 generated mirror: - shortcuts/sheets/data/flag-schemas.json(chart 部分) - skills/lark-sheets/references/lark-sheets-{chart,batch-update,write-cells}.md(rendered schema 段) dropdown 文案改动(flag-defs.json 4 处 desc + dropdown 段的 reference 渲染)不在本 commit 范围,保持 930c9c7 的状态。 --- shortcuts/sheets/data/flag-schemas.json | 14 +++++++++++--- .../references/lark-sheets-batch-update.md | 2 +- skills/lark-sheets/references/lark-sheets-chart.md | 4 ++-- .../references/lark-sheets-write-cells.md | 2 +- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json index 485cffd88..c4d5e5987 100644 --- a/shortcuts/sheets/data/flag-schemas.json +++ b/shortcuts/sheets/data/flag-schemas.json @@ -560,7 +560,7 @@ ] }, "items": { - "description": "列表选项(type='list' 时必填)", + "description": "列表选项", "type": "array", "items": { "type": "string" @@ -1772,6 +1772,10 @@ } } }, + "required": [ + "position", + "size" + ], "additionalProperties": {} } }, @@ -2798,6 +2802,10 @@ } } }, + "required": [ + "position", + "size" + ], "additionalProperties": {} } }, @@ -3573,7 +3581,7 @@ }, "+dropdown-set": { "options": { - "description": "列表选项(type='list' 时必填)", + "description": "列表选项", "type": "array", "items": { "type": "string" @@ -3582,7 +3590,7 @@ }, "+dropdown-update": { "options": { - "description": "列表选项(type='list' 时必填)", + "description": "列表选项", "type": "array", "items": { "type": "string" diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 1518bb046..c16f5f8a8 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -118,7 +118,7 @@ _单元格边框配置,含 top/bottom/left/right 四个方向,每个方向 ### `+dropdown-update` `--options` -_列表选项(type='list' 时必填)_ +_列表选项_ **数组项**(类型 string): - 标量:string diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md index d83555a3f..b477e7742 100644 --- a/skills/lark-sheets/references/lark-sheets-chart.md +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -147,9 +147,9 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ _创建/更新的图表属性_ **顶层字段**: -- `position` (object?) — 必填 { row: number, col: string } +- `position` (object) — 必填 { row: number, col: string } - `offset` (object?) — 可选 { row_offset?: number, col_offset?: number } -- `size` (object?) — 必填 { width: number, height: number } +- `size` (object) — 必填 { width: number, height: number } - `snapshot` (object?) — 图表快照配置 { title?: object, subTitle?: object, style?: object, legend?: oneOf, plotArea?: object, …共 6 项 } ## Examples diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 4c05182b5..f6282e533 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -331,7 +331,7 @@ _单元格边框配置,含 top/bottom/left/right 四个方向,每个方向 ### `+dropdown-set` `--options` -_列表选项(type='list' 时必填)_ +_列表选项_ **数组项**(类型 string): - 标量:string From 71eae77f654cd8c708244064cc10f278dd26663a Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Tue, 26 May 2026 16:08:14 +0800 Subject: [PATCH 066/114] =?UTF-8?q?docs(sheets):=20sync=20lark-sheets=20sk?= =?UTF-8?q?ill=20from=20spec=20=E2=80=94=20+filter-view-update=20--propert?= =?UTF-8?q?ies=20desc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 去掉 +filter-view-update --properties 描述里"pass at least one of --properties.rules / --range / --view-name"的误导承诺。--properties 实际是硬必填(MarkFlagRequired),且 update 走 PUT 整组覆盖语义。 --- shortcuts/sheets/data/flag-defs.json | 2 +- skills/lark-sheets/references/lark-sheets-filter-view.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index ed0582885..a397d5e2f 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -4048,7 +4048,7 @@ "kind": "own", "type": "string", "required": "required", - "desc": "Filter-view rule JSON: `rules?`, `filtered_columns?`. `range` and `view_name` are separate flags; pass at least one of `--properties.rules` / `--range` / `--view-name`", + "desc": "Filter-view rule JSON: `rules?`, `filtered_columns?`; update overwrites the entire rule set (read back with `+filter-view-list` first, then patch; pass `rules: []` to clear). `range` and `view_name` are separate flags", "input": [ "file", "stdin" diff --git a/skills/lark-sheets/references/lark-sheets-filter-view.md b/skills/lark-sheets/references/lark-sheets-filter-view.md index c2a93331b..e489a3d4b 100644 --- a/skills/lark-sheets/references/lark-sheets-filter-view.md +++ b/skills/lark-sheets/references/lark-sheets-filter-view.md @@ -59,7 +59,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--view-id` | string | required | 目标筛选视图 reference_id | -| `--properties` | string + File + Stdin(复合 JSON) | required | 筛选视图规则 JSON,含 `rules?` 和 `filtered_columns?`。`range` 和 `view_name` 是独立 flag;至少传 `--properties.rules` / `--range` / `--view-name` 之一 | +| `--properties` | string + File + Stdin(复合 JSON) | required | 筛选视图规则 JSON,含 `rules?` 和 `filtered_columns?`;update 是整组覆盖式(先 `+filter-view-list` 回读再 patch;传空 `rules: []` 清空)。`range` 和 `view_name` 是独立 flag | | `--range` | string | optional | 筛选视图作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段;update 时省略表示保留当前 range | | `--view-name` | string | optional | 筛选视图名称;create 不传时系统自动分配,update 不传时保留原名;优先级高于 `--properties` 中同名字段 | @@ -113,7 +113,7 @@ lark-cli sheets +filter-view-create --url "..." --sheet-id "$SID" \ ### `+filter-view-update` -> ⚠️ update 是 patch:`--view-name` / `--range` / `--properties.rules` 任传一个或多个;至少传一个。先 `+filter-view-list` 读取当前 rules 再回写差异。重复 `+filter-view-create` 不会复用 view_id,会产生新视图。 +> ⚠️ update 是整组覆盖(PUT 语义):`--properties` **必传**,未在请求里出现的 rules / filtered_columns 会被清空。如要保留已有 rules,先 `+filter-view-list` 读回再合并写回。`--range` 变更会丢弃已有筛选规则属预期行为(rules 跟当前 range 绑定)。重复 `+filter-view-create` 不会复用 view_id,会产生新视图。 ### `+filter-view-delete` @@ -121,6 +121,6 @@ lark-cli sheets +filter-view-create --url "..." --sheet-id "$SID" \ ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`+filter-view-create` 校验 `--range` 起始行为表头(第一行);`+filter-view-update` 必须先 `+filter-view-list` 确认 view 存在,且 `--view-name` / `--range` / `--properties` 至少传一个;`+filter-view-delete` 强制 `--yes` 或 `--dry-run`。 +- `Validate`:XOR 公共四件套;`+filter-view-create` 校验 `--range` 起始行为表头(第一行);`+filter-view-update` 必须先 `+filter-view-list` 确认 view 存在,`--properties` 必传(整组覆盖式);`+filter-view-delete` 强制 `--yes` 或 `--dry-run`。 - `DryRun`:输出"将要 POST/PATCH/DELETE 的 view 请求模板",零网络副作用;`--sheet-name` 在 dry-run 输出里生成为 `` 占位符。 - `Execute`:写后调用 `+filter-view-list --view-id ` 回读,envelope.meta.verification 给出当前 range + rules 与请求体的对比。 From f53e55ce651ca3c983649319849df93bdaf0908c Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Tue, 26 May 2026 16:13:53 +0800 Subject: [PATCH 067/114] fix(sheets): align +cells-search/+cells-replace option keys with server schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI emitted `options.regex` and `options.include_formulas`, but the server-side `search_data` / `replace_data` tool schemas declare and consume `use_regex` and `match_formulas`. Result: passing `--regex` or `--include-formulas` always failed with `unexpected property ... is not defined in schema`. Keep the user-facing flag names (`--regex`, `--include-formulas`) — only the JSON keys sent to the server change. Updates the dry-run test that locked the wrong contract. --- shortcuts/sheets/lark_sheet_search_replace.go | 4 ++-- shortcuts/sheets/lark_sheet_search_replace_test.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/shortcuts/sheets/lark_sheet_search_replace.go b/shortcuts/sheets/lark_sheet_search_replace.go index d5a5579e6..6e0b8ecb3 100644 --- a/shortcuts/sheets/lark_sheet_search_replace.go +++ b/shortcuts/sheets/lark_sheet_search_replace.go @@ -95,10 +95,10 @@ func searchReplaceOptions(runtime flagView) map[string]interface{} { opts["match_entire_cell"] = true } if runtime.Bool("regex") { - opts["regex"] = true + opts["use_regex"] = true } if runtime.Bool("include-formulas") { - opts["include_formulas"] = true + opts["match_formulas"] = true } return opts } diff --git a/shortcuts/sheets/lark_sheet_search_replace_test.go b/shortcuts/sheets/lark_sheet_search_replace_test.go index e3b4cc068..bd2ad96bd 100644 --- a/shortcuts/sheets/lark_sheet_search_replace_test.go +++ b/shortcuts/sheets/lark_sheet_search_replace_test.go @@ -33,7 +33,7 @@ func TestSearchReplaceShortcuts_DryRun(t *testing.T) { }, wantOptions: map[string]interface{}{ "match_case": true, - "regex": true, + "use_regex": true, }, }, { @@ -49,8 +49,8 @@ func TestSearchReplaceShortcuts_DryRun(t *testing.T) { wantOptions: map[string]interface{}{ "match_case": true, "match_entire_cell": true, - "regex": true, - "include_formulas": true, + "use_regex": true, + "match_formulas": true, }, }, { From bea4c746ae7079a24aedffc06f7df180290f3243 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Tue, 26 May 2026 19:29:44 +0800 Subject: [PATCH 068/114] =?UTF-8?q?docs(sheets):=20sync=20float-image=20re?= =?UTF-8?q?ference=20from=20spec=20=E2=80=94=20fix=20non-runnable=20exampl?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two examples in skills/lark-sheets/references/lark-sheets-float-image.md didn't actually run against PPE; sync brings them in line with CLI behavior: - +float-image-create local-path example missed --image-name (CLI rejects with `required flag(s) "image-name" not set` even when path basename already has the filename). Add `--image-name "logo.png"` + inline note. - +float-image-update "only change position" example missed image source (CLI rejects with `one of --image, --image-token, or --image-uri is required`). Expand to two steps: list with --jq pulls the current image_token, then update re-passes --image-token to satisfy the guard. - Leading warning realigned: image source is mandatory on every update call; "keep original image" still requires passing the token explicitly. Upstream change: sheet-skill-spec MR fix/float-image-reference-examples. --- .../references/lark-sheets-chart.md | 132 ++++++++++++++++-- .../references/lark-sheets-float-image.md | 24 +++- 2 files changed, 139 insertions(+), 17 deletions(-) diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md index b477e7742..23819ffa5 100644 --- a/skills/lark-sheets/references/lark-sheets-chart.md +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -162,27 +162,139 @@ _创建/更新的图表属性_ ### `+chart-create` -示例: +> **`snapshot.data` 必填 `dim1.serie.index` 或 `dim2.series[].index` 之一**(1-based,对应 `refs.value` 范围内的列序)。schema 允许传空 `{}` 但 server 运行时强制:缺则被拒为 `snapshot.data.dim1.serie.index and dim2.series[].index are both missing; at least one must be set`,即便侥幸通过也只会渲染空图。 + +最小可用列图(inline 模式:refs 含表头行): ```bash -# 内联 JSON —— 最小列图骨架(inline 模式:refs 含表头行,首列/首行即类别/系列名) -# 完整字段(堆叠/数据标签/detached/坐标轴等)跑 --print-schema --flag-name properties lark-cli sheets +chart-create --url "https://example.feishu.cn/sheets/shtXXX" \ - --sheet-name "Sheet1" --properties '{"position":{"row":42,"col":"A"},"size":{"width":600,"height":400},"snapshot":{"data":{"refs":[{"value":"Sheet1!A1:B10"}]},"plotArea":{"plot":{"type":"column"}}}}' + --sheet-name "Sheet1" --properties '{ + "position":{"row":42,"col":"A"}, + "size":{"width":600,"height":400}, + "snapshot":{ + "data":{ + "refs":[{"value":"Sheet1!A1:B10"}], + "dim1":{"serie":{"index":1}}, + "dim2":{"series":[{"index":2}]} + }, + "plotArea":{"plot":{"type":"column"}} + } + }' # 走文件(推荐配置较多时) -lark-cli sheets +chart-create --url "https://example.feishu.cn/sheets/shtXXX" \ - --sheet-name "Sheet1" --properties @chart-config.json +lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties @chart-config.json +``` + +**饼图专属示例**(`sectors` 必须嵌在 `plotArea.plot.series[i].sectors.sector[]`,且 `sector[].index` 1-based): + +饼图比 column / bar 更复杂:`sectors` 是 object,里面再包一个**单数** `sector` 数组——CLI 不替你 normalize,写错路径会被 server schema 直接拒。 + +```bash +lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties '{ + "position":{"row":24,"col":"F"}, + "size":{"width":600,"height":450}, + "snapshot":{ + "title":{"text":"各部门员工人数占比"}, + "plotArea":{"plot":{ + "type":"pie", + "series":[{ + "index":1, + "sectors":{"sector":[{"index":1,"offsetRadius":0.05}]} + }] + }}, + "data":{ + "refs":[{"value":"Sheet1!A1:B11"}], + "dim1":{"serie":{"index":1,"aggregate":true}}, + "dim2":{"series":[{"index":2,"aggregateType":"sum"}]} + } + } +}' +``` + +**数据与表头分离(必须用 `detached` + `nameRef`)**: + +场景:周度销量明细表,真实表头在第 1 行(A1=周次、C1=订单量、D1=退款量),数据按 B 列"店铺"分段;用户只要"3 号店"那一段(第 11–17 行)。 + +```bash +lark-cli sheets +chart-create --url "..." --sheet-name "Sheet2" --properties '{ + "position":{"row":7,"col":"F"}, + "size":{"width":600,"height":360}, + "snapshot":{ + "title":{"text":"3 号店周度订单/退款"}, + "plotArea":{"plot":{"type":"column"}}, + "data":{ + "headerMode":"detached", + "direction":"column", + "refs":[{"value":"Sheet2!A11:D17"}], + "dim1":{"serie":{"index":1,"nameRef":"Sheet2!A1"}}, + "dim2":{"series":[ + {"index":3,"nameRef":"Sheet2!C1"}, + {"index":4,"nameRef":"Sheet2!D1"} + ]} + } + } +}' ``` -> **`--properties` JSON 关键字段**(结构见上方 `## Schemas` 段;详见语义内容章节): -> - `position.row` / `position.col` 必须留足空间,越界会被 API 拒 -> - `snapshot.data.headerMode`:默认 inline;当 refs 仅覆盖数据子集且语义表头在子集之外,必须 `detached` + `nameRef` +约束: +- `refs` 只覆盖纯数据 `A11:D17`,**不要**把表头行 A1 并进来 +- `nameRef` 在 detached 模式下**必填**,缺了被校验报 `headerMode=detached requires ... nameRef` +- `index` 按 refs 内的列序算(A=1、B=2、C=3、D=4),**不是**全表列号 +- `nameRef` 必须配对应的 `index`;单写 `nameRef` 不传 `index` 直接报参数错 + +**多张图共享同一组表头(按维度拆图,必须用 detached)**: + +场景:销售明细表头在 A1:E1(月份/区域/销售额/订单数/客单价),数据按区域分 3 段(华北 A2:E9、华东 A10:E17、华南 A18:E25),要分别画 3 张图。 + +❌ 常见错误: + +```jsonc +// 错误 1:refs 含全局表头但跨段 —— 多个区域被混进同一张图 +{"data":{"refs":[{"value":"Sheet!A1:E17"}], ... }} // 华东图混进华北 8 行 +// 错误 2:inline + refs 只取数据段、不写 detached/nameRef —— 图例显示成具体数据值 +{"data":{"refs":[{"value":"Sheet!A10:E17"}],"dim1":{"serie":{"index":1}}, ... }} +``` + +✅ 正确模式:3 张图各自 detached、refs 干净不重叠: + +```jsonc +// 图 1:华北 +{"data":{ + "headerMode":"detached","direction":"column", + "refs":[{"value":"Sheet!A2:E9"}], + "dim1":{"serie":{"index":1,"nameRef":"Sheet!A1"}}, + "dim2":{"series":[ + {"index":3,"nameRef":"Sheet!C1"}, + {"index":4,"nameRef":"Sheet!D1"} + ]} +}} +// 图 2:华东 —— refs 改 Sheet!A10:E17,其余同上 +// 图 3:华南 —— refs 改 Sheet!A18:E25,其余同上 +``` + +> `--properties` JSON 关键字段: +> - `position.row` / `position.col` 必须留足空间,越界会被 API 拒(按本文件"图表位置选择"四步走) +> - `snapshot.data.headerMode`:默认 inline;当 refs 仅覆盖数据子集而语义表头在子集之外,必须 `detached` + `nameRef` > - chart 引用 pivot 输出时,`snapshot.data.refs` 必须排除总计 / 小计行 ### `+chart-update` -> 更新前必须先 `+chart-list --chart-id ` 回读完整配置,再在其基础上修改,避免漏字段把图表回退到默认状态。 +**Update 三步法**(缺一步会丢字段): + +1. `+chart-list --chart-id ` 拿到完整 snapshot +2. 在拿到的 snapshot 上**局部**修改要改的字段,其余保持不变 +3. 把**完整 snapshot** 整个回写到 `--properties.snapshot` + +```bash +lark-cli sheets +chart-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \ + --properties '{ + "position":{"row":0,"col":"A"}, + "size":{"width":480,"height":320}, + "snapshot": <完整快照(由 +chart-list 取回后局部修改)> + }' +``` + +> 关键:**不能只提交局部 snapshot**,否则未传字段会被还原为默认值。`+chart-update` 的语义是 PUT(整体覆盖),不是 PATCH。 ### `+chart-delete` diff --git a/skills/lark-sheets/references/lark-sheets-float-image.md b/skills/lark-sheets/references/lark-sheets-float-image.md index 2ddf06479..6743a31bd 100644 --- a/skills/lark-sheets/references/lark-sheets-float-image.md +++ b/skills/lark-sheets/references/lark-sheets-float-image.md @@ -30,7 +30,7 @@ - `--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` 仅 `+float-image-create` 支持**。`+float-image-update` 换图仍只接受 `--image-token` / `--image-uri`(patch 模式:不传则保留原图);要在 update 里换一张本地新图,先用 `+cells-set-image` 上传到任意临时单元格、从返回取 `file_token`,再把它传给 update 的 `--image-token`。 +> ⚠️ **`--image` 仅 `+float-image-create` 支持**。`+float-image-update` 换图只接受 `--image-token` / `--image-uri`,**且即使不换图也必传其一**(CLI 强制;要保留原图就把 list 回读的 `image_token` 回填);要在 update 里换一张本地新图,先用 `+cells-set-image` 上传到任意临时单元格、从返回取 `file_token`,再把它传给 update 的 `--image-token`。 ## Shortcuts @@ -111,8 +111,9 @@ lark-cli sheets +float-image-list --url "..." --sheet-id "$SID" ```bash # 首选:直接给本地图片路径,CLI 自动上传(无需手动拿 token) +# 注意:--image-name 是 required(即使路径 basename 已经是 logo.png 也要显式传) lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \ - --image ./logo.png \ + --image ./logo.png --image-name "logo.png" \ --position-row 2 --position-col B --size-width 300 --size-height 200 --z-index 1 # 用已有 file_token(从 +float-image-list 的 image_token 或 +cells-set-image 返回的 file_token) @@ -130,14 +131,23 @@ lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \ > **patch 模式**:除了 `--float-image-id`(必填,定位目标图片)外,其它字段都可选——只传你需要改的那几个,未传的字段保持原值不变。至少传一个改动字段。 > -> 推荐流程:先 `+float-image-list --float-image-id ` 回读当前完整属性,再针对要改的字段调一次 `+float-image-update`。 +> ⚠️ **图片来源必传**:CLI 强制要求 `--image-token` / `--image-uri` 之一(即使本次不换图)。要"保留原图"也得显式传当前 `image_token`——先用 `+float-image-list --float-image-id ` 回读拿到 `image_token`,再传给 update。 +> +> 推荐流程:`+float-image-list --float-image-id ` → 抽 `image_token` → 调一次 `+float-image-update` 传完整图片来源 + 想改的字段。 ```bash -# 只改位置,保留其它属性 +# 第 1 步:list 回读当前 image_token(必拿,下一步要用) +lark-cli sheets +float-image-list --url "..." --sheet-id "$SID" \ + --float-image-id "$IMG_ID" --jq '.data.sheets[0].float_images[0].image_token' +# 拿到 e.g. "boxbn...",赋给 shell 变量 IMG_TOKEN + +# 第 2 步:只改位置,图片来源原样回填(不传 --image-token 会被 CLI 拒) lark-cli sheets +float-image-update --url "..." --sheet-id "$SID" \ - --float-image-id "$IMG_ID" --position-row 5 --position-col C + --float-image-id "$IMG_ID" \ + --image-token "$IMG_TOKEN" --image-name "logo.png" \ + --position-row 5 --position-col C -# 只换图,位置/尺寸不变 +# 只换图,位置/尺寸不变(image-name 必传;旧位置/尺寸字段不传则按 patch 语义保留) lark-cli sheets +float-image-update --url "..." --sheet-id "$SID" \ --float-image-id "$IMG_ID" --image-name "new-logo.png" --image-token "$NEW_TOKEN" ``` @@ -150,6 +160,6 @@ lark-cli sheets +float-image-delete --url "..." --sheet-id "$SID" --float-image- ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`+float-image-create` 要求 `--image` / `--image-token` / `--image-uri` **恰好给一个**,`--position-row/col` 与 `--size-width/height` 必填且为合法整数;传 `--image` 时还会校验路径安全(绝对路径 / 越出工作目录会被拒,`--dry-run` 同样拦)。`+float-image-update` 必须 `--float-image-id`,其余字段至少传 1 个(patch 模式:未传字段保持原值,换图只接受 `--image-token` / `--image-uri`);`+float-image-delete` 强制 `--yes` 或 `--dry-run`。 +- `Validate`:XOR 公共四件套;`+float-image-create` 要求 `--image` / `--image-token` / `--image-uri` **恰好给一个** + `--image-name` 必填,`--position-row/col` 与 `--size-width/height` 必填且为合法整数;传 `--image` 时还会校验路径安全(绝对路径 / 越出工作目录会被拒,`--dry-run` 同样拦)。`+float-image-update` 必须 `--float-image-id`,**并且必须传 `--image-token` 或 `--image-uri` 之一**(patch 模式只是说"未传字段保持原值",但图片来源仍是硬必填——只改位置 / 尺寸时也要把 list 回读的 `image_token` 回填);`+float-image-delete` 强制 `--yes` 或 `--dry-run`。 - `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 float_image 请求模板";传 `--image` 时会多打印一步本地图片上传(`POST /open-apis/drive/v1/medias/upload_all`,`parent_type=sheet_image`)。 - `Execute`:写后调用 `+float-image-list --float-image-id ` 回读,envelope.meta.verification 给出新位置 / 尺寸对比。 From 55ccbc5f6ae8e19112d70a632538daf306a37a8d Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Tue, 26 May 2026 21:34:04 +0800 Subject: [PATCH 069/114] =?UTF-8?q?feat:=20=E5=90=8C=E6=AD=A5=20tools-sche?= =?UTF-8?q?ma.json=20=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shortcuts/sheets/data/flag-schemas.json | 42 +++++++++++++++---- .../references/lark-sheets-chart.md | 2 +- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json index c4d5e5987..4a909314d 100644 --- a/shortcuts/sheets/data/flag-schemas.json +++ b/shortcuts/sheets/data/flag-schemas.json @@ -1610,7 +1610,10 @@ ] } } - } + }, + "required": [ + "plot" + ] }, "data": { "type": "object", @@ -1676,7 +1679,10 @@ "type": "string", "description": "可选。维度名称的单元格引用(A1 表示法,如 'Sheet1!A1')。当维度名称不直接来自 refs 首行/首列时,可通过此字段把维度名显式指向目标表头单元格,图表会以该单元格当前值作为类别维度名展示。" } - } + }, + "required": [ + "index" + ] }, "field": { "type": "object", @@ -1735,7 +1741,10 @@ "type": "string", "description": "可选。系列名称的单元格引用(A1 表示法,如 'Sheet1!C1')。当系列名称不直接来自 refs 首行/首列时,可通过此字段把系列名显式指向目标表头单元格,图表会以该单元格当前值作为系列名(图例/tooltip)展示。" } - } + }, + "required": [ + "index" + ] } }, "fields": { @@ -1769,7 +1778,11 @@ } } } - } + }, + "required": [ + "plotArea", + "data" + ] } }, "required": [ @@ -2640,7 +2653,10 @@ ] } } - } + }, + "required": [ + "plot" + ] }, "data": { "type": "object", @@ -2706,7 +2722,10 @@ "type": "string", "description": "可选。维度名称的单元格引用(A1 表示法,如 'Sheet1!A1')。当维度名称不直接来自 refs 首行/首列时,可通过此字段把维度名显式指向目标表头单元格,图表会以该单元格当前值作为类别维度名展示。" } - } + }, + "required": [ + "index" + ] }, "field": { "type": "object", @@ -2765,7 +2784,10 @@ "type": "string", "description": "可选。系列名称的单元格引用(A1 表示法,如 'Sheet1!C1')。当系列名称不直接来自 refs 首行/首列时,可通过此字段把系列名显式指向目标表头单元格,图表会以该单元格当前值作为系列名(图例/tooltip)展示。" } - } + }, + "required": [ + "index" + ] } }, "fields": { @@ -2799,7 +2821,11 @@ } } } - } + }, + "required": [ + "plotArea", + "data" + ] } }, "required": [ diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md index 23819ffa5..5793d5456 100644 --- a/skills/lark-sheets/references/lark-sheets-chart.md +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -150,7 +150,7 @@ _创建/更新的图表属性_ - `position` (object) — 必填 { row: number, col: string } - `offset` (object?) — 可选 { row_offset?: number, col_offset?: number } - `size` (object) — 必填 { width: number, height: number } -- `snapshot` (object?) — 图表快照配置 { title?: object, subTitle?: object, style?: object, legend?: oneOf, plotArea?: object, …共 6 项 } +- `snapshot` (object?) — 图表快照配置 { title?: object, subTitle?: object, style?: object, legend?: oneOf, plotArea: object, …共 6 项 } ## Examples From 08e8b5c870eb07813f7d702b7f83d9d82b7be7a0 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Wed, 27 May 2026 11:10:32 +0800 Subject: [PATCH 070/114] fix(sheets): allow +float-image-update to omit the image source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image source (--image-token / --image-uri) is the only optional part of an update: omit all of them to keep the current image. image_name, position and size stay required — the manage_float_image tool rejects an update without them, and +float-image-list does not return image_name to backfill. Previously the shortcut forced an image source even when only position/size changed, so those updates were rejected CLI-side before any API call (reported as a Fail case in the sheets e2e rerun). - floatImageProperties: gate the image-source requirement on create only; keep image_name/position/size required on both; emit image_uri only when set - sync flag-defs.json + lark-sheets-float-image.md from sheet-skill-spec (image-name/position/size now required on +float-image-update) - tests: cover the image-source-optional dry-run; the single-required checks move to the +batch-update sub-op path (cobra owns the standalone path) --- shortcuts/sheets/batch_op_contract_test.go | 23 ++++++ shortcuts/sheets/data/flag-defs.json | 10 +-- shortcuts/sheets/lark_sheet_object_crud.go | 44 ++++++++--- .../sheets/lark_sheet_object_crud_test.go | 77 +++++++++++++++++++ .../references/lark-sheets-float-image.md | 37 ++++----- 5 files changed, 154 insertions(+), 37 deletions(-) diff --git a/shortcuts/sheets/batch_op_contract_test.go b/shortcuts/sheets/batch_op_contract_test.go index 4e73a11a8..2335db416 100644 --- a/shortcuts/sheets/batch_op_contract_test.go +++ b/shortcuts/sheets/batch_op_contract_test.go @@ -526,6 +526,29 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) { `{"sheet-id":"sh1","image-name":"x.png","image-token":"t","position-row":0,"position-col":"A","size-width":100,"size-height":50}`, "--float-image-id is required", }, + // +float-image-update's core (image_name / position / size) is mandatory + // on update too — the tool rejects without them and +float-image-list + // can't backfill image_name. cobra gates these on the standalone path; + // the batch sub-op must reject them here. The image source stays optional + // (omitting it keeps the current image), so these inputs omit it. + { + "+float-image-update missing --image-name", + "+float-image-update", + `{"sheet-id":"sh1","float-image-id":"fi1","position-row":0,"position-col":"A","size-width":100,"size-height":50}`, + "--image-name is required", + }, + { + "+float-image-update missing position", + "+float-image-update", + `{"sheet-id":"sh1","float-image-id":"fi1","image-name":"x.png","size-width":100,"size-height":50}`, + "--position-row and --position-col are required", + }, + { + "+float-image-update missing size", + "+float-image-update", + `{"sheet-id":"sh1","float-image-id":"fi1","image-name":"x.png","position-row":0,"position-col":"A"}`, + "--size-width and --size-height are required", + }, // +filter-{update,delete} need sheet-id (not sheet-name) because // server contract: filter_id === sheet_id, and we can't resolve // sheet-name → sheet-id mid-batch. diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index a397d5e2f..abd051074 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -4547,7 +4547,7 @@ "name": "image-name", "kind": "own", "type": "string", - "required": "optional", + "required": "required", "desc": "Image name, including extension (e.g. `logo.png`)" }, { @@ -4568,28 +4568,28 @@ "name": "position-row", "kind": "own", "type": "int", - "required": "optional", + "required": "required", "desc": "Row anchor of the image's top-left corner (0-based)" }, { "name": "position-col", "kind": "own", "type": "string", - "required": "optional", + "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": "optional", + "required": "required", "desc": "Image width in pixels" }, { "name": "size-height", "kind": "own", "type": "int", - "required": "optional", + "required": "required", "desc": "Image height in pixels" }, { diff --git a/shortcuts/sheets/lark_sheet_object_crud.go b/shortcuts/sheets/lark_sheet_object_crud.go index 781fd3b8d..9742c52c7 100644 --- a/shortcuts/sheets/lark_sheet_object_crud.go +++ b/shortcuts/sheets/lark_sheet_object_crud.go @@ -429,12 +429,26 @@ func floatImageName(runtime flagView) string { } // floatImageProperties assembles the tool's properties object from the flat -// flags. Caller marks required flags via cobra Required:true; this function -// enforces the image source XOR: exactly one of --image / --image-token / -// --image-uri must be set. uploadedImageToken, when non-empty, is the -// file_token obtained by uploading a local --image (Execute only); in -// Validate/DryRun it is "" and a placeholder token stands in. -func floatImageProperties(runtime flagView, uploadedImageToken string) (map[string]interface{}, error) { +// flags. The manage_float_image tool requires image_name, position and size on +// both create and update; the only difference is the image source: +// - create (requireImageSource=true): exactly one of --image / --image-token +// / --image-uri must be set. +// - update (requireImageSource=false): the image source is optional — omit +// all three to keep the current image; when given it stays mutually +// exclusive. Despite the "patch" framing, the tool still rejects an update +// missing image_name, position or size, and +float-image-list does not +// return image_name for the CLI to backfill, so the caller must supply the +// full core set. +// +// image_name, position and size are cobra-required on both create and update, +// so the standalone path is already gated by the flag layer; the explicit +// checks below are what enforces them on the +batch-update sub-op path, which +// has no cobra layer (mirrors the --float-image-id check in floatImageWriteInput). +// +// uploadedImageToken, when non-empty, is the file_token obtained by uploading a +// local --image (Execute only); in Validate/DryRun it is "" and a placeholder +// token stands in. +func floatImageProperties(runtime flagView, uploadedImageToken string, requireImageSource bool) (map[string]interface{}, error) { img := strings.TrimSpace(runtime.Str("image")) token := strings.TrimSpace(runtime.Str("image-token")) uri := strings.TrimSpace(runtime.Str("image-uri")) @@ -444,14 +458,24 @@ func floatImageProperties(runtime flagView, uploadedImageToken string) (map[stri set++ } } - if set == 0 { + if set == 0 && requireImageSource { return nil, common.FlagErrorf("one of --image, --image-token, or --image-uri is required") } if set > 1 { return nil, common.FlagErrorf("--image, --image-token, and --image-uri are mutually exclusive") } + name := floatImageName(runtime) + if name == "" { + return nil, common.FlagErrorf("--image-name is required") + } + if !runtime.Changed("position-row") || !runtime.Changed("position-col") { + return nil, common.FlagErrorf("--position-row and --position-col are required") + } + if !runtime.Changed("size-width") || !runtime.Changed("size-height") { + return nil, common.FlagErrorf("--size-width and --size-height are required") + } props := map[string]interface{}{ - "image_name": floatImageName(runtime), + "image_name": name, "position": map[string]interface{}{ "row": runtime.Int("position-row"), "col": strings.TrimSpace(runtime.Str("position-col")), @@ -475,7 +499,7 @@ func floatImageProperties(runtime flagView, uploadedImageToken string) (map[stri } case token != "": props["image_token"] = token - default: + case uri != "": props["image_uri"] = uri } if runtime.Changed("offset-row") || runtime.Changed("offset-col") { @@ -604,7 +628,7 @@ func floatImageWriteInput(runtime flagView, token, sheetID, sheetName, op string if withIDFlag && strings.TrimSpace(runtime.Str("float-image-id")) == "" { return nil, common.FlagErrorf("--float-image-id is required") } - props, err := floatImageProperties(runtime, uploadedImageToken) + props, err := floatImageProperties(runtime, uploadedImageToken, op == "create") if err != nil { return nil, err } diff --git a/shortcuts/sheets/lark_sheet_object_crud_test.go b/shortcuts/sheets/lark_sheet_object_crud_test.go index 5d0728737..5220d24b2 100644 --- a/shortcuts/sheets/lark_sheet_object_crud_test.go +++ b/shortcuts/sheets/lark_sheet_object_crud_test.go @@ -261,6 +261,58 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { }, }, }, + { + // patch mode: position + size with no image source. The image + // fields are omitted so the server keeps the current image; only + // image_name (server-mandated) and the changed geometry are sent. + // This is the shape that used to be rejected CLI-side. + name: "+float-image-update patch position+size, no image source", + sc: FloatImageUpdate, + args: []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--float-image-id", "imgABC", "--image-name", "logo.png", + "--position-row", "10", "--position-col", "I", + "--size-width", "90", "--size-height", "70", + }, + toolName: "manage_float_image_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "update", + "float_image_id": "imgABC", + "properties": map[string]interface{}{ + "image_name": "logo.png", + "position": map[string]interface{}{"row": float64(10), "col": "I"}, + "size": map[string]interface{}{"width": float64(90), "height": float64(70)}, + }, + }, + }, + { + // swap the image: an explicit --image-token rides alongside the + // mandatory core (image_name + position + size). + name: "+float-image-update swap image via image-token", + sc: FloatImageUpdate, + args: []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--float-image-id", "imgABC", + "--image-name", "new.png", "--image-token", "tok_new", + "--position-row", "2", "--position-col", "B", + "--size-width", "300", "--size-height", "200", + }, + toolName: "manage_float_image_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "update", + "float_image_id": "imgABC", + "properties": map[string]interface{}{ + "image_name": "new.png", + "image_token": "tok_new", + "position": map[string]interface{}{"row": float64(2), "col": "B"}, + "size": map[string]interface{}{"width": float64(300), "height": float64(200)}, + }, + }, + }, } for _, tt := range tests { tt := tt @@ -295,6 +347,31 @@ func TestSparklineUpdate_MissingSparklineID(t *testing.T) { } } +// Note: +float-image-update's image_name / position / size are cobra-required +// (flag-defs.json), so the standalone path is gated by the flag layer — its +// "required flag(s) … not set" wording is framework-owned and intentionally not +// re-asserted here. The CLI-side enforcement that matters is on the +// +batch-update sub-op path (no cobra layer); that is covered by +// TestBatchOp_RejectsBadSubOpInput in batch_op_contract_test.go. + +// TestFloatImageCreate_RequiresImageSource guards the asymmetry with update: +// create still mandates one of --image / --image-token / --image-uri. +func TestFloatImageCreate_RequiresImageSource(t *testing.T) { + t.Parallel() + _, stderr, err := runShortcutCapturingErr(t, FloatImageCreate, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--image-name", "x.png", + "--position-row", "0", "--position-col", "A", + "--size-width", "10", "--size-height", "10", + }) + if err == nil { + t.Fatalf("expected CLI to require an image source on create; stderr=%s", stderr) + } + if combined := stderr + err.Error(); !strings.Contains(combined, "one of --image, --image-token, or --image-uri is required") { + t.Errorf("expected error to require an image source; got=%s|%v", stderr, err) + } +} + // TestObjectDelete_AllHighRisk asserts every delete shortcut blocks // without --yes (framework-enforced). func TestObjectDelete_AllHighRisk(t *testing.T) { diff --git a/skills/lark-sheets/references/lark-sheets-float-image.md b/skills/lark-sheets/references/lark-sheets-float-image.md index 6743a31bd..5c3ecb421 100644 --- a/skills/lark-sheets/references/lark-sheets-float-image.md +++ b/skills/lark-sheets/references/lark-sheets-float-image.md @@ -30,7 +30,7 @@ - `--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` 仅 `+float-image-create` 支持**。`+float-image-update` 换图只接受 `--image-token` / `--image-uri`,**且即使不换图也必传其一**(CLI 强制;要保留原图就把 list 回读的 `image_token` 回填);要在 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 一样**必填**(`manage_float_image` 工具强制要求这套核心字段,且 `+float-image-list` 不回传 `image_name` 供 CLI 回填)。要在 update 里换一张本地新图,先用 `+cells-set-image` 上传到任意临时单元格、从返回取 `file_token`,再把它传给 update 的 `--image-token`。 ## Shortcuts @@ -76,13 +76,13 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--float-image-id` | string | required | 目标图片 id | -| `--image-name` | string | optional | 图片名称,含扩展名(如 `logo.png`) | +| `--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 | -| `--position-row` | int | optional | 图片左上角所在行(0-based) | -| `--position-col` | string | optional | 图片左上角所在列(列字母,如 `A` / `B`) | -| `--size-width` | int | optional | 图片宽度(像素) | -| `--size-height` | int | optional | 图片高度(像素) | +| `--position-row` | int | required | 图片左上角所在行(0-based) | +| `--position-col` | string | required | 图片左上角所在列(列字母,如 `A` / `B`) | +| `--size-width` | int | required | 图片宽度(像素) | +| `--size-height` | int | required | 图片高度(像素) | | `--offset-row` | int | optional | 在 `--position-row` 基础上的行内偏移(像素) | | `--offset-col` | int | optional | 在 `--position-col` 基础上的列内偏移(像素) | | `--z-index` | int | optional | 图片 Z 轴层级,控制重叠顺序 | @@ -129,27 +129,20 @@ lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \ ### `+float-image-update` -> **patch 模式**:除了 `--float-image-id`(必填,定位目标图片)外,其它字段都可选——只传你需要改的那几个,未传的字段保持原值不变。至少传一个改动字段。 -> -> ⚠️ **图片来源必传**:CLI 强制要求 `--image-token` / `--image-uri` 之一(即使本次不换图)。要"保留原图"也得显式传当前 `image_token`——先用 `+float-image-list --float-image-id ` 回读拿到 `image_token`,再传给 update。 +> **update ≈ create,只有图片源可省**:`manage_float_image` 工具的 update 要求和 create 相同的核心字段——`--image-name`、`--position-{row,col}`、`--size-{width,height}` **全部必填**;唯一区别是**图片源(`--image-token` / `--image-uri`)可以全省**,省略即保留原图。这**不是**"只发改动字段"的 patch:缺任一核心字段会被工具拒绝(`+float-image-list` 不回传 `image_name`,CLI 无法替你回填)。 > -> 推荐流程:`+float-image-list --float-image-id ` → 抽 `image_token` → 调一次 `+float-image-update` 传完整图片来源 + 想改的字段。 +> 推荐流程:先 `+float-image-list --float-image-id ` 回读当前 position / size,再带上 `--image-name` 和完整的 position / size 调一次 `+float-image-update`。 ```bash -# 第 1 步:list 回读当前 image_token(必拿,下一步要用) -lark-cli sheets +float-image-list --url "..." --sheet-id "$SID" \ - --float-image-id "$IMG_ID" --jq '.data.sheets[0].float_images[0].image_token' -# 拿到 e.g. "boxbn...",赋给 shell 变量 IMG_TOKEN - -# 第 2 步:只改位置,图片来源原样回填(不传 --image-token 会被 CLI 拒) +# 调整位置 + 尺寸,保留原图(不传图片源) lark-cli sheets +float-image-update --url "..." --sheet-id "$SID" \ - --float-image-id "$IMG_ID" \ - --image-token "$IMG_TOKEN" --image-name "logo.png" \ - --position-row 5 --position-col C + --float-image-id "$IMG_ID" --image-name "logo.png" \ + --position-row 5 --position-col C --size-width 300 --size-height 200 -# 只换图,位置/尺寸不变(image-name 必传;旧位置/尺寸字段不传则按 patch 语义保留) +# 换图:额外带 --image-token,核心字段同样要给全 lark-cli sheets +float-image-update --url "..." --sheet-id "$SID" \ - --float-image-id "$IMG_ID" --image-name "new-logo.png" --image-token "$NEW_TOKEN" + --float-image-id "$IMG_ID" --image-name "new-logo.png" --image-token "$NEW_TOKEN" \ + --position-row 5 --position-col C --size-width 300 --size-height 200 ``` ### `+float-image-delete` @@ -160,6 +153,6 @@ lark-cli sheets +float-image-delete --url "..." --sheet-id "$SID" --float-image- ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`+float-image-create` 要求 `--image` / `--image-token` / `--image-uri` **恰好给一个** + `--image-name` 必填,`--position-row/col` 与 `--size-width/height` 必填且为合法整数;传 `--image` 时还会校验路径安全(绝对路径 / 越出工作目录会被拒,`--dry-run` 同样拦)。`+float-image-update` 必须 `--float-image-id`,**并且必须传 `--image-token` 或 `--image-uri` 之一**(patch 模式只是说"未传字段保持原值",但图片来源仍是硬必填——只改位置 / 尺寸时也要把 list 回读的 `image_token` 回填);`+float-image-delete` 强制 `--yes` 或 `--dry-run`。 +- `Validate`:XOR 公共四件套;`+float-image-create` 要求 `--image` / `--image-token` / `--image-uri` **恰好给一个**,`--position-row/col` 与 `--size-width/height` 必填且为合法整数;传 `--image` 时还会校验路径安全(绝对路径 / 越出工作目录会被拒,`--dry-run` 同样拦)。`+float-image-update` 必须 `--float-image-id`,并和 create 一样必填 `--image-name` / `--position-{row,col}` / `--size-{width,height}`(缺任一核心字段本地直接报错,不会静默发 0);图片源 `--image-token` / `--image-uri` 可省(省略保留原图),给则二选一;`+float-image-delete` 强制 `--yes` 或 `--dry-run`。 - `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 float_image 请求模板";传 `--image` 时会多打印一步本地图片上传(`POST /open-apis/drive/v1/medias/upload_all`,`parent_type=sheet_image`)。 - `Execute`:写后调用 `+float-image-list --float-image-id ` 回读,envelope.meta.verification 给出新位置 / 尺寸对比。 From 60c61d81570c7b932d1e4cdf8099820476985fb2 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Wed, 27 May 2026 11:56:42 +0800 Subject: [PATCH 071/114] docs(sheets): sync lark-sheets skill from spec Mirror the canonical-spec reference fixes into the consumer skill: - search_replace output contract: `matches[]` with `address` (+ `has_more`/`next_offset`) - workbook sheet fields: `sheet_name`/`is_hidden`/`*_count`, no `frozen_*` - `+range-fill` example uses a non-overlapping target (A3:A100) - drop the unimplemented `envelope.meta.verification` auto-readback claim; advise manual list/get verification instead --- skills/lark-sheets/SKILL.md | 2 +- skills/lark-sheets/references/lark-sheets-chart.md | 2 +- .../references/lark-sheets-conditional-format.md | 2 +- skills/lark-sheets/references/lark-sheets-filter-view.md | 2 +- skills/lark-sheets/references/lark-sheets-filter.md | 2 +- skills/lark-sheets/references/lark-sheets-float-image.md | 2 +- skills/lark-sheets/references/lark-sheets-pivot-table.md | 2 +- .../lark-sheets/references/lark-sheets-range-operations.md | 6 +++--- skills/lark-sheets/references/lark-sheets-search-replace.md | 5 +++-- .../lark-sheets/references/lark-sheets-sheet-structure.md | 2 +- skills/lark-sheets/references/lark-sheets-sparkline.md | 2 +- skills/lark-sheets/references/lark-sheets-workbook.md | 4 ++-- skills/lark-sheets/references/lark-sheets-write-cells.md | 2 +- 13 files changed, 18 insertions(+), 17 deletions(-) diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index 5096c9364..b00c35ad0 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -114,7 +114,7 @@ lark-cli sheets <其它 flag> ### flag 内容类型与输出约定(术语速记) - flag 表里 JSON 类入参标三类:**复合 JSON** = 深层嵌套对象(用 `--print-schema` 取完整结构);**简单 JSON** = 一维 / 二维标量数组(如 `["sheet1!A1:B2",...]` / `[["alice",95]]`,结构简单无需 print-schema);**非 JSON 文本** = 原样文本(如 CSV)。`--print-schema` 只对**复合 JSON** flag 有效(同一 shortcut 的简单 JSON flag 如 `--colors` 不在此列)。 -- **envelope**:所有 shortcut 返回统一外层结构 `{ok, identity, data, ...}`。正文里 `envelope.data` 指业务数据层(如 `+csv-get` 的 `annotated_csv`);`envelope.meta.verification` 指写操作执行后 CLI 自动回读、给出的"预期 vs 实际"对比。 +- **envelope**:所有 shortcut 返回统一外层结构 `{ok, identity, data, ...}`。正文里 `envelope.data` 指业务数据层(如 `+csv-get` 的 `annotated_csv`);写操作不会自动回读,如需校验请自行调用对应的 `+*-list` / `+*-get` / `+cells-get`。 ## 复合 JSON / 大入参:优先 stdin diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md index 5793d5456..f575cade5 100644 --- a/skills/lark-sheets/references/lark-sheets-chart.md +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -314,6 +314,6 @@ lark-cli sheets +chart-delete --url "https://example.feishu.cn/sheets/shtXXX" -- - `Validate`:XOR 公共四件套;`+chart-create` / `+chart-update` 的 `--properties` 必须能解析为合法 JSON;`+chart-delete`(high-risk-write)校验 `--yes` 或 `--dry-run` 至少一个。 - `DryRun`:`+chart-create` / `+chart-update` 输出"将要 POST 的 body 模板";`+chart-delete` 输出"将要删除的 chart_id 及隶属 sheet",零网络副作用。 -- `Execute`:写操作执行后自动调用 `+chart-list` 回读对比,记录到 `envelope.meta.verification`,便于上层根据回读结果判定是否符合预期。 +- `Execute`:写操作执行后不自动回读;如需确认,自行调用 `+chart-list` 比对结果。 > `+chart-create` / `+chart-update` 是 write 级别,按需可用 `--dry-run` 预览,不要求 `--yes`。只有 `+chart-delete`(high-risk-write)必须 `--yes`。 diff --git a/skills/lark-sheets/references/lark-sheets-conditional-format.md b/skills/lark-sheets/references/lark-sheets-conditional-format.md index 5e497033c..48081a73e 100644 --- a/skills/lark-sheets/references/lark-sheets-conditional-format.md +++ b/skills/lark-sheets/references/lark-sheets-conditional-format.md @@ -174,4 +174,4 @@ lark-cli sheets +cond-format-delete --url "..." --sheet-id "$SID" --rule-id "$RU - `Validate`:XOR 公共四件套;`--rule-type` / `--ranges` 必填;`--properties` 必须能解析为合法 JSON;按 `--rule-type` 检查必填子字段(`cellIs` 需 `attrs.operator` + `attrs.value`、`expression` 需 `attrs.formula`、`colorScale` 需 `min/mid/max` 配色等);`+cond-format-delete` 强制 `--yes` 或 `--dry-run`。 - `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 conditional_format 请求模板"。 -- `Execute`:写后调用 `+cond-format-list --rule-id ` 回读,envelope.meta.verification 给出规则 / 范围 / 样式对比。 +- `Execute`:写后不自动回读;如需确认,自行调用 `+cond-format-list --rule-id ` 比对规则 / 范围 / 样式。 diff --git a/skills/lark-sheets/references/lark-sheets-filter-view.md b/skills/lark-sheets/references/lark-sheets-filter-view.md index e489a3d4b..0b9ed3909 100644 --- a/skills/lark-sheets/references/lark-sheets-filter-view.md +++ b/skills/lark-sheets/references/lark-sheets-filter-view.md @@ -123,4 +123,4 @@ lark-cli sheets +filter-view-create --url "..." --sheet-id "$SID" \ - `Validate`:XOR 公共四件套;`+filter-view-create` 校验 `--range` 起始行为表头(第一行);`+filter-view-update` 必须先 `+filter-view-list` 确认 view 存在,`--properties` 必传(整组覆盖式);`+filter-view-delete` 强制 `--yes` 或 `--dry-run`。 - `DryRun`:输出"将要 POST/PATCH/DELETE 的 view 请求模板",零网络副作用;`--sheet-name` 在 dry-run 输出里生成为 `` 占位符。 -- `Execute`:写后调用 `+filter-view-list --view-id ` 回读,envelope.meta.verification 给出当前 range + rules 与请求体的对比。 +- `Execute`:写后不自动回读;如需确认,自行调用 `+filter-view-list --view-id ` 比对当前 range + rules。 diff --git a/skills/lark-sheets/references/lark-sheets-filter.md b/skills/lark-sheets/references/lark-sheets-filter.md index bd3aee05d..356bdb643 100644 --- a/skills/lark-sheets/references/lark-sheets-filter.md +++ b/skills/lark-sheets/references/lark-sheets-filter.md @@ -116,4 +116,4 @@ lark-cli sheets +filter-delete --url "..." --sheet-id "$SID" --yes - `Validate`:XOR 公共四件套;`+filter-create` 校验 `--range` 至少 2 行(表头 + 至少 1 行数据);`+filter-update` 必须先 `+filter-list` 确认目标存在;`+filter-delete` 强制 `--yes` 或 `--dry-run`。 - `DryRun`:输出"将要 POST/PATCH/DELETE 的 filter 请求模板"。 -- `Execute`:写后调用 `+filter-list` 回读,envelope.meta.verification 给出当前筛选条件 + 已过滤行数。 +- `Execute`:写后不自动回读;如需确认,自行调用 `+filter-list` 查看当前筛选条件 + 已过滤行数。 diff --git a/skills/lark-sheets/references/lark-sheets-float-image.md b/skills/lark-sheets/references/lark-sheets-float-image.md index 5c3ecb421..26a2ccd58 100644 --- a/skills/lark-sheets/references/lark-sheets-float-image.md +++ b/skills/lark-sheets/references/lark-sheets-float-image.md @@ -155,4 +155,4 @@ lark-cli sheets +float-image-delete --url "..." --sheet-id "$SID" --float-image- - `Validate`:XOR 公共四件套;`+float-image-create` 要求 `--image` / `--image-token` / `--image-uri` **恰好给一个**,`--position-row/col` 与 `--size-width/height` 必填且为合法整数;传 `--image` 时还会校验路径安全(绝对路径 / 越出工作目录会被拒,`--dry-run` 同样拦)。`+float-image-update` 必须 `--float-image-id`,并和 create 一样必填 `--image-name` / `--position-{row,col}` / `--size-{width,height}`(缺任一核心字段本地直接报错,不会静默发 0);图片源 `--image-token` / `--image-uri` 可省(省略保留原图),给则二选一;`+float-image-delete` 强制 `--yes` 或 `--dry-run`。 - `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 float_image 请求模板";传 `--image` 时会多打印一步本地图片上传(`POST /open-apis/drive/v1/medias/upload_all`,`parent_type=sheet_image`)。 -- `Execute`:写后调用 `+float-image-list --float-image-id ` 回读,envelope.meta.verification 给出新位置 / 尺寸对比。 +- `Execute`:写后不自动回读;如需确认,自行调用 `+float-image-list --float-image-id ` 比对新位置 / 尺寸。 diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md index dff2e3dfa..f6edb4173 100644 --- a/skills/lark-sheets/references/lark-sheets-pivot-table.md +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -157,6 +157,6 @@ lark-cli sheets +pivot-delete --url "..." --sheet-id "$SID" --pivot-table-id "$P - `Validate`:XOR 公共四件套;`+pivot-create` 的 `--source` 必填且必须含表头行;`--properties` 中 `rows` / `columns` / `values` 至少非空之一;`+pivot-delete` 强制 `--yes` 或 `--dry-run`。 - `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 pivot 请求模板"+ 预估输出尺寸(行数 × 列数)。 -- `Execute`:写后调用 `+pivot-list --pivot-table-id ` 回读 + `+csv-get` 抽样读透视产物,envelope.meta.verification 给出实际输出尺寸 + 总计行位置。 +- `Execute`:写后不自动回读;如需确认,自行调用 `+pivot-list --pivot-table-id ` 并用 `+csv-get` 抽样读透视产物核对输出尺寸 + 总计行位置。 > ⚠️ pivot 输出包含总计 / 小计行;后续 chart 引用 pivot 时,`snapshot.data.refs` 必须排除这些行(见 `lark-sheets-chart` 的「⚠️ chart 数据源引用 pivot 时必须排除总计行」段)。 diff --git a/skills/lark-sheets/references/lark-sheets-range-operations.md b/skills/lark-sheets/references/lark-sheets-range-operations.md index 1e0938529..9e4793eb9 100644 --- a/skills/lark-sheets/references/lark-sheets-range-operations.md +++ b/skills/lark-sheets/references/lark-sheets-range-operations.md @@ -245,8 +245,8 @@ lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --start 0 --end 5 --t ### `+range-fill` ```bash -# 用 A1:A2 的序列规律自动填充到 A1:A100 -lark-cli sheets +range-fill --url "..." --sheet-id "$SID" --source-range "A1:A2" --target-range "A1:A100" --series-type auto +# 用 A1:A2 的序列规律向下填充到 A3:A100(target 区域不能与 source 重叠,否则后端报 source overlaps destination) +lark-cli sheets +range-fill --url "..." --sheet-id "$SID" --source-range "A1:A2" --target-range "A3:A100" --series-type auto ``` ### `+range-sort` @@ -260,4 +260,4 @@ lark-cli sheets +range-sort --url "..." --sheet-id "$SID" --range "A1:E100" --ha - `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`(只行高支持自适应)。 - `DryRun`:所有写操作输出"将要 PATCH 的 range + 受影响 cell 数估算"。 -- `Execute`:写后调用 `+cells-get --range <影响范围>` 抽样回读对比,envelope.meta.verification 沉淀对比结果。 +- `Execute`:写后不自动回读;如需确认,自行调用 `+cells-get --range <影响范围>` 抽样比对。 diff --git a/skills/lark-sheets/references/lark-sheets-search-replace.md b/skills/lark-sheets/references/lark-sheets-search-replace.md index 2272e9a06..9a7e98947 100644 --- a/skills/lark-sheets/references/lark-sheets-search-replace.md +++ b/skills/lark-sheets/references/lark-sheets-search-replace.md @@ -80,8 +80,9 @@ lark-cli sheets +cells-search --spreadsheet-token shtXXX --sheet-id "$SID" \ 输出契约(envelope.data): -- `matched_cells` — 命中 cell 列表,每条含 `cell`(A1)+ `value` + `sheet_id` +- `matches` — 命中 cell 列表,每条含 `address`(A1)+ `value` + `sheet_id` - `total_matches` — 匹配总数 +- `has_more` / `next_offset` — 分页游标(命中数超过单页上限时用于继续读取) ### `+cells-replace` @@ -107,4 +108,4 @@ lark-cli sheets +cells-replace --url "https://example.feishu.cn/sheets/shtXXX" \ - `Validate`:XOR 公共四件套;`--find` 非空;正则模式下 `--find` 必须是合法正则。 - `DryRun`:`+cells-search` 输出请求模板;`+cells-replace` 额外返回预估替换数(`would_replace_count`)。 -- `Execute`:写后自动回读匹配范围抽样验证,`envelope.meta.verification` 给出"预估替换数 vs 实际替换数"对比。 +- `Execute`:写后不自动回读;如需确认,自行用 `+cells-search` 复查旧值是否已不再命中。 diff --git a/skills/lark-sheets/references/lark-sheets-sheet-structure.md b/skills/lark-sheets/references/lark-sheets-sheet-structure.md index 1a8636b50..f833efb33 100644 --- a/skills/lark-sheets/references/lark-sheets-sheet-structure.md +++ b/skills/lark-sheets/references/lark-sheets-sheet-structure.md @@ -207,4 +207,4 @@ lark-cli sheets +dim-freeze --url "..." --sheet-id "$SID" --dimension row --coun - `Validate`:XOR 公共四件套;`--start ≤ --end`;`+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`。 - `DryRun`:写操作输出"将要 PATCH 的 dimension 区间 + 目标参数"。 -- `Execute`:写后自动调用 `+sheet-info --include row_heights,col_widths,hidden_rows,hidden_cols,groups,frozen` 回读对比,envelope.meta.verification 给出受影响的范围。 +- `Execute`:写后不自动回读;如需确认,自行调用 `+sheet-info --include row_heights,col_widths,hidden_rows,hidden_cols,groups,frozen` 查看受影响的范围。 diff --git a/skills/lark-sheets/references/lark-sheets-sparkline.md b/skills/lark-sheets/references/lark-sheets-sparkline.md index 8eadd0978..6c2bea74c 100644 --- a/skills/lark-sheets/references/lark-sheets-sparkline.md +++ b/skills/lark-sheets/references/lark-sheets-sparkline.md @@ -146,4 +146,4 @@ lark-cli sheets +sparkline-delete --url "..." --sheet-id "$SID" --group-id "grpA - `--properties`(仅 `+sparkline-create` / `+sparkline-update`)顶层只接 `config`(同组共享样式)和 `sparklines`(迷你图项数组);`+sparkline-create` 要求每个 `sparklines[i]` 含 `position` 与 `source`(或 `source_range`,二选一)。 - `+sparkline-delete` 强制 `--yes` 或 `--dry-run`。 - `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 sparkline group 请求模板"。 -- `Execute`:写后调用 `+sparkline-list --group-id ` 回读,envelope.meta.verification 给出 `config` / `sparklines` 字段级对比。 +- `Execute`:写后不自动回读;如需确认,自行调用 `+sparkline-list --group-id ` 查看 `config` / `sparklines`。 diff --git a/skills/lark-sheets/references/lark-sheets-workbook.md b/skills/lark-sheets/references/lark-sheets-workbook.md index 3406212cc..514ffb704 100644 --- a/skills/lark-sheets/references/lark-sheets-workbook.md +++ b/skills/lark-sheets/references/lark-sheets-workbook.md @@ -142,7 +142,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ ### `+workbook-info` -输出契约:返回 `sheets[]`,每个含 `sheet_id` / `title` / `row_count` / `column_count` / `frozen_row_count` / `frozen_col_count` / `index` / `hidden`。是操作飞书表格的第一步——任何后续 sheet 级动作都需要先拿这里的 sheet_id。 +输出契约:返回 `sheets[]`,每个含 `sheet_id` / `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。 ### `+sheet-create` @@ -194,4 +194,4 @@ lark-cli sheets +sheet-set-tab-color --url "..." --sheet-id "$SID" --color "#FF0 - `Validate`:XOR 公共四件套;`+sheet-create` 校验 `--title` 非空、`--row-count` ≤ 50000、`--col-count` ≤ 200;`+sheet-delete` 必须 `--yes` 或 `--dry-run`。 - `DryRun`:`+sheet-*` 写操作输出"将要 PATCH 的 sheet metadata";`--sheet-name` 在 dry-run 输出里生成为 `` 占位符,不实际解析为 sheet-id。 -- `Execute`:所有写操作执行后自动调用 `+workbook-info` 回读,envelope.meta.verification 包含目标 sheet 的新状态。 +- `Execute`:写操作不自动回读;如需确认目标 sheet 的新状态,自行调用 `+workbook-info`。 diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index f6282e533..c03d25950 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -426,4 +426,4 @@ lark-cli sheets +csv-put --spreadsheet-token shtXXX --sheet-id "$SID" \ - `Validate`:XOR 公共四件套;`+cells-set` 的 `--cells` 必须能解析为 JSON 二维矩阵且行列数与 `--range` 完全一致;`+cells-set-style` 的样式 flag 至少一个非空(或带 `--border-styles`);`+cells-set-image` 的 `--range` 必须是单 cell(起止 cell 相同);`+csv-put` 的 `--csv` 必须能按 RFC 4180 解析;防爆参数上限校验。 - `DryRun`:输出目标 range + 推断尺寸 + 是否覆盖非空 cell 警告,零网络副作用。 -- `Execute`:写后调用 `+cells-get --range <写入区域> --include value,formula` 抽样回读,envelope.meta.verification 给出"预期 vs 实际"对比。 +- `Execute`:写后不自动回读;如需确认,自行调用 `+cells-get --range <写入区域> --include value,formula` 抽样核对。 From a09593a0fe074e52b301d870c3c0594694455929 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Wed, 27 May 2026 16:11:07 +0800 Subject: [PATCH 072/114] fix(sheets): allow +pivot-create to omit both sheet selectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manage_pivot_table_object treats sheet_id / sheet_name as the placement target — when both are absent, handleCreate() auto-creates a new sub-sheet to host the pivot table. The CLI's flag schema didn't reflect this: - Exposed a third flag --target-sheet-id that mapped to the same wire field as --sheet-id, leaving the caller unsure which one to use - --sheet-id / --sheet-name had "XOR with the other" descriptions that read like "operation context", so callers (especially LLM tool callers) felt obligated to set one — frequently the source sheet — which silently disabled the backend's auto-create guardrail and dropped the pivot at A1, overlapping the source data Wire change (synced from sheet-skill-spec): drop the duplicate --target-sheet-id flag; rewrite --sheet-id / --sheet-name descriptions to make the placement-target semantics explicit and call out that omitting both is the recommended path. Implementation change (this PR): add an at-most-one sheet-selector helper and let object create-shortcuts opt into it. - helpers.go: new optionalSheetSelector (both empty allowed; both set still rejected; control-char validation unchanged). requireSheetSelector is untouched — every existing caller keeps the exactly-one contract. - lark_sheet_object_crud.go: objectCRUDSpec gains allowEmptySheetSelectorOnCreate; objectCreateInput dispatches to optionalSheetSelector when it's set. Only pivotSpec opts in; chart / cond-format / sparkline / filter-view / float-image keep the existing require semantics. DryRun and Execute switch to direct flag extraction (same pattern Validate already used) so the XOR check happens in exactly one place (the builder). - pivotSpec: drop the enhanceCreateInput branch that read the now-removed --target-sheet-id flag. - Tests: TestPivotCreate_SheetSelectorSemantics covers both-empty / both-set / single-set; TestObjectCreate_RequiresSheetSelector regresses chart / cond-format / sparkline / filter-view to lock the scope of the relaxation. --- shortcuts/sheets/data/flag-defs.json | 11 +- shortcuts/sheets/helpers.go | 27 ++++ shortcuts/sheets/lark_sheet_object_crud.go | 52 +++++--- .../sheets/lark_sheet_object_crud_test.go | 123 +++++++++++++++++- .../references/lark-sheets-pivot-table.md | 1 - 5 files changed, 181 insertions(+), 33 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index abd051074..b58b426b6 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -3244,14 +3244,14 @@ "kind": "public", "type": "string", "required": "xor", - "desc": "Sheet reference_id (XOR with `--sheet-name`)" + "desc": "Reference_id of the sub-sheet where the pivot table is located / will be created to (mutually exclusive with --sheet-name; takes priority when both given; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended)" }, { "name": "sheet-name", "kind": "public", "type": "string", "required": "xor", - "desc": "Sheet name (XOR with `--sheet-id`)" + "desc": "Name of the sub-sheet where the pivot table is located / will be created to (mutually exclusive with --sheet-id; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended)" }, { "name": "properties", @@ -3264,13 +3264,6 @@ "stdin" ] }, - { - "name": "target-sheet-id", - "kind": "own", - "type": "string", - "required": "optional", - "desc": "Destination sub-sheet id for the pivot table; auto-creates a new sub-sheet when omitted (recommended)" - }, { "name": "target-position", "kind": "own", diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go index 64206b9e2..28fcc1d5c 100644 --- a/shortcuts/sheets/helpers.go +++ b/shortcuts/sheets/helpers.go @@ -133,6 +133,33 @@ func requireSheetSelector(sheetID, sheetName string) error { return nil } +// optionalSheetSelector is the "at most one" counterpart of +// requireSheetSelector: both empty is acceptable (the backend tool then +// decides what to do — e.g. manage_pivot_table_object auto-creates a new +// sub-sheet to host the pivot), and both set is rejected. Control-char +// validation still applies whenever a value is provided. +// +// Used by shortcuts whose backend tool treats sheet_id/sheet_name as the +// placement target rather than the operation context (currently only +// +pivot-create). Other shortcuts continue to use requireSheetSelector. +func optionalSheetSelector(sheetID, sheetName string) error { + sheetID = strings.TrimSpace(sheetID) + sheetName = strings.TrimSpace(sheetName) + if sheetID != "" && sheetName != "" { + return common.FlagErrorf("--sheet-id and --sheet-name are mutually exclusive") + } + if sheetID != "" { + if err := validate.RejectControlChars(sheetID, "sheet-id"); err != nil { + return common.FlagErrorf("%v", err) + } + } else if sheetName != "" { + if err := validate.RejectControlChars(sheetName, "sheet-name"); err != nil { + return common.FlagErrorf("%v", err) + } + } + return nil +} + // sheetSelectorForToolInput packs --sheet-id / --sheet-name into the tool // input map, omitting empty fields. Use after resolveSheetSelector returns. func sheetSelectorForToolInput(input map[string]interface{}, sheetID, sheetName string) { diff --git a/shortcuts/sheets/lark_sheet_object_crud.go b/shortcuts/sheets/lark_sheet_object_crud.go index 9742c52c7..69ac31dbd 100644 --- a/shortcuts/sheets/lark_sheet_object_crud.go +++ b/shortcuts/sheets/lark_sheet_object_crud.go @@ -27,10 +27,11 @@ import ( // the surface narrow even though everything funnels through one tool). // // Five of the seven objects share the factory below (newObjectCRUDShortcuts). -// pivot adds optional --target-sheet-id / --target-position on create, -// declared with extraCreateFlags. filter is special-cased further down -// (no separate id flag — filter_id is implicit per sheet — and --range is -// a first-class create flag, not buried in --data). +// pivot opts into allowEmptySheetSelectorOnCreate=true so the backend can +// auto-create a placement sub-sheet when neither --sheet-id nor --sheet-name +// is given; it also exposes optional --target-position on create. filter is +// special-cased further down (no separate id flag — filter_id is implicit +// per sheet — and --range is a first-class create flag, not buried in --data). // objectCRUDSpec describes a 3-shortcut create/update/delete cluster. // idFlag / idField empty → no per-object id flag (only filter uses that @@ -54,6 +55,13 @@ type objectCRUDSpec struct { // +sparkline-list instead of letting the caller hit an opaque // server-side rejection). validateUpdateInput func(input map[string]interface{}) error + // allowEmptySheetSelectorOnCreate, when true, makes the *create* + // shortcut accept empty --sheet-id / --sheet-name (backend then picks + // the placement target — e.g. manage_pivot_table_object auto-creates + // a sub-sheet to host the pivot). Both flags being set is still + // rejected. Update/delete continue to require an explicit selector. + // Today only pivotSpec opts in. + allowEmptySheetSelectorOnCreate bool } func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { @@ -79,7 +87,8 @@ func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) - sheetID, sheetName, _ := resolveSheetSelector(runtime) + sheetID := strings.TrimSpace(runtime.Str("sheet-id")) + sheetName := strings.TrimSpace(runtime.Str("sheet-name")) input, _ := objectCreateInput(runtime, token, sheetID, sheetName, spec) return invokeToolDryRun(token, ToolKindWrite, spec.toolName, input) }, @@ -88,10 +97,8 @@ func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { if err != nil { return err } - sheetID, sheetName, err := resolveSheetSelector(runtime) - if err != nil { - return err - } + sheetID := strings.TrimSpace(runtime.Str("sheet-id")) + sheetName := strings.TrimSpace(runtime.Str("sheet-name")) input, err := objectCreateInput(runtime, token, sheetID, sheetName, spec) if err != nil { return err @@ -107,7 +114,13 @@ func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { } func objectCreateInput(runtime flagView, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) { - if err := requireSheetSelector(sheetID, sheetName); err != nil { + var err error + if spec.allowEmptySheetSelectorOnCreate { + err = optionalSheetSelector(sheetID, sheetName) + } else { + err = requireSheetSelector(sheetID, sheetName) + } + if err != nil { return nil, err } props, err := requireJSONObject(runtime, "properties") @@ -288,17 +301,18 @@ var ChartCreate = newObjectCreateShortcut(chartSpec) var ChartUpdate = newObjectUpdateShortcut(chartSpec) var ChartDelete = newObjectDeleteShortcut(chartSpec) -// pivot — create exposes --target-sheet-id / --target-position (top-level -// of the tool input) plus --source / --range hoisted from properties. +// pivot — create exposes --target-position (top-level of the tool input) +// plus --source / --range hoisted from properties. --sheet-id / --sheet-name +// are the placement target (where the pivot table lands); the backend +// auto-creates a new sub-sheet when both are omitted, so create opts into +// allowEmptySheetSelectorOnCreate. var pivotSpec = objectCRUDSpec{ - commandPrefix: "+pivot", - toolName: "manage_pivot_table_object", - idFlag: "pivot-table-id", - idField: "pivot_table_id", + commandPrefix: "+pivot", + toolName: "manage_pivot_table_object", + idFlag: "pivot-table-id", + idField: "pivot_table_id", + allowEmptySheetSelectorOnCreate: true, enhanceCreateInput: func(rt flagView, input map[string]interface{}) { - if v := strings.TrimSpace(rt.Str("target-sheet-id")); v != "" { - input["target_sheet_id"] = v - } if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" { input["target_position"] = v } diff --git a/shortcuts/sheets/lark_sheet_object_crud_test.go b/shortcuts/sheets/lark_sheet_object_crud_test.go index 5220d24b2..aafc6dc87 100644 --- a/shortcuts/sheets/lark_sheet_object_crud_test.go +++ b/shortcuts/sheets/lark_sheet_object_crud_test.go @@ -51,16 +51,20 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { "properties": map[string]interface{}{"type": "bar"}, }, }, - // pivot — has extra create flags incl. required --source + // pivot — has extra create flags incl. required --source. + // --sheet-id is the placement target (where the pivot lands); + // pivotSpec.allowEmptySheetSelectorOnCreate lets both --sheet-id + // and --sheet-name be omitted so the backend auto-creates a + // sub-sheet — covered separately in the +pivot-create empty- + // selector / mutex tests below. { - name: "+pivot-create with target / source / range flags", + name: "+pivot-create with placement / source / range flags", sc: PivotCreate, args: []string{ "--url", testURL, "--sheet-id", testSheetID, "--properties", `{"rows":[{"field":"A"}]}`, "--source", "Sheet1!A1:F1000", "--range", "F1", - "--target-sheet-id", "sh2", "--target-position", "B5", }, toolName: "manage_pivot_table_object", @@ -68,7 +72,6 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { "excel_id": testToken, "sheet_id": testSheetID, "operation": "create", - "target_sheet_id": "sh2", "target_position": "B5", "properties": map[string]interface{}{ "rows": []interface{}{map[string]interface{}{"field": "A"}}, @@ -77,6 +80,26 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { }, }, }, + // +pivot-create accepts both sheet selectors empty — backend + // auto-creates a placement sub-sheet. + { + name: "+pivot-create empty --sheet-id / --sheet-name omits sheet from input", + sc: PivotCreate, + args: []string{ + "--url", testURL, + "--properties", `{"rows":[{"field":"A"}]}`, + "--source", "Sheet1!A1:F1000", + }, + toolName: "manage_pivot_table_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "create", + "properties": map[string]interface{}{ + "rows": []interface{}{map[string]interface{}{"field": "A"}}, + "source": "Sheet1!A1:F1000", + }, + }, + }, { name: "+pivot-delete", sc: PivotDelete, @@ -325,6 +348,98 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { } } +// TestPivotCreate_SheetSelectorSemantics locks in the "at most one" +// semantics for +pivot-create (and only +pivot-create): both --sheet-id +// and --sheet-name may be omitted (backend auto-creates a placement +// sub-sheet), but passing both is rejected. +// +// Companion regression — TestObjectCreate_RequiresSheetSelector below — +// confirms every other *-create still rejects empty selector. +func TestPivotCreate_SheetSelectorSemantics(t *testing.T) { + t.Parallel() + + t.Run("both empty is accepted", func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, PivotCreate, []string{ + "--url", testURL, + "--properties", `{"rows":[{"field":"A"}]}`, + "--source", "Sheet1!A1:F1000", + }) + input := decodeToolInput(t, body, "manage_pivot_table_object") + if _, ok := input["sheet_id"]; ok { + t.Errorf("expected no sheet_id in input; got %v", input["sheet_id"]) + } + if _, ok := input["sheet_name"]; ok { + t.Errorf("expected no sheet_name in input; got %v", input["sheet_name"]) + } + }) + + t.Run("both set is rejected", func(t *testing.T) { + t.Parallel() + _, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{ + "--url", testURL, + "--sheet-id", testSheetID, + "--sheet-name", "Sheet1", + "--properties", `{"rows":[{"field":"A"}]}`, + "--source", "Sheet1!A1:F1000", + }) + if err == nil { + t.Fatalf("expected CLI to reject both --sheet-id and --sheet-name set; stderr=%s", stderr) + } + combined := stderr + err.Error() + if !strings.Contains(combined, "mutually exclusive") { + t.Errorf("expected error to say 'mutually exclusive'; got=%s|%v", stderr, err) + } + }) + + t.Run("only sheet-id is accepted", func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, PivotCreate, []string{ + "--url", testURL, + "--sheet-id", testSheetID, + "--properties", `{"rows":[{"field":"A"}]}`, + "--source", "Sheet1!A1:F1000", + }) + input := decodeToolInput(t, body, "manage_pivot_table_object") + if got, _ := input["sheet_id"].(string); got != testSheetID { + t.Errorf("sheet_id = %q, want %q", got, testSheetID) + } + }) +} + +// TestObjectCreate_RequiresSheetSelector regresses the non-pivot create +// shortcuts: pivot-create is the only one whose spec sets +// allowEmptySheetSelectorOnCreate=true. Every other *-create must still +// reject empty --sheet-id / --sheet-name (this is the guardrail that +// keeps the change minimally scoped). +func TestObjectCreate_RequiresSheetSelector(t *testing.T) { + t.Parallel() + cases := []struct { + name string + sc common.Shortcut + args []string // omit sheet selector flags on purpose + }{ + {"chart", ChartCreate, []string{"--url", testURL, "--properties", `{"type":"line"}`}}, + {"cond-format", CondFormatCreate, []string{"--url", testURL, "--properties", `{"attrs":[]}`, "--rule-type", "cellIs", "--ranges", `["A1:A10"]`}}, + {"sparkline", SparklineCreate, []string{"--url", testURL, "--properties", `{"sparklines":[]}`}}, + {"filter-view", FilterViewCreate, []string{"--url", testURL, "--properties", `{}`, "--range", "A1:F10"}}, + } + for _, tt := range cases { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args) + if err == nil { + t.Fatalf("expected CLI to reject empty sheet selector for +%s-create; stderr=%s", tt.name, stderr) + } + combined := stderr + err.Error() + if !strings.Contains(combined, "specify at least one of --sheet-id or --sheet-name") { + t.Errorf("expected 'specify at least one of --sheet-id or --sheet-name'; got=%s|%v", stderr, err) + } + }) + } +} + // TestSparklineUpdate_MissingSparklineID confirms the standalone-path // pre-check fires: +sparkline-update with properties.sparklines[] but no // per-item sparkline_id must fail CLI-side with a pointer to diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md index f6edb4173..f78c36d82 100644 --- a/skills/lark-sheets/references/lark-sheets-pivot-table.md +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -62,7 +62,6 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--properties` | string + File + Stdin(复合 JSON) | required | JSON:{"rows":[...],"columns":[...],"values":[...],"filters":[...],"show_row_grand_total":true,"show_col_grand_total":true}(数据源走 --source,不要再放进 properties.source) | -| `--target-sheet-id` | string | optional | 透视表落点子表 id;省略时自动新建子表(推荐) | | `--target-position` | string | optional | 透视表落点子表内的起始 cell(A1 格式,如 `A1`),与 `--target-sheet-id` 配套、映射到顶层 `target_position`,默认 `A1`(值为 A1 时不下发)。它与 `--range` 都表达落点但落在不同 wire 字段,避免两者同时给冲突值 | | `--source` | string | required | 透视表源数据区域(A1 表示法,格式 `SheetName!StartCell:EndCell`,如 `Sheet1!A1:D100`) | | `--range` | string | optional | 透视表左上角放置位置(A1 单值,如 `F1`,仅 create 生效),映射到 `properties.range`;省略时放在落点子表(默认新建子表)的左上角。它与 `--target-position` 都表达落点但落在不同 wire 字段,避免两者同时给冲突值 | From ce5878e3c4d0dd33ff6647a2b326ec25414beec1 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Wed, 27 May 2026 19:16:04 +0800 Subject: [PATCH 073/114] docs(sheets): clarify filter/filter-view rules update is whole-set PUT Synced from upstream tools-schema. The rules field on manage_filter_object and manage_filter_view_object now documents update as whole-set PUT semantics: submitted rules become the complete rule set, all existing columns' rules are cleared first, columns not listed lose their old rules (no merge), and [] clears everything. Description-only change, no structural/field change. --- shortcuts/sheets/data/flag-schemas.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json index 4a909314d..bdcd9abe0 100644 --- a/shortcuts/sheets/data/flag-schemas.json +++ b/shortcuts/sheets/data/flag-schemas.json @@ -3634,7 +3634,7 @@ }, "rules": { "type": "array", - "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。update 时传空数组 [] 表示清空所有现有列的规则。", + "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。update 为整组覆盖(PUT 语义):传入的 rules 即更新后的完整规则集,会先清空所有现有列的旧规则再应用,未列出的列其旧规则会被清除(不与旧规则合并);传空数组 [] 即清空所有列的规则。", "items": { "type": "object", "description": "单列筛选规则。", @@ -3879,7 +3879,7 @@ }, "rules": { "type": "array", - "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。update 时传空数组 [] 表示清空所有现有列的规则。", + "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。update 为整组覆盖(PUT 语义):传入的 rules 即更新后的完整规则集,会先清空所有现有列的旧规则再应用,未列出的列其旧规则会被清除(不与旧规则合并);传空数组 [] 即清空所有列的规则。", "items": { "type": "object", "description": "单列筛选规则。", @@ -4128,7 +4128,7 @@ }, "rules": { "type": "array", - "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。结构与 manage_filter_object.properties.rules 完全一致。", + "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。结构与 manage_filter_object.properties.rules 完全一致。update 同样为整组覆盖(PUT 语义):传入的 rules 整组替换该视图所有列的规则,未列出的列其旧规则会被清除(不与旧规则合并),传空数组 [] 即清空全部。", "items": { "type": "object", "description": "单列筛选规则。", @@ -4373,7 +4373,7 @@ }, "rules": { "type": "array", - "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。结构与 manage_filter_object.properties.rules 完全一致。", + "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。结构与 manage_filter_object.properties.rules 完全一致。update 同样为整组覆盖(PUT 语义):传入的 rules 整组替换该视图所有列的规则,未列出的列其旧规则会被清除(不与旧规则合并),传空数组 [] 即清空全部。", "items": { "type": "object", "description": "单列筛选规则。", From 69d28511636fd9ccfa2b001ac4fc762bc787dd9c Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Wed, 27 May 2026 19:36:07 +0800 Subject: [PATCH 074/114] refactor(sheets): switch dim-* / rows-cols-resize to A1-string range schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 9 row/column-region shortcuts used to share two int flags --start / --end with inconsistent end semantics across commands — +dim-insert / -delete / -hide / -unhide / -group / -ungroup treated --end as exclusive, while +dim-move / +rows-resize / +cols-resize treated it as inclusive. The skill reference even called this out as "the highest-frequency off-by-one source", patched in docs rather than at the surface. Three underlying tool schemas (position+count, A1 range string, 0-based int pair) were all flattened onto the same --start/--end pair, which forced a different normaliser per command and pushed mental math (count = end - start) onto every caller. Schema (sourced from base, regenerated via sheet-skill-spec, mirrored into shortcuts/sheets/data/ and skills/lark-sheets/): +dim-insert --position + --count rows: "3"; columns: "C". --count rows/columns inserted *before* --position. +dim-delete / -hide / -unhide / -group / -ungroup --range +rows-resize / +cols-resize --range A1 closed range. Rows: "3:7" or "5". Columns: "C:F" or "C". Mixing letters and digits in one range is rejected. +dim-move --source-range + --target --target must match --source-range's dimension (both row or both column). The move places the source block *before* --target. Wire-shape preserved: modify_sheet_structure still receives `position` + `count` (insert) or a `range` A1 string (other dim-* ops); v3 move_dimension still receives 0-based inclusive ints (CLI parses the A1 strings into them); resize_range still receives a two-sided A1 range (single-element form is expanded to "N:N" before send). This is a flag-surface break (--start / --end / --dimension flags removed from these 9 shortcuts); --dimension stays only on +dim-freeze since it has no range to derive from. Code: A1 parser added (parseA1Range / parseA1Position / letterToColumnIndex reused from write_cells); dimRange / dimRangeFull / dimPosition deleted; dim-move switches to source-range + target parsing; resize gains a same-dimension guard so +rows-resize rejects "A:C" with a clear "+rows-resize expects row numbers" message. Tests: TestSheetStructureShortcuts_DryRun / TestDimMove_DryRun / TestDimMove_Column / TestDimMove_MismatchedDimension / TestDimRange_Validation / TestParseA1Range / TestResize_TypeAndSizeGuards / TestRangeOperationsShortcuts_DryRun all rewritten against the new schema. Batch contract trio (BodyMatchesStandalone / ErrorEquivalence / RejectsBadSubOpInput) and TestBatchOp_DispatchCoversReportedBugs likewise. Full `go test ./shortcuts/sheets/` passes. --- shortcuts/sheets/batch_op_contract_test.go | 54 ++-- shortcuts/sheets/data/flag-defs.json | 235 ++++-------------- shortcuts/sheets/execute_paths_test.go | 7 +- .../sheets/lark_sheet_batch_update_test.go | 6 +- .../sheets/lark_sheet_range_operations.go | 60 +++-- .../lark_sheet_range_operations_test.go | 50 ++-- .../sheets/lark_sheet_sheet_structure.go | 230 ++++++++++------- .../sheets/lark_sheet_sheet_structure_test.go | 185 +++++++++++--- .../lark-sheets-range-operations.md | 18 +- .../references/lark-sheets-sheet-structure.md | 92 +++---- 10 files changed, 511 insertions(+), 426 deletions(-) diff --git a/shortcuts/sheets/batch_op_contract_test.go b/shortcuts/sheets/batch_op_contract_test.go index 2335db416..ba4fa9cf9 100644 --- a/shortcuts/sheets/batch_op_contract_test.go +++ b/shortcuts/sheets/batch_op_contract_test.go @@ -72,20 +72,20 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) { { shortcut: "+dim-insert", sc: DimInsert, - args: []string{"--sheet-id", "sh1", "--dimension", "row", "--start", "10", "--end", "12", "--inherit-style", "before"}, - subInput: `{"sheet-id":"sh1","dimension":"row","start":10,"end":12,"inherit-style":"before"}`, + args: []string{"--sheet-id", "sh1", "--position", "11", "--count", "2", "--inherit-style", "before"}, + subInput: `{"sheet-id":"sh1","position":"11","count":2,"inherit-style":"before"}`, }, { shortcut: "+dim-delete", sc: DimDelete, - args: []string{"--sheet-id", "sh1", "--dimension", "column", "--start", "2", "--end", "4"}, - subInput: `{"sheet-id":"sh1","dimension":"column","start":2,"end":4}`, + args: []string{"--sheet-id", "sh1", "--range", "C:D"}, + subInput: `{"sheet-id":"sh1","range":"C:D"}`, }, { shortcut: "+dim-hide", sc: DimHide, - args: []string{"--sheet-id", "sh1", "--dimension", "row", "--start", "1", "--end", "3"}, - subInput: `{"sheet-id":"sh1","dimension":"row","start":1,"end":3}`, + args: []string{"--sheet-id", "sh1", "--range", "2:3"}, + subInput: `{"sheet-id":"sh1","range":"2:3"}`, }, { shortcut: "+dim-freeze", @@ -96,20 +96,20 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) { { shortcut: "+dim-group", sc: DimGroup, - args: []string{"--sheet-id", "sh1", "--dimension", "row", "--start", "1", "--end", "5", "--group-state", "fold"}, - subInput: `{"sheet-id":"sh1","dimension":"row","start":1,"end":5,"group-state":"fold"}`, + args: []string{"--sheet-id", "sh1", "--range", "2:5", "--group-state", "fold"}, + subInput: `{"sheet-id":"sh1","range":"2:5","group-state":"fold"}`, }, { shortcut: "+rows-resize", sc: RowsResize, - args: []string{"--sheet-id", "sh1", "--start", "0", "--end", "0", "--type", "pixel", "--size", "30"}, - subInput: `{"sheet-id":"sh1","start":0,"end":0,"type":"pixel","size":30}`, + args: []string{"--sheet-id", "sh1", "--range", "1", "--type", "pixel", "--size", "30"}, + subInput: `{"sheet-id":"sh1","range":"1","type":"pixel","size":30}`, }, { shortcut: "+cols-resize", sc: ColsResize, - args: []string{"--sheet-id", "sh1", "--start", "1", "--end", "3", "--type", "standard"}, - subInput: `{"sheet-id":"sh1","start":1,"end":3,"type":"standard"}`, + args: []string{"--sheet-id", "sh1", "--range", "B:D", "--type", "standard"}, + subInput: `{"sheet-id":"sh1","range":"B:D","type":"standard"}`, }, { shortcut: "+range-move", @@ -377,25 +377,25 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) { { name: "+dim-insert missing sheet selector", shortcut: DimInsert, - args: []string{"--dimension", "row", "--start", "0", "--end", "1"}, + args: []string{"--position", "1", "--count", "1"}, subShortcut: "+dim-insert", - subInput: `{"dimension":"row","start":0,"end":1}`, + subInput: `{"position":"1","count":1}`, wantContains: "specify at least one of --sheet-id or --sheet-name", }, { - name: "+dim-insert --end <= --start", + name: "+dim-insert count <= 0", shortcut: DimInsert, - args: []string{"--sheet-id", "sh1", "--dimension", "row", "--start", "5", "--end", "3"}, + args: []string{"--sheet-id", "sh1", "--position", "5", "--count", "0"}, subShortcut: "+dim-insert", - subInput: `{"sheet-id":"sh1","dimension":"row","start":5,"end":3}`, - wantContains: "must be greater than --start", + subInput: `{"sheet-id":"sh1","position":"5","count":0}`, + wantContains: "--count must be > 0", }, { name: "+rows-resize --type pixel without --size", shortcut: RowsResize, - args: []string{"--sheet-id", "sh1", "--start", "0", "--end", "1", "--type", "pixel"}, + args: []string{"--sheet-id", "sh1", "--range", "1:2", "--type", "pixel"}, subShortcut: "+rows-resize", - subInput: `{"sheet-id":"sh1","start":0,"end":1,"type":"pixel"}`, + subInput: `{"sheet-id":"sh1","range":"1:2","type":"pixel"}`, wantContains: "--type pixel requires --size", }, { @@ -485,15 +485,15 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) { "--range is required", }, { - "+dim-insert missing --dimension", + "+dim-insert missing --position", "+dim-insert", - `{"sheet-id":"sh1","start":0,"end":1}`, - "--dimension is required", + `{"sheet-id":"sh1","count":1}`, + "--position is required", }, { "+rows-resize missing --type", "+rows-resize", - `{"sheet-id":"sh1","start":0,"end":0}`, + `{"sheet-id":"sh1","range":"1:1"}`, "--type is required", }, { @@ -622,10 +622,12 @@ func TestBatchOp_DispatchCoversReportedBugs(t *testing.T) { t.Errorf("+range-copy operation = %v, want copy", copyIn["operation"]) } - // +rows-resize → resize_range with range + resize_height (not raw start/end). + // +rows-resize → resize_range with range + resize_height. The CLI's single + // "23" input must be expanded to "23:23" because resize_range rejects + // bare single-element ranges. body = parseDryRunBody(t, BatchUpdate, []string{ "--url", testURL, - "--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","start":22,"end":22,"type":"pixel","size":40}}]`, + "--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","range":"23","type":"pixel","size":40}}]`, "--yes", }) ops = decodeToolInput(t, body, "batch_update")["operations"].([]interface{}) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index b58b426b6..eb221c4c9 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -608,42 +608,31 @@ "desc": "Sheet name (XOR with `--sheet-id`)" }, { - "name": "dimension", + "name": "inherit-style", "kind": "own", "type": "string", - "required": "required", - "desc": "Dimension (row or column)", + "required": "optional", + "desc": "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)", + "default": "none", "enum": [ - "row", - "column" + "before", + "after", + "none" ] }, { - "name": "start", + "name": "position", "kind": "own", - "type": "int", + "type": "string", "required": "required", - "desc": "Insert start position (0-based)" + "desc": "Insert position (1-based row number like `3` or column letter like `C`); new rows/columns are inserted *before* this position" }, { - "name": "end", + "name": "count", "kind": "own", "type": "int", "required": "required", - "desc": "Insert end position (exclusive)" - }, - { - "name": "inherit-style", - "kind": "own", - "type": "string", - "required": "optional", - "desc": "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)", - "default": "none", - "enum": [ - "before", - "after", - "none" - ] + "desc": "Number of rows/columns to insert (must be > 0)" }, { "name": "dry-run", @@ -686,29 +675,11 @@ "desc": "Sheet name (XOR with `--sheet-id`)" }, { - "name": "dimension", + "name": "range", "kind": "own", "type": "string", "required": "required", - "desc": "Dimension (row or column)", - "enum": [ - "row", - "column" - ] - }, - { - "name": "start", - "kind": "own", - "type": "int", - "required": "required", - "desc": "Start position (0-based)" - }, - { - "name": "end", - "kind": "own", - "type": "int", - "required": "required", - "desc": "End position (exclusive)" + "desc": "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`" }, { "name": "yes", @@ -758,29 +729,11 @@ "desc": "Sheet name (XOR with `--sheet-id`)" }, { - "name": "dimension", + "name": "range", "kind": "own", "type": "string", "required": "required", - "desc": "Dimension (row or column)", - "enum": [ - "row", - "column" - ] - }, - { - "name": "start", - "kind": "own", - "type": "int", - "required": "required", - "desc": "Start position (0-based)" - }, - { - "name": "end", - "kind": "own", - "type": "int", - "required": "required", - "desc": "End position (exclusive)" + "desc": "Row/column closed range to hide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`" }, { "name": "dry-run", @@ -823,29 +776,11 @@ "desc": "Sheet name (XOR with `--sheet-id`)" }, { - "name": "dimension", + "name": "range", "kind": "own", "type": "string", "required": "required", - "desc": "Dimension (row or column)", - "enum": [ - "row", - "column" - ] - }, - { - "name": "start", - "kind": "own", - "type": "int", - "required": "required", - "desc": "Start position (0-based)" - }, - { - "name": "end", - "kind": "own", - "type": "int", - "required": "required", - "desc": "End position (exclusive)" + "desc": "Row/column closed range to unhide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`" }, { "name": "dry-run", @@ -945,31 +880,6 @@ "required": "xor", "desc": "Sheet name (XOR with `--sheet-id`)" }, - { - "name": "dimension", - "kind": "own", - "type": "string", - "required": "required", - "desc": "Dimension (row or column)", - "enum": [ - "row", - "column" - ] - }, - { - "name": "start", - "kind": "own", - "type": "int", - "required": "required", - "desc": "Start position (0-based)" - }, - { - "name": "end", - "kind": "own", - "type": "int", - "required": "required", - "desc": "End position (exclusive)" - }, { "name": "depth", "kind": "own", @@ -990,6 +900,13 @@ "fold" ] }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Row/column closed range to group; rows use 1-based numbers like `3:7`, columns use letters like `C:F`" + }, { "name": "dry-run", "kind": "system", @@ -1030,31 +947,6 @@ "required": "xor", "desc": "Sheet name (XOR with `--sheet-id`)" }, - { - "name": "dimension", - "kind": "own", - "type": "string", - "required": "required", - "desc": "Dimension (row or column)", - "enum": [ - "row", - "column" - ] - }, - { - "name": "start", - "kind": "own", - "type": "int", - "required": "required", - "desc": "Start position (0-based)" - }, - { - "name": "end", - "kind": "own", - "type": "int", - "required": "required", - "desc": "End position (exclusive)" - }, { "name": "depth", "kind": "own", @@ -1063,6 +955,13 @@ "desc": "Group nesting level to ungroup; default 1 (outermost)", "default": "1" }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`" + }, { "name": "dry-run", "kind": "system", @@ -1104,36 +1003,18 @@ "desc": "Sheet name (XOR with `--sheet-id`)" }, { - "name": "dimension", + "name": "source-range", "kind": "own", "type": "string", "required": "required", - "desc": "Dimension (row or column)", - "enum": [ - "row", - "column" - ] - }, - { - "name": "start", - "kind": "own", - "type": "int", - "required": "required", - "desc": "Source range start position (0-based)" - }, - { - "name": "end", - "kind": "own", - "type": "int", - "required": "required", - "desc": "Source range end position (inclusive)" + "desc": "Source row/column closed range to move; rows use 1-based numbers like `3:7`, columns use letters like `C:F`" }, { "name": "target", "kind": "own", - "type": "int", + "type": "string", "required": "required", - "desc": "Destination position (move target inserts before this index; 0-based)" + "desc": "Destination position (the moved rows/columns are placed *before* this position); rows use 1-based row number like `12`, columns use column letter like `H`. Must match the dimension of --source-range" }, { "name": "dry-run", @@ -2201,20 +2082,6 @@ "required": "xor", "desc": "Sheet name (XOR with `--sheet-id`)" }, - { - "name": "start", - "kind": "own", - "type": "int", - "required": "required", - "desc": "Start row (0-based, inclusive)" - }, - { - "name": "end", - "kind": "own", - "type": "int", - "required": "required", - "desc": "End row (0-based, inclusive)" - }, { "name": "type", "kind": "own", @@ -2235,6 +2102,13 @@ "desc": "Row height in pixels (e.g. 30 / 40 / 60); required when `--type pixel`, ignored otherwise", "default": "0" }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row)" + }, { "name": "dry-run", "kind": "system", @@ -2275,20 +2149,6 @@ "required": "xor", "desc": "Sheet name (XOR with `--sheet-id`)" }, - { - "name": "start", - "kind": "own", - "type": "int", - "required": "required", - "desc": "Start column (0-based, inclusive)" - }, - { - "name": "end", - "kind": "own", - "type": "int", - "required": "required", - "desc": "End column (0-based, inclusive)" - }, { "name": "type", "kind": "own", @@ -2308,6 +2168,13 @@ "desc": "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise", "default": "0" }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Column closed range to resize; column letters like `A:E` or `C` (single column)" + }, { "name": "dry-run", "kind": "system", @@ -3269,7 +3136,7 @@ "kind": "own", "type": "string", "required": "optional", - "desc": "Top-left cell within the target sub-sheet (A1 notation, e.g. `A1`); pairs with `--target-sheet-id`, maps to the top-level `target_position`, default `A1` (not sent when the value is A1). It and `--range` both express placement but map to different wire fields — avoid passing conflicting values for both.", + "desc": "Top-left cell within the target sub-sheet (A1 notation, e.g. `A1`); maps to the top-level `target_position`, default `A1` (not sent when the value is A1). It and `--range` both express placement but map to different wire fields — avoid passing conflicting values for both.", "default": "A1" }, { diff --git a/shortcuts/sheets/execute_paths_test.go b/shortcuts/sheets/execute_paths_test.go index 8b08aaed2..3d5a5bd3b 100644 --- a/shortcuts/sheets/execute_paths_test.go +++ b/shortcuts/sheets/execute_paths_test.go @@ -317,8 +317,9 @@ func TestExecute_WorkbookCreate(t *testing.T) { } // TestExecute_DimMove covers the native v3 move_dimension call. CLI's -// 0-based inclusive --start/--end pass straight through to v3's -// source.{start_index,end_index} (also 0-based inclusive). +// --source-range "1:3" (1-based inclusive) is parsed into v3's +// source.{start_index=0,end_index=2} (0-based inclusive); --target "11" is +// parsed into destination_index=10. func TestExecute_DimMove(t *testing.T) { t.Parallel() move := &httpmock.Stub{ @@ -332,7 +333,7 @@ func TestExecute_DimMove(t *testing.T) { } _, err := runShortcutWithStubs(t, DimMove, []string{ "--url", testURL, "--sheet-id", testSheetID, - "--dimension", "row", "--start", "0", "--end", "2", "--target", "10", + "--source-range", "1:3", "--target", "11", }, move) if err != nil { t.Fatalf("execute failed: %v", err) diff --git a/shortcuts/sheets/lark_sheet_batch_update_test.go b/shortcuts/sheets/lark_sheet_batch_update_test.go index f190c8d1e..b216a9c0c 100644 --- a/shortcuts/sheets/lark_sheet_batch_update_test.go +++ b/shortcuts/sheets/lark_sheet_batch_update_test.go @@ -20,7 +20,7 @@ func TestBatchUpdate_TranslatesShortcutToToolName(t *testing.T) { "--url", testURL, "--operations", `[ {"shortcut":"+cells-set","input":{"sheet_id":"sh1","range":"A1","cells":[[{"value":42}]]}}, - {"shortcut":"+dim-insert","input":{"sheet_id":"sh1","dimension":"row","start":0,"end":3}} + {"shortcut":"+dim-insert","input":{"sheet_id":"sh1","position":"1","count":3}} ]`, "--continue-on-error", "--yes", @@ -353,7 +353,7 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) { }, { name: "user filled operation manually", - opsJSON: `[{"shortcut":"+dim-insert","input":{"operation":"delete","range":"1:1"}}]`, + opsJSON: `[{"shortcut":"+dim-insert","input":{"operation":"delete","position":"1","count":1}}]`, wantMatch: "do not pass input.operation", }, { @@ -430,7 +430,7 @@ func TestBatchUpdate_ResizeNoOperationField(t *testing.T) { t.Parallel() body := parseDryRunBody(t, BatchUpdate, []string{ "--url", testURL, - "--operations", `[{"shortcut":"+rows-resize","input":{"sheet_id":"sh1","start":0,"end":2,"type":"pixel","size":30}}]`, + "--operations", `[{"shortcut":"+rows-resize","input":{"sheet_id":"sh1","range":"1:3","type":"pixel","size":30}}]`, "--yes", }) input := decodeToolInput(t, body, "batch_update") diff --git a/shortcuts/sheets/lark_sheet_range_operations.go b/shortcuts/sheets/lark_sheet_range_operations.go index 2e00c11d5..44e8b4eef 100644 --- a/shortcuts/sheets/lark_sheet_range_operations.go +++ b/shortcuts/sheets/lark_sheet_range_operations.go @@ -180,25 +180,24 @@ func mergeInput(runtime flagView, token, sheetID, sheetName, op string, withMerg return input, nil } -// resize_range now exposes two CLI shortcuts: +// resize_range exposes two CLI shortcuts: // -// +rows-resize / +cols-resize — set row heights / column widths. The new -// --type enum (pixel / standard / [auto]) replaces the old --size/--reset -// pair; --type pixel still takes a --size pixel value, --type standard -// restores the sheet default, --type auto auto-fits row heights (rows only). +// +rows-resize / +cols-resize — set row heights / column widths. --type +// enum (pixel / standard / [auto]) controls how: --type pixel needs --size, +// --type standard restores the sheet default, --type auto auto-fits row +// heights (rows only). --range is an A1 closed range ("2:10" / "5" rows or +// "A:E" / "C" columns); single-element form is expanded to "N:N" before +// send because resize_range rejects bare single-element ranges. // // Wire shape: resize_height / resize_width carries { type, value? }, e.g. // { "type": "pixel", "value": 30 } or { "type": "standard" }. -// -// Both shortcuts share the underlying resize_range tool; --end is inclusive -// in the new CLI surface (was exclusive in the legacy +dim-resize). // RowsResize wraps resize_range for row heights. --type auto enables // auto-fit (rows only); --type pixel requires --size. var RowsResize = common.Shortcut{ Service: "sheets", Command: "+rows-resize", - Description: "Resize rows by pixel / standard / auto (--type pixel needs --size; --start/--end are 0-based inclusive).", + Description: "Resize rows by pixel / standard / auto (--type pixel needs --size; --range is 1-based A1 like \"2:10\" or \"5\").", Risk: "write", Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, @@ -238,7 +237,7 @@ var RowsResize = common.Shortcut{ var ColsResize = common.Shortcut{ Service: "sheets", Command: "+cols-resize", - Description: "Resize columns by pixel / standard (--type pixel needs --size; --start/--end are 0-based inclusive; no auto for cols).", + Description: "Resize columns by pixel / standard (--type pixel needs --size; --range is column letters like \"A:E\" or \"C\"; no auto for cols).", Risk: "write", Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, @@ -275,7 +274,7 @@ var ColsResize = common.Shortcut{ // validateViaResize wires the standalone Validate to resizeInput so both // paths (standalone + batch sub-op) emit the same error for missing --type, -// out-of-range --start/--end, or --type auto on columns. +// malformed --range, or --type auto on columns. func validateViaResize(dimension string) func(ctx context.Context, runtime *common.RuntimeContext) error { return func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -297,20 +296,42 @@ func autoSuffix(dimension string) string { return "" } +// commandForDimension returns the shortcut command name a given dimension +// belongs to; used in error messages so users see "+rows-resize" / "+cols-resize" +// instead of the internal "row" / "column" tag. +func commandForDimension(dimension string) string { + if dimension == "row" { + return "+rows-resize" + } + return "+cols-resize" +} + // resizeInput builds the resize_range tool input. dimension is "row" / -// "column"; --end is inclusive on the CLI surface, dimRangeFull wants -// exclusive end, so it is bumped by one here. dimRangeFull (not dimRange) is -// used so a single row/column still emits "N:N" — resize_range rejects a bare -// "N". +// "column" (selected by the calling shortcut); --range must match that +// 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. func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string) (map[string]interface{}, error) { if err := requireSheetSelector(sheetID, sheetName); err != nil { return nil, err } - if !runtime.Changed("start") || !runtime.Changed("end") { - return nil, common.FlagErrorf("--start and --end are required") + if !runtime.Changed("range") { + return nil, common.FlagErrorf("--range is required") + } + rangeStr := strings.TrimSpace(runtime.Str("range")) + parsedDim, _, _, err := parseA1Range(rangeStr) + if err != nil { + return nil, common.FlagErrorf("invalid --range %q: %v", rangeStr, err) + } + if parsedDim != dimension { + want := "row numbers (e.g. \"2:10\")" + if dimension == "column" { + want = "column letters (e.g. \"A:E\")" + } + return nil, common.FlagErrorf("--range %q is a %s range; %s expects %s", rangeStr, parsedDim, commandForDimension(dimension), want) } - if runtime.Int("start") < 0 || runtime.Int("end") < runtime.Int("start") { - return nil, common.FlagErrorf("invalid range: --start (%d) must be >= 0 and --end (%d) must be >= --start", runtime.Int("start"), runtime.Int("end")) + if !strings.Contains(rangeStr, ":") { + rangeStr = rangeStr + ":" + rangeStr } typ := strings.TrimSpace(runtime.Str("type")) if typ == "" { @@ -326,7 +347,6 @@ func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string) if typ != "pixel" && hasSize { return nil, common.FlagErrorf("--size is only valid with --type pixel") } - rangeStr := dimRangeFull(dimension, runtime.Int("start"), runtime.Int("end")+1) input := map[string]interface{}{ "excel_id": token, "range": rangeStr, diff --git a/shortcuts/sheets/lark_sheet_range_operations_test.go b/shortcuts/sheets/lark_sheet_range_operations_test.go index 8bc3c171d..1c5c4f9a9 100644 --- a/shortcuts/sheets/lark_sheet_range_operations_test.go +++ b/shortcuts/sheets/lark_sheet_range_operations_test.go @@ -67,9 +67,9 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) { }, }, { - name: "+rows-resize --type pixel --size 200", + name: "+rows-resize --range 1:5 pixel 200", sc: RowsResize, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "0", "--end", "4", "--type", "pixel", "--size", "200"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "pixel", "--size", "200"}, toolName: "resize_range", wantInput: map[string]interface{}{ "excel_id": testToken, @@ -82,9 +82,9 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) { }, }, { - name: "+rows-resize single row (start==end) keeps N:N range", + name: "+rows-resize single row \"1\" expands to \"1:1\"", sc: RowsResize, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "0", "--end", "0", "--type", "auto"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1", "--type", "auto"}, toolName: "resize_range", wantInput: map[string]interface{}{ "range": "1:1", @@ -92,9 +92,9 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) { }, }, { - name: "+cols-resize --type standard (reset to default)", + name: "+cols-resize --range B:D standard", sc: ColsResize, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "1", "--end", "3", "--type", "standard"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "B:D", "--type", "standard"}, toolName: "resize_range", wantInput: map[string]interface{}{ "excel_id": testToken, @@ -106,9 +106,9 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) { }, }, { - name: "+cols-resize --type pixel --size 120", + name: "+cols-resize --range A:C pixel 120", sc: ColsResize, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "0", "--end", "2", "--type", "pixel", "--size", "120"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "pixel", "--size", "120"}, toolName: "resize_range", wantInput: map[string]interface{}{ "range": "A:C", @@ -118,6 +118,16 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) { }, }, }, + { + name: "+cols-resize single column \"C\" expands to \"C:C\"", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "C", "--type", "standard"}, + toolName: "resize_range", + wantInput: map[string]interface{}{ + "range": "C:C", + "resize_width": map[string]interface{}{"type": "standard"}, + }, + }, { name: "+range-move cross-sheet", sc: RangeMove, @@ -257,26 +267,38 @@ func TestResize_TypeAndSizeGuards(t *testing.T) { { name: "+rows-resize --type pixel without --size", sc: RowsResize, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "0", "--end", "3", "--type", "pixel"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "pixel"}, want: "--type pixel requires --size", }, { name: "+rows-resize --type standard with --size", sc: RowsResize, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "0", "--end", "3", "--type", "standard", "--size", "30"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "standard", "--size", "30"}, want: "--size is only valid with --type pixel", }, { name: "+cols-resize rejects --type auto", sc: ColsResize, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "0", "--end", "2", "--type", "auto"}, + 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" }, { - name: "--end < --start", + name: "+rows-resize given column range", + sc: RowsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "standard"}, + want: "+rows-resize expects row numbers", + }, + { + name: "+cols-resize given row range", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "standard"}, + want: "+cols-resize expects column letters", + }, + { + name: "+rows-resize end < start", sc: RowsResize, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "5", "--end", "3", "--type", "standard"}, - want: "must be >= --start", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "5:3", "--type", "standard"}, + want: "end position is before start", }, } for _, tt := range cases { diff --git a/shortcuts/sheets/lark_sheet_sheet_structure.go b/shortcuts/sheets/lark_sheet_sheet_structure.go index d4c477a27..aa41f5d6a 100644 --- a/shortcuts/sheets/lark_sheet_sheet_structure.go +++ b/shortcuts/sheets/lark_sheet_sheet_structure.go @@ -6,6 +6,7 @@ package sheets import ( "context" "fmt" + "strconv" "strings" "github.com/larksuite/cli/internal/validate" @@ -15,9 +16,12 @@ import ( // ─── lark_sheet_sheet_structure ─────────────────────────────────────── // // Wraps get_sheet_structure (read) and modify_sheet_structure (write, -// operation-enum dispatch). CLI's --start/--end are 0-based with exclusive -// end; the tool wants 1-based inclusive row numbers ("3:7") or column -// letters ("C:F"). The conversion lives in dimRange / dimPosition below. +// operation-enum dispatch). All region/position arguments use A1-style +// strings (1-based row numbers like "3:7" / "5", or column letters like +// "C:F" / "C"); dim-* / resize never expose 0-based int indices on the CLI +// surface, so there is no inclusive/exclusive ambiguity across commands. +// parseA1Range / parseA1Position handle parsing into the 0-based ints that +// dim-move's native v3 endpoint expects. // // +rows-resize / +cols-resize live in lark_sheet_range_operations (different // tool); they are only grouped under "工作表" for discoverability. @@ -118,7 +122,7 @@ func infoTypeFromInclude(include []string) string { var DimInsert = common.Shortcut{ Service: "sheets", Command: "+dim-insert", - Description: "Insert blank rows or columns at a given range.", + Description: "Insert blank rows or columns at a given position.", Risk: "write", Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, @@ -153,21 +157,31 @@ var DimInsert = common.Shortcut{ }, } +// dimInsertInput passes --position (1-based row number "3" or column letter +// "C") straight to the tool's `position` field; --count maps to `count`. func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { if err := requireSheetSelector(sheetID, sheetName); err != nil { return nil, err } - if err := requireDimRange(runtime); err != nil { - return nil, err + if !runtime.Changed("position") { + return nil, common.FlagErrorf("--position is required") + } + if !runtime.Changed("count") { + return nil, common.FlagErrorf("--count is required") + } + position := strings.TrimSpace(runtime.Str("position")) + if _, _, err := parseA1Position(position); err != nil { + return nil, common.FlagErrorf("invalid --position %q: %v", position, err) + } + count := runtime.Int("count") + if count <= 0 { + return nil, common.FlagErrorf("--count must be > 0 (got %d)", count) } - dim := runtime.Str("dimension") - start := runtime.Int("start") - end := runtime.Int("end") input := map[string]interface{}{ "excel_id": token, "operation": "insert", - "position": dimPosition(dim, start), - "count": end - start, + "position": position, + "count": count, } sheetSelectorForToolInput(input, sheetID, sheetName) switch runtime.Str("inherit-style") { @@ -338,40 +352,25 @@ func dimFreezeInput(runtime flagView, token, sheetID, sheetName string) (map[str return input, nil } -// requireDimRange validates the dimension/start/end triple shared by -// insert/delete/hide/unhide/group/ungroup. Pure flag-level checks — the sheet -// selector and token live in their own helpers. -func requireDimRange(runtime flagView) error { - if !runtime.Changed("dimension") { - return common.FlagErrorf("--dimension is required") - } - if !runtime.Changed("start") || !runtime.Changed("end") { - return common.FlagErrorf("--start and --end are required") - } - start := runtime.Int("start") - end := runtime.Int("end") - if start < 0 { - return common.FlagErrorf("--start must be >= 0") - } - if end <= start { - return common.FlagErrorf("--end (%d) must be greater than --start (%d)", end, start) - } - return nil -} - -// dimRangeOpInput builds the tool input for delete/hide/unhide which all -// take a `range` field. dimRange handles 0-based exclusive → 1-based inclusive. +// dimRangeOpInput builds the tool input for delete/hide/unhide/group/ungroup +// which all take a `range` string field. --range is a 1-based A1 closed range +// ("3:7" / "5" for rows, "C:F" / "C" for columns) and passes straight through +// after format validation. func dimRangeOpInput(runtime flagView, token, sheetID, sheetName, op string) (map[string]interface{}, error) { if err := requireSheetSelector(sheetID, sheetName); err != nil { return nil, err } - if err := requireDimRange(runtime); err != nil { - return nil, err + if !runtime.Changed("range") { + return nil, common.FlagErrorf("--range is required") + } + rangeStr := strings.TrimSpace(runtime.Str("range")) + if _, _, _, err := parseA1Range(rangeStr); err != nil { + return nil, common.FlagErrorf("invalid --range %q: %v", rangeStr, err) } input := map[string]interface{}{ "excel_id": token, "operation": op, - "range": dimRange(runtime.Str("dimension"), runtime.Int("start"), runtime.Int("end")), + "range": rangeStr, } sheetSelectorForToolInput(input, sheetID, sheetName) return input, nil @@ -475,48 +474,75 @@ func dimGroupInput(runtime flagView, token, sheetID, sheetName, op string) (map[ return input, nil } -// ─── dimension formatting helpers ───────────────────────────────────── +// ─── A1 parsing helpers ─────────────────────────────────────────────── -// dimRange formats a CLI (0-based exclusive end) range as the tool's -// 1-based inclusive A1-style range string. row → "3:7", column → "C:F". -// A single-element range collapses to "3" / "C". -func dimRange(dimension string, start, end int) string { - if dimension == "column" { - startLetter := columnIndexToLetter(start) - endLetter := columnIndexToLetter(end - 1) - if start == end-1 { - return startLetter - } - return startLetter + ":" + endLetter +// parseA1Range parses an A1 closed range ("3:7" / "5" / "C:F" / "C") into +// the inferred dimension ("row" or "column") and 0-based inclusive indices. +// Single-element form yields startIdx == endIdx. Mixing digits and letters +// across the two sides ("3:C") is rejected. +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") + } + parts := strings.Split(s, ":") + if len(parts) > 2 { + return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element") + } + dim1, idx1, err := parseA1Position(parts[0]) + if err != nil { + return "", 0, 0, err } - if start == end-1 { - return fmt.Sprintf("%d", start+1) + if len(parts) == 1 { + return dim1, idx1, idx1, nil } - return fmt.Sprintf("%d:%d", start+1, end) -} - -// dimRangeFull is like dimRange but never collapses a single-element range to -// a bare index — it always emits the two-sided "N:N" / "C:C" form. resize_range -// rejects a bare index ("23" → Invalid range), so single-row/column resizes -// must keep both sides. -func dimRangeFull(dimension string, start, end int) string { - if dimension == "column" { - return columnIndexToLetter(start) + ":" + columnIndexToLetter(end-1) - } - return fmt.Sprintf("%d:%d", start+1, end) + dim2, idx2, err := parseA1Position(parts[1]) + if err != nil { + return "", 0, 0, err + } + if dim1 != dim2 { + return "", 0, 0, fmt.Errorf("cannot mix row (digits) and column (letters) in one range") + } + if idx2 < idx1 { + return "", 0, 0, fmt.Errorf("end position is before start") + } + return dim1, idx1, idx2, nil } -// dimPosition formats a single CLI 0-based index as the tool's 1-based row -// number string or column letter. -func dimPosition(dimension string, idx int) string { - if dimension == "column" { - return columnIndexToLetter(idx) +// parseA1Position parses a single A1 position element: pure digits → row +// (1-based number, returned as 0-based idx); pure letters → column (letters +// case-insensitive, "A" → 0, "AA" → 26). +func parseA1Position(s string) (dimension string, idx int, err error) { + s = strings.TrimSpace(s) + if s == "" { + return "", 0, fmt.Errorf("position is empty") + } + isDigits := true + isLetters := true + for _, r := range s { + if r < '0' || r > '9' { + isDigits = false + } + if !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) { + isLetters = false + } + } + if isDigits { + n, _ := strconv.Atoi(s) + if n <= 0 { + return "", 0, fmt.Errorf("row number must be >= 1 (got %q)", s) + } + return "row", n - 1, nil } - return fmt.Sprintf("%d", idx+1) + if isLetters { + return "column", letterToColumnIndex(s), nil + } + return "", 0, fmt.Errorf("expected pure digits (row number) or letters (column letter), got %q", s) } // columnIndexToLetter converts a 0-based column index to the spreadsheet // letter notation (0 → "A", 25 → "Z", 26 → "AA", 701 → "ZZ", 702 → "AAA"). +// Used by +workbook helpers that need to format absolute column references. func columnIndexToLetter(idx int) string { if idx < 0 { return "" @@ -535,13 +561,9 @@ func columnIndexToLetter(idx int) string { // // Moves a contiguous block of rows or columns to a new index in the same // sheet via the native v3 move_dimension endpoint (not the One-OpenAPI -// dispatcher). CLI's --start / --end are 0-based inclusive; v3 -// move_dimension's source.{start_index,end_index} are likewise 0-based -// inclusive, so they pass straight through. The earlier build POSTed a -// {source,destinationIndex} body to the v2 dimension_range endpoint, which -// is the add/update/delete surface and expects a `dimension` object — -// hence the server rejected it with "[9499] Missing required parameter: -// Dimension". +// dispatcher). CLI accepts --source-range (A1 closed range like "3:7" or +// "C:F") + --target (A1 single position like "12" or "H"); both are parsed +// into the 0-based int indices that v3 move_dimension expects. var DimMove = common.Shortcut{ Service: "sheets", @@ -559,16 +581,8 @@ var DimMove = common.Shortcut{ if _, _, err := resolveSheetSelector(runtime); err != nil { return err } - if !runtime.Changed("dimension") || !runtime.Changed("start") || !runtime.Changed("end") || !runtime.Changed("target") { - return common.FlagErrorf("--dimension / --start / --end / --target are all required") - } - if runtime.Int("start") < 0 || runtime.Int("end") < runtime.Int("start") { - return common.FlagErrorf("--end (%d) must be >= --start (%d) (both 0-indexed, inclusive)", runtime.Int("end"), runtime.Int("start")) - } - if runtime.Int("target") < 0 { - return common.FlagErrorf("--target must be >= 0") - } - return nil + _, err := buildDimMovePlan(runtime) + return err }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) @@ -606,6 +620,36 @@ var DimMove = common.Shortcut{ }, } +// dimMovePlan is the parsed form of --source-range / --target. +type dimMovePlan struct { + dimension string // "row" / "column" + startIdx int // 0-based inclusive + endIdx int // 0-based inclusive + targetIdx int // 0-based; destination position (move inserts before this) +} + +// buildDimMovePlan parses --source-range + --target and enforces that the +// target dimension matches the source. Used by both Validate and Execute. +func buildDimMovePlan(runtime flagView) (*dimMovePlan, error) { + if !runtime.Changed("source-range") || !runtime.Changed("target") { + return nil, common.FlagErrorf("--source-range and --target are required") + } + src := strings.TrimSpace(runtime.Str("source-range")) + dim, startIdx, endIdx, err := parseA1Range(src) + if err != nil { + return nil, common.FlagErrorf("invalid --source-range %q: %v", src, err) + } + tgt := strings.TrimSpace(runtime.Str("target")) + tgtDim, tgtIdx, err := parseA1Position(tgt) + if err != nil { + return nil, common.FlagErrorf("invalid --target %q: %v", tgt, err) + } + if tgtDim != dim { + return nil, common.FlagErrorf("--target %q dimension (%s) must match --source-range %q dimension (%s)", tgt, tgtDim, src, dim) + } + return &dimMovePlan{dimension: dim, startIdx: startIdx, endIdx: endIdx, targetIdx: tgtIdx}, nil +} + // dimMovePath builds the native v3 move_dimension endpoint. sheet_id lives in // the path (unlike the v2 dimension_range body that the earlier build used). func dimMovePath(token, sheetID string) string { @@ -614,16 +658,22 @@ func dimMovePath(token, sheetID string) string { } func dimMoveBody(runtime *common.RuntimeContext) map[string]interface{} { + plan, err := buildDimMovePlan(runtime) + if err != nil { + // Validate has already rejected this case; emit an empty body + // rather than panic on the dry-run path. + return map[string]interface{}{} + } dim := "ROWS" - if runtime.Str("dimension") == "column" { + if plan.dimension == "column" { dim = "COLUMNS" } return map[string]interface{}{ "source": map[string]interface{}{ "major_dimension": dim, - "start_index": runtime.Int("start"), - "end_index": runtime.Int("end"), // both CLI --end and v3 end_index are 0-based inclusive + "start_index": plan.startIdx, + "end_index": plan.endIdx, }, - "destination_index": runtime.Int("target"), + "destination_index": plan.targetIdx, } } diff --git a/shortcuts/sheets/lark_sheet_sheet_structure_test.go b/shortcuts/sheets/lark_sheet_sheet_structure_test.go index d5443ef81..6d9d8f62c 100644 --- a/shortcuts/sheets/lark_sheet_sheet_structure_test.go +++ b/shortcuts/sheets/lark_sheet_sheet_structure_test.go @@ -11,8 +11,10 @@ import ( ) // TestSheetStructureShortcuts_DryRun covers all 8 shortcuts in -// lark_sheet_sheet_structure (sheet-info + 7 dim-*) and verifies the -// CLI 0-based exclusive-end → tool 1-based inclusive A1 conversion. +// lark_sheet_sheet_structure (sheet-info + 7 dim-*) and verifies that the +// CLI's A1-style --range / --position / --count flags map straight through +// to the tool's `range` / `position` / `count` fields (or are normalised +// per shortcut's wire shape). func TestSheetStructureShortcuts_DryRun(t *testing.T) { t.Parallel() @@ -46,9 +48,9 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) { }, }, { - name: "+dim-insert row 5..8 inherit-before → position 6 + count 3 + side", + name: "+dim-insert row position=6 count=3 inherit-before", sc: DimInsert, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--start", "5", "--end", "8", "--inherit-style", "before"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--position", "6", "--count", "3", "--inherit-style", "before"}, toolName: "modify_sheet_structure", wantInput: map[string]interface{}{ "excel_id": testToken, @@ -60,9 +62,22 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) { }, }, { - name: "+dim-delete column B..D", + name: "+dim-insert column position=C count=2", + sc: DimInsert, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--position", "C", "--count", "2"}, + toolName: "modify_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "insert", + "sheet_id": testSheetID, + "position": "C", + "count": float64(2), + }, + }, + { + name: "+dim-delete column B:D", sc: DimDelete, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "column", "--start", "1", "--end", "4"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "B:D"}, toolName: "modify_sheet_structure", wantInput: map[string]interface{}{ "excel_id": testToken, @@ -72,9 +87,9 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) { }, }, { - name: "+dim-hide row 2..5 → range 3:5", + name: "+dim-hide row 3:5", sc: DimHide, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--start", "2", "--end", "5"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "3:5"}, toolName: "modify_sheet_structure", wantInput: map[string]interface{}{ "excel_id": testToken, @@ -84,9 +99,9 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) { }, }, { - name: "+dim-unhide column 26..29 → AA:AC", + name: "+dim-unhide column AA:AC", sc: DimUnhide, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "column", "--start", "26", "--end", "29"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "AA:AC"}, toolName: "modify_sheet_structure", wantInput: map[string]interface{}{ "excel_id": testToken, @@ -119,9 +134,9 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) { }, }, { - name: "+dim-group with state", + name: "+dim-group row 1:5 fold", sc: DimGroup, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--start", "0", "--end", "5", "--group-state", "fold"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--group-state", "fold"}, toolName: "modify_sheet_structure", wantInput: map[string]interface{}{ "excel_id": testToken, @@ -132,9 +147,9 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) { }, }, { - name: "+dim-ungroup", + name: "+dim-ungroup row 1:5", sc: DimUngroup, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--start", "0", "--end", "5"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5"}, toolName: "modify_sheet_structure", wantInput: map[string]interface{}{ "excel_id": testToken, @@ -155,29 +170,56 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) { } } -func TestDimRange_StartEndValidation(t *testing.T) { +// TestDimRange_Validation covers the A1 range parser's edge cases routed +// through +dim-hide (any --range shortcut works; we just need to exercise +// the validator). +func TestDimRange_Validation(t *testing.T) { t.Parallel() - stdout, stderr, err := runShortcutCapturingErr(t, DimHide, []string{ - "--url", testURL, "--sheet-id", testSheetID, - "--dimension", "row", "--start", "5", "--end", "3", "--dry-run", - }) - if err == nil { - t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) + cases := []struct { + name string + args []string + want string + }{ + { + name: "end before start", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "5:3", "--dry-run"}, + want: "end position is before start", + }, + { + name: "mix row+column", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "3:C", "--dry-run"}, + want: "cannot mix row", + }, + { + name: "invalid characters", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--dry-run"}, + want: "expected pure digits", + }, } - if !strings.Contains(stdout+stderr+err.Error(), "must be greater than --start") { - t.Errorf("expected end>start guard; got=%s|%s|%v", stdout, stderr, err) + for _, tt := range cases { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, DimHide, tt.args) + if err == nil { + t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) + } + if !strings.Contains(stdout+stderr+err.Error(), tt.want) { + t.Errorf("expected %q substring; got=%s|%s|%v", tt.want, stdout, stderr, err) + } + }) } } // TestDimMove_DryRun verifies the native v3 move_dimension payload shape. -// CLI's 0-based inclusive (--start / --end) maps straight through to v3's -// source.{start_index,end_index} (also 0-based inclusive), and sheet_id is -// carried in the path, not the body. +// CLI's --source-range "1:3" (1-based inclusive) is parsed into +// source.{start_index=0, end_index=2} (0-based inclusive), and sheet_id is +// carried in the path, not the body. --target "11" → destination_index=10. func TestDimMove_DryRun(t *testing.T) { t.Parallel() calls := parseDryRunAPI(t, DimMove, []string{ "--url", testURL, "--sheet-id", testSheetID, - "--dimension", "row", "--start", "0", "--end", "2", "--target", "10", + "--source-range", "1:3", "--target", "11", }) if len(calls) != 1 { t.Fatalf("api calls = %d, want 1", len(calls)) @@ -196,15 +238,96 @@ func TestDimMove_DryRun(t *testing.T) { t.Errorf("start_index = %v, want 0", src["start_index"]) } if src["end_index"].(float64) != 2 { - t.Errorf("end_index = %v, want 2 (0-based inclusive, passes straight through)", src["end_index"]) + t.Errorf("end_index = %v, want 2 (0-based inclusive)", src["end_index"]) } if body["destination_index"].(float64) != 10 { - t.Errorf("destination_index = %v, want 10", body["destination_index"]) + t.Errorf("destination_index = %v, want 10 (target \"11\" → 0-based 10)", body["destination_index"]) + } +} + +// TestDimMove_Column exercises the column path: --source-range "C:F" → +// COLUMNS / start=2 / end=5; --target "H" → destination_index=7. +func TestDimMove_Column(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, DimMove, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--source-range", "C:F", "--target", "H", + }) + c := calls[0].(map[string]interface{}) + body, _ := c["body"].(map[string]interface{}) + src, _ := body["source"].(map[string]interface{}) + if src["major_dimension"] != "COLUMNS" { + t.Errorf("major_dimension = %v, want COLUMNS", src["major_dimension"]) + } + if src["start_index"].(float64) != 2 || src["end_index"].(float64) != 5 { + t.Errorf("source = %v, want start=2 end=5", src) + } + if body["destination_index"].(float64) != 7 { + t.Errorf("destination_index = %v, want 7", body["destination_index"]) + } +} + +// TestDimMove_MismatchedDimension verifies that mixing source row + target +// column (or vice versa) is rejected at Validate. +func TestDimMove_MismatchedDimension(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, DimMove, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--source-range", "1:3", "--target", "H", "--dry-run", + }) + if err == nil { + t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) + } + if !strings.Contains(stdout+stderr+err.Error(), "must match --source-range") { + t.Errorf("expected dimension-mismatch guard; got=%s|%s|%v", stdout, stderr, err) + } +} + +// TestParseA1Range covers parser edge cases directly. +func TestParseA1Range(t *testing.T) { + t.Parallel() + cases := []struct { + in string + dim string + start int + end int + wantErr bool + }{ + {"3:7", "row", 2, 6, false}, + {"5", "row", 4, 4, false}, + {"C:F", "column", 2, 5, false}, + {"C", "column", 2, 2, false}, + {"aa:ac", "column", 26, 28, false}, // lower-case letters accepted + {"", "", 0, 0, true}, + {"3:C", "", 0, 0, true}, + {"7:3", "", 0, 0, true}, + {"A1", "", 0, 0, true}, // cell ref, not a row/col range + {"3:5:7", "", 0, 0, true}, + {"0", "", 0, 0, true}, // rows are 1-based + } + for _, c := range cases { + c := c + t.Run(c.in, func(t *testing.T) { + t.Parallel() + dim, start, end, err := parseA1Range(c.in) + if c.wantErr { + if err == nil { + t.Errorf("parseA1Range(%q) = (%q, %d, %d, nil), want error", c.in, dim, start, end) + } + return + } + if err != nil { + t.Fatalf("parseA1Range(%q) unexpected error: %v", c.in, err) + } + if dim != c.dim || start != c.start || end != c.end { + t.Errorf("parseA1Range(%q) = (%q, %d, %d), want (%q, %d, %d)", c.in, dim, start, end, c.dim, c.start, c.end) + } + }) } } -// TestColumnIndexToLetter exercises the corner cases of the letter helper: -// single, double, and triple-letter spans. +// TestColumnIndexToLetter exercises the corner cases of the letter helper +// (still in use by lark_sheet_workbook.go for absolute column refs). func TestColumnIndexToLetter(t *testing.T) { t.Parallel() cases := []struct { diff --git a/skills/lark-sheets/references/lark-sheets-range-operations.md b/skills/lark-sheets/references/lark-sheets-range-operations.md index 9e4793eb9..3370f3e5f 100644 --- a/skills/lark-sheets/references/lark-sheets-range-operations.md +++ b/skills/lark-sheets/references/lark-sheets-range-operations.md @@ -123,10 +123,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--start` | int | required | 起始行(0-based, inclusive) | -| `--end` | int | required | 结束行(0-based, inclusive) | | `--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` | ### `+cols-resize` @@ -134,10 +133,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--start` | int | required | 起始列(0-based, inclusive) | -| `--end` | int | required | 结束列(0-based, inclusive) | | `--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` | ### `+range-move` @@ -224,16 +222,16 @@ lark-cli sheets +cells-unmerge --url "..." --sheet-id "$SID" --range "A1:C100" ```bash # 把第 2-10 行设为固定 30 px -lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --start 2 --end 10 --type pixel --size 30 +lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --range "2:10" --type pixel --size 30 # 把 A-C 列设为固定 120 px -lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --start 0 --end 2 --type pixel --size 120 +lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --range "A:C" --type pixel --size 120 -# 行高自动适应内容(列宽不支持 auto) -lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --start 0 --end 0 --type auto +# 第 1 行行高自动适应内容(列宽不支持 auto) +lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --range "1" --type auto -# 重置为默认 -lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --start 0 --end 5 --type standard +# 重置 A-E 列为默认列宽 +lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --range "A:E" --type standard ``` > 同时出现在 `lark-sheets-sheet-structure.md` —— 行高 / 列宽调整也算行列结构层动作。 diff --git a/skills/lark-sheets/references/lark-sheets-sheet-structure.md b/skills/lark-sheets/references/lark-sheets-sheet-structure.md index f833efb33..16473e3b7 100644 --- a/skills/lark-sheets/references/lark-sheets-sheet-structure.md +++ b/skills/lark-sheets/references/lark-sheets-sheet-structure.md @@ -17,31 +17,31 @@ | 操作需求 | 使用工具 | 说明 | |---------|---------|------| | 查看子表布局 | `+sheet-info` | 获取行高、列宽、隐藏行列、行列分组、合并单元格等信息 | -| 变更子表结构 | `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` | 插入/删除/隐藏/取消隐藏/冻结行列、行列分组操作 | +| 变更子表结构 | `+dim-{insert|delete|hide|unhide|freeze|group|ungroup|move}` | 插入/删除/隐藏/取消隐藏/冻结/分组/移动行列 | 注意: - 当表格存在合并单元格时,应结合返回的 `merged_cells` 判断表头、分组标题和区域语义 - 不要把合并区域中非左上角的空白单元格理解为"无内容";通常应将左上角单元格的内容视为整个合并区域的语义内容 -- 插入用 `+dim-insert`:`--dimension`(`row`/`column`)+ `--start`(插入起始 index,0-based)+ `--end`(结束 index,exclusive);插入行/列数 = `--end` − `--start`。新行/列样式继承用 `--inherit-style`(`before`/`after`/`none`) -- 处理"在第 N 行后追加"这类请求时,注意 `--start` 是 0-based 索引、`--end` 是 exclusive,换算时避免 off-by-one -- 例如"在第 20 行后新增 116 行":`--dimension row --start 21 --end 137`("第 20 行后"即从 index 21 起插入,`--end` = `--start` + 116) +- 插入用 `+dim-insert`:`--position`(插入位置;行用 1-based 行号如 `3`,列用字母如 `C`,新行/列插在此位置**之前**)+ `--count`(插入数量,>0)。新行/列样式继承用 `--inherit-style`(`before`/`after`/`none`) +- 例如"在第 20 行后新增 116 行":`--position 21 --count 116`("第 20 行后"即 1-based 行号 21) -**⚠️ `--end` 区间端点语义对照(跨命令不一致,最高发的 off-by-one 来源)**:同样叫 `--start` / `--end`、同样作用于行/列区间,但 `--end` 含义因命令而异,构造参数前务必对照本表: +**区间表达统一为 A1 风格**:所有涉及"一段连续行/列"的 shortcut 都用同一套 A1 闭区间字符串语法,**不存在 inclusive / exclusive / 0-based / 1-based 跨命令差异**: -| 命令 | `--end` 语义 | 备注 | +| 命令 | 用什么 flag 表达区间 / 位置 | 例子 | | --- | --- | --- | -| `+dim-insert` / `+dim-delete` / `+dim-hide` / `+dim-unhide` / `+dim-group` / `+dim-ungroup` | **exclusive**(不含 end) | 操作行/列数 = `--end` − `--start` | -| `+dim-move` | **inclusive**(含 end) | ⚠️ 与同族 `+dim-*` **相反**!`--start`/`--end` 是**源区间**(闭区间),目标位置另用 `--target` | -| `+rows-resize` / `+cols-resize` | **inclusive**(含 end) | `--start`/`--end` 均为 0-based 闭区间 | +| `+dim-insert` | `--position` + `--count` | `--position 3 --count 5`(在第 3 行前插 5 行)/ `--position C --count 2`(在 C 列前插 2 列) | +| `+dim-delete` / `+dim-hide` / `+dim-unhide` / `+dim-group` / `+dim-ungroup` / `+rows-resize` / `+cols-resize` | `--range` | `"3:7"`(第 3-7 行,闭区间)/ `"C:F"`(C-F 列,闭区间)/ `"5"` 或 `"C"`(单行/列) | +| `+dim-move` | `--source-range`(源区间)+ `--target`(目标位置) | `--source-range "3:7" --target 12`(把第 3-7 行移到第 12 行前)/ `--source-range "C:F" --target H` | -把 `+dim-insert` / `+dim-delete` 的 exclusive 习惯照搬到 `+dim-move` / `+rows-resize` / `+cols-resize`(或反过来)会少算/多算一行/一列——动手前先在本表确认目标命令的 `--end` 端点语义。 +行用 1-based 数字、列用字母——跟 Excel / 飞书 UI 看到的行号、列字母完全一致。 **常见配置错误(必须注意)**: -- **插入列位置偏移**:插入列时 `--start` 是基于 0 的列索引,不是列字母。插入前先通过 `+workbook-info` 或读取表头确认目标位置的实际列索引,不要凭猜测 -- **插入后引用偏移**:插入行/列后,原有数据的行列号会发生偏移。如果插入后还需要对原有区域执行写入操作,必须重新计算偏移后的行列号 -- **删除行列前先确认范围**:删除操作不可逆,执行前应确认 `--start` / `--end` 精确无误(`+dim-delete` 用 `--dimension` + `--start`(0-based)+ `--end`(exclusive))。可先用 `+csv-get` 读取目标区域验证内容 -- **"在左侧新增一列"的正确写法**:用户说"在 D 列左侧新增一列"时,`--dimension column`、`--start` 取 D 列的 0-based 索引(新列插在该 index 之前)、`--end = --start + 1`;要继承左侧列样式加 `--inherit-style before` +- **插入列直接用字母**:`+dim-insert` 的 `--position` 在列场景直接传字母(如 `C`),不要把列字母换算成 0-based 索引 +- **插入后引用偏移**:插入行/列后,原有数据的行号 / 列字母会发生偏移。如果插入后还需要对原有区域执行写入操作,必须重新计算偏移后的位置 +- **删除行列前先确认范围**:删除操作不可逆,执行前应确认 `--range` 精确无误。可先用 `+csv-get` 读取目标区域验证内容 +- **"在 D 列左侧新增一列"的正确写法**:`--position D --count 1`(新列插在 D 列之前);要继承左侧列样式加 `--inherit-style before` +- **`+dim-move` 同维度约束**:`--source-range` 是行区间时 `--target` 必须是行号(数字),是列区间时 `--target` 必须是列字母——不可一行一列混用 - **插入列后必须检查多行表头合并区域**:很多表格有 2-3 行的合并表头。插入列后,原有的合并区域不会自动扩展到新列。必须先用 `+sheet-info --include merges` 读取合并区域,插入后将跨越插入位置的合并区域重新设置(用 `+cells-{merge|unmerge}`),否则新列的表头会是空的、格式不连续 - **公式写入范围跳过表头行**:写入公式时从数据行开始(不是第 1 行)。先确认表头占几行(可能 1-3 行),公式的起始行 = 表头行数 + 1 @@ -76,10 +76,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | -| `--start` | int | required | 插入起始位置(0-based) | -| `--end` | int | required | 插入结束位置(exclusive) | | `--inherit-style` | string | optional | 新行/列样式继承策略 enum:`before`(继承前一行/列)/ `after`(继承后一行/列)/ `none`(默认)(可选值:`before` / `after` / `none`) | +| `--position` | string | required | 插入位置(在此行/列**之前**插入):行用 1-based 行号如 `3`;列用字母如 `C` | +| `--count` | int | required | 插入数量(>0) | ### `+dim-delete` @@ -87,9 +86,7 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | -| `--start` | int | required | 起始位置(0-based) | -| `--end` | int | required | 结束位置(exclusive) | +| `--range` | string | required | 要删除的行/列闭区间;行用 1-based 数字如 `3:7` 或单行 `5`,列用字母如 `C:F` 或单列 `C` | ### `+dim-hide` @@ -97,9 +94,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | -| `--start` | int | required | 起始位置(0-based) | -| `--end` | int | required | 结束位置(exclusive) | +| `--range` | string | required | 要隐藏的行/列闭区间;行如 `3:7`,列如 `C:F` | ### `+dim-unhide` @@ -107,9 +102,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | -| `--start` | int | required | 起始位置(0-based) | -| `--end` | int | required | 结束位置(exclusive) | +| `--range` | string | required | 要取消隐藏的行/列闭区间;行如 `3:7`,列如 `C:F` | ### `+dim-freeze` @@ -126,11 +119,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | -| `--start` | int | required | 起始位置(0-based) | -| `--end` | int | required | 结束位置(exclusive) | | `--depth` | int | optional | 嵌套分组的层级(创建到第几层),默认 1 | | `--group-state` | string | optional | 分组初始展开状态(可选值:`expand` / `fold`)(默认 `expand`) | +| `--range` | string | required | 要创建分组的行/列闭区间;行如 `3:7`,列如 `C:F` | ### `+dim-ungroup` @@ -138,10 +129,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | -| `--start` | int | required | 起始位置(0-based) | -| `--end` | int | required | 结束位置(exclusive) | | `--depth` | int | optional | 要取消的分组层级,默认 1(最外层) | +| `--range` | string | required | 要取消分组的行/列闭区间;行如 `3:7`,列如 `C:F` | ### `+dim-move` @@ -149,10 +138,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | -| `--start` | int | required | 源起止区间的起始位置(0-based) | -| `--end` | int | required | 源起止区间的结束位置(inclusive) | -| `--target` | int | required | 目标位置(move 到该 index 之前;0-based) | +| `--source-range` | string | required | 要移动的源行/列闭区间;行如 `3:7`,列如 `C:F` | +| `--target` | string | required | 目标位置(移到此行/列**之前**):行用 1-based 行号如 `12`,列用字母如 `H`。必须与 `--source-range` 同维度(行/列) | ## Examples @@ -164,26 +151,41 @@ _公共四件套 · 系统:`--dry-run`_ ### `+dim-insert` -示例: - ```bash # 在第 10 行前插 3 行,继承上方样式 lark-cli sheets +dim-insert --url "https://example.feishu.cn/sheets/shtXXX" \ - --sheet-id "$SID" --dimension row --start 10 --end 13 --inherit-style before + --sheet-id "$SID" --position 10 --count 3 --inherit-style before + +# 在 C 列前插 2 列 +lark-cli sheets +dim-insert --url "..." --sheet-id "$SID" --position C --count 2 ``` ### `+dim-delete` ```bash -# 删除第 5-7 行(0-based,--end 为 exclusive) -lark-cli sheets +dim-delete --url "..." --sheet-id "$SID" --dimension row --start 5 --end 8 --yes +# 删除第 5-7 行 +lark-cli sheets +dim-delete --url "..." --sheet-id "$SID" --range "5:7" --yes + +# 删除 D-F 列 +lark-cli sheets +dim-delete --url "..." --sheet-id "$SID" --range "D:F" --yes ``` ### `+dim-hide` / `+dim-unhide` ```bash -lark-cli sheets +dim-hide --url "..." --sheet-id "$SID" --dimension row --start 5 --end 8 -lark-cli sheets +dim-unhide --url "..." --sheet-id "$SID" --dimension row --start 5 --end 8 +lark-cli sheets +dim-hide --url "..." --sheet-id "$SID" --range "5:7" +lark-cli sheets +dim-unhide --url "..." --sheet-id "$SID" --range "5:7" +lark-cli sheets +dim-hide --url "..." --sheet-id "$SID" --range "C:F" +``` + +### `+dim-move` + +```bash +# 把第 3-7 行移到第 12 行前 +lark-cli sheets +dim-move --url "..." --sheet-id "$SID" --source-range "3:7" --target 12 + +# 把 C-F 列移到 H 列前 +lark-cli sheets +dim-move --url "..." --sheet-id "$SID" --source-range "C:F" --target H ``` ### `+rows-resize` / `+cols-resize` @@ -205,6 +207,6 @@ lark-cli sheets +dim-freeze --url "..." --sheet-id "$SID" --dimension row --coun ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`--start ≤ --end`;`+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`。 -- `DryRun`:写操作输出"将要 PATCH 的 dimension 区间 + 目标参数"。 +- `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`。 +- `DryRun`:写操作输出"将要 PATCH 的目标范围 + 目标参数"。 - `Execute`:写后不自动回读;如需确认,自行调用 `+sheet-info --include row_heights,col_widths,hidden_rows,hidden_cols,groups,frozen` 查看受影响的范围。 From 0476dec83c6f17bf2987af67964e2312ee4a7575 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Wed, 27 May 2026 19:46:36 +0800 Subject: [PATCH 075/114] docs(sheets): sync +pivot-create placement reference from spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion sync from sheet-skill-spec — the canonical reference rewrites +pivot-create's "5 placement-related flags" rundown into a clearer "4 placement-related flags" form (--target-sheet-id was already removed in #1130, this updates the prose accordingly), and clarifies that --sheet-id / --sheet-name on +pivot-create are the *placement* sheet (not the source-data sheet), with omit-both as the strongly-recommended default. Also picks up a base-side --target-position description tweak that dropped the now-stale "与 --target-sheet-id 配套" reference. No CLI surface change. --- .../references/lark-sheets-pivot-table.md | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md index f78c36d82..28fa8ee51 100644 --- a/skills/lark-sheets/references/lark-sheets-pivot-table.md +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -62,7 +62,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--properties` | string + File + Stdin(复合 JSON) | required | JSON:{"rows":[...],"columns":[...],"values":[...],"filters":[...],"show_row_grand_total":true,"show_col_grand_total":true}(数据源走 --source,不要再放进 properties.source) | -| `--target-position` | string | optional | 透视表落点子表内的起始 cell(A1 格式,如 `A1`),与 `--target-sheet-id` 配套、映射到顶层 `target_position`,默认 `A1`(值为 A1 时不下发)。它与 `--range` 都表达落点但落在不同 wire 字段,避免两者同时给冲突值 | +| `--target-position` | string | optional | 透视表落点子表内的起始 cell(A1 格式,如 `A1`),映射到顶层 `target_position`,默认 `A1`(值为 A1 时不下发)。它与 `--range` 都表达落点但落在不同 wire 字段,避免两者同时给冲突值 | | `--source` | string | required | 透视表源数据区域(A1 表示法,格式 `SheetName!StartCell:EndCell`,如 `Sheet1!A1:D100`) | | `--range` | string | optional | 透视表左上角放置位置(A1 单值,如 `F1`,仅 create 生效),映射到 `properties.range`;省略时放在落点子表(默认新建子表)的左上角。它与 `--target-position` 都表达落点但落在不同 wire 字段,避免两者同时给冲突值 | @@ -108,7 +108,7 @@ _创建/更新的透视表属性_ ## Examples -公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。`+pivot-create` 默认自动新建子表存放透视表产物(推荐)。 +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`。其中 `--sheet-id` / `--sheet-name` 在 `+pivot-update` / `+pivot-delete` / `+pivot-list` 上是公共四件套语义(定位透视表所在 sheet,XOR 必传一个);但在 **`+pivot-create` 上是透视表的"落点"语义**——两个都不传时后端自动新建子表存放产物(强烈推荐,绝不碰源数据)。 ### `+pivot-list` @@ -120,26 +120,27 @@ lark-cli sheets +pivot-list --url "..." --sheet-id "$SID" > 数据源 `--source` 必须从表头行开始;空行 / 汇总行会被当作数据参与聚合,需提前用 `+csv-get` 确认起止边界。`--source` 和 `--range` 是独立 flag(不要再放 `--properties`);`rows` / `columns` / `values` 等数组字段走 `--properties`。 > -> **先理清 `+pivot-create` 上 5 个位置类入参(语义不同,别混)**: -> - 公共 `--sheet-id` / `--sheet-name`(**必填**,公共四件套):定位**操作所在工作表**(数据源 sheet 的上下文)。 -> - `--source`(**必填**):**源数据**区域,须自带 `Sheet!` 前缀(如 `Sheet1!A1:D100`)。 -> - `--target-sheet-id` / `--target-position` / `--range`:**产物落点**,按下面 3 种策略二选其一。 +> **先理清 `+pivot-create` 上 4 个位置类入参(语义不同,别混)**: +> - `--source`(**必填**):**源数据**区域,须自带 `Sheet!` 前缀(如 `Sheet1!A1:D100`)。源 sheet 的名字在 `--source` 字符串里,**不**通过单独 flag 传。 +> - `--sheet-id` / `--sheet-name`:**透视表的落点 sheet**(即产物放哪张子表)。两个互斥(最多传一个),都不传时后端自动新建子表存放产物(强烈推荐)。**注意:跟其它 shortcut 不同,这里 `--sheet-id` / `--sheet-name` 表达的不是"数据源所在 sheet"而是"产物落点 sheet"**。 +> - `--target-position`(可选,A1 表示法,默认 `A1`):落点 sheet 内的起始 cell,映射到顶层 `target_position`。 +> - `--range`(可选,A1 单值,仅 create 生效):跟 `--target-position` 表达同一意图但映射到 `properties.range`,**两者不要同时给**。 > > **落点 3 种策略(互斥,选其一)**: -> 1. **默认(强烈推荐)**:`--target-sheet-id` / `--target-position` / `--range` **都不传** → 服务端**自动新建子表**存放产物,绝不碰源数据。 -> 2. **放进指定的已有子表**:`--target-sheet-id <落点子表 id>`,可选 `--target-position <子表内起点 cell,默认 A1>`(这两个是**配套**使用,不是互斥)。 -> 3. **`--range`**:仅在不指定落点子表、想精确指定左上角 cell(映射到 `properties.range`)时用。⚠️ **`--range` 把产物落在公共 `--sheet-id` 指向的那张 sheet 上**——若 `--sheet-id` 就是源数据 sheet,产物会盖在源数据旁/上、**可能覆盖数据**。它与 `--target-position` 表达同一意图但落不同 wire 字段,**两者不要同时给**。 +> 1. **默认(强烈推荐)**:`--sheet-id` / `--sheet-name` / `--target-position` / `--range` **全都不传** → 服务端**自动新建子表**存放产物,绝不碰任何已有数据。 +> 2. **放进指定的已有子表**:传 `--sheet-id <落点子表 id>`(或 `--sheet-name`),可选 `--target-position <子表内起点 cell>`。⚠️ **若落点子表就是源数据所在的 sheet**,必须配 `--target-position` 或 `--range` 指向源数据范围**之外**的位置,否则产物默认从 A1 起会盖在源数据上。 +> 3. **`--range`**:跟策略 2 等价(同样需要 `--sheet-id` / `--sheet-name` 指定落点子表,不然落到自动新建子表),只是用 `properties.range` 那条 wire 路径表达位置。同样的覆盖风险,同样需要避开源数据范围。 > -> 一般用策略 1(默认新建子表)即可,无需 `--range`/`--target-*`。 +> 一般用策略 1(默认新建子表)即可,零覆盖风险,无需任何 `--sheet-*` / `--range` / `--target-*` flag。 ```bash -# 策略 1(推荐):只给必填的公共 sheet 定位 + 源数据,不给落点 flag → 自动新建子表,零覆盖风险 -lark-cli sheets +pivot-create --url "..." --sheet-id "$SID" \ +# 策略 1(强烈推荐):不传任何落点 flag → 后端自动新建子表,零覆盖风险 +lark-cli sheets +pivot-create --url "..." \ --source "Sheet1!A1:D100" --properties @pivot.json -# 策略 2:落进指定的已有目标子表(不会碰源数据) -lark-cli sheets +pivot-create --url "..." --sheet-id "$SID" \ - --source "Sheet1!A1:D100" --target-sheet-id "$DEST_SID" --target-position "A1" --properties @pivot.json +# 策略 2:落进指定的已有目标子表(注意目标 sheet ≠ 源 sheet,否则要配 --target-position 避开源数据) +lark-cli sheets +pivot-create --url "..." \ + --source "Sheet1!A1:D100" --sheet-id "$DEST_SID" --target-position "A1" --properties @pivot.json ``` ### `+pivot-update` @@ -154,7 +155,7 @@ lark-cli sheets +pivot-delete --url "..." --sheet-id "$SID" --pivot-table-id "$P ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`+pivot-create` 的 `--source` 必填且必须含表头行;`--properties` 中 `rows` / `columns` / `values` 至少非空之一;`+pivot-delete` 强制 `--yes` 或 `--dry-run`。 +- `Validate`:`--url` / `--spreadsheet-token` XOR 必填;`+pivot-{update,delete,list}` 的 `--sheet-id` / `--sheet-name` XOR 必填一个,`+pivot-create` 例外(两个都可空,触发 backend auto-create 子表);`+pivot-create` 的 `--source` 必填且必须含表头行;`--properties` 中 `rows` / `columns` / `values` 至少非空之一;`+pivot-delete` 强制 `--yes` 或 `--dry-run`。 - `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 pivot 请求模板"+ 预估输出尺寸(行数 × 列数)。 - `Execute`:写后不自动回读;如需确认,自行调用 `+pivot-list --pivot-table-id ` 并用 `+csv-get` 抽样读透视产物核对输出尺寸 + 总计行位置。 From 8e84f47d3eabd62a8df22e7450aedea00ee19f99 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Thu, 28 May 2026 16:34:34 +0800 Subject: [PATCH 076/114] docs(sheets): sync +pivot-create summarize_by lowercase enum values from spec --- .../references/lark-sheets-pivot-table.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md index 28fa8ee51..25bbad56d 100644 --- a/skills/lark-sheets/references/lark-sheets-pivot-table.md +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -20,19 +20,19 @@ 创建透视表前先识别用户需求中的分组维度和聚合指标,**不要搞反**: - **rows(行字段)** = 分组维度,即"按什么分组"。例:部门、地区、医生、产品类别 -- **values(值字段)** = 聚合指标,即"统计什么数值"。例:SUM(销售额)、COUNT(订单数) +- **values(值字段)** = 聚合指标,即"统计什么数值"。例:销售额(聚合方式 `sum`)、订单数(聚合方式 `count`) - **columns(列字段)** = 交叉维度(可选),即"再按什么横向展开"。例:月份、性别 | 用户说 | rows | values | columns | |--------|------|--------|---------| -| "按部门统计人数" | 部门 | COUNT(姓名) | — | -| "按医生统计费用和结余" | 主管医生 | SUM(费用), SUM(结余) | — | -| "各部门男女人数" | 部门 | COUNT(姓名) | 性别 | +| "按部门统计人数" | 部门 | 姓名(`summarize_by: "count"`) | — | +| "按医生统计费用和结余" | 主管医生 | 费用(`"sum"`)、结余(`"sum"`) | — | +| "各部门男女人数" | 部门 | 姓名(`"count"`) | 性别 | **常见配置错误(必须注意)**: - **数据源范围必须精确**:透视表的数据源范围必须包含表头行,且精确覆盖全部数据行列。范围过大(包含空行/空列)或过小(遗漏数据列)都会导致透视表结果错误 -- **行列字段选择要匹配用户意图**:用户说"按商品统计金额"→ 行字段=商品,值字段=金额(SUM)。不要把行列字段搞反 -- **聚合类型要匹配**:用户说"统计数量"→ COUNT;"统计总额"→ SUM;"统计平均"→ AVERAGE。默认不要用 COUNT 替代 SUM +- **行列字段选择要匹配用户意图**:用户说"按商品统计金额"→ 行字段=商品,值字段=金额(`summarize_by: "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` 确认透视表结构正确 From 77f86ec2fd650b59d8d571949d09f7f57294971d Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Thu, 28 May 2026 17:38:15 +0800 Subject: [PATCH 077/114] docs(sheets): wrap sheet names in single quotes in A1 examples Synced from spec. Affects 3 reference md (pivot-table / batch-update / write-cells) and 2 generated flag-data JSONs. A1 examples like `Sheet1!A1:D100` now read `'Sheet1'!A1:D100` so models default to single-quoted sheet names. Excel A1 notation requires single quotes for sheet names containing hyphens / spaces / non-ASCII chars; always-quoting is also valid for plain names, so this is the safer default to teach. Affected flags: - +pivot-create --source - +dropdown-update --ranges / --source-range - +dropdown-delete --ranges - +dropdown-set --source-range - +cells-batch-set-style --ranges - +cells-batch-clear --ranges --- shortcuts/sheets/data/flag-defs.json | 14 +++++++------- shortcuts/sheets/data/flag-schemas.json | 8 ++++---- .../references/lark-sheets-batch-update.md | 10 +++++----- .../references/lark-sheets-pivot-table.md | 2 +- .../references/lark-sheets-write-cells.md | 2 +- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index eb221c4c9..de3b1ddf6 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -1799,7 +1799,7 @@ "kind": "own", "type": "string", "required": "xor", - "desc": "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `Sheet1!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress." + "desc": "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress." }, { "name": "dry-run", @@ -2527,7 +2527,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 (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", "input": [ "file", "stdin" @@ -2673,7 +2673,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 (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", "input": [ "file", "stdin" @@ -2720,7 +2720,7 @@ "kind": "own", "type": "string", "required": "xor", - "desc": "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `Sheet1!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress." + "desc": "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress." }, { "name": "dry-run", @@ -2753,7 +2753,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\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", "input": [ "file", "stdin" @@ -2797,7 +2797,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 (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", "input": [ "file", "stdin" @@ -3144,7 +3144,7 @@ "kind": "own", "type": "string", "required": "required", - "desc": "Pivot table source range (A1 notation; format `SheetName!StartCell:EndCell`, e.g. `Sheet1!A1:D100`)" + "desc": "Pivot table source range (A1 notation; format `'SheetName'!StartCell:EndCell`, e.g. `'Sheet1'!A1:D100`)" }, { "name": "range", diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json index bdcd9abe0..4a909314d 100644 --- a/shortcuts/sheets/data/flag-schemas.json +++ b/shortcuts/sheets/data/flag-schemas.json @@ -3634,7 +3634,7 @@ }, "rules": { "type": "array", - "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。update 为整组覆盖(PUT 语义):传入的 rules 即更新后的完整规则集,会先清空所有现有列的旧规则再应用,未列出的列其旧规则会被清除(不与旧规则合并);传空数组 [] 即清空所有列的规则。", + "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。update 时传空数组 [] 表示清空所有现有列的规则。", "items": { "type": "object", "description": "单列筛选规则。", @@ -3879,7 +3879,7 @@ }, "rules": { "type": "array", - "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。update 为整组覆盖(PUT 语义):传入的 rules 即更新后的完整规则集,会先清空所有现有列的旧规则再应用,未列出的列其旧规则会被清除(不与旧规则合并);传空数组 [] 即清空所有列的规则。", + "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。update 时传空数组 [] 表示清空所有现有列的规则。", "items": { "type": "object", "description": "单列筛选规则。", @@ -4128,7 +4128,7 @@ }, "rules": { "type": "array", - "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。结构与 manage_filter_object.properties.rules 完全一致。update 同样为整组覆盖(PUT 语义):传入的 rules 整组替换该视图所有列的规则,未列出的列其旧规则会被清除(不与旧规则合并),传空数组 [] 即清空全部。", + "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。结构与 manage_filter_object.properties.rules 完全一致。", "items": { "type": "object", "description": "单列筛选规则。", @@ -4373,7 +4373,7 @@ }, "rules": { "type": "array", - "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。结构与 manage_filter_object.properties.rules 完全一致。update 同样为整组覆盖(PUT 语义):传入的 rules 整组替换该视图所有列的规则,未列出的列其旧规则会被清除(不与旧规则合并),传空数组 [] 即清空全部。", + "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。结构与 manage_filter_object.properties.rules 完全一致。", "items": { "type": "object", "description": "单列筛选规则。", diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index c16f5f8a8..7de341f9b 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -51,7 +51,7 @@ _公共: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 数组,每项必须带 sheet 前缀(如 `["'Sheet1'!A1:B2","'Sheet2'!D1:D10"]`);前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id;支持跨 sheet;所有 range 应用同一组 style | | `--background-color` | string | optional | 背景颜色(十六进制,如 `#ffffff`) | | `--font-color` | string | optional | 字体颜色(十六进制,如 `#000000`) | | `--font-size` | float64 | optional | 字体大小(px,例:10、12、14) | @@ -70,12 +70,12 @@ _公共: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 数组(如 `["'Sheet1'!A2:A100","'Sheet1'!C2:C100"]`),每项必须带 sheet 前缀;前缀必须是 sheet 显示名(如 `Sheet1`),不接受 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 | 启用多选 | | `--highlight` | bool | optional | 下拉胶囊背景色高亮开关。**不传 = 开**(按内置 10 色色板循环上色);`--highlight=false` 关闭得到纯白下拉。配色用 `--colors` 覆盖。 | -| `--source-range` | string | xor | listFromRange 模式的下拉源 range,A1 表示法 + sheet 前缀(如 `Sheet1!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表(type=list),传本 flag 走 range 引用(type=listFromRange)。`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。当 `--highlight` 开启且 source 覆盖单元格数超过 2000 时,服务端会将该下拉判为 option-error(这是不支持的组合);CLI 会向 stderr 输出 warning。如需取消,传 `--highlight=false`。 | +| `--source-range` | string | xor | listFromRange 模式的下拉源 range,A1 表示法 + sheet 前缀(如 `'Sheet1'!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表(type=list),传本 flag 走 range 引用(type=listFromRange)。`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。当 `--highlight` 开启且 source 覆盖单元格数超过 2000 时,服务端会将该下拉判为 option-error(这是不支持的组合);CLI 会向 stderr 输出 warning。如需取消,传 `--highlight=false`。 | ### `+dropdown-delete` @@ -83,7 +83,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 显示名(如 `Sheet1`),不接受 sheet reference_id | ### `+cells-batch-clear` @@ -91,7 +91,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 数组,每项必须带 sheet 前缀(如 `["'Sheet1'!A2:Z1000","'Sheet2'!A2:Z1000"]`);前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id;支持跨 sheet;对所有 range 执行同一 scope 的清除 | | `--scope` | string | optional | 清除范围 enum:`content`(默认,仅清内容)/ `formats`(仅清格式)/ `all`(清内容 + 格式)(可选值:`content` / `formats` / `all`) | ## Schemas diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md index 25bbad56d..3a8f1cafa 100644 --- a/skills/lark-sheets/references/lark-sheets-pivot-table.md +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -63,7 +63,7 @@ _公共四件套 · 系统:`--dry-run`_ | --- | --- | --- | --- | | `--properties` | string + File + Stdin(复合 JSON) | required | JSON:{"rows":[...],"columns":[...],"values":[...],"filters":[...],"show_row_grand_total":true,"show_col_grand_total":true}(数据源走 --source,不要再放进 properties.source) | | `--target-position` | string | optional | 透视表落点子表内的起始 cell(A1 格式,如 `A1`),映射到顶层 `target_position`,默认 `A1`(值为 A1 时不下发)。它与 `--range` 都表达落点但落在不同 wire 字段,避免两者同时给冲突值 | -| `--source` | string | required | 透视表源数据区域(A1 表示法,格式 `SheetName!StartCell:EndCell`,如 `Sheet1!A1:D100`) | +| `--source` | string | required | 透视表源数据区域(A1 表示法,格式 `'SheetName'!StartCell:EndCell`,如 `'Sheet1'!A1:D100`) | | `--range` | string | optional | 透视表左上角放置位置(A1 单值,如 `F1`,仅 create 生效),映射到 `properties.range`;省略时放在落点子表(默认新建子表)的左上角。它与 `--target-position` 都表达落点但落在不同 wire 字段,避免两者同时给冲突值 | ### `+pivot-update` diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index c03d25950..d19f2febf 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -290,7 +290,7 @@ _公共四件套 · 系统:`--dry-run`_ | `--colors` | string + File + Stdin(简单 JSON) | optional | 下拉胶囊背景色,RGB hex 数组(如 `["#1FB6C1","#F006C2"]`)。长度可短不可长——超长 Validate 拦截(`--colors length (N) must not exceed dropdown source size (M)`),未指定项按内置 10 色色板循环补色。**单独传即生效**;`--highlight=false` 时被忽略。 | | `--multiple` | bool | optional | 启用多选;默认 `false` | | `--highlight` | bool | optional | 下拉胶囊背景色高亮开关。**不传 = 开**(按内置 10 色色板循环上色);`--highlight=false` 关闭得到纯白下拉。配色用 `--colors` 覆盖。 | -| `--source-range` | string | xor | listFromRange 模式的下拉源 range,A1 表示法 + sheet 前缀(如 `Sheet1!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表(type=list),传本 flag 走 range 引用(type=listFromRange)。`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。当 `--highlight` 开启且 source 覆盖单元格数超过 2000 时,服务端会将该下拉判为 option-error(这是不支持的组合);CLI 会向 stderr 输出 warning。如需取消,传 `--highlight=false`。 | +| `--source-range` | string | xor | listFromRange 模式的下拉源 range,A1 表示法 + sheet 前缀(如 `'Sheet1'!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表(type=list),传本 flag 走 range 引用(type=listFromRange)。`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。当 `--highlight` 开启且 source 覆盖单元格数超过 2000 时,服务端会将该下拉判为 option-error(这是不支持的组合);CLI 会向 stderr 输出 warning。如需取消,传 `--highlight=false`。 | ### `+csv-put` From 0f695b60ece4640fbf0a9a2726657cc2c4ae7593 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Thu, 28 May 2026 17:51:00 +0800 Subject: [PATCH 078/114] docs(sheets): wrap A1 sheet names in handwritten examples + bash histexpand guide Synced from spec. Affects 4 reference md (chart / pivot-table / sparkline / write-cells) and SKILL.md. In addition to wrapping sheet names in single quotes in all remaining handwritten examples (covers chart refs.value / nameRef, sparkline source, write-cells --source-range, pivot-create narrative), SKILL.md gains a new "Shell quoting for A1 references with !" section. The new section addresses bash history expansion: in interactive bash (e.g., ShellExec sandbox), unescaped `!Word` after `"..."` triggers `bash: !A1: event not found`, dropping the command before lark-cli sees it. The section gives 4 quoting strategies (shell single-quote outer, `set +H` prefix, mixed quoting, sheet-rename fallback) and an anti-pattern list. Affected files: - skills/lark-sheets/SKILL.md (new section) - skills/lark-sheets/references/lark-sheets-chart.md - skills/lark-sheets/references/lark-sheets-pivot-table.md - skills/lark-sheets/references/lark-sheets-sparkline.md - skills/lark-sheets/references/lark-sheets-write-cells.md --- skills/lark-sheets/SKILL.md | 34 ++++++++++++++++++- .../references/lark-sheets-chart.md | 30 ++++++++-------- .../references/lark-sheets-pivot-table.md | 11 ++++-- .../references/lark-sheets-sparkline.md | 8 ++--- .../references/lark-sheets-write-cells.md | 10 +++--- 5 files changed, 66 insertions(+), 27 deletions(-) diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index b00c35ad0..290eafd62 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -81,7 +81,7 @@ metadata: - ⚠️ **`/wiki/` 知识库链接不能直接当表格定位用**:wiki 链接背后可能是电子表格,也可能是文档 / 多维表格等其它类型,`--url` **不会**自动把 wiki token 解析成 spreadsheet token,直接传会失败。必须先把它解析成真实文档 token —— `lark-cli wiki +node-get --node-token ""`,确认返回的 `obj_type` 为 `sheet` 后,取其 `obj_token` 作为 `--spreadsheet-token` 传入(解析细节见 [`../lark-wiki/SKILL.md`](../lark-wiki/SKILL.md))。 - **例外**:`+workbook-create` 是新建一个还不存在的表格,**不接受任何 spreadsheet / sheet 定位 flag**(只有 `--title` / `--folder-token` / `--headers` / `--values`)。 2. **sheet 定位(公共四件套 shortcut 必填)**:`--sheet-id` 与 `--sheet-name` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --sheet-id or --sheet-name`。 - - ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:即使写了 `--range "Sheet1!A1:B2"`,仍**必须**额外传 `--sheet-id` 或 `--sheet-name`,否则照样报上面的错。 + - ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:即使写了 `--range "'Sheet1'!A1:B2"`,仍**必须**额外传 `--sheet-id` 或 `--sheet-name`,否则照样报上面的错。 - **例外**:徽章标为 `_公共:URL/token(无 sheet 定位)…_` 的 shortcut(如 `+workbook-info` / `+workbook-export` / `+batch-update` / `+dropdown-get|update|delete` / `+cells-batch-set-style` / `+cells-batch-clear` / `+sheet-create`)**不接受也不需要** sheet 定位,只给一组 spreadsheet 定位即可。 | Flag | Type | 必填 | 说明 | @@ -127,3 +127,35 @@ lark-cli sheets +cells-set --url "..." --sheet-name "Sheet1" --range "A1:B2" --c ``` **`@file` 接绝对路径会被拒,且被拒后不要照报错提示做。** `@file` 出于安全只接受 cwd 下的相对路径,传 `@/tmp/cells.json` 这类绝对路径或 cwd 之外的路径会被拒。此时报错会建议"先 cd 到目标目录,或改用相对路径"——**两条都不要照做**:cd 过去、或把临时文件写进用户项目目录,都会污染工作目录。正解是改用 stdin(`-- - < 文件`)。 + +## Shell 调用注意事项:A1 reference 含 `!` 的引号选择 + +A1 表示法的 sheet 前缀(`Sheet1!A1`)里那个 `!` 在 interactive bash(豆包 ShellExec / 任何带 history 的 shell)下是**历史展开触发字符**。`!Word` 形式会被 bash 当成"展开最近以 Word 开头的历史命令",如果没有匹配就报: + +``` +bash: !A1: event not found +``` + +——命令根本到不了 lark-cli。**double-quote 内 `!` 仍会触发 histexpand**;只有 single-quote 或显式关闭 history expansion 才能让 `!` 字面通过。 + +**对 agent 的可执行规则**: + +| 场景 | 推荐写法 | 说明 | +|---|---|---| +| 一次性调用,sheet 名无特殊字符 | `--source 'Sheet1!A1:D100'` | shell single-quote 整段包住,A1 内部不带单引号也合法 | +| 一次性调用,sheet 名含 `-` / 空格 / 非 ASCII | `set +H; lark-cli sheets … --source "'Sales-Q1'!A1:D100"` | 先 `set +H` 关 histexpand,然后 double-quote 外层 + A1 内部 single-quote 包裹 sheet 名 | +| 该 shell session 后续会跑大量 lark-cli 命令 | 在 session 第一行执行 `set +H`,后续全程 double-quote 即可 | 一次性配置,减少每条命令的引号心智 | +| 实在写不对 | `+sheet-rename --title ` 先把含特殊字符的 sheet 改成 plain 名 | U046 模型走通的兜底路径,但要权衡是否会破坏其它引用 | + +**反模式**: + +- ❌ `--source "Sheet1!A1:D100"`(double-quote + 未关 histexpand)—— `!A1` 被吃掉,命令不达 lark-cli +- ❌ `--source 'Sales-Q1'\''!A1:D100'`(缺一节 `'\''`)—— shell 把 `Sales-Q1` 当字符串、`!A1` 又触发 histexpand +- ❌ `--source "'Sheet1'!A1"`(未 `set +H` 的 double-quote)—— A1 内部包是对的、但 shell 层的 `!A1` 还会被展开 + +**注意区分两层引号**: + +- **shell 层引号**(bash 的 `"..."` / `'...'`):决定 bash 是否做参数展开、变量替换、history expansion +- **A1 层引号**(A1 reference 字符串里的 `'Sheet name'!A1`):Excel/Sheets 标准,sheet 名含 `-` / 空格 / 非 ASCII 字符时**必须**单引号包裹;plain 名时可选(建议总是包裹以保持一致) + +bash single-quote 不可嵌套,要在 single-quote 中再放 single-quote,用序列 `'\''`(关闭外层 single-quote → 转义一个 single-quote → 重开外层 single-quote);嫌麻烦就用 `set +H` 方案。 diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md index f575cade5..3c242f9ef 100644 --- a/skills/lark-sheets/references/lark-sheets-chart.md +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -53,7 +53,7 @@ > **正确做法**: > 1. 在 `data` 下显式设置 `"headerMode": "detached"`; > 2. `refs` **只覆盖该子集的纯数据**,不要向上/向左多带 1 行/列,也不要把全局表头整段并进来(否则会把其它分组的数据混进图); -> 3. **`nameRef` 必填**:给 `dim1.serie.nameRef` 写真正表头中"类别名"那一格的 A1 引用(如 `Sheet2!A1`),给每个 `dim2.series[i].nameRef` 写对应数值列的 A1 引用(如 `Sheet2!C1`、`Sheet2!D1`)。任一缺失会被校验拦下并报 `headerMode=detached requires ... nameRef`; +> 3. **`nameRef` 必填**:给 `dim1.serie.nameRef` 写真正表头中"类别名"那一格的 A1 引用(如 `'Sheet2'!A1`,sheet 名按 A1 标准单引号包裹),给每个 `dim2.series[i].nameRef` 写对应数值列的 A1 引用(如 `'Sheet2'!C1`、`'Sheet2'!D1`)。任一缺失会被校验拦下并报 `headerMode=detached requires ... nameRef`; > 4. `refs[i].value` 必须是单元格或普通矩形范围(CELL / NORMAL),不接受整行/整列/开区间;`direction='column'` 时起始行必须 > 0,`direction='row'` 时起始列必须 > 0; > 5. `index` 仍按 `refs` 内的列/行号填,从 1 开始。 > @@ -173,7 +173,7 @@ lark-cli sheets +chart-create --url "https://example.feishu.cn/sheets/shtXXX" \ "size":{"width":600,"height":400}, "snapshot":{ "data":{ - "refs":[{"value":"Sheet1!A1:B10"}], + "refs":[{"value":"'Sheet1'!A1:B10"}], "dim1":{"serie":{"index":1}}, "dim2":{"series":[{"index":2}]} }, @@ -203,7 +203,7 @@ lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties '{ }] }}, "data":{ - "refs":[{"value":"Sheet1!A1:B11"}], + "refs":[{"value":"'Sheet1'!A1:B11"}], "dim1":{"serie":{"index":1,"aggregate":true}}, "dim2":{"series":[{"index":2,"aggregateType":"sum"}]} } @@ -225,11 +225,11 @@ lark-cli sheets +chart-create --url "..." --sheet-name "Sheet2" --properties '{ "data":{ "headerMode":"detached", "direction":"column", - "refs":[{"value":"Sheet2!A11:D17"}], - "dim1":{"serie":{"index":1,"nameRef":"Sheet2!A1"}}, + "refs":[{"value":"'Sheet2'!A11:D17"}], + "dim1":{"serie":{"index":1,"nameRef":"'Sheet2'!A1"}}, "dim2":{"series":[ - {"index":3,"nameRef":"Sheet2!C1"}, - {"index":4,"nameRef":"Sheet2!D1"} + {"index":3,"nameRef":"'Sheet2'!C1"}, + {"index":4,"nameRef":"'Sheet2'!D1"} ]} } } @@ -250,9 +250,9 @@ lark-cli sheets +chart-create --url "..." --sheet-name "Sheet2" --properties '{ ```jsonc // 错误 1:refs 含全局表头但跨段 —— 多个区域被混进同一张图 -{"data":{"refs":[{"value":"Sheet!A1:E17"}], ... }} // 华东图混进华北 8 行 +{"data":{"refs":[{"value":"'Sheet'!A1:E17"}], ... }} // 华东图混进华北 8 行 // 错误 2:inline + refs 只取数据段、不写 detached/nameRef —— 图例显示成具体数据值 -{"data":{"refs":[{"value":"Sheet!A10:E17"}],"dim1":{"serie":{"index":1}}, ... }} +{"data":{"refs":[{"value":"'Sheet'!A10:E17"}],"dim1":{"serie":{"index":1}}, ... }} ``` ✅ 正确模式:3 张图各自 detached、refs 干净不重叠: @@ -261,15 +261,15 @@ lark-cli sheets +chart-create --url "..." --sheet-name "Sheet2" --properties '{ // 图 1:华北 {"data":{ "headerMode":"detached","direction":"column", - "refs":[{"value":"Sheet!A2:E9"}], - "dim1":{"serie":{"index":1,"nameRef":"Sheet!A1"}}, + "refs":[{"value":"'Sheet'!A2:E9"}], + "dim1":{"serie":{"index":1,"nameRef":"'Sheet'!A1"}}, "dim2":{"series":[ - {"index":3,"nameRef":"Sheet!C1"}, - {"index":4,"nameRef":"Sheet!D1"} + {"index":3,"nameRef":"'Sheet'!C1"}, + {"index":4,"nameRef":"'Sheet'!D1"} ]} }} -// 图 2:华东 —— refs 改 Sheet!A10:E17,其余同上 -// 图 3:华南 —— refs 改 Sheet!A18:E25,其余同上 +// 图 2:华东 —— refs 改 'Sheet'!A10:E17,其余同上 +// 图 3:华南 —— refs 改 'Sheet'!A18:E25,其余同上 ``` > `--properties` JSON 关键字段: diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md index 3a8f1cafa..4b8b524c1 100644 --- a/skills/lark-sheets/references/lark-sheets-pivot-table.md +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -121,7 +121,7 @@ lark-cli sheets +pivot-list --url "..." --sheet-id "$SID" > 数据源 `--source` 必须从表头行开始;空行 / 汇总行会被当作数据参与聚合,需提前用 `+csv-get` 确认起止边界。`--source` 和 `--range` 是独立 flag(不要再放 `--properties`);`rows` / `columns` / `values` 等数组字段走 `--properties`。 > > **先理清 `+pivot-create` 上 4 个位置类入参(语义不同,别混)**: -> - `--source`(**必填**):**源数据**区域,须自带 `Sheet!` 前缀(如 `Sheet1!A1:D100`)。源 sheet 的名字在 `--source` 字符串里,**不**通过单独 flag 传。 +> - `--source`(**必填**):**源数据**区域,须自带 `Sheet!` 前缀(如 `'Sheet1'!A1:D100`,sheet 名按 A1 标准单引号包裹)。源 sheet 的名字在 `--source` 字符串里,**不**通过单独 flag 传。 > - `--sheet-id` / `--sheet-name`:**透视表的落点 sheet**(即产物放哪张子表)。两个互斥(最多传一个),都不传时后端自动新建子表存放产物(强烈推荐)。**注意:跟其它 shortcut 不同,这里 `--sheet-id` / `--sheet-name` 表达的不是"数据源所在 sheet"而是"产物落点 sheet"**。 > - `--target-position`(可选,A1 表示法,默认 `A1`):落点 sheet 内的起始 cell,映射到顶层 `target_position`。 > - `--range`(可选,A1 单值,仅 create 生效):跟 `--target-position` 表达同一意图但映射到 `properties.range`,**两者不要同时给**。 @@ -136,13 +136,18 @@ lark-cli sheets +pivot-list --url "..." --sheet-id "$SID" ```bash # 策略 1(强烈推荐):不传任何落点 flag → 后端自动新建子表,零覆盖风险 lark-cli sheets +pivot-create --url "..." \ - --source "Sheet1!A1:D100" --properties @pivot.json + --source "'Sheet1'!A1:D100" --properties @pivot.json # 策略 2:落进指定的已有目标子表(注意目标 sheet ≠ 源 sheet,否则要配 --target-position 避开源数据) lark-cli sheets +pivot-create --url "..." \ - --source "Sheet1!A1:D100" --sheet-id "$DEST_SID" --target-position "A1" --properties @pivot.json + --source "'Sheet1'!A1:D100" --sheet-id "$DEST_SID" --target-position "A1" --properties @pivot.json ``` +> ⚠️ 上面 bash 示例的 `--source` 用了双引号,在 interactive bash(含豆包 ShellExec)下 `!` 会触发 history expansion 报 `bash: !A1: event not found`。**实际跑命令时**用以下任一种写法: +> - **首选**:命令最前面加 `set +H;` 关掉 history expansion,全程 double-quote 不踩坑 +> - **次选**:`--source` 整体改 shell single-quote,但 sheet 名内的 A1 单引号需要 `'\''` 转义:`--source ''\''Sheet1'\''!A1:D100'` +> - **应急**:先 `+sheet-rename --title ` 把含 `-` / 空格的 sheet 名改成 plain 名(U046 模型最终走通的兜底路径) + ### `+pivot-update` > 不允许改 `--source` / `--range`(透视表创建后位置/数据源固定);只能用 `--properties` 改 rows / columns / values / filters 等。先 `+pivot-list --pivot-table-id ` 回读再 patch,避免漏字段。 diff --git a/skills/lark-sheets/references/lark-sheets-sparkline.md b/skills/lark-sheets/references/lark-sheets-sparkline.md index 6c2bea74c..090dbcda5 100644 --- a/skills/lark-sheets/references/lark-sheets-sparkline.md +++ b/skills/lark-sheets/references/lark-sheets-sparkline.md @@ -108,8 +108,8 @@ lark-cli sheets +sparkline-create --url "..." --sheet-id "$SID" --properties @sp { "config": { "line_width": 2 }, "sparklines": [ - {"position": {"row": 1, "col": "F"}, "source": "Sheet1!A2:E2"}, - {"position": {"row": 2, "col": "F"}, "source": "Sheet1!A3:E3"} + {"position": {"row": 1, "col": "F"}, "source": "'Sheet1'!A2:E2"}, + {"position": {"row": 2, "col": "F"}, "source": "'Sheet1'!A3:E3"} ] } ``` @@ -122,8 +122,8 @@ lark-cli sheets +sparkline-create --url "..." --sheet-id "$SID" --properties @sp # 假设 +sparkline-list 已返回 group_id=grpA,组内 sparkline_id=sl_1 / sl_2 lark-cli sheets +sparkline-update --url "..." --sheet-id "$SID" --group-id "grpA" --properties '{ "sparklines": [ - {"sparkline_id":"sl_1","source":"Sheet1!A2:A20"}, - {"sparkline_id":"sl_2","source":"Sheet1!B2:B20"} + {"sparkline_id":"sl_1","source":"'Sheet1'!A2:A20"}, + {"sparkline_id":"sl_2","source":"'Sheet1'!B2:B20"} ] }' ``` diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index d19f2febf..3fe4926fd 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -143,9 +143,9 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl | flag | 选项来源 | 适用场景 | |---|---|---| | `--options '["a","b","c"]'` | 写在命令里的固定列表 | 选项集是常量、不需要事后维护 | -| `--source-range 'Sheet1!T1:T3'` | 已有单元格里的值 | 选项要跟数据动态同步;想维护一张「枚举值」列后多处引用 | +| `--source-range '''Sheet1''!T1:T3'` | 已有单元格里的值 | 选项要跟数据动态同步;想维护一张「枚举值」列后多处引用 | -两个 flag **必须传一个、且只能传一个**——同时传或都不传,CLI 会立刻报错。`--source-range` 用 A1 + sheet 前缀写法(如 `Sheet1!T1:T3`),可以指同 sheet 也可以指其它 sheet(如 `Refs!A1:A10`)。 +两个 flag **必须传一个、且只能传一个**——同时传或都不传,CLI 会立刻报错。`--source-range` 用 A1 + sheet 前缀写法(如 `'Sheet1'!T1:T3`,sheet 名按 A1 标准单引号包裹),可以指同 sheet 也可以指其它 sheet(如 `'Refs'!A1:A10`)。 ### 配色:默认即上色,三种意图三条线 @@ -182,16 +182,18 @@ lark-cli sheets +dropdown-set \ --colors '["#bff7d9","#FFE699","#bacefd"]' ``` -**`--source-range` 模式**(先在 `Sheet1!T1:T3` 维护「男/女/保密」三行,再让 `B2:B21` 引用它): +**`--source-range` 模式**(先在 `'Sheet1'!T1:T3` 维护「男/女/保密」三行,再让 `B2:B21` 引用它): ``` lark-cli sheets +dropdown-set \ --url https://... --sheet-id \ --range B2:B21 \ - --source-range 'Sheet1!T1:T3' \ + --source-range ''\''Sheet1'\''!T1:T3' \ --colors '["#cce8ff","#ffd6e7","#e6e6e6"]' ``` +> ⚠️ `--source-range` 的 shell 包法:上面用 `''\''Sheet1'\''!T1:T3'` 是为了在 shell single-quote 外层中嵌入 A1 内部 single-quote(防 bash 历史展开 `!T1`)。等效更简单写法:命令最前加 `set +H;` 关 history expansion,然后 `--source-range "'Sheet1'!T1:T3"`。详见 SKILL.md「Shell 调用注意事项」。 + **纯白下拉**(明确告诉用户"不要彩色"时才用): ``` From 690e74689667db70255ebce642b711f89f49ee5c Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Thu, 28 May 2026 18:03:59 +0800 Subject: [PATCH 079/114] docs(sheets): drop bash histexpand section, fix write-cells table escape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync from spec, refining the bash-quoting deep-dive added in 0f695b6: - Drop the `## Shell 调用注意事项` section in SKILL.md and the inline `⚠️ bash 引号` callouts in lark-sheets-pivot-table.md and lark-sheets-write-cells.md. The 4-scenario quoting table + anti-pattern list turned out too verbose for the SKILL intro; single-quoted examples in the references are themselves enough nudge. - lark-sheets-write-cells.md L146: fix the table cell escape from the malformed `'''Sheet1''!T1:T3'` (consecutive `''` are no-op empty strings) to `''\''Sheet1'\''!T1:T3'`, matching the bash example at L191 verbatim. Net: 1 insertion, 40 deletions across 3 files. --- skills/lark-sheets/SKILL.md | 32 ------------------- .../references/lark-sheets-pivot-table.md | 5 --- .../references/lark-sheets-write-cells.md | 4 +-- 3 files changed, 1 insertion(+), 40 deletions(-) diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index 290eafd62..773a4d9d1 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -127,35 +127,3 @@ lark-cli sheets +cells-set --url "..." --sheet-name "Sheet1" --range "A1:B2" --c ``` **`@file` 接绝对路径会被拒,且被拒后不要照报错提示做。** `@file` 出于安全只接受 cwd 下的相对路径,传 `@/tmp/cells.json` 这类绝对路径或 cwd 之外的路径会被拒。此时报错会建议"先 cd 到目标目录,或改用相对路径"——**两条都不要照做**:cd 过去、或把临时文件写进用户项目目录,都会污染工作目录。正解是改用 stdin(`-- - < 文件`)。 - -## Shell 调用注意事项:A1 reference 含 `!` 的引号选择 - -A1 表示法的 sheet 前缀(`Sheet1!A1`)里那个 `!` 在 interactive bash(豆包 ShellExec / 任何带 history 的 shell)下是**历史展开触发字符**。`!Word` 形式会被 bash 当成"展开最近以 Word 开头的历史命令",如果没有匹配就报: - -``` -bash: !A1: event not found -``` - -——命令根本到不了 lark-cli。**double-quote 内 `!` 仍会触发 histexpand**;只有 single-quote 或显式关闭 history expansion 才能让 `!` 字面通过。 - -**对 agent 的可执行规则**: - -| 场景 | 推荐写法 | 说明 | -|---|---|---| -| 一次性调用,sheet 名无特殊字符 | `--source 'Sheet1!A1:D100'` | shell single-quote 整段包住,A1 内部不带单引号也合法 | -| 一次性调用,sheet 名含 `-` / 空格 / 非 ASCII | `set +H; lark-cli sheets … --source "'Sales-Q1'!A1:D100"` | 先 `set +H` 关 histexpand,然后 double-quote 外层 + A1 内部 single-quote 包裹 sheet 名 | -| 该 shell session 后续会跑大量 lark-cli 命令 | 在 session 第一行执行 `set +H`,后续全程 double-quote 即可 | 一次性配置,减少每条命令的引号心智 | -| 实在写不对 | `+sheet-rename --title ` 先把含特殊字符的 sheet 改成 plain 名 | U046 模型走通的兜底路径,但要权衡是否会破坏其它引用 | - -**反模式**: - -- ❌ `--source "Sheet1!A1:D100"`(double-quote + 未关 histexpand)—— `!A1` 被吃掉,命令不达 lark-cli -- ❌ `--source 'Sales-Q1'\''!A1:D100'`(缺一节 `'\''`)—— shell 把 `Sales-Q1` 当字符串、`!A1` 又触发 histexpand -- ❌ `--source "'Sheet1'!A1"`(未 `set +H` 的 double-quote)—— A1 内部包是对的、但 shell 层的 `!A1` 还会被展开 - -**注意区分两层引号**: - -- **shell 层引号**(bash 的 `"..."` / `'...'`):决定 bash 是否做参数展开、变量替换、history expansion -- **A1 层引号**(A1 reference 字符串里的 `'Sheet name'!A1`):Excel/Sheets 标准,sheet 名含 `-` / 空格 / 非 ASCII 字符时**必须**单引号包裹;plain 名时可选(建议总是包裹以保持一致) - -bash single-quote 不可嵌套,要在 single-quote 中再放 single-quote,用序列 `'\''`(关闭外层 single-quote → 转义一个 single-quote → 重开外层 single-quote);嫌麻烦就用 `set +H` 方案。 diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md index 4b8b524c1..a13a70577 100644 --- a/skills/lark-sheets/references/lark-sheets-pivot-table.md +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -143,11 +143,6 @@ lark-cli sheets +pivot-create --url "..." \ --source "'Sheet1'!A1:D100" --sheet-id "$DEST_SID" --target-position "A1" --properties @pivot.json ``` -> ⚠️ 上面 bash 示例的 `--source` 用了双引号,在 interactive bash(含豆包 ShellExec)下 `!` 会触发 history expansion 报 `bash: !A1: event not found`。**实际跑命令时**用以下任一种写法: -> - **首选**:命令最前面加 `set +H;` 关掉 history expansion,全程 double-quote 不踩坑 -> - **次选**:`--source` 整体改 shell single-quote,但 sheet 名内的 A1 单引号需要 `'\''` 转义:`--source ''\''Sheet1'\''!A1:D100'` -> - **应急**:先 `+sheet-rename --title ` 把含 `-` / 空格的 sheet 名改成 plain 名(U046 模型最终走通的兜底路径) - ### `+pivot-update` > 不允许改 `--source` / `--range`(透视表创建后位置/数据源固定);只能用 `--properties` 改 rows / columns / values / filters 等。先 `+pivot-list --pivot-table-id ` 回读再 patch,避免漏字段。 diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 3fe4926fd..a0df4ff00 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -143,7 +143,7 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl | flag | 选项来源 | 适用场景 | |---|---|---| | `--options '["a","b","c"]'` | 写在命令里的固定列表 | 选项集是常量、不需要事后维护 | -| `--source-range '''Sheet1''!T1:T3'` | 已有单元格里的值 | 选项要跟数据动态同步;想维护一张「枚举值」列后多处引用 | +| `--source-range ''\''Sheet1'\''!T1:T3'` | 已有单元格里的值 | 选项要跟数据动态同步;想维护一张「枚举值」列后多处引用 | 两个 flag **必须传一个、且只能传一个**——同时传或都不传,CLI 会立刻报错。`--source-range` 用 A1 + sheet 前缀写法(如 `'Sheet1'!T1:T3`,sheet 名按 A1 标准单引号包裹),可以指同 sheet 也可以指其它 sheet(如 `'Refs'!A1:A10`)。 @@ -192,8 +192,6 @@ lark-cli sheets +dropdown-set \ --colors '["#cce8ff","#ffd6e7","#e6e6e6"]' ``` -> ⚠️ `--source-range` 的 shell 包法:上面用 `''\''Sheet1'\''!T1:T3'` 是为了在 shell single-quote 外层中嵌入 A1 内部 single-quote(防 bash 历史展开 `!T1`)。等效更简单写法:命令最前加 `set +H;` 关 history expansion,然后 `--source-range "'Sheet1'!T1:T3"`。详见 SKILL.md「Shell 调用注意事项」。 - **纯白下拉**(明确告诉用户"不要彩色"时才用): ``` From 9c3d30aa009a623d40ba9ff72a423dac431b0b6c Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Thu, 28 May 2026 19:15:38 +0800 Subject: [PATCH 080/114] =?UTF-8?q?feat(sheets):=20rename=20+pivot-create?= =?UTF-8?q?=20sheet=20selector=20=E2=86=92=20--target-sheet-{id,name}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit +pivot-create's placement selector (where the pivot table lands) is no longer the generic --sheet-id / --sheet-name; it is now --target-sheet-id / --target-sheet-name. The new names mark this as the *output* sheet, distinct from the *data-source* sheet (which lives inside --source as `'Sheet'!Range`). The other +pivot-{list,update,delete} shortcuts keep --sheet-id / --sheet-name (their semantics are "sheet that hosts the existing pivot", same as every other shortcut). Motivation: an LLM agent reading the previous CLI surface saw +pivot-create expose --sheet-id and assumed (as it had to) that it pointed at the data source, like every other shortcut. The new flag name makes the intent unambiguous at the call site, without relying on the agent having read the narrative caveat in the reference doc. Background: evaluation case U046 spent multiple rounds tripping on this exact confusion before working around it with +sheet-rename. Implementation: - objectCRUDSpec gains createSheetIDFlag / createSheetNameFlag (with default-fallback accessors sheetIDFlagOnCreate / sheetNameFlagOnCreate); newObjectCreateShortcut + objectCreateInput consult the spec instead of hard-coded "sheet-id" / "sheet-name". pivotSpec sets target-sheet-*; every other create spec inherits the defaults. - optionalSheetSelector (only used by pivot create) takes the two flag names as parameters so its mutex / control-char errors quote the names the user actually typed (--target-sheet-id, not --sheet-id). - batch_op_dispatch: introduce sheetSelectorFlagsForSubOp(shortcut) → (idFlag, nameFlag) returning target-sheet-* for "+pivot-create" and the defaults otherwise; translateBatchOp uses it so +pivot-create sub-ops in +batch-update accept the same renamed input keys. - Tests: - lark_sheet_object_crud_test.go: pivot-create cases switch args and expected error wording to target-sheet-*; extra assertion that the mutex error quotes the renamed flag (regression guard against flag-name drift between code and error message). - batch_op_contract_test.go: +pivot-create sub-op test uses target-sheet-id / target-sheet-name input keys; the body-vs-standalone contract loop reads the selector via sheetSelectorFlagsForSubOp so every other shortcut keeps using sheet-id / sheet-name. Synced reference docs (skills/lark-sheets/{SKILL.md, references/lark-sheets-pivot-table.md}) mirror the spec's new flag names, narrative, 3-placement-strategy block, and SKILL.md exception bullet that explains why +pivot-create's badge says 无 sheet 定位 yet still has placement selectors (just under different names). flag-defs.json synced from spec picks up the renamed flags + kind=own. All sheets-package tests pass. --- shortcuts/sheets/batch_op_contract_test.go | 19 ++++++--- shortcuts/sheets/batch_op_dispatch.go | 20 ++++++++- shortcuts/sheets/data/flag-defs.json | 28 ++++++------- shortcuts/sheets/helpers.go | 14 +++++-- shortcuts/sheets/lark_sheet_object_crud.go | 42 +++++++++++++++---- .../sheets/lark_sheet_object_crud_test.go | 41 +++++++++++------- skills/lark-sheets/SKILL.md | 1 + .../references/lark-sheets-pivot-table.md | 22 ++++++---- 8 files changed, 129 insertions(+), 58 deletions(-) diff --git a/shortcuts/sheets/batch_op_contract_test.go b/shortcuts/sheets/batch_op_contract_test.go index ba4fa9cf9..1073dd985 100644 --- a/shortcuts/sheets/batch_op_contract_test.go +++ b/shortcuts/sheets/batch_op_contract_test.go @@ -214,8 +214,12 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) { { shortcut: "+pivot-create", sc: PivotCreate, - args: []string{"--sheet-id", "sh1", "--properties", `{"rows":[]}`, "--source", "Sheet1!A1:D100"}, - subInput: `{"sheet-id":"sh1","properties":{"rows":[]},"source":"Sheet1!A1:D100"}`, + // +pivot-create renamed --sheet-id / --sheet-name → --target-sheet-id / + // --target-sheet-name to flag the placement-sheet semantics (the data + // source is in --source). Both standalone args and the +batch-update + // sub-op input must use the new names. + args: []string{"--target-sheet-id", "sh1", "--properties", `{"rows":[]}`, "--source", "Sheet1!A1:D100"}, + subInput: `{"target-sheet-id":"sh1","properties":{"rows":[]},"source":"Sheet1!A1:D100"}`, }, { shortcut: "+cond-format-create", @@ -292,10 +296,13 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) { t.Fatalf("bad subInput JSON: %v", err) } fv := newMapFlagViewForCommand(tc.shortcut, subInput) - sid := subInput["sheet-id"] - sname := subInput["sheet-name"] - sidStr, _ := sid.(string) - snameStr, _ := sname.(string) + // Match what translateBatchOp does — read the sheet selector + // via the shortcut-specific flag names so +pivot-create + // (target-sheet-id / target-sheet-name) and the rest + // (sheet-id / sheet-name) both resolve correctly. + sidFlag, snameFlag := sheetSelectorFlagsForSubOp(tc.shortcut) + sidStr, _ := subInput[sidFlag].(string) + snameStr, _ := subInput[snameFlag].(string) batchBody, err := mapping.translate(fv, testToken, sidStr, snameStr) if err != nil { t.Fatalf("batch translate failed: %v", err) diff --git a/shortcuts/sheets/batch_op_dispatch.go b/shortcuts/sheets/batch_op_dispatch.go index 672b48d5b..f0b36e64e 100644 --- a/shortcuts/sheets/batch_op_dispatch.go +++ b/shortcuts/sheets/batch_op_dispatch.go @@ -43,6 +43,21 @@ type batchOpMapping struct { translate batchTranslateFn } +// sheetSelectorFlagsForSubOp returns the (id, name) flag names a +batch-update +// sub-op uses to express its placement / context sheet. Defaults are +// `sheet-id` / `sheet-name`; +pivot-create deviates because its create +// shortcut renamed the placement selector to `target-sheet-id` / +// `target-sheet-name` (the data-source sheet is encoded in --source as +// `'SheetName'!Range`, not in a sheet selector flag). Update / delete on +// pivot still use the default names — only the create create-side +// shortcut was renamed. +func sheetSelectorFlagsForSubOp(shortcut string) (string, string) { + if shortcut == "+pivot-create" { + return "target-sheet-id", "target-sheet-name" + } + return "sheet-id", "sheet-name" +} + // objCreateTranslate / objUpdateTranslate / objDeleteTranslate bind an object // CRUD spec to the shared object_crud builders. func objCreateTranslate(spec objectCRUDSpec) batchTranslateFn { @@ -278,8 +293,9 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte } } fv := newMapFlagViewForCommand(sc, input) - sheetID := strings.TrimSpace(fv.Str("sheet-id")) - sheetName := strings.TrimSpace(fv.Str("sheet-name")) + 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, common.FlagErrorf("operations[%d] (%s): %v", index, sc, err) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index de3b1ddf6..2bec61ca1 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -3106,20 +3106,6 @@ "required": "xor", "desc": "Spreadsheet token (XOR with `--url`)" }, - { - "name": "sheet-id", - "kind": "public", - "type": "string", - "required": "xor", - "desc": "Reference_id of the sub-sheet where the pivot table is located / will be created to (mutually exclusive with --sheet-name; takes priority when both given; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended)" - }, - { - "name": "sheet-name", - "kind": "public", - "type": "string", - "required": "xor", - "desc": "Name of the sub-sheet where the pivot table is located / will be created to (mutually exclusive with --sheet-id; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended)" - }, { "name": "properties", "kind": "own", @@ -3139,6 +3125,20 @@ "desc": "Top-left cell within the target sub-sheet (A1 notation, e.g. `A1`); maps to the top-level `target_position`, default `A1` (not sent when the value is A1). It and `--range` both express placement but map to different wire fields — avoid passing conflicting values for both.", "default": "A1" }, + { + "name": "target-sheet-id", + "kind": "own", + "type": "string", + "required": "xor", + "desc": "Reference_id of the target sub-sheet where the pivot table will be placed (mutually exclusive with `--target-sheet-name`; takes priority when both given; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended). Distinct from the data-source sheet, which lives inside --source as a 'Sheet'-prefixed A1 reference like 'Sheet1'!A1:D100." + }, + { + "name": "target-sheet-name", + "kind": "own", + "type": "string", + "required": "xor", + "desc": "Name of the target sub-sheet where the pivot table will be placed (mutually exclusive with `--target-sheet-id`; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended). Distinct from the data-source sheet, which lives inside --source as a 'Sheet'-prefixed A1 reference like 'Sheet1'!A1:D100." + }, { "name": "source", "kind": "own", diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go index 28fcc1d5c..3b8bf1daf 100644 --- a/shortcuts/sheets/helpers.go +++ b/shortcuts/sheets/helpers.go @@ -142,18 +142,24 @@ func requireSheetSelector(sheetID, sheetName string) error { // Used by shortcuts whose backend tool treats sheet_id/sheet_name as the // placement target rather than the operation context (currently only // +pivot-create). Other shortcuts continue to use requireSheetSelector. -func optionalSheetSelector(sheetID, sheetName string) error { +// +// idFlagName / nameFlagName parameterize the flag names quoted back in +// the mutex / control-char errors — +pivot-create exposes the placement +// selector as `--target-sheet-id` / `--target-sheet-name`, not the +// generic `--sheet-id` / `--sheet-name`, and the error wording must +// match what the user actually typed. +func optionalSheetSelector(sheetID, sheetName, idFlagName, nameFlagName string) error { sheetID = strings.TrimSpace(sheetID) sheetName = strings.TrimSpace(sheetName) if sheetID != "" && sheetName != "" { - return common.FlagErrorf("--sheet-id and --sheet-name are mutually exclusive") + return common.FlagErrorf("--%s and --%s are mutually exclusive", idFlagName, nameFlagName) } if sheetID != "" { - if err := validate.RejectControlChars(sheetID, "sheet-id"); err != nil { + if err := validate.RejectControlChars(sheetID, idFlagName); err != nil { return common.FlagErrorf("%v", err) } } else if sheetName != "" { - if err := validate.RejectControlChars(sheetName, "sheet-name"); err != nil { + if err := validate.RejectControlChars(sheetName, nameFlagName); err != nil { return common.FlagErrorf("%v", err) } } diff --git a/shortcuts/sheets/lark_sheet_object_crud.go b/shortcuts/sheets/lark_sheet_object_crud.go index 69ac31dbd..b896729ea 100644 --- a/shortcuts/sheets/lark_sheet_object_crud.go +++ b/shortcuts/sheets/lark_sheet_object_crud.go @@ -62,6 +62,32 @@ type objectCRUDSpec struct { // rejected. Update/delete continue to require an explicit selector. // Today only pivotSpec opts in. allowEmptySheetSelectorOnCreate bool + // createSheetIDFlag / createSheetNameFlag override the default + // `sheet-id` / `sheet-name` flag names on the *create* shortcut and + // its +batch-update sub-op. Used by pivot to expose + // `target-sheet-id` / `target-sheet-name` — the placement target, + // semantically distinct from the data-source sheet (which is encoded + // in --source as `'SheetName'!Range`). Empty = default names. + // Update/delete continue to use `sheet-id` / `sheet-name`. + createSheetIDFlag string + createSheetNameFlag string +} + +// sheetIDFlagOnCreate / sheetNameFlagOnCreate return the cobra flag name +// used to read the placement-sheet selector on this spec's create +// shortcut. Defaults to `sheet-id` / `sheet-name`. +func (s objectCRUDSpec) sheetIDFlagOnCreate() string { + if s.createSheetIDFlag != "" { + return s.createSheetIDFlag + } + return "sheet-id" +} + +func (s objectCRUDSpec) sheetNameFlagOnCreate() string { + if s.createSheetNameFlag != "" { + return s.createSheetNameFlag + } + return "sheet-name" } func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { @@ -80,15 +106,15 @@ func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { if err != nil { return err } - sheetID := strings.TrimSpace(runtime.Str("sheet-id")) - sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + sheetID := strings.TrimSpace(runtime.Str(spec.sheetIDFlagOnCreate())) + sheetName := strings.TrimSpace(runtime.Str(spec.sheetNameFlagOnCreate())) _, err = objectCreateInput(runtime, token, sheetID, sheetName, spec) return err }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) - sheetID := strings.TrimSpace(runtime.Str("sheet-id")) - sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + sheetID := strings.TrimSpace(runtime.Str(spec.sheetIDFlagOnCreate())) + sheetName := strings.TrimSpace(runtime.Str(spec.sheetNameFlagOnCreate())) input, _ := objectCreateInput(runtime, token, sheetID, sheetName, spec) return invokeToolDryRun(token, ToolKindWrite, spec.toolName, input) }, @@ -97,8 +123,8 @@ func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { if err != nil { return err } - sheetID := strings.TrimSpace(runtime.Str("sheet-id")) - sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + sheetID := strings.TrimSpace(runtime.Str(spec.sheetIDFlagOnCreate())) + sheetName := strings.TrimSpace(runtime.Str(spec.sheetNameFlagOnCreate())) input, err := objectCreateInput(runtime, token, sheetID, sheetName, spec) if err != nil { return err @@ -116,7 +142,7 @@ func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { func objectCreateInput(runtime flagView, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) { var err error if spec.allowEmptySheetSelectorOnCreate { - err = optionalSheetSelector(sheetID, sheetName) + err = optionalSheetSelector(sheetID, sheetName, spec.sheetIDFlagOnCreate(), spec.sheetNameFlagOnCreate()) } else { err = requireSheetSelector(sheetID, sheetName) } @@ -312,6 +338,8 @@ var pivotSpec = objectCRUDSpec{ idFlag: "pivot-table-id", idField: "pivot_table_id", allowEmptySheetSelectorOnCreate: true, + createSheetIDFlag: "target-sheet-id", + createSheetNameFlag: "target-sheet-name", enhanceCreateInput: func(rt flagView, input map[string]interface{}) { if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" { input["target_position"] = v diff --git a/shortcuts/sheets/lark_sheet_object_crud_test.go b/shortcuts/sheets/lark_sheet_object_crud_test.go index aafc6dc87..d7752887a 100644 --- a/shortcuts/sheets/lark_sheet_object_crud_test.go +++ b/shortcuts/sheets/lark_sheet_object_crud_test.go @@ -52,16 +52,20 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { }, }, // pivot — has extra create flags incl. required --source. - // --sheet-id is the placement target (where the pivot lands); - // pivotSpec.allowEmptySheetSelectorOnCreate lets both --sheet-id - // and --sheet-name be omitted so the backend auto-creates a - // sub-sheet — covered separately in the +pivot-create empty- - // selector / mutex tests below. + // --target-sheet-id is the placement target (where the pivot lands); + // the placement selector is renamed from the generic --sheet-id / + // --sheet-name to --target-sheet-id / --target-sheet-name to keep + // it semantically distinct from the data-source sheet (which is + // encoded inside --source as `'SheetName'!Range`). + // pivotSpec.allowEmptySheetSelectorOnCreate lets both target + // selectors be omitted so the backend auto-creates a sub-sheet — + // covered separately in the +pivot-create empty-selector / mutex + // tests below. { name: "+pivot-create with placement / source / range flags", sc: PivotCreate, args: []string{ - "--url", testURL, "--sheet-id", testSheetID, + "--url", testURL, "--target-sheet-id", testSheetID, "--properties", `{"rows":[{"field":"A"}]}`, "--source", "Sheet1!A1:F1000", "--range", "F1", @@ -80,10 +84,10 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { }, }, }, - // +pivot-create accepts both sheet selectors empty — backend + // +pivot-create accepts both target selectors empty — backend // auto-creates a placement sub-sheet. { - name: "+pivot-create empty --sheet-id / --sheet-name omits sheet from input", + name: "+pivot-create empty --target-sheet-id / --target-sheet-name omits sheet from input", sc: PivotCreate, args: []string{ "--url", testURL, @@ -349,9 +353,9 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { } // TestPivotCreate_SheetSelectorSemantics locks in the "at most one" -// semantics for +pivot-create (and only +pivot-create): both --sheet-id -// and --sheet-name may be omitted (backend auto-creates a placement -// sub-sheet), but passing both is rejected. +// semantics for +pivot-create (and only +pivot-create): both +// --target-sheet-id and --target-sheet-name may be omitted (backend +// auto-creates a placement sub-sheet), but passing both is rejected. // // Companion regression — TestObjectCreate_RequiresSheetSelector below — // confirms every other *-create still rejects empty selector. @@ -378,25 +382,30 @@ func TestPivotCreate_SheetSelectorSemantics(t *testing.T) { t.Parallel() _, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{ "--url", testURL, - "--sheet-id", testSheetID, - "--sheet-name", "Sheet1", + "--target-sheet-id", testSheetID, + "--target-sheet-name", "Sheet1", "--properties", `{"rows":[{"field":"A"}]}`, "--source", "Sheet1!A1:F1000", }) if err == nil { - t.Fatalf("expected CLI to reject both --sheet-id and --sheet-name set; stderr=%s", stderr) + t.Fatalf("expected CLI to reject both --target-sheet-id and --target-sheet-name set; stderr=%s", stderr) } combined := stderr + err.Error() if !strings.Contains(combined, "mutually exclusive") { t.Errorf("expected error to say 'mutually exclusive'; got=%s|%v", stderr, err) } + // 错误信息必须用真实的 flag 名(target-*),否则模型按消息提示去 + // 改 --sheet-id 还是错的。 + if !strings.Contains(combined, "--target-sheet-id") { + t.Errorf("expected error to quote --target-sheet-id flag name; got=%s|%v", stderr, err) + } }) - t.Run("only sheet-id is accepted", func(t *testing.T) { + t.Run("only target-sheet-id is accepted", func(t *testing.T) { t.Parallel() body := parseDryRunBody(t, PivotCreate, []string{ "--url", testURL, - "--sheet-id", testSheetID, + "--target-sheet-id", testSheetID, "--properties", `{"rows":[{"field":"A"}]}`, "--source", "Sheet1!A1:F1000", }) diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index 773a4d9d1..75cc3ba01 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -83,6 +83,7 @@ metadata: 2. **sheet 定位(公共四件套 shortcut 必填)**:`--sheet-id` 与 `--sheet-name` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --sheet-id or --sheet-name`。 - ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:即使写了 `--range "'Sheet1'!A1:B2"`,仍**必须**额外传 `--sheet-id` 或 `--sheet-name`,否则照样报上面的错。 - **例外**:徽章标为 `_公共:URL/token(无 sheet 定位)…_` 的 shortcut(如 `+workbook-info` / `+workbook-export` / `+batch-update` / `+dropdown-get|update|delete` / `+cells-batch-set-style` / `+cells-batch-clear` / `+sheet-create`)**不接受也不需要** sheet 定位,只给一组 spreadsheet 定位即可。 + - **例外**:`+pivot-create` 的徽章虽然同样是「无 sheet 定位」,但它**有自己的 sheet 选择器** `--target-sheet-id` / `--target-sheet-name`(**不是** `--sheet-id` / `--sheet-name`)——这是**透视表落点 sheet**(产物放哪张子表),与"数据源 sheet"语义不同(数据源走 `--source` 的 `'SheetName'!Range`,没有独立 flag)。两个 target 都可不传 → 后端自动新建子表存放透视表(推荐);两个都给则报 `--target-sheet-id and --target-sheet-name are mutually exclusive`。详见 `lark-sheets-pivot-table` 的 `+pivot-create` 段。 | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md index a13a70577..56d2610fd 100644 --- a/skills/lark-sheets/references/lark-sheets-pivot-table.md +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -57,12 +57,14 @@ _公共四件套 · 系统:`--dry-run`_ ### `+pivot-create` -_公共四件套 · 系统:`--dry-run`_ +_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--properties` | string + File + Stdin(复合 JSON) | required | JSON:{"rows":[...],"columns":[...],"values":[...],"filters":[...],"show_row_grand_total":true,"show_col_grand_total":true}(数据源走 --source,不要再放进 properties.source) | | `--target-position` | string | optional | 透视表落点子表内的起始 cell(A1 格式,如 `A1`),映射到顶层 `target_position`,默认 `A1`(值为 A1 时不下发)。它与 `--range` 都表达落点但落在不同 wire 字段,避免两者同时给冲突值 | +| `--target-sheet-id` | string | xor | 透视表落点目标子表的 reference_id(与 `--target-sheet-name` 互斥,优先于 --target-sheet-name;都不传时自动新建一张子表放置透视表——推荐)。与数据源 sheet 区分:数据源 sheet 写在 --source 的 A1 引用里(带 sheet 前缀,形如 `'Sheet1'!A1:D100`)。 | +| `--target-sheet-name` | string | xor | 透视表落点目标子表的名称(与 `--target-sheet-id` 互斥;都不传时自动新建一张子表放置透视表——推荐)。与数据源 sheet 区分:数据源 sheet 写在 --source 的 A1 引用里(带 sheet 前缀,形如 `'Sheet1'!A1:D100`)。 | | `--source` | string | required | 透视表源数据区域(A1 表示法,格式 `'SheetName'!StartCell:EndCell`,如 `'Sheet1'!A1:D100`) | | `--range` | string | optional | 透视表左上角放置位置(A1 单值,如 `F1`,仅 create 生效),映射到 `properties.range`;省略时放在落点子表(默认新建子表)的左上角。它与 `--target-position` 都表达落点但落在不同 wire 字段,避免两者同时给冲突值 | @@ -108,7 +110,9 @@ _创建/更新的透视表属性_ ## Examples -公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`。其中 `--sheet-id` / `--sheet-name` 在 `+pivot-update` / `+pivot-delete` / `+pivot-list` 上是公共四件套语义(定位透视表所在 sheet,XOR 必传一个);但在 **`+pivot-create` 上是透视表的"落点"语义**——两个都不传时后端自动新建子表存放产物(强烈推荐,绝不碰源数据)。 +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`,其中 `--sheet-id` / `--sheet-name` 在 `+pivot-update` / `+pivot-delete` / `+pivot-list` 上是公共四件套语义(定位透视表所在 sheet,XOR 必传一个)。 + +**`+pivot-create` 例外**:placement 选择器改名为 `--target-sheet-id` / `--target-sheet-name`(不再叫 `--sheet-id` / `--sheet-name`),明示这是"产物落点 sheet"而非"数据源 sheet"。两个都不传时后端自动新建子表存放产物(强烈推荐,绝不碰源数据)。数据源 sheet 写在 `--source` 的 `'SheetName'!Range` 里,不靠任何 sheet 选择器 flag 表达。 ### `+pivot-list` @@ -122,16 +126,16 @@ lark-cli sheets +pivot-list --url "..." --sheet-id "$SID" > > **先理清 `+pivot-create` 上 4 个位置类入参(语义不同,别混)**: > - `--source`(**必填**):**源数据**区域,须自带 `Sheet!` 前缀(如 `'Sheet1'!A1:D100`,sheet 名按 A1 标准单引号包裹)。源 sheet 的名字在 `--source` 字符串里,**不**通过单独 flag 传。 -> - `--sheet-id` / `--sheet-name`:**透视表的落点 sheet**(即产物放哪张子表)。两个互斥(最多传一个),都不传时后端自动新建子表存放产物(强烈推荐)。**注意:跟其它 shortcut 不同,这里 `--sheet-id` / `--sheet-name` 表达的不是"数据源所在 sheet"而是"产物落点 sheet"**。 +> - `--target-sheet-id` / `--target-sheet-name`:**透视表的落点 sheet**(即产物放哪张子表)。两个互斥(最多传一个),都不传时后端自动新建子表存放产物(强烈推荐)。**与其它 shortcut 的 `--sheet-id` / `--sheet-name` 区分**:那是"数据源 / 操作目标 sheet",这里是"产物落点 sheet"——CLI 用不同 flag 名把这两类语义分开,避免误传。 > - `--target-position`(可选,A1 表示法,默认 `A1`):落点 sheet 内的起始 cell,映射到顶层 `target_position`。 > - `--range`(可选,A1 单值,仅 create 生效):跟 `--target-position` 表达同一意图但映射到 `properties.range`,**两者不要同时给**。 > > **落点 3 种策略(互斥,选其一)**: -> 1. **默认(强烈推荐)**:`--sheet-id` / `--sheet-name` / `--target-position` / `--range` **全都不传** → 服务端**自动新建子表**存放产物,绝不碰任何已有数据。 -> 2. **放进指定的已有子表**:传 `--sheet-id <落点子表 id>`(或 `--sheet-name`),可选 `--target-position <子表内起点 cell>`。⚠️ **若落点子表就是源数据所在的 sheet**,必须配 `--target-position` 或 `--range` 指向源数据范围**之外**的位置,否则产物默认从 A1 起会盖在源数据上。 -> 3. **`--range`**:跟策略 2 等价(同样需要 `--sheet-id` / `--sheet-name` 指定落点子表,不然落到自动新建子表),只是用 `properties.range` 那条 wire 路径表达位置。同样的覆盖风险,同样需要避开源数据范围。 +> 1. **默认(强烈推荐)**:`--target-sheet-id` / `--target-sheet-name` / `--target-position` / `--range` **全都不传** → 服务端**自动新建子表**存放产物,绝不碰任何已有数据。 +> 2. **放进指定的已有子表**:传 `--target-sheet-id <落点子表 id>`(或 `--target-sheet-name`),可选 `--target-position <子表内起点 cell>`。⚠️ **若落点子表就是源数据所在的 sheet**,必须配 `--target-position` 或 `--range` 指向源数据范围**之外**的位置,否则产物默认从 A1 起会盖在源数据上。 +> 3. **`--range`**:跟策略 2 等价(同样需要 `--target-sheet-id` / `--target-sheet-name` 指定落点子表,不然落到自动新建子表),只是用 `properties.range` 那条 wire 路径表达位置。同样的覆盖风险,同样需要避开源数据范围。 > -> 一般用策略 1(默认新建子表)即可,零覆盖风险,无需任何 `--sheet-*` / `--range` / `--target-*` flag。 +> 一般用策略 1(默认新建子表)即可,零覆盖风险,无需任何 `--target-*` / `--range` flag。 ```bash # 策略 1(强烈推荐):不传任何落点 flag → 后端自动新建子表,零覆盖风险 @@ -140,7 +144,7 @@ lark-cli sheets +pivot-create --url "..." \ # 策略 2:落进指定的已有目标子表(注意目标 sheet ≠ 源 sheet,否则要配 --target-position 避开源数据) lark-cli sheets +pivot-create --url "..." \ - --source "'Sheet1'!A1:D100" --sheet-id "$DEST_SID" --target-position "A1" --properties @pivot.json + --source "'Sheet1'!A1:D100" --target-sheet-id "$DEST_SID" --target-position "A1" --properties @pivot.json ``` ### `+pivot-update` @@ -155,7 +159,7 @@ lark-cli sheets +pivot-delete --url "..." --sheet-id "$SID" --pivot-table-id "$P ### Validate / DryRun / Execute 约束 -- `Validate`:`--url` / `--spreadsheet-token` XOR 必填;`+pivot-{update,delete,list}` 的 `--sheet-id` / `--sheet-name` XOR 必填一个,`+pivot-create` 例外(两个都可空,触发 backend auto-create 子表);`+pivot-create` 的 `--source` 必填且必须含表头行;`--properties` 中 `rows` / `columns` / `values` 至少非空之一;`+pivot-delete` 强制 `--yes` 或 `--dry-run`。 +- `Validate`:`--url` / `--spreadsheet-token` XOR 必填;`+pivot-{update,delete,list}` 的 `--sheet-id` / `--sheet-name` XOR 必填一个;`+pivot-create` 例外(用 `--target-sheet-id` / `--target-sheet-name` 表达落点,两个都可空时触发 backend auto-create 子表,两个都给则报 mutually exclusive);`+pivot-create` 的 `--source` 必填且必须含表头行;`--properties` 中 `rows` / `columns` / `values` 至少非空之一;`+pivot-delete` 强制 `--yes` 或 `--dry-run`。 - `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 pivot 请求模板"+ 预估输出尺寸(行数 × 列数)。 - `Execute`:写后不自动回读;如需确认,自行调用 `+pivot-list --pivot-table-id ` 并用 `+csv-get` 抽样读透视产物核对输出尺寸 + 总计行位置。 From dce617eab299ff6d1d51195420638b96ac6a35e3 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Thu, 28 May 2026 19:23:42 +0800 Subject: [PATCH 081/114] docs(sheets): strip migration-history language from pivot reference / SKILL Synced from spec. Removes "renamed from / no longer called / not --sheet-id" style migration-history language that snuck into the previous sync. Reference and SKILL now describe the current flag names directly without referencing the old names. --- skills/lark-sheets/SKILL.md | 3 +-- skills/lark-sheets/references/lark-sheets-pivot-table.md | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index 75cc3ba01..0d3692d36 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -82,8 +82,7 @@ metadata: - **例外**:`+workbook-create` 是新建一个还不存在的表格,**不接受任何 spreadsheet / sheet 定位 flag**(只有 `--title` / `--folder-token` / `--headers` / `--values`)。 2. **sheet 定位(公共四件套 shortcut 必填)**:`--sheet-id` 与 `--sheet-name` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --sheet-id or --sheet-name`。 - ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:即使写了 `--range "'Sheet1'!A1:B2"`,仍**必须**额外传 `--sheet-id` 或 `--sheet-name`,否则照样报上面的错。 - - **例外**:徽章标为 `_公共:URL/token(无 sheet 定位)…_` 的 shortcut(如 `+workbook-info` / `+workbook-export` / `+batch-update` / `+dropdown-get|update|delete` / `+cells-batch-set-style` / `+cells-batch-clear` / `+sheet-create`)**不接受也不需要** sheet 定位,只给一组 spreadsheet 定位即可。 - - **例外**:`+pivot-create` 的徽章虽然同样是「无 sheet 定位」,但它**有自己的 sheet 选择器** `--target-sheet-id` / `--target-sheet-name`(**不是** `--sheet-id` / `--sheet-name`)——这是**透视表落点 sheet**(产物放哪张子表),与"数据源 sheet"语义不同(数据源走 `--source` 的 `'SheetName'!Range`,没有独立 flag)。两个 target 都可不传 → 后端自动新建子表存放透视表(推荐);两个都给则报 `--target-sheet-id and --target-sheet-name are mutually exclusive`。详见 `lark-sheets-pivot-table` 的 `+pivot-create` 段。 + - **例外**:徽章标为 `_公共:URL/token(无 sheet 定位)…_` 的 shortcut(如 `+workbook-info` / `+workbook-export` / `+batch-update` / `+dropdown-get|update|delete` / `+cells-batch-set-style` / `+cells-batch-clear` / `+sheet-create`)**不接受也不需要** sheet 定位,只给一组 spreadsheet 定位即可。`+pivot-create` 用 `--target-sheet-id` / `--target-sheet-name`(XOR,可都不传,落点细节见 `lark-sheets-pivot-table`)。 | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md index 56d2610fd..dad7a1bb5 100644 --- a/skills/lark-sheets/references/lark-sheets-pivot-table.md +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -112,7 +112,7 @@ _创建/更新的透视表属性_ 公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`,其中 `--sheet-id` / `--sheet-name` 在 `+pivot-update` / `+pivot-delete` / `+pivot-list` 上是公共四件套语义(定位透视表所在 sheet,XOR 必传一个)。 -**`+pivot-create` 例外**:placement 选择器改名为 `--target-sheet-id` / `--target-sheet-name`(不再叫 `--sheet-id` / `--sheet-name`),明示这是"产物落点 sheet"而非"数据源 sheet"。两个都不传时后端自动新建子表存放产物(强烈推荐,绝不碰源数据)。数据源 sheet 写在 `--source` 的 `'SheetName'!Range` 里,不靠任何 sheet 选择器 flag 表达。 +**`+pivot-create` 例外**:placement 选择器用 `--target-sheet-id` / `--target-sheet-name`(XOR,两个都不传时后端自动新建子表存放产物,强烈推荐,绝不碰源数据)。数据源 sheet 写在 `--source` 的 `'SheetName'!Range` 里,不靠 sheet 选择器 flag。 ### `+pivot-list` @@ -126,7 +126,7 @@ lark-cli sheets +pivot-list --url "..." --sheet-id "$SID" > > **先理清 `+pivot-create` 上 4 个位置类入参(语义不同,别混)**: > - `--source`(**必填**):**源数据**区域,须自带 `Sheet!` 前缀(如 `'Sheet1'!A1:D100`,sheet 名按 A1 标准单引号包裹)。源 sheet 的名字在 `--source` 字符串里,**不**通过单独 flag 传。 -> - `--target-sheet-id` / `--target-sheet-name`:**透视表的落点 sheet**(即产物放哪张子表)。两个互斥(最多传一个),都不传时后端自动新建子表存放产物(强烈推荐)。**与其它 shortcut 的 `--sheet-id` / `--sheet-name` 区分**:那是"数据源 / 操作目标 sheet",这里是"产物落点 sheet"——CLI 用不同 flag 名把这两类语义分开,避免误传。 +> - `--target-sheet-id` / `--target-sheet-name`:**透视表的落点 sheet**(即产物放哪张子表)。两个互斥(最多传一个),都不传时后端自动新建子表存放产物(强烈推荐)。 > - `--target-position`(可选,A1 表示法,默认 `A1`):落点 sheet 内的起始 cell,映射到顶层 `target_position`。 > - `--range`(可选,A1 单值,仅 create 生效):跟 `--target-position` 表达同一意图但映射到 `properties.range`,**两者不要同时给**。 > From e3eca666fbe7b1d4e9bdf91f2345fde40f9a2014 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Thu, 28 May 2026 20:42:45 +0800 Subject: [PATCH 082/114] docs(sheets): require +workbook-info before guessing sheet name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synced from spec. SKILL.md adds a new rule under the sheet-locator section: unless the user has explicitly named a sheet, the agent must call +workbook-info first to fetch sheets[].sheet_id / sheets[].title rather than guessing the default `Sheet1`. The Chinese-language tables this CLI is typically used against rarely use that literal name — "数据" / "Sheet" (no digit) / "工作表 1" / business-named sheets are far more common — so guessing wastes a round-trip before the agent ends up calling +workbook-info anyway. The 统一调用范式 example also switches its `--sheet-name "Sheet1"` placeholder to `<真实表名>` to remove the inadvertent suggestion that `Sheet1` is a sensible default. --- skills/lark-sheets/SKILL.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index 0d3692d36..bfb981dc6 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -81,6 +81,7 @@ metadata: - ⚠️ **`/wiki/` 知识库链接不能直接当表格定位用**:wiki 链接背后可能是电子表格,也可能是文档 / 多维表格等其它类型,`--url` **不会**自动把 wiki token 解析成 spreadsheet token,直接传会失败。必须先把它解析成真实文档 token —— `lark-cli wiki +node-get --node-token ""`,确认返回的 `obj_type` 为 `sheet` 后,取其 `obj_token` 作为 `--spreadsheet-token` 传入(解析细节见 [`../lark-wiki/SKILL.md`](../lark-wiki/SKILL.md))。 - **例外**:`+workbook-create` 是新建一个还不存在的表格,**不接受任何 spreadsheet / sheet 定位 flag**(只有 `--title` / `--folder-token` / `--headers` / `--values`)。 2. **sheet 定位(公共四件套 shortcut 必填)**:`--sheet-id` 与 `--sheet-name` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --sheet-id or --sheet-name`。 + - ⚠️ **不确定 sheet 名时禁止直接猜 `Sheet1`**:除非用户对话明确说出 sheet 名 / id,或上下文(之前的工具调用 / URL 锚点 `?sheet=xxx`)已经出现过具体值,否则**第一步先调 `+workbook-info --url "..."`**(或 `--spreadsheet-token`)拿 `sheets[].sheet_id` / `sheets[].title` 列表再选。中文环境下子表常叫"数据" / "Sheet"(无数字)/ "工作表 1" / 业务名,猜 `Sheet1` 大概率撞 `sheet not found`,比先查多耗一次失败调用 + 重试。 - ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:即使写了 `--range "'Sheet1'!A1:B2"`,仍**必须**额外传 `--sheet-id` 或 `--sheet-name`,否则照样报上面的错。 - **例外**:徽章标为 `_公共:URL/token(无 sheet 定位)…_` 的 shortcut(如 `+workbook-info` / `+workbook-export` / `+batch-update` / `+dropdown-get|update|delete` / `+cells-batch-set-style` / `+cells-batch-clear` / `+sheet-create`)**不接受也不需要** sheet 定位,只给一组 spreadsheet 定位即可。`+pivot-create` 用 `--target-sheet-id` / `--target-sheet-name`(XOR,可都不传,落点细节见 `lark-sheets-pivot-table`)。 @@ -95,9 +96,10 @@ metadata: ```bash lark-cli sheets <其它 flag> -# workbook 定位:--url "..." 或 --spreadsheet-token "..." (二选一,必给) -# sheet 定位: --sheet-id "$SID" 或 --sheet-name "Sheet1" (二选一,必给) -# 例:lark-cli sheets +csv-get --url "https://.../sheets/shtXXX" --sheet-name "Sheet1" --range "A1:F30" +# workbook 定位:--url "..." 或 --spreadsheet-token "..." (二选一,必给) +# sheet 定位: --sheet-id "$SID" 或 --sheet-name "<真实表名>" (二选一,必给;占位符不要原样填) +# 例:lark-cli sheets +csv-get --url "https://.../sheets/shtXXX" --sheet-name "<真实表名>" --range "A1:F30" +# 注意:真实表名不要直接填 "Sheet1"——大多数表的子表不叫这个;先 +workbook-info 拿 sheets[].title 再代入。 ``` ### 系统 flag From 7d24e2b6499e5ff6f2e84126e132a8aa732a6491 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Thu, 28 May 2026 21:41:01 +0800 Subject: [PATCH 083/114] docs(sheets): tell agent to `set +H` for A1 references containing `!` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synced from spec. The sheet-locator section now warns: when a flag value contains `!` (--source / --range / --ranges with a cross-sheet prefix), run `set +H` at the start of the bash session to disable history expansion — otherwise interactive bash (e.g. inside an agent's shell sandbox) lexes "Sheet1!A1" as a history reference and fails with `event not found` before lark-cli ever sees the argument. When the sheet name itself contains hyphens / spaces / non-ASCII characters, the A1 reference also needs single quotes around the sheet name per A1 notation, e.g. --source "'Sales-2025'!A1:D100". Also flips the previous `--range` example to `--range 'Sheet1!A1:B2'` (shell single-quote) for consistency. --- skills/lark-sheets/SKILL.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index bfb981dc6..cd01bf069 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -82,7 +82,8 @@ metadata: - **例外**:`+workbook-create` 是新建一个还不存在的表格,**不接受任何 spreadsheet / sheet 定位 flag**(只有 `--title` / `--folder-token` / `--headers` / `--values`)。 2. **sheet 定位(公共四件套 shortcut 必填)**:`--sheet-id` 与 `--sheet-name` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --sheet-id or --sheet-name`。 - ⚠️ **不确定 sheet 名时禁止直接猜 `Sheet1`**:除非用户对话明确说出 sheet 名 / id,或上下文(之前的工具调用 / URL 锚点 `?sheet=xxx`)已经出现过具体值,否则**第一步先调 `+workbook-info --url "..."`**(或 `--spreadsheet-token`)拿 `sheets[].sheet_id` / `sheets[].title` 列表再选。中文环境下子表常叫"数据" / "Sheet"(无数字)/ "工作表 1" / 业务名,猜 `Sheet1` 大概率撞 `sheet not found`,比先查多耗一次失败调用 + 重试。 - - ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:即使写了 `--range "'Sheet1'!A1:B2"`,仍**必须**额外传 `--sheet-id` 或 `--sheet-name`,否则照样报上面的错。 + - ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:即使写了 `--range 'Sheet1!A1:B2'`,仍**必须**额外传 `--sheet-id` 或 `--sheet-name`,否则照样报上面的错。 + - ⚠️ **A1 reference 含 `!`**(`--source` / `--range` / `--ranges`)**:shell session 起手先 `set +H`** 关 bash history expansion,否则 `"Sheet1!A1"` 会被拦成 `event not found`;含特殊字符(`-` / 空格 / 非 ASCII)的 sheet 名还要内部 single-quote 包,如 `--source "'Sales-2025'!A1:D100"`。 - **例外**:徽章标为 `_公共:URL/token(无 sheet 定位)…_` 的 shortcut(如 `+workbook-info` / `+workbook-export` / `+batch-update` / `+dropdown-get|update|delete` / `+cells-batch-set-style` / `+cells-batch-clear` / `+sheet-create`)**不接受也不需要** sheet 定位,只给一组 spreadsheet 定位即可。`+pivot-create` 用 `--target-sheet-id` / `--target-sheet-name`(XOR,可都不传,落点细节见 `lark-sheets-pivot-table`)。 | Flag | Type | 必填 | 说明 | From 74761a0e1c79a55752f6895ac93f42ebb33fc5d2 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Fri, 29 May 2026 12:08:47 +0800 Subject: [PATCH 084/114] feat(sheets): add schema-driven JSON flag validation Validate composite JSON flags (--properties, --cells, --options, --border-styles, --sort-keys) against the embedded flag-schemas.json on every standalone and +batch-update sub-op invocation, replacing ad-hoc per-shortcut guards. Supports the JSON Schema subset actually used upstream: type / enum / oneOf / required / properties / items / nullable / minimum / maximum / minItems / maxItems / additionalProperties (true | false | ). Enum errors quote the failing value, truncate beyond 8 entries, and surface case-only "did you mean" hints (SUM -> sum). Coverage: 18 / 19 (shortcut, flag) pairs. +batch-update --operations stays validator-skipped; its translator already does richer per sub-op checks. mapFlagView.Command() routes batch sub-ops through the same (command, flag) -> schema pipeline as standalone. loadFlagSchemas() is now sync.Once-guarded so parallel first access from t.Parallel test sets and concurrent shortcut invocations is race-free. Removes superseded hand-written guards: - +pivot-create validateCreateInput / validatePivotCreateProps - +range-sort sort-keys per-item shape check Test fixtures updated to be schema-conformant (chart position/size, pivot summarize_by lowercase, cells 2D-array shape). --- shortcuts/common/runner.go | 10 + shortcuts/sheets/batch_op_contract_test.go | 75 +- shortcuts/sheets/data/flag-schemas.json | 1004 ++++++++--------- shortcuts/sheets/execute_paths_test.go | 4 +- shortcuts/sheets/flag_schema.go | 30 +- shortcuts/sheets/flag_schema_validate.go | 478 ++++++++ shortcuts/sheets/flag_schema_validate_test.go | 531 +++++++++ shortcuts/sheets/flag_view.go | 11 +- shortcuts/sheets/helpers.go | 7 + .../sheets/lark_sheet_batch_update_test.go | 3 +- shortcuts/sheets/lark_sheet_object_crud.go | 24 +- .../sheets/lark_sheet_object_crud_test.go | 120 +- .../sheets/lark_sheet_range_operations.go | 21 +- .../lark_sheet_range_operations_test.go | 19 +- .../sheets/lark_sheet_sheet_structure.go | 8 +- .../sheets/lark_sheet_sheet_structure_test.go | 10 +- shortcuts/sheets/lark_sheet_write_cells.go | 9 + .../sheets/lark_sheet_write_cells_test.go | 14 +- .../references/lark-sheets-write-cells.md | 3 +- 19 files changed, 1749 insertions(+), 632 deletions(-) create mode 100644 shortcuts/sheets/flag_schema_validate.go create mode 100644 shortcuts/sheets/flag_schema_validate_test.go diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index a88dd326e..b4c72e1f3 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -70,6 +70,16 @@ func (ctx *RuntimeContext) IsBot() bool { return ctx.As().IsBot() } +// Command returns the shortcut command name as cobra knows it (e.g. +// "+pivot-create"). Used by per-service helpers (e.g. sheets schema +// validation) that key off the shortcut identity. +func (ctx *RuntimeContext) Command() string { + if ctx.Cmd == nil { + return "" + } + return ctx.Cmd.Name() +} + // UserOpenId returns the current user's open_id from config. func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId } diff --git a/shortcuts/sheets/batch_op_contract_test.go b/shortcuts/sheets/batch_op_contract_test.go index 1073dd985..d758c0dcc 100644 --- a/shortcuts/sheets/batch_op_contract_test.go +++ b/shortcuts/sheets/batch_op_contract_test.go @@ -196,14 +196,14 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) { { shortcut: "+chart-create", sc: ChartCreate, - args: []string{"--sheet-id", "sh1", "--properties", `{"position":{"start":"A1"}}`}, - subInput: `{"sheet-id":"sh1","properties":{"position":{"start":"A1"}}}`, + args: []string{"--sheet-id", "sh1", "--properties", `{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`}, + subInput: `{"sheet-id":"sh1","properties":{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}}`, }, { shortcut: "+chart-update", sc: ChartUpdate, - args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--properties", `{"title":"T"}`}, - subInput: `{"sheet-id":"sh1","chart-id":"c1","properties":{"title":"T"}}`, + args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--properties", `{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`}, + subInput: `{"sheet-id":"sh1","chart-id":"c1","properties":{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}}`, }, { shortcut: "+chart-delete", @@ -606,6 +606,73 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) { } } +// TestBatchOp_SchemaValidatesSubOps confirms the schema-driven +// validator fires on +batch-update sub-operations the same way it +// fires on standalone shortcuts. mapFlagView.Command() returns the +// sub-op's shortcut name, so validateInputAgainstSchema (called at +// each input builder's tail) routes through the same (command, flag) +// lookup pipeline a standalone invocation would. This regression +// pins that wiring — without it, agents could slip past CLI-side +// schema checks by wrapping a bad input in +batch-update. +func TestBatchOp_SchemaValidatesSubOps(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + subShortcut string + subInput string + wantContains string + }{ + // +pivot-create properties.values items enforce summarize_by + // enum — schema rejects an out-of-enum value as a sub-op too. + { + "+pivot-create summarize_by out of enum", + "+pivot-create", + `{"sheet-id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`, + "summarize_by", + }, + // +chart-create properties.position.row has minimum:0 — P0 + // addition; validator must catch -1 even in the batch path. + { + "+chart-create position.row below minimum", + "+chart-create", + `{"sheet-id":"sh1","properties":{"position":{"row":-1,"col":"A"},"size":{"width":400,"height":300}}}`, + "below minimum", + }, + // +cells-set --cells is a 2D array of objects per the + // upstream-fixed schema; sub-op passing an object must be + // rejected at the schema layer (not "expected JSON array"). + { + "+cells-set cells wrong shape", + "+cells-set", + `{"sheet-id":"sh1","range":"A1","cells":{"foo":"bar"}}`, + `expected type "array"`, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var subInput map[string]interface{} + if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil { + t.Fatalf("bad subInput JSON: %v", err) + } + rawOp := map[string]interface{}{ + "shortcut": tc.subShortcut, + "input": subInput, + } + _, err := translateBatchOp(rawOp, testToken, 0) + if err == nil { + t.Fatalf("translator accepted schema-violating sub-op — expected error containing %q", tc.wantContains) + } + if !strings.Contains(err.Error(), tc.wantContains) { + t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains) + } + }) + } +} + // TestBatchOp_DispatchCoversReportedBugs is a focused guard for the two // originally reported failures: +range-copy and +rows-resize sub-ops must // translate to the correct MCP body (not a near-passthrough that drops diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json index 4a909314d..62ff31b4c 100644 --- a/shortcuts/sheets/data/flag-schemas.json +++ b/shortcuts/sheets/data/flag-schemas.json @@ -7,7 +7,6 @@ "description": "要批量执行的 CLI shortcut 操作列表,按声明顺序串行执行;任一失败立即中断。", "items": { "type": "object", - "additionalProperties": false, "required": [ "shortcut", "input" @@ -203,424 +202,431 @@ }, "+cells-set": { "cells": { - "type": "object", - "properties": { - "value": { - "description": "静态单元格值(文本、数字、布尔)。公式请优先使用 'formula' 字段;如果误把以 '=' 开头的公式字符串写到这里,工具会按 Excel 语义自动识别为公式入库,但仍应按 'formula' 字段的契约传参。", - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ] - }, - "formula": { - "description": "以 '=' 开头的单元格公式(例如:'=SUM(A1:A10)')。公式必须写在此字段,而不是 'value'。", - "type": "string" - }, - "note": { - "description": "单元格批注/备注。设为 null 可清除已有的批注。", - "type": "string", - "nullable": true - }, - "cell_styles": { + "description": "【维度】行列数必须与 range 完全一致:'A1:C2'→[[_,_,_],[_,_,_]](2行×3列),'B5:B7'→[[_],[_],[_]](3行×1列),'A1'→[[_]](1×1)。不修改的单元格填 {}。【内容字段,只能选一个】value(普通值)/ formula(公式,以 = 开头)/ rich_text(富文本)/ multiple_values(多值)。【可叠加字段,可与内容字段自由组合】cell_styles、border_styles、note、data_validation。【增量】所有字段均为增量更新,未传字段保留原值不变。", + "type": "array", + "items": { + "type": "array", + "items": { "type": "object", "properties": { - "font_color": { - "description": "字体颜色(十六进制,例如 \"#000000\")", - "type": "string" - }, - "font_size": { - "description": "字体大小(单位:px/像素,例如 10、12、14)", - "type": "number" - }, - "font_weight": { - "description": "字重", - "type": "string", - "enum": [ - "normal", - "bold" - ] - }, - "font_style": { - "description": "字体样式", - "type": "string", - "enum": [ - "normal", - "italic" - ] - }, - "font_line": { - "description": "字体线条样式", - "type": "string", - "enum": [ - "none", - "underline", - "line-through" + "value": { + "description": "静态单元格值(文本、数字、布尔)。公式请优先使用 'formula' 字段;如果误把以 '=' 开头的公式字符串写到这里,工具会按 Excel 语义自动识别为公式入库,但仍应按 'formula' 字段的契约传参。", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } ] }, - "background_color": { - "description": "背景颜色(十六进制,例如 \"#ffffff\")", + "formula": { + "description": "以 '=' 开头的单元格公式(例如:'=SUM(A1:A10)')。公式必须写在此字段,而不是 'value'。", "type": "string" }, - "horizontal_alignment": { - "description": "水平对齐方式", + "note": { + "description": "单元格批注/备注。设为 null 可清除已有的批注。", "type": "string", - "enum": [ - "left", - "center", - "right" - ] - }, - "vertical_alignment": { - "description": "垂直对齐方式", - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ] + "nullable": true }, - "number_format": { - "description": "数字格式(例如:文本用 \"@\"、数字用 \"0.00\"、货币用 \"$#,##0.00\"、日期用 \"mm/dd/yyyy\")", - "type": "string" - }, - "word_wrap": { - "description": "是否自动换行,默认溢出,可选自动换行或裁剪。", - "type": "string", - "enum": [ - "overflow", - "auto-wrap", - "word-clip" - ] - } - }, - "description": "单元格样式属性,包括字体、颜色、对齐方式和数字格式" - }, - "border_styles": { - "type": "object", - "description": "单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)。", - "properties": { - "top": { + "cell_styles": { "type": "object", "properties": { - "style": { - "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", - "type": "string", - "enum": [ - "solid", - "dashed", - "dotted", - "double", - "none" - ] + "font_color": { + "description": "字体颜色(十六进制,例如 \"#000000\")", + "type": "string" }, - "weight": { - "description": "边框粗细/线宽", + "font_size": { + "description": "字体大小(单位:px/像素,例如 10、12、14)", + "type": "number" + }, + "font_weight": { + "description": "字重", "type": "string", "enum": [ - "thin", - "medium", - "thick" + "normal", + "bold" ] }, - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" - } - } - }, - "bottom": { - "type": "object", - "properties": { - "style": { - "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "font_style": { + "description": "字体样式", "type": "string", "enum": [ - "solid", - "dashed", - "dotted", - "double", - "none" + "normal", + "italic" ] }, - "weight": { - "description": "边框粗细/线宽", + "font_line": { + "description": "字体线条样式", "type": "string", "enum": [ - "thin", - "medium", - "thick" + "none", + "underline", + "line-through" ] }, - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", + "background_color": { + "description": "背景颜色(十六进制,例如 \"#ffffff\")", "type": "string" - } - } - }, - "left": { - "type": "object", - "properties": { - "style": { - "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + }, + "horizontal_alignment": { + "description": "水平对齐方式", "type": "string", "enum": [ - "solid", - "dashed", - "dotted", - "double", - "none" + "left", + "center", + "right" ] }, - "weight": { - "description": "边框粗细/线宽", + "vertical_alignment": { + "description": "垂直对齐方式", "type": "string", "enum": [ - "thin", - "medium", - "thick" + "top", + "middle", + "bottom" ] }, - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", + "number_format": { + "description": "数字格式(例如:文本用 \"@\"、数字用 \"0.00\"、货币用 \"$#,##0.00\"、日期用 \"mm/dd/yyyy\")", "type": "string" + }, + "word_wrap": { + "description": "是否自动换行,默认溢出,可选自动换行或裁剪。", + "type": "string", + "enum": [ + "overflow", + "auto-wrap", + "word-clip" + ] } - } + }, + "description": "单元格样式属性,包括字体、颜色、对齐方式和数字格式" }, - "right": { + "border_styles": { "type": "object", + "description": "单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)。", "properties": { - "style": { - "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", - "type": "string", - "enum": [ - "solid", - "dashed", - "dotted", - "double", - "none" - ] + "top": { + "type": "object", + "properties": { + "style": { + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", + "enum": [ + "solid", + "dashed", + "dotted", + "double", + "none" + ] + }, + "weight": { + "description": "边框粗细/线宽", + "type": "string", + "enum": [ + "thin", + "medium", + "thick" + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + } + } }, - "weight": { - "description": "边框粗细/线宽", - "type": "string", - "enum": [ - "thin", - "medium", - "thick" - ] + "bottom": { + "type": "object", + "properties": { + "style": { + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", + "enum": [ + "solid", + "dashed", + "dotted", + "double", + "none" + ] + }, + "weight": { + "description": "边框粗细/线宽", + "type": "string", + "enum": [ + "thin", + "medium", + "thick" + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + } + } }, - "color": { - "description": "边框颜色(十六进制,例如 \"#000000\")", - "type": "string" + "left": { + "type": "object", + "properties": { + "style": { + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", + "enum": [ + "solid", + "dashed", + "dotted", + "double", + "none" + ] + }, + "weight": { + "description": "边框粗细/线宽", + "type": "string", + "enum": [ + "thin", + "medium", + "thick" + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + } + } + }, + "right": { + "type": "object", + "properties": { + "style": { + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", + "enum": [ + "solid", + "dashed", + "dotted", + "double", + "none" + ] + }, + "weight": { + "description": "边框粗细/线宽", + "type": "string", + "enum": [ + "thin", + "medium", + "thick" + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + } + } } } - } - } - }, - "rich_text": { - "description": "富文本内容。设置后会忽略 value 字段。可包含带样式的文本段 text、超链接 link、@提及 mention、单元格图片 embed-image、附件 attachment。", - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "description": "段类型", - "type": "string", - "enum": [ - "text", - "link", - "mention", - "embed-image", - "attachment" - ] - }, - "text": { - "description": "显示文本", - "type": "string" - }, - "style": { - "description": "文本段样式(仅 type='text' 时生效),结构同 cell_styles", - "type": "object" - }, - "link": { - "description": "超链接地址(仅 type='link' 时必填)", - "type": "string" - }, - "mention_token": { - "description": "@提及目标的 token,如 userId 或 fileId(仅 type='mention' 时必填)", - "type": "string" - }, - "mention_type": { - "description": "@提及类型编号(仅 type='mention' 时可选)", - "type": "number" - }, - "notify": { - "description": "是否发送通知(仅 type='mention' 时可选,默认 true)", - "type": "boolean" - }, - "image_width": { - "description": "图片宽度(像素,仅 type='embed-image' 时使用)", - "type": "number" - }, - "image_height": { - "description": "图片高度(像素,仅 type='embed-image' 时使用)", - "type": "number" - }, - "image_name": { - "description": "图片名称(仅 type='embed-image' 时使用,创建新图片时必填)", - "type": "string" - }, - "image_uri": { - "description": "图片文件 reference_id(仅 type='embed-image' 时使用,与 image_token 二选一,如`<|image|>:abcdef` 或者 `<|superscript|>:abcdef-<|image|>:abcdef`,其中 `abcdef` 为实际的对象 ID,占位符仅用于示意)", - "type": "string" - }, - "image_token": { - "description": "图片文件 token(仅 type='embed-image' 时可选,修改已有图片时可从 get_cell_range 获取)", - "type": "string" - }, - "attachment_token": { - "description": "附件文件 token,通过飞书 Drive 上传获取(仅 type='attachment' 时可选,修改已有附件时可从 get_cell_range 获取)", - "type": "string" - }, - "attachment_uri": { - "description": "附件文件 reference_id(仅 type='attachment' 时使用,与 attachment_token 二选一,如`<|attachment|>:abcdef` 或者 `<|superscript|>:abcdef-<|attachment|>:abcdef`,其中 `abcdef` 为实际的对象 ID,占位符仅用于示意)", - "type": "string" - }, - "attachment_name": { - "description": "附件文件名称(仅 type='attachment' 时使用,配合 attachment_reference_id 使用,创建新附件时必填)", - "type": "string" - }, - "mime_type": { - "description": "附件 MIME 类型(仅 type='attachment' 时使用,例如 'application/pdf')", - "type": "string" - }, - "file_size": { - "description": "附件文件大小(字节,仅 type='attachment' 时使用)", - "type": "number" - } }, - "required": [ - "type", - "text" - ] - } - }, - "multiple_values": { - "description": "多值内容,用于支持多选的列表验证单元格。设置后会忽略 value 和 rich_text 字段。", - "type": "array", - "items": { - "type": "object", - "properties": { - "value": { - "description": "值(文本、数字、布尔)", - "oneOf": [ - { + "rich_text": { + "description": "富文本内容。设置后会忽略 value 字段。可包含带样式的文本段 text、超链接 link、@提及 mention、单元格图片 embed-image、附件 attachment。", + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "description": "段类型", + "type": "string", + "enum": [ + "text", + "link", + "mention", + "embed-image", + "attachment" + ] + }, + "text": { + "description": "显示文本", "type": "string" }, - { + "style": { + "description": "文本段样式(仅 type='text' 时生效),结构同 cell_styles", + "type": "object" + }, + "link": { + "description": "超链接地址(仅 type='link' 时必填)", + "type": "string" + }, + "mention_token": { + "description": "@提及目标的 token,如 userId 或 fileId(仅 type='mention' 时必填)", + "type": "string" + }, + "mention_type": { + "description": "@提及类型编号(仅 type='mention' 时可选)", "type": "number" }, - { + "notify": { + "description": "是否发送通知(仅 type='mention' 时可选,默认 true)", "type": "boolean" + }, + "image_width": { + "description": "图片宽度(像素,仅 type='embed-image' 时使用)", + "type": "number" + }, + "image_height": { + "description": "图片高度(像素,仅 type='embed-image' 时使用)", + "type": "number" + }, + "image_name": { + "description": "图片名称(仅 type='embed-image' 时使用,创建新图片时必填)", + "type": "string" + }, + "image_uri": { + "description": "图片文件 reference_id(仅 type='embed-image' 时使用,与 image_token 二选一,如`<|image|>:abcdef` 或者 `<|superscript|>:abcdef-<|image|>:abcdef`,其中 `abcdef` 为实际的对象 ID,占位符仅用于示意)", + "type": "string" + }, + "image_token": { + "description": "图片文件 token(仅 type='embed-image' 时可选,修改已有图片时可从 get_cell_range 获取)", + "type": "string" + }, + "attachment_token": { + "description": "附件文件 token,通过飞书 Drive 上传获取(仅 type='attachment' 时可选,修改已有附件时可从 get_cell_range 获取)", + "type": "string" + }, + "attachment_uri": { + "description": "附件文件 reference_id(仅 type='attachment' 时使用,与 attachment_token 二选一,如`<|attachment|>:abcdef` 或者 `<|superscript|>:abcdef-<|attachment|>:abcdef`,其中 `abcdef` 为实际的对象 ID,占位符仅用于示意)", + "type": "string" + }, + "attachment_name": { + "description": "附件文件名称(仅 type='attachment' 时使用,配合 attachment_reference_id 使用,创建新附件时必填)", + "type": "string" + }, + "mime_type": { + "description": "附件 MIME 类型(仅 type='attachment' 时使用,例如 'application/pdf')", + "type": "string" + }, + "file_size": { + "description": "附件文件大小(字节,仅 type='attachment' 时使用)", + "type": "number" } + }, + "required": [ + "type", + "text" ] - }, - "format": { - "description": "可选的数字格式(例如 '$#,##0.00')", - "type": "string" } }, - "required": [ - "value" - ] - } - }, - "data_validation": { - "description": "数据验证配置。设为 null 可清除已有的数据验证。", - "type": "object", - "nullable": true, - "properties": { - "type": { - "description": "数据验证类型:list(下拉列表)、listFromRange(引用范围下拉列表)、number(数字)、date(日期)、textLength(文本长度)、checkbox(复选框)", - "type": "string", - "enum": [ - "list", - "listFromRange", - "number", - "date", - "textLength", - "checkbox" - ] - }, - "items": { - "description": "列表选项", + "multiple_values": { + "description": "多值内容,用于支持多选的列表验证单元格。设置后会忽略 value 和 rich_text 字段。", "type": "array", "items": { - "type": "string" - } - }, - "range": { - "description": "源数据区域(type='listFromRange' 时必填,格式:'SheetName!A1:A10')", - "type": "string" - }, - "operator": { - "description": "比较运算符(type='number'/'date'/'textLength' 时必填)", - "type": "string", - "enum": [ - "equal", - "notEqual", - "greaterThan", - "greaterThanOrEqual", - "lessThan", - "lessThanOrEqual", - "between", - "notBetween" - ] - }, - "values": { - "description": "比较值(operator 为 'between'/'notBetween' 时需要两个值,其它运算符需要一个值)", - "type": "array", - "items": { - "oneOf": [ - { - "type": "string" + "type": "object", + "properties": { + "value": { + "description": "值(文本、数字、布尔)", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] }, - { - "type": "number" + "format": { + "description": "可选的数字格式(例如 '$#,##0.00')", + "type": "string" } + }, + "required": [ + "value" ] } }, - "support_multiple_values": { - "description": "列表验证是否支持多选(type='list'/'listFromRange' 时可选,默认 false)", - "type": "boolean" - }, - "help_text": { - "description": "验证失败时显示的提示文本", - "type": "string" - }, - "enable_highlight": { - "description": "是否开启下拉选项的胶囊背景色高亮,仅 type='list'/'listFromRange' 生效。默认 true,自动按内置 10 色色板循环上色。仅当用户明确要求纯色下拉时才传 false。当用户要求按下拉项分别染色时,用本字段 + highlight_colors 一步搞定即可,不要走条件格式(条件格式是染整格背景,语义不符)。", - "type": "boolean" - }, - "highlight_colors": { - "description": "下拉选项的胶囊背景色数组(十六进制,例如 [\"#FFE699\",\"#bff7d9\",\"#ffb3b3\"])。按顺序对应(type='list' 对应 items;type='listFromRange' 按 range 内单元格行优先顺序,如 A1:A10 对应第 1-10 项;A1:B2 顺序为 A1,B1,A2,B2)。长度可以短于但不能长于;未指定项及不提供该字段时按内置 10 色色板循环补色。当 enable_highlight=false 时本字段被忽略。", - "type": "array", - "items": { - "type": "string" - } + "data_validation": { + "description": "数据验证配置。设为 null 可清除已有的数据验证。", + "type": "object", + "nullable": true, + "properties": { + "type": { + "description": "数据验证类型:list(下拉列表)、listFromRange(引用范围下拉列表)、number(数字)、date(日期)、textLength(文本长度)、checkbox(复选框)", + "type": "string", + "enum": [ + "list", + "listFromRange", + "number", + "date", + "textLength", + "checkbox" + ] + }, + "items": { + "description": "列表选项", + "type": "array", + "items": { + "type": "string" + } + }, + "range": { + "description": "源数据区域(type='listFromRange' 时必填,格式:'SheetName!A1:A10')", + "type": "string" + }, + "operator": { + "description": "比较运算符(type='number'/'date'/'textLength' 时必填)", + "type": "string", + "enum": [ + "equal", + "notEqual", + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual", + "between", + "notBetween" + ] + }, + "values": { + "description": "比较值(operator 为 'between'/'notBetween' 时需要两个值,其它运算符需要一个值)", + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "support_multiple_values": { + "description": "列表验证是否支持多选(type='list'/'listFromRange' 时可选,默认 false)", + "type": "boolean" + }, + "help_text": { + "description": "验证失败时显示的提示文本", + "type": "string" + }, + "enable_highlight": { + "description": "是否开启下拉选项的胶囊背景色高亮,仅 type='list'/'listFromRange' 生效。默认 true,自动按内置 10 色色板循环上色。仅当用户明确要求纯色下拉时才传 false。当用户要求按下拉项分别染色时,用本字段 + highlight_colors 一步搞定即可,不要走条件格式(条件格式是染整格背景,语义不符)。", + "type": "boolean" + }, + "highlight_colors": { + "description": "下拉选项的胶囊背景色数组(十六进制,例如 [\"#FFE699\",\"#bff7d9\",\"#ffb3b3\"])。按顺序对应(type='list' 对应 items;type='listFromRange' 按 range 内单元格行优先顺序,如 A1:A10 对应第 1-10 项;A1:B2 顺序为 A1,B1,A2,B2)。长度可以短于但不能长于;未指定项及不提供该字段时按内置 10 色色板循环补色。当 enable_highlight=false 时本字段被忽略。", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ] } - }, - "required": [ - "type" - ] + } } } } @@ -771,8 +777,7 @@ "required": [ "row", "col" - ], - "additionalProperties": false + ] }, "offset": { "type": "object", @@ -786,8 +791,7 @@ "type": "number", "description": "列偏移量(像素)" } - }, - "additionalProperties": false + } }, "size": { "type": "object", @@ -807,8 +811,7 @@ "required": [ "width", "height" - ], - "additionalProperties": false + ] }, "snapshot": { "type": "object", @@ -1788,8 +1791,7 @@ "required": [ "position", "size" - ], - "additionalProperties": {} + ] } }, "+chart-update": { @@ -1814,8 +1816,7 @@ "required": [ "row", "col" - ], - "additionalProperties": false + ] }, "offset": { "type": "object", @@ -1829,8 +1830,7 @@ "type": "number", "description": "列偏移量(像素)" } - }, - "additionalProperties": false + } }, "size": { "type": "object", @@ -1850,8 +1850,7 @@ "required": [ "width", "height" - ], - "additionalProperties": false + ] }, "snapshot": { "type": "object", @@ -2831,8 +2830,7 @@ "required": [ "position", "size" - ], - "additionalProperties": {} + ] } }, "+cond-format-create": { @@ -2897,8 +2895,7 @@ ], "description": "字体样式:\"bold\"=加粗,\"italic\"=斜体,\"bold italic\"=加粗+斜体。" } - }, - "additionalProperties": false + } }, "attrs": { "type": "array", @@ -2931,8 +2928,7 @@ "required": [ "compare_type", "value" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -2957,8 +2953,7 @@ "required": [ "compare_type", "text" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -2993,8 +2988,7 @@ "required": [ "operator", "time_period" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -3033,8 +3027,7 @@ "required": [ "color", "value_type" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -3065,8 +3058,7 @@ "required": [ "value_type", "color" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -3092,8 +3084,7 @@ "required": [ "is_bottom", "value_type" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -3112,8 +3103,7 @@ }, "required": [ "operator" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -3129,8 +3119,7 @@ }, "required": [ "formula" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -3201,8 +3190,7 @@ "icon_type", "value_type", "operator" - ], - "additionalProperties": false + ] } ] } @@ -3216,8 +3204,7 @@ "rule_type", "ranges", "style" - ], - "additionalProperties": false + ] } }, "+cond-format-update": { @@ -3282,8 +3269,7 @@ ], "description": "字体样式:\"bold\"=加粗,\"italic\"=斜体,\"bold italic\"=加粗+斜体。" } - }, - "additionalProperties": false + } }, "attrs": { "type": "array", @@ -3316,8 +3302,7 @@ "required": [ "compare_type", "value" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -3342,8 +3327,7 @@ "required": [ "compare_type", "text" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -3378,8 +3362,7 @@ "required": [ "operator", "time_period" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -3418,8 +3401,7 @@ "required": [ "color", "value_type" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -3450,8 +3432,7 @@ "required": [ "value_type", "color" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -3477,8 +3458,7 @@ "required": [ "is_bottom", "value_type" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -3497,8 +3477,7 @@ }, "required": [ "operator" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -3514,8 +3493,7 @@ }, "required": [ "formula" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -3586,8 +3564,7 @@ "icon_type", "value_type", "operator" - ], - "additionalProperties": false + ] } ] } @@ -3601,8 +3578,7 @@ "rule_type", "ranges", "style" - ], - "additionalProperties": false + ] } }, "+dropdown-set": { @@ -3634,7 +3610,7 @@ }, "rules": { "type": "array", - "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。update 时传空数组 [] 表示清空所有现有列的规则。", + "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。update 为整组覆盖(PUT 语义):传入的 rules 即更新后的完整规则集,会先清空所有现有列的旧规则再应用,未列出的列其旧规则会被清除(不与旧规则合并);传空数组 [] 即清空所有列的规则。", "items": { "type": "object", "description": "单列筛选规则。", @@ -3684,8 +3660,7 @@ "required": [ "type", "compare_type" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -3730,8 +3705,7 @@ "required": [ "type", "compare_type" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -3760,8 +3734,7 @@ "required": [ "type", "compare_type" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -3824,16 +3797,14 @@ "second" ] } - }, - "additionalProperties": false + } } } }, "required": [ "type", "compare_type" - ], - "additionalProperties": false + ] } ] } @@ -3849,8 +3820,7 @@ "required": [ "column_index", "conditions" - ], - "additionalProperties": false + ] } }, "filtered_columns": { @@ -3864,8 +3834,7 @@ "required": [ "range", "rules" - ], - "additionalProperties": false + ] } }, "+filter-update": { @@ -3879,7 +3848,7 @@ }, "rules": { "type": "array", - "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。update 时传空数组 [] 表示清空所有现有列的规则。", + "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。update 为整组覆盖(PUT 语义):传入的 rules 即更新后的完整规则集,会先清空所有现有列的旧规则再应用,未列出的列其旧规则会被清除(不与旧规则合并);传空数组 [] 即清空所有列的规则。", "items": { "type": "object", "description": "单列筛选规则。", @@ -3929,8 +3898,7 @@ "required": [ "type", "compare_type" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -3975,8 +3943,7 @@ "required": [ "type", "compare_type" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -4005,8 +3972,7 @@ "required": [ "type", "compare_type" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -4069,16 +4035,14 @@ "second" ] } - }, - "additionalProperties": false + } } } }, "required": [ "type", "compare_type" - ], - "additionalProperties": false + ] } ] } @@ -4094,8 +4058,7 @@ "required": [ "column_index", "conditions" - ], - "additionalProperties": false + ] } }, "filtered_columns": { @@ -4109,8 +4072,7 @@ "required": [ "range", "rules" - ], - "additionalProperties": false + ] } }, "+filter-view-create": { @@ -4128,7 +4090,7 @@ }, "rules": { "type": "array", - "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。结构与 manage_filter_object.properties.rules 完全一致。", + "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。结构与 manage_filter_object.properties.rules 完全一致。update 同样为整组覆盖(PUT 语义):传入的 rules 整组替换该视图所有列的规则,未列出的列其旧规则会被清除(不与旧规则合并),传空数组 [] 即清空全部。", "items": { "type": "object", "description": "单列筛选规则。", @@ -4178,8 +4140,7 @@ "required": [ "type", "compare_type" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -4224,8 +4185,7 @@ "required": [ "type", "compare_type" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -4254,8 +4214,7 @@ "required": [ "type", "compare_type" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -4318,16 +4277,14 @@ "second" ] } - }, - "additionalProperties": false + } } } }, "required": [ "type", "compare_type" - ], - "additionalProperties": false + ] } ] } @@ -4343,8 +4300,7 @@ "required": [ "column_index", "conditions" - ], - "additionalProperties": false + ] } }, "filtered_columns": { @@ -4354,8 +4310,7 @@ "type": "string" } } - }, - "additionalProperties": false + } } }, "+filter-view-update": { @@ -4373,7 +4328,7 @@ }, "rules": { "type": "array", - "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。结构与 manage_filter_object.properties.rules 完全一致。", + "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。结构与 manage_filter_object.properties.rules 完全一致。update 同样为整组覆盖(PUT 语义):传入的 rules 整组替换该视图所有列的规则,未列出的列其旧规则会被清除(不与旧规则合并),传空数组 [] 即清空全部。", "items": { "type": "object", "description": "单列筛选规则。", @@ -4423,8 +4378,7 @@ "required": [ "type", "compare_type" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -4469,8 +4423,7 @@ "required": [ "type", "compare_type" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -4499,8 +4452,7 @@ "required": [ "type", "compare_type" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -4563,16 +4515,14 @@ "second" ] } - }, - "additionalProperties": false + } } } }, "required": [ "type", "compare_type" - ], - "additionalProperties": false + ] } ] } @@ -4588,8 +4538,7 @@ "required": [ "column_index", "conditions" - ], - "additionalProperties": false + ] } }, "filtered_columns": { @@ -4599,8 +4548,7 @@ "type": "string" } } - }, - "additionalProperties": false + } } }, "+pivot-create": { @@ -5158,8 +5106,7 @@ } } } - }, - "additionalProperties": {} + } } }, "+pivot-update": { @@ -5717,8 +5664,7 @@ } } } - }, - "additionalProperties": {} + } } }, "+range-sort": { @@ -5806,8 +5752,7 @@ "type": "boolean", "description": "是否显示该点。" } - }, - "additionalProperties": false + } }, "negative_point": { "type": "object", @@ -5821,8 +5766,7 @@ "type": "boolean", "description": "是否显示该点。" } - }, - "additionalProperties": false + } }, "markers_point": { "type": "object", @@ -5836,8 +5780,7 @@ "type": "boolean", "description": "是否显示该点。" } - }, - "additionalProperties": false + } }, "first_point": { "type": "object", @@ -5851,8 +5794,7 @@ "type": "boolean", "description": "是否显示该点。" } - }, - "additionalProperties": false + } }, "high_point": { "type": "object", @@ -5866,8 +5808,7 @@ "type": "boolean", "description": "是否显示该点。" } - }, - "additionalProperties": false + } }, "low_point": { "type": "object", @@ -5881,11 +5822,9 @@ "type": "boolean", "description": "是否显示该点。" } - }, - "additionalProperties": false + } } - }, - "additionalProperties": false + } }, "line_width": { "type": "number", @@ -5922,8 +5861,7 @@ "type": "boolean", "description": "是否显示坐标轴。" } - }, - "additionalProperties": false + } }, "show_gradient": { "type": "boolean", @@ -5953,8 +5891,7 @@ }, "required": [ "type" - ], - "additionalProperties": false + ] }, "extremum_min": { "type": "object", @@ -5976,11 +5913,9 @@ }, "required": [ "type" - ], - "additionalProperties": false + ] } - }, - "additionalProperties": false + } }, "sparklines": { "type": "array", @@ -6010,8 +5945,7 @@ "required": [ "row", "col" - ], - "additionalProperties": false + ] }, "source": { "type": "string", @@ -6028,15 +5962,12 @@ }, "required": [ "range" - ], - "additionalProperties": false + ] } - }, - "additionalProperties": false + } } } - }, - "additionalProperties": false + } } }, "+sparkline-update": { @@ -6101,8 +6032,7 @@ "type": "boolean", "description": "是否显示该点。" } - }, - "additionalProperties": false + } }, "negative_point": { "type": "object", @@ -6116,8 +6046,7 @@ "type": "boolean", "description": "是否显示该点。" } - }, - "additionalProperties": false + } }, "markers_point": { "type": "object", @@ -6131,8 +6060,7 @@ "type": "boolean", "description": "是否显示该点。" } - }, - "additionalProperties": false + } }, "first_point": { "type": "object", @@ -6146,8 +6074,7 @@ "type": "boolean", "description": "是否显示该点。" } - }, - "additionalProperties": false + } }, "high_point": { "type": "object", @@ -6161,8 +6088,7 @@ "type": "boolean", "description": "是否显示该点。" } - }, - "additionalProperties": false + } }, "low_point": { "type": "object", @@ -6176,11 +6102,9 @@ "type": "boolean", "description": "是否显示该点。" } - }, - "additionalProperties": false + } } - }, - "additionalProperties": false + } }, "line_width": { "type": "number", @@ -6217,8 +6141,7 @@ "type": "boolean", "description": "是否显示坐标轴。" } - }, - "additionalProperties": false + } }, "show_gradient": { "type": "boolean", @@ -6248,8 +6171,7 @@ }, "required": [ "type" - ], - "additionalProperties": false + ] }, "extremum_min": { "type": "object", @@ -6271,11 +6193,9 @@ }, "required": [ "type" - ], - "additionalProperties": false + ] } - }, - "additionalProperties": false + } }, "sparklines": { "type": "array", @@ -6305,8 +6225,7 @@ "required": [ "row", "col" - ], - "additionalProperties": false + ] }, "source": { "type": "string", @@ -6323,15 +6242,12 @@ }, "required": [ "range" - ], - "additionalProperties": false + ] } - }, - "additionalProperties": false + } } } - }, - "additionalProperties": false + } } } } diff --git a/shortcuts/sheets/execute_paths_test.go b/shortcuts/sheets/execute_paths_test.go index 3d5a5bd3b..25932768c 100644 --- a/shortcuts/sheets/execute_paths_test.go +++ b/shortcuts/sheets/execute_paths_test.go @@ -217,7 +217,7 @@ func TestExecute_FilterCreate(t *testing.T) { out, err := runShortcutWithStubs(t, FilterCreate, []string{ "--url", testURL, "--sheet-id", testSheetID, "--range", "A1:F100", - "--properties", `{"rules":[{"col":"B","filter_type":"multiValue","expected":["x"]}]}`, + "--properties", `{"rules":[{"column_index":"B","conditions":[{"type":"multiValue","compare_type":"equal","values":["x"]}]}]}`, }, stub) if err != nil { t.Fatalf("execute failed: %v\nout=%s", err, out) @@ -354,7 +354,7 @@ func TestExecute_ChartCreate(t *testing.T) { stub := toolOutputStub(testToken, "write", `{"chart_id":"chartNEW"}`) out, err := runShortcutWithStubs(t, ChartCreate, []string{ "--url", testURL, "--sheet-id", testSheetID, - "--properties", `{"type":"line"}`, + "--properties", `{"type":"line","position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`, }, stub) if err != nil { t.Fatalf("execute failed: %v", err) diff --git a/shortcuts/sheets/flag_schema.go b/shortcuts/sheets/flag_schema.go index 03bf0b65d..bcd692c4f 100644 --- a/shortcuts/sheets/flag_schema.go +++ b/shortcuts/sheets/flag_schema.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "sort" + "sync" ) // ─── --print-schema runtime introspection ───────────────────────────── @@ -38,25 +39,28 @@ type flagSchemaIndex struct { Flags map[string]map[string]json.RawMessage `json:"flags"` } +// loadFlagSchemas is sync.Once-guarded so concurrent first access from +// parallel goroutines (e.g. parallel unit tests, parallel shortcut +// invocations) doesn't race on the lazy parse. var ( + flagSchemasOnce sync.Once parsedFlagSchemas *flagSchemaIndex parseFlagErr error ) func loadFlagSchemas() (*flagSchemaIndex, error) { - if parsedFlagSchemas != nil || parseFlagErr != nil { - return parsedFlagSchemas, parseFlagErr - } - var idx flagSchemaIndex - if err := json.Unmarshal(flagSchemasJSON, &idx); err != nil { - parseFlagErr = fmt.Errorf("flag-schemas.json: %w", err) - return nil, parseFlagErr - } - if idx.Flags == nil { - idx.Flags = map[string]map[string]json.RawMessage{} - } - parsedFlagSchemas = &idx - return parsedFlagSchemas, nil + flagSchemasOnce.Do(func() { + var idx flagSchemaIndex + if err := json.Unmarshal(flagSchemasJSON, &idx); err != nil { + parseFlagErr = fmt.Errorf("flag-schemas.json: %w", err) + return + } + if idx.Flags == nil { + idx.Flags = map[string]map[string]json.RawMessage{} + } + parsedFlagSchemas = &idx + }) + return parsedFlagSchemas, parseFlagErr } // commandsWithFlagSchema returns the set of shortcut commands that have diff --git a/shortcuts/sheets/flag_schema_validate.go b/shortcuts/sheets/flag_schema_validate.go new file mode 100644 index 000000000..f83135d94 --- /dev/null +++ b/shortcuts/sheets/flag_schema_validate.go @@ -0,0 +1,478 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── schema-driven flag validation ──────────────────────────────────── +// +// Composite JSON flags (--properties, --cells, --operations, …) carry +// non-trivial payloads whose shape is already pinned by the embedded +// data/flag-schemas.json (see flag_schema.go). Rather than hand-write +// per-spec validators for type / enum / required / nested checks, every +// such flag is run through validatePropertiesAgainstSchema after the +// shortcut's enhance hook has filled in any flat-flag-derived fields +// (schema describes the *final* tool input, not the raw --properties +// JSON the user typed). Cross-field business rules that JSON Schema +// can't express (e.g. sparkline-update requires sparkline_id per item) +// continue to live in spec.validateUpdateInput. +// +// The rule set is a subset of ai-tools/.../validate-tool-params.ts — +// type, enum, oneOf, required, nested properties, and array items. +// additionalProperties is intentionally lenient: the embedded schema +// is a sub-tree and may not be exhaustive, so rejecting unknown keys +// would be more disruptive than valuable. + +// validateParsedJSONFlag validates the just-parsed value of a single +// JSON flag against its embedded schema, if one is registered for the +// (command, flag) pair. Called from parseJSONFlag so every JSON flag +// — sort-keys, options, border-styles, cells, operations, ranges, … — +// is checked at the user-input boundary, in user-input shape. +// +// `properties` is intentionally skipped here: its schema describes the +// *final* tool-input properties (the shape after enhance* hooks +// inject flat-flag-derived fields such as cond-format's rule_type), +// not what the user typed under --properties. The input-builder tail +// validates that one via validateInputAgainstSchema after enhance. +func validateParsedJSONFlag(fv flagView, name string, value interface{}) error { + if fv == nil || value == nil { + return nil + } + if _, skip := parseJSONFlagSkip[name]; skip { + return nil + } + return validateValueAgainstSchema(fv, name, value) +} + +// parseJSONFlagSkip lists flag names where parseJSONFlag-time schema +// validation is intentionally bypassed: +// +// - properties: schema describes the *final* tool-input shape (after +// enhance hooks inject flat-flag-derived fields); validated at the +// input-builder tail via validateInputAgainstSchema instead. +// - operations: +batch-update's translator does richer validation +// (allowed-shortcut allow-list, fan-out rejection, …) with more +// actionable error messages than a generic "not in enum [...]" +// would. The translator path stays the source of truth. +var parseJSONFlagSkip = map[string]struct{}{ + "properties": {}, + "operations": {}, +} + +// validateValueAgainstSchema is the (command, flag) → schema → check +// pipeline shared by both validateParsedJSONFlag (user shape) and +// validateInputAgainstSchema (wire shape). +func validateValueAgainstSchema(fv flagView, name string, value interface{}) error { + command := fv.Command() + if command == "" { + return nil + } + idx, err := loadFlagSchemas() + if err != nil || idx == nil { + return nil + } + entry, ok := idx.Flags[command] + if !ok { + return nil + } + raw, ok := entry[name] + if !ok { + return nil + } + var schema schemaProperty + if err := json.Unmarshal(raw, &schema); err != nil { + return nil + } + if vErr := validateAgainstSchema(value, &schema, ""); vErr != nil { + return common.FlagErrorf("--%s: %s", name, vErr.Error()) + } + return nil +} + +// validateInputAgainstSchema validates input[flag] for every flag the +// embedded schema registers under the view's shortcut command. Returns +// nil when no schema is registered for the command, or when none of +// the registered flag names appear in `input` (schema describes the +// shape of values when they are present, not which flags must be +// present). Designed to be called at the tail of every input builder +// so wiring up a new shortcut requires only the standard one-line +// invocation, not a per-shortcut validator. +func validateInputAgainstSchema(fv flagView, input map[string]interface{}) error { + if fv == nil || input == nil { + return nil + } + command := fv.Command() + if command == "" { + return nil + } + idx, err := loadFlagSchemas() + if err != nil || idx == nil { + return nil + } + entry, ok := idx.Flags[command] + if !ok || len(entry) == 0 { + return nil + } + + // Deterministic order so error messages are stable across runs. + flagNames := make([]string, 0, len(entry)) + for name := range entry { + flagNames = append(flagNames, name) + } + sort.Strings(flagNames) + + for _, flagName := range flagNames { + if _, skip := inputSchemaSkip[flagName]; skip { + continue + } + // Input keys are wire-style (underscore); schema keys are CLI-style + // (hyphen) — translate before lookup. Flags whose wire form lives + // under a different key (e.g. --sort-keys → sort_conditions) won't + // be found here; they're already validated in user shape via + // parseJSONFlag → validateParsedJSONFlag. + inputKey := strings.ReplaceAll(flagName, "-", "_") + value, present := input[inputKey] + if !present { + continue + } + if err := validateValueAgainstSchema(fv, flagName, value); err != nil { + return err + } + } + return nil +} + +// inputSchemaSkip mirrors parseJSONFlagSkip for the input-builder +// tail. Same rationale: bypass schema validation for flags where +// richer translator-side validation owns the contract (operations). +var inputSchemaSkip = map[string]struct{}{ + "operations": {}, +} + +// schemaProperty mirrors the JSON Schema subset used by +// data/flag-schemas.json. Unknown keys (description, …) are dropped — +// they're documentation. +// +// Minimum / Maximum / MinItems / MaxItems use *float64 / *int because +// 0 is a meaningful bound (e.g. chart row >= 0); nil distinguishes +// "no bound declared" from "bound is zero". +// +// AdditionalProperties handles the JSON Schema three-way: +// - absent / true → lenient, any extra key allowed (validator's +// default; matches the file header's "may not be exhaustive" +// stance for schemas that simply don't declare it). +// - false → strict, every extra key rejected. +// - → extra keys allowed, but each value must validate +// against this schema. Used today for pivot's dynamic +// map> fields (groups / collapse). +type schemaProperty struct { + Type string `json:"type"` + Nullable bool `json:"nullable"` + Enum []interface{} `json:"enum"` + Properties map[string]*schemaProperty `json:"properties"` + Required []string `json:"required"` + Items *schemaProperty `json:"items"` + OneOf []*schemaProperty `json:"oneOf"` + Minimum *float64 `json:"minimum"` + Maximum *float64 `json:"maximum"` + MinItems *int `json:"minItems"` + MaxItems *int `json:"maxItems"` + AdditionalProperties *additionalProps `json:"additionalProperties"` +} + +// additionalProps captures the three JSON Schema forms of +// `additionalProperties`. UnmarshalJSON decodes true / false / object +// into the same struct so callers can branch on (Strict, Schema). +type additionalProps struct { + Strict bool // true when schema declared additionalProperties:false + Schema *schemaProperty // non-nil when declared as an object schema +} + +func (a *additionalProps) UnmarshalJSON(data []byte) error { + trimmed := strings.TrimSpace(string(data)) + switch trimmed { + case "true": + return nil // lenient — same as absent + case "false": + a.Strict = true + return nil + } + var sub schemaProperty + if err := json.Unmarshal(data, &sub); err != nil { + return err + } + a.Schema = &sub + return nil +} + +// validateAgainstSchema recursively checks `value` against `schema`, +// prefixing any failure with the JSON path navigated so far. +func validateAgainstSchema(value interface{}, schema *schemaProperty, path string) error { + if schema == nil { + return nil // defensive — current callers always pass &schema, but + // keeps validator safe for future programmatic construction. + } + if value == nil && schema.Nullable { + return nil + } + + if schema.Type != "" { + if !matchesJSONType(value, schema.Type) { + return fmt.Errorf("%sexpected type %q, got %q", pathPrefix(path), schema.Type, jsType(value)) + } + } + + // Numeric bounds — only checked when value is a number (type mismatch + // 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) + } + if schema.Maximum != nil && num > *schema.Maximum { + return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum) + } + } + + // Array length bounds — only checked when value is an array. + if arr, ok := value.([]interface{}); ok { + if schema.MinItems != nil && len(arr) < *schema.MinItems { + return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems) + } + if schema.MaxItems != nil && len(arr) > *schema.MaxItems { + return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems) + } + } + + if len(schema.Enum) > 0 { + matched := false + for _, allowed := range schema.Enum { + if jsonEqual(allowed, value) { + matched = true + break + } + } + 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 != "" { + msg += fmt.Sprintf(` (did you mean %q?)`, hint) + } + return fmt.Errorf("%s", msg) + } + } + + if len(schema.OneOf) > 0 { + matched := false + for _, sub := range schema.OneOf { + if validateAgainstSchema(value, sub, path) == nil { + matched = true + break + } + } + if !matched { + return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path)) + } + } + + // Object-level checks. `required` and `properties` are independent + // per JSON Schema: `required` enforces keys regardless of whether + // the schema also describes their per-key shape via `properties`. + 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)) + } + } + if schema.Properties != nil { + keys := make([]string, 0, len(schema.Properties)) + for k := range schema.Properties { + keys = append(keys, k) + } + sort.Strings(keys) + for _, key := range keys { + sub := schema.Properties[key] + v, present := obj[key] + if !present { + continue + } + child := key + if path != "" { + child = path + "." + key + } + if err := validateAgainstSchema(v, sub, child); err != nil { + return err + } + } + } + // additionalProperties: enforce only when explicitly declared. + // Absent means lenient (matches the file header's stance). Sort + // extras so the first rejection is deterministic across runs. + if schema.AdditionalProperties != nil { + extras := make([]string, 0) + for key := range obj { + if _, declared := schema.Properties[key]; declared { + continue + } + extras = append(extras, key) + } + sort.Strings(extras) + for _, key := range extras { + if schema.AdditionalProperties.Strict { + return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key) + } + if schema.AdditionalProperties.Schema != nil { + child := key + if path != "" { + child = path + "." + key + } + if err := validateAgainstSchema(obj[key], schema.AdditionalProperties.Schema, child); err != nil { + return err + } + } + } + } + } + + if schema.Type == "array" && schema.Items != nil { + arr, ok := value.([]interface{}) + if !ok { + return nil // type mismatch already reported above. + } + for i, item := range arr { + child := fmt.Sprintf("%s[%d]", path, i) + if err := validateAgainstSchema(item, schema.Items, child); err != nil { + return err + } + } + } + + return nil +} + +func matchesJSONType(value interface{}, expected string) bool { + switch expected { + case "object": + _, ok := value.(map[string]interface{}) + return ok + case "array": + _, ok := value.([]interface{}) + return ok + case "string": + _, ok := value.(string) + return ok + case "number": + _, ok := value.(float64) + return ok + case "integer": + f, ok := value.(float64) + return ok && f == float64(int64(f)) + case "boolean": + _, ok := value.(bool) + return ok + case "null": + return value == nil + } + return true +} + +func jsType(value interface{}) string { + switch value.(type) { + case nil: + return "null" + case map[string]interface{}: + return "object" + case []interface{}: + return "array" + case string: + return "string" + case float64: + return "number" + case bool: + return "boolean" + } + return fmt.Sprintf("%T", value) +} + +func jsonEqual(a, b interface{}) bool { + ja, _ := json.Marshal(a) + jb, _ := json.Marshal(b) + return string(ja) == string(jb) +} + +// formatJSONValue is the "what you actually passed" half of an enum +// error. Strings get JSON-quoted ("SUM"); everything else (numbers, +// booleans, null, objects, arrays) gets its JSON encoding. Marshal +// failure falls back to %v so we never panic just to format an error. +func formatJSONValue(v interface{}) string { + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(b) +} + +// formatEnum renders the allowed-values list for an enum error. Caps +// the visible entries at enumDisplayLimit so a 50-shortcut enum +// doesn't bury the actual error in a wall of options; the overflow +// hint tells the user how many more exist (and to consult --help / +// --print-schema for the full list). +const enumDisplayLimit = 8 + +func formatEnum(values []interface{}) string { + if len(values) <= enumDisplayLimit { + return "[" + joinFormatted(values) + "]" + } + shown := values[:enumDisplayLimit] + return fmt.Sprintf("[%s, … (%d more)]", joinFormatted(shown), len(values)-enumDisplayLimit) +} + +func joinFormatted(values []interface{}) string { + parts := make([]string, 0, len(values)) + for _, v := range values { + parts = append(parts, formatJSONValue(v)) + } + 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. +func suggestEnumMatch(value interface{}, values []interface{}) string { + s, ok := value.(string) + if !ok { + return "" + } + lower := strings.ToLower(s) + 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 + } + } + } + return "" +} + +func pathPrefix(path string) string { + if path == "" { + return "" + } + return path + ": " +} + +func pathOrRoot(path string) string { + if path == "" { + return "(root)" + } + return path +} diff --git a/shortcuts/sheets/flag_schema_validate_test.go b/shortcuts/sheets/flag_schema_validate_test.go new file mode 100644 index 000000000..307e2874c --- /dev/null +++ b/shortcuts/sheets/flag_schema_validate_test.go @@ -0,0 +1,531 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "encoding/json" + "strings" + "testing" +) + +// parseSchema is a tiny test helper: take an inline JSON Schema string, +// hand back a *schemaProperty for validateAgainstSchema. Lets test +// cases declare their schema inline rather than hand-building structs. +func parseSchema(t *testing.T, raw string) *schemaProperty { + t.Helper() + var s schemaProperty + if err := json.Unmarshal([]byte(raw), &s); err != nil { + t.Fatalf("bad inline schema %q: %v", raw, err) + } + return &s +} + +// parseValue decodes a JSON literal the same way encoding/json gives +// validateAgainstSchema its input (numbers → float64, objects → +// map[string]interface{}, arrays → []interface{}). +func parseValue(t *testing.T, raw string) interface{} { + t.Helper() + var v interface{} + if err := json.Unmarshal([]byte(raw), &v); err != nil { + t.Fatalf("bad inline value %q: %v", raw, err) + } + return v +} + +// TestValidateAgainstSchema is the validator's contract test: every +// supported keyword (type, enum, oneOf, required, nested properties, +// array items, nullable, minimum/maximum, minItems/maxItems) gets a +// pass + fail case, and the failure message is asserted to mention +// the JSON path and the violated constraint. Together these pin the +// validator's behaviour without going through any shortcut wiring. +func TestValidateAgainstSchema(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + schema string + value string + wantOK bool + wantInErr string // substring required in error message when !wantOK + }{ + // ─── type ───────────────────────────────────────────────────── + {"type string ok", `{"type":"string"}`, `"hi"`, true, ""}, + {"type string wrong", `{"type":"string"}`, `42`, false, `expected type "string"`}, + {"type number ok", `{"type":"number"}`, `3.14`, true, ""}, + {"type number wrong", `{"type":"number"}`, `"x"`, false, `got "string"`}, + {"type integer ok", `{"type":"integer"}`, `5`, true, ""}, + {"type integer fractional rejected", `{"type":"integer"}`, `5.5`, false, `expected type "integer"`}, + {"type boolean ok", `{"type":"boolean"}`, `true`, true, ""}, + {"type array ok", `{"type":"array"}`, `[1,2]`, true, ""}, + {"type object ok", `{"type":"object"}`, `{"a":1}`, true, ""}, + + // ─── nullable short-circuit ─────────────────────────────────── + {"nullable null accepted", `{"type":"string","nullable":true}`, `null`, true, ""}, + {"nullable schema still type-checks non-null", `{"type":"string","nullable":true}`, `42`, false, `expected type "string"`}, + {"nullable schema accepts matching type", `{"type":"string","nullable":true}`, `"x"`, true, ""}, + {"null rejected when nullable not set", `{"type":"string"}`, `null`, false, `expected type "string"`}, + + // ─── enum ──────────────────────────────────────────────────── + {"enum hit", `{"type":"string","enum":["asc","desc"]}`, `"asc"`, true, ""}, + {"enum miss", `{"type":"string","enum":["asc","desc"]}`, `"sideways"`, false, `not in enum ["asc", "desc"]`}, + + // ─── oneOf ─────────────────────────────────────────────────── + {"oneOf string branch", `{"oneOf":[{"type":"string"},{"type":"number"}]}`, `"x"`, true, ""}, + {"oneOf number branch", `{"oneOf":[{"type":"string"},{"type":"number"}]}`, `7`, true, ""}, + {"oneOf no branch", `{"oneOf":[{"type":"string"},{"type":"number"}]}`, `true`, false, `oneOf alternatives`}, + + // ─── required ──────────────────────────────────────────────── + { + "required key present", + `{"type":"object","required":["a"],"properties":{"a":{"type":"string"}}}`, + `{"a":"x"}`, true, "", + }, + { + "required key missing", + `{"type":"object","required":["a"]}`, + `{}`, false, `required property "a"`, + }, + + // ─── nested properties recurse ─────────────────────────────── + { + "nested property wrong type", + `{"type":"object","properties":{"inner":{"type":"object","properties":{"x":{"type":"number"}}}}}`, + `{"inner":{"x":"oops"}}`, false, `inner.x: expected type "number"`, + }, + + // ─── array items recurse with [i] path ─────────────────────── + { + "array items ok", + `{"type":"array","items":{"type":"string"}}`, + `["a","b"]`, true, "", + }, + { + "array item wrong type pinpoints index", + `{"type":"array","items":{"type":"string"}}`, + `["a",2,"c"]`, false, `[1]: expected type "string"`, + }, + + // ─── numeric bounds (P0 additions) ─────────────────────────── + {"minimum ok", `{"type":"number","minimum":0}`, `0`, true, ""}, + {"minimum fail", `{"type":"number","minimum":0}`, `-1`, false, `below minimum`}, + {"maximum ok", `{"type":"number","maximum":100}`, `100`, true, ""}, + {"maximum fail", `{"type":"number","maximum":100}`, `101`, false, `above maximum`}, + {"minimum on integer", `{"type":"integer","minimum":10}`, `5`, false, `below minimum`}, + + // ─── array length bounds (P0 additions) ────────────────────── + {"minItems ok", `{"type":"array","minItems":1}`, `[1]`, true, ""}, + {"minItems fail", `{"type":"array","minItems":1}`, `[]`, false, `array has 0 items, minimum is 1`}, + {"maxItems ok", `{"type":"array","maxItems":3}`, `[1,2,3]`, true, ""}, + {"maxItems fail", `{"type":"array","maxItems":3}`, `[1,2,3,4]`, false, `array has 4 items, maximum is 3`}, + + // ─── combined bounds inside nested array of objects ────────── + { + "nested minimum in array item objects", + `{"type":"array","items":{"type":"object","properties":{"row":{"type":"integer","minimum":0}}}}`, + `[{"row":0},{"row":-1}]`, false, `[1].row: value -1 is below minimum 0`, + }, + + // ─── additionalProperties absent: lenient (default) ────────── + { + "extras allowed when additionalProperties absent", + `{"type":"object","properties":{"a":{"type":"string"}}}`, + `{"a":"x","whatever":42}`, true, "", + }, + + // ─── additionalProperties:false: strict mode ───────────────── + { + "extras allowed when additionalProperties:true (explicit)", + `{"type":"object","properties":{"a":{"type":"string"}},"additionalProperties":true}`, + `{"a":"x","extra":1}`, true, "", + }, + { + "extras rejected when additionalProperties:false", + `{"type":"object","properties":{"a":{"type":"string"}},"additionalProperties":false}`, + `{"a":"x","typo":1}`, false, `unexpected property "typo"`, + }, + { + "declared property still accepted under strict mode", + `{"type":"object","properties":{"a":{"type":"string"}},"additionalProperties":false}`, + `{"a":"x"}`, true, "", + }, + + // ─── additionalProperties:: extras must match ──────── + { + "extras pass when matching additionalProperties schema", + `{"type":"object","properties":{"name":{"type":"string"}},"additionalProperties":{"type":"array","items":{"type":"string"}}}`, + `{"name":"x","g1":["a","b"],"g2":["c"]}`, true, "", + }, + { + "extras fail when wrong type for additionalProperties schema", + `{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}}}`, + `{"g1":[1,2]}`, false, `g1[0]: expected type "string"`, + }, + { + "extras fail when value isn't even right kind", + `{"type":"object","additionalProperties":{"type":"array"}}`, + `{"key":"not-an-array"}`, false, `key: expected type "array"`, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + s := parseSchema(t, tc.schema) + v := parseValue(t, tc.value) + err := validateAgainstSchema(v, s, "") + if tc.wantOK { + if err != nil { + t.Fatalf("expected pass, got error: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got pass", tc.wantInErr) + } + if !strings.Contains(err.Error(), tc.wantInErr) { + t.Errorf("error = %q, want substring %q", err.Error(), tc.wantInErr) + } + }) + } +} + +// TestValidateAgainstSchema_EnumErrorEnhancements pins the three +// enum-error UX upgrades together: +// - the failing value is quoted in JSON form ("SUM", not bare SUM) +// - the allowed list is JSON-quoted ("sum", not bare sum) and gets +// truncated past 8 entries with an "N more" hint +// - case-only mismatches surface a `did you mean` suggestion +// pointing at the canonical spelling +func TestValidateAgainstSchema_EnumErrorEnhancements(t *testing.T) { + t.Parallel() + + t.Run("small enum is fully listed and quoted", func(t *testing.T) { + t.Parallel() + s := parseSchema(t, `{"type":"string","enum":["asc","desc"]}`) + err := validateAgainstSchema("sideways", s, "order") + if err == nil { + t.Fatal("expected enum violation") + } + msg := err.Error() + if !strings.Contains(msg, `value "sideways"`) { + t.Errorf("want failing value quoted; got %q", msg) + } + if !strings.Contains(msg, `["asc", "desc"]`) { + t.Errorf("want enum list comma+quote formatted; got %q", msg) + } + }) + + t.Run("large enum is truncated with overflow hint", func(t *testing.T) { + t.Parallel() + // 12 values; default enumDisplayLimit is 8. + s := parseSchema(t, `{"type":"string","enum":[ + "a","b","c","d","e","f","g","h","i","j","k","l" + ]}`) + err := validateAgainstSchema("z", s, "x") + if err == nil { + t.Fatal("expected enum violation") + } + msg := err.Error() + if !strings.Contains(msg, "4 more") { + t.Errorf("want overflow hint '4 more'; got %q", msg) + } + if strings.Contains(msg, `"i"`) || strings.Contains(msg, `"l"`) { + t.Errorf("want truncation to first 8; got %q", msg) + } + if !strings.Contains(msg, `"h"`) { // 8th entry should be present. + t.Errorf("want first 8 entries shown; got %q", msg) + } + }) + + t.Run("case-only mismatch produces did-you-mean hint", func(t *testing.T) { + t.Parallel() + s := parseSchema(t, `{"type":"string","enum":["sum","count","average"]}`) + err := validateAgainstSchema("SUM", s, "") + if err == nil { + t.Fatal("expected enum violation") + } + if !strings.Contains(err.Error(), `did you mean "sum"?`) { + t.Errorf("want did-you-mean hint; got %q", err.Error()) + } + }) + + t.Run("no did-you-mean when value is not a near miss", func(t *testing.T) { + t.Parallel() + s := parseSchema(t, `{"type":"string","enum":["sum","count"]}`) + err := validateAgainstSchema("BOGUS", s, "") + if err == nil { + t.Fatal("expected enum violation") + } + if strings.Contains(err.Error(), "did you mean") { + t.Errorf("want no hint for unrelated value; got %q", err.Error()) + } + }) + + t.Run("did-you-mean only triggers for strings (not numbers)", func(t *testing.T) { + t.Parallel() + s := parseSchema(t, `{"enum":[1,2,3]}`) + err := validateAgainstSchema(float64(4), s, "") + if err == nil { + t.Fatal("expected enum violation") + } + if strings.Contains(err.Error(), "did you mean") { + t.Errorf("numeric enum should not get casing hint; got %q", err.Error()) + } + // And the failing numeric value still surfaces in JSON form. + if !strings.Contains(err.Error(), "value 4 ") { + t.Errorf("want numeric value in error; got %q", err.Error()) + } + }) +} + +// TestValidateInputAgainstSchema_RealEnumDidYouMean exercises the +// did-you-mean path against the real embedded schema for the most +// common real-world miscue — pivot summarize_by upper-cased. +func TestValidateInputAgainstSchema_RealEnumDidYouMean(t *testing.T) { + t.Parallel() + fv := mapFlagView{command: "+pivot-create"} + bad := map[string]interface{}{ + "properties": map[string]interface{}{ + "values": []interface{}{ + map[string]interface{}{"field": "A", "summarize_by": "SUM"}, + }, + }, + } + err := validateInputAgainstSchema(fv, bad) + if err == nil { + t.Fatal("expected enum violation") + } + msg := err.Error() + if !strings.Contains(msg, `did you mean "sum"?`) { + t.Errorf("expected did-you-mean hint pointing at \"sum\"; got %q", msg) + } +} + +// 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 +// programmatic construction (or a malformed schema sub-tree decoded +// as a nil pointer inside oneOf) won't crash with a nil deref. +func TestValidateAgainstSchema_NilSchemaSafe(t *testing.T) { + t.Parallel() + if err := validateAgainstSchema("anything", nil, ""); err != nil { + t.Errorf("nil schema should noop; got %v", err) + } +} + +// TestValidateAgainstSchema_AdditionalPropertiesSortedFirstFailure +// asserts that when multiple extras violate additionalProperties:false, +// the *alphabetically first* extra is the one reported — without the +// sort, Go map iteration would make the failing key non-deterministic +// across runs and the error message would flake. +func TestValidateAgainstSchema_AdditionalPropertiesSortedFirstFailure(t *testing.T) { + t.Parallel() + schema := parseSchema(t, `{ + "type":"object", + "properties":{"declared":{"type":"string"}}, + "additionalProperties":false + }`) + // Three extras; "alpha" comes first when sorted. + value := parseValue(t, `{"declared":"ok","zeta":1,"alpha":2,"middle":3}`) + for i := 0; i < 30; i++ { + err := validateAgainstSchema(value, schema, "") + if err == nil { + t.Fatalf("iter %d: expected extras to be rejected", i) + } + if !strings.Contains(err.Error(), `"alpha"`) { + t.Fatalf("iter %d: expected alphabetically first extra to be reported; got %v", i, err) + } + } +} + +// TestValidateAgainstSchema_ArrayItemRequired pins that `required` +// fires inside array items too — the recursion path applies the same +// object-level rules at every level, so a missing key in items +// surfaces as `[i].missing` and not a silently-passed item. +func TestValidateAgainstSchema_ArrayItemRequired(t *testing.T) { + t.Parallel() + schema := parseSchema(t, `{ + "type":"array", + "items":{ + "type":"object", + "required":["id"], + "properties":{"id":{"type":"string"}} + } + }`) + value := parseValue(t, `[{"id":"a"},{"name":"b"}]`) + err := validateAgainstSchema(value, schema, "") + if err == nil { + t.Fatal("expected required violation on items[1]") + } + if !strings.Contains(err.Error(), `required property "id"`) || !strings.Contains(err.Error(), "[1]") { + t.Errorf("expected required-id at [1]; got %v", err) + } +} + +// TestValidateAgainstSchema_DeterministicPropertyOrder regresses the +// "iterate properties in sorted key order" guarantee so that the +// first-failure error message is stable across runs (Go map iteration +// is randomized — without the sort, a schema with two bad fields +// would non-deterministically report either one). +func TestValidateAgainstSchema_DeterministicPropertyOrder(t *testing.T) { + t.Parallel() + schema := parseSchema(t, `{ + "type":"object", + "properties":{ + "a":{"type":"string"}, + "b":{"type":"string"}, + "c":{"type":"string"} + } + }`) + value := parseValue(t, `{"a":1,"b":2,"c":3}`) + // Run many times; "a" must always be the reported field (sorted first). + for i := 0; i < 50; i++ { + err := validateAgainstSchema(value, schema, "") + if err == nil || !strings.Contains(err.Error(), "a:") { + t.Fatalf("iter %d: expected error mentioning 'a:'; got %v", i, err) + } + } +} + +// TestValidateInputAgainstSchema_RealSchema exercises the full +// (command, flag) lookup pipeline against the real embedded +// flag-schemas.json — confirms that an out-of-enum summarize_by +// surfaces a descriptive error all the way through, and that a +// well-formed input passes. Mirrors what shortcut tests check, but +// without booting cobra. +func TestValidateInputAgainstSchema_RealSchema(t *testing.T) { + t.Parallel() + fv := mapFlagView{command: "+pivot-create"} + + // Schema-conformant: values[0].summarize_by="sum" is in enum. + good := map[string]interface{}{ + "properties": map[string]interface{}{ + "values": []interface{}{ + map[string]interface{}{"field": "A", "summarize_by": "sum"}, + }, + }, + } + if err := validateInputAgainstSchema(fv, good); err != nil { + t.Errorf("good input rejected: %v", err) + } + + // Schema-violating: summarize_by="SUM" upper-case is not in enum. + bad := map[string]interface{}{ + "properties": map[string]interface{}{ + "values": []interface{}{ + map[string]interface{}{"field": "A", "summarize_by": "SUM"}, + }, + }, + } + err := validateInputAgainstSchema(fv, bad) + if err == nil { + t.Fatal("expected enum violation, got nil") + } + if !strings.Contains(err.Error(), "summarize_by") || !strings.Contains(err.Error(), "not in enum") { + t.Errorf("error = %q, want summarize_by + enum hint", err.Error()) + } +} + +// TestValidateInputAgainstSchema_RealMinItems exercises a P0 +// addition end-to-end: +pivot-create properties.values has +// minItems:1, so an explicit empty values array is rejected by the +// schema validator (previously slipped past). +func TestValidateInputAgainstSchema_RealMinItems(t *testing.T) { + t.Parallel() + fv := mapFlagView{command: "+pivot-create"} + bad := map[string]interface{}{ + "properties": map[string]interface{}{ + "values": []interface{}{}, // minItems:1 violated + }, + } + err := validateInputAgainstSchema(fv, bad) + if err == nil { + t.Fatal("expected minItems violation for empty values, got nil") + } + if !strings.Contains(err.Error(), "values") || !strings.Contains(err.Error(), "minimum is 1") { + t.Errorf("error = %q, want values + minimum-is-1 hint", err.Error()) + } +} + +// TestValidateInputAgainstSchema_RealMinimum exercises another P0 +// addition: +chart-create properties.position.row has minimum:0, so +// row:-1 must be rejected before the request hits the wire. +func TestValidateInputAgainstSchema_RealMinimum(t *testing.T) { + t.Parallel() + fv := mapFlagView{command: "+chart-create"} + bad := map[string]interface{}{ + "properties": map[string]interface{}{ + "position": map[string]interface{}{"row": float64(-1), "col": "A"}, + "size": map[string]interface{}{"width": float64(400), "height": float64(300)}, + }, + } + err := validateInputAgainstSchema(fv, bad) + if err == nil { + t.Fatal("expected minimum violation for row:-1, got nil") + } + if !strings.Contains(err.Error(), "row") || !strings.Contains(err.Error(), "below minimum") { + t.Errorf("error = %q, want row + below-minimum hint", err.Error()) + } +} + +// TestValidateInputAgainstSchema_RealAdditionalProperties pins the +// additionalProperties: form against the real embedded +// schema. +pivot-create properties.collapse is declared as a dynamic +// map>; passing a non-string in any value +// must be rejected end-to-end. +func TestValidateInputAgainstSchema_RealAdditionalProperties(t *testing.T) { + t.Parallel() + fv := mapFlagView{command: "+pivot-create"} + + good := map[string]interface{}{ + "properties": map[string]interface{}{ + "values": []interface{}{map[string]interface{}{"field": "A", "summarize_by": "sum"}}, + "collapse": map[string]interface{}{"region": []interface{}{"NA", "EU"}}, + }, + } + if err := validateInputAgainstSchema(fv, good); err != nil { + t.Errorf("schema-conformant collapse rejected: %v", err) + } + + bad := map[string]interface{}{ + "properties": map[string]interface{}{ + "values": []interface{}{map[string]interface{}{"field": "A", "summarize_by": "sum"}}, + "collapse": map[string]interface{}{"region": []interface{}{"NA", 42}}, // 42 violates items.type=string + }, + } + err := validateInputAgainstSchema(fv, bad) + if err == nil { + t.Fatal("expected additionalProperties violation, got nil") + } + if !strings.Contains(err.Error(), "collapse") || !strings.Contains(err.Error(), `expected type "string"`) { + t.Errorf("error = %q, want collapse + string-type hint", err.Error()) + } +} + +// TestValidateInputAgainstSchema_UnknownCommand returns nil — schema +// validation is opportunistic, an unknown command never errors. Lets +// shortcuts opt out simply by not registering a schema entry. +func TestValidateInputAgainstSchema_UnknownCommand(t *testing.T) { + t.Parallel() + fv := mapFlagView{command: "+definitely-not-a-shortcut"} + if err := validateInputAgainstSchema(fv, map[string]interface{}{"properties": "anything"}); err != nil { + t.Errorf("unknown command should noop; got %v", err) + } +} + +// TestValidateInputAgainstSchema_SkipOperations confirms that the +// operations skip-list entry is honoured: even with a clearly +// malformed operations value, validateInputAgainstSchema is a no-op +// because translator-side validation owns that contract. +func TestValidateInputAgainstSchema_SkipOperations(t *testing.T) { + t.Parallel() + fv := mapFlagView{command: "+batch-update"} + input := map[string]interface{}{ + "operations": "definitely-not-an-array", + } + if err := validateInputAgainstSchema(fv, input); err != nil { + t.Errorf("operations should be skipped; got %v", err) + } +} diff --git a/shortcuts/sheets/flag_view.go b/shortcuts/sheets/flag_view.go index d99f65304..3e922c527 100644 --- a/shortcuts/sheets/flag_view.go +++ b/shortcuts/sheets/flag_view.go @@ -27,6 +27,12 @@ type flagView interface { StrArray(name string) []string StrSlice(name string) []string Changed(name string) bool + // Command returns the shortcut command this view feeds (e.g. + // "+pivot-create"). Used to look up the schema entry for + // schema-driven flag validation; both standalone and batch sub-op + // paths populate it so a sub-op gets validated against the same + // schema as the standalone shortcut. + Command() string } // mapFlagView adapts a +batch-update sub-op input object (decoded JSON) to the @@ -47,13 +53,16 @@ type flagView interface { type mapFlagView struct { raw map[string]interface{} // user-supplied sub-op input (drives Changed) defaults map[string]interface{} // flag defaults (value fallback only) + command string // shortcut command (e.g. "+chart-create"); used by schema validator } +func (m mapFlagView) Command() string { return m.command } + // newMapFlagViewForCommand wraps a sub-op input and seeds the value-fallback // defaults declared for `command` in flag-defs.json, so an absent flag resolves // to the same value the standalone cobra command would carry. func newMapFlagViewForCommand(command string, input map[string]interface{}) mapFlagView { - fv := mapFlagView{raw: input, defaults: map[string]interface{}{}} + fv := mapFlagView{raw: input, defaults: map[string]interface{}{}, command: command} defs, err := loadFlagDefs() if err != nil { return fv diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go index 3b8bf1daf..3465484cb 100644 --- a/shortcuts/sheets/helpers.go +++ b/shortcuts/sheets/helpers.go @@ -199,6 +199,13 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) { if err := json.Unmarshal([]byte(raw), &out); err != nil { return nil, common.FlagErrorf("--%s: invalid JSON: %v", name, err) } + // Schema-driven flag validation at the user-input boundary. Skips + // --properties (validated at the input-builder tail after enhance + // hooks fill in flat-flag-derived fields) and any flag without an + // embedded schema entry. + if err := validateParsedJSONFlag(runtime, name, out); err != nil { + return nil, err + } return out, nil } diff --git a/shortcuts/sheets/lark_sheet_batch_update_test.go b/shortcuts/sheets/lark_sheet_batch_update_test.go index b216a9c0c..a9ef16ac3 100644 --- a/shortcuts/sheets/lark_sheet_batch_update_test.go +++ b/shortcuts/sheets/lark_sheet_batch_update_test.go @@ -290,13 +290,14 @@ func TestBatchUpdate_ValidationGuards(t *testing.T) { } // dropdown-update with non-array --options (object instead) → array guard + // (now via schema validator at parseJSONFlag time) stdout, stderr, err = runShortcutCapturingErr(t, DropdownUpdate, []string{ "--url", testURL, "--ranges", `["sheet1!A1:A2"]`, "--options", `{"not":"array"}`, "--dry-run", }) - if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must be a JSON array") { + if err == nil || !strings.Contains(stdout+stderr+err.Error(), `expected type "array"`) { t.Errorf("expected JSON array guard; got=%s|%s|%v", stdout, stderr, err) } } diff --git a/shortcuts/sheets/lark_sheet_object_crud.go b/shortcuts/sheets/lark_sheet_object_crud.go index b896729ea..66b48c27b 100644 --- a/shortcuts/sheets/lark_sheet_object_crud.go +++ b/shortcuts/sheets/lark_sheet_object_crud.go @@ -49,11 +49,13 @@ type objectCRUDSpec struct { enhanceCreateInput func(rt flagView, input map[string]interface{}) enhanceUpdateInput func(rt flagView, input map[string]interface{}) // validateUpdateInput, when set, runs after enhanceUpdateInput to - // enforce constraints that span across input fields (e.g. sparkline - // requires properties.sparklines[i] to carry sparkline_id on update — - // a server contract the CLI now surfaces with a pointer to - // +sparkline-list instead of letting the caller hit an opaque - // server-side rejection). + // enforce *cross-field, update-only* constraints JSON Schema can't + // express (e.g. sparkline requires properties.sparklines[i] to + // carry sparkline_id on update — same schema is shared with create + // where the id is server-assigned). Type / enum / required / + // nested-shape checks are not handled here: they run automatically + // against data/flag-schemas.json in objectCreateInput / + // objectUpdateInput via validatePropertiesAgainstSchema. validateUpdateInput func(input map[string]interface{}) error // allowEmptySheetSelectorOnCreate, when true, makes the *create* // shortcut accept empty --sheet-id / --sheet-name (backend then picks @@ -162,6 +164,9 @@ func objectCreateInput(runtime flagView, token, sheetID, sheetName string, spec if spec.enhanceCreateInput != nil { spec.enhanceCreateInput(runtime, input) } + if err := validateInputAgainstSchema(runtime, input); err != nil { + return nil, err + } return input, nil } @@ -238,6 +243,9 @@ func objectUpdateInput(runtime flagView, token, sheetID, sheetName string, spec if spec.enhanceUpdateInput != nil { spec.enhanceUpdateInput(runtime, input) } + if err := validateInputAgainstSchema(runtime, input); err != nil { + return nil, err + } if spec.validateUpdateInput != nil { if err := spec.validateUpdateInput(input); err != nil { return nil, err @@ -811,6 +819,9 @@ func filterCreateInput(runtime flagView, token, sheetID, sheetName string) (map[ "properties": props, } sheetSelectorForToolInput(input, sheetID, sheetName) + if err := validateInputAgainstSchema(runtime, input); err != nil { + return nil, err + } return input, nil } @@ -878,6 +889,9 @@ func filterUpdateInput(runtime flagView, token, sheetID, sheetName string) (map[ "properties": props, } sheetSelectorForToolInput(input, sheetID, sheetName) + if err := validateInputAgainstSchema(runtime, input); err != nil { + return nil, err + } return input, nil } diff --git a/shortcuts/sheets/lark_sheet_object_crud_test.go b/shortcuts/sheets/lark_sheet_object_crud_test.go index d7752887a..5d8ccefcb 100644 --- a/shortcuts/sheets/lark_sheet_object_crud_test.go +++ b/shortcuts/sheets/lark_sheet_object_crud_test.go @@ -29,26 +29,34 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { { name: "+chart-create", sc: ChartCreate, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--properties", `{"type":"line"}`}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--properties", `{"type":"line","position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`}, toolName: "manage_chart_object", wantInput: map[string]interface{}{ - "excel_id": testToken, - "sheet_id": testSheetID, - "operation": "create", - "properties": map[string]interface{}{"type": "line"}, + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "create", + "properties": map[string]interface{}{ + "type": "line", + "position": map[string]interface{}{"row": float64(0), "col": "A"}, + "size": map[string]interface{}{"width": float64(400), "height": float64(300)}, + }, }, }, { name: "+chart-update", sc: ChartUpdate, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chartXYZ", "--properties", `{"type":"bar"}`}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chartXYZ", "--properties", `{"type":"bar","position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`}, toolName: "manage_chart_object", wantInput: map[string]interface{}{ - "excel_id": testToken, - "sheet_id": testSheetID, - "operation": "update", - "chart_id": "chartXYZ", - "properties": map[string]interface{}{"type": "bar"}, + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "update", + "chart_id": "chartXYZ", + "properties": map[string]interface{}{ + "type": "bar", + "position": map[string]interface{}{"row": float64(0), "col": "A"}, + "size": map[string]interface{}{"width": float64(400), "height": float64(300)}, + }, }, }, // pivot — has extra create flags incl. required --source. @@ -149,24 +157,34 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { { name: "+filter-create without --properties sends properties.range only", sc: FilterCreate, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:F1000"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:F1000", "--properties", `{"rules":[]}`}, toolName: "manage_filter_object", wantInput: map[string]interface{}{ - "excel_id": testToken, - "sheet_id": testSheetID, - "operation": "create", - "properties": map[string]interface{}{"range": "A1:F1000"}, + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "create", + "properties": map[string]interface{}{ + "range": "A1:F1000", + "rules": []interface{}{}, + }, }, }, { name: "+filter-create with --properties merges rules", sc: FilterCreate, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:F1000", "--properties", `{"rules":[{"col":"B"}]}`}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:F1000", "--properties", `{"rules":[{"column_index":"B","conditions":[{"type":"text","compare_type":"contains","values":["x"]}]}]}`}, toolName: "manage_filter_object", wantInput: map[string]interface{}{ "properties": map[string]interface{}{ "range": "A1:F1000", - "rules": []interface{}{map[string]interface{}{"col": "B"}}, + "rules": []interface{}{map[string]interface{}{ + "column_index": "B", + "conditions": []interface{}{map[string]interface{}{ + "type": "text", + "compare_type": "contains", + "values": []interface{}{"x"}, + }}, + }}, }, }, }, @@ -194,7 +212,7 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { args: []string{ "--url", testURL, "--sheet-id", testSheetID, "--range", "A1:F1000", - "--properties", `{"rules":[{"col":"B"}]}`, + "--properties", `{"rules":[{"column_index":"B","conditions":[{"type":"text","compare_type":"contains","values":["x"]}]}]}`, }, toolName: "manage_filter_object", wantInput: map[string]interface{}{ @@ -204,7 +222,14 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { "operation": "update", "properties": map[string]interface{}{ "range": "A1:F1000", - "rules": []interface{}{map[string]interface{}{"col": "B"}}, + "rules": []interface{}{map[string]interface{}{ + "column_index": "B", + "conditions": []interface{}{map[string]interface{}{ + "type": "text", + "compare_type": "contains", + "values": []interface{}{"x"}, + }}, + }}, }, }, }, @@ -416,6 +441,59 @@ func TestPivotCreate_SheetSelectorSemantics(t *testing.T) { }) } +// TestPivotCreate_SchemaValidates exercises the schema-driven +// validator wired into objectCreateInput. The pivot create schema +// doesn't constrain rows/columns/values to be present (the backend +// just creates an empty shell), but it does pin types and enums — +// confirm both kinds of misuse are surfaced as CLI-side errors and +// that schema-conformant input is accepted. +func TestPivotCreate_SchemaValidates(t *testing.T) { + t.Parallel() + + t.Run("rejects wrong type for rows", func(t *testing.T) { + t.Parallel() + _, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{ + "--url", testURL, + "--properties", `{"rows":"not-an-array"}`, + "--source", "Sheet1!A1:F1000", + "--dry-run", + }) + if err == nil { + t.Fatalf("expected schema validator to reject rows=string; stderr=%s", stderr) + } + combined := stderr + err.Error() + if !strings.Contains(combined, "rows") || !strings.Contains(combined, "array") { + t.Errorf("expected error to mention rows/array; got=%s|%v", stderr, err) + } + }) + + t.Run("rejects out-of-enum summarize_by", func(t *testing.T) { + t.Parallel() + _, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{ + "--url", testURL, + "--properties", `{"values":[{"field":"A","summarize_by":"BOGUS"}]}`, + "--source", "Sheet1!A1:F1000", + "--dry-run", + }) + if err == nil { + t.Fatalf("expected schema validator to reject summarize_by=BOGUS; stderr=%s", stderr) + } + if !strings.Contains(stderr+err.Error(), "summarize_by") { + t.Errorf("expected error to mention summarize_by; got=%s|%v", stderr, err) + } + }) + + t.Run("schema-conformant input is accepted", func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, PivotCreate, []string{ + "--url", testURL, + "--properties", `{"values":[{"field":"A","summarize_by":"sum"}]}`, + "--source", "Sheet1!A1:F1000", + }) + decodeToolInput(t, body, "manage_pivot_table_object") + }) +} + // TestObjectCreate_RequiresSheetSelector regresses the non-pivot create // shortcuts: pivot-create is the only one whose spec sets // allowEmptySheetSelectorOnCreate=true. Every other *-create must still @@ -428,7 +506,7 @@ func TestObjectCreate_RequiresSheetSelector(t *testing.T) { sc common.Shortcut args []string // omit sheet selector flags on purpose }{ - {"chart", ChartCreate, []string{"--url", testURL, "--properties", `{"type":"line"}`}}, + {"chart", ChartCreate, []string{"--url", testURL, "--properties", `{"type":"line","position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`}}, {"cond-format", CondFormatCreate, []string{"--url", testURL, "--properties", `{"attrs":[]}`, "--rule-type", "cellIs", "--ranges", `["A1:A10"]`}}, {"sparkline", SparklineCreate, []string{"--url", testURL, "--properties", `{"sparklines":[]}`}}, {"filter-view", FilterViewCreate, []string{"--url", testURL, "--properties", `{}`, "--range", "A1:F10"}}, diff --git a/shortcuts/sheets/lark_sheet_range_operations.go b/shortcuts/sheets/lark_sheet_range_operations.go index 44e8b4eef..94717be22 100644 --- a/shortcuts/sheets/lark_sheet_range_operations.go +++ b/shortcuts/sheets/lark_sheet_range_operations.go @@ -614,27 +614,14 @@ func rangeSortInput(runtime flagView, token, sheetID, sheetName string) (map[str if strings.TrimSpace(runtime.Str("range")) == "" { return nil, common.FlagErrorf("--range is required") } + // requireJSONArray runs the embedded JSON Schema for --sort-keys + // via parseJSONFlag → validateParsedJSONFlag, so each item is + // already pinned to {column: string, ascending: bool} with the + // failing index reported. No per-item hand-written guard needed. keys, err := requireJSONArray(runtime, "sort-keys") if err != nil { return nil, err } - // transform_range.sort_conditions[i] requires both `column` (string) - // and `ascending` (bool); the server's own validation surfaces a - // terse "required property X is missing" with no per-item context. - // Pre-check here so the user sees which entry is malformed. - for i, raw := range keys { - item, ok := raw.(map[string]interface{}) - if !ok { - return nil, common.FlagErrorf("--sort-keys[%d] must be an object {column, ascending}; got %T", i, raw) - } - col, _ := item["column"].(string) - if strings.TrimSpace(col) == "" { - return nil, common.FlagErrorf("--sort-keys[%d] missing required string field `column` (the column letter to sort by, e.g. \"C\")", i) - } - if _, ok := item["ascending"].(bool); !ok { - return nil, common.FlagErrorf("--sort-keys[%d] missing required bool field `ascending`", i) - } - } input := map[string]interface{}{ "excel_id": token, "operation": "sort", diff --git a/shortcuts/sheets/lark_sheet_range_operations_test.go b/shortcuts/sheets/lark_sheet_range_operations_test.go index 1c5c4f9a9..ba692c709 100644 --- a/shortcuts/sheets/lark_sheet_range_operations_test.go +++ b/shortcuts/sheets/lark_sheet_range_operations_test.go @@ -221,11 +221,12 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) { } } -// TestRangeSort_RejectsMalformedKeys verifies the pre-check that each -// --sort-keys entry has both `column` (string) and `ascending` (bool); -// previously the CLI passed any JSON through and the server bounced -// with a terse "required property X missing" that didn't name the bad -// entry. +// TestRangeSort_RejectsMalformedKeys verifies the schema-driven check +// that each --sort-keys entry has both `column` (string) and +// `ascending` (bool). The schema validator (loaded from +// data/flag-schemas.json) reports the offending JSON path; previously +// the CLI passed any JSON through and the server bounced with a terse +// "required property X missing" that didn't name the bad entry. func TestRangeSort_RejectsMalformedKeys(t *testing.T) { t.Parallel() cases := []struct { @@ -233,10 +234,10 @@ func TestRangeSort_RejectsMalformedKeys(t *testing.T) { keys string want string }{ - {"missing column", `[{"ascending":true}]`, "missing required string field `column`"}, - {"missing ascending", `[{"column":"B"}]`, "missing required bool field `ascending`"}, - {"old vocab col/order", `[{"col":"B","order":"asc"}]`, "missing required string field `column`"}, - {"non-object item", `["B"]`, "must be an object"}, + {"missing column", `[{"ascending":true}]`, `required property "column" is missing at [0]`}, + {"missing ascending", `[{"column":"B"}]`, `required property "ascending" is missing at [0]`}, + {"old vocab col/order", `[{"col":"B","order":"asc"}]`, `required property "column" is missing at [0]`}, + {"non-object item", `["B"]`, `[0]: expected type "object"`}, } for _, c := range cases { c := c diff --git a/shortcuts/sheets/lark_sheet_sheet_structure.go b/shortcuts/sheets/lark_sheet_sheet_structure.go index aa41f5d6a..fcdd9667a 100644 --- a/shortcuts/sheets/lark_sheet_sheet_structure.go +++ b/shortcuts/sheets/lark_sheet_sheet_structure.go @@ -622,10 +622,10 @@ var DimMove = common.Shortcut{ // dimMovePlan is the parsed form of --source-range / --target. type dimMovePlan struct { - dimension string // "row" / "column" - startIdx int // 0-based inclusive - endIdx int // 0-based inclusive - targetIdx int // 0-based; destination position (move inserts before this) + dimension string // "row" / "column" + startIdx int // 0-based inclusive + endIdx int // 0-based inclusive + targetIdx int // 0-based; destination position (move inserts before this) } // buildDimMovePlan parses --source-range + --target and enforces that the diff --git a/shortcuts/sheets/lark_sheet_sheet_structure_test.go b/shortcuts/sheets/lark_sheet_sheet_structure_test.go index 6d9d8f62c..efe73d85f 100644 --- a/shortcuts/sheets/lark_sheet_sheet_structure_test.go +++ b/shortcuts/sheets/lark_sheet_sheet_structure_test.go @@ -287,11 +287,11 @@ func TestDimMove_MismatchedDimension(t *testing.T) { func TestParseA1Range(t *testing.T) { t.Parallel() cases := []struct { - in string - dim string - start int - end int - wantErr bool + in string + dim string + start int + end int + wantErr bool }{ {"3:7", "row", 2, 6, false}, {"5", "row", 4, 4, false}, diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index 9f0930930..6ff0af538 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -98,6 +98,9 @@ func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[stri if copyTo := strings.TrimSpace(runtime.Str("copy-to-range")); copyTo != "" { input["copy_to_range"] = copyTo } + if err := validateInputAgainstSchema(runtime, input); err != nil { + return nil, err + } return input, nil } @@ -186,6 +189,9 @@ func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map "cells": cells, } sheetSelectorForToolInput(input, sheetID, sheetName) + if err := validateInputAgainstSchema(runtime, input); err != nil { + return nil, err + } return input, nil } @@ -329,6 +335,9 @@ func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[s "cells": cells, } sheetSelectorForToolInput(input, sheetID, sheetName) + if err := validateInputAgainstSchema(runtime, input); err != nil { + return nil, err + } return input, nil } diff --git a/shortcuts/sheets/lark_sheet_write_cells_test.go b/shortcuts/sheets/lark_sheet_write_cells_test.go index cc3b220bf..ca5e37ca2 100644 --- a/shortcuts/sheets/lark_sheet_write_cells_test.go +++ b/shortcuts/sheets/lark_sheet_write_cells_test.go @@ -179,11 +179,11 @@ func TestDropdownSet_CellsShape(t *testing.T) { // the flag" apart from "user passed --highlight=false": // // - omitted → no enable_highlight key in body (server applies its -// new default = true) +// new default = true) // - --highlight → enable_highlight=true (presence-only cobra form) // - --highlight=true → enable_highlight=true (explicit form) // - --highlight=false → enable_highlight=false (the only way to opt out; -// the documented "plain dropdown" path) +// the documented "plain dropdown" path) func TestDropdownSet_HighlightTriState(t *testing.T) { t.Parallel() cases := []struct { @@ -382,7 +382,7 @@ func TestCellsSetStyle_FlatFlags(t *testing.T) { "--font-weight", "bold", "--background-color", "#ffff00", "--horizontal-alignment", "center", - "--border-styles", `{"top":{"style":"thick"}}`, + "--border-styles", `{"top":{"style":"solid"}}`, }) input := decodeToolInput(t, body, "set_cell_range") cells, _ := input["cells"].([]interface{}) @@ -420,8 +420,12 @@ func TestCellsSet_RequiresJSONArray(t *testing.T) { if err == nil { t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) } - if !strings.Contains(stdout+stderr+err.Error(), "must be a JSON array") { - t.Errorf("expected JSON-array guard; got=%s|%s|%v", stdout, stderr, err) + // Schema validator fires first now: "--cells: expected type \"array\", got \"object\"". + // Either the schema phrasing or the legacy requireJSONArray phrasing is + // acceptable — both pin the same contract. + combined := stdout + stderr + err.Error() + if !strings.Contains(combined, `expected type "array"`) && !strings.Contains(combined, "must be a JSON array") { + t.Errorf("expected array-type guard; got=%s|%s|%v", stdout, stderr, err) } } diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index a0df4ff00..35acaf939 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -308,8 +308,9 @@ _公共四件套 · 系统:`--dry-run`_ ### `+cells-set` `--cells` +_【维度】行列数必须与 range 完全一致:'A1:C2'→[[_,_,_],[_,_,_]](2行×3列),'B5:B7'→[[_],[_],[_]](3行×1列),'A1'→[[_]](1×1)_ -**顶层字段**: +**二维数组项**(类型 object): - `value` (oneOf?) — 静态单元格值(文本、数字、布尔) - `formula` (string?) — 以 '=' 开头的单元格公式(例如:'=SUM(A1:A10)') - `note` (string?) — 单元格批注/备注 From e58fa1371606e9ab77c66ba872d785f6c626533f Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Mon, 1 Jun 2026 16:24:19 +0800 Subject: [PATCH 085/114] feat(sheets): add --rows-json output flag to +csv-get MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit +csv-get --rows-json returns structured rows ({row_number, values:{col→cell}}) instead of the CSV string, so callers can address cells by row_number / column letter without parsing [row=N] or RFC-4180 CSV. Same read, alternate output shape — a flag on +csv-get (default stays CSV), not a separate shortcut, since the two differ only in representation. - CsvGet.Execute: --rows-json reshapes the response via assembleRowsJSON (parses annotated_csv into per-row records keyed by column letter; every logical row emitted; embedded newlines parsed into cell values) - surfaces the under-read hint structurally as data_not_fully_read - flag-defs.json + read-data reference synced from spec --- shortcuts/sheets/data/flag-defs.json | 8 + shortcuts/sheets/lark_sheet_read_data.go | 251 +++++++++++++++++- shortcuts/sheets/lark_sheet_read_data_test.go | 124 +++++++++ .../references/lark-sheets-read-data.md | 16 +- 4 files changed, 397 insertions(+), 2 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index 2bec61ca1..3dfd06bc4 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -1224,6 +1224,14 @@ "required": "optional", "desc": "Skip hidden rows and columns; default `false`" }, + { + "name": "rows-json", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false", + "default": "false" + }, { "name": "dry-run", "kind": "system", diff --git a/shortcuts/sheets/lark_sheet_read_data.go b/shortcuts/sheets/lark_sheet_read_data.go index bffe18be1..15c80ea59 100644 --- a/shortcuts/sheets/lark_sheet_read_data.go +++ b/shortcuts/sheets/lark_sheet_read_data.go @@ -5,6 +5,9 @@ package sheets import ( "context" + "encoding/csv" + "regexp" + "strconv" "strings" "github.com/larksuite/cli/shortcuts/common" @@ -161,7 +164,12 @@ var CsvGet = common.Shortcut{ if err != nil { return err } - if !runtime.Bool("include-row-prefix") { + switch { + case runtime.Bool("rows-json"): + // --rows-json reshapes the CSV response into structured rows + // ({row_number, values:{col→cell}}); see assembleRowsJSON. + out = assembleRowsJSON(out, strings.TrimSpace(runtime.Str("range"))) + case !runtime.Bool("include-row-prefix"): out = stripRowPrefixFromCsvOutput(out) } runtime.Out(out, nil) @@ -214,6 +222,247 @@ func stripRowPrefixFromCsvOutput(out interface{}) interface{} { return m } +// rowPrefixRe matches the leading "[row=N] " (or "[row=N],") annotation that +// the tool prepends to the first physical line of each logical CSV record. +var rowPrefixRe = regexp.MustCompile(`^\[row=(\d+)\][ ,]?`) + +// assembleRowsJSON reshapes the tool's annotated_csv string into structured +// rows so callers never have to regex-parse "[row=N]" or RFC-4180 CSV by hand: +// +// { +// "range": "A1:K3380", +// "current_region": "...", // passthrough, if the tool returned it +// "rows": [{"row_number":1,"values":{"A":"姓名", ..., "K":"时间差_分钟"}}, +// {"row_number":2,"values":{"A":"张三", ..., "K":"8.5"}}, ...] +// } +// +// Every logical row is emitted, including the first — no row is assumed to be a +// header, since sheet data is not always tabular. Each cell is keyed by its +// column letter (from the tool's col_indices when present, else derived from the +// requested range's start column). On any parsing trouble it returns the +// original output unchanged. +func assembleRowsJSON(out interface{}, requestedRange string) interface{} { + m, ok := out.(map[string]interface{}) + if !ok { + return out + } + csvStr, ok := m["annotated_csv"].(string) + if !ok { + return out + } + + // Group physical lines into logical records by [row=N] boundaries; lines + // without a prefix are embedded-newline continuations of the current record. + type logicalRow struct { + num int + text string + } + var groups []logicalRow + for _, line := range strings.Split(csvStr, "\n") { + if mm := rowPrefixRe.FindStringSubmatch(line); mm != nil { + n, _ := strconv.Atoi(mm[1]) + groups = append(groups, logicalRow{num: n, text: line[len(mm[0]):]}) + } else if len(groups) > 0 { + groups[len(groups)-1].text += "\n" + line + } + } + if len(groups) == 0 { + return out + } + + // Parse every logical row; widest row sets the column count. No row is + // singled out as a header — that would assume the data is tabular, which it + // often is not. The model reads row 1 like any other row and decides for + // itself whether it is a header. + parsed := make([][]string, len(groups)) + maxCols := 0 + for i, g := range groups { + parsed[i] = parseCSVRecord(g.text) + if len(parsed[i]) > maxCols { + maxCols = len(parsed[i]) + } + } + if maxCols == 0 { + return out + } + + // Column letters key each cell. Prefer the tool's col_indices (authoritative, + // length == col_count); otherwise derive from the requested range's start col. + letters := coerceStringSlice(m["col_indices"]) + if len(letters) < maxCols { + start := csvStartColIndex(requestedRange) + letters = make([]string, maxCols) + for j := 0; j < maxCols; j++ { + letters[j] = csvColLetter(start + j) + } + } + + rows := make([]map[string]interface{}, 0, len(groups)) + for i := range groups { + fields := parsed[i] + values := make(map[string]interface{}, len(letters)) + for j := range letters { + v := "" + if j < len(fields) { + v = fields[j] + } + values[letters[j]] = v + } + rows = append(rows, map[string]interface{}{ + "row_number": groups[i].num, + "values": values, + }) + } + + result := map[string]interface{}{} + for k, v := range m { + result[k] = v + } + result["range"] = requestedRange + result["rows"] = rows + + // Surface the backend's "数据没读全" signal structurally instead of leaving it + // buried in warning_message prose. The tool flags it when current_region (the + // true data extent) reaches past actual_range (what was actually read) — the + // single most important anti-under-read hint. Mirror that same comparison + // (regionEndRow > actualEndRow) from the already-passthrough A1 ranges so the + // model gets the real data range as a first-class field, never having to + // parse it out of prose. + if cr, _ := m["current_region"].(string); cr != "" { + ar, _ := m["actual_range"].(string) + regionEnd := a1EndRow(cr) + readEnd := a1EndRow(ar) + if regionEnd > 0 && readEnd > 0 && regionEnd > readEnd { + result["data_not_fully_read"] = map[string]interface{}{ + "read_through_row": readEnd, + "data_extends_through_row": regionEnd, + "unread_rows": regionEnd - readEnd, + "reread_range": cr, + } + } + } + + // Drop the fields whose information rows-json fully carries elsewhere: + // - annotated_csv / row_indices / col_indices → reconstructed into + // columns + rows (with integer row_number), losslessly. + // - warning_message → its two halves are both handled: the static + // "[row=N] / col_indices[j]" parse nag is moot once those fields exist, + // and the dynamic "数据没读全" half is now the structured + // data_not_fully_read field above. (Confirmed against the backend's + // get-range-as-csv.ts — warning_message has no other content.) + delete(result, "annotated_csv") + delete(result, "row_indices") + delete(result, "col_indices") + delete(result, "warning_message") + return result +} + +// a1EndRow extracts the ending row number from an A1 range, e.g. "A1:N51" → 51, +// "Sheet1!B2:D9" → 9, "C5" → 5. Returns 0 when no row number is present. +func a1EndRow(rng string) int { + rng = strings.TrimSpace(rng) + if i := strings.LastIndex(rng, "!"); i >= 0 { + rng = rng[i+1:] + } + if i := strings.LastIndex(rng, ":"); i >= 0 { + rng = rng[i+1:] + } + var digits strings.Builder + for _, c := range rng { + if c >= '0' && c <= '9' { + digits.WriteRune(c) + } + } + if digits.Len() == 0 { + return 0 + } + n, _ := strconv.Atoi(digits.String()) + return n +} + +// parseCSVRecord parses a single logical CSV record (which may span multiple +// physical lines via quoted embedded newlines) into its fields. An empty record +// yields no fields; a malformed record falls back to a naive comma split so a +// stray quote never drops a whole row. +func parseCSVRecord(text string) []string { + if strings.TrimSpace(text) == "" { + return nil + } + r := csv.NewReader(strings.NewReader(text)) + r.FieldsPerRecord = -1 + fields, err := r.Read() + if err != nil { + return strings.Split(text, ",") + } + return fields +} + +// coerceStringSlice returns v as []string when it is a homogeneous []interface{} +// of strings (the shape of the tool's col_indices), else nil. +func coerceStringSlice(v interface{}) []string { + arr, ok := v.([]interface{}) + if !ok { + return nil + } + out := make([]string, 0, len(arr)) + for _, e := range arr { + s, ok := e.(string) + if !ok { + return nil + } + out = append(out, s) + } + return out +} + +// csvStartColIndex returns the 0-based column index of a range's start column, +// e.g. "A1:K3380" → 0, "C5:F9" → 2, "Sheet1!D2" → 3. Unparseable input → 0. +func csvStartColIndex(rng string) int { + rng = strings.TrimSpace(rng) + if i := strings.LastIndex(rng, "!"); i >= 0 { + rng = rng[i+1:] + } + var letters strings.Builder + for _, c := range rng { + if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') { + letters.WriteRune(c) + continue + } + break + } + if letters.Len() == 0 { + return 0 + } + return csvColToIndex(letters.String()) +} + +// csvColToIndex converts a column letter to its 0-based index ("A"→0, "K"→10, +// "AA"→26). Non-letter input → -1. +func csvColToIndex(s string) int { + n := 0 + for _, c := range strings.ToUpper(s) { + if c < 'A' || c > 'Z' { + break + } + n = n*26 + int(c-'A'+1) + } + return n - 1 +} + +// csvColLetter converts a 0-based column index back to its letter (0→"A", +// 25→"Z", 26→"AA"). Negative input → "". +func csvColLetter(idx int) string { + if idx < 0 { + return "" + } + var b []byte + for idx >= 0 { + b = append([]byte{byte('A' + idx%26)}, b...) + idx = idx/26 - 1 + } + return string(b) +} + // DropdownGet wraps get_cell_ranges scoped to data_validation: read the // dropdown configuration on a range. Aligned with its sibling +cells-get // — sheet selection is via --sheet-id / --sheet-name (XOR), and --range diff --git a/shortcuts/sheets/lark_sheet_read_data_test.go b/shortcuts/sheets/lark_sheet_read_data_test.go index 1d1babd2a..15acfeeea 100644 --- a/shortcuts/sheets/lark_sheet_read_data_test.go +++ b/shortcuts/sheets/lark_sheet_read_data_test.go @@ -76,6 +76,20 @@ func TestReadDataShortcuts_DryRun(t *testing.T) { "value_render_option": "formatted_value", }, }, + { + // --rows-json is post-processing on +csv-get's response; it must + // NOT leak into the get_range_as_csv input. + name: "+csv-get --rows-json builds the same input (flag is post-process)", + sc: CsvGet, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C10", "--rows-json"}, + toolName: "get_range_as_csv", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "range": "A1:C10", + "max_rows": float64(unboundedReadLimit), + }, + }, } for _, tt := range tests { tt := tt @@ -180,3 +194,113 @@ func TestCsvGet_StripRowPrefix(t *testing.T) { t.Errorf("other field corrupted: %v", out["other"]) } } + +// TestAssembleRowsJSON covers the --rows-json reshaping: every logical row +// emitted (no header singled out), integer row_number, column-letter keyed +// values, embedded newlines inside quoted fields, and current_region passthrough. +func TestAssembleRowsJSON(t *testing.T) { + t.Parallel() + in := map[string]interface{}{ + "annotated_csv": "[row=1] 姓名,备注,时间差_分钟\n[row=2] 张三,\"line1\nline2\",8.5\n[row=3] 李四,ok,3", + "current_region": "A1:C3", + "col_indices": []interface{}{"A", "B", "C"}, + "row_indices": []interface{}{1, 2, 3}, + "warning_message": "①定位行号…②定位列字母…", + } + out, ok := assembleRowsJSON(in, "A1:C3").(map[string]interface{}) + if !ok { + t.Fatalf("assembleRowsJSON did not return a map") + } + + // Fields whose info rows-json carries elsewhere are dropped (annotated_csv / + // indices → rows; warning_message → moot static nag + structured + // data_not_fully_read). Unrelated metadata like current_region is preserved. + if _, exists := out["annotated_csv"]; exists { + t.Errorf("annotated_csv should be dropped") + } + if _, exists := out["col_indices"]; exists { + t.Errorf("col_indices should be dropped") + } + if _, exists := out["warning_message"]; exists { + t.Errorf("warning_message should be dropped in rows-json mode") + } + if _, exists := out["columns"]; exists { + t.Errorf("columns field should not exist (no header assumption)") + } + if out["current_region"] != "A1:C3" { + t.Errorf("current_region passthrough lost: %v", out["current_region"]) + } + + rows, _ := out["rows"].([]map[string]interface{}) + if len(rows) != 3 { + t.Fatalf("want all 3 rows (incl. row 1), got %d: %+v", len(rows), rows) + } + // Row 1 is emitted as a normal row, not consumed as a header. + if rows[0]["row_number"].(int) != 1 { + t.Errorf("first row_number = %v, want 1", rows[0]["row_number"]) + } + if v := rows[0]["values"].(map[string]interface{}); v["A"] != "姓名" || v["C"] != "时间差_分钟" { + t.Errorf("row 1 values wrong: %+v", v) + } + // Row 2 keeps its embedded newline inside a single cell. + v1 := rows[1]["values"].(map[string]interface{}) + if rows[1]["row_number"].(int) != 2 || v1["A"] != "张三" || v1["B"] != "line1\nline2" || v1["C"] != "8.5" { + t.Errorf("row 2 wrong (embedded newline?): %+v", rows[1]) + } +} + +// TestAssembleRowsJSON_DerivedLetters verifies cell letters are derived from the +// range start when the tool omits col_indices (e.g. a C-anchored read). +func TestAssembleRowsJSON_DerivedLetters(t *testing.T) { + t.Parallel() + in := map[string]interface{}{ + "annotated_csv": "[row=5] h1,h2\n[row=6] a,b", + } + out := assembleRowsJSON(in, "C5:D6").(map[string]interface{}) + rows := out["rows"].([]map[string]interface{}) + if len(rows) != 2 { + t.Fatalf("want 2 rows, got %d", len(rows)) + } + if rows[0]["row_number"].(int) != 5 { + t.Errorf("first row_number = %v, want 5", rows[0]["row_number"]) + } + if v := rows[0]["values"].(map[string]interface{}); v["C"] != "h1" || v["D"] != "h2" { + t.Errorf("derived-letter values wrong: %+v", v) + } + if v := rows[1]["values"].(map[string]interface{}); v["C"] != "a" || v["D"] != "b" { + t.Errorf("row 6 values wrong: %+v", v) + } +} + +// TestAssembleRowsJSON_DataNotFullyRead verifies the structured under-read hint: +// when current_region extends past actual_range, rows-json surfaces the true data +// range as a first-class field (mirroring the backend's prose warning). +func TestAssembleRowsJSON_DataNotFullyRead(t *testing.T) { + t.Parallel() + // Read only A1:D2, but the data region reaches D4 → 2 rows unread. + in := map[string]interface{}{ + "annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三", + "actual_range": "A1:D2", + "current_region": "A1:D4", + } + out := assembleRowsJSON(in, "A1:D2").(map[string]interface{}) + hint, ok := out["data_not_fully_read"].(map[string]interface{}) + if !ok { + t.Fatalf("data_not_fully_read missing; out=%+v", out) + } + if hint["read_through_row"] != 2 || hint["data_extends_through_row"] != 4 || + hint["unread_rows"] != 2 || hint["reread_range"] != "A1:D4" { + t.Errorf("data_not_fully_read wrong: %+v", hint) + } + + // Fully-read case: no hint emitted. + in2 := map[string]interface{}{ + "annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三", + "actual_range": "A1:D2", + "current_region": "A1:D2", + } + out2 := assembleRowsJSON(in2, "A1:D2").(map[string]interface{}) + if _, exists := out2["data_not_fully_read"]; exists { + t.Errorf("data_not_fully_read should be absent when fully read") + } +} diff --git a/skills/lark-sheets/references/lark-sheets-read-data.md b/skills/lark-sheets/references/lark-sheets-read-data.md index fa1e32de8..2feca0201 100644 --- a/skills/lark-sheets/references/lark-sheets-read-data.md +++ b/skills/lark-sheets/references/lark-sheets-read-data.md @@ -19,12 +19,13 @@ | 读取目的 | 用这个 shortcut | 数据去向 | 说明 | |---------|----------------|---------|------| -| 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本;大表请按 `--range` 行窗口分批读(单次返回量由 `--max-chars` 自动兜底,截断时看 `has_more`) | +| 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本(加 `--rows-json` 改为结构化 rows `{row_number, values:{列字母→值}}`);大表请按 `--range` 行窗口分批读(截断时看 `has_more`) | | 查看公式、样式、批注、数据验证 | `+cells-get` | 对话上下文 | 返回单元格完整信息,token 开销较大 | | 查看某区域的下拉框(数据验证)选项 | `+dropdown-get` | 对话上下文 | 返回该 A1 范围已配置的下拉列表选项 | **选择原则**: - 只看值或做数据处理 → `+csv-get`;大表分批读取,避免一次拉全表撑爆上下文 +- 要结构化、按 `row_number` / 列字母定位的输出 → `+csv-get --rows-json`(默认 CSV 串更省 token,超大表批量仍用默认) - 需要公式/样式/批注 → `+cells-get` - 只想知道某区域下拉框有哪些选项 → `+dropdown-get` @@ -114,6 +115,7 @@ _公共四件套 · 系统:`--dry-run`_ | `--max-chars` | int | optional | 防爆,默认 200000(隐藏 flag:不在 `--help` 列出,但可正常传入) | | `--include-row-prefix` | bool | optional | 是否在每行前加 `[row=N]` 前缀,默认 `true` | | `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` | +| `--rows-json` | bool | optional | 返回结构化 rows(`{row_number, values:{列字母→值}}`)而非 CSV 文本,默认 `false` | ## Examples @@ -138,6 +140,18 @@ lark-cli sheets +csv-get --spreadsheet-token shtXXX --sheet-name "销售明细" - `current_region` — 自动扩展到非空连续区域的 A1 范围 - `has_more` — 是否截断;截断后续读用 `--range` 接着读 +**加 `--rows-json`:返回结构化 rows(而非 CSV 字符串)** + +```bash +lark-cli sheets +csv-get --url "https://example.feishu.cn/sheets/shtXXX" --sheet-name "Sheet1" --range "A1:G20" --rows-json +``` + +`--rows-json` 下的输出契约(替换 `annotated_csv` / `col_indices` / `row_indices`): + +- `rows` — 数组,每元素 `{row_number, values}`。`row_number` 是真实表格行号(整数,下游需要行号的操作直接取它);`values` 按**列字母** key(如 `values["D"]`,绝对列字母)。**所有逻辑行都在 `rows` 里**。引号内换行已解析进单元格值,无需自己按 RFC-4180 拆行。 +- `data_not_fully_read` — **仅当没读全时出现**:`{read_through_row, data_extends_through_row, unread_rows, reread_range}`。出现即表示真实数据超出本次读取范围;批量写入前必须按 `reread_range` 重读全区,否则漏行。 +- 其余字段(`current_region` / `actual_range` / `has_more`)同上。 + ### `+cells-get` 示例: From 7dd479df12687619cde12eabdfa006bb8e3ef04e Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Mon, 1 Jun 2026 19:27:03 +0800 Subject: [PATCH 086/114] feat(cli): agent-friendly errors, proxy silencing, +csv-put --range Agent-experience fixes distilled from analyzing 50 real sheets trajectories, where the top failures were hallucinated command/flag names, proxy warnings corrupting JSON on stdout, and --range carried over from +csv-get to +csv-put. - did-you-mean: unify the duplicated Levenshtein into a shared internal/suggest package and wire its prefix-weighted ranker into unknown-subcommand and unknown-flag errors; flag-parse errors now return a structured envelope with suggestions plus the full valid list, so agents recover from semantic typos (e.g. --query vs --find). - proxy: suppress the one-time proxy warning in non-interactive (agent/CI/piped) runs so a 2>&1-merged stderr line cannot corrupt stdout JSON; interactive sessions still warn. - sheets +csv-put: accept --range as an alias for --start-cell (parity with +csv-get / +cells-set) and echo the computed writes_range in dry-run and the success envelope, so agents see the paste footprint before it overwrites neighbours. - docs(sheets): add an intent->command cheat-sheet to SKILL.md, a runtime-prerequisites section, and document the --range alias and writes_range behaviour. --- cmd/build.go | 7 ++ cmd/event/suggestions.go | 34 +----- cmd/event/suggestions_test.go | 21 ---- cmd/flag_suggest_test.go | 70 ++++++++++++ cmd/root.go | 90 ++++++++++++++- cmd/unknown_subcommand_test.go | 10 +- internal/cmdpolicy/suggest.go | 49 +-------- internal/cmdpolicy/suggest_test.go | 20 ---- internal/cmdutil/factory_default.go | 4 +- internal/suggest/suggest.go | 104 ++++++++++++++++++ internal/suggest/suggest_test.go | 74 +++++++++++++ internal/util/proxy.go | 13 ++- internal/util/proxy_test.go | 29 ++++- shortcuts/sheets/csv_put_range_alias_test.go | 76 +++++++++++++ shortcuts/sheets/data/flag-defs.json | 8 ++ shortcuts/sheets/lark_sheet_write_cells.go | 78 ++++++++++++- skills/lark-sheets/SKILL.md | 23 ++++ .../references/lark-sheets-core-operations.md | 11 +- .../references/lark-sheets-write-cells.md | 7 ++ 19 files changed, 589 insertions(+), 139 deletions(-) create mode 100644 cmd/flag_suggest_test.go create mode 100644 internal/suggest/suggest.go create mode 100644 internal/suggest/suggest_test.go create mode 100644 shortcuts/sheets/csv_put_range_alias_test.go diff --git a/cmd/build.go b/cmd/build.go index a748544b0..84828ed77 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -117,6 +117,13 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B installTipsHelpFunc(rootCmd) rootCmd.SilenceErrors = true + // SilenceUsage as a static field (not only in PersistentPreRun) so it also + // covers flag-parse errors, which fail before PreRun runs — otherwise cobra + // dumps usage instead of our structured error. SetFlagErrorFunc on root is + // inherited by every subcommand, turning unknown-flag errors into a + // structured "did you mean" envelope. + rootCmd.SilenceUsage = true + rootCmd.SetFlagErrorFunc(flagDidYouMean) RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals) rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { diff --git a/cmd/event/suggestions.go b/cmd/event/suggestions.go index f3275fda4..2bdaf5dff 100644 --- a/cmd/event/suggestions.go +++ b/cmd/event/suggestions.go @@ -10,6 +10,7 @@ import ( eventlib "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/suggest" ) const maxSuggestions = 3 @@ -28,7 +29,7 @@ func suggestEventKeys(input string) []string { hits = append(hits, match{def.Key, 0}) continue } - if d := levenshtein(input, def.Key); d <= threshold { + if d := suggest.Levenshtein(input, def.Key); d <= threshold { hits = append(hits, match{def.Key, d}) } } @@ -69,34 +70,3 @@ func unknownEventKeyErr(key string) error { "Run 'lark-cli event list' to see available keys.", ) } - -// levenshtein computes classic edit distance (two-row DP). -func levenshtein(a, b string) int { - if a == b { - return 0 - } - ra, rb := []rune(a), []rune(b) - if len(ra) == 0 { - return len(rb) - } - if len(rb) == 0 { - return len(ra) - } - prev := make([]int, len(rb)+1) - curr := make([]int, len(rb)+1) - for j := range prev { - prev[j] = j - } - for i := 1; i <= len(ra); i++ { - curr[0] = i - for j := 1; j <= len(rb); j++ { - cost := 1 - if ra[i-1] == rb[j-1] { - cost = 0 - } - curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost) - } - prev, curr = curr, prev - } - return prev[len(rb)] -} diff --git a/cmd/event/suggestions_test.go b/cmd/event/suggestions_test.go index 0838fb623..fdaaa2c01 100644 --- a/cmd/event/suggestions_test.go +++ b/cmd/event/suggestions_test.go @@ -10,27 +10,6 @@ import ( _ "github.com/larksuite/cli/events" ) -func TestLevenshtein(t *testing.T) { - cases := []struct { - a, b string - want int - }{ - {"", "", 0}, - {"a", "", 1}, - {"", "abc", 3}, - {"kitten", "kitten", 0}, - {"kitten", "sitten", 1}, - {"kitten", "sitting", 3}, - {"飞书", "飞书", 0}, - {"飞书", "飞s", 1}, - } - for _, tc := range cases { - if got := levenshtein(tc.a, tc.b); got != tc.want { - t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want) - } - } -} - func TestSuggestEventKeys(t *testing.T) { cases := []struct { name string diff --git a/cmd/flag_suggest_test.go b/cmd/flag_suggest_test.go new file mode 100644 index 000000000..7adb35053 --- /dev/null +++ b/cmd/flag_suggest_test.go @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "errors" + "slices" + "strings" + "testing" + + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +func TestUnknownFlagName(t *testing.T) { + cases := []struct { + in string + name string + ok bool + }{ + {"unknown flag: --query", "query", 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 := unknownFlagName(errors.New(c.in)) + if name != c.name || ok != c.ok { + t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok) + } + } +} + +func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) { + c := &cobra.Command{Use: "demo"} + c.Flags().String("range", "", "") + c.Flags().String("find", "", "") + c.Flags().Bool("dry-run", false, "") + + err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + if exitErr.Detail.Type != "unknown_flag" { + t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type) + } + if !strings.Contains(exitErr.Detail.Hint, "--range") { + t.Errorf("hint should suggest --range, got %q", exitErr.Detail.Hint) + } + detail, _ := exitErr.Detail.Detail.(map[string]any) + valid, _ := detail["valid_flags"].([]string) + if !slices.Contains(valid, "find") || !slices.Contains(valid, "range") { + t.Errorf("valid_flags should list find & range, got %v", valid) + } +} + +func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) { + c := &cobra.Command{Use: "demo"} + err := flagDidYouMean(c, errors.New("flag needs an argument: --find")) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + if exitErr.Detail.Type != "flag_error" { + t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type) + } +} diff --git a/cmd/root.go b/cmd/root.go index 00d9a24bc..297e1d93b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -26,8 +26,10 @@ import ( "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/internal/skillscheck" + "github.com/larksuite/cli/internal/suggest" "github.com/larksuite/cli/internal/update" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) const rootLong = `lark-cli — Lark/Feishu CLI tool. @@ -297,6 +299,12 @@ func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyErr func installUnknownSubcommandGuard(cmd *cobra.Command) { if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil { cmd.RunE = unknownSubcommandRunE + // Route an unknown subcommand to unknownSubcommandRunE even when flags + // are also present (e.g. `sheets +cells-find --url ...`). A pure group + // consumes no flags itself, so unknown flags belong to the (missing) + // subcommand; whitelisting them here prevents cobra from erroring on the + // flag first and printing usage instead of our structured suggestion. + cmd.FParseErrWhitelist.UnknownFlags = true if cmd.Annotations == nil { cmd.Annotations = map[string]string{} } @@ -313,10 +321,12 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error { } unknown := args[0] available := availableSubcommandNames(cmd) + suggestions := suggest.Closest(unknown, available, 6) msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath()) hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath()) - if len(available) > 0 { - hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", ")) + if len(suggestions) > 0 { + hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)", + strings.Join(suggestions, ", "), cmd.CommandPath()) } return &output.ExitError{ Code: output.ExitValidation, @@ -327,6 +337,7 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error { Detail: map[string]any{ "unknown": unknown, "command_path": cmd.CommandPath(), + "suggestions": suggestions, "available": available, }, }, @@ -349,6 +360,81 @@ func availableSubcommandNames(cmd *cobra.Command) []string { return subs } +// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It +// converts cobra's flag-parse errors into the structured ErrorEnvelope: an +// unknown flag gets a focused "did you mean" hint plus the full valid-flag list +// in detail (so agents recover even when the typo is semantic, e.g. --query vs +// --find, where edit distance alone finds nothing). Other flag errors stay +// structured but generic. +func flagDidYouMean(c *cobra.Command, ferr error) error { + name, isUnknown := unknownFlagName(ferr) + if !isUnknown { + return &output.ExitError{ + Code: output.ExitValidation, + Detail: &output.ErrDetail{ + Type: "flag_error", + Message: ferr.Error(), + Hint: fmt.Sprintf("run `%s --help` for valid flags", c.CommandPath()), + }, + } + } + valid := visibleFlagNames(c) + suggestions := suggest.Closest(name, valid, 3) + hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath()) + if len(suggestions) > 0 { + for i := range suggestions { + suggestions[i] = "--" + suggestions[i] + } + hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)", + strings.Join(suggestions, ", "), c.CommandPath()) + } + return &output.ExitError{ + Code: output.ExitValidation, + Detail: &output.ErrDetail{ + Type: "unknown_flag", + Message: fmt.Sprintf("unknown flag %q for %q", "--"+name, c.CommandPath()), + Hint: hint, + Detail: map[string]any{ + "unknown": "--" + name, + "command_path": c.CommandPath(), + "suggestions": suggestions, + "valid_flags": valid, + }, + }, + } +} + +// unknownFlagName 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 the caller keeps +// those structured but generic — hallucinated flags are essentially always long. +func unknownFlagName(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 of c (for suggestions and +// the valid_flags detail). +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 +} + // installTipsHelpFunc wraps the default help function to append a TIPS section // when a command has tips set via cmdutil.SetTips. It also force-shows global // flags that are normally hidden in single-app mode (currently --profile) diff --git a/cmd/unknown_subcommand_test.go b/cmd/unknown_subcommand_test.go index 4bba607d5..2cc6f2d84 100644 --- a/cmd/unknown_subcommand_test.go +++ b/cmd/unknown_subcommand_test.go @@ -113,11 +113,11 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) { if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) { t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message) } - if !strings.Contains(exitErr.Detail.Hint, "+search") || !strings.Contains(exitErr.Detail.Hint, "+upload") { - t.Errorf("hint should list available shortcuts, got %q", exitErr.Detail.Hint) - } - if strings.Contains(exitErr.Detail.Hint, "+secret") { - t.Error("hidden commands must not appear in the hint") + // "+bogus" has no close neighbor among drive's subcommands, so the hint falls + // back to pointing at --help; the full machine-readable list lives in + // detail.available below (which also excludes hidden commands). + if !strings.Contains(exitErr.Detail.Hint, "--help") { + t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint) } detail, ok := exitErr.Detail.Detail.(map[string]any) diff --git a/internal/cmdpolicy/suggest.go b/internal/cmdpolicy/suggest.go index 2f7362e31..ea2ae5979 100644 --- a/internal/cmdpolicy/suggest.go +++ b/internal/cmdpolicy/suggest.go @@ -5,6 +5,7 @@ package cmdpolicy import ( "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/suggest" ) // suggestRisk returns the closest valid Risk literal by edit distance @@ -20,9 +21,9 @@ func suggestRisk(bad string) string { platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite, } best := string(candidates[0]) - bestDist := levenshtein(lowered, best) + bestDist := suggest.Levenshtein(lowered, best) for _, c := range candidates[1:] { - if d := levenshtein(lowered, string(c)); d < bestDist { + if d := suggest.Levenshtein(lowered, string(c)); d < bestDist { bestDist, best = d, string(c) } } @@ -40,47 +41,3 @@ func toLower(s string) string { } return string(b) } - -// levenshtein computes the classic edit distance between two strings. -// O(len(a)*len(b)) time, O(min(a,b)) space. Three-element string set -// makes raw performance irrelevant — clarity beats trickiness here. -func levenshtein(a, b string) int { - if len(a) == 0 { - return len(b) - } - if len(b) == 0 { - return len(a) - } - prev := make([]int, len(b)+1) - curr := make([]int, len(b)+1) - for j := 0; j <= len(b); j++ { - prev[j] = j - } - for i := 1; i <= len(a); i++ { - curr[0] = i - for j := 1; j <= len(b); j++ { - cost := 1 - if a[i-1] == b[j-1] { - cost = 0 - } - curr[j] = min3( - prev[j]+1, // deletion - curr[j-1]+1, // insertion - prev[j-1]+cost, // substitution - ) - } - prev, curr = curr, prev - } - return prev[len(b)] -} - -func min3(a, b, c int) int { - m := a - if b < m { - m = b - } - if c < m { - m = c - } - return m -} diff --git a/internal/cmdpolicy/suggest_test.go b/internal/cmdpolicy/suggest_test.go index da91495a2..e8aae8e6c 100644 --- a/internal/cmdpolicy/suggest_test.go +++ b/internal/cmdpolicy/suggest_test.go @@ -29,23 +29,3 @@ func TestSuggestRisk(t *testing.T) { } } } - -func TestLevenshtein(t *testing.T) { - cases := []struct { - a, b string - want int - }{ - {"", "", 0}, - {"", "abc", 3}, - {"abc", "", 3}, - {"abc", "abc", 0}, - {"wrtie", "write", 2}, - {"kitten", "sitting", 3}, - } - for _, c := range cases { - got := levenshtein(c.a, c.b) - if got != c.want { - t.Errorf("levenshtein(%q,%q) = %d, want %d", c.a, c.b, got, c.want) - } - } -} diff --git a/internal/cmdutil/factory_default.go b/internal/cmdutil/factory_default.go index f18a816b3..fa3801092 100644 --- a/internal/cmdutil/factory_default.go +++ b/internal/cmdutil/factory_default.go @@ -102,7 +102,7 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error { func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) { return sync.OnceValues(func() (*http.Client, error) { - util.WarnIfProxied(f.IOStreams.ErrOut) + util.WarnIfProxied(f.IOStreams.ErrOut, f.IOStreams.IsTerminal) var transport http.RoundTripper = util.SharedTransport() transport = &RetryTransport{Base: transport} @@ -129,7 +129,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) { lark.WithLogLevel(larkcore.LogLevelError), lark.WithHeaders(BaseSecurityHeaders()), } - util.WarnIfProxied(f.IOStreams.ErrOut) + util.WarnIfProxied(f.IOStreams.ErrOut, f.IOStreams.IsTerminal) opts = append(opts, lark.WithHttpClient(&http.Client{ Transport: buildSDKTransport(), CheckRedirect: safeRedirectPolicy, diff --git a/internal/suggest/suggest.go b/internal/suggest/suggest.go new file mode 100644 index 000000000..fe4471f6b --- /dev/null +++ b/internal/suggest/suggest.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package suggest provides the shared "did you mean" primitives: a rune-aware +// Levenshtein edit distance and a prefix-weighted Closest ranker. It is the +// single home for these so cmd, cmd/event, and internal/cmdpolicy stop each +// carrying their own copy. +package suggest + +import "sort" + +// Levenshtein computes the classic edit distance between two strings. It is +// rune-aware, so it is correct for multi-byte input. +func Levenshtein(a, b string) int { + if a == b { + return 0 + } + ra, rb := []rune(a), []rune(b) + if len(ra) == 0 { + return len(rb) + } + if len(rb) == 0 { + return len(ra) + } + prev := make([]int, len(rb)+1) + curr := make([]int, len(rb)+1) + for j := range prev { + prev[j] = j + } + for i := 1; i <= len(ra); i++ { + curr[0] = i + for j := 1; j <= len(rb); j++ { + cost := 1 + if ra[i-1] == rb[j-1] { + cost = 0 + } + curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost) + } + prev, curr = curr, prev + } + return prev[len(rb)] +} + +// Closest returns up to maxN of candidates that plausibly match typed, ranked +// by shared-prefix length (desc) then edit distance (asc), keeping only +// reasonably-close ones. +// +// Shared prefix is weighted first on purpose: hallucinated names are often +// semantically close but lexically far (e.g. "+cells-find" vs "+cells-search", +// "--with-styles" vs nothing close), where the common prefix is the strongest +// signal of intent that raw edit distance misses. +func Closest(typed string, candidates []string, maxN int) []string { + type scored struct { + name string + prefix int + dist int + } + limit := editLimit(typed) + ranked := make([]scored, 0, len(candidates)) + for _, c := range candidates { + p := sharedPrefixLen(typed, c) + d := Levenshtein(typed, c) + // Keep only plausible matches: a meaningful shared prefix, or an edit + // distance within budget. Drop everything else so the hint stays short. + if p >= 3 || d <= limit { + ranked = append(ranked, scored{name: c, prefix: p, dist: d}) + } + } + sort.Slice(ranked, func(i, j int) bool { + if ranked[i].prefix != ranked[j].prefix { + return ranked[i].prefix > ranked[j].prefix + } + if ranked[i].dist != ranked[j].dist { + return ranked[i].dist < ranked[j].dist + } + return ranked[i].name < ranked[j].name + }) + if maxN <= 0 || maxN > len(ranked) { + maxN = len(ranked) + } + out := make([]string, 0, maxN) + for _, s := range ranked[:maxN] { + out = append(out, s.name) + } + return out +} + +// editLimit allows roughly one third of the typed length in edits (min 2), so +// short names tolerate a couple of typos and longer ones proportionally more. +func editLimit(s string) int { + if l := len([]rune(s)) / 3; l > 2 { + return l + } + return 2 +} + +func sharedPrefixLen(a, b string) int { + ra, rb := []rune(a), []rune(b) + n := 0 + for n < len(ra) && n < len(rb) && ra[n] == rb[n] { + n++ + } + return n +} diff --git a/internal/suggest/suggest_test.go b/internal/suggest/suggest_test.go new file mode 100644 index 000000000..dcf194349 --- /dev/null +++ b/internal/suggest/suggest_test.go @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package suggest + +import ( + "slices" + "testing" +) + +func TestClosest_HallucinatedSharesPrefix(t *testing.T) { + cmds := []string{ + "+cells-get", "+cells-set", "+cells-search", "+cells-replace", + "+cells-clear", "+cells-merge", "+csv-get", "+chart-create", + "+pivot-create", "+sheet-info", + } + // "+cells-find" is semantically +cells-search but lexically far; the shared + // "+cells-" prefix should still surface the right family (incl. +cells-search). + got := Closest("+cells-find", cmds, 6) + if len(got) == 0 || len(got) > 6 { + t.Fatalf("expected 1..6 suggestions, got %v", got) + } + if !slices.Contains(got, "+cells-search") { + t.Errorf("expected +cells-search among suggestions, got %v", got) + } + for _, s := range got { + if len(s) < 7 || s[:7] != "+cells-" { + t.Errorf("suggestion %q does not share the +cells- prefix", s) + } + } +} + +func TestClosest_TypoRanksExactNeighborFirst(t *testing.T) { + got := Closest("+cell-get", []string{"+cells-get", "+cells-set", "+csv-get", "+sheet-info"}, 3) + if len(got) == 0 || got[0] != "+cells-get" { + t.Errorf("expected +cells-get first for typo +cell-get, got %v", got) + } +} + +func TestClosest_NoPlausibleMatch(t *testing.T) { + if got := Closest("+zzzzzz", []string{"+cells-get", "+csv-get"}, 6); len(got) != 0 { + t.Errorf("expected no suggestions for unrelated input, got %v", got) + } +} + +func TestLevenshtein(t *testing.T) { + cases := []struct { + a, b string + want int + }{ + {"", "abc", 3}, + {"abc", "", 3}, + {"abc", "abc", 0}, + {"kitten", "sitting", 3}, + {"cell-get", "cells-get", 1}, + {"--query", "--find", 5}, + {"飞书", "飞书", 0}, // rune-aware: multi-byte equal + {"飞书", "飞s", 1}, // one rune substitution, not byte count + } + for _, c := range cases { + if d := Levenshtein(c.a, c.b); d != c.want { + t.Errorf("Levenshtein(%q,%q) = %d, want %d", c.a, c.b, d, c.want) + } + } +} + +func TestSharedPrefixLen(t *testing.T) { + if got := sharedPrefixLen("+cells-find", "+cells-search"); got != 7 { + t.Errorf("sharedPrefixLen = %d, want 7", got) + } + if got := sharedPrefixLen("abc", "xyz"); got != 0 { + t.Errorf("sharedPrefixLen = %d, want 0", got) + } +} diff --git a/internal/util/proxy.go b/internal/util/proxy.go index d9e251859..d8d6ba577 100644 --- a/internal/util/proxy.go +++ b/internal/util/proxy.go @@ -58,7 +58,18 @@ func redactProxyURL(raw string) string { // WarnIfProxied prints a one-time warning to w when a proxy environment variable // is detected and proxy is not disabled via LARK_CLI_NO_PROXY. Proxy credentials // are redacted. Safe to call multiple times; only the first call prints. -func WarnIfProxied(w io.Writer) { +// +// The warning is suppressed entirely when interactive is false — i.e. stdin is +// not a TTY, which is the case for agent / CI / piped invocations. Those callers +// frequently parse the CLI's stdout as JSON and merge streams with `2>&1`; a +// stray stderr warning then corrupts the parsed payload. Suppressing in the +// non-interactive case keeps machine-consumed output clean, while human +// interactive sessions still get the security notice. Passing interactive=false +// does not consume the once guard, so a later interactive call can still warn. +func WarnIfProxied(w io.Writer, interactive bool) { + if !interactive { + return + } proxyWarningOnce.Do(func() { if os.Getenv(EnvNoProxy) != "" { return diff --git a/internal/util/proxy_test.go b/internal/util/proxy_test.go index f78720963..6d2a6a376 100644 --- a/internal/util/proxy_test.go +++ b/internal/util/proxy_test.go @@ -97,7 +97,7 @@ func TestWarnIfProxied_WithProxy(t *testing.T) { t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128") var buf bytes.Buffer - WarnIfProxied(&buf) + WarnIfProxied(&buf, true) out := buf.String() if out == "" { @@ -119,13 +119,30 @@ func TestWarnIfProxied_WithoutProxy(t *testing.T) { } var buf bytes.Buffer - WarnIfProxied(&buf) + WarnIfProxied(&buf, true) if buf.Len() != 0 { t.Errorf("expected no output when no proxy is set, got: %s", buf.String()) } } +func TestWarnIfProxied_SilentWhenNonInteractive(t *testing.T) { + proxyWarningOnce = sync.Once{} + + // Non-interactive (interactive=false) mirrors agent / CI / piped invocations + // where stdin is not a TTY. The proxy warning must be suppressed so callers + // that parse stdout as JSON — often merging streams with `2>&1` — are not + // corrupted by a stray stderr line. + t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128") + + var buf bytes.Buffer + WarnIfProxied(&buf, false) + + if buf.Len() != 0 { + t.Errorf("expected no warning in non-interactive mode, got: %s", buf.String()) + } +} + func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) { proxyWarningOnce = sync.Once{} @@ -133,7 +150,7 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) { t.Setenv(EnvNoProxy, "1") var buf bytes.Buffer - WarnIfProxied(&buf) + WarnIfProxied(&buf, true) if buf.Len() != 0 { t.Errorf("expected no warning when proxy is disabled, got: %s", buf.String()) @@ -146,10 +163,10 @@ func TestWarnIfProxied_OnlyOnce(t *testing.T) { t.Setenv("HTTP_PROXY", "http://proxy:1234") var buf bytes.Buffer - WarnIfProxied(&buf) + WarnIfProxied(&buf, true) first := buf.String() - WarnIfProxied(&buf) + WarnIfProxied(&buf, true) second := buf.String() if first == "" { @@ -189,7 +206,7 @@ func TestWarnIfProxied_RedactsCredentials(t *testing.T) { t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080") var buf bytes.Buffer - WarnIfProxied(&buf) + WarnIfProxied(&buf, true) out := buf.String() if bytes.Contains([]byte(out), []byte("s3cret")) { diff --git a/shortcuts/sheets/csv_put_range_alias_test.go b/shortcuts/sheets/csv_put_range_alias_test.go new file mode 100644 index 000000000..707eebd1d --- /dev/null +++ b/shortcuts/sheets/csv_put_range_alias_test.go @@ -0,0 +1,76 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import "testing" + +// +csv-put locates with --start-cell, while +csv-get / +cells-set locate with +// --range. Agents routinely carry --range over to +csv-put and hit a guaranteed +// first-try failure. csvPutInput now accepts --range as an alias for +// --start-cell; a range value collapses to its top-left cell. +func TestCsvPutInput_RangeAliasForStartCell(t *testing.T) { + tests := []struct { + name string + raw map[string]interface{} + wantAnchor string + }{ + {"start-cell direct (unchanged)", map[string]interface{}{"csv": "a,b", "start-cell": "B2"}, "B2"}, + {"range alias, single cell", map[string]interface{}{"csv": "a,b", "range": "B2"}, "B2"}, + {"range alias collapses to top-left", map[string]interface{}{"csv": "a,b", "range": "A1:H17"}, "A1"}, + {"start-cell wins when both set", map[string]interface{}{"csv": "a,b", "start-cell": "C3", "range": "A1:H17"}, "C3"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fv := newMapFlagViewForCommand("+csv-put", tt.raw) + input, err := csvPutInput(fv, "tok", "sid", "") + if err != nil { + t.Fatalf("csvPutInput returned error: %v", err) + } + got, _ := input["start_cell"].(string) + if got != tt.wantAnchor { + t.Errorf("start_cell = %q, want %q", got, tt.wantAnchor) + } + }) + } +} + +// With neither --start-cell nor --range set, +csv-put keeps its existing +// behavior: --start-cell defaults to A1, so the paste anchors at A1. +func TestCsvPutInput_DefaultsToA1(t *testing.T) { + fv := newMapFlagViewForCommand("+csv-put", map[string]interface{}{"csv": "a,b"}) + input, err := csvPutInput(fv, "tok", "sid", "") + if err != nil { + t.Fatalf("csvPutInput returned error: %v", err) + } + if got, _ := input["start_cell"].(string); got != "A1" { + t.Errorf("start_cell = %q, want %q (default)", got, "A1") + } +} + +// csvPutWriteRangeFromInput surfaces the real paste footprint so agents can see +// how far a CSV reaches from its anchor — it auto-expands to the CSV's own size, +// not to any user-set range. +func TestCsvPutWriteRangeFromInput(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + want string + ok bool + }{ + {"3x3 at B2", map[string]interface{}{"start_cell": "B2", "csv": "a,b,c\n1,2,3\n4,5,6"}, "B2:D4", true}, + {"single cell at A1", map[string]interface{}{"start_cell": "A1", "csv": "x"}, "A1:A1", true}, + {"1 row 3 cols at C3", map[string]interface{}{"start_cell": "C3", "csv": "a,b,c"}, "C3:E3", true}, + {"ragged rows use max width", map[string]interface{}{"start_cell": "A1", "csv": "a,b\nc,d,e"}, "A1:C2", true}, + {"missing csv", map[string]interface{}{"start_cell": "A1"}, "", false}, + {"non-single anchor", map[string]interface{}{"start_cell": "A1:B2", "csv": "x"}, "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := csvPutWriteRangeFromInput(tt.input) + if ok != tt.ok || got != tt.want { + t.Errorf("got (%q, %v), want (%q, %v)", got, ok, tt.want, tt.ok) + } + }) + } +} diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index 3dfd06bc4..bf11ad83a 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -1876,6 +1876,14 @@ "desc": "Allow overwriting (default true); set false to error if any target cell is non-empty", "default": "true" }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "alias for --start-cell (parity with +csv-get / +cells-set, which locate with --range); a range like A1:H17 collapses to its top-left cell", + "hidden": true + }, { "name": "dry-run", "kind": "system", diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index 6ff0af538..3374d2d91 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -5,6 +5,7 @@ package sheets import ( "context" + "encoding/csv" "fmt" "image" _ "image/gif" @@ -17,6 +18,7 @@ import ( "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" ) // ─── lark_sheet_write_cells ─────────────────────────────────────────── @@ -205,13 +207,28 @@ var CsvPut = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, - Flags: flagsFor("+csv-put"), - Validate: validateViaInput(csvPutInput), + Flags: flagsFor("+csv-put"), // includes the hidden --range alias (defined in the base flags table) + PostMount: func(cmd *cobra.Command) { + // --range is an accepted alias for --start-cell (see csvPutInput). + // Neither is individually required; exactly one must be set. flag-defs + // marks --start-cell required, so clear that annotation and switch to a + // one-required group — otherwise cobra rejects `--range A1` for a + // missing --start-cell before the handler ever runs. + if fl := cmd.Flags().Lookup("start-cell"); fl != nil { + delete(fl.Annotations, cobra.BashCompOneRequiredFlag) + } + cmd.MarkFlagsOneRequired("start-cell", "range") + }, + Validate: validateViaInput(csvPutInput), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) input, _ := csvPutInput(runtime, token, sheetID, sheetName) - return invokeToolDryRun(token, ToolKindWrite, "set_range_from_csv", input) + dr := invokeToolDryRun(token, ToolKindWrite, "set_range_from_csv", input) + if rng, ok := csvPutWriteRangeFromInput(input); ok { + dr = dr.Set("writes_range", rng) + } + return dr }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -230,11 +247,54 @@ var CsvPut = common.Shortcut{ if err != nil { return err } + if rng, ok := csvPutWriteRangeFromInput(input); ok { + if m, isMap := out.(map[string]interface{}); isMap { + m["writes_range"] = rng + } + } runtime.Out(out, nil) return nil }, } +// csvPutWriteRangeFromInput computes the rectangle +csv-put will actually write, +// from the built tool input (start_cell + csv). +csv-put pastes from the anchor +// and auto-expands to the CSV's own row/column count — the footprint is the +// result, not a user-set boundary. Surfacing it (e.g. "B2:D4") in dry-run and in +// the success envelope lets agents see how far a paste reaches before it +// silently overwrites neighbouring cells (use --allow-overwrite=false to block +// that). Returns ok=false when the anchor is not a single cell or the CSV has no +// parseable fields. +func csvPutWriteRangeFromInput(input map[string]interface{}) (string, bool) { + anchor, _ := input["start_cell"].(string) + csvText, _ := input["csv"].(string) + if anchor == "" || csvText == "" { + return "", false + } + col0, row0, ok := splitCellRef(anchor) + if !ok { + return "", false + } + r := csv.NewReader(strings.NewReader(csvText)) + r.FieldsPerRecord = -1 // tolerate ragged rows; we only need the max width + records, err := r.ReadAll() + if err != nil || len(records) == 0 { + return "", false + } + cols := 0 + for _, rec := range records { + if len(rec) > cols { + cols = len(rec) + } + } + if cols == 0 { + return "", false + } + endCol := columnIndexToLetter(col0 + cols - 1) + endRow := row0 + len(records) // row0 is 0-based; +len(records) is the 1-based bottom row + return fmt.Sprintf("%s:%s%d", anchor, endCol, endRow), true +} + func csvPutInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { if err := requireSheetSelector(sheetID, sheetName); err != nil { return nil, err @@ -243,6 +303,18 @@ func csvPutInput(runtime flagView, token, sheetID, sheetName string) (map[string return nil, common.FlagErrorf("--csv is required") } anchor := strings.TrimSpace(runtime.Str("start-cell")) + // --range is accepted as an alias for --start-cell. +csv-get and +cells-set + // locate with --range, so agents routinely carry --range over to +csv-put and + // hit a guaranteed first-try failure. Honor it when --start-cell was not + // explicitly set — guard on Changed, not emptiness, because --start-cell + // defaults to "A1" and is therefore never empty. A range like "A1:H17" + // collapses to its top-left cell; +csv-put pastes from the anchor and + // auto-expands, so the range's lower-right bound is irrelevant. + if !runtime.Changed("start-cell") { + if rng := strings.TrimSpace(runtime.Str("range")); rng != "" { + anchor = strings.TrimSpace(strings.SplitN(rng, ":", 2)[0]) + } + } if anchor == "" { return nil, common.FlagErrorf("--start-cell is required") } diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index cd01bf069..adfb4c36d 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -32,6 +32,29 @@ metadata: | 透视表 pivot | `--pivot-table-id` | 迷你图(按组) | `--group-id` | | 浮动图片 | `--float-image-id` | | | +## 场景 → 命令速查(拿不准命令名先查这里,别按直觉拼) + +把高频意图映射到**真实存在**的 shortcut / flag。agent 常从 Excel / Google Sheets / 飞书 OpenAPI 误迁移命令名或 flag,先对照本表,避免一次必然失败的试错。完整 shortcut 见各工具参考。 + +| 你要做的事 | ✅ 正确写法 | ❌ 不存在(会被 cobra 拒) | +| --- | --- | --- | +| 读数据(纯值 / CSV) | `+csv-get`(范围用 `--range`) | — | +| 读值 + 公式 / 样式 / 批注 | `+cells-get --include value,formula,style,comment,data_validation` | `--value-render-option`、`--with-styles`、`--with-merges`、`--include-merged-cells` | +| 写纯值(整块 CSV 平铺) | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格;也接受 `--range` 别名,区间自动取左上角) | — | +| 写值 / 公式 / 样式 | `+cells-set`(定位用 `--range`) | — | +| 查找单元格 | `+cells-search`(关键字用 `--find`) | `+cells-find`、`+find`、`--query` | +| 查找并替换 | `+cells-replace` | — | +| 看子表结构(合并 / 行高列宽 / 冻结 / 隐藏) | `+sheet-info` | `+sheet-get`、`+structure-get`、`+sheet-structure-get` | +| 看工作簿 / 子表清单 | `+workbook-info` | — | +| 导出 xlsx / 单表 csv | `+workbook-export` | — | +| 清除内容 / 格式 | `+cells-clear`(范围维度用 `--scope`,取值 content / formats / all) | `--type` | +| 批量清除多区域 | `+cells-batch-clear`(`--scope`) | `--target` | +| 调整列宽 / 行高 | `+cols-resize` / `+rows-resize`(行、列是两个独立命令) | `--dimension`(无此 flag) | +| 分组汇总 / 透视 | `+pivot-create`(默认不传落点 flag → 自动新建子表,零覆盖) | 用 SUMIF / 本地脚本拼一张假透视表 | + +> ⚠️ **定位 flag**:`+cells-get` / `+cells-set` / `+csv-get` 用 `--range`;`+csv-put` 规范用 `--start-cell`(单个左上角锚点格),也接受 `--range` 别名(区间自动取左上角),二者择一即可。 +> ⚠️ **读取附加信息**一律走 `+cells-get --include …`,**没有** `--value-render-option` / `--with-styles` 这类 flag;**看合并单元格**用 `+sheet-info` 的 `merged_cells`,不要在 `+cells-get` 里找 merge flag。 + ## References 本 skill 的 reference 分两组:先读**通用方法与规范**(横切所有任务的工作流、铁律、样式、公式规则,不含具体 shortcut),它们规定了"怎么做对";再按操作对象进入**工具参考**查具体 shortcut 与调用细节。编辑类任务务必先过一遍通用方法与规范,其中的铁律对所有工具参考一律生效。 diff --git a/skills/lark-sheets/references/lark-sheets-core-operations.md b/skills/lark-sheets/references/lark-sheets-core-operations.md index cce1ad08b..46b07fa40 100644 --- a/skills/lark-sheets/references/lark-sheets-core-operations.md +++ b/skills/lark-sheets/references/lark-sheets-core-operations.md @@ -32,7 +32,7 @@ | 用户需求语义 | 路径 | |---|---| - | "完善 / 补齐 / 填空 / 修正所有 XX" / 数据分析 / 清洗 / 大数据集 | **A:分批 `+csv-get` 导出到本地 + pandas 处理 + 分批回写**(默认覆盖所有对应数据行,不以用户选区为准) | + | "完善 / 补齐 / 填空 / 修正所有 XX" / 数据分析 / 清洗 / 大数据集 | **A:先判能否用原生**(公式 / `+pivot` / `+filter`,见第 5 步);确需代码再**分批 `+csv-get` 导出 + 本地脚本处理 + 分批回写**(默认覆盖所有对应数据行,不以用户选区为准;动代码前先过下方「运行环境前提」) | | "查一下 / 看看 / 统计 / 汇总" 等只读 | B:`+csv-get` 读到上下文 | | 需要公式 / 样式 / 批注 | C:`+cells-get` | | 续写 / 扩展 / 完善已有内容 | D:`+csv-get` 看结构 + `+cells-get` 读源区样式 + `+sheet-info --include row_heights,merges`(见铁律 5) | @@ -59,6 +59,15 @@ 7. **验证**:重新读取受影响区域确认值 / 公式 / 样式 / 批注符合预期;对象类(图表 / 透视表 / 条件格式 / 筛选 / 迷你图 / 浮动图片)重新读对象配置确认;出错先定位错误类型 / 受影响区域 / 根因再修复重验。 +## 运行环境前提(动用本地代码 / 脚本前必读) + +铁律是**原生工具优先、代码兜底**(见上)。一旦确需本地 `python` / `node`(多步清洗、统计建模、公式试错 3 次降级),先过这几条——实测大量失败绕路都源于此: + +- **解释器不一定存在**:目标环境常是 Windows + Git Bash,**`python3` 往往指向 Microsoft Store 占位符(不可用)**。动手前一次性探测 `python --version 2>&1 || python3 --version 2>&1 || py --version 2>&1 || node --version 2>&1`,选可用的那个;同一条命令失败后别原样重发。能不写代码就不写——优先飞书公式 / `+pivot` / `+csv-put`。 +- **临时文件路径**:Windows 上 `/tmp` 不等于系统临时目录,写进去外部 `python` / `node` 读不到。用 `$TEMP`(或脚本内 `tempfile.gettempdir()` / `os.tmpdir()`)取真实临时目录,不要硬编码 `/tmp`;仍放在用户项目目录之外。 +- **解析 CLI 输出别用 `2>&1`**:`[WARN] proxy …` 等提示走 stderr,`2>&1` 会把它混进 stdout 的 JSON,导致 `json.load` / `ConvertFrom-Json` 解析失败。要解析就直接管道 stdout(`lark-cli … | jq …`),或先 `> file`(只重定向 stdout)再读;需要诊断时把 stderr 单独导到另一个文件。 +- **文件编码**:喂给 CLI 的 CSV / JSON 用 **UTF-8 无 BOM**(BOM 会污染首格或触发 `invalid character` 解析错);读 CLI 输出的脚本显式指定 `encoding='utf-8'`。 + ## 公式策略 - **公式优先于硬编码**(同铁律 4):能用公式表达的计算一律写公式,源数据变化才能自动重算。 diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 35acaf939..dd772f246 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -301,6 +301,7 @@ _公共四件套 · 系统:`--dry-run`_ | `--start-cell` | string | required | 目标区域起点 A1(如 `A1`、`B5`,不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet);必须是单个单元格,不接受范围写法;终点按 CSV 实际行列数自动推断 | | `--csv` | string + File + Stdin(非 JSON 文本) | required | RFC 4180 CSV 文本;只写纯值,不带公式/样式/批注 | | `--allow-overwrite` | bool | optional | 允许覆盖(默认 true);设为 false 时若目标非空报错 | +| `--range` | string | optional | --start-cell 的别名(与 +csv-get / +cells-set 一致,用 --range 定位);传区间(如 A1:H17)时自动取其左上角单元格(隐藏 flag:不在 `--help` 列出,但可正常传入) | ## Schemas @@ -423,6 +424,12 @@ lark-cli sheets +csv-put --spreadsheet-token shtXXX --sheet-id "$SID" \ > # ↑ A2 实际写入字符串 "=SUM(B2:B10)",**不是公式**。需要写公式请用 +cells-set。 > ``` +> **定位 + 写入边界(关键,避免误覆盖)**: +> - 定位用 `--start-cell`(锚点 = 左上角单元格);也接受 `--range` 别名(与 `+csv-get` / `+cells-set` 一致,传区间会自动取左上角)。 +> - ⚠️ `--start-cell` / `--range` **只定左上角、不限制写入大小**:CSV 从锚点按自身行列数 auto-expand 铺开。给一个"小 range"**不会**截断数据——超出部分照写,且默认覆盖。这与 `+cells-set --range`(精确矩形、`--cells` 必须与 range 同维)语义相反,别把那套心智搬过来。 +> - dry-run 与成功响应都回显 `writes_range`(实际落区,如 `B2:D4`):**写前先 `--dry-run` 看一眼落区**,确认不会盖到相邻数据。 +> - 要保护非空 cell:`--allow-overwrite=false`(落区内出现非空 cell 即报错)。 + ### Validate / DryRun / Execute 约束 - `Validate`:XOR 公共四件套;`+cells-set` 的 `--cells` 必须能解析为 JSON 二维矩阵且行列数与 `--range` 完全一致;`+cells-set-style` 的样式 flag 至少一个非空(或带 `--border-styles`);`+cells-set-image` 的 `--range` 必须是单 cell(起止 cell 相同);`+csv-put` 的 `--csv` 必须能按 RFC 4180 解析;防爆参数上限校验。 From 5c22f912aaf6594c3a36e9004023ac63242ddd17 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Mon, 1 Jun 2026 20:14:04 +0800 Subject: [PATCH 087/114] =?UTF-8?q?feat(sheets):=20close=20P0-4=20pivot=20?= =?UTF-8?q?gaps=20=E2=80=94=20enum=20case,=20clear=E2=86=92pivot-delete=20?= =?UTF-8?q?hint,=20placement=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last open P0 from the 50-trajectory analysis — the two pivot black holes: upper-cased summarize_by, and pivots built over the source sheet that hit #REF! and then couldn't be removed. - enum case tolerance: validateAgainstSchema rewrites a case-only enum mismatch to the canonical (lower-case) spelling in place ("SUM" -> "sum") before the request is sent, killing the whole class instead of only hinting at it. Covers every nested enum (values[], calculated_fields[]); genuinely unknown values still fail with the existing did-you-mean message. - +cells-clear / +cells-batch-clear: when the backend reports "can not find embedded block" (the range overlaps a pivot/chart), annotate the error with the real fix — clearing cells can't delete an embedded object; remove it with +pivot-delete / +chart-delete (id via +pivot-list / +chart-list). Applied to both shortcuts, a Tips line, and the cells-clear reference. - +pivot-create: a --help Tips block making "omit --target-* -> backend auto-creates a sub-sheet, zero overwrite" the can't-miss default, plus a placement_warning (dry-run + execute output) when an explicit target sheet is set with no offset — definite when the target name matches the source sheet, conditional otherwise. Local-only, advisory, never blocks the call. The placement_warning is structured output, not a stderr line, so it survives non-interactive proxy-warning silencing and isn't swallowed by 2>&1. --- shortcuts/sheets/flag_schema_validate.go | 14 +++ shortcuts/sheets/flag_schema_validate_test.go | 85 +++++++++++++++--- shortcuts/sheets/lark_sheet_batch_update.go | 3 +- shortcuts/sheets/lark_sheet_object_crud.go | 88 ++++++++++++++++++- .../sheets/lark_sheet_object_crud_test.go | 67 ++++++++++++++ .../sheets/lark_sheet_range_operations.go | 31 ++++++- .../lark_sheet_range_operations_test.go | 45 ++++++++++ .../lark-sheets-range-operations.md | 2 + 8 files changed, 319 insertions(+), 16 deletions(-) diff --git a/shortcuts/sheets/flag_schema_validate.go b/shortcuts/sheets/flag_schema_validate.go index f83135d94..cad22b1a5 100644 --- a/shortcuts/sheets/flag_schema_validate.go +++ b/shortcuts/sheets/flag_schema_validate.go @@ -303,6 +303,20 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin if !present { continue } + // Case-insensitive enum tolerance: when the value matches an + // allowed enum entry except for casing, rewrite it in place to + // the canonical spelling. The schema lists enums in their + // canonical (lower-case) form, so "SUM" / "COUNTA" would + // otherwise be rejected right here before the request is even + // sent; normalizing kills the whole pivot summarize_by "SUM vs + // sum" class. Genuinely-unknown values still fail below, with + // their own did-you-mean hint. + if sub != nil && len(sub.Enum) > 0 { + if canon := suggestEnumMatch(v, sub.Enum); canon != "" { + obj[key] = canon + v = canon + } + } child := key if path != "" { child = path + "." + key diff --git a/shortcuts/sheets/flag_schema_validate_test.go b/shortcuts/sheets/flag_schema_validate_test.go index 307e2874c..2e4036923 100644 --- a/shortcuts/sheets/flag_schema_validate_test.go +++ b/shortcuts/sheets/flag_schema_validate_test.go @@ -33,6 +33,63 @@ func parseValue(t *testing.T, raw string) interface{} { return v } +// TestValidateAgainstSchema_EnumCaseNormalization pins the case-insensitive +// enum tolerance: a value matching an allowed enum entry except for casing is +// rewritten in place to the canonical spelling (so the case-sensitive backend +// accepts it), while genuinely-unknown values still fail. Only fires for enum +// fields nested in an object/array — the pivot values[].summarize_by path. +func TestValidateAgainstSchema_EnumCaseNormalization(t *testing.T) { + t.Parallel() + + schema := parseSchema(t, `{"type":"object","properties":{"summarize_by":{"type":"string","enum":["sum","count","average"]}}}`) + + t.Run("rewrites case-only mismatch in place", func(t *testing.T) { + obj := map[string]interface{}{"summarize_by": "SUM"} + if err := validateAgainstSchema(obj, schema, ""); err != nil { + t.Fatalf("case-only value should pass after normalization, got: %v", err) + } + if got := obj["summarize_by"]; got != "sum" { + t.Errorf("summarize_by = %q, want normalized %q", got, "sum") + } + }) + + t.Run("leaves exact match untouched", func(t *testing.T) { + obj := map[string]interface{}{"summarize_by": "count"} + if err := validateAgainstSchema(obj, schema, ""); err != nil { + t.Fatalf("exact match should pass: %v", err) + } + if got := obj["summarize_by"]; got != "count" { + t.Errorf("exact value mutated to %q", got) + } + }) + + t.Run("unknown value still fails", func(t *testing.T) { + obj := map[string]interface{}{"summarize_by": "COUNTA"} + if err := validateAgainstSchema(obj, schema, ""); err == nil { + t.Fatal("unknown enum value should fail") + } else if !strings.Contains(err.Error(), "not in enum") { + t.Errorf("want enum error, got: %v", err) + } + }) + + t.Run("normalizes inside array-of-objects (values[] shape)", func(t *testing.T) { + arrSchema := parseSchema(t, `{"type":"array","items":{"type":"object","properties":{"summarize_by":{"type":"string","enum":["sum","count"]}}}}`) + arr := []interface{}{ + map[string]interface{}{"summarize_by": "Sum"}, + map[string]interface{}{"summarize_by": "COUNT"}, + } + if err := validateAgainstSchema(arr, arrSchema, ""); err != nil { + t.Fatalf("array case normalization failed: %v", err) + } + if got := arr[0].(map[string]interface{})["summarize_by"]; got != "sum" { + t.Errorf("arr[0] summarize_by = %q, want sum", got) + } + if got := arr[1].(map[string]interface{})["summarize_by"]; got != "count" { + t.Errorf("arr[1] summarize_by = %q, want count", got) + } + }) +} + // TestValidateAgainstSchema is the validator's contract test: every // supported keyword (type, enum, oneOf, required, nested properties, // array items, nullable, minimum/maximum, minItems/maxItems) gets a @@ -280,26 +337,27 @@ func TestValidateAgainstSchema_EnumErrorEnhancements(t *testing.T) { }) } -// TestValidateInputAgainstSchema_RealEnumDidYouMean exercises the -// did-you-mean path against the real embedded schema for the most -// common real-world miscue — pivot summarize_by upper-cased. -func TestValidateInputAgainstSchema_RealEnumDidYouMean(t *testing.T) { +// TestValidateInputAgainstSchema_RealEnumCaseNormalized confirms the +// case-insensitive enum tolerance fires against the real embedded schema for +// the most common real-world miscue — pivot summarize_by upper-cased. "SUM" is +// rewritten to "sum" in place and the input passes; previously this surfaced a +// did-you-mean error, but P0-4 canonicalizes it so the agent's first try wins. +func TestValidateInputAgainstSchema_RealEnumCaseNormalized(t *testing.T) { t.Parallel() fv := mapFlagView{command: "+pivot-create"} - bad := map[string]interface{}{ + in := map[string]interface{}{ "properties": map[string]interface{}{ "values": []interface{}{ map[string]interface{}{"field": "A", "summarize_by": "SUM"}, }, }, } - err := validateInputAgainstSchema(fv, bad) - if err == nil { - t.Fatal("expected enum violation") + if err := validateInputAgainstSchema(fv, in); err != nil { + t.Fatalf("upper-case summarize_by should be normalized and pass, got: %v", err) } - msg := err.Error() - if !strings.Contains(msg, `did you mean "sum"?`) { - t.Errorf("expected did-you-mean hint pointing at \"sum\"; got %q", msg) + vals := in["properties"].(map[string]interface{})["values"].([]interface{}) + if got := vals[0].(map[string]interface{})["summarize_by"]; got != "sum" { + t.Errorf("summarize_by = %q, want normalized to %q", got, "sum") } } @@ -411,11 +469,12 @@ func TestValidateInputAgainstSchema_RealSchema(t *testing.T) { t.Errorf("good input rejected: %v", err) } - // Schema-violating: summarize_by="SUM" upper-case is not in enum. + // Schema-violating: a value with no case-only match still fails loudly + // (case normalization only rescues casing mistakes, not unknown words). bad := map[string]interface{}{ "properties": map[string]interface{}{ "values": []interface{}{ - map[string]interface{}{"field": "A", "summarize_by": "SUM"}, + map[string]interface{}{"field": "A", "summarize_by": "bogus"}, }, }, } diff --git a/shortcuts/sheets/lark_sheet_batch_update.go b/shortcuts/sheets/lark_sheet_batch_update.go index 223ab08dd..3df12ca8f 100644 --- a/shortcuts/sheets/lark_sheet_batch_update.go +++ b/shortcuts/sheets/lark_sheet_batch_update.go @@ -275,7 +275,7 @@ var CellsBatchClear = common.Shortcut{ } out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input) if err != nil { - return err + return annotateEmbeddedBlockClearErr(err) } runtime.Out(out, nil) return nil @@ -283,6 +283,7 @@ var CellsBatchClear = common.Shortcut{ Tips: []string{ "high-risk-write — always preview with --dry-run; clear is not undoable.", "Every --ranges item must carry a sheet prefix (e.g. \"Sheet1!A1:A10\"); all ranges are cleared with the same --scope.", + "Can't delete an embedded pivot/chart by clearing cells — remove the object itself with +pivot-delete / +chart-delete.", }, } diff --git a/shortcuts/sheets/lark_sheet_object_crud.go b/shortcuts/sheets/lark_sheet_object_crud.go index 66b48c27b..221b9d03d 100644 --- a/shortcuts/sheets/lark_sheet_object_crud.go +++ b/shortcuts/sheets/lark_sheet_object_crud.go @@ -5,6 +5,7 @@ package sheets import ( "context" + "fmt" "path/filepath" "strings" @@ -73,6 +74,17 @@ type objectCRUDSpec struct { // Update/delete continue to use `sheet-id` / `sheet-name`. createSheetIDFlag string createSheetNameFlag string + // createTips, when set, populates the create shortcut's --help TIPS + // section. Used by pivot to make "omit --target-* → backend auto-creates + // a sub-sheet, zero overwrite" a hard, can't-miss note at the point of + // use (the most-stepped-on #REF! trap in real trajectories). + createTips []string + // createWarn, when set, is evaluated on the create shortcut's dry-run and + // execute paths; a non-empty return is surfaced as a `placement_warning` + // field in the output. Used by pivot to flag a likely source-data overwrite + // before it happens, without blocking the call. Local-only (no network), so + // it stays safe to call from dry-run. + createWarn func(rt flagView) string } // sheetIDFlagOnCreate / sheetNameFlagOnCreate return the cobra flag name @@ -103,6 +115,7 @@ func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flags, + Tips: spec.createTips, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) if err != nil { @@ -118,7 +131,13 @@ func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { sheetID := strings.TrimSpace(runtime.Str(spec.sheetIDFlagOnCreate())) sheetName := strings.TrimSpace(runtime.Str(spec.sheetNameFlagOnCreate())) input, _ := objectCreateInput(runtime, token, sheetID, sheetName, spec) - return invokeToolDryRun(token, ToolKindWrite, spec.toolName, input) + dr := invokeToolDryRun(token, ToolKindWrite, spec.toolName, input) + if spec.createWarn != nil { + if w := spec.createWarn(runtime); w != "" { + dr = dr.Set("placement_warning", w) + } + } + return dr }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -135,6 +154,13 @@ func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { if err != nil { return err } + if spec.createWarn != nil { + if w := spec.createWarn(runtime); w != "" { + if m, ok := out.(map[string]interface{}); ok { + m["placement_warning"] = w + } + } + } runtime.Out(out, nil) return nil }, @@ -348,6 +374,12 @@ var pivotSpec = objectCRUDSpec{ allowEmptySheetSelectorOnCreate: true, createSheetIDFlag: "target-sheet-id", createSheetNameFlag: "target-sheet-name", + createTips: []string{ + "Placement: omit --target-sheet-id / --target-sheet-name and the backend auto-creates a fresh sub-sheet for the pivot — zero overwrite risk. This is the default and the recommended path.", + "Only pass --target-sheet-id/-name to land in an existing sheet; if that sheet holds the source data you MUST set --target-position (or --range) outside the data, else the pivot overwrites it and the anchor shows #REF!.", + "Removing a stray pivot is +pivot-delete (get its id from +pivot-list); +cells-clear / +cells-batch-clear only clear cell values/formats and cannot delete the pivot object.", + }, + createWarn: pivotPlacementWarn, enhanceCreateInput: func(rt flagView, input map[string]interface{}) { if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" { input["target_position"] = v @@ -368,6 +400,60 @@ var PivotCreate = newObjectCreateShortcut(pivotSpec) var PivotUpdate = newObjectUpdateShortcut(pivotSpec) var PivotDelete = newObjectDeleteShortcut(pivotSpec) +// pivotPlacementWarn flags the one +pivot-create combination that silently +// overwrites data: an explicit placement sheet (--target-sheet-id/-name) with +// no offset (--target-position unset or A1, and no --range), so the pivot lands +// at A1 of an existing sheet. When that sheet is demonstrably the source-data +// sheet — target given by name, source carries a sheet prefix, names match — +// the warning is definite. When placement is by id (or the source has no +// prefix) the two can't be compared without a workbook lookup, which dry-run +// must avoid, so a conditional reminder is emitted instead. Returns "" when +// placement is safe (no target, or an offset was given). Advisory only: it is +// surfaced as placement_warning and never blocks the call. +func pivotPlacementWarn(rt flagView) string { + tgtID := strings.TrimSpace(rt.Str("target-sheet-id")) + tgtName := strings.TrimSpace(rt.Str("target-sheet-name")) + if tgtID == "" && tgtName == "" { + return "" // default path — backend auto-creates a sub-sheet, zero overwrite. + } + if pos := strings.TrimSpace(rt.Str("target-position")); pos != "" && pos != "A1" { + return "" // caller steered the pivot off A1. + } + if strings.TrimSpace(rt.Str("range")) != "" { + return "" // --range offset given. + } + srcSheet := sheetNameFromA1(rt.Str("source")) + if tgtName != "" && srcSheet != "" { + if strings.EqualFold(tgtName, srcSheet) { + return fmt.Sprintf("--target-sheet-name %q is the source-data sheet and no --target-position is set: "+ + "the pivot lands at A1 and overwrites the source (the anchor then shows #REF!). Set --target-position "+ + "to a blank cell outside the data, or omit --target-* to auto-create a sub-sheet.", tgtName) + } + return "" // distinct named sheet — safe. + } + return "a placement sheet is set without --target-position: if it is the source-data sheet, the pivot lands " + + "at A1 and overwrites the source (the anchor then shows #REF!). Set --target-position to a blank cell " + + "outside the data, or omit --target-* to auto-create a sub-sheet." +} + +// sheetNameFromA1 extracts the sheet name from a sheet-prefixed A1 reference, +// stripping the single quotes Lark wraps around names that contain spaces: +// "'Sheet 1'!A1:D100" → "Sheet 1", "Data!A1" → "Data". Returns "" when there +// is no sheet prefix. (splitSheetPrefixedRange keeps the quotes; this one drops +// them, which is what name comparison needs.) +func sheetNameFromA1(ref string) string { + ref = strings.TrimSpace(ref) + idx := strings.Index(ref, "!") + if idx <= 0 { + return "" + } + name := strings.TrimSpace(ref[:idx]) + if len(name) >= 2 && strings.HasPrefix(name, "'") && strings.HasSuffix(name, "'") { + name = name[1 : len(name)-1] + } + return name +} + // conditional format — CLI surface uses --rule-id (short), wired to the // tool's conditional_format_id on the wire. --rule-type and --ranges are // hoisted out of properties (both required, set on every CRUD write). diff --git a/shortcuts/sheets/lark_sheet_object_crud_test.go b/shortcuts/sheets/lark_sheet_object_crud_test.go index 5d8ccefcb..c5bc4480b 100644 --- a/shortcuts/sheets/lark_sheet_object_crud_test.go +++ b/shortcuts/sheets/lark_sheet_object_crud_test.go @@ -10,6 +10,73 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) +// TestPivotPlacementWarn pins the advisory that fires only on the risky +// +pivot-create combination — an explicit placement sheet with no offset — +// and stays silent (or only conditionally reminds) everywhere else. +func TestPivotPlacementWarn(t *testing.T) { + t.Parallel() + tests := []struct { + name string + raw map[string]interface{} + want string // "" none | "definite" names the sheet | "conditional" generic reminder + }{ + {"no placement target → silent (default sub-sheet)", + map[string]interface{}{"source": "'Sheet1'!A1:D100"}, ""}, + {"target-position offset → silent", + map[string]interface{}{"target-sheet-name": "Sheet1", "source": "'Sheet1'!A1:D100", "target-position": "H1"}, ""}, + {"range offset → silent", + map[string]interface{}{"target-sheet-id": "sht_x", "range": "H1"}, ""}, + {"target name == source sheet, no offset → definite", + map[string]interface{}{"target-sheet-name": "Sheet1", "source": "'Sheet1'!A1:D100"}, "definite"}, + {"case-insensitive name match → definite", + map[string]interface{}{"target-sheet-name": "sheet1", "source": "'Sheet1'!A1:D100"}, "definite"}, + {"target name != source sheet → silent (distinct sheet is safe)", + map[string]interface{}{"target-sheet-name": "PivotOut", "source": "'Sheet1'!A1:D100"}, ""}, + {"target by id, no offset → conditional", + map[string]interface{}{"target-sheet-id": "sht_abc", "source": "'Sheet1'!A1:D100"}, "conditional"}, + {"target name but source lacks prefix → conditional", + map[string]interface{}{"target-sheet-name": "Sheet1", "source": "A1:D100"}, "conditional"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := pivotPlacementWarn(mapFlagView{raw: tc.raw}) + switch tc.want { + case "": + if got != "" { + t.Errorf("expected no warning, got %q", got) + } + case "definite": + if !strings.Contains(got, "--target-sheet-name") { + t.Errorf("expected definite warning citing --target-sheet-name, got %q", got) + } + case "conditional": + if !strings.Contains(got, "a placement sheet is set") { + t.Errorf("expected conditional reminder, got %q", got) + } + } + }) + } +} + +// TestSheetNameFromA1 covers the source-sheet extraction used by the placement +// warning: prefix detection, single-quote stripping, and the no-prefix case. +func TestSheetNameFromA1(t *testing.T) { + t.Parallel() + tests := []struct{ in, want string }{ + {"'Sheet1'!A1:D100", "Sheet1"}, + {"Data!A1", "Data"}, + {"'My Sheet'!A1:B2", "My Sheet"}, + {"A1:D100", ""}, + {"", ""}, + {" 'X'!A1 ", "X"}, + } + for _, tc := range tests { + if got := sheetNameFromA1(tc.in); got != tc.want { + t.Errorf("sheetNameFromA1(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + // TestObjectCRUDShortcuts_DryRun walks the create / update / delete trio // for each object skill. Together these cover all 21 CRUD shortcuts plus // the per-object id flag renames (rule-id, group-id, view-id, etc.). diff --git a/shortcuts/sheets/lark_sheet_range_operations.go b/shortcuts/sheets/lark_sheet_range_operations.go index 94717be22..669e5eaf5 100644 --- a/shortcuts/sheets/lark_sheet_range_operations.go +++ b/shortcuts/sheets/lark_sheet_range_operations.go @@ -5,8 +5,10 @@ package sheets import ( "context" + "errors" "strings" + "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -58,13 +60,14 @@ var CellsClear = common.Shortcut{ } out, err := callTool(ctx, runtime, token, ToolKindWrite, "clear_cell_range", input) if err != nil { - return err + return annotateEmbeddedBlockClearErr(err) } runtime.Out(out, nil) return nil }, Tips: []string{ "high-risk-write — always preview with --dry-run; clear is not undoable.", + "Can't delete an embedded pivot/chart by clearing cells — remove the object itself with +pivot-delete / +chart-delete.", }, } @@ -97,6 +100,32 @@ func normalizeClearType(scope string) string { } } +// annotateEmbeddedBlockClearErr augments the backend's "embedded block" clear +// failure with the concrete fix. clear_cell_range only clears cell values / +// formats — it cannot delete an embedded object (pivot table / chart) that +// overlaps the range, which is what the backend's "can not find embedded block" +// actually means. Trajectories burned dozens of commands trying to recover a +// pivot-occupied A1 with cells-clear; point the agent at the object's own +// delete command instead. Non-matching errors pass through untouched. +func annotateEmbeddedBlockClearErr(err error) error { + var ee *output.ExitError + if !errors.As(err, &ee) || ee.Detail == nil { + return err + } + if !strings.Contains(strings.ToLower(ee.Detail.Message), "embedded block") { + return err + } + const hint = "the range overlaps an embedded object (pivot table / chart); " + + "cells-clear only clears cell values/formats and cannot delete it — " + + "delete the object with its own command (+pivot-delete / +chart-delete; find the id via +pivot-list / +chart-list)" + if ee.Detail.Hint == "" { + ee.Detail.Hint = hint + } else { + ee.Detail.Hint += "; " + hint + } + return ee +} + // CellsMerge / CellsUnmerge share the merge_cells tool, dispatched by the // `operation` enum. --merge-type applies to merge only and maps to tool // field merge_type (`all` / `rows` / `columns`). diff --git a/shortcuts/sheets/lark_sheet_range_operations_test.go b/shortcuts/sheets/lark_sheet_range_operations_test.go index ba692c709..909757147 100644 --- a/shortcuts/sheets/lark_sheet_range_operations_test.go +++ b/shortcuts/sheets/lark_sheet_range_operations_test.go @@ -4,12 +4,57 @@ package sheets import ( + "errors" "strings" "testing" + "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) +func TestAnnotateEmbeddedBlockClearErr(t *testing.T) { + t.Parallel() + + t.Run("adds pivot-delete hint on embedded-block error", func(t *testing.T) { + in := &output.ExitError{Code: output.ExitAPI, Detail: &output.ErrDetail{ + Type: "api", + Message: `tool "clear_cell_range" failed: [500] can not find embedded block`, + }} + var ee *output.ExitError + if !errors.As(annotateEmbeddedBlockClearErr(in), &ee) || ee.Detail == nil { + t.Fatal("expected ExitError with detail") + } + if !strings.Contains(ee.Detail.Hint, "+pivot-delete") { + t.Errorf("hint should point at +pivot-delete, got %q", ee.Detail.Hint) + } + }) + + t.Run("appends to existing hint", func(t *testing.T) { + in := &output.ExitError{Code: output.ExitAPI, Detail: &output.ErrDetail{ + Message: "embedded block missing", Hint: "preexisting", + }} + out := annotateEmbeddedBlockClearErr(in).(*output.ExitError) + if !strings.HasPrefix(out.Detail.Hint, "preexisting; ") { + t.Errorf("existing hint should be preserved and appended, got %q", out.Detail.Hint) + } + }) + + t.Run("passes through unrelated ExitError untouched", func(t *testing.T) { + in := &output.ExitError{Code: output.ExitAPI, Detail: &output.ErrDetail{Message: "some other failure"}} + out := annotateEmbeddedBlockClearErr(in).(*output.ExitError) + if out.Detail.Hint != "" { + t.Errorf("unrelated error should not gain a hint, got %q", out.Detail.Hint) + } + }) + + t.Run("passes through non-ExitError untouched", func(t *testing.T) { + in := errors.New("can not find embedded block") + if out := annotateEmbeddedBlockClearErr(in); out != in { + t.Error("plain (non-ExitError) error should be returned as-is") + } + }) +} + func TestRangeOperationsShortcuts_DryRun(t *testing.T) { t.Parallel() diff --git a/skills/lark-sheets/references/lark-sheets-range-operations.md b/skills/lark-sheets/references/lark-sheets-range-operations.md index 3370f3e5f..892cbf1d4 100644 --- a/skills/lark-sheets/references/lark-sheets-range-operations.md +++ b/skills/lark-sheets/references/lark-sheets-range-operations.md @@ -100,6 +100,8 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ | `--range` | string | required | 清除范围(A1 格式) | | `--scope` | string | optional | 清除范围 enum:`content`(默认,仅清内容)/ `formats`(仅清格式)/ `all`(清内容 + 格式)(可选值:`content` / `formats` / `all`) | +> **删不掉嵌入对象**:`+cells-clear`(任何 `--scope`,含 `all`)只清单元格的值 / 格式,**删不掉**压在范围内的透视表 / 图表等嵌入对象——后端会报 `can not find embedded block`。删透视表用 `+pivot-delete`、删图表用 `+chart-delete`(先用 `+pivot-list` / `+chart-list` 拿对象 id)。 + ### `+cells-merge` _公共四件套 · 系统:`--dry-run`_ From 5aba007f579d815b01a829ebbe2f655f501d6c6d Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Tue, 2 Jun 2026 14:26:01 +0800 Subject: [PATCH 088/114] feat(sheets): strip UTF-8 BOM from stdin/@file flag input resolveInputFlags now strips a leading UTF-8 BOM from content read via stdin or @file, so it cannot corrupt the first CSV cell or break JSON parsing of payloads like --operations / --cells downstream. Also pulls the synced lark-sheets skill docs from sheet-skill-spec and drops scheme-number tags from two test comments. --- shortcuts/common/runner.go | 18 ++++++- shortcuts/common/runner_input_test.go | 50 +++++++++++++++++++ shortcuts/sheets/data/flag-defs.json | 2 +- shortcuts/sheets/data/flag-schemas.json | 2 +- shortcuts/sheets/flag_schema_validate_test.go | 2 +- skills/lark-sheets/SKILL.md | 7 +-- .../references/lark-sheets-batch-update.md | 9 +++- .../lark-sheets-conditional-format.md | 2 + .../references/lark-sheets-core-operations.md | 14 +++--- .../lark-sheets-range-operations.md | 4 +- .../references/lark-sheets-read-data.md | 3 +- 11 files changed, 93 insertions(+), 20 deletions(-) diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index b4c72e1f3..b8a18573d 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -906,6 +906,16 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf return rctx, nil } +// stripUTF8BOM removes a leading UTF-8 byte-order mark from content read from a +// file or stdin. A BOM that survives into a CSV cell corrupts the first value +// (e.g. "\ufeffNorth", which then makes a MAXIFS/lookup miss it), and a BOM at the +// head of a JSON payload makes json.Unmarshal fail with "invalid character 'ï'". +// Some editors and exporters add it silently. Only a leading BOM is removed; interior +// occurrences are left untouched. +func stripUTF8BOM(s string) string { + return strings.TrimPrefix(s, "\uFEFF") +} + // resolveInputFlags resolves @file and - (stdin) for flags with Input sources. // Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content. func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error { @@ -935,7 +945,9 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error { if err != nil { return FlagErrorf("--%s: failed to read from stdin: %v", fl.Name, err) } - rctx.Cmd.Flags().Set(fl.Name, string(data)) + // strip a leading UTF-8 BOM so it can't corrupt the first CSV + // cell or break JSON parsing downstream. + rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data))) continue } @@ -958,7 +970,9 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error { if err != nil { return FlagErrorf("--%s: %v", fl.Name, err) } - rctx.Cmd.Flags().Set(fl.Name, string(data)) + // strip a leading UTF-8 BOM so it + // can't corrupt the first CSV cell or break JSON parsing downstream. + rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data))) continue } } diff --git a/shortcuts/common/runner_input_test.go b/shortcuts/common/runner_input_test.go index 47a42c138..ff05ff459 100644 --- a/shortcuts/common/runner_input_test.go +++ b/shortcuts/common/runner_input_test.go @@ -216,3 +216,53 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) { t.Errorf("unexpected error: %v", err) } } + +func TestStripUTF8BOM(t *testing.T) { + cases := []struct{ name, in, want string }{ + {"leading BOM removed", "\uFEFFhello", "hello"}, + {"no BOM unchanged", "hello", "hello"}, + {"empty unchanged", "", ""}, + {"only BOM becomes empty", "\uFEFF", ""}, + {"interior BOM preserved", "a\uFEFFb", "a\uFEFFb"}, + {"only the first BOM removed", "\uFEFF\uFEFFx", "\uFEFFx"}, + } + for _, c := range cases { + if got := stripUTF8BOM(c.in); got != c.want { + t.Errorf("%s: stripUTF8BOM(%q) = %q, want %q", c.name, c.in, got, c.want) + } + } +} + +func TestResolveInputFlags_StripBOMStdin(t *testing.T) { + // A CSV piped via stdin with a leading BOM (e.g. from an upstream export) + // must reach the shortcut without the BOM, so it can't corrupt the first cell. + rctx := newTestRuntimeWithStdin(map[string]string{"csv": "-"}, "\uFEFFname,age\nzhang,8") + flags := []Flag{{Name: "csv", Input: []string{File, Stdin}}} + + if err := resolveInputFlags(rctx, flags); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := rctx.Str("csv"); got != "name,age\nzhang,8" { + t.Errorf("leading BOM not stripped from stdin, got %q", got) + } +} + +func TestResolveInputFlags_StripBOMFile(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + + // A JSON operations file saved with a BOM would otherwise fail json.Unmarshal + // with "invalid character 'ï'". + if err := os.WriteFile("ops.json", []byte("\uFEFF[{\"shortcut\":\"+cells-set\"}]"), 0644); err != nil { + t.Fatal(err) + } + rctx := newTestRuntimeWithStdin(map[string]string{"operations": "@ops.json"}, "") + flags := []Flag{{Name: "operations", Input: []string{File, Stdin}}} + + if err := resolveInputFlags(rctx, flags); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := rctx.Str("operations"); got != "[{\"shortcut\":\"+cells-set\"}]" { + t.Errorf("leading BOM not stripped from file, got %q", got) + } +} diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index bf11ad83a..325ef358f 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -2492,7 +2492,7 @@ "kind": "own", "type": "string", "required": "required", - "desc": "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is the shortcut's flag set minus the spreadsheet locator. For basic flags use lark-cli sheets --help; for composite JSON flags use --print-schema --flag-name . Do not pass an explicit operation field. Strict transaction by default, pass --continue-on-error for soft batch; no nesting; executed serially.", + "desc": "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets --help; for composite JSON flags use --print-schema --flag-name . Do not pass an explicit operation field. Strict transaction by default, pass --continue-on-error for soft batch; no nesting; executed serially.", "input": [ "file", "stdin" diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json index 62ff31b4c..c7aad1edf 100644 --- a/shortcuts/sheets/data/flag-schemas.json +++ b/shortcuts/sheets/data/flag-schemas.json @@ -70,7 +70,7 @@ }, "input": { "type": "object", - "description": "该 shortcut 的入参集(不含 spreadsheet 定位);基础 flag 跑 `lark-cli sheets --help`,复合 JSON flag 跑 `--print-schema --flag-name `。不要手填 `operation`(动作由 shortcut 名表达)。" + "description": "该 shortcut 的入参集——含子表定位 sheet_id(或 sheet_name),但不含 spreadsheet token/url(后者只在顶层 --url/--spreadsheet-token 给一次;+batch-update 顶层没有 --sheet-id);input 的键是该 shortcut 的 flag 展平成 JSON、不是再套一层嵌套。基础 flag 跑 `lark-cli sheets --help`,复合 JSON flag 跑 `--print-schema --flag-name `。不要手填 `operation`(动作由 shortcut 名表达)。" } } } diff --git a/shortcuts/sheets/flag_schema_validate_test.go b/shortcuts/sheets/flag_schema_validate_test.go index 2e4036923..409d01929 100644 --- a/shortcuts/sheets/flag_schema_validate_test.go +++ b/shortcuts/sheets/flag_schema_validate_test.go @@ -341,7 +341,7 @@ func TestValidateAgainstSchema_EnumErrorEnhancements(t *testing.T) { // case-insensitive enum tolerance fires against the real embedded schema for // the most common real-world miscue — pivot summarize_by upper-cased. "SUM" is // rewritten to "sum" in place and the input passes; previously this surfaced a -// did-you-mean error, but P0-4 canonicalizes it so the agent's first try wins. +// did-you-mean error, but in-place canonicalization fixes it so the agent's first try wins. func TestValidateInputAgainstSchema_RealEnumCaseNormalized(t *testing.T) { t.Parallel() fv := mapFlagView{command: "+pivot-create"} diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index adfb4c36d..b74314cdf 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -146,10 +146,11 @@ lark-cli sheets <其它 flag> flag 帮助里标注支持 **Stdin** 的入参,当 payload 较大、含换行 / 引号等特殊字符,或已经落在某个文件里时,优先用 stdin(`-`)传入,避免命令行超长与 shell 转义问题。 -推荐写法:payload 写到 cwd 之外的临时文件(如 `/tmp/cells.json`,不污染用户项目目录),再用 stdin 喂进去: +推荐写法:payload 写到用户项目目录之外的临时文件(放系统临时目录,避免污染项目),再用 stdin 喂进去: ```bash -lark-cli sheets +cells-set --url "..." --sheet-name "Sheet1" --range "A1:B2" --cells - < /tmp/cells.json +# TMPFILE 指向系统临时目录下的 payload 文件(脚本里用 tempfile.gettempdir() / os.tmpdir() 等取临时目录) +lark-cli sheets +cells-set --url "..." --sheet-name "Sheet1" --range "A1:B2" --cells - < "$TMPFILE" ``` -**`@file` 接绝对路径会被拒,且被拒后不要照报错提示做。** `@file` 出于安全只接受 cwd 下的相对路径,传 `@/tmp/cells.json` 这类绝对路径或 cwd 之外的路径会被拒。此时报错会建议"先 cd 到目标目录,或改用相对路径"——**两条都不要照做**:cd 过去、或把临时文件写进用户项目目录,都会污染工作目录。正解是改用 stdin(`-- - < 文件`)。 +**`@file` 接绝对路径会被拒,且被拒后不要照报错提示做。** `@file` 出于安全只接受 cwd 下的相对路径,传 cwd 之外的绝对路径会被拒。此时报错会建议"先 cd 到目标目录,或改用相对路径"——**两条都不要照做**:cd 过去、或把临时文件写进用户项目目录,都会污染工作目录。正解是改用 stdin(`-- - < 文件`)。 diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 7de341f9b..b2880d891 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -42,7 +42,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--operations` | string + File + Stdin(复合 JSON) | required | JSON 数组:[{"shortcut":"+xxx-yyy","input":{...}}, ...]。shortcut 用 CLI 名;input 是该 shortcut 的入参集(不含 spreadsheet 定位),基础 flag 查 --help,复合 JSON flag 查 --print-schema --flag-name ;不要手填 operation 字段(由 CLI 按 shortcut 自动注入)。默认严格事务(首个失败即整批中断),传 --continue-on-error 切换为软批量(遇失败仍继续);不支持嵌套;按数组顺序串行执行 | +| `--operations` | string + File + Stdin(复合 JSON) | required | JSON 数组:[{"shortcut":"+xxx-yyy","input":{...}}, ...]。shortcut 用 CLI 名;input 是该 shortcut 的入参集——含子表定位 sheet_id(或 sheet_name),但不含 spreadsheet token/url(后者只在顶层 --url/--spreadsheet-token 给一次;+batch-update 顶层没有 --sheet-id);input 的键是该 shortcut 的 flag 展平成 JSON(如 "range":"A11:B12"),不是再套一层嵌套。基础 flag 查 --help,复合 JSON flag 查 --print-schema --flag-name ;不要手填 operation 字段(由 CLI 按 shortcut 自动注入)。默认严格事务(首个失败即整批中断),传 --continue-on-error 切换为软批量(遇失败仍继续);不支持嵌套;按数组顺序串行执行 | | `--continue-on-error` | bool | optional | 遇子操作失败时继续执行剩余操作;默认 false(首个失败即整批中断) | ### `+cells-batch-set-style` @@ -104,7 +104,7 @@ _要批量执行的 CLI shortcut 操作列表,按声明顺序串行执行; **数组项**(类型 object): - `shortcut` (enum) — CLI shortcut 名(不是底层 MCP tool 名) [+cells-set / +cells-set-style / +cells-clear / +cells-merge / +cells-unmerge / +cells-replace / +csv-put / +dropdown-set / +dim-insert / +dim-delete / +dim-hide / +dim-unhide / +dim-freeze / +dim-group / +dim-ungroup / +rows-resize / +cols-resize / +range-move / +range-copy / +range-fill / +range-sort / +sheet-create / +sheet-delete / +sheet-rename / +sheet-move / +sheet-copy / +sheet-hide / +sheet-unhide / +sheet-set-tab-color / +chart-create / +chart-update / +chart-delete / +pivot-create / +pivot-update / +pivot-delete / +cond-format-create / +cond-format-update / +cond-format-delete / +filter-create / +filter-update / +filter-delete / +filter-view-create / +filter-view-update / +filter-view-delete / +sparkline-create / +sparkline-update / +sparkline-delete / +float-image-create / +float-image-update / +float-image-delete] -- `input` (object) — 该 shortcut 的入参集(不含 spreadsheet 定位);基础 flag 跑 `lark-cli sheets --help… +- `input` (object) — 该 shortcut 的入参集——含子表定位 sheet_id(或 sheet_name),但不含 spreadsheet token/url(后者只在顶层 … ### `+cells-batch-set-style` `--border-styles` @@ -142,6 +142,11 @@ 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。 + > **常见组合:插列 + 写表头 + 整列回填**——一次原子提交,不要拆成 N 次独立调用。批量回填同一列 **只需一次** `+cells-set`(range 写整列范围、cells 写 N×1 矩阵),不需要逐行循环。 > > ```jsonc diff --git a/skills/lark-sheets/references/lark-sheets-conditional-format.md b/skills/lark-sheets/references/lark-sheets-conditional-format.md index 48081a73e..9d58ea09b 100644 --- a/skills/lark-sheets/references/lark-sheets-conditional-format.md +++ b/skills/lark-sheets/references/lark-sheets-conditional-format.md @@ -170,6 +170,8 @@ lark-cli sheets +cond-format-create --url "..." --sheet-id "$SID" \ lark-cli sheets +cond-format-delete --url "..." --sheet-id "$SID" --rule-id "$RULE_ID" --yes ``` +> 一次只删一个 `--rule-id`。要删**多个**条件格式时,先 `+cond-format-list` 拿到各 `rule-id`,再用 `+batch-update` 把多个 `+cond-format-delete` 合并为单次原子提交,不要逐个调用。 + ### Validate / DryRun / Execute 约束 - `Validate`:XOR 公共四件套;`--rule-type` / `--ranges` 必填;`--properties` 必须能解析为合法 JSON;按 `--rule-type` 检查必填子字段(`cellIs` 需 `attrs.operator` + `attrs.value`、`expression` 需 `attrs.formula`、`colorScale` 需 `min/mid/max` 配色等);`+cond-format-delete` 强制 `--yes` 或 `--dry-run`。 diff --git a/skills/lark-sheets/references/lark-sheets-core-operations.md b/skills/lark-sheets/references/lark-sheets-core-operations.md index 46b07fa40..c7c915c48 100644 --- a/skills/lark-sheets/references/lark-sheets-core-operations.md +++ b/skills/lark-sheets/references/lark-sheets-core-operations.md @@ -32,7 +32,7 @@ | 用户需求语义 | 路径 | |---|---| - | "完善 / 补齐 / 填空 / 修正所有 XX" / 数据分析 / 清洗 / 大数据集 | **A:先判能否用原生**(公式 / `+pivot` / `+filter`,见第 5 步);确需代码再**分批 `+csv-get` 导出 + 本地脚本处理 + 分批回写**(默认覆盖所有对应数据行,不以用户选区为准;动代码前先过下方「运行环境前提」) | + | "完善 / 补齐 / 填空 / 修正所有 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) | @@ -59,14 +59,14 @@ 7. **验证**:重新读取受影响区域确认值 / 公式 / 样式 / 批注符合预期;对象类(图表 / 透视表 / 条件格式 / 筛选 / 迷你图 / 浮动图片)重新读对象配置确认;出错先定位错误类型 / 受影响区域 / 根因再修复重验。 -## 运行环境前提(动用本地代码 / 脚本前必读) +## 用本地代码 / 脚本时的 CLI 配合要点 -铁律是**原生工具优先、代码兜底**(见上)。一旦确需本地 `python` / `node`(多步清洗、统计建模、公式试错 3 次降级),先过这几条——实测大量失败绕路都源于此: +复杂处理——多步清洗、统计建模、批量转换、语义任务的分批编排等——用代码(`python` / `node` 等)解决是完全正当的。原生能力(公式 / `+pivot` / `+filter`)能表达就优先用(可随源数据自动重算);原生表达不了或逻辑更复杂时,放手用代码。下面几条让脚本与 CLI 顺畅配合: -- **解释器不一定存在**:目标环境常是 Windows + Git Bash,**`python3` 往往指向 Microsoft Store 占位符(不可用)**。动手前一次性探测 `python --version 2>&1 || python3 --version 2>&1 || py --version 2>&1 || node --version 2>&1`,选可用的那个;同一条命令失败后别原样重发。能不写代码就不写——优先飞书公式 / `+pivot` / `+csv-put`。 -- **临时文件路径**:Windows 上 `/tmp` 不等于系统临时目录,写进去外部 `python` / `node` 读不到。用 `$TEMP`(或脚本内 `tempfile.gettempdir()` / `os.tmpdir()`)取真实临时目录,不要硬编码 `/tmp`;仍放在用户项目目录之外。 -- **解析 CLI 输出别用 `2>&1`**:`[WARN] proxy …` 等提示走 stderr,`2>&1` 会把它混进 stdout 的 JSON,导致 `json.load` / `ConvertFrom-Json` 解析失败。要解析就直接管道 stdout(`lark-cli … | jq …`),或先 `> file`(只重定向 stdout)再读;需要诊断时把 stderr 单独导到另一个文件。 -- **文件编码**:喂给 CLI 的 CSV / JSON 用 **UTF-8 无 BOM**(BOM 会污染首格或触发 `invalid character` 解析错);读 CLI 输出的脚本显式指定 `encoding='utf-8'`。 +- **解析输出时只读 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 的报错(参数错误、缺依赖、解释器不可用等)定位原因,再决定换写法、补依赖或退回原生工具。 ## 公式策略 diff --git a/skills/lark-sheets/references/lark-sheets-range-operations.md b/skills/lark-sheets/references/lark-sheets-range-operations.md index 892cbf1d4..4c2630175 100644 --- a/skills/lark-sheets/references/lark-sheets-range-operations.md +++ b/skills/lark-sheets/references/lark-sheets-range-operations.md @@ -100,8 +100,6 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ | `--range` | string | required | 清除范围(A1 格式) | | `--scope` | string | optional | 清除范围 enum:`content`(默认,仅清内容)/ `formats`(仅清格式)/ `all`(清内容 + 格式)(可选值:`content` / `formats` / `all`) | -> **删不掉嵌入对象**:`+cells-clear`(任何 `--scope`,含 `all`)只清单元格的值 / 格式,**删不掉**压在范围内的透视表 / 图表等嵌入对象——后端会报 `can not find embedded block`。删透视表用 `+pivot-delete`、删图表用 `+chart-delete`(先用 `+pivot-list` / `+chart-list` 拿对象 id)。 - ### `+cells-merge` _公共四件套 · 系统:`--dry-run`_ @@ -200,6 +198,8 @@ _排序条件列表(仅 sort 操作)_ ### `+cells-clear` +> **删不掉嵌入对象**:`+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 调用。 ```bash diff --git a/skills/lark-sheets/references/lark-sheets-read-data.md b/skills/lark-sheets/references/lark-sheets-read-data.md index 2feca0201..e82391efd 100644 --- a/skills/lark-sheets/references/lark-sheets-read-data.md +++ b/skills/lark-sheets/references/lark-sheets-read-data.md @@ -54,6 +54,7 @@ - **CSV 行号计数错误(高频致命错误)**:`+csv-get` 返回的 CSV 遵循 RFC 4180 标准,被双引号 `"..."` 包裹的字段中的换行符属于**字段内容的一部分**(即单元格内换行),不代表新的一行。计算行号时必须按**逻辑记录**计数,而非按物理换行符 `\n` 计数 - **手动数列确定列号(高频致命错误)**:禁止通过在 CSV 表头中手动数逗号/字段来确定目标列的列字母。当列数超过 10 时,手动计数极易产生 off-by-one 偏移(例如把 W 列误判为 X 列)。**必须使用 `col_indices`**:先在 CSV 表头中找到目标字段名是第 j 个字段(0-based),再用 `col_indices[j]` 获取该列的实际列字母 - **用数据列的值推导行号(高频致命错误,常被巧合掩盖)**:CSV 中常见"序号 / ID / 编号 / No."等形似行号的列,其值与实际表格行号**没有任何绑定关系**——序号可能跳号(1,2,3,5,6...)、可能从非 1 开始、可能有重复或被中途重置。此规则适用于**所有需要行号的下游操作**:合并单元格、区间写入/清空/格式化、插入/删除行、条件格式范围、筛选器范围、图表数据源、透视表范围、搜索替换范围等等——**凡是要把行号填进任何工具参数的场景,行号一律从 `annotated_csv` 中目标行开头的 `[row=N]` 前缀直接读取**,禁止用"序号=行号"、"表头占 1 行所以数据从第 2 行开始"、"第 N 个序号就在第 N+1 行"等心算,也禁止先心算再"事后核对"。**危险特征**:前几十行中序号恰好等于表格行号(典型成因:表头 +1 与一次跳号 -1 的偏移互相抵消形成巧合),模型一旦把这个巧合当作规律,会在后续所有行沿用;而中间再出现跳号时,从该行起整块区域全部错位,且错位不自查很难发现。**正确工作流**:①在 `annotated_csv` 里定位目标逻辑行(按字段内容匹配);②直接读取该行开头的 `[row=N]` 前缀得到真实表格行号;③把这个行号填进下游工具参数。区间操作时,起始行用 start 行的 `[row=N]`、结束行用 end 行的 `[row=N]`。**自检**:动手前,在 `annotated_csv` 靠后位置再抽 1~2 行,核对 `[row=N]` 是否与首列"序号"一致——不一致(典型:`[row=57] 58,...`)即说明有跳号/隐藏行,更要严格从 `[row=N]` 取值,不要被序号列迷惑 +- **按 `row_count` 盲读空行(高频低效)**:`+workbook-info` 的 `row_count` 是 sheet 的**网格物理行数**(常是 200 / 1000 等默认值),不是数据末行;按它把 `--range` 拉到 `S200`(实际数据可能只到 `S32`)会读回大片空行,浪费上下文又干扰判断。真实数据末行以 `+csv-get` 返回的 `current_region` 为准(它就是数据边界),再按下方「确定数据范围的正确流程」确认末行。 - **current_region 当作纯数据范围(高频致命错误)**:`current_region` 返回的是从请求范围向四周扩展到被空行空列包围的**连续非空区域**,等价于 Excel 的 Ctrl+Shift+\*。它包含该区域内**所有非空行**——不仅包含数据行,还可能包含标题行、汇总行(如"总计")、签名行(如"编制人/审批人")、脚注等非数据内容。**严禁直接将 `current_region` 的末尾行作为数据范围的结束行**。正确做法见下方「确定数据范围的正确流程」 ### 确定数据范围的正确流程(排序、筛选、批量写入等操作前必做) @@ -137,7 +138,7 @@ lark-cli sheets +csv-get --spreadsheet-token shtXXX --sheet-name "销售明细" - `annotated_csv` — 含 `[row=N]` 前缀的 CSV 主入口 - `col_indices` / `row_indices` — 列字母 / 行号映射数组 -- `current_region` — 自动扩展到非空连续区域的 A1 范围 +- `current_region` — 自动扩展到非空连续区域的 A1 范围。它是**真实数据边界**,**优先于 `+workbook-info` 的 `row_count`**(`row_count` 是网格物理行数,常是 200 / 1000 等默认值、远大于实际数据;按它盲读会拉回大片空行) - `has_more` — 是否截断;截断后续读用 `--range` 接着读 **加 `--rows-json`:返回结构化 rows(而非 CSV 字符串)** From fc6e60ba5b025818f1765234ae9475008c8d0c89 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Tue, 2 Jun 2026 14:57:29 +0800 Subject: [PATCH 089/114] fix(sheets): drop dead --value-render-option flag from +csv-get MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit +csv-get wraps get_range_as_csv, which has no value_render_option support (absent from its input type, executor, and published tool schema — it always returns formatted display text via getText()). The CLI passed the flag through as a silent no-op: callers asking for raw_value/formula got formatted values. Remove the flag from flag-defs, drop the value_render_option passthrough in csvGetInput, and clean the stale SKILL references. The real value_render_option capability is unchanged on +cells-get (get_cell_ranges) via --include formula. --- shortcuts/sheets/data/flag-defs.json | 13 ------------- shortcuts/sheets/lark_sheet_read_data.go | 3 --- shortcuts/sheets/lark_sheet_read_data_test.go | 13 ------------- skills/lark-sheets/SKILL.md | 4 ++-- .../references/lark-sheets-range-operations.md | 2 +- .../lark-sheets/references/lark-sheets-read-data.md | 1 - 6 files changed, 3 insertions(+), 33 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index 325ef358f..7a9fe4dc8 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -1187,19 +1187,6 @@ "required": "required", "desc": "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)" }, - { - "name": "value-render-option", - "kind": "own", - "type": "string", - "required": "optional", - "desc": "Cell value render mode", - "default": "formatted_value", - "enum": [ - "formatted_value", - "raw_value", - "formula" - ] - }, { "name": "max-chars", "kind": "own", diff --git a/shortcuts/sheets/lark_sheet_read_data.go b/shortcuts/sheets/lark_sheet_read_data.go index 15c80ea59..40044c94a 100644 --- a/shortcuts/sheets/lark_sheet_read_data.go +++ b/shortcuts/sheets/lark_sheet_read_data.go @@ -183,9 +183,6 @@ func csvGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName strin if r := strings.TrimSpace(runtime.Str("range")); r != "" { input["range"] = r } - if v := runtime.Str("value-render-option"); v != "" { - input["value_render_option"] = v - } if runtime.Bool("skip-hidden") { input["skip_hidden"] = true } diff --git a/shortcuts/sheets/lark_sheet_read_data_test.go b/shortcuts/sheets/lark_sheet_read_data_test.go index 15acfeeea..c5d878c5f 100644 --- a/shortcuts/sheets/lark_sheet_read_data_test.go +++ b/shortcuts/sheets/lark_sheet_read_data_test.go @@ -34,19 +34,6 @@ func TestReadDataShortcuts_DryRun(t *testing.T) { "cell_limit": float64(unboundedReadLimit), // pinned high; --max-chars is the only cap }, }, - { - name: "+csv-get with value-render-option", - sc: CsvGet, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C10", "--value-render-option", "formula"}, - toolName: "get_range_as_csv", - wantInput: map[string]interface{}{ - "excel_id": testToken, - "sheet_id": testSheetID, - "range": "A1:C10", - "value_render_option": "formula", - "max_rows": float64(unboundedReadLimit), // pinned high; --max-chars is the only cap - }, - }, { // Canonical form: --sheet-id + bare --range. Aligned with // +cells-get / +csv-get; before the e2e BUG-019 fix this diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index b74314cdf..49aa2a041 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -39,7 +39,7 @@ metadata: | 你要做的事 | ✅ 正确写法 | ❌ 不存在(会被 cobra 拒) | | --- | --- | --- | | 读数据(纯值 / CSV) | `+csv-get`(范围用 `--range`) | — | -| 读值 + 公式 / 样式 / 批注 | `+cells-get --include value,formula,style,comment,data_validation` | `--value-render-option`、`--with-styles`、`--with-merges`、`--include-merged-cells` | +| 读值 + 公式 / 样式 / 批注 | `+cells-get --include value,formula,style,comment,data_validation` | `--with-styles`、`--with-merges`、`--include-merged-cells` | | 写纯值(整块 CSV 平铺) | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格;也接受 `--range` 别名,区间自动取左上角) | — | | 写值 / 公式 / 样式 | `+cells-set`(定位用 `--range`) | — | | 查找单元格 | `+cells-search`(关键字用 `--find`) | `+cells-find`、`+find`、`--query` | @@ -53,7 +53,7 @@ metadata: | 分组汇总 / 透视 | `+pivot-create`(默认不传落点 flag → 自动新建子表,零覆盖) | 用 SUMIF / 本地脚本拼一张假透视表 | > ⚠️ **定位 flag**:`+cells-get` / `+cells-set` / `+csv-get` 用 `--range`;`+csv-put` 规范用 `--start-cell`(单个左上角锚点格),也接受 `--range` 别名(区间自动取左上角),二者择一即可。 -> ⚠️ **读取附加信息**一律走 `+cells-get --include …`,**没有** `--value-render-option` / `--with-styles` 这类 flag;**看合并单元格**用 `+sheet-info` 的 `merged_cells`,不要在 `+cells-get` 里找 merge flag。 +> ⚠️ **读取附加信息**一律走 `+cells-get --include …`,**没有** `--with-styles` 这类 flag;**看合并单元格**用 `+sheet-info` 的 `merged_cells`,不要在 `+cells-get` 里找 merge flag。 ## References diff --git a/skills/lark-sheets/references/lark-sheets-range-operations.md b/skills/lark-sheets/references/lark-sheets-range-operations.md index 4c2630175..5a74e2728 100644 --- a/skills/lark-sheets/references/lark-sheets-range-operations.md +++ b/skills/lark-sheets/references/lark-sheets-range-operations.md @@ -69,7 +69,7 @@ **硬性流程**: -1. sort 前先用 `+csv-get` 抽样目标列的前 3–5 行,或用 `+cells-get`(`--value-render-option raw_value` 看原始值;默认 `formatted_value` 返回显示值)确认原始值形态,不要只看列名和用户问题就直接排。 +1. sort 前先用 `+csv-get` 抽样目标列的前 3–5 行确认原始值形态,不要只看列名和用户问题就直接排。 2. 若是纯数字或日期 → 直接 sort。 3. 若是带符号 / 表达式 / 单位的文本 → **不要直接排**: - 简单场景(货币、千分位、单位前缀):新增辅助列,用公式提取数值(如 `=VALUE(SUBSTITUTE(SUBSTITUTE(A2,"¥",""),",",""))`),按辅助列排序,排完可按需清除辅助列。 diff --git a/skills/lark-sheets/references/lark-sheets-read-data.md b/skills/lark-sheets/references/lark-sheets-read-data.md index e82391efd..ed54e4830 100644 --- a/skills/lark-sheets/references/lark-sheets-read-data.md +++ b/skills/lark-sheets/references/lark-sheets-read-data.md @@ -112,7 +112,6 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--range` | string | required | A1 范围,如 `A1:F30`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet) | -| `--value-render-option` | string | optional | 单元格取值模式(可选值:`formatted_value` / `raw_value` / `formula`)(默认 `formatted_value`) | | `--max-chars` | int | optional | 防爆,默认 200000(隐藏 flag:不在 `--help` 列出,但可正常传入) | | `--include-row-prefix` | bool | optional | 是否在每行前加 `[row=N]` 前缀,默认 `true` | | `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` | From a8b29a1cf192fccef685cd4a3e75f69941db88a1 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Tue, 2 Jun 2026 15:42:17 +0800 Subject: [PATCH 090/114] chore: rename ppe x-tt-env lane to ppe_moa_canvas --- internal/cmdutil/secheader.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmdutil/secheader.go b/internal/cmdutil/secheader.go index f092ae57c..97028109d 100644 --- a/internal/cmdutil/secheader.go +++ b/internal/cmdutil/secheader.go @@ -75,7 +75,7 @@ func BaseSecurityHeaders() http.Header { h.Set(HeaderVersion, build.Version) h.Set(HeaderBuild, DetectBuildKind()) h.Set(HeaderUserAgent, UserAgentValue()) - h.Set("x-tt-env", "ppe_lark_cli_sheet") + h.Set("x-tt-env", "ppe_moa_canvas") h.Set("x-use-ppe", "1") if v := AgentTraceValue(); v != "" { h.Set(HeaderAgentTrace, v) From 3bca5457960a5c76c341c21183ea884f4b09fb95 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Tue, 2 Jun 2026 17:05:03 +0800 Subject: [PATCH 091/114] docs(sheets): sync skill description from spec (cloud-drive alias, lark-drive search, doubao routing) --- skills/lark-sheets/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index 49aa2a041..6d4a27e81 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-sheets version: 2.0.0-draft -description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)等任务时使用。若用户是想按名称或关键词搜索云空间里的表格文件,请改用 lark-doc 的 `docs +search` 先定位资源。仅针对飞书在线电子表格,不适用于本地 Excel 文件。" +description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。仅针对飞书在线电子表格,不适用于本地 Excel 文件。" metadata: requires: bins: ["lark-cli"] From bc1cd72074b92137d689066fbc1040947ac65213 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Thu, 21 May 2026 16:04:59 +0800 Subject: [PATCH 092/114] feat(sheets): restore pre-refactor shortcuts under backward/ for compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lark-sheets refactor renamed every shortcut (verb-noun → noun-verb, e.g. +create-sheet → +sheet-create) and dropped the old commands. External callers and the tests/cli_e2e/sheets suite still drive the legacy command names (+create, +read, +write, +create-sheet, ...), which broke. Re-add the pre-refactor implementations verbatim from main as an isolated shortcuts/sheets/backward package (package rename only) and register backward.Shortcuts() alongside sheets.Shortcuts(). Both sets mount under the `sheets` service; their command names are fully disjoint (38 new vs 42 old, zero overlap), so old and new commands coexist without collision. --- shortcuts/register.go | 6 + shortcuts/sheets/backward/helpers.go | 239 +++++ .../sheets/backward/lark_sheets_cell_data.go | 421 ++++++++ .../backward/lark_sheets_cell_images.go | 150 +++ .../lark_sheets_cell_style_and_merge.go | 350 +++++++ .../sheets/backward/lark_sheets_dropdown.go | 333 ++++++ .../backward/lark_sheets_filter_views.go | 489 +++++++++ .../backward/lark_sheets_float_images.go | 464 +++++++++ .../lark_sheets_row_column_management.go | 369 +++++++ .../lark_sheets_sheet_cell_ops_test.go | 781 ++++++++++++++ .../backward/lark_sheets_sheet_create_test.go | 391 +++++++ .../lark_sheets_sheet_dimension_test.go | 979 ++++++++++++++++++ .../lark_sheets_sheet_dropdown_test.go | 552 ++++++++++ .../backward/lark_sheets_sheet_export_test.go | 140 +++ .../lark_sheets_sheet_filter_view_test.go | 673 ++++++++++++ .../lark_sheets_sheet_float_image_test.go | 524 ++++++++++ .../backward/lark_sheets_sheet_manage_test.go | 702 +++++++++++++ .../backward/lark_sheets_sheet_management.go | 721 +++++++++++++ .../lark_sheets_sheet_media_upload_test.go | 272 +++++ .../backward/lark_sheets_sheet_ranges_test.go | 268 +++++ .../lark_sheets_sheet_write_image_test.go | 632 +++++++++++ .../lark_sheets_spreadsheet_management.go | 323 ++++++ shortcuts/sheets/backward/shortcuts.go | 71 ++ 23 files changed, 9850 insertions(+) create mode 100644 shortcuts/sheets/backward/helpers.go create mode 100644 shortcuts/sheets/backward/lark_sheets_cell_data.go create mode 100644 shortcuts/sheets/backward/lark_sheets_cell_images.go create mode 100644 shortcuts/sheets/backward/lark_sheets_cell_style_and_merge.go create mode 100644 shortcuts/sheets/backward/lark_sheets_dropdown.go create mode 100644 shortcuts/sheets/backward/lark_sheets_filter_views.go create mode 100644 shortcuts/sheets/backward/lark_sheets_float_images.go create mode 100644 shortcuts/sheets/backward/lark_sheets_row_column_management.go create mode 100644 shortcuts/sheets/backward/lark_sheets_sheet_cell_ops_test.go create mode 100644 shortcuts/sheets/backward/lark_sheets_sheet_create_test.go create mode 100644 shortcuts/sheets/backward/lark_sheets_sheet_dimension_test.go create mode 100644 shortcuts/sheets/backward/lark_sheets_sheet_dropdown_test.go create mode 100644 shortcuts/sheets/backward/lark_sheets_sheet_export_test.go create mode 100644 shortcuts/sheets/backward/lark_sheets_sheet_filter_view_test.go create mode 100644 shortcuts/sheets/backward/lark_sheets_sheet_float_image_test.go create mode 100644 shortcuts/sheets/backward/lark_sheets_sheet_manage_test.go create mode 100644 shortcuts/sheets/backward/lark_sheets_sheet_management.go create mode 100644 shortcuts/sheets/backward/lark_sheets_sheet_media_upload_test.go create mode 100644 shortcuts/sheets/backward/lark_sheets_sheet_ranges_test.go create mode 100644 shortcuts/sheets/backward/lark_sheets_sheet_write_image_test.go create mode 100644 shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go create mode 100644 shortcuts/sheets/backward/shortcuts.go diff --git a/shortcuts/register.go b/shortcuts/register.go index 395990285..e0b140163 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -29,6 +29,7 @@ import ( "github.com/larksuite/cli/shortcuts/markdown" "github.com/larksuite/cli/shortcuts/minutes" "github.com/larksuite/cli/shortcuts/sheets" + sheetsbackward "github.com/larksuite/cli/shortcuts/sheets/backward" "github.com/larksuite/cli/shortcuts/slides" "github.com/larksuite/cli/shortcuts/task" "github.com/larksuite/cli/shortcuts/vc" @@ -64,6 +65,11 @@ func init() { allShortcuts = append(allShortcuts, im.Shortcuts()...) allShortcuts = append(allShortcuts, contact_shortcuts.Shortcuts()...) allShortcuts = append(allShortcuts, sheets.Shortcuts()...) + // Backward-compatible sheets shortcuts (pre-refactor command names), + // kept under shortcuts/sheets/backward so external callers relying on the + // old `+create`, `+read`, `+write`, ... commands keep working alongside the + // refactored ones. Command names are disjoint from sheets.Shortcuts(). + allShortcuts = append(allShortcuts, sheetsbackward.Shortcuts()...) allShortcuts = append(allShortcuts, base.Shortcuts()...) allShortcuts = append(allShortcuts, event.Shortcuts()...) allShortcuts = append(allShortcuts, mail.Shortcuts()...) diff --git a/shortcuts/sheets/backward/helpers.go b/shortcuts/sheets/backward/helpers.go new file mode 100644 index 000000000..9c8f3284c --- /dev/null +++ b/shortcuts/sheets/backward/helpers.go @@ -0,0 +1,239 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var ( + singleCellRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*$`) + cellSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+[1-9][0-9]*$`) + cellToColRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+$`) + colSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+:[A-Za-z]+$`) + rowSpanRangePattern = regexp.MustCompile(`^[1-9][0-9]*:[1-9][0-9]*$`) + cellRefPattern = regexp.MustCompile(`^([A-Za-z]+)([1-9][0-9]*)$`) +) + +var sheetRangeSeparatorReplacer = strings.NewReplacer(`\!`, "!", `\!`, "!", "!", "!") + +// getFirstSheetID queries the spreadsheet and returns the first sheet's ID. +func getFirstSheetID(runtime *common.RuntimeContext, spreadsheetToken string) (string, error) { + data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil) + if err != nil { + return "", err + } + sheets, _ := data["sheets"].([]interface{}) + if len(sheets) > 0 { + sheet, _ := sheets[0].(map[string]interface{}) + if id, ok := sheet["sheet_id"].(string); ok && id != "" { + return id, nil + } + } + return "", output.Errorf(output.ExitAPI, "not_found", "no sheets found in this spreadsheet") +} + +// extractSpreadsheetToken extracts spreadsheet token from URL. +func extractSpreadsheetToken(input string) string { + input = strings.TrimSpace(input) + prefixes := []string{"/sheets/", "/spreadsheets/"} + for _, prefix := range prefixes { + if idx := strings.Index(input, prefix); idx >= 0 { + token := input[idx+len(prefix):] + if idx2 := strings.IndexAny(token, "/?#"); idx2 >= 0 { + token = token[:idx2] + } + return token + } + } + return input +} + +func normalizeSheetRange(sheetID, input string) string { + input = normalizeSheetRangeSeparators(input) + if input == "" || strings.Contains(input, "!") || sheetID == "" { + return input + } + if looksLikeRelativeRange(input) { + return sheetID + "!" + input + } + return input +} + +func normalizePointRange(sheetID, input string) string { + input = normalizeSheetRange(sheetID, input) + if input == "" { + return input + } + rangeSheetID, subRange, ok := splitSheetRange(input) + if !ok || !singleCellRangePattern.MatchString(subRange) { + return input + } + return rangeSheetID + "!" + subRange + ":" + subRange +} + +func normalizeWriteRange(sheetID, input string, values interface{}) string { + rows, cols := matrixDimensions(values) + input = normalizeSheetRangeSeparators(input) + if input == "" { + return buildRectRange(sheetID, "A1", rows, cols) + } + + input = normalizeSheetRange(sheetID, input) + rangeSheetID, subRange, ok := splitSheetRange(input) + if !ok { + return buildRectRange(input, "A1", rows, cols) + } + if singleCellRangePattern.MatchString(subRange) { + return buildRectRange(rangeSheetID, subRange, rows, cols) + } + return input +} + +func validateSheetRangeInput(sheetID, input string) error { + input = normalizeSheetRangeSeparators(input) + if input == "" || strings.Contains(input, "!") || sheetID != "" { + return nil + } + if looksLikeRelativeRange(input) { + return common.FlagErrorf("--range %q requires --sheet-id or a ! prefix", input) + } + return nil +} + +// validateSingleCellRange rejects multi-cell spans (e.g. "A1:B2") that are +// invalid for single-cell operations like write-image. Empty and single-cell +// values pass through. +func validateSingleCellRange(input string) error { + input = normalizeSheetRangeSeparators(input) + if input == "" { + return nil + } + // Extract the sub-range after the sheet ID prefix, if present. + subRange := input + if _, sr, ok := splitSheetRange(input); ok { + subRange = sr + } + if cellSpanRangePattern.MatchString(subRange) { + parts := strings.SplitN(subRange, ":", 2) + if strings.EqualFold(parts[0], parts[1]) { + return nil + } + return common.FlagErrorf("--range %q must be a single cell (e.g. A1 or A1:A1), got a multi-cell span", input) + } + return nil +} + +func looksLikeRelativeRange(input string) bool { + input = normalizeSheetRangeSeparators(input) + if input == "" { + return false + } + return singleCellRangePattern.MatchString(input) || + cellSpanRangePattern.MatchString(input) || + cellToColRangePattern.MatchString(input) || + colSpanRangePattern.MatchString(input) || + rowSpanRangePattern.MatchString(input) +} + +func splitSheetRange(input string) (sheetID, subRange string, ok bool) { + parts := strings.SplitN(normalizeSheetRangeSeparators(input), "!", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + return parts[0], parts[1], true +} + +func normalizeSheetRangeSeparators(input string) string { + input = strings.TrimSpace(input) + if input == "" { + return input + } + return sheetRangeSeparatorReplacer.Replace(input) +} + +func buildRectRange(sheetID, anchor string, rows, cols int) string { + if sheetID == "" { + return "" + } + if rows < 1 { + rows = 1 + } + if cols < 1 { + cols = 1 + } + endCell, err := offsetCell(anchor, rows-1, cols-1) + if err != nil { + return sheetID + } + return sheetID + "!" + anchor + ":" + endCell +} + +func matrixDimensions(values interface{}) (rows, cols int) { + rowList, ok := values.([]interface{}) + if !ok || len(rowList) == 0 { + return 1, 1 + } + rows = len(rowList) + for _, row := range rowList { + if cells, ok := row.([]interface{}); ok && len(cells) > cols { + cols = len(cells) + } + } + if cols == 0 { + cols = 1 + } + return rows, cols +} + +func offsetCell(cell string, rowOffset, colOffset int) (string, error) { + matches := cellRefPattern.FindStringSubmatch(strings.TrimSpace(cell)) + if len(matches) != 3 { + return "", fmt.Errorf("invalid cell reference: %s", cell) + } + colIndex := columnNameToIndex(matches[1]) + if colIndex < 1 { + return "", fmt.Errorf("invalid column: %s", matches[1]) + } + rowIndex, err := strconv.Atoi(matches[2]) + if err != nil { + return "", err + } + return fmt.Sprintf("%s%d", columnIndexToName(colIndex+colOffset), rowIndex+rowOffset), nil +} + +func columnNameToIndex(name string) int { + name = strings.ToUpper(strings.TrimSpace(name)) + if name == "" { + return 0 + } + index := 0 + for _, r := range name { + if r < 'A' || r > 'Z' { + return 0 + } + index = index*26 + int(r-'A'+1) + } + return index +} + +func columnIndexToName(index int) string { + if index < 1 { + return "" + } + var out []byte + for index > 0 { + index-- + out = append([]byte{byte('A' + index%26)}, out...) + index /= 26 + } + return string(out) +} diff --git a/shortcuts/sheets/backward/lark_sheets_cell_data.go b/shortcuts/sheets/backward/lark_sheets_cell_data.go new file mode 100644 index 000000000..be04c0b05 --- /dev/null +++ b/shortcuts/sheets/backward/lark_sheets_cell_data.go @@ -0,0 +1,421 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +func parseValues2DJSON(raw string) ([][]interface{}, error) { + var rows [][]interface{} + if err := json.Unmarshal([]byte(raw), &rows); err != nil { + return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array") + } + if rows == nil { + return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array") + } + return rows, nil +} + +var SheetRead = common.Shortcut{ + Service: "sheets", + Command: "+read", + Description: "Read spreadsheet cell values", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "range", Desc: "read range (!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"}, + {Name: "sheet-id", Desc: "sheet ID"}, + {Name: "value-render-option", Desc: "render option: ToString|FormattedValue|Formula|UnformattedValue"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { + return err + } + if r := runtime.Str("range"); r != "" { + if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") { + return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")) + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + readRange := runtime.Str("range") + if readRange == "" && runtime.Str("sheet-id") != "" { + readRange = runtime.Str("sheet-id") + } + readRange = normalizePointRange(runtime.Str("sheet-id"), readRange) + return common.NewDryRunAPI(). + GET("/open-apis/sheets/v2/spreadsheets/:token/values/:range"). + Set("token", token).Set("range", readRange) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + + readRange := runtime.Str("range") + if readRange == "" && runtime.Str("sheet-id") != "" { + readRange = runtime.Str("sheet-id") + } + + if readRange == "" { + var err error + readRange, err = getFirstSheetID(runtime, token) + if err != nil { + return err + } + } + readRange = normalizePointRange(runtime.Str("sheet-id"), readRange) + + params := map[string]interface{}{} + renderOption := runtime.Str("value-render-option") + if renderOption != "" { + params["valueRenderOption"] = renderOption + } + + data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values/%s", validate.EncodePathSegment(token), validate.EncodePathSegment(readRange)), params, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetWrite = common.Shortcut{ + Service: "sheets", + Command: "+write", + Description: "Write to spreadsheet cells (overwrite mode)", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "range", Desc: "write range (!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"}, + {Name: "sheet-id", Desc: "sheet ID"}, + {Name: "values", Desc: "2D array JSON", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + + if _, err := parseValues2DJSON(runtime.Str("values")); err != nil { + return err + } + if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + writeRange := runtime.Str("range") + if writeRange == "" && runtime.Str("sheet-id") != "" { + writeRange = runtime.Str("sheet-id") + } + values, _ := parseValues2DJSON(runtime.Str("values")) + writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values) + return common.NewDryRunAPI(). + PUT("/open-apis/sheets/v2/spreadsheets/:token/values"). + Body(map[string]interface{}{"valueRange": map[string]interface{}{"range": writeRange, "values": values}}). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + + values, err := parseValues2DJSON(runtime.Str("values")) + if err != nil { + return err + } + + writeRange := runtime.Str("range") + if writeRange == "" && runtime.Str("sheet-id") != "" { + writeRange = runtime.Str("sheet-id") + } + + if writeRange == "" { + var err error + writeRange, err = getFirstSheetID(runtime, token) + if err != nil { + return err + } + } + writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values) + + data, err := runtime.CallAPI("PUT", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values", validate.EncodePathSegment(token)), nil, map[string]interface{}{ + "valueRange": map[string]interface{}{ + "range": writeRange, + "values": values, + }, + }) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetAppend = common.Shortcut{ + Service: "sheets", + Command: "+append", + Description: "Append rows to a spreadsheet", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "range", Desc: "append range (!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"}, + {Name: "sheet-id", Desc: "sheet ID"}, + {Name: "values", Desc: "2D array JSON", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + + if _, err := parseValues2DJSON(runtime.Str("values")); err != nil { + return err + } + if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + appendRange := runtime.Str("range") + if appendRange == "" && runtime.Str("sheet-id") != "" { + appendRange = runtime.Str("sheet-id") + } + values, _ := parseValues2DJSON(runtime.Str("values")) + appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange) + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v2/spreadsheets/:token/values_append"). + Body(map[string]interface{}{"valueRange": map[string]interface{}{"range": appendRange, "values": values}}). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + + values, err := parseValues2DJSON(runtime.Str("values")) + if err != nil { + return err + } + + appendRange := runtime.Str("range") + if appendRange == "" && runtime.Str("sheet-id") != "" { + appendRange = runtime.Str("sheet-id") + } + + if appendRange == "" { + var err error + appendRange, err = getFirstSheetID(runtime, token) + if err != nil { + return err + } + } + appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange) + + data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{ + "valueRange": map[string]interface{}{ + "range": appendRange, + "values": values, + }, + }) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetFind = common.Shortcut{ + Service: "sheets", + Command: "+find", + Description: "Find cells in a spreadsheet", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "find", Desc: "search text", Required: true}, + {Name: "range", Desc: "search range (!A1:D10, or A1:D10 / C2 with --sheet-id)"}, + {Name: "ignore-case", Type: "bool", Desc: "case-insensitive search"}, + {Name: "match-entire-cell", Type: "bool", Desc: "match entire cell"}, + {Name: "search-by-regex", Type: "bool", Desc: "regex search"}, + {Name: "include-formulas", Type: "bool", Desc: "search formulas"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { + return err + } + if r := runtime.Str("range"); r != "" { + if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") { + return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")) + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + sheetID := runtime.Str("sheet-id") + findCondition := map[string]interface{}{ + "range": sheetID, + "match_case": !runtime.Bool("ignore-case"), + "match_entire_cell": runtime.Bool("match-entire-cell"), + "search_by_regex": runtime.Bool("search-by-regex"), + "include_formulas": runtime.Bool("include-formulas"), + } + if runtime.Str("range") != "" { + findCondition["range"] = normalizePointRange(sheetID, runtime.Str("range")) + } + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/find"). + Body(map[string]interface{}{ + "find": runtime.Str("find"), + "find_condition": findCondition, + }). + Set("token", token).Set("sheet_id", sheetID).Set("find", runtime.Str("find")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + + sheetID := runtime.Str("sheet-id") + findText := runtime.Str("find") + + findCondition := map[string]interface{}{ + "range": sheetID, + "match_case": !runtime.Bool("ignore-case"), + "match_entire_cell": runtime.Bool("match-entire-cell"), + "search_by_regex": runtime.Bool("search-by-regex"), + "include_formulas": runtime.Bool("include-formulas"), + } + if runtime.Str("range") != "" { + findCondition["range"] = normalizePointRange(sheetID, runtime.Str("range")) + } + + reqData := map[string]interface{}{ + "find_condition": findCondition, + "find": findText, + } + + data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/find", validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)), nil, reqData) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetReplace = common.Shortcut{ + Service: "sheets", + Command: "+replace", + Description: "Find and replace cell values in a spreadsheet", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "find", Desc: "search text or regex pattern", Required: true}, + {Name: "replacement", Desc: "replacement text", Required: true}, + {Name: "range", Desc: "search range (!A1:D10, or A1:D10 with --sheet-id)"}, + {Name: "match-case", Type: "bool", Desc: "case-sensitive search"}, + {Name: "match-entire-cell", Type: "bool", Desc: "match entire cell content"}, + {Name: "search-by-regex", Type: "bool", Desc: "use regex search"}, + {Name: "include-formulas", Type: "bool", Desc: "search in formulas"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { + return err + } + if r := runtime.Str("range"); r != "" { + if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") { + return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")) + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + sheetID := runtime.Str("sheet-id") + findCondition := map[string]interface{}{ + "range": sheetID, + "match_case": runtime.Bool("match-case"), + "match_entire_cell": runtime.Bool("match-entire-cell"), + "search_by_regex": runtime.Bool("search-by-regex"), + "include_formulas": runtime.Bool("include-formulas"), + } + if runtime.Str("range") != "" { + findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range")) + } + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/replace"). + Body(map[string]interface{}{ + "find_condition": findCondition, + "find": runtime.Str("find"), + "replacement": runtime.Str("replacement"), + }). + Set("token", token).Set("sheet_id", sheetID) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + + sheetID := runtime.Str("sheet-id") + findCondition := map[string]interface{}{ + "range": sheetID, + "match_case": runtime.Bool("match-case"), + "match_entire_cell": runtime.Bool("match-entire-cell"), + "search_by_regex": runtime.Bool("search-by-regex"), + "include_formulas": runtime.Bool("include-formulas"), + } + if runtime.Str("range") != "" { + findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range")) + } + + data, err := runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/replace", + validate.EncodePathSegment(token), + validate.EncodePathSegment(sheetID), + ), + nil, + map[string]interface{}{ + "find_condition": findCondition, + "find": runtime.Str("find"), + "replacement": runtime.Str("replacement"), + }, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/sheets/backward/lark_sheets_cell_images.go b/shortcuts/sheets/backward/lark_sheets_cell_images.go new file mode 100644 index 000000000..d2c0af692 --- /dev/null +++ b/shortcuts/sheets/backward/lark_sheets_cell_images.go @@ -0,0 +1,150 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var SheetWriteImage = common.Shortcut{ + Service: "sheets", + Command: "+write-image", + Description: "Write an image into a spreadsheet cell", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "sheet ID"}, + {Name: "range", Desc: "target cell (e.g. A1 or !A1). Start and end cell must be the same", Required: true}, + {Name: "image", Desc: "local image file path (supported formats: PNG, JPEG, JPG, GIF, BMP, JFIF, EXIF, TIFF, BPG, HEIC)", Required: true}, + {Name: "name", Desc: "image file name with extension (defaults to the basename of --image)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + if token == "" { + return common.FlagErrorf("specify --url or --spreadsheet-token") + } + if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { + return err + } + if err := validateSingleCellRange(runtime.Str("range")); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + pointRange := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range")) + imageName := runtime.Str("name") + if imageName == "" { + imageName = filepath.Base(runtime.Str("image")) + } + return common.NewDryRunAPI(). + Desc("JSON upload with inline image bytes"). + POST("/open-apis/sheets/v2/spreadsheets/:token/values_image"). + Body(map[string]interface{}{ + "range": pointRange, + "image": fmt.Sprintf("", runtime.Str("image")), + "name": imageName, + }). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + + pointRange := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range")) + + imagePath := runtime.Str("image") + fio := runtime.FileIO() + stat, err := validateSheetWriteImageFile(fio, imagePath) + if err != nil { + return err + } + + imageFile, err := fio.Open(imagePath) + if err != nil { + return wrapSheetWriteImageOpenError(err) + } + defer imageFile.Close() + + imageBytes, err := io.ReadAll(imageFile) + if err != nil { + return output.ErrValidation("cannot read image file: %s", err) + } + + imageName := runtime.Str("name") + if imageName == "" { + imageName = filepath.Base(imagePath) + } + + fmt.Fprintf(runtime.IO().ErrOut, "Writing image: %s (%d bytes) → %s\n", imageName, stat.Size(), pointRange) + + data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_image", validate.EncodePathSegment(token)), nil, map[string]interface{}{ + "range": pointRange, + "image": imageBytes, + "name": imageName, + }) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +func validateSheetWriteImageFile(fio fileio.FileIO, imagePath string) (fileio.FileInfo, error) { + if fio == nil { + return nil, output.ErrValidation("no file I/O provider registered") + } + stat, err := fio.Stat(imagePath) + if err != nil { + return nil, wrapSheetWriteImageStatError(err, imagePath) + } + if stat.IsDir() || !stat.Mode().IsRegular() { + return nil, output.ErrValidation("image must be a regular file: %s", imagePath) + } + const maxImageSize int64 = 20 * 1024 * 1024 + if stat.Size() > maxImageSize { + return nil, output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024) + } + return stat, nil +} + +func wrapSheetWriteImageStatError(err error, imagePath string) error { + if errors.Is(err, fileio.ErrPathValidation) { + return output.ErrValidation("unsafe image path: %s", err) + } + if os.IsNotExist(err) { + return output.ErrValidation("image file not found: %s", imagePath) + } + return output.ErrValidation("cannot stat image file: %s", err) +} + +func wrapSheetWriteImageOpenError(err error) error { + if errors.Is(err, fileio.ErrPathValidation) { + return output.ErrValidation("unsafe image path: %s", err) + } + return output.ErrValidation("cannot read image file: %s", err) +} diff --git a/shortcuts/sheets/backward/lark_sheets_cell_style_and_merge.go b/shortcuts/sheets/backward/lark_sheets_cell_style_and_merge.go new file mode 100644 index 000000000..520f91e36 --- /dev/null +++ b/shortcuts/sheets/backward/lark_sheets_cell_style_and_merge.go @@ -0,0 +1,350 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +func validateBatchStyleData(raw string) error { + var data interface{} + if err := json.Unmarshal([]byte(raw), &data); err != nil { + return common.FlagErrorf("--data must be valid JSON: %v", err) + } + arr, ok := data.([]interface{}) + if !ok || len(arr) == 0 { + return common.FlagErrorf("--data must be a non-empty JSON array") + } + for i, item := range arr { + entry, ok := item.(map[string]interface{}) + if !ok { + return common.FlagErrorf("--data[%d] must be an object with ranges and style", i) + } + rangesRaw, ok := entry["ranges"] + if !ok { + return common.FlagErrorf("--data[%d].ranges is required", i) + } + ranges, ok := rangesRaw.([]interface{}) + if !ok || len(ranges) == 0 { + return common.FlagErrorf("--data[%d].ranges must be a non-empty array of strings", i) + } + for j, r := range ranges { + s, ok := r.(string) + if !ok || s == "" { + return common.FlagErrorf("--data[%d].ranges[%d] must be a non-empty string", i, j) + } + if _, _, ok := splitSheetRange(s); !ok { + return common.FlagErrorf("--data[%d].ranges[%d] %q must include a sheetId! prefix", i, j, s) + } + } + styleRaw, ok := entry["style"] + if !ok { + return common.FlagErrorf("--data[%d].style is required", i) + } + if _, ok := styleRaw.(map[string]interface{}); !ok { + return common.FlagErrorf("--data[%d].style must be a JSON object", i) + } + } + return nil +} + +var SheetSetStyle = common.Shortcut{ + Service: "sheets", + Command: "+set-style", + Description: "Set cell style for a range", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "range", Desc: "cell range (!A1:B2, or A1:B2 with --sheet-id)", Required: true}, + {Name: "sheet-id", Desc: "sheet ID (for relative range)"}, + {Name: "style", Desc: "style JSON object (e.g. {\"font\":{\"bold\":true},\"backColor\":\"#ff0000\"})", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + if token == "" { + return common.FlagErrorf("specify --url or --spreadsheet-token") + } + var style interface{} + if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil { + return common.FlagErrorf("--style must be valid JSON: %v", err) + } + if _, ok := style.(map[string]interface{}); !ok { + return common.FlagErrorf("--style must be a JSON object, got %T", style) + } + if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range")) + var style interface{} + _ = json.Unmarshal([]byte(runtime.Str("style")), &style) // Validate already parses and validates this JSON. + return common.NewDryRunAPI(). + PUT("/open-apis/sheets/v2/spreadsheets/:token/style"). + Body(map[string]interface{}{ + "appendStyle": map[string]interface{}{ + "range": r, + "style": style, + }, + }). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + + r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range")) + var style interface{} + if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil { + return common.FlagErrorf("--style must be valid JSON: %v", err) + } + + data, err := runtime.CallAPI("PUT", + fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/style", validate.EncodePathSegment(token)), + nil, + map[string]interface{}{ + "appendStyle": map[string]interface{}{ + "range": r, + "style": style, + }, + }, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetBatchSetStyle = common.Shortcut{ + Service: "sheets", + Command: "+batch-set-style", + Description: "Batch set cell styles for multiple ranges", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "data", Desc: "JSON array of {ranges, style} objects; each range must carry a sheetId! prefix (e.g. sheet1!A1)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + if token == "" { + return common.FlagErrorf("specify --url or --spreadsheet-token") + } + return validateBatchStyleData(runtime.Str("data")) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + var data interface{} + _ = json.Unmarshal([]byte(runtime.Str("data")), &data) // Validate already parses and validates this JSON via validateBatchStyleData(). + normalizeBatchStyleRanges(data) + return common.NewDryRunAPI(). + PUT("/open-apis/sheets/v2/spreadsheets/:token/styles_batch_update"). + Body(map[string]interface{}{ + "data": data, + }). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + + var data interface{} + if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil { + return common.FlagErrorf("--data must be valid JSON: %v", err) + } + normalizeBatchStyleRanges(data) + + result, err := runtime.CallAPI("PUT", + fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/styles_batch_update", validate.EncodePathSegment(token)), + nil, + map[string]interface{}{ + "data": data, + }, + ) + if err != nil { + return err + } + runtime.Out(result, nil) + return nil + }, +} + +func normalizeBatchStyleRanges(data interface{}) { + items, ok := data.([]interface{}) + if !ok { + return + } + for _, item := range items { + entry, ok := item.(map[string]interface{}) + if !ok { + continue + } + ranges, ok := entry["ranges"].([]interface{}) + if !ok { + continue + } + for i, r := range ranges { + if s, ok := r.(string); ok { + ranges[i] = normalizePointRange("", s) + } + } + } +} + +var SheetMergeCells = common.Shortcut{ + Service: "sheets", + Command: "+merge-cells", + Description: "Merge cells in a spreadsheet", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "range", Desc: "cell range (!A1:B2, or A1:B2 with --sheet-id)", Required: true}, + {Name: "sheet-id", Desc: "sheet ID (for relative range)"}, + {Name: "merge-type", Desc: "merge method", Required: true, Enum: []string{"MERGE_ALL", "MERGE_ROWS", "MERGE_COLUMNS"}}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + if token == "" { + return common.FlagErrorf("specify --url or --spreadsheet-token") + } + if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range")) + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v2/spreadsheets/:token/merge_cells"). + Body(map[string]interface{}{ + "range": r, + "mergeType": runtime.Str("merge-type"), + }). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + + r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range")) + + data, err := runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/merge_cells", validate.EncodePathSegment(token)), + nil, + map[string]interface{}{ + "range": r, + "mergeType": runtime.Str("merge-type"), + }, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetUnmergeCells = common.Shortcut{ + Service: "sheets", + Command: "+unmerge-cells", + Description: "Unmerge (split) cells in a spreadsheet", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "range", Desc: "cell range (!A1:B2, or A1:B2 with --sheet-id)", Required: true}, + {Name: "sheet-id", Desc: "sheet ID (for relative range)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + if token == "" { + return common.FlagErrorf("specify --url or --spreadsheet-token") + } + if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range")) + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v2/spreadsheets/:token/unmerge_cells"). + Body(map[string]interface{}{ + "range": r, + }). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + + r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range")) + + data, err := runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/unmerge_cells", validate.EncodePathSegment(token)), + nil, + map[string]interface{}{ + "range": r, + }, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/sheets/backward/lark_sheets_dropdown.go b/shortcuts/sheets/backward/lark_sheets_dropdown.go new file mode 100644 index 000000000..e5645af65 --- /dev/null +++ b/shortcuts/sheets/backward/lark_sheets_dropdown.go @@ -0,0 +1,333 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +func dataValidationBasePath(token string) string { + return fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dataValidation", + validate.EncodePathSegment(token)) +} + +func dataValidationSheetPath(token, sheetID string) string { + return fmt.Sprintf("%s/%s", dataValidationBasePath(token), validate.EncodePathSegment(sheetID)) +} + +func validateDropdownToken(runtime *common.RuntimeContext) (string, error) { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + if token == "" { + return "", common.FlagErrorf("specify --url or --spreadsheet-token") + } + return token, nil +} + +func parseJSONStringArray(flagName, value string) ([]interface{}, error) { + var typed []string + if err := json.Unmarshal([]byte(value), &typed); err != nil { + return nil, common.FlagErrorf("--%s must be a JSON array of strings: %v", flagName, err) + } + if typed == nil { + return nil, common.FlagErrorf("--%s must be a JSON array, got null", flagName) + } + arr := make([]interface{}, len(typed)) + for i, s := range typed { + arr[i] = s + } + return arr, nil +} + +func validateRangesFlag(runtime *common.RuntimeContext) ([]interface{}, error) { + ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges")) + if err != nil { + return nil, err + } + if len(ranges) == 0 { + return nil, common.FlagErrorf("--ranges must not be empty") + } + for i, r := range ranges { + s, _ := r.(string) + if _, _, ok := splitSheetRange(s); !ok { + return nil, common.FlagErrorf("--ranges[%d] %q must be a fully qualified range with sheet ID prefix (e.g. !A2:A100)", i, s) + } + } + return ranges, nil +} + +func buildDropdownBody(runtime *common.RuntimeContext) (map[string]interface{}, error) { + condValues, err := parseJSONStringArray("condition-values", runtime.Str("condition-values")) + if err != nil { + return nil, err + } + if len(condValues) == 0 { + return nil, common.FlagErrorf("--condition-values must not be empty") + } + + dv := map[string]interface{}{ + "conditionValues": condValues, + } + + opts := map[string]interface{}{} + if runtime.Cmd.Flags().Changed("multiple") { + opts["multipleValues"] = runtime.Bool("multiple") + } + if runtime.Cmd.Flags().Changed("highlight") { + opts["highlightValidData"] = runtime.Bool("highlight") + } + if runtime.Str("colors") != "" { + colors, err := parseJSONStringArray("colors", runtime.Str("colors")) + if err != nil { + return nil, err + } + if len(colors) != len(condValues) { + return nil, common.FlagErrorf("--colors length (%d) must match --condition-values length (%d)", len(colors), len(condValues)) + } + opts["colors"] = colors + } + if len(opts) > 0 { + dv["options"] = opts + } + + return dv, nil +} + +// SheetSetDropdown sets dropdown list validation on a range. +var SheetSetDropdown = common.Shortcut{ + Service: "sheets", + Command: "+set-dropdown", + Description: "Set dropdown list on a cell range", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "range", Desc: "cell range (!A2:A100)", Required: true}, + {Name: "condition-values", Desc: `dropdown options as JSON array (e.g. '["opt1","opt2"]'), max 500, each <=100 chars, no commas`, Required: true}, + {Name: "multiple", Desc: "enable multi-select (default false)", Type: "bool"}, + {Name: "highlight", Desc: "color-code options (default false)", Type: "bool"}, + {Name: "colors", Desc: `RGB hex color array (e.g. '["#1FB6C1","#F006C2"]'), must match condition-values length`}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateDropdownToken(runtime); err != nil { + return err + } + if _, _, ok := splitSheetRange(runtime.Str("range")); !ok { + return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. !A2:A100)") + } + _, err := buildDropdownBody(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateDropdownToken(runtime) + dv, _ := buildDropdownBody(runtime) + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v2/spreadsheets/:token/dataValidation"). + Body(map[string]interface{}{ + "range": runtime.Str("range"), + "dataValidationType": "list", + "dataValidation": dv, + }). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateDropdownToken(runtime) + dv, err := buildDropdownBody(runtime) + if err != nil { + return err + } + + data, err := runtime.CallAPI("POST", dataValidationBasePath(token), nil, + map[string]interface{}{ + "range": runtime.Str("range"), + "dataValidationType": "list", + "dataValidation": dv, + }, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +// SheetUpdateDropdown updates dropdown list settings for given ranges. +var SheetUpdateDropdown = common.Shortcut{ + Service: "sheets", + Command: "+update-dropdown", + Description: "Update dropdown list settings", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "ranges", Desc: `ranges as JSON array (e.g. '["sheetId!A1:A100"]')`, Required: true}, + {Name: "condition-values", Desc: `dropdown options as JSON array (e.g. '["opt1","opt2"]')`, Required: true}, + {Name: "multiple", Desc: "enable multi-select (default false)", Type: "bool"}, + {Name: "highlight", Desc: "color-code options (default false)", Type: "bool"}, + {Name: "colors", Desc: `RGB hex color array, must match condition-values length`}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateDropdownToken(runtime); err != nil { + return err + } + if _, err := validateRangesFlag(runtime); err != nil { + return err + } + _, err := buildDropdownBody(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateDropdownToken(runtime) + ranges, _ := parseJSONStringArray("ranges", runtime.Str("ranges")) + dv, _ := buildDropdownBody(runtime) + return common.NewDryRunAPI(). + PUT("/open-apis/sheets/v2/spreadsheets/:token/dataValidation/:sheet_id"). + Body(map[string]interface{}{ + "ranges": ranges, + "dataValidationType": "list", + "dataValidation": dv, + }). + Set("token", token).Set("sheet_id", runtime.Str("sheet-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateDropdownToken(runtime) + ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges")) + if err != nil { + return err + } + dv, err := buildDropdownBody(runtime) + if err != nil { + return err + } + + data, err := runtime.CallAPI("PUT", dataValidationSheetPath(token, runtime.Str("sheet-id")), nil, + map[string]interface{}{ + "ranges": ranges, + "dataValidationType": "list", + "dataValidation": dv, + }, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +// SheetGetDropdown queries dropdown list settings for a range. +var SheetGetDropdown = common.Shortcut{ + Service: "sheets", + Command: "+get-dropdown", + Description: "Get dropdown list settings for a range", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "range", Desc: "cell range (!A2:A100)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateDropdownToken(runtime); err != nil { + return err + } + if _, _, ok := splitSheetRange(runtime.Str("range")); !ok { + return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. !A2:A100)") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateDropdownToken(runtime) + return common.NewDryRunAPI(). + GET("/open-apis/sheets/v2/spreadsheets/:token/dataValidation?range=:range&dataValidationType=list"). + Set("token", token).Set("range", runtime.Str("range")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateDropdownToken(runtime) + data, err := runtime.CallAPI("GET", dataValidationBasePath(token), + map[string]interface{}{ + "range": runtime.Str("range"), + "dataValidationType": "list", + }, nil, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +// SheetDeleteDropdown deletes dropdown list settings from given ranges. +var SheetDeleteDropdown = common.Shortcut{ + Service: "sheets", + Command: "+delete-dropdown", + Description: "Delete dropdown list from cell ranges", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "ranges", Desc: `ranges as JSON array (e.g. '["sheetId!A2:A100"]'), max 100 ranges`, Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateDropdownToken(runtime); err != nil { + return err + } + _, err := validateRangesFlag(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateDropdownToken(runtime) + ranges, _ := parseJSONStringArray("ranges", runtime.Str("ranges")) + dvRanges := make([]interface{}, 0, len(ranges)) + for _, r := range ranges { + dvRanges = append(dvRanges, map[string]interface{}{"range": r}) + } + return common.NewDryRunAPI(). + DELETE("/open-apis/sheets/v2/spreadsheets/:token/dataValidation"). + Body(map[string]interface{}{ + "dataValidationRanges": dvRanges, + }). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateDropdownToken(runtime) + ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges")) + if err != nil { + return err + } + + dvRanges := make([]interface{}, 0, len(ranges)) + for _, r := range ranges { + dvRanges = append(dvRanges, map[string]interface{}{"range": r}) + } + + data, err := runtime.CallAPI("DELETE", dataValidationBasePath(token), nil, + map[string]interface{}{ + "dataValidationRanges": dvRanges, + }, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/sheets/backward/lark_sheets_filter_views.go b/shortcuts/sheets/backward/lark_sheets_filter_views.go new file mode 100644 index 000000000..b76a473f3 --- /dev/null +++ b/shortcuts/sheets/backward/lark_sheets_filter_views.go @@ -0,0 +1,489 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +func filterViewBasePath(token, sheetID string) string { + return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/filter_views", + validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)) +} + +func filterViewItemPath(token, sheetID, filterViewID string) string { + return fmt.Sprintf("%s/%s", filterViewBasePath(token, sheetID), validate.EncodePathSegment(filterViewID)) +} + +func filterViewConditionBasePath(token, sheetID, filterViewID string) string { + return fmt.Sprintf("%s/conditions", filterViewItemPath(token, sheetID, filterViewID)) +} + +func filterViewConditionItemPath(token, sheetID, filterViewID, conditionID string) string { + return fmt.Sprintf("%s/%s", filterViewConditionBasePath(token, sheetID, filterViewID), validate.EncodePathSegment(conditionID)) +} + +func validateFilterViewToken(runtime *common.RuntimeContext) (string, error) { + return validateSheetManageToken(runtime) +} + +func hasNonEmptyStringFlag(runtime *common.RuntimeContext, name string) bool { + return runtime.Cmd.Flags().Changed(name) && strings.TrimSpace(runtime.Str(name)) != "" +} + +var SheetCreateFilterView = common.Shortcut{ + Service: "sheets", + Command: "+create-filter-view", + Description: "Create a filter view", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "range", Desc: "filter range (e.g. sheetId!A1:H14)", Required: true}, + {Name: "filter-view-name", Desc: "display name (max 100 chars)"}, + {Name: "filter-view-id", Desc: "custom 10-char alphanumeric ID (auto-generated if omitted)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateFilterViewToken(runtime); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return common.FlagErrorf("--range must not be empty") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFilterViewToken(runtime) + body := map[string]interface{}{"range": runtime.Str("range")} + if s := runtime.Str("filter-view-name"); s != "" { + body["filter_view_name"] = s + } + if s := runtime.Str("filter-view-id"); s != "" { + body["filter_view_id"] = s + } + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views"). + Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFilterViewToken(runtime) + body := map[string]interface{}{"range": runtime.Str("range")} + if s := runtime.Str("filter-view-name"); s != "" { + body["filter_view_name"] = s + } + if s := runtime.Str("filter-view-id"); s != "" { + body["filter_view_id"] = s + } + data, err := runtime.CallAPI("POST", filterViewBasePath(token, runtime.Str("sheet-id")), nil, body) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetUpdateFilterView = common.Shortcut{ + Service: "sheets", + Command: "+update-filter-view", + Description: "Update a filter view", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "filter-view-id", Desc: "filter view ID", Required: true}, + {Name: "range", Desc: "new filter range"}, + {Name: "filter-view-name", Desc: "new display name (max 100 chars)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateFilterViewToken(runtime); err != nil { + return err + } + if !hasNonEmptyStringFlag(runtime, "range") && + !hasNonEmptyStringFlag(runtime, "filter-view-name") { + return common.FlagErrorf("specify at least one of --range or --filter-view-name") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFilterViewToken(runtime) + body := map[string]interface{}{} + if s := runtime.Str("range"); s != "" { + body["range"] = s + } + if s := runtime.Str("filter-view-name"); s != "" { + body["filter_view_name"] = s + } + return common.NewDryRunAPI(). + PATCH("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id"). + Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFilterViewToken(runtime) + body := map[string]interface{}{} + if s := runtime.Str("range"); s != "" { + body["range"] = s + } + if s := runtime.Str("filter-view-name"); s != "" { + body["filter_view_name"] = s + } + data, err := runtime.CallAPI("PATCH", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetListFilterViews = common.Shortcut{ + Service: "sheets", + Command: "+list-filter-views", + Description: "List all filter views in a sheet", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := validateFilterViewToken(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFilterViewToken(runtime) + return common.NewDryRunAPI(). + GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/query"). + Set("token", token).Set("sheet_id", runtime.Str("sheet-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFilterViewToken(runtime) + data, err := runtime.CallAPI("GET", filterViewBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetGetFilterView = common.Shortcut{ + Service: "sheets", + Command: "+get-filter-view", + Description: "Get a filter view by ID", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "filter-view-id", Desc: "filter view ID", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := validateFilterViewToken(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFilterViewToken(runtime) + return common.NewDryRunAPI(). + GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id"). + Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFilterViewToken(runtime) + data, err := runtime.CallAPI("GET", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetDeleteFilterView = common.Shortcut{ + Service: "sheets", + Command: "+delete-filter-view", + Description: "Delete a filter view", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "filter-view-id", Desc: "filter view ID", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := validateFilterViewToken(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFilterViewToken(runtime) + return common.NewDryRunAPI(). + DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id"). + Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFilterViewToken(runtime) + data, err := runtime.CallAPI("DELETE", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetCreateFilterViewCondition = common.Shortcut{ + Service: "sheets", + Command: "+create-filter-view-condition", + Description: "Create a filter condition on a filter view", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "filter-view-id", Desc: "filter view ID", Required: true}, + {Name: "condition-id", Desc: "column letter (e.g. E)", Required: true}, + {Name: "filter-type", Desc: "filter type: hiddenValue, number, text, color", Required: true}, + {Name: "compare-type", Desc: "comparison operator (e.g. less, beginsWith, between)"}, + {Name: "expected", Desc: "filter values JSON array (e.g. [\"6\"])", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateFilterViewToken(runtime); err != nil { + return err + } + return validateExpectedFlag(runtime.Str("expected")) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFilterViewToken(runtime) + body := buildConditionBody(runtime, true) + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions"). + Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFilterViewToken(runtime) + body := buildConditionBody(runtime, true) + data, err := runtime.CallAPI("POST", filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetUpdateFilterViewCondition = common.Shortcut{ + Service: "sheets", + Command: "+update-filter-view-condition", + Description: "Update a filter condition on a filter view", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "filter-view-id", Desc: "filter view ID", Required: true}, + {Name: "condition-id", Desc: "column letter (e.g. E)", Required: true}, + {Name: "filter-type", Desc: "filter type: hiddenValue, number, text, color"}, + {Name: "compare-type", Desc: "comparison operator"}, + {Name: "expected", Desc: "filter values JSON array"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateFilterViewToken(runtime); err != nil { + return err + } + if !hasNonEmptyStringFlag(runtime, "filter-type") && + !hasNonEmptyStringFlag(runtime, "compare-type") && + !hasNonEmptyStringFlag(runtime, "expected") { + return common.FlagErrorf("specify at least one of --filter-type, --compare-type, or --expected") + } + if s := runtime.Str("expected"); s != "" { + return validateExpectedFlag(s) + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFilterViewToken(runtime) + body := buildConditionBody(runtime, false) + return common.NewDryRunAPI(). + PUT("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/:condition_id"). + Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")). + Set("filter_view_id", runtime.Str("filter-view-id")).Set("condition_id", runtime.Str("condition-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFilterViewToken(runtime) + body := buildConditionBody(runtime, false) + data, err := runtime.CallAPI("PUT", + filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")), + nil, body) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetListFilterViewConditions = common.Shortcut{ + Service: "sheets", + Command: "+list-filter-view-conditions", + Description: "List all filter conditions of a filter view", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "filter-view-id", Desc: "filter view ID", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := validateFilterViewToken(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFilterViewToken(runtime) + return common.NewDryRunAPI(). + GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/query"). + Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFilterViewToken(runtime) + data, err := runtime.CallAPI("GET", + filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"))+"/query", + nil, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetGetFilterViewCondition = common.Shortcut{ + Service: "sheets", + Command: "+get-filter-view-condition", + Description: "Get a filter condition by column", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "filter-view-id", Desc: "filter view ID", Required: true}, + {Name: "condition-id", Desc: "column letter (e.g. E)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := validateFilterViewToken(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFilterViewToken(runtime) + return common.NewDryRunAPI(). + GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/:condition_id"). + Set("token", token).Set("sheet_id", runtime.Str("sheet-id")). + Set("filter_view_id", runtime.Str("filter-view-id")).Set("condition_id", runtime.Str("condition-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFilterViewToken(runtime) + data, err := runtime.CallAPI("GET", + filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")), + nil, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetDeleteFilterViewCondition = common.Shortcut{ + Service: "sheets", + Command: "+delete-filter-view-condition", + Description: "Delete a filter condition from a filter view", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "filter-view-id", Desc: "filter view ID", Required: true}, + {Name: "condition-id", Desc: "column letter (e.g. E)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := validateFilterViewToken(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFilterViewToken(runtime) + return common.NewDryRunAPI(). + DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/:condition_id"). + Set("token", token).Set("sheet_id", runtime.Str("sheet-id")). + Set("filter_view_id", runtime.Str("filter-view-id")).Set("condition_id", runtime.Str("condition-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFilterViewToken(runtime) + data, err := runtime.CallAPI("DELETE", + filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")), + nil, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +func validateExpectedFlag(s string) error { + if s == "" { + return nil + } + var arr []interface{} + if err := json.Unmarshal([]byte(s), &arr); err != nil { + return output.ErrValidation("--expected must be a JSON array (e.g. [\"6\"]), got: %s", s) + } + return nil +} + +func buildConditionBody(runtime *common.RuntimeContext, includeConditionID bool) map[string]interface{} { + body := map[string]interface{}{} + if includeConditionID { + body["condition_id"] = runtime.Str("condition-id") + } + if s := runtime.Str("filter-type"); s != "" { + body["filter_type"] = s + } + if s := runtime.Str("compare-type"); s != "" { + body["compare_type"] = s + } + if s := runtime.Str("expected"); s != "" { + var arr []interface{} + _ = json.Unmarshal([]byte(s), &arr) + body["expected"] = arr + } + return body +} diff --git a/shortcuts/sheets/backward/lark_sheets_float_images.go b/shortcuts/sheets/backward/lark_sheets_float_images.go new file mode 100644 index 000000000..a0b7bf490 --- /dev/null +++ b/shortcuts/sheets/backward/lark_sheets_float_images.go @@ -0,0 +1,464 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const sheetImageParentType = "sheet_image" + +var SheetMediaUpload = common.Shortcut{ + Service: "sheets", + Command: "+media-upload", + Description: "Upload a local image for use as a floating image and return the file_token", + Risk: "write", + Scopes: []string{"docs:document.media:upload"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "file", Desc: "local image path (files > 20MB use multipart upload automatically)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSheetMediaUploadParent(runtime); err != nil { + return err + } + _, _, err := validateSheetMediaUploadFile(runtime, runtime.Str("file")) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + parentNode, err := resolveSheetMediaUploadParent(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + filePath := runtime.Str("file") + fileName := filepath.Base(filePath) + + dry := common.NewDryRunAPI() + if sheetMediaShouldUseMultipart(runtime.FileIO(), filePath) { + dry.Desc("chunked media upload (files > 20MB)"). + POST("/open-apis/drive/v1/medias/upload_prepare"). + Body(map[string]interface{}{ + "file_name": fileName, + "parent_type": sheetImageParentType, + "parent_node": parentNode, + "size": "", + }). + POST("/open-apis/drive/v1/medias/upload_part"). + Body(map[string]interface{}{ + "upload_id": "", + "seq": "", + "size": "", + "file": "", + }). + POST("/open-apis/drive/v1/medias/upload_finish"). + Body(map[string]interface{}{ + "upload_id": "", + "block_num": "", + }) + return dry.Set("spreadsheet_token", parentNode) + } + return dry.Desc("multipart/form-data upload"). + POST("/open-apis/drive/v1/medias/upload_all"). + Body(map[string]interface{}{ + "file_name": fileName, + "parent_type": sheetImageParentType, + "parent_node": parentNode, + "size": "", + "file": "@" + filePath, + }). + Set("spreadsheet_token", parentNode) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + parentNode, err := resolveSheetMediaUploadParent(runtime) + if err != nil { + return err + } + filePath := runtime.Str("file") + + safePath, stat, err := validateSheetMediaUploadFile(runtime, filePath) + if err != nil { + return err + } + + fileName := filepath.Base(safePath) + fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s) -> spreadsheet %s\n", + fileName, common.FormatSize(stat.Size()), common.MaskToken(parentNode)) + if stat.Size() > common.MaxDriveMediaUploadSinglePartSize { + fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n") + } + + fileToken, err := uploadSheetMediaFile(runtime, safePath, fileName, stat.Size(), parentNode) + if err != nil { + return err + } + + runtime.Out(map[string]interface{}{ + "file_token": fileToken, + "file_name": fileName, + "size": stat.Size(), + "spreadsheet_token": parentNode, + }, nil) + return nil + }, +} + +func validateSheetMediaUploadFile(runtime *common.RuntimeContext, filePath string) (string, fileio.FileInfo, error) { + stat, err := runtime.FileIO().Stat(filePath) + if err != nil { + return "", nil, common.WrapInputStatError(err, "file not found") + } + if !stat.Mode().IsRegular() { + return "", nil, output.ErrValidation("file must be a regular file: %s", filePath) + } + return filePath, stat, nil +} + +func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, error) { + token := runtime.Str("spreadsheet-token") + if u := runtime.Str("url"); u != "" { + if parsed := extractSpreadsheetToken(u); parsed != "" { + token = parsed + } + } + if token == "" { + return "", common.FlagErrorf("specify --url or --spreadsheet-token") + } + return token, nil +} + +func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, parentNode string) (string, error) { + if fileSize <= common.MaxDriveMediaUploadSinglePartSize { + pn := parentNode + return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ + FilePath: filePath, + FileName: fileName, + FileSize: fileSize, + ParentType: sheetImageParentType, + ParentNode: &pn, + }) + } + return common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{ + FilePath: filePath, + FileName: fileName, + FileSize: fileSize, + ParentType: sheetImageParentType, + ParentNode: parentNode, + }) +} + +func sheetMediaShouldUseMultipart(fio fileio.FileIO, filePath string) bool { + info, err := fio.Stat(filePath) + if err != nil { + return false + } + return info.Mode().IsRegular() && info.Size() > common.MaxDriveMediaUploadSinglePartSize +} + +func floatImageBasePath(token, sheetID string) string { + return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/float_images", + validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)) +} + +func floatImageItemPath(token, sheetID, floatImageID string) string { + return fmt.Sprintf("%s/%s", floatImageBasePath(token, sheetID), validate.EncodePathSegment(floatImageID)) +} + +func validateFloatImageToken(runtime *common.RuntimeContext) (string, error) { + token := runtime.Str("spreadsheet-token") + if u := runtime.Str("url"); u != "" { + if parsed := extractSpreadsheetToken(u); parsed != u { + token = parsed + } + } + if token == "" { + return "", common.FlagErrorf("specify --url or --spreadsheet-token") + } + return token, nil +} + +func validateFloatImageRange(sheetID, rangeVal string) error { + if rangeVal == "" { + return nil + } + if err := validateSingleCellRange(rangeVal); err != nil { + return err + } + if prefix, _, ok := splitSheetRange(rangeVal); ok && sheetID != "" && prefix != sheetID { + return common.FlagErrorf("--range prefix %q does not match --sheet-id %q", prefix, sheetID) + } + return nil +} + +func validateFloatImageUpdatePayload(runtime *common.RuntimeContext) error { + hasField := runtime.Str("range") != "" || + runtime.Cmd.Flags().Changed("width") || + runtime.Cmd.Flags().Changed("height") || + runtime.Cmd.Flags().Changed("offset-x") || + runtime.Cmd.Flags().Changed("offset-y") + if !hasField { + return common.FlagErrorf("specify at least one of --range, --width, --height, --offset-x, --offset-y to update") + } + return nil +} + +func validateFloatImageDims(runtime *common.RuntimeContext) error { + if runtime.Cmd.Flags().Changed("width") { + if v := runtime.Int("width"); v < 20 { + return common.FlagErrorf("--width must be >= 20 pixels, got %d", v) + } + } + if runtime.Cmd.Flags().Changed("height") { + if v := runtime.Int("height"); v < 20 { + return common.FlagErrorf("--height must be >= 20 pixels, got %d", v) + } + } + if runtime.Cmd.Flags().Changed("offset-x") { + if v := runtime.Int("offset-x"); v < 0 { + return common.FlagErrorf("--offset-x must be >= 0, got %d", v) + } + } + if runtime.Cmd.Flags().Changed("offset-y") { + if v := runtime.Int("offset-y"); v < 0 { + return common.FlagErrorf("--offset-y must be >= 0, got %d", v) + } + } + return nil +} + +func buildFloatImageBody(runtime *common.RuntimeContext, includeToken bool) map[string]interface{} { + body := map[string]interface{}{} + if includeToken { + if s := runtime.Str("float-image-token"); s != "" { + body["float_image_token"] = s + } + } + if s := runtime.Str("range"); s != "" { + body["range"] = s + } + if runtime.Cmd.Flags().Changed("width") { + body["width"] = runtime.Int("width") + } + if runtime.Cmd.Flags().Changed("height") { + body["height"] = runtime.Int("height") + } + if runtime.Cmd.Flags().Changed("offset-x") { + body["offset_x"] = runtime.Int("offset-x") + } + if runtime.Cmd.Flags().Changed("offset-y") { + body["offset_y"] = runtime.Int("offset-y") + } + return body +} + +var SheetCreateFloatImage = common.Shortcut{ + Service: "sheets", + Command: "+create-float-image", + Description: "Create a floating image on a sheet", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "float-image-token", Desc: "image file token (from upload API)", Required: true}, + {Name: "range", Desc: "anchor cell, must be a single cell (e.g. sheetId!A1:A1)", Required: true}, + {Name: "width", Type: "int", Desc: "width in pixels (>=20)"}, + {Name: "height", Type: "int", Desc: "height in pixels (>=20)"}, + {Name: "offset-x", Type: "int", Desc: "horizontal offset from anchor cell's top-left (pixels, >=0)"}, + {Name: "offset-y", Type: "int", Desc: "vertical offset from anchor cell's top-left (pixels, >=0)"}, + {Name: "float-image-id", Desc: "custom 10-char alphanumeric ID (auto-generated if omitted)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateFloatImageToken(runtime); err != nil { + return err + } + if err := validateFloatImageRange(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { + return err + } + return validateFloatImageDims(runtime) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFloatImageToken(runtime) + body := buildFloatImageBody(runtime, true) + if s := runtime.Str("float-image-id"); s != "" { + body["float_image_id"] = s + } + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images"). + Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFloatImageToken(runtime) + body := buildFloatImageBody(runtime, true) + if s := runtime.Str("float-image-id"); s != "" { + body["float_image_id"] = s + } + data, err := runtime.CallAPI("POST", floatImageBasePath(token, runtime.Str("sheet-id")), nil, body) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetUpdateFloatImage = common.Shortcut{ + Service: "sheets", + Command: "+update-float-image", + Description: "Update a floating image", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "float-image-id", Desc: "float image ID", Required: true}, + {Name: "range", Desc: "new anchor cell, must be a single cell (e.g. sheetId!B2:B2)"}, + {Name: "width", Type: "int", Desc: "width in pixels (>=20)"}, + {Name: "height", Type: "int", Desc: "height in pixels (>=20)"}, + {Name: "offset-x", Type: "int", Desc: "horizontal offset from anchor cell's top-left (pixels, >=0)"}, + {Name: "offset-y", Type: "int", Desc: "vertical offset from anchor cell's top-left (pixels, >=0)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateFloatImageToken(runtime); err != nil { + return err + } + if err := validateFloatImageUpdatePayload(runtime); err != nil { + return err + } + if err := validateFloatImageRange(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { + return err + } + return validateFloatImageDims(runtime) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFloatImageToken(runtime) + body := buildFloatImageBody(runtime, false) + return common.NewDryRunAPI(). + PATCH("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id"). + Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFloatImageToken(runtime) + body := buildFloatImageBody(runtime, false) + data, err := runtime.CallAPI("PATCH", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, body) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetGetFloatImage = common.Shortcut{ + Service: "sheets", + Command: "+get-float-image", + Description: "Get a floating image by ID", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "float-image-id", Desc: "float image ID", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := validateFloatImageToken(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFloatImageToken(runtime) + return common.NewDryRunAPI(). + GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id"). + Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFloatImageToken(runtime) + data, err := runtime.CallAPI("GET", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetListFloatImages = common.Shortcut{ + Service: "sheets", + Command: "+list-float-images", + Description: "List all floating images in a sheet", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := validateFloatImageToken(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFloatImageToken(runtime) + return common.NewDryRunAPI(). + GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/query"). + Set("token", token).Set("sheet_id", runtime.Str("sheet-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFloatImageToken(runtime) + data, err := runtime.CallAPI("GET", floatImageBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetDeleteFloatImage = common.Shortcut{ + Service: "sheets", + Command: "+delete-float-image", + Description: "Delete a floating image", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "float-image-id", Desc: "float image ID", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := validateFloatImageToken(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateFloatImageToken(runtime) + return common.NewDryRunAPI(). + DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id"). + Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateFloatImageToken(runtime) + data, err := runtime.CallAPI("DELETE", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/sheets/backward/lark_sheets_row_column_management.go b/shortcuts/sheets/backward/lark_sheets_row_column_management.go new file mode 100644 index 000000000..581c8eb0e --- /dev/null +++ b/shortcuts/sheets/backward/lark_sheets_row_column_management.go @@ -0,0 +1,369 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import ( + "context" + "fmt" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var SheetAddDimension = common.Shortcut{ + Service: "sheets", + Command: "+add-dimension", + Description: "Add rows or columns at the end of a sheet", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "worksheet ID", Required: true}, + {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, + {Name: "length", Type: "int", Desc: "number of rows/columns to add (1-5000)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + length := runtime.Int("length") + if length < 1 || length > 5000 { + return common.FlagErrorf("--length must be between 1 and 5000, got %d", length) + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v2/spreadsheets/:token/dimension_range"). + Body(map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "length": runtime.Int("length"), + }, + }). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + + data, err := runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)), + nil, + map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "length": runtime.Int("length"), + }, + }, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetInsertDimension = common.Shortcut{ + Service: "sheets", + Command: "+insert-dimension", + Description: "Insert rows or columns at a specified position", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "worksheet ID", Required: true}, + {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, + {Name: "start-index", Type: "int", Desc: "start position (0-indexed)", Required: true}, + {Name: "end-index", Type: "int", Desc: "end position (0-indexed, exclusive)", Required: true}, + {Name: "inherit-style", Desc: "style inheritance: BEFORE or AFTER", Enum: []string{"BEFORE", "AFTER"}}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + if runtime.Int("start-index") < 0 { + return common.FlagErrorf("--start-index must be >= 0") + } + if runtime.Int("end-index") <= runtime.Int("start-index") { + return common.FlagErrorf("--end-index must be greater than --start-index") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + body := map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "startIndex": runtime.Int("start-index"), + "endIndex": runtime.Int("end-index"), + }, + } + if s := runtime.Str("inherit-style"); s != "" { + body["inheritStyle"] = s + } + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v2/spreadsheets/:token/insert_dimension_range"). + Body(body). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + + body := map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "startIndex": runtime.Int("start-index"), + "endIndex": runtime.Int("end-index"), + }, + } + if s := runtime.Str("inherit-style"); s != "" { + body["inheritStyle"] = s + } + + data, err := runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/insert_dimension_range", validate.EncodePathSegment(token)), + nil, body, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetUpdateDimension = common.Shortcut{ + Service: "sheets", + Command: "+update-dimension", + Description: "Update row or column properties (visibility, size)", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "worksheet ID", Required: true}, + {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, + {Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true}, + {Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true}, + {Name: "visible", Type: "bool", Desc: "true to show, false to hide"}, + {Name: "fixed-size", Type: "int", Desc: "row height or column width in pixels"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + if runtime.Int("start-index") < 1 { + return common.FlagErrorf("--start-index must be >= 1") + } + if runtime.Int("end-index") < runtime.Int("start-index") { + return common.FlagErrorf("--end-index must be >= --start-index") + } + if !runtime.Cmd.Flags().Changed("visible") && !runtime.Cmd.Flags().Changed("fixed-size") { + return common.FlagErrorf("specify at least one of --visible or --fixed-size") + } + if runtime.Cmd.Flags().Changed("fixed-size") && runtime.Int("fixed-size") < 1 { + return common.FlagErrorf("--fixed-size must be >= 1") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + props := map[string]interface{}{} + if runtime.Cmd.Flags().Changed("visible") { + props["visible"] = runtime.Bool("visible") + } + if runtime.Cmd.Flags().Changed("fixed-size") { + props["fixedSize"] = runtime.Int("fixed-size") + } + return common.NewDryRunAPI(). + PUT("/open-apis/sheets/v2/spreadsheets/:token/dimension_range"). + Body(map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "startIndex": runtime.Int("start-index"), + "endIndex": runtime.Int("end-index"), + }, + "dimensionProperties": props, + }). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + + props := map[string]interface{}{} + if runtime.Cmd.Flags().Changed("visible") { + props["visible"] = runtime.Bool("visible") + } + if runtime.Cmd.Flags().Changed("fixed-size") { + props["fixedSize"] = runtime.Int("fixed-size") + } + + data, err := runtime.CallAPI("PUT", + fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)), + nil, + map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "startIndex": runtime.Int("start-index"), + "endIndex": runtime.Int("end-index"), + }, + "dimensionProperties": props, + }, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetMoveDimension = common.Shortcut{ + Service: "sheets", + Command: "+move-dimension", + Description: "Move rows or columns to a new position", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "worksheet ID", Required: true}, + {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, + {Name: "start-index", Type: "int", Desc: "source start position (0-indexed)", Required: true}, + {Name: "end-index", Type: "int", Desc: "source end position (0-indexed, inclusive)", Required: true}, + {Name: "destination-index", Type: "int", Desc: "target position to move to (0-indexed)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + if runtime.Int("start-index") < 0 { + return common.FlagErrorf("--start-index must be >= 0") + } + if runtime.Int("end-index") < runtime.Int("start-index") { + return common.FlagErrorf("--end-index must be >= --start-index") + } + if runtime.Int("destination-index") < 0 { + return common.FlagErrorf("--destination-index must be >= 0") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/move_dimension"). + Body(map[string]interface{}{ + "source": map[string]interface{}{ + "major_dimension": runtime.Str("dimension"), + "start_index": runtime.Int("start-index"), + "end_index": runtime.Int("end-index"), + }, + "destination_index": runtime.Int("destination-index"), + }). + Set("token", token). + Set("sheet_id", runtime.Str("sheet-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + + data, err := runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/move_dimension", + validate.EncodePathSegment(token), + validate.EncodePathSegment(runtime.Str("sheet-id")), + ), + nil, + map[string]interface{}{ + "source": map[string]interface{}{ + "major_dimension": runtime.Str("dimension"), + "start_index": runtime.Int("start-index"), + "end_index": runtime.Int("end-index"), + }, + "destination_index": runtime.Int("destination-index"), + }, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetDeleteDimension = common.Shortcut{ + Service: "sheets", + Command: "+delete-dimension", + Description: "Delete rows or columns", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "worksheet ID", Required: true}, + {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, + {Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true}, + {Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + if runtime.Int("start-index") < 1 { + return common.FlagErrorf("--start-index must be >= 1") + } + if runtime.Int("end-index") < runtime.Int("start-index") { + return common.FlagErrorf("--end-index must be >= --start-index") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + return common.NewDryRunAPI(). + DELETE("/open-apis/sheets/v2/spreadsheets/:token/dimension_range"). + Body(map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "startIndex": runtime.Int("start-index"), + "endIndex": runtime.Int("end-index"), + }, + }). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + + data, err := runtime.CallAPI("DELETE", + fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)), + nil, + map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "startIndex": runtime.Int("start-index"), + "endIndex": runtime.Int("end-index"), + }, + }, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_cell_ops_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_cell_ops_test.go new file mode 100644 index 000000000..c0dd5b070 --- /dev/null +++ b/shortcuts/sheets/backward/lark_sheets_sheet_cell_ops_test.go @@ -0,0 +1,781 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +// ── MergeCells ─────────────────────────────────────────────────────────────── + +func TestSheetMergeCellsValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "", "merge-type": "MERGE_ALL", + }, nil) + err := SheetMergeCells.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestSheetMergeCellsValidateRelativeRangeWithoutSheetID(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "range": "A1:B2", "sheet-id": "", "merge-type": "MERGE_ALL", + }, nil) + err := SheetMergeCells.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--sheet-id") { + t.Fatalf("expected sheet-id error, got: %v", err) + } +} + +func TestSheetMergeCellsValidateSuccess(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "", "merge-type": "MERGE_ROWS", + }, nil) + if err := SheetMergeCells.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetMergeCellsDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "range": "A1:B2", "sheet-id": "sheet1", "merge-type": "MERGE_ALL", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetMergeCells.DryRun(context.Background(), rt)) + if !strings.Contains(got, `merge_cells`) { + t.Fatalf("DryRun URL missing merge_cells: %s", got) + } + if !strings.Contains(got, `"range":"sheet1!A1:B2"`) { + t.Fatalf("DryRun range not normalized: %s", got) + } + if !strings.Contains(got, `"mergeType":"MERGE_ALL"`) { + t.Fatalf("DryRun missing mergeType: %s", got) + } +} + +func TestSheetMergeCellsExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/merge_cells", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"spreadsheetToken": "shtTOKEN"}}, + }) + err := mountAndRunSheets(t, SheetMergeCells, []string{ + "+merge-cells", "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:B2", "--merge-type", "MERGE_ALL", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "spreadsheetToken") { + t.Fatalf("stdout missing spreadsheetToken: %s", stdout.String()) + } +} + +func TestSheetMergeCellsExecuteAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/merge_cells", + Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"}, + }) + err := mountAndRunSheets(t, SheetMergeCells, []string{ + "+merge-cells", "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:B2", "--merge-type", "MERGE_ALL", "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected error") + } +} + +// ── UnmergeCells ───────────────────────────────────────────────────────────── + +func TestSheetUnmergeCellsValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "", + }, nil) + err := SheetUnmergeCells.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestSheetUnmergeCellsValidateSuccess(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "", + }, nil) + if err := SheetUnmergeCells.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetUnmergeCellsDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "range": "sheet1!A1:B2", "sheet-id": "", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetUnmergeCells.DryRun(context.Background(), rt)) + if !strings.Contains(got, `unmerge_cells`) { + t.Fatalf("DryRun URL missing unmerge_cells: %s", got) + } + if !strings.Contains(got, `"range":"sheet1!A1:B2"`) { + t.Fatalf("DryRun missing range: %s", got) + } +} + +func TestSheetUnmergeCellsExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/unmerge_cells", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"spreadsheetToken": "shtTOKEN"}}, + }) + err := mountAndRunSheets(t, SheetUnmergeCells, []string{ + "+unmerge-cells", "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:B2", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetUnmergeCellsExecuteAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/unmerge_cells", + Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"}, + }) + err := mountAndRunSheets(t, SheetUnmergeCells, []string{ + "+unmerge-cells", "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:B2", "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected error") + } +} + +// ── Replace ────────────────────────────────────────────────────────────────── + +func TestSheetReplaceValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "", "sheet-id": "s1", "find": "a", "replacement": "b", "range": "", + }, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false}) + err := SheetReplace.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestSheetReplaceValidateSuccess(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "find": "hello", "replacement": "world", "range": "", + }, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false}) + if err := SheetReplace.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetReplaceValidateMismatchedRangeSheetID(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", "find": "a", "replacement": "b", + "range": "sheet2!A1:B2", + }, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false}) + err := SheetReplace.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "does not match") { + t.Fatalf("expected mismatch error, got: %v", err) + } +} + +func TestSheetReplaceValidateMatchingRangeSheetID(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", "find": "a", "replacement": "b", + "range": "sheet1!A1:B2", + }, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false}) + if err := SheetReplace.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetReplaceDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "find": "old", "replacement": "new", "range": "A1:C5", + }, map[string]bool{"match-case": true, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false}) + got := mustMarshalSheetsDryRun(t, SheetReplace.DryRun(context.Background(), rt)) + if !strings.Contains(got, `replace`) { + t.Fatalf("DryRun URL missing replace: %s", got) + } + if !strings.Contains(got, `"find":"old"`) { + t.Fatalf("DryRun missing find: %s", got) + } + if !strings.Contains(got, `"replacement":"new"`) { + t.Fatalf("DryRun missing replacement: %s", got) + } + if !strings.Contains(got, `"match_case":true`) { + t.Fatalf("DryRun missing match_case: %s", got) + } +} + +func TestSheetReplaceDryRunNoRange(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "find": "a", "replacement": "b", "range": "", + }, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false}) + got := mustMarshalSheetsDryRun(t, SheetReplace.DryRun(context.Background(), rt)) + // When no range specified, range defaults to sheet-id + if !strings.Contains(got, `"range":"sheet1"`) { + t.Fatalf("DryRun range should default to sheet-id: %s", got) + } +} + +func TestSheetReplaceExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/replace", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "replace_result": map[string]interface{}{ + "matched_cells": []interface{}{"A1"}, "rows_count": float64(1), + }, + }}, + } + reg.Register(stub) + err := mountAndRunSheets(t, SheetReplace, []string{ + "+replace", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--find", "hello", "--replacement", "world", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "matched_cells") { + t.Fatalf("stdout missing matched_cells: %s", stdout.String()) + } + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse body: %v", err) + } + if body["find"] != "hello" || body["replacement"] != "world" { + t.Fatalf("unexpected body: %#v", body) + } +} + +func TestSheetReplaceExecuteAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/replace", + Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"}, + }) + err := mountAndRunSheets(t, SheetReplace, []string{ + "+replace", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--find", "a", "--replacement", "b", "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected error") + } +} + +// ── SetStyle ───────────────────────────────────────────────────────────────── + +func TestSheetSetStyleValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "", + "style": `{"font":{"bold":true}}`, + }, nil) + err := SheetSetStyle.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestSheetSetStyleValidateInvalidJSON(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "", + "style": `{invalid}`, + }, nil) + err := SheetSetStyle.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--style must be valid JSON") { + t.Fatalf("expected JSON error, got: %v", err) + } +} + +func TestSheetSetStyleValidateRejectsArray(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "", + "style": `[{"bold":true}]`, + }, nil) + err := SheetSetStyle.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "JSON object") { + t.Fatalf("expected object error, got: %v", err) + } +} + +func TestSheetSetStyleValidateRejectsString(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "", + "style": `"bold"`, + }, nil) + err := SheetSetStyle.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "JSON object") { + t.Fatalf("expected object error, got: %v", err) + } +} + +func TestSheetSetStyleValidateRejectsNull(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "", + "style": `null`, + }, nil) + err := SheetSetStyle.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "JSON object") { + t.Fatalf("expected object error, got: %v", err) + } +} + +func TestSheetSetStyleValidateSuccess(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "", + "style": `{"font":{"bold":true},"backColor":"#ff0000"}`, + }, nil) + if err := SheetSetStyle.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetSetStyleDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "range": "A1:B2", "sheet-id": "sheet1", + "style": `{"font":{"bold":true}}`, + }, nil) + got := mustMarshalSheetsDryRun(t, SheetSetStyle.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"method":"PUT"`) { + t.Fatalf("DryRun should use PUT: %s", got) + } + if !strings.Contains(got, `/style`) { + t.Fatalf("DryRun URL missing /style: %s", got) + } + if !strings.Contains(got, `"range":"sheet1!A1:B2"`) { + t.Fatalf("DryRun range not normalized: %s", got) + } + if !strings.Contains(got, `"bold":true`) { + t.Fatalf("DryRun missing style: %s", got) + } +} + +func TestSheetSetStyleExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "updates": map[string]interface{}{"updatedCells": float64(4), "updatedRange": "sheet1!A1:B2"}, + }}, + } + reg.Register(stub) + err := mountAndRunSheets(t, SheetSetStyle, []string{ + "+set-style", "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:B2", "--style", `{"font":{"bold":true}}`, "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "updatedCells") { + t.Fatalf("stdout missing updatedCells: %s", stdout.String()) + } + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse body: %v", err) + } + appendStyle, _ := body["appendStyle"].(map[string]interface{}) + if appendStyle["range"] != "sheet1!A1:B2" { + t.Fatalf("unexpected range: %v", appendStyle["range"]) + } +} + +func TestSheetSetStyleDryRunExpandsSingleCell(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "range": "A1", "sheet-id": "sheet1", + "style": `{"font":{"bold":true}}`, + }, nil) + got := mustMarshalSheetsDryRun(t, SheetSetStyle.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"range":"sheet1!A1:A1"`) { + t.Fatalf("DryRun should expand single cell to A1:A1: %s", got) + } +} + +func TestSheetSetStyleExecuteExpandsSingleCell(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "updates": map[string]interface{}{"updatedCells": float64(1), "updatedRange": "sheet1!A1:A1"}, + }}, + } + reg.Register(stub) + err := mountAndRunSheets(t, SheetSetStyle, []string{ + "+set-style", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--range", "A1", + "--style", `{"font":{"bold":true}}`, "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse body: %v", err) + } + appendStyle, _ := body["appendStyle"].(map[string]interface{}) + if appendStyle["range"] != "sheet1!A1:A1" { + t.Fatalf("single cell should be expanded to sheet1!A1:A1, got: %v", appendStyle["range"]) + } +} + +func TestSheetSetStyleExecuteAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style", + Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"}, + }) + err := mountAndRunSheets(t, SheetSetStyle, []string{ + "+set-style", "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:B2", "--style", `{"font":{"bold":true}}`, "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected error") + } +} + +// ── BatchSetStyle ──────────────────────────────────────────────────────────── + +func TestSheetBatchSetStyleValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "", + "data": `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`, + }, nil) + err := SheetBatchSetStyle.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestSheetBatchSetStyleValidateInvalidJSON(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "data": `not-json`, + }, nil) + err := SheetBatchSetStyle.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--data must be valid JSON") { + t.Fatalf("expected JSON error, got: %v", err) + } +} + +func TestSheetBatchSetStyleValidateNotArray(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "data": `{"not":"array"}`, + }, nil) + err := SheetBatchSetStyle.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "non-empty JSON array") { + t.Fatalf("expected array error, got: %v", err) + } +} + +func TestSheetBatchSetStyleValidateEmptyArray(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "data": `[]`, + }, nil) + err := SheetBatchSetStyle.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "non-empty JSON array") { + t.Fatalf("expected empty array error, got: %v", err) + } +} + +func TestSheetBatchSetStyleValidateSuccess(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", + "data": `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`, + }, nil) + if err := SheetBatchSetStyle.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetBatchSetStyleValidateRejectsMalformedEntries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data string + wantSubst string + }{ + { + name: "entry must be object", + data: `["bad"]`, + wantSubst: "must be an object with ranges and style", + }, + { + name: "ranges required", + data: `[{"style":{}}]`, + wantSubst: ".ranges is required", + }, + { + name: "ranges must be array", + data: `[{"ranges":"sheet1!A1","style":{}}]`, + wantSubst: ".ranges must be a non-empty array of strings", + }, + { + name: "ranges must not be empty", + data: `[{"ranges":[],"style":{}}]`, + wantSubst: ".ranges must be a non-empty array of strings", + }, + { + name: "range must include sheet prefix", + data: `[{"ranges":["A1"],"style":{}}]`, + wantSubst: "must include a sheetId! prefix", + }, + { + name: "style required", + data: `[{"ranges":["sheet1!A1:B2"]}]`, + wantSubst: ".style is required", + }, + { + name: "style must be object", + data: `[{"ranges":["sheet1!A1:B2"],"style":"bad"}]`, + wantSubst: ".style must be a JSON object", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "data": tt.data, + }, nil) + err := SheetBatchSetStyle.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), tt.wantSubst) { + t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err) + } + }) + } +} + +func TestSheetBatchSetStyleDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", + "data": `[{"ranges":["sheet1!A1:B2"],"style":{"backColor":"#ff0000"}}]`, + }, nil) + got := mustMarshalSheetsDryRun(t, SheetBatchSetStyle.DryRun(context.Background(), rt)) + if !strings.Contains(got, `styles_batch_update`) { + t.Fatalf("DryRun URL missing styles_batch_update: %s", got) + } + if !strings.Contains(got, `"method":"PUT"`) { + t.Fatalf("DryRun should use PUT: %s", got) + } +} + +func TestSheetBatchSetStyleExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "totalUpdatedCells": float64(4), "revision": float64(90), + }}, + }) + err := mountAndRunSheets(t, SheetBatchSetStyle, []string{ + "+batch-set-style", "--spreadsheet-token", "shtTOKEN", + "--data", `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`, "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "totalUpdatedCells") { + t.Fatalf("stdout missing totalUpdatedCells: %s", stdout.String()) + } +} + +func TestSheetBatchSetStyleDryRunExpandsSingleCells(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", + "data": `[{"ranges":["sheet1!A2","sheet1!B2"],"style":{"font":{"bold":true}}}]`, + }, nil) + got := mustMarshalSheetsDryRun(t, SheetBatchSetStyle.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"sheet1!A2:A2"`) || !strings.Contains(got, `"sheet1!B2:B2"`) { + t.Fatalf("DryRun should expand single cells to A2:A2 and B2:B2: %s", got) + } +} + +func TestSheetBatchSetStyleExecuteNormalizesMixedRanges(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "totalUpdatedCells": float64(5), + }}, + } + reg.Register(stub) + err := mountAndRunSheets(t, SheetBatchSetStyle, []string{ + "+batch-set-style", "--spreadsheet-token", "shtTOKEN", + "--data", `[{"ranges":["sheet1!C1:D2","sheet1!E3"],"style":{"font":{"italic":true}}}]`, + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse body: %v", err) + } + data, _ := body["data"].([]interface{}) + if len(data) != 1 { + t.Fatalf("expected 1 data entry, got %d", len(data)) + } + entry, _ := data[0].(map[string]interface{}) + ranges, _ := entry["ranges"].([]interface{}) + if len(ranges) != 2 || ranges[0] != "sheet1!C1:D2" || ranges[1] != "sheet1!E3:E3" { + t.Fatalf("ranges should preserve span and expand single cell, got: %v", ranges) + } +} + +func TestSheetBatchSetStyleExecuteAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update", + Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"}, + }) + err := mountAndRunSheets(t, SheetBatchSetStyle, []string{ + "+batch-set-style", "--spreadsheet-token", "shtTOKEN", + "--data", `[{"ranges":["sheet1!A1:B2"],"style":{}}]`, "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected error") + } +} + +func TestNormalizeBatchStyleRanges(t *testing.T) { + t.Parallel() + + t.Run("single cell with sheet prefix is expanded in place", func(t *testing.T) { + t.Parallel() + data := []interface{}{ + map[string]interface{}{ + "ranges": []interface{}{"sheet1!A1", "sheet1!B2"}, + "style": map[string]interface{}{"font": map[string]interface{}{"bold": true}}, + }, + } + normalizeBatchStyleRanges(data) + got := data[0].(map[string]interface{})["ranges"].([]interface{}) + if got[0] != "sheet1!A1:A1" || got[1] != "sheet1!B2:B2" { + t.Fatalf("want [sheet1!A1:A1 sheet1!B2:B2], got %v", got) + } + }) + + t.Run("multi-cell span passes through unchanged", func(t *testing.T) { + t.Parallel() + data := []interface{}{ + map[string]interface{}{ + "ranges": []interface{}{"sheet1!A1:B2"}, + }, + } + normalizeBatchStyleRanges(data) + got := data[0].(map[string]interface{})["ranges"].([]interface{}) + if got[0] != "sheet1!A1:B2" { + t.Fatalf("multi-cell span should be unchanged, got %v", got[0]) + } + }) + + t.Run("bare single cell without sheet prefix passes through", func(t *testing.T) { + t.Parallel() + // Without a sheetId! prefix there's no sheet context; entry is left + // alone and the backend will reject it. Documented in the helper. + data := []interface{}{ + map[string]interface{}{ + "ranges": []interface{}{"A1"}, + }, + } + normalizeBatchStyleRanges(data) + got := data[0].(map[string]interface{})["ranges"].([]interface{}) + if got[0] != "A1" { + t.Fatalf("bare single cell should pass through, got %v", got[0]) + } + }) + + t.Run("non-string entries are preserved", func(t *testing.T) { + t.Parallel() + data := []interface{}{ + map[string]interface{}{ + "ranges": []interface{}{"sheet1!A1", 42, nil, "sheet1!B2"}, + }, + } + normalizeBatchStyleRanges(data) + got := data[0].(map[string]interface{})["ranges"].([]interface{}) + if got[0] != "sheet1!A1:A1" { + t.Fatalf("first entry should be expanded, got %v", got[0]) + } + if got[1] != 42 { + t.Fatalf("int entry should be preserved, got %v", got[1]) + } + if got[2] != nil { + t.Fatalf("nil entry should be preserved, got %v", got[2]) + } + if got[3] != "sheet1!B2:B2" { + t.Fatalf("last entry should be expanded, got %v", got[3]) + } + }) + + t.Run("missing or non-array ranges key is skipped", func(t *testing.T) { + t.Parallel() + data := []interface{}{ + map[string]interface{}{ + "style": map[string]interface{}{"font": map[string]interface{}{"bold": true}}, + }, + map[string]interface{}{ + "ranges": "not-an-array", + }, + "not-a-map", + } + normalizeBatchStyleRanges(data) + if data[1].(map[string]interface{})["ranges"] != "not-an-array" { + t.Fatal("non-array ranges should be left alone") + } + }) + + t.Run("top-level non-array inputs do not panic", func(t *testing.T) { + t.Parallel() + // Any of these would panic if the helper didn't guard its type assertions. + normalizeBatchStyleRanges(nil) + normalizeBatchStyleRanges(map[string]interface{}{"foo": "bar"}) + normalizeBatchStyleRanges("string") + normalizeBatchStyleRanges(42) + }) +} diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_create_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_create_test.go new file mode 100644 index 000000000..cfd4781c4 --- /dev/null +++ b/shortcuts/sheets/backward/lark_sheets_sheet_create_test.go @@ -0,0 +1,391 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestSheetCreateBotAutoGrantSuccess(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "ou_current_user")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "spreadsheet": map[string]interface{}{ + "spreadsheet_token": "shtcn_new_sheet", + "url": "https://example.feishu.cn/sheets/shtcn_new_sheet", + }, + }, + }, + }) + + permStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/shtcn_new_sheet/members", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + } + reg.Register(permStub) + + err := runSheetCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "项目排期", + "--as", "bot", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeSheetCreateEnvelope(t, stdout) + grant, _ := data["permission_grant"].(map[string]interface{}) + if grant["status"] != common.PermissionGrantGranted { + t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted) + } + if grant["user_open_id"] != "ou_current_user" { + t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user") + } + if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new spreadsheet." { + t.Fatalf("permission_grant.message = %#v", grant["message"]) + } + + var body map[string]interface{} + if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil { + t.Fatalf("failed to parse permission request body: %v", err) + } + if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" { + t.Fatalf("unexpected permission request body: %#v", body) + } +} + +func TestSheetCreateUserSkipsPermissionGrantAugmentation(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "ou_current_user")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "spreadsheet": map[string]interface{}{ + "spreadsheet_token": "shtcn_new_sheet", + "url": "https://example.feishu.cn/sheets/shtcn_new_sheet", + }, + }, + }, + }) + + err := runSheetCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "项目排期", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeSheetCreateEnvelope(t, stdout) + if _, ok := data["permission_grant"]; ok { + t.Fatalf("did not expect permission_grant in user mode output: %#v", data) + } +} + +func TestSheetCreateFallbackURLWhenBackendOmitsIt(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "spreadsheet": map[string]interface{}{ + "spreadsheet_token": "shtcn_new_sheet", + // "url" deliberately omitted to exercise the fallback. + }, + }, + }, + }) + + err := runSheetCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "项目排期", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeSheetCreateEnvelope(t, stdout) + if got, want := data["url"], "https://www.feishu.cn/sheets/shtcn_new_sheet"; got != want { + t.Fatalf("url = %#v, want %q (brand-standard fallback)", got, want) + } +} + +func TestSheetCreateDryRunIncludesFolderToken(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{ + "title": "项目排期", + "folder-token": "fldcn123", + "headers": "", + "data": "", + }, + nil, nil) + got := mustMarshalSheetsDryRun(t, SheetCreate.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"folder_token":"fldcn123"`) { + t.Fatalf("DryRun should include folder_token, got: %s", got) + } +} + +func TestSheetCreatePreservesBackendURL(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "spreadsheet": map[string]interface{}{ + "spreadsheet_token": "shtcn_new_sheet", + "url": "https://tenant.larkoffice.com/sheets/shtcn_new_sheet", + }, + }, + }, + }) + + err := runSheetCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "项目排期", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeSheetCreateEnvelope(t, stdout) + if got, want := data["url"], "https://tenant.larkoffice.com/sheets/shtcn_new_sheet"; got != want { + t.Fatalf("url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want) + } +} + +func TestSheetCreateFallbackURLWhenBackendURLIsWhitespace(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "spreadsheet": map[string]interface{}{ + "spreadsheet_token": "shtcn_new_sheet", + "url": " ", // whitespace-only must trigger fallback, not pass through. + }, + }, + }, + }) + + err := runSheetCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "项目排期", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeSheetCreateEnvelope(t, stdout) + if got, want := data["url"], "https://www.feishu.cn/sheets/shtcn_new_sheet"; got != want { + t.Fatalf("url = %#v, want %q (whitespace-only backend URL must yield fallback)", got, want) + } +} + +func TestSheetCreateTrimsPaddedBackendURL(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "spreadsheet": map[string]interface{}{ + "spreadsheet_token": "shtcn_new_sheet", + "url": " https://tenant.larkoffice.com/sheets/shtcn_new_sheet ", + }, + }, + }, + }) + + err := runSheetCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "项目排期", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeSheetCreateEnvelope(t, stdout) + if got, want := data["url"], "https://tenant.larkoffice.com/sheets/shtcn_new_sheet"; got != want { + t.Fatalf("url = %#v, want trimmed backend URL %q (whitespace must not leak into output)", got, want) + } +} + +func sheetCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig { + t.Helper() + + replacer := strings.NewReplacer("/", "-", " ", "-") + suffix := replacer.Replace(strings.ToLower(t.Name())) + return &core.CliConfig{ + AppID: "test-sheet-create-" + suffix, + AppSecret: "secret-sheet-create-" + suffix, + Brand: core.BrandFeishu, + UserOpenId: userOpenID, + } +} + +func runSheetCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error { + t.Helper() + + parent := &cobra.Command{Use: "sheets"} + SheetCreate.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +func decodeSheetCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} { + t.Helper() + + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String()) + } + data, _ := envelope["data"].(map[string]interface{}) + if data == nil { + t.Fatalf("missing data in output envelope: %#v", envelope) + } + return data +} + +func TestSheetCreateBotAutoGrantSkippedNoUser(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "spreadsheet": map[string]interface{}{ + "spreadsheet_token": "shtcn_skipped", + "url": "https://example.feishu.cn/sheets/shtcn_skipped", + }, + }, + }, + }) + + err := runSheetCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "No User Sheet", + "--as", "bot", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeSheetCreateEnvelope(t, stdout) + grant, _ := data["permission_grant"].(map[string]interface{}) + if grant["status"] != common.PermissionGrantSkipped { + t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped) + } + if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "auth login") { + t.Fatalf("hint = %#v, want string containing 'auth login'", grant["hint"]) + } +} + +func TestSheetCreateBotAutoGrantFailed(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "ou_current_user")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "spreadsheet": map[string]interface{}{ + "spreadsheet_token": "shtcn_grant_fail", + "url": "https://example.feishu.cn/sheets/shtcn_grant_fail", + }, + }, + }, + }) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/shtcn_grant_fail/members", + Body: map[string]interface{}{ + "code": 230001, + "msg": "no permission", + }, + }) + + err := runSheetCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "Grant Fail Sheet", + "--as", "bot", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeSheetCreateEnvelope(t, stdout) + grant, _ := data["permission_grant"].(map[string]interface{}) + if grant["status"] != common.PermissionGrantFailed { + t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed) + } + if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "Retry later") { + t.Fatalf("hint = %#v, want string containing 'Retry later'", grant["hint"]) + } +} diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_dimension_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_dimension_test.go new file mode 100644 index 000000000..51b13ff5b --- /dev/null +++ b/shortcuts/sheets/backward/lark_sheets_sheet_dimension_test.go @@ -0,0 +1,979 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import ( + "context" + "encoding/json" + "strconv" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +// newDimTestRuntime creates a RuntimeContext with string, int, and bool flags. +func newDimTestRuntime(t *testing.T, strFlags map[string]string, intFlags map[string]int, boolFlags map[string]bool) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "test"} + for name := range strFlags { + cmd.Flags().String(name, "", "") + } + for name := range intFlags { + cmd.Flags().Int(name, 0, "") + } + for name := range boolFlags { + cmd.Flags().Bool(name, false, "") + } + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + for name, value := range strFlags { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("Flags().Set(%q) error = %v", name, err) + } + } + for name, value := range intFlags { + if err := cmd.Flags().Set(name, strconv.Itoa(value)); err != nil { + t.Fatalf("Flags().Set(%q) error = %v", name, err) + } + } + for name, value := range boolFlags { + if err := cmd.Flags().Set(name, strconv.FormatBool(value)); err != nil { + t.Fatalf("Flags().Set(%q) error = %v", name, err) + } + } + return &common.RuntimeContext{Cmd: cmd} +} + +func marshalDryRun(t *testing.T, v interface{}) string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + return string(b) +} + +// ── AddDimension ───────────────────────────────────────────────────────────── + +func TestSheetAddDimensionValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"length": 10}, nil) + err := SheetAddDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestSheetAddDimensionValidateLengthOutOfRange(t *testing.T) { + t.Parallel() + for _, length := range []int{0, -1, 5001} { + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"length": length}, nil) + err := SheetAddDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--length") { + t.Fatalf("length=%d: expected length error, got: %v", length, err) + } + } +} + +func TestSheetAddDimensionValidateSuccess(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"length": 100}, nil) + if err := SheetAddDimension.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetAddDimensionValidateWithURL(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"}, + map[string]int{"length": 5}, nil) + if err := SheetAddDimension.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDimensionShortcutsValidateRejectURLAndTokenTogether(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + shortcut common.Shortcut + strFlags map[string]string + intFlags map[string]int + boolFlags map[string]bool + }{ + { + name: "add", + shortcut: SheetAddDimension, + strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS"}, + intFlags: map[string]int{"length": 1}, + }, + { + name: "insert", + shortcut: SheetInsertDimension, + strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS", "inherit-style": ""}, + intFlags: map[string]int{"start-index": 0, "end-index": 1}, + }, + { + name: "update", + shortcut: SheetUpdateDimension, + strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS"}, + intFlags: map[string]int{"start-index": 1, "end-index": 1}, + boolFlags: map[string]bool{"visible": true}, + }, + { + name: "move", + shortcut: SheetMoveDimension, + strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS"}, + intFlags: map[string]int{"start-index": 0, "end-index": 0, "destination-index": 1}, + }, + { + name: "delete", + shortcut: SheetDeleteDimension, + strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS"}, + intFlags: map[string]int{"start-index": 1, "end-index": 1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, tt.strFlags, tt.intFlags, tt.boolFlags) + err := tt.shortcut.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("expected mutual exclusivity error, got: %v", err) + } + }) + } +} + +func TestSheetAddDimensionDryRun(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"}, + map[string]int{"length": 8}, nil) + got := marshalDryRun(t, SheetAddDimension.DryRun(context.Background(), rt)) + + if !strings.Contains(got, `dimension_range`) { + t.Fatalf("DryRun URL missing dimension_range: %s", got) + } + if !strings.Contains(got, `"sheetId":"sheet1"`) { + t.Fatalf("DryRun missing sheetId: %s", got) + } + if !strings.Contains(got, `"majorDimension":"ROWS"`) { + t.Fatalf("DryRun missing majorDimension: %s", got) + } + if !strings.Contains(got, `"length":8`) { + t.Fatalf("DryRun missing length: %s", got) + } +} + +func TestSheetAddDimensionDryRunWithURL(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"}, + map[string]int{"length": 3}, nil) + got := marshalDryRun(t, SheetAddDimension.DryRun(context.Background(), rt)) + if !strings.Contains(got, "shtFromURL") { + t.Fatalf("DryRun should extract token from URL: %s", got) + } +} + +func TestSheetAddDimensionExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range", + Body: map[string]interface{}{ + "code": 0, "msg": "Success", + "data": map[string]interface{}{"addCount": float64(8), "majorDimension": "ROWS"}, + }, + } + reg.Register(stub) + + err := mountAndRunSheets(t, SheetAddDimension, []string{ + "+add-dimension", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--dimension", "ROWS", + "--length", "8", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), `"addCount"`) { + t.Fatalf("stdout missing addCount: %s", stdout.String()) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse request body: %v", err) + } + dim, _ := body["dimension"].(map[string]interface{}) + if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" { + t.Fatalf("unexpected request body: %#v", body) + } +} + +func TestSheetAddDimensionExecuteAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range", + Status: 400, + Body: map[string]interface{}{"code": 90001, "msg": "invalid request"}, + }) + + err := mountAndRunSheets(t, SheetAddDimension, []string{ + "+add-dimension", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--dimension", "ROWS", + "--length", "8", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected API error, got nil") + } +} + +// ── InsertDimension ────────────────────────────────────────────────────────── + +func TestSheetInsertDimensionValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""}, + map[string]int{"start-index": 0, "end-index": 3}, nil) + err := SheetInsertDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestSheetInsertDimensionValidateNegativeStartIndex(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""}, + map[string]int{"start-index": -1, "end-index": 3}, nil) + err := SheetInsertDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--start-index") { + t.Fatalf("expected start-index error, got: %v", err) + } +} + +func TestSheetInsertDimensionValidateEndNotGreaterThanStart(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""}, + map[string]int{"start-index": 5, "end-index": 5}, nil) + err := SheetInsertDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--end-index") { + t.Fatalf("expected end-index error, got: %v", err) + } +} + +func TestSheetInsertDimensionValidateSuccess(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS", "inherit-style": ""}, + map[string]int{"start-index": 0, "end-index": 4}, nil) + if err := SheetInsertDimension.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetInsertDimensionDryRun(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS", "inherit-style": "BEFORE"}, + map[string]int{"start-index": 3, "end-index": 7}, nil) + got := marshalDryRun(t, SheetInsertDimension.DryRun(context.Background(), rt)) + + if !strings.Contains(got, `insert_dimension_range`) { + t.Fatalf("DryRun URL missing insert_dimension_range: %s", got) + } + if !strings.Contains(got, `"startIndex":3`) { + t.Fatalf("DryRun missing startIndex: %s", got) + } + if !strings.Contains(got, `"endIndex":7`) { + t.Fatalf("DryRun missing endIndex: %s", got) + } + if !strings.Contains(got, `"inheritStyle":"BEFORE"`) { + t.Fatalf("DryRun missing inheritStyle: %s", got) + } +} + +func TestSheetInsertDimensionDryRunNoInheritStyle(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "COLUMNS", "inherit-style": ""}, + map[string]int{"start-index": 0, "end-index": 2}, nil) + got := marshalDryRun(t, SheetInsertDimension.DryRun(context.Background(), rt)) + + if strings.Contains(got, `inheritStyle`) { + t.Fatalf("DryRun should omit inheritStyle when empty: %s", got) + } +} + +func TestSheetInsertDimensionExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range", + Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}}, + } + reg.Register(stub) + + err := mountAndRunSheets(t, SheetInsertDimension, []string{ + "+insert-dimension", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--dimension", "ROWS", + "--start-index", "3", + "--end-index", "7", + "--inherit-style", "AFTER", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse request body: %v", err) + } + dim, _ := body["dimension"].(map[string]interface{}) + if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" { + t.Fatalf("unexpected dimension: %#v", dim) + } + if body["inheritStyle"] != "AFTER" { + t.Fatalf("unexpected inheritStyle: %v", body["inheritStyle"]) + } +} + +func TestSheetInsertDimensionExecuteWithoutInheritStyle(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range", + Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}}, + } + reg.Register(stub) + + err := mountAndRunSheets(t, SheetInsertDimension, []string{ + "+insert-dimension", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--dimension", "COLUMNS", + "--start-index", "0", + "--end-index", "2", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse request body: %v", err) + } + if _, ok := body["inheritStyle"]; ok { + t.Fatalf("inheritStyle should be absent when not specified: %#v", body) + } +} + +func TestSheetInsertDimensionExecuteAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range", + Status: 400, + Body: map[string]interface{}{"code": 90001, "msg": "invalid request"}, + }) + + err := mountAndRunSheets(t, SheetInsertDimension, []string{ + "+insert-dimension", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--dimension", "ROWS", + "--start-index", "0", + "--end-index", "3", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected API error, got nil") + } +} + +// ── UpdateDimension ────────────────────────────────────────────────────────── + +func TestSheetUpdateDimensionValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 50}, + map[string]bool{"visible": true}) + err := SheetUpdateDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestSheetUpdateDimensionValidateStartIndexLessThan1(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 0, "end-index": 3, "fixed-size": 50}, + map[string]bool{"visible": true}) + err := SheetUpdateDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--start-index") { + t.Fatalf("expected start-index error, got: %v", err) + } +} + +func TestSheetUpdateDimensionValidateEndLessThanStart(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 5, "end-index": 3, "fixed-size": 50}, + map[string]bool{"visible": true}) + err := SheetUpdateDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--end-index") { + t.Fatalf("expected end-index error, got: %v", err) + } +} + +func TestSheetUpdateDimensionValidateNoProperties(t *testing.T) { + t.Parallel() + // Neither --visible nor --fixed-size is set (Changed returns false) + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 1, "end-index": 3}, nil) + // Register the flags but don't set them so Changed() returns false + rt.Cmd.Flags().Bool("visible", false, "") + rt.Cmd.Flags().Int("fixed-size", 0, "") + err := SheetUpdateDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--visible or --fixed-size") { + t.Fatalf("expected properties error, got: %v", err) + } +} + +func TestSheetUpdateDimensionValidateSuccessWithVisible(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 1, "end-index": 3}, + map[string]bool{"visible": true}) + // Ensure fixed-size flag exists but is not set + rt.Cmd.Flags().Int("fixed-size", 0, "") + if err := SheetUpdateDimension.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetUpdateDimensionValidateFixedSizeZero(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 0}, nil) + rt.Cmd.Flags().Bool("visible", false, "") + err := SheetUpdateDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--fixed-size must be >= 1") { + t.Fatalf("expected fixed-size error, got: %v", err) + } +} + +func TestSheetUpdateDimensionValidateFixedSizeNegative(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 1, "end-index": 3, "fixed-size": -10}, nil) + rt.Cmd.Flags().Bool("visible", false, "") + err := SheetUpdateDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--fixed-size must be >= 1") { + t.Fatalf("expected fixed-size error, got: %v", err) + } +} + +func TestSheetUpdateDimensionValidateSuccessWithFixedSize(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"}, + map[string]int{"start-index": 1, "end-index": 5, "fixed-size": 120}, nil) + // Ensure visible flag exists but is not set + rt.Cmd.Flags().Bool("visible", false, "") + if err := SheetUpdateDimension.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetUpdateDimensionDryRun(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"}, + map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 50}, + map[string]bool{"visible": true}) + got := marshalDryRun(t, SheetUpdateDimension.DryRun(context.Background(), rt)) + + if !strings.Contains(got, `"method":"PUT"`) { + t.Fatalf("DryRun should use PUT: %s", got) + } + if !strings.Contains(got, `dimension_range`) { + t.Fatalf("DryRun URL missing dimension_range: %s", got) + } + if !strings.Contains(got, `"visible":true`) { + t.Fatalf("DryRun missing visible: %s", got) + } + if !strings.Contains(got, `"fixedSize":50`) { + t.Fatalf("DryRun missing fixedSize: %s", got) + } +} + +func TestSheetUpdateDimensionDryRunOnlyVisible(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"}, + map[string]int{"start-index": 1, "end-index": 3}, + map[string]bool{"visible": false}) + // Add fixed-size flag but don't set it + rt.Cmd.Flags().Int("fixed-size", 0, "") + got := marshalDryRun(t, SheetUpdateDimension.DryRun(context.Background(), rt)) + + if !strings.Contains(got, `"visible":false`) { + t.Fatalf("DryRun missing visible: %s", got) + } + if strings.Contains(got, `fixedSize`) { + t.Fatalf("DryRun should omit fixedSize when not set: %s", got) + } +} + +func TestSheetUpdateDimensionExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range", + Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}}, + } + reg.Register(stub) + + err := mountAndRunSheets(t, SheetUpdateDimension, []string{ + "+update-dimension", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--dimension", "ROWS", + "--start-index", "1", + "--end-index", "3", + "--visible=true", + "--fixed-size", "50", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse request body: %v", err) + } + props, _ := body["dimensionProperties"].(map[string]interface{}) + if props["visible"] != true { + t.Fatalf("expected visible=true, got: %#v", props) + } + if props["fixedSize"] != float64(50) { + t.Fatalf("expected fixedSize=50, got: %#v", props) + } +} + +func TestSheetUpdateDimensionExecuteAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range", + Status: 400, + Body: map[string]interface{}{"code": 90001, "msg": "invalid request"}, + }) + + err := mountAndRunSheets(t, SheetUpdateDimension, []string{ + "+update-dimension", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--dimension", "ROWS", + "--start-index", "1", + "--end-index", "3", + "--visible=true", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected API error, got nil") + } +} + +// ── MoveDimension ──────────────────────────────────────────────────────────── + +func TestSheetMoveDimensionValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil) + err := SheetMoveDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestSheetMoveDimensionValidateNegativeStartIndex(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": -1, "end-index": 1, "destination-index": 4}, nil) + err := SheetMoveDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--start-index") { + t.Fatalf("expected start-index error, got: %v", err) + } +} + +func TestSheetMoveDimensionValidateEndLessThanStart(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 5, "end-index": 3, "destination-index": 0}, nil) + err := SheetMoveDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--end-index") { + t.Fatalf("expected end-index error, got: %v", err) + } +} + +func TestSheetMoveDimensionValidateNegativeDestinationIndex(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 0, "end-index": 1, "destination-index": -1}, nil) + err := SheetMoveDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--destination-index") { + t.Fatalf("expected destination-index error, got: %v", err) + } +} + +func TestSheetMoveDimensionValidateSuccess(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"}, + map[string]int{"start-index": 0, "end-index": 2, "destination-index": 5}, nil) + if err := SheetMoveDimension.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetMoveDimensionValidateWithURL(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil) + if err := SheetMoveDimension.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetMoveDimensionDryRun(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"}, + map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil) + got := marshalDryRun(t, SheetMoveDimension.DryRun(context.Background(), rt)) + + if !strings.Contains(got, `move_dimension`) { + t.Fatalf("DryRun URL missing move_dimension: %s", got) + } + if !strings.Contains(got, `"major_dimension":"ROWS"`) { + t.Fatalf("DryRun missing major_dimension: %s", got) + } + if !strings.Contains(got, `"start_index":0`) { + t.Fatalf("DryRun missing start_index: %s", got) + } + if !strings.Contains(got, `"destination_index":4`) { + t.Fatalf("DryRun missing destination_index: %s", got) + } +} + +func TestSheetMoveDimensionDryRunWithURL(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "sheet1", "dimension": "COLUMNS"}, + map[string]int{"start-index": 1, "end-index": 3, "destination-index": 0}, nil) + got := marshalDryRun(t, SheetMoveDimension.DryRun(context.Background(), rt)) + if !strings.Contains(got, "shtFromURL") { + t.Fatalf("DryRun should extract token from URL: %s", got) + } +} + +func TestSheetMoveDimensionExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/move_dimension", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, + } + reg.Register(stub) + + err := mountAndRunSheets(t, SheetMoveDimension, []string{ + "+move-dimension", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--dimension", "ROWS", + "--start-index", "0", + "--end-index", "1", + "--destination-index", "4", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse request body: %v", err) + } + source, _ := body["source"].(map[string]interface{}) + if source["major_dimension"] != "ROWS" { + t.Fatalf("unexpected major_dimension: %v", source["major_dimension"]) + } + if body["destination_index"] != float64(4) { + t.Fatalf("unexpected destination_index: %v", body["destination_index"]) + } +} + +func TestSheetMoveDimensionExecuteWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/move_dimension", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, + }) + + err := mountAndRunSheets(t, SheetMoveDimension, []string{ + "+move-dimension", + "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", + "--dimension", "COLUMNS", + "--start-index", "1", + "--end-index", "2", + "--destination-index", "0", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetMoveDimensionExecuteAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/move_dimension", + Status: 400, + Body: map[string]interface{}{"code": 1310211, "msg": "wrong sheet id"}, + }) + + err := mountAndRunSheets(t, SheetMoveDimension, []string{ + "+move-dimension", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--dimension", "ROWS", + "--start-index", "0", + "--end-index", "1", + "--destination-index", "4", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected API error, got nil") + } +} + +// ── DeleteDimension ────────────────────────────────────────────────────────── + +func TestSheetDeleteDimensionValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 1, "end-index": 3}, nil) + err := SheetDeleteDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestSheetDeleteDimensionValidateStartIndexLessThan1(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 0, "end-index": 3}, nil) + err := SheetDeleteDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--start-index") { + t.Fatalf("expected start-index error, got: %v", err) + } +} + +func TestSheetDeleteDimensionValidateEndLessThanStart(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"}, + map[string]int{"start-index": 5, "end-index": 3}, nil) + err := SheetDeleteDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--end-index") { + t.Fatalf("expected end-index error, got: %v", err) + } +} + +func TestSheetDeleteDimensionValidateSuccess(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 3, "end-index": 7}, nil) + if err := SheetDeleteDimension.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetDeleteDimensionValidateWithURL(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"}, + map[string]int{"start-index": 1, "end-index": 2}, nil) + if err := SheetDeleteDimension.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetDeleteDimensionDryRun(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"}, + map[string]int{"start-index": 3, "end-index": 7}, nil) + got := marshalDryRun(t, SheetDeleteDimension.DryRun(context.Background(), rt)) + + if !strings.Contains(got, `"method":"DELETE"`) { + t.Fatalf("DryRun should use DELETE: %s", got) + } + if !strings.Contains(got, `dimension_range`) { + t.Fatalf("DryRun URL missing dimension_range: %s", got) + } + if !strings.Contains(got, `"startIndex":3`) { + t.Fatalf("DryRun missing startIndex: %s", got) + } + if !strings.Contains(got, `"endIndex":7`) { + t.Fatalf("DryRun missing endIndex: %s", got) + } +} + +func TestSheetDeleteDimensionDryRunWithURL(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "sheet1", "dimension": "COLUMNS"}, + map[string]int{"start-index": 1, "end-index": 5}, nil) + got := marshalDryRun(t, SheetDeleteDimension.DryRun(context.Background(), rt)) + if !strings.Contains(got, "shtFromURL") { + t.Fatalf("DryRun should extract token from URL: %s", got) + } +} + +func TestSheetDeleteDimensionExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{"delCount": float64(5), "majorDimension": "ROWS"}, + }, + } + reg.Register(stub) + + err := mountAndRunSheets(t, SheetDeleteDimension, []string{ + "+delete-dimension", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--dimension", "ROWS", + "--start-index", "3", + "--end-index", "7", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), `"delCount"`) { + t.Fatalf("stdout missing delCount: %s", stdout.String()) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse request body: %v", err) + } + dim, _ := body["dimension"].(map[string]interface{}) + if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" { + t.Fatalf("unexpected dimension: %#v", dim) + } +} + +func TestSheetDeleteDimensionExecuteWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dimension_range", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{"delCount": float64(2), "majorDimension": "COLUMNS"}, + }, + }) + + err := mountAndRunSheets(t, SheetDeleteDimension, []string{ + "+delete-dimension", + "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", + "--dimension", "COLUMNS", + "--start-index", "1", + "--end-index", "2", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetDeleteDimensionExecuteAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range", + Status: 400, + Body: map[string]interface{}{"code": 90001, "msg": "invalid request"}, + }) + + err := mountAndRunSheets(t, SheetDeleteDimension, []string{ + "+delete-dimension", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--dimension", "ROWS", + "--start-index", "3", + "--end-index", "7", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected API error, got nil") + } +} diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_dropdown_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_dropdown_test.go new file mode 100644 index 000000000..e07dbd2f1 --- /dev/null +++ b/shortcuts/sheets/backward/lark_sheets_sheet_dropdown_test.go @@ -0,0 +1,552 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +// ── SetDropdown ───────────────────────────────────────────────────────────── + +func TestSetDropdownValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "", + "range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`, + "colors": "", + }, map[string]bool{"multiple": false, "highlight": false}) + err := SheetSetDropdown.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestSetDropdownValidateInvalidConditionValues(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", + "range": "s1!A2:A100", "condition-values": "not-json", + "colors": "", + }, map[string]bool{"multiple": false, "highlight": false}) + err := SheetSetDropdown.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--condition-values must be a JSON array") { + t.Fatalf("expected JSON array error, got: %v", err) + } +} + +func TestSetDropdownValidateNonStringConditionValues(t *testing.T) { + t.Parallel() + cases := []struct { + name string + input string + }{ + {"mixed types", `["ok", 1, null]`}, + {"all numbers", `[1, 2, 3]`}, + {"null literal", `null`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", + "range": "s1!A2:A100", "condition-values": tc.input, + "colors": "", + }, map[string]bool{"multiple": false, "highlight": false}) + err := SheetSetDropdown.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--condition-values must be") { + t.Fatalf("expected validation error for %q, got: %v", tc.input, err) + } + }) + } +} + +func TestSetDropdownValidateInvalidColors(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", + "range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`, + "colors": "bad-json", + }, map[string]bool{"multiple": false, "highlight": true}) + err := SheetSetDropdown.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--colors must be a JSON array") { + t.Fatalf("expected colors JSON error, got: %v", err) + } +} + +func TestSetDropdownValidateRangeMissingSheetID(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", + "range": "A2:A100", "condition-values": `["opt1"]`, + "colors": "", + }, map[string]bool{"multiple": false, "highlight": false}) + err := SheetSetDropdown.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "fully qualified range") { + t.Fatalf("expected range validation error, got: %v", err) + } +} + +func TestSetDropdownValidateEmptyConditionValues(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", + "range": "s1!A2:A100", "condition-values": `[]`, + "colors": "", + }, map[string]bool{"multiple": false, "highlight": false}) + err := SheetSetDropdown.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--condition-values must not be empty") { + t.Fatalf("expected empty error, got: %v", err) + } +} + +func TestSetDropdownValidateColorsMismatchLength(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", + "range": "s1!A2:A100", "condition-values": `["a","b","c"]`, + "colors": `["#FF0000"]`, + }, map[string]bool{"multiple": false, "highlight": true}) + err := SheetSetDropdown.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--colors length") { + t.Fatalf("expected length mismatch error, got: %v", err) + } +} + +func TestSetDropdownValidateSuccess(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", + "range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`, + "colors": "", + }, map[string]bool{"multiple": false, "highlight": false}) + if err := SheetSetDropdown.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSetDropdownDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", + "range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`, + "colors": "", + }, map[string]bool{"multiple": true, "highlight": false}) + got := mustMarshalSheetsDryRun(t, SheetSetDropdown.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"method":"POST"`) { + t.Fatalf("DryRun should use POST: %s", got) + } + if !strings.Contains(got, `dataValidation`) { + t.Fatalf("DryRun missing dataValidation: %s", got) + } + if !strings.Contains(got, `"dataValidationType":"list"`) { + t.Fatalf("DryRun missing dataValidationType: %s", got) + } +} + +func TestSetDropdownExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, + }) + err := mountAndRunSheets(t, SheetSetDropdown, []string{ + "+set-dropdown", "--spreadsheet-token", "shtTOKEN", + "--range", "s1!A2:A100", "--condition-values", `["opt1","opt2","opt3"]`, + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSetDropdownExecuteWithMultipleAndColors(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, + } + reg.Register(stub) + err := mountAndRunSheets(t, SheetSetDropdown, []string{ + "+set-dropdown", "--spreadsheet-token", "shtTOKEN", + "--range", "s1!A2:A100", "--condition-values", `["a","b"]`, + "--multiple", "--highlight", "--colors", `["#1FB6C1","#F006C2"]`, + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse body: %v", err) + } + dv, _ := body["dataValidation"].(map[string]interface{}) + opts, _ := dv["options"].(map[string]interface{}) + if opts["multipleValues"] != true { + t.Fatalf("expected multipleValues=true, got: %v", opts["multipleValues"]) + } + if opts["highlightValidData"] != true { + t.Fatalf("expected highlightValidData=true, got: %v", opts["highlightValidData"]) + } +} + +func TestSetDropdownExecuteAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation", + Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"}, + }) + err := mountAndRunSheets(t, SheetSetDropdown, []string{ + "+set-dropdown", "--spreadsheet-token", "shtTOKEN", + "--range", "s1!A2:A100", "--condition-values", `["opt1"]`, + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected error") + } +} + +func TestSetDropdownWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, + }) + err := mountAndRunSheets(t, SheetSetDropdown, []string{ + "+set-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--range", "s1!A2:A100", "--condition-values", `["opt1"]`, + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// ── UpdateDropdown ────────────────────────────────────────────────────────── + +func TestUpdateDropdownValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "", "sheet-id": "s1", + "ranges": `["s1!A1:A100"]`, "condition-values": `["opt1"]`, + "colors": "", + }, map[string]bool{"multiple": false, "highlight": false}) + err := SheetUpdateDropdown.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestUpdateDropdownValidateInvalidRanges(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", + "ranges": "not-json", "condition-values": `["opt1"]`, + "colors": "", + }, map[string]bool{"multiple": false, "highlight": false}) + err := SheetUpdateDropdown.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--ranges must be a JSON array") { + t.Fatalf("expected JSON array error, got: %v", err) + } +} + +func TestUpdateDropdownValidateRangesMissingSheetID(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", + "ranges": `["A1:A100"]`, "condition-values": `["opt1"]`, + "colors": "", + }, map[string]bool{"multiple": false, "highlight": false}) + err := SheetUpdateDropdown.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "fully qualified range") { + t.Fatalf("expected range validation error, got: %v", err) + } +} + +func TestUpdateDropdownValidateEmptyRanges(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", + "ranges": `[]`, "condition-values": `["opt1"]`, + "colors": "", + }, map[string]bool{"multiple": false, "highlight": false}) + err := SheetUpdateDropdown.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--ranges must not be empty") { + t.Fatalf("expected empty error, got: %v", err) + } +} + +func TestUpdateDropdownValidateInvalidColors(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", + "ranges": `["s1!A1:A100"]`, "condition-values": `["opt1"]`, + "colors": "{not-array}", + }, map[string]bool{"multiple": false, "highlight": true}) + err := SheetUpdateDropdown.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--colors must be a JSON array") { + t.Fatalf("expected colors JSON error, got: %v", err) + } +} + +func TestUpdateDropdownDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", + "ranges": `["sheet1!A1:A100"]`, "condition-values": `["new1","new2"]`, + "colors": "", + }, map[string]bool{"multiple": false, "highlight": false}) + got := mustMarshalSheetsDryRun(t, SheetUpdateDropdown.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"method":"PUT"`) { + t.Fatalf("DryRun should use PUT: %s", got) + } + if !strings.Contains(got, `sheet1`) { + t.Fatalf("DryRun missing sheet_id: %s", got) + } +} + +func TestUpdateDropdownExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation/sheet1", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "spreadsheetToken": "shtTOKEN", "sheetId": "sheet1", + }}, + }) + err := mountAndRunSheets(t, SheetUpdateDropdown, []string{ + "+update-dropdown", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--ranges", `["sheet1!A1:A100"]`, + "--condition-values", `["new1","new2"]`, "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestUpdateDropdownWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation/sheet1", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, + }) + err := mountAndRunSheets(t, SheetUpdateDropdown, []string{ + "+update-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", "--ranges", `["sheet1!A1:A100"]`, + "--condition-values", `["opt1"]`, "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// ── GetDropdown ───────────────────────────────────────────────────────────── + +func TestGetDropdownValidateRangeMissingSheetID(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "range": "A2:A100", + }, nil) + err := SheetGetDropdown.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "fully qualified range") { + t.Fatalf("expected range validation error, got: %v", err) + } +} + +func TestGetDropdownValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "", "range": "s1!A2:A100", + }, nil) + err := SheetGetDropdown.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestGetDropdownDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "range": "s1!A2:A100", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetGetDropdown.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"method":"GET"`) { + t.Fatalf("DryRun should use GET: %s", got) + } + if !strings.Contains(got, `dataValidation`) { + t.Fatalf("DryRun missing dataValidation path: %s", got) + } +} + +func TestGetDropdownExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation", + Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{ + "dataValidations": []interface{}{ + map[string]interface{}{ + "dataValidationType": "list", + "conditionValues": []interface{}{"opt1", "opt2"}, + "ranges": []interface{}{"s1!A2:A100"}, + }, + }, + }}, + }) + err := mountAndRunSheets(t, SheetGetDropdown, []string{ + "+get-dropdown", "--spreadsheet-token", "shtTOKEN", + "--range", "s1!A2:A100", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "dataValidations") { + t.Fatalf("stdout missing dataValidations: %s", stdout.String()) + } +} + +func TestGetDropdownWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation", + Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{ + "dataValidations": []interface{}{}, + }}, + }) + err := mountAndRunSheets(t, SheetGetDropdown, []string{ + "+get-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--range", "s1!A2:A100", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// ── DeleteDropdown ────────────────────────────────────────────────────────── + +func TestDeleteDropdownValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "", "ranges": `["s1!A2:A100"]`, + }, nil) + err := SheetDeleteDropdown.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestDeleteDropdownValidateRangesMissingSheetID(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "ranges": `["B1:B50"]`, + }, nil) + err := SheetDeleteDropdown.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "fully qualified range") { + t.Fatalf("expected range validation error, got: %v", err) + } +} + +func TestDeleteDropdownValidateEmptyRanges(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "ranges": `[]`, + }, nil) + err := SheetDeleteDropdown.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--ranges must not be empty") { + t.Fatalf("expected empty error, got: %v", err) + } +} + +func TestDeleteDropdownValidateInvalidRanges(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "ranges": "bad", + }, nil) + err := SheetDeleteDropdown.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--ranges must be a JSON array") { + t.Fatalf("expected JSON array error, got: %v", err) + } +} + +func TestDeleteDropdownDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "ranges": `["s1!A2:A100","s1!C1:C50"]`, + }, nil) + got := mustMarshalSheetsDryRun(t, SheetDeleteDropdown.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"method":"DELETE"`) { + t.Fatalf("DryRun should use DELETE: %s", got) + } + if !strings.Contains(got, `dataValidationRanges`) { + t.Fatalf("DryRun missing dataValidationRanges: %s", got) + } +} + +func TestDeleteDropdownExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "rangeResults": []interface{}{ + map[string]interface{}{"range": "s1!A2:A100", "success": true, "updatedCells": 99}, + }, + }}, + }) + err := mountAndRunSheets(t, SheetDeleteDropdown, []string{ + "+delete-dropdown", "--spreadsheet-token", "shtTOKEN", + "--ranges", `["s1!A2:A100"]`, "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "rangeResults") { + t.Fatalf("stdout missing rangeResults: %s", stdout.String()) + } +} + +func TestDeleteDropdownExecuteMultipleRanges(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, + } + reg.Register(stub) + err := mountAndRunSheets(t, SheetDeleteDropdown, []string{ + "+delete-dropdown", "--spreadsheet-token", "shtTOKEN", + "--ranges", `["s1!A2:A100","s1!C1:C50"]`, "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse body: %v", err) + } + dvRanges, _ := body["dataValidationRanges"].([]interface{}) + if len(dvRanges) != 2 { + t.Fatalf("expected 2 ranges, got: %d", len(dvRanges)) + } +} + +func TestDeleteDropdownWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, + }) + err := mountAndRunSheets(t, SheetDeleteDropdown, []string{ + "+delete-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--ranges", `["s1!A2:A100"]`, "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// suppress unused import for bytes in case the test helpers already import it +var _ = (*bytes.Buffer)(nil) diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_export_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_export_test.go new file mode 100644 index 000000000..afe456b5d --- /dev/null +++ b/shortcuts/sheets/backward/lark_sheets_sheet_export_test.go @@ -0,0 +1,140 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/tidwall/gjson" +) + +func TestSheetExportValidateRejectsURLAndTokenTogether(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, map[string]string{ + "url": "https://example.feishu.cn/sheets/shtFromURL", + "spreadsheet-token": "shtTOKEN", + "file-extension": "xlsx", + "output-path": "", + "sheet-id": "", + }, nil, nil) + err := SheetExport.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("expected mutual exclusivity error, got: %v", err) + } +} + +func TestSheetExportValidateRequiresSheetIDForCSV(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, map[string]string{ + "url": "", + "spreadsheet-token": "shtTOKEN", + "file-extension": "csv", + "output-path": "", + "sheet-id": "", + }, nil, nil) + err := SheetExport.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--sheet-id is required when --file-extension is csv") { + t.Fatalf("expected csv sheet-id validation error, got: %v", err) + } +} + +func TestSheetExportValidateAllowsCSVWithSheetID(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, map[string]string{ + "url": "", + "spreadsheet-token": "shtTOKEN", + "file-extension": "csv", + "output-path": "", + "sheet-id": "sheet1", + }, nil, nil) + if err := SheetExport.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetExportDryRunIncludesSubIDForCSV(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, map[string]string{ + "url": "", + "spreadsheet-token": "shtTOKEN", + "file-extension": "csv", + "output-path": "", + "sheet-id": "sheet1", + }, nil, nil) + got := mustMarshalSheetsDryRun(t, SheetExport.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"sub_id":"sheet1"`) { + t.Fatalf("DryRun should include sub_id for csv export, got: %s", got) + } +} + +func TestSheetExportCommandRejectsInvalidFileExtension(t *testing.T) { + t.Parallel() + + f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetExport, []string{ + "+export", + "--spreadsheet-token", "shtTOKEN", + "--file-extension", "pdf", + "--as", "user", + }, f, nil) + if err == nil || !strings.Contains(err.Error(), `allowed: xlsx, csv`) { + t.Fatalf("expected invalid file-extension error, got: %v", err) + } +} + +func TestSheetExportExecuteWithoutOutputPathReturnsMetadataOnly(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/export_tasks", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "ticket": "tk_123", + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/export_tasks/tk_123", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "result": map[string]interface{}{ + "file_token": "box_123", + }, + }, + }, + }) + + err := mountAndRunSheets(t, SheetExport, []string{ + "+export", + "--spreadsheet-token", "shtTOKEN", + "--file-extension", "xlsx", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got := stdout.String() + if gjson.Get(got, "data.file_token").String() != "box_123" || gjson.Get(got, "data.ticket").String() != "tk_123" { + t.Fatalf("stdout should return export metadata, got: %s", got) + } + if strings.Contains(got, `"saved_path"`) { + t.Fatalf("stdout should not include saved_path when --output-path is omitted: %s", got) + } +} diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_filter_view_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_filter_view_test.go new file mode 100644 index 000000000..66e49d4a5 --- /dev/null +++ b/shortcuts/sheets/backward/lark_sheets_sheet_filter_view_test.go @@ -0,0 +1,673 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +// ── CreateFilterView ───────────────────────────────────────────────────────── + +func TestCreateFilterViewValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "", "sheet-id": "s1", "range": "s1!A1:H14", + "filter-view-name": "", "filter-view-id": "", + }, nil) + err := SheetCreateFilterView.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestValidateFilterViewTokenRejectsURLAndTokenTogether(t *testing.T) { + t.Parallel() + + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "https://example.feishu.cn/sheets/shtFromURL", + "spreadsheet-token": "shtTOKEN", + "sheet-id": "s1", + "range": "s1!A1:H14", + "filter-view-name": "", + "filter-view-id": "", + }, nil) + _, err := validateFilterViewToken(rt) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("expected mutual exclusivity error, got: %v", err) + } +} + +func TestCreateFilterViewValidateRejectsEmptyRange(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "range": "", + "filter-view-name": "", "filter-view-id": "", + }, nil) + err := SheetCreateFilterView.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--range must not be empty") { + t.Fatalf("expected empty range error, got: %v", err) + } +} + +func TestCreateFilterViewValidateSuccess(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "range": "s1!A1:H14", + "filter-view-name": "", "filter-view-id": "", + }, nil) + if err := SheetCreateFilterView.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCreateFilterViewDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "range": "sheet1!A1:H14", + "filter-view-name": "my view", "filter-view-id": "", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetCreateFilterView.DryRun(context.Background(), rt)) + if !strings.Contains(got, `filter_views`) { + t.Fatalf("DryRun URL missing filter_views: %s", got) + } + if !strings.Contains(got, `"filter_view_name":"my view"`) { + t.Fatalf("DryRun missing name: %s", got) + } +} + +func TestCreateFilterViewExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "filter_view": map[string]interface{}{"filter_view_id": "pH9hbVcCXA", "range": "sheet1!A1:H14"}, + }}, + }) + err := mountAndRunSheets(t, SheetCreateFilterView, []string{ + "+create-filter-view", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--range", "sheet1!A1:H14", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "filter_view_id") { + t.Fatalf("stdout missing filter_view_id: %s", stdout.String()) + } +} + +func TestCreateFilterViewExecuteAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views", + Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"}, + }) + err := mountAndRunSheets(t, SheetCreateFilterView, []string{ + "+create-filter-view", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--range", "sheet1!A1:H14", "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected error") + } +} + +// ── UpdateFilterView ───────────────────────────────────────────────────────── + +func TestUpdateFilterViewDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", + "filter-view-id": "pH9hbVcCXA", "range": "sheet1!A1:J20", "filter-view-name": "", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetUpdateFilterView.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"method":"PATCH"`) { + t.Fatalf("DryRun should use PATCH: %s", got) + } + if !strings.Contains(got, `pH9hbVcCXA`) { + t.Fatalf("DryRun missing filter_view_id: %s", got) + } +} + +func TestUpdateFilterViewExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv123", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "filter_view": map[string]interface{}{"filter_view_id": "fv123", "range": "sheet1!A1:J20"}, + }}, + }) + err := mountAndRunSheets(t, SheetUpdateFilterView, []string{ + "+update-filter-view", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--filter-view-id", "fv123", "--range", "sheet1!A1:J20", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestUpdateFilterViewRejectsNoFields(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetUpdateFilterView, []string{ + "+update-filter-view", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--filter-view-id", "fv1", + "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected validation error when no update fields provided, got nil") + } + if !strings.Contains(err.Error(), "at least one") { + t.Fatalf("unexpected error message: %v", err) + } +} + +func TestUpdateFilterViewRejectsBlankFieldsOnly(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetUpdateFilterView, []string{ + "+update-filter-view", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--filter-view-id", "fv1", + "--range", "", "--filter-view-name", "", + "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected validation error when only blank update fields are provided, got nil") + } + if !strings.Contains(err.Error(), "at least one") { + t.Fatalf("unexpected error message: %v", err) + } +} + +// ── ListFilterViews ────────────────────────────────────────────────────────── + +func TestListFilterViewsDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetListFilterViews.DryRun(context.Background(), rt)) + if !strings.Contains(got, `filter_views/query`) { + t.Fatalf("DryRun URL missing query: %s", got) + } +} + +func TestListFilterViewsExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/query", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"filter_view_id": "fv1"}}, + }}, + }) + err := mountAndRunSheets(t, SheetListFilterViews, []string{ + "+list-filter-views", "--spreadsheet-token", "shtTOKEN", "--sheet-id", "sheet1", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "fv1") { + t.Fatalf("stdout missing fv1: %s", stdout.String()) + } +} + +// ── GetFilterView ──────────────────────────────────────────────────────────── + +func TestGetFilterViewDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv123", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetGetFilterView.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"method":"GET"`) { + t.Fatalf("DryRun should use GET: %s", got) + } + if !strings.Contains(got, `fv123`) { + t.Fatalf("DryRun missing filter_view_id: %s", got) + } +} + +func TestGetFilterViewExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv123", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "filter_view": map[string]interface{}{"filter_view_id": "fv123"}, + }}, + }) + err := mountAndRunSheets(t, SheetGetFilterView, []string{ + "+get-filter-view", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--filter-view-id", "fv123", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// ── DeleteFilterView ───────────────────────────────────────────────────────── + +func TestDeleteFilterViewDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv123", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetDeleteFilterView.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"method":"DELETE"`) { + t.Fatalf("DryRun should use DELETE: %s", got) + } +} + +func TestDeleteFilterViewExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv123", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, + }) + err := mountAndRunSheets(t, SheetDeleteFilterView, []string{ + "+delete-filter-view", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--filter-view-id", "fv123", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// ── CreateFilterViewCondition ──────────────────────────────────────────────── + +func TestCreateFilterViewConditionValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "", "sheet-id": "s1", "filter-view-id": "fv1", + "condition-id": "E", "filter-type": "number", "compare-type": "less", "expected": `["6"]`, + }, nil) + err := SheetCreateFilterViewCondition.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestCreateFilterViewConditionDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv1", + "condition-id": "E", "filter-type": "number", "compare-type": "less", "expected": `["6"]`, + }, nil) + got := mustMarshalSheetsDryRun(t, SheetCreateFilterViewCondition.DryRun(context.Background(), rt)) + if !strings.Contains(got, `conditions`) { + t.Fatalf("DryRun URL missing conditions: %s", got) + } + if !strings.Contains(got, `"condition_id":"E"`) { + t.Fatalf("DryRun missing condition_id: %s", got) + } + if !strings.Contains(got, `"filter_type":"number"`) { + t.Fatalf("DryRun missing filter_type: %s", got) + } +} + +func TestCreateFilterViewConditionExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "condition": map[string]interface{}{"condition_id": "E", "filter_type": "number"}, + }}, + } + reg.Register(stub) + err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{ + "+create-filter-view-condition", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--filter-view-id", "fv1", + "--condition-id", "E", "--filter-type", "number", "--compare-type", "less", + "--expected", `["6"]`, "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse body: %v", err) + } + if body["condition_id"] != "E" { + t.Fatalf("unexpected condition_id: %v", body["condition_id"]) + } +} + +// ── UpdateFilterViewCondition ──────────────────────────────────────────────── + +func TestUpdateFilterViewConditionDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv1", + "condition-id": "E", "filter-type": "number", "compare-type": "between", "expected": `["2","10"]`, + }, nil) + got := mustMarshalSheetsDryRun(t, SheetUpdateFilterViewCondition.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"method":"PUT"`) { + t.Fatalf("DryRun should use PUT: %s", got) + } + if !strings.Contains(got, `"compare_type":"between"`) { + t.Fatalf("DryRun missing compare_type: %s", got) + } +} + +func TestUpdateFilterViewConditionExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "PUT", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/E", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "condition": map[string]interface{}{"condition_id": "E"}, + }}, + }) + err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{ + "+update-filter-view-condition", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", + "--filter-type", "number", "--compare-type", "between", "--expected", `["2","10"]`, "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestUpdateFilterViewConditionRejectsNoFields(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{ + "+update-filter-view-condition", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", + "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected validation error when no update fields provided, got nil") + } + if !strings.Contains(err.Error(), "at least one") { + t.Fatalf("unexpected error message: %v", err) + } +} + +// ── ListFilterViewConditions ───────────────────────────────────────────────── + +func TestListFilterViewConditionsDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv1", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetListFilterViewConditions.DryRun(context.Background(), rt)) + if !strings.Contains(got, `conditions/query`) { + t.Fatalf("DryRun URL missing conditions/query: %s", got) + } +} + +func TestListFilterViewConditionsExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/query", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"condition_id": "E"}}, + }}, + }) + err := mountAndRunSheets(t, SheetListFilterViewConditions, []string{ + "+list-filter-view-conditions", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// ── GetFilterViewCondition ─────────────────────────────────────────────────── + +func TestGetFilterViewConditionDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", + "filter-view-id": "fv1", "condition-id": "E", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetGetFilterViewCondition.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"method":"GET"`) { + t.Fatalf("DryRun should use GET: %s", got) + } +} + +func TestGetFilterViewConditionExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/E", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "condition": map[string]interface{}{"condition_id": "E", "filter_type": "number"}, + }}, + }) + err := mountAndRunSheets(t, SheetGetFilterViewCondition, []string{ + "+get-filter-view-condition", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// ── DeleteFilterViewCondition ──────────────────────────────────────────────── + +func TestDeleteFilterViewConditionDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", + "filter-view-id": "fv1", "condition-id": "E", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetDeleteFilterViewCondition.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"method":"DELETE"`) { + t.Fatalf("DryRun should use DELETE: %s", got) + } +} + +func TestDeleteFilterViewConditionExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/E", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, + }) + err := mountAndRunSheets(t, SheetDeleteFilterViewCondition, []string{ + "+delete-filter-view-condition", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// ── URL flag coverage ──────────────────────────────────────────────────────── + +func TestCreateFilterViewWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "filter_view": map[string]interface{}{"filter_view_id": "fv1"}, + }}, + }) + err := mountAndRunSheets(t, SheetCreateFilterView, []string{ + "+create-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", "--range", "sheet1!A1:H14", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestListFilterViewsWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/query", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"items": []interface{}{}}}, + }) + err := mountAndRunSheets(t, SheetListFilterViews, []string{ + "+list-filter-views", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestGetFilterViewWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "filter_view": map[string]interface{}{"filter_view_id": "fv1"}, + }}, + }) + err := mountAndRunSheets(t, SheetGetFilterView, []string{ + "+get-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestUpdateFilterViewWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "filter_view": map[string]interface{}{"filter_view_id": "fv1"}, + }}, + }) + err := mountAndRunSheets(t, SheetUpdateFilterView, []string{ + "+update-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--range", "sheet1!A1:J20", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDeleteFilterViewWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, + }) + err := mountAndRunSheets(t, SheetDeleteFilterView, []string{ + "+delete-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCreateFilterViewConditionWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "condition": map[string]interface{}{"condition_id": "E"}, + }}, + }) + err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{ + "+create-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", "--filter-view-id", "fv1", + "--condition-id", "E", "--filter-type", "number", "--compare-type", "less", + "--expected", `["6"]`, "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestUpdateFilterViewConditionWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "PUT", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/E", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "condition": map[string]interface{}{"condition_id": "E"}, + }}, + }) + err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{ + "+update-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", + "--filter-type", "number", "--expected", `["5"]`, "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestListFilterViewConditionsWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/query", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"items": []interface{}{}}}, + }) + err := mountAndRunSheets(t, SheetListFilterViewConditions, []string{ + "+list-filter-view-conditions", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestGetFilterViewConditionWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/E", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "condition": map[string]interface{}{"condition_id": "E"}, + }}, + }) + err := mountAndRunSheets(t, SheetGetFilterViewCondition, []string{ + "+get-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDeleteFilterViewConditionWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/E", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, + }) + err := mountAndRunSheets(t, SheetDeleteFilterViewCondition, []string{ + "+delete-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// ── --expected validation rejects non-array input ──────────────────────────── + +func TestCreateFilterViewConditionRejectsNonArrayExpected(t *testing.T) { + cases := []struct { + name string + expected string + }{ + {"plain string", "hello"}, + {"JSON object", `{"key":"val"}`}, + {"JSON number", "42"}, + {"JSON string", `"hello"`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{ + "+create-filter-view-condition", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--filter-view-id", "fv1", + "--condition-id", "A", "--filter-type", "text", "--compare-type", "contains", + "--expected", tc.expected, "--as", "user", + }, f, stdout) + if err == nil { + t.Fatalf("expected validation error for --expected=%q, got nil", tc.expected) + } + if !strings.Contains(err.Error(), "--expected must be a JSON array") { + t.Fatalf("unexpected error message: %v", err) + } + }) + } +} diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_float_image_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_float_image_test.go new file mode 100644 index 000000000..e9640098a --- /dev/null +++ b/shortcuts/sheets/backward/lark_sheets_sheet_float_image_test.go @@ -0,0 +1,524 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +// ── CreateFloatImage ──────────────────────────────────────────────────────── + +func TestCreateFloatImageValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "", "sheet-id": "s1", + "float-image-token": "boxToken", "range": "s1!A1:A1", + "width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "", + }, nil) + err := SheetCreateFloatImage.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestCreateFloatImageValidateSuccess(t *testing.T) { + t.Parallel() + // Pixel flags are int-typed by the shortcut; leave them unset (empty + // intFlags map) so Cmd.Flags().Changed(...) returns false and + // validateFloatImageDims doesn't try to read non-existent ints. + rt := newDimTestRuntime(t, + map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", + "float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "", + }, nil, nil) + if err := SheetCreateFloatImage.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCreateFloatImageValidateRejectsMultiCellRange(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", + "float-image-token": "boxToken", "range": "s1!A1:B2", + "width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "", + }, nil) + err := SheetCreateFloatImage.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "single cell") { + t.Fatalf("expected single-cell error, got: %v", err) + } +} + +func TestCreateFloatImageValidateRejectsSheetIDMismatch(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", + "float-image-token": "boxToken", "range": "other!A1:A1", + "width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "", + }, nil) + err := SheetCreateFloatImage.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "does not match --sheet-id") { + t.Fatalf("expected sheet-id mismatch error, got: %v", err) + } +} + +func TestCreateFloatImageValidateRejectsOutOfBoundsDims(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + intFlags map[string]int + wantSubst string + }{ + {"width below 20", map[string]int{"width": 5}, "--width must be >= 20"}, + {"height below 20", map[string]int{"height": 10}, "--height must be >= 20"}, + {"negative offset-x", map[string]int{"offset-x": -1}, "--offset-x must be >= 0"}, + {"negative offset-y", map[string]int{"offset-y": -5}, "--offset-y must be >= 0"}, + } + + baseStr := map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", + "float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "", + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, baseStr, tt.intFlags, nil) + err := SheetCreateFloatImage.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), tt.wantSubst) { + t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err) + } + }) + } +} + +func TestCreateFloatImageValidateAcceptsBoundaryDims(t *testing.T) { + t.Parallel() + // Boundary values exactly at the lower bound should pass. + rt := newDimTestRuntime(t, + map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", + "float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "", + }, + map[string]int{"width": 20, "height": 20, "offset-x": 0, "offset-y": 0}, nil) + if err := SheetCreateFloatImage.Validate(context.Background(), rt); err != nil { + t.Fatalf("boundary values should pass, got: %v", err) + } +} + +func TestCreateFloatImageDryRun(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", + "float-image-token": "boxToken", "range": "sheet1!A1:A1", "float-image-id": "", + }, + map[string]int{"width": 200, "height": 150}, nil) + got := mustMarshalSheetsDryRun(t, SheetCreateFloatImage.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"method":"POST"`) { + t.Fatalf("DryRun should use POST: %s", got) + } + if !strings.Contains(got, `float_images`) { + t.Fatalf("DryRun URL missing float_images: %s", got) + } + if !strings.Contains(got, `"float_image_token":"boxToken"`) { + t.Fatalf("DryRun missing float_image_token: %s", got) + } + if !strings.Contains(got, `"width":200`) || !strings.Contains(got, `"height":150`) { + t.Fatalf("DryRun should emit numeric width/height, got: %s", got) + } +} + +func TestCreateFloatImageExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "float_image": map[string]interface{}{ + "float_image_id": "fi12345678", "float_image_token": "boxToken", + "range": "sheet1!A1:A1", "width": 200, "height": 150, + }, + }}, + } + reg.Register(stub) + err := mountAndRunSheets(t, SheetCreateFloatImage, []string{ + "+create-float-image", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--float-image-token", "boxToken", + "--range", "sheet1!A1:A1", "--width", "200", "--height", "150", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "float_image_id") { + t.Fatalf("stdout missing float_image_id: %s", stdout.String()) + } + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse body: %v", err) + } + if body["float_image_token"] != "boxToken" { + t.Fatalf("unexpected float_image_token: %v", body["float_image_token"]) + } + if w, ok := body["width"].(float64); !ok || w != 200 { + t.Fatalf("width should be numeric 200, got %T=%v", body["width"], body["width"]) + } + if h, ok := body["height"].(float64); !ok || h != 150 { + t.Fatalf("height should be numeric 150, got %T=%v", body["height"], body["height"]) + } +} + +func TestCreateFloatImageWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "float_image": map[string]interface{}{"float_image_id": "fi12345678"}, + }}, + }) + err := mountAndRunSheets(t, SheetCreateFloatImage, []string{ + "+create-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", "--float-image-token", "boxToken", + "--range", "sheet1!A1:A1", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCreateFloatImageExecuteAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images", + Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"}, + }) + err := mountAndRunSheets(t, SheetCreateFloatImage, []string{ + "+create-float-image", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--float-image-token", "boxToken", + "--range", "sheet1!A1:A1", "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected error") + } +} + +// ── UpdateFloatImage ──────────────────────────────────────────────────────── + +func TestUpdateFloatImageValidateRejectsEmptyPayload(t *testing.T) { + t.Parallel() + // Only IDs set, no mutable field: PATCH would be an empty {} body. + rt := newDimTestRuntime(t, + map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", + "float-image-id": "fi123", "range": "", + }, nil, nil) + err := SheetUpdateFloatImage.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "specify at least one of --range") { + t.Fatalf("expected empty-payload error, got: %v", err) + } +} + +func TestUpdateFloatImageValidateAcceptsSingleField(t *testing.T) { + t.Parallel() + // Any single mutable field should satisfy the payload check. + tests := []struct { + name string + strFlags map[string]string + intFlags map[string]int + }{ + { + name: "range only", + strFlags: map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", + "float-image-id": "fi123", "range": "sheet1!B2:B2", + }, + }, + { + name: "offset-x only (zero value)", + strFlags: map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", + "float-image-id": "fi123", "range": "", + }, + intFlags: map[string]int{"offset-x": 0}, + }, + } + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, tt.strFlags, tt.intFlags, nil) + if err := SheetUpdateFloatImage.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestUpdateFloatImageValidateRejectsSheetIDMismatch(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", + "float-image-id": "fi123", "range": "other!A1:A1", + "width": "", "height": "", "offset-x": "", "offset-y": "", + }, nil) + err := SheetUpdateFloatImage.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "does not match --sheet-id") { + t.Fatalf("expected sheet-id mismatch error, got: %v", err) + } +} + +func TestUpdateFloatImageValidateRejectsOutOfBoundsDims(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + intFlags map[string]int + wantSubst string + }{ + {"width below 20", map[string]int{"width": 19}, "--width must be >= 20"}, + {"height below 20", map[string]int{"height": 0}, "--height must be >= 20"}, + {"negative offset-x", map[string]int{"offset-x": -10}, "--offset-x must be >= 0"}, + {"negative offset-y", map[string]int{"offset-y": -1}, "--offset-y must be >= 0"}, + } + + baseStr := map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", + "float-image-id": "fi123", "range": "", + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, baseStr, tt.intFlags, nil) + err := SheetUpdateFloatImage.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), tt.wantSubst) { + t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err) + } + }) + } +} + +func TestUpdateFloatImageDryRun(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", + "float-image-id": "fi12345678", "range": "sheet1!B2:B2", + }, + map[string]int{"width": 300, "offset-y": 10}, nil) + got := mustMarshalSheetsDryRun(t, SheetUpdateFloatImage.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"method":"PATCH"`) { + t.Fatalf("DryRun should use PATCH: %s", got) + } + if !strings.Contains(got, `fi12345678`) { + t.Fatalf("DryRun missing float_image_id: %s", got) + } + if !strings.Contains(got, `"width":300`) || !strings.Contains(got, `"offset_y":10`) { + t.Fatalf("DryRun should emit numeric width/offset_y, got: %s", got) + } +} + +func TestUpdateFloatImageExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "float_image": map[string]interface{}{"float_image_id": "fi123", "width": 300}, + }}, + }) + err := mountAndRunSheets(t, SheetUpdateFloatImage, []string{ + "+update-float-image", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--float-image-id", "fi123", + "--width", "300", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestUpdateFloatImageWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "float_image": map[string]interface{}{"float_image_id": "fi123"}, + }}, + }) + err := mountAndRunSheets(t, SheetUpdateFloatImage, []string{ + "+update-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", "--float-image-id", "fi123", + "--range", "sheet1!C3:C3", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// ── GetFloatImage ─────────────────────────────────────────────────────────── + +func TestGetFloatImageValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "", "sheet-id": "s1", "float-image-id": "fi1", + }, nil) + err := SheetGetFloatImage.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestGetFloatImageDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "float-image-id": "fi123", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetGetFloatImage.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"method":"GET"`) { + t.Fatalf("DryRun should use GET: %s", got) + } + if !strings.Contains(got, `fi123`) { + t.Fatalf("DryRun missing float_image_id: %s", got) + } +} + +func TestGetFloatImageExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "float_image": map[string]interface{}{ + "float_image_id": "fi123", "range": "sheet1!A1:A1", "width": 100, "height": 100, + }, + }}, + }) + err := mountAndRunSheets(t, SheetGetFloatImage, []string{ + "+get-float-image", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "fi123") { + t.Fatalf("stdout missing fi123: %s", stdout.String()) + } +} + +func TestGetFloatImageWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "float_image": map[string]interface{}{"float_image_id": "fi123"}, + }}, + }) + err := mountAndRunSheets(t, SheetGetFloatImage, []string{ + "+get-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// ── ListFloatImages ───────────────────────────────────────────────────────── + +func TestListFloatImagesDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetListFloatImages.DryRun(context.Background(), rt)) + if !strings.Contains(got, `float_images/query`) { + t.Fatalf("DryRun URL missing query: %s", got) + } +} + +func TestListFloatImagesExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/query", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"float_image_id": "fi1"}, + map[string]interface{}{"float_image_id": "fi2"}, + }, + }}, + }) + err := mountAndRunSheets(t, SheetListFloatImages, []string{ + "+list-float-images", "--spreadsheet-token", "shtTOKEN", "--sheet-id", "sheet1", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "fi1") { + t.Fatalf("stdout missing fi1: %s", stdout.String()) + } +} + +func TestListFloatImagesWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/query", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"items": []interface{}{}}}, + }) + err := mountAndRunSheets(t, SheetListFloatImages, []string{ + "+list-float-images", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// ── DeleteFloatImage ──────────────────────────────────────────────────────── + +func TestDeleteFloatImageDryRun(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "float-image-id": "fi123", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetDeleteFloatImage.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"method":"DELETE"`) { + t.Fatalf("DryRun should use DELETE: %s", got) + } +} + +func TestDeleteFloatImageExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, + }) + err := mountAndRunSheets(t, SheetDeleteFloatImage, []string{ + "+delete-float-image", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDeleteFloatImageWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, + }) + err := mountAndRunSheets(t, SheetDeleteFloatImage, []string{ + "+delete-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_manage_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_manage_test.go new file mode 100644 index 000000000..1a8115b7a --- /dev/null +++ b/shortcuts/sheets/backward/lark_sheets_sheet_manage_test.go @@ -0,0 +1,702 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" + "github.com/tidwall/gjson" +) + +func TestSheetCreateSheetValidateMissingToken(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "", "title": "Sheet 2"}, + nil, nil) + err := SheetCreateSheet.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestSheetInfoRequiresSpreadsheetMetaAndReadScopes(t *testing.T) { + t.Parallel() + + want := []string{"sheets:spreadsheet.meta:read", "sheets:spreadsheet:read"} + if !reflect.DeepEqual(SheetInfo.Scopes, want) { + t.Fatalf("SheetInfo scopes = %v, want %v", SheetInfo.Scopes, want) + } +} + +func TestSheetManageValidateRejectsURLAndTokenTogether(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + shortcut common.Shortcut + args map[string]string + }{ + { + name: "create-sheet", + shortcut: SheetCreateSheet, + args: map[string]string{ + "url": "https://example.feishu.cn/sheets/shtFromURL", + "spreadsheet-token": "shtTOKEN", + "title": "Data", + }, + }, + { + name: "copy-sheet", + shortcut: SheetCopySheet, + args: map[string]string{ + "url": "https://example.feishu.cn/sheets/shtFromURL", + "spreadsheet-token": "shtTOKEN", + "sheet-id": "sheet1", + "title": "Copy", + }, + }, + { + name: "delete-sheet", + shortcut: SheetDeleteSheet, + args: map[string]string{ + "url": "https://example.feishu.cn/sheets/shtFromURL", + "spreadsheet-token": "shtTOKEN", + "sheet-id": "sheet1", + }, + }, + { + name: "update-sheet", + shortcut: SheetUpdateSheet, + args: map[string]string{ + "url": "https://example.feishu.cn/sheets/shtFromURL", + "spreadsheet-token": "shtTOKEN", + "sheet-id": "sheet1", + "title": "Renamed", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, tt.args, nil, nil) + err := tt.shortcut.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("expected mutual exclusivity error, got: %v", err) + } + }) + } +} + +func TestSheetCreateSheetValidateRejectsInvalidTitle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + title string + wantSubst string + }{ + {name: "special chars", title: "bad/title", wantSubst: "must not contain"}, + {name: "empty", title: "", wantSubst: "must not be empty"}, + {name: "tab", title: "bad\ttitle", wantSubst: "tabs or line breaks"}, + {name: "newline", title: "bad\ntitle", wantSubst: "tabs or line breaks"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"spreadsheet-token": "sht1", "title": tt.title}, + nil, nil) + err := SheetCreateSheet.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), tt.wantSubst) { + t.Fatalf("expected title error containing %q, got: %v", tt.wantSubst, err) + } + }) + } +} + +func TestSheetCreateSheetValidateRejectsNegativeIndexWhenTitleProvided(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{"spreadsheet-token": "sht1", "title": "Data"}, + map[string]int{"index": -1}, nil) + err := SheetCreateSheet.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--index must be >= 0") { + t.Fatalf("expected index validation error, got: %v", err) + } +} + +func TestSheetCopySheetValidateRejectsInvalidTitle(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{"spreadsheet-token": "sht1", "sheet-id": "sheet1", "title": "bad\ttitle"}, + nil, nil) + err := SheetCopySheet.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "tabs or line breaks") { + t.Fatalf("expected title error, got: %v", err) + } +} + +func TestSheetCopySheetValidateRejectsNegativeIndexWhenTitleProvided(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{"spreadsheet-token": "sht1", "sheet-id": "sheet1", "title": "Copy"}, + map[string]int{"index": -1}, nil) + err := SheetCopySheet.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--index must be >= 0") { + t.Fatalf("expected index validation error, got: %v", err) + } +} + +func TestSheetUpdateSheetValidateRejectsEmptyTitle(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{"spreadsheet-token": "sht1", "sheet-id": "sheet1", "title": ""}, + nil, nil) + err := SheetUpdateSheet.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "must not be empty") { + t.Fatalf("expected empty-title error, got: %v", err) + } +} + +func TestSheetCreateSheetDryRun(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{"spreadsheet-token": "shtTOKEN", "title": "Data"}, + map[string]int{"index": 0}, nil) + got := mustMarshalSheetsDryRun(t, SheetCreateSheet.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update"`) { + t.Fatalf("DryRun URL mismatch: %s", got) + } + if !strings.Contains(got, `"addSheet"`) || !strings.Contains(got, `"title":"Data"`) || !strings.Contains(got, `"index":0`) { + t.Fatalf("DryRun body mismatch: %s", got) + } +} + +func TestSheetCreateSheetExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "replies": []interface{}{ + map[string]interface{}{ + "addSheet": map[string]interface{}{ + "properties": map[string]interface{}{ + "sheetId": "sheet_new", + "title": "Data", + "index": 0, + }, + }, + }, + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRunSheets(t, SheetCreateSheet, []string{ + "+create-sheet", + "--spreadsheet-token", "shtTOKEN", + "--title", "Data", + "--index", "0", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gjson.Get(stdout.String(), "data.sheet_id").String() != "sheet_new" { + t.Fatalf("stdout missing sheet_id: %s", stdout.String()) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse body: %v", err) + } + requests, _ := body["requests"].([]interface{}) + if len(requests) != 1 { + t.Fatalf("unexpected body: %#v", body) + } + req0, _ := requests[0].(map[string]interface{}) + addSheet, _ := req0["addSheet"].(map[string]interface{}) + props, _ := addSheet["properties"].(map[string]interface{}) + if props["title"] != "Data" { + t.Fatalf("request title = %#v", props["title"]) + } + if idx, ok := props["index"].(float64); !ok || idx != 0 { + t.Fatalf("request index = %#v", props["index"]) + } +} + +func TestSheetCopySheetValidateMissingSheetID(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{"spreadsheet-token": "sht1", "sheet-id": ""}, + nil, nil) + err := SheetCopySheet.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--sheet-id") { + t.Fatalf("expected sheet-id error, got: %v", err) + } +} + +func TestSheetCopySheetDryRun(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{"spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "title": "Copy"}, + map[string]int{"index": 2}, nil) + got := mustMarshalSheetsDryRun(t, SheetCopySheet.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update"`) { + t.Fatalf("DryRun URL mismatch: %s", got) + } + if !strings.Contains(got, `"copySheet"`) || !strings.Contains(got, `"sheetId":"sheet1"`) || !strings.Contains(got, `"title":"Copy"`) { + t.Fatalf("DryRun body mismatch: %s", got) + } + if !strings.Contains(got, `"[2] Move copied sheet to requested index"`) || !strings.Contains(got, `\u003ccopied_sheet_id\u003e`) || !strings.Contains(got, `"index":2`) { + t.Fatalf("DryRun should describe follow-up move: %s", got) + } +} + +func TestSheetCopySheetExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + copyStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "replies": []interface{}{ + map[string]interface{}{ + "copySheet": map[string]interface{}{ + "properties": map[string]interface{}{ + "sheetId": "sheet_copy", + "title": "Copy", + "index": 1, + }, + }, + }, + }, + }, + }, + } + moveStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "replies": []interface{}{ + map[string]interface{}{ + "updateSheet": map[string]interface{}{ + "properties": map[string]interface{}{ + "sheetId": "sheet_copy", + "index": 2, + }, + }, + }, + }, + }, + }, + } + reg.Register(copyStub) + reg.Register(moveStub) + + err := mountAndRunSheets(t, SheetCopySheet, []string{ + "+copy-sheet", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--title", "Copy", + "--index", "2", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gjson.Get(stdout.String(), "data.sheet_id").String() != "sheet_copy" { + t.Fatalf("stdout missing copied sheet id: %s", stdout.String()) + } + if gjson.Get(stdout.String(), "data.sheet.index").Int() != 2 { + t.Fatalf("stdout missing moved index: %s", stdout.String()) + } + + var copyBody map[string]interface{} + if err := json.Unmarshal(copyStub.CapturedBody, ©Body); err != nil { + t.Fatalf("parse copy body: %v", err) + } + if !strings.Contains(string(copyStub.CapturedBody), `"copySheet"`) { + t.Fatalf("copy request missing copySheet: %s", string(copyStub.CapturedBody)) + } + if !strings.Contains(string(moveStub.CapturedBody), `"updateSheet"`) || !strings.Contains(string(moveStub.CapturedBody), `"index":2`) { + t.Fatalf("move request mismatch: %s", string(moveStub.CapturedBody)) + } +} + +func TestSheetCopySheetExecuteMoveFailureIncludesCopiedSheetRecovery(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "replies": []interface{}{ + map[string]interface{}{ + "copySheet": map[string]interface{}{ + "properties": map[string]interface{}{ + "sheetId": "sheet_copy", + "title": "Copy", + "index": 1, + }, + }, + }, + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update", + Status: 400, + Body: map[string]interface{}{ + "code": 1310211, + "msg": "wrong sheet id", + "error": map[string]interface{}{ + "log_id": "log-move-failed", + }, + }, + }) + + err := mountAndRunSheets(t, SheetCopySheet, []string{ + "+copy-sheet", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--title", "Copy", + "--index", "2", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected move failure, got nil") + } + + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected *output.ExitError with detail, got %T: %v", err, err) + } + if exitErr.Detail.Code != 1310211 { + t.Fatalf("error code = %d, want 1310211", exitErr.Detail.Code) + } + if !strings.Contains(exitErr.Detail.Message, `sheet copied successfully as "sheet_copy"`) { + t.Fatalf("message missing copied sheet id: %q", exitErr.Detail.Message) + } + if !strings.Contains(exitErr.Detail.Hint, "do not retry +copy-sheet") { + t.Fatalf("hint missing retry guard: %q", exitErr.Detail.Hint) + } + if !strings.Contains(exitErr.Detail.Hint, "+update-sheet --spreadsheet-token shtTOKEN --sheet-id sheet_copy --index 2") { + t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint) + } + + detail, _ := exitErr.Detail.Detail.(map[string]interface{}) + if detail["partial_success"] != true { + t.Fatalf("partial_success = %#v, want true", detail["partial_success"]) + } + if detail["sheet_id"] != "sheet_copy" { + t.Fatalf("sheet_id = %#v, want %q", detail["sheet_id"], "sheet_copy") + } + if detail["requested_index"] != 2 { + t.Fatalf("requested_index = %#v, want 2", detail["requested_index"]) + } + if detail["retry_command"] != "lark-cli sheets +update-sheet --spreadsheet-token shtTOKEN --sheet-id sheet_copy --index 2" { + t.Fatalf("retry_command = %#v", detail["retry_command"]) + } + if detail["log_id"] != "log-move-failed" { + t.Fatalf("log_id = %#v, want %q", detail["log_id"], "log-move-failed") + } +} + +func TestSheetDeleteSheetDryRun(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{"spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1"}, + nil, nil) + got := mustMarshalSheetsDryRun(t, SheetDeleteSheet.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"method":"POST"`) { + t.Fatalf("DryRun should use POST: %s", got) + } + if !strings.Contains(got, `"/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update"`) { + t.Fatalf("DryRun URL mismatch: %s", got) + } + if !strings.Contains(got, `"deleteSheet"`) || !strings.Contains(got, `"sheetId":"sheet1"`) { + t.Fatalf("DryRun body mismatch: %s", got) + } +} + +func TestSheetDeleteSheetExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "replies": []interface{}{ + map[string]interface{}{ + "deleteSheet": map[string]interface{}{ + "result": true, + "sheetId": "sheet1", + }, + }, + }, + }, + }, + }) + + err := mountAndRunSheets(t, SheetDeleteSheet, []string{ + "+delete-sheet", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--yes", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !gjson.Get(stdout.String(), "data.deleted").Bool() { + t.Fatalf("stdout missing deleted=true: %s", stdout.String()) + } + if gjson.Get(stdout.String(), "data.sheet_id").String() != "sheet1" { + t.Fatalf("stdout missing sheet_id: %s", stdout.String()) + } +} + +func TestSheetUpdateSheetValidateRequiresMutation(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{"spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1"}, + nil, nil) + err := SheetUpdateSheet.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "specify at least one") { + t.Fatalf("expected mutation error, got: %v", err) + } +} + +func TestSheetUpdateSheetValidateRejectsBadProtectionConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + strFlags map[string]string + intFlags map[string]int + wantSubst string + }{ + { + name: "lock-info requires lock", + strFlags: map[string]string{ + "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "lock-info": "private", + }, + wantSubst: "--lock when updating protection settings", + }, + { + name: "user-ids requires user-id-type", + strFlags: map[string]string{ + "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "lock": "LOCK", + "user-ids": `["ou_1"]`, + }, + wantSubst: "--user-ids requires --user-id-type", + }, + { + name: "negative frozen rows rejected", + strFlags: map[string]string{ + "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", + }, + intFlags: map[string]int{"frozen-row-count": -1}, + wantSubst: "--frozen-row-count must be >= 0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, tt.strFlags, tt.intFlags, nil) + err := SheetUpdateSheet.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), tt.wantSubst) { + t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err) + } + }) + } +} + +func TestSheetUpdateSheetDryRun(t *testing.T) { + t.Parallel() + + rt := newDimTestRuntime(t, + map[string]string{ + "spreadsheet-token": "shtTOKEN", + "sheet-id": "sheet1", + "title": "Hidden Sheet", + "lock": "LOCK", + "lock-info": "private", + "user-ids": `["ou_1"]`, + "user-id-type": "open_id", + }, + map[string]int{ + "index": 3, + "frozen-row-count": 2, + "frozen-col-count": 1, + }, + map[string]bool{"hidden": false}, + ) + got := mustMarshalSheetsDryRun(t, SheetUpdateSheet.DryRun(context.Background(), rt)) + for _, want := range []string{ + `"/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update"`, + `"user_id_type":"open_id"`, + `"sheetId":"sheet1"`, + `"title":"Hidden Sheet"`, + `"index":3`, + `"hidden":false`, + `"frozenRowCount":2`, + `"frozenColCount":1`, + `"lock":"LOCK"`, + `"lockInfo":"private"`, + `"userIDs":["ou_1"]`, + } { + if !strings.Contains(got, want) { + t.Fatalf("DryRun missing %s: %s", want, got) + } + } +} + +func TestSheetUpdateSheetExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update?user_id_type=open_id", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "replies": []interface{}{ + map[string]interface{}{ + "updateSheet": map[string]interface{}{ + "properties": map[string]interface{}{ + "sheetId": "sheet1", + "title": "Renamed", + "index": 1, + "hidden": true, + "frozenRowCount": 2, + "frozenColCount": 1, + "protect": map[string]interface{}{ + "lock": "LOCK", + "lockInfo": "private", + "userIDs": []interface{}{"ou_1"}, + }, + }, + }, + }, + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRunSheets(t, SheetUpdateSheet, []string{ + "+update-sheet", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--title", "Renamed", + "--index", "1", + "--hidden=true", + "--frozen-row-count", "2", + "--frozen-col-count", "1", + "--lock", "LOCK", + "--lock-info", "private", + "--user-ids", `["ou_1"]`, + "--user-id-type", "open_id", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gjson.Get(stdout.String(), "data.sheet_id").String() != "sheet1" { + t.Fatalf("stdout missing sheet_id: %s", stdout.String()) + } + if gjson.Get(stdout.String(), "data.sheet.title").String() != "Renamed" { + t.Fatalf("stdout missing title: %s", stdout.String()) + } + if gjson.Get(stdout.String(), "data.sheet.grid_properties.frozen_row_count").Int() != 2 { + t.Fatalf("stdout missing frozen_row_count: %s", stdout.String()) + } + if gjson.Get(stdout.String(), "data.sheet.protect.lock_info").String() != "private" { + t.Fatalf("stdout missing lock_info: %s", stdout.String()) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse body: %v", err) + } + requests, ok := body["requests"].([]interface{}) + if !ok || len(requests) != 1 { + t.Fatalf("unexpected requests body: %#v", body) + } + req0, _ := requests[0].(map[string]interface{}) + updateSheet, _ := req0["updateSheet"].(map[string]interface{}) + props, _ := updateSheet["properties"].(map[string]interface{}) + if props["sheetId"] != "sheet1" || props["title"] != "Renamed" { + t.Fatalf("unexpected properties: %#v", props) + } +} + +func TestBuildUpdateSheetOutputOmitsBlankTitleWhenTitleNotChanged(t *testing.T) { + t.Parallel() + + out, ok := buildUpdateSheetOutput("shtTOKEN", map[string]interface{}{ + "replies": []interface{}{ + map[string]interface{}{ + "updateSheet": map[string]interface{}{ + "properties": map[string]interface{}{ + "sheetId": "sheet1", + "title": "", + "hidden": false, + "frozenRowCount": 0, + }, + }, + }, + }, + }, false) + if !ok { + t.Fatal("expected output") + } + sheet, _ := out["sheet"].(map[string]interface{}) + if _, exists := sheet["title"]; exists { + t.Fatalf("blank title should be omitted when title is unchanged: %#v", sheet) + } + if sheet["sheet_id"] != "sheet1" { + t.Fatalf("unexpected sheet output: %#v", sheet) + } +} diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_management.go b/shortcuts/sheets/backward/lark_sheets_sheet_management.go new file mode 100644 index 000000000..0484a6cd4 --- /dev/null +++ b/shortcuts/sheets/backward/lark_sheets_sheet_management.go @@ -0,0 +1,721 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var sheetProtectLockValues = []string{"LOCK", "UNLOCK"} + +func sheetBatchUpdatePath(token string) string { + return fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/sheets_batch_update", validate.EncodePathSegment(token)) +} + +func validateSheetManageToken(runtime *common.RuntimeContext) (string, error) { + if err := common.ExactlyOne(runtime, "url", "spreadsheet-token"); err != nil { + return "", err + } + if token := strings.TrimSpace(runtime.Str("spreadsheet-token")); token != "" { + if err := validate.RejectControlChars(token, "spreadsheet-token"); err != nil { + return "", common.FlagErrorf("%v", err) + } + return token, nil + } + + url := strings.TrimSpace(runtime.Str("url")) + if url == "" { + return "", common.FlagErrorf("specify --url or --spreadsheet-token") + } + + token := extractSpreadsheetToken(url) + if token == "" || token == url { + return "", common.FlagErrorf("--url must be a spreadsheet URL like https://.../sheets/") + } + if err := validate.RejectControlChars(token, "url"); err != nil { + return "", common.FlagErrorf("%v", err) + } + return token, nil +} + +func validateSheetID(flagName, sheetID string) error { + if strings.TrimSpace(sheetID) == "" { + return common.FlagErrorf("specify --%s", flagName) + } + if err := validate.RejectControlChars(sheetID, flagName); err != nil { + return common.FlagErrorf("%v", err) + } + return nil +} + +func validateSheetTitle(flagName, title string) error { + if title == "" { + return common.FlagErrorf("--%s must not be empty", flagName) + } + if strings.ContainsAny(title, "\t\r\n") { + return common.FlagErrorf("--%s must not contain tabs or line breaks", flagName) + } + if err := validate.RejectControlChars(title, flagName); err != nil { + return common.FlagErrorf("%v", err) + } + if len([]rune(title)) > 100 { + return common.FlagErrorf("--%s must be <= 100 characters", flagName) + } + if strings.ContainsAny(title, `/\?*[]:`) || strings.Contains(title, `\`) { + return common.FlagErrorf("--%s must not contain any of / \\ ? * [ ] :", flagName) + } + return nil +} + +func validateNonNegativeInt(flagName string, value int) error { + if value < 0 { + return common.FlagErrorf("--%s must be >= 0, got %d", flagName, value) + } + return nil +} + +func buildSheetCreateProperties(runtime *common.RuntimeContext) map[string]interface{} { + properties := map[string]interface{}{} + if runtime.Changed("title") { + properties["title"] = runtime.Str("title") + } + if runtime.Changed("index") { + properties["index"] = runtime.Int("index") + } + return properties +} + +func buildCreateSheetBody(runtime *common.RuntimeContext) map[string]interface{} { + return map[string]interface{}{ + "requests": []interface{}{ + map[string]interface{}{ + "addSheet": map[string]interface{}{ + "properties": buildSheetCreateProperties(runtime), + }, + }, + }, + } +} + +func buildCopySheetBody(runtime *common.RuntimeContext) map[string]interface{} { + copySheet := map[string]interface{}{ + "source": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + }, + } + if runtime.Changed("title") { + copySheet["destination"] = map[string]interface{}{ + "title": runtime.Str("title"), + } + } + return map[string]interface{}{ + "requests": []interface{}{ + map[string]interface{}{ + "copySheet": copySheet, + }, + }, + } +} + +func buildDeleteSheetBody(sheetID string) map[string]interface{} { + return map[string]interface{}{ + "requests": []interface{}{ + map[string]interface{}{ + "deleteSheet": map[string]interface{}{ + "sheetId": sheetID, + }, + }, + }, + } +} + +func buildMoveCopiedSheetBody(sheetID string, index int) map[string]interface{} { + return map[string]interface{}{ + "requests": []interface{}{ + map[string]interface{}{ + "updateSheet": map[string]interface{}{ + "properties": map[string]interface{}{ + "sheetId": sheetID, + "index": index, + }, + }, + }, + }, + } +} + +func normalizeSheetProperties(properties map[string]interface{}, titleChanged bool) map[string]interface{} { + sheet := map[string]interface{}{} + if v, ok := properties["sheetId"]; ok { + sheet["sheet_id"] = v + } + if v, ok := properties["title"]; ok { + if title, ok := v.(string); !ok || title != "" || titleChanged { + sheet["title"] = v + } + } + if v, ok := properties["index"]; ok { + sheet["index"] = v + } + if v, ok := properties["hidden"]; ok { + sheet["hidden"] = v + } + + grid := map[string]interface{}{} + if v, ok := properties["frozenRowCount"]; ok { + grid["frozen_row_count"] = v + } + if v, ok := properties["frozenColCount"]; ok { + grid["frozen_column_count"] = v + } + if len(grid) > 0 { + sheet["grid_properties"] = grid + } + + if protect, ok := properties["protect"].(map[string]interface{}); ok { + outProtect := map[string]interface{}{} + if v, ok := protect["lock"]; ok { + outProtect["lock"] = v + } + if v, ok := protect["lockInfo"]; ok { + outProtect["lock_info"] = v + } + if v, ok := protect["userIDs"]; ok { + outProtect["user_ids"] = v + } + if len(outProtect) > 0 { + sheet["protect"] = outProtect + } + } + return sheet +} + +func firstReply(data map[string]interface{}) (map[string]interface{}, bool) { + replies, ok := data["replies"].([]interface{}) + if !ok || len(replies) == 0 { + return nil, false + } + reply, ok := replies[0].(map[string]interface{}) + if !ok { + return nil, false + } + return reply, true +} + +func buildOperateSheetOutput(token string, data map[string]interface{}, opKey string, titleChanged bool) (map[string]interface{}, bool) { + reply, ok := firstReply(data) + if !ok { + return nil, false + } + op, ok := reply[opKey].(map[string]interface{}) + if !ok { + return nil, false + } + properties, ok := op["properties"].(map[string]interface{}) + if !ok { + return nil, false + } + sheet := normalizeSheetProperties(properties, titleChanged) + out := map[string]interface{}{ + "spreadsheet_token": token, + "sheet": sheet, + } + if sheetID, ok := sheet["sheet_id"].(string); ok && sheetID != "" { + out["sheet_id"] = sheetID + } + return out, true +} + +func buildDeleteSheetOutput(token string, sheetID string, data map[string]interface{}) (map[string]interface{}, bool) { + reply, ok := firstReply(data) + if !ok { + return nil, false + } + del, ok := reply["deleteSheet"].(map[string]interface{}) + if !ok { + return nil, false + } + out := map[string]interface{}{ + "spreadsheet_token": token, + "sheet_id": sheetID, + "deleted": true, + } + if v, ok := del["sheetId"].(string); ok && v != "" { + out["sheet_id"] = v + } + if v, ok := del["result"].(bool); ok { + out["deleted"] = v + } + return out, true +} + +func mergeSheetOutputs(base, overlay map[string]interface{}) map[string]interface{} { + if base == nil { + return overlay + } + if overlay == nil { + return base + } + out := map[string]interface{}{} + for k, v := range base { + out[k] = v + } + for k, v := range overlay { + if k == "sheet" { + baseSheet, _ := out["sheet"].(map[string]interface{}) + overlaySheet, _ := v.(map[string]interface{}) + mergedSheet := map[string]interface{}{} + for sk, sv := range baseSheet { + mergedSheet[sk] = sv + } + for sk, sv := range overlaySheet { + mergedSheet[sk] = sv + } + out["sheet"] = mergedSheet + continue + } + out[k] = v + } + return out +} + +func mergeSheetErrorDetail(detail interface{}, overlay map[string]interface{}) interface{} { + if len(overlay) == 0 { + return detail + } + if detail == nil { + return overlay + } + if existing, ok := detail.(map[string]interface{}); ok { + merged := map[string]interface{}{} + for k, v := range existing { + merged[k] = v + } + for k, v := range overlay { + merged[k] = v + } + return merged + } + + merged := map[string]interface{}{} + for k, v := range overlay { + merged[k] = v + } + merged["cause_detail"] = detail + return merged +} + +func copySheetMoveRetryCommand(token, sheetID string, index int) string { + return fmt.Sprintf("lark-cli sheets +update-sheet --spreadsheet-token %s --sheet-id %s --index %d", token, sheetID, index) +} + +func wrapCopySheetMoveError(err error, token, sheetID string, index int) error { + if strings.TrimSpace(sheetID) == "" { + return err + } + + retryCommand := copySheetMoveRetryCommand(token, sheetID, index) + msg := fmt.Sprintf("sheet copied successfully as %q, but moving it to index %d failed", sheetID, index) + hint := fmt.Sprintf( + "do not retry +copy-sheet: the new sheet already exists as %s\nretry only the move with: %s", + sheetID, + retryCommand, + ) + detail := map[string]interface{}{ + "partial_success": true, + "failed_step": "move_copied_sheet", + "spreadsheet_token": token, + "sheet_id": sheetID, + "requested_index": index, + "retry_command": retryCommand, + } + + var exitErr *output.ExitError + if errors.As(err, &exitErr) && exitErr.Detail != nil { + if upstreamHint := strings.TrimSpace(exitErr.Detail.Hint); upstreamHint != "" { + hint = upstreamHint + "\n" + hint + } + return &output.ExitError{ + Code: exitErr.Code, + Detail: &output.ErrDetail{ + Type: exitErr.Detail.Type, + Code: exitErr.Detail.Code, + Message: fmt.Sprintf("%s: %s", msg, exitErr.Detail.Message), + Hint: hint, + ConsoleURL: exitErr.Detail.ConsoleURL, + Risk: exitErr.Detail.Risk, + Detail: mergeSheetErrorDetail(exitErr.Detail.Detail, detail), + }, + Err: err, + Raw: exitErr.Raw, + } + } + + return &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{ + Type: "api_error", + Message: fmt.Sprintf("%s: %v", msg, err), + Hint: hint, + Detail: detail, + }, + Err: err, + } +} + +func validateUpdateSheetFlags(runtime *common.RuntimeContext) error { + if err := validateSheetID("sheet-id", runtime.Str("sheet-id")); err != nil { + return err + } + if runtime.Changed("title") { + if err := validateSheetTitle("title", runtime.Str("title")); err != nil { + return err + } + } + if runtime.Changed("index") { + if err := validateNonNegativeInt("index", runtime.Int("index")); err != nil { + return err + } + } + if runtime.Changed("frozen-row-count") { + if err := validateNonNegativeInt("frozen-row-count", runtime.Int("frozen-row-count")); err != nil { + return err + } + } + if runtime.Changed("frozen-col-count") { + if err := validateNonNegativeInt("frozen-col-count", runtime.Int("frozen-col-count")); err != nil { + return err + } + } + if runtime.Changed("lock-info") { + if err := validate.RejectControlChars(runtime.Str("lock-info"), "lock-info"); err != nil { + return common.FlagErrorf("%v", err) + } + } + + hasProtectConfig := runtime.Changed("lock") || runtime.Changed("lock-info") || runtime.Changed("user-ids") + if hasProtectConfig { + lock := runtime.Str("lock") + if !runtime.Changed("lock") { + return common.FlagErrorf("specify --lock when updating protection settings") + } + if runtime.Changed("lock-info") && lock != "LOCK" { + return common.FlagErrorf("--lock-info requires --lock LOCK") + } + if runtime.Changed("user-ids") { + if lock != "LOCK" { + return common.FlagErrorf("--user-ids requires --lock LOCK") + } + if runtime.Str("user-id-type") == "" { + return common.FlagErrorf("--user-ids requires --user-id-type") + } + userIDs, err := parseJSONStringArray("user-ids", runtime.Str("user-ids")) + if err != nil { + return err + } + if len(userIDs) == 0 { + return common.FlagErrorf("--user-ids must not be empty") + } + } + } + + hasUpdate := runtime.Changed("title") || + runtime.Changed("index") || + runtime.Changed("hidden") || + runtime.Changed("frozen-row-count") || + runtime.Changed("frozen-col-count") || + hasProtectConfig + if !hasUpdate { + return common.FlagErrorf("specify at least one of --title, --index, --hidden, --frozen-row-count, --frozen-col-count, --lock, --lock-info, or --user-ids") + } + + return nil +} + +func buildUpdateSheetBody(runtime *common.RuntimeContext) (map[string]interface{}, error) { + properties := map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + } + + if runtime.Changed("title") { + properties["title"] = runtime.Str("title") + } + if runtime.Changed("index") { + properties["index"] = runtime.Int("index") + } + if runtime.Changed("hidden") { + properties["hidden"] = runtime.Bool("hidden") + } + if runtime.Changed("frozen-row-count") { + properties["frozenRowCount"] = runtime.Int("frozen-row-count") + } + if runtime.Changed("frozen-col-count") { + properties["frozenColCount"] = runtime.Int("frozen-col-count") + } + if runtime.Changed("lock") || runtime.Changed("lock-info") || runtime.Changed("user-ids") { + protect := map[string]interface{}{ + "lock": runtime.Str("lock"), + } + if runtime.Changed("lock-info") { + protect["lockInfo"] = runtime.Str("lock-info") + } + if runtime.Changed("user-ids") { + userIDs, err := parseJSONStringArray("user-ids", runtime.Str("user-ids")) + if err != nil { + return nil, err + } + protect["userIDs"] = userIDs + } + properties["protect"] = protect + } + + return map[string]interface{}{ + "requests": []interface{}{ + map[string]interface{}{ + "updateSheet": map[string]interface{}{ + "properties": properties, + }, + }, + }, + }, nil +} + +func buildUpdateSheetOutput(token string, data map[string]interface{}, titleChanged bool) (map[string]interface{}, bool) { + return buildOperateSheetOutput(token, data, "updateSheet", titleChanged) +} + +var SheetCreateSheet = common.Shortcut{ + Service: "sheets", + Command: "+create-sheet", + Description: "Create a sheet in an existing spreadsheet", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "title", Desc: "sheet title"}, + {Name: "index", Type: "int", Desc: "sheet index (0-based)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + if runtime.Changed("title") { + if err := validateSheetTitle("title", runtime.Str("title")); err != nil { + return err + } + } + if runtime.Changed("index") { + if err := validateNonNegativeInt("index", runtime.Int("index")); err != nil { + return err + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update"). + Body(buildCreateSheetBody(runtime)). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildCreateSheetBody(runtime)) + if err != nil { + return err + } + if out, ok := buildOperateSheetOutput(token, data, "addSheet", runtime.Changed("title")); ok { + runtime.Out(out, nil) + return nil + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetCopySheet = common.Shortcut{ + Service: "sheets", + Command: "+copy-sheet", + Description: "Copy a sheet within a spreadsheet", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "source sheet ID", Required: true}, + {Name: "title", Desc: "new sheet title"}, + {Name: "index", Type: "int", Desc: "new sheet index (0-based)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + if err := validateSheetID("sheet-id", runtime.Str("sheet-id")); err != nil { + return err + } + if runtime.Changed("title") { + if err := validateSheetTitle("title", runtime.Str("title")); err != nil { + return err + } + } + if runtime.Changed("index") { + if err := validateNonNegativeInt("index", runtime.Int("index")); err != nil { + return err + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + dry := common.NewDryRunAPI(). + POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update"). + Desc("[1] Copy sheet"). + Body(buildCopySheetBody(runtime)). + Set("token", token) + if runtime.Changed("index") { + dry.POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update"). + Desc("[2] Move copied sheet to requested index"). + Body(buildMoveCopiedSheetBody("", runtime.Int("index"))). + Set("token", token) + } + return dry + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildCopySheetBody(runtime)) + if err != nil { + return err + } + out, ok := buildOperateSheetOutput(token, data, "copySheet", runtime.Changed("title")) + if !ok { + runtime.Out(data, nil) + return nil + } + if runtime.Changed("index") { + copiedSheetID, _ := out["sheet_id"].(string) + moveResp, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildMoveCopiedSheetBody(copiedSheetID, runtime.Int("index"))) + if err != nil { + return wrapCopySheetMoveError(err, token, copiedSheetID, runtime.Int("index")) + } + if moveOut, ok := buildUpdateSheetOutput(token, moveResp, false); ok { + out = mergeSheetOutputs(out, moveOut) + } + } + runtime.Out(out, nil) + return nil + }, +} + +var SheetDeleteSheet = common.Shortcut{ + Service: "sheets", + Command: "+delete-sheet", + Description: "Delete a sheet from a spreadsheet", + Risk: "high-risk-write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "sheet ID to delete", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + return validateSheetID("sheet-id", runtime.Str("sheet-id")) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update"). + Body(buildDeleteSheetBody(runtime.Str("sheet-id"))). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildDeleteSheetBody(runtime.Str("sheet-id"))) + if err != nil { + return err + } + if out, ok := buildDeleteSheetOutput(token, runtime.Str("sheet-id"), data); ok { + runtime.Out(out, nil) + return nil + } + runtime.Out(data, nil) + return nil + }, +} + +var SheetUpdateSheet = common.Shortcut{ + Service: "sheets", + Command: "+update-sheet", + Description: "Update sheet properties", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "sheet ID", Required: true}, + {Name: "title", Desc: "sheet title"}, + {Name: "index", Type: "int", Desc: "sheet index (0-based)"}, + {Name: "hidden", Type: "bool", Desc: "set true to hide or false to unhide"}, + {Name: "frozen-row-count", Type: "int", Desc: "freeze rows through this count (0 unfreezes)"}, + {Name: "frozen-col-count", Type: "int", Desc: "freeze columns through this count (0 unfreezes)"}, + {Name: "lock", Desc: "sheet protection mode", Enum: sheetProtectLockValues}, + {Name: "lock-info", Desc: "protection remark"}, + {Name: "user-ids", Desc: `extra editor IDs for protected sheet as JSON array (e.g. '["ou_xxx"]')`}, + {Name: "user-id-type", Desc: "user ID type for --user-ids", Enum: []string{"open_id", "union_id", "lark_id", "user_id"}}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + return validateUpdateSheetFlags(runtime) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + body, _ := buildUpdateSheetBody(runtime) + dry := common.NewDryRunAPI(). + POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update"). + Body(body). + Set("token", token) + if userIDType := runtime.Str("user-id-type"); userIDType != "" { + dry.Params(map[string]interface{}{"user_id_type": userIDType}) + } + return dry + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + body, err := buildUpdateSheetBody(runtime) + if err != nil { + return err + } + var params map[string]interface{} + if userIDType := runtime.Str("user-id-type"); userIDType != "" { + params = map[string]interface{}{"user_id_type": userIDType} + } + + data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), params, body) + if err != nil { + return err + } + if out, ok := buildUpdateSheetOutput(token, data, runtime.Changed("title")); ok { + runtime.Out(out, nil) + return nil + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_media_upload_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_media_upload_test.go new file mode 100644 index 000000000..03b104160 --- /dev/null +++ b/shortcuts/sheets/backward/lark_sheets_sheet_media_upload_test.go @@ -0,0 +1,272 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import ( + "bytes" + "encoding/json" + "mime" + "mime/multipart" + "os" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestSheetMediaUploadValidateMissingToken(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetMediaUpload, []string{ + "+media-upload", "--file", "img.png", "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestSheetMediaUploadValidateMissingFileBeforeDryRun(t *testing.T) { + dir := t.TempDir() + withSheetsTestWorkingDir(t, dir) + + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetMediaUpload, []string{ + "+media-upload", + "--spreadsheet-token", "shtSTUB", + "--file", "missing.png", + "--dry-run", "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "file not found") { + t.Fatalf("expected file-not-found error before dry-run planning, got: %v", err) + } +} + +func TestSheetMediaUploadValidateRejectsDirectoryBeforeDryRun(t *testing.T) { + dir := t.TempDir() + withSheetsTestWorkingDir(t, dir) + if err := os.Mkdir("imgdir", 0o755); err != nil { + t.Fatal(err) + } + + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetMediaUpload, []string{ + "+media-upload", + "--spreadsheet-token", "shtSTUB", + "--file", "imgdir", + "--dry-run", "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "regular file") { + t.Fatalf("expected regular-file error before dry-run planning, got: %v", err) + } +} + +func TestSheetMediaUploadDryRunSmallFile(t *testing.T) { + dir := t.TempDir() + withSheetsTestWorkingDir(t, dir) + if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil { + t.Fatal(err) + } + + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetMediaUpload, []string{ + "+media-upload", + "--spreadsheet-token", "shtSTUB", + "--file", "img.png", + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "/open-apis/drive/v1/medias/upload_all") { + t.Fatalf("dry-run should use upload_all for small file, got: %s", out) + } + if !strings.Contains(out, `"sheet_image"`) { + t.Fatalf("dry-run should include parent_type=sheet_image, got: %s", out) + } + if strings.Contains(out, "upload_prepare") { + t.Fatalf("dry-run should not use multipart for small file, got: %s", out) + } +} + +func TestSheetMediaUploadDryRunURLExtractsToken(t *testing.T) { + dir := t.TempDir() + withSheetsTestWorkingDir(t, dir) + if err := os.WriteFile("img.png", []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetMediaUpload, []string{ + "+media-upload", + "--url", "https://example.feishu.cn/sheets/shtFromURL?sheet=abc", + "--file", "img.png", + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "shtFromURL") { + t.Fatalf("dry-run should extract token from URL, got: %s", stdout.String()) + } +} + +func TestSheetMediaUploadDryRunLargeFileUsesMultipart(t *testing.T) { + dir := t.TempDir() + withSheetsTestWorkingDir(t, dir) + // Sparse file: 20MB + 1 byte, triggers multipart path without allocating disk. + largeFile, err := os.Create("big.png") + if err != nil { + t.Fatal(err) + } + if err := largeFile.Truncate(20*1024*1024 + 1); err != nil { + t.Fatal(err) + } + _ = largeFile.Close() + + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err = mountAndRunSheets(t, SheetMediaUpload, []string{ + "+media-upload", + "--spreadsheet-token", "shtSTUB", + "--file", "big.png", + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + for _, want := range []string{ + "/open-apis/drive/v1/medias/upload_prepare", + "/open-apis/drive/v1/medias/upload_part", + "/open-apis/drive/v1/medias/upload_finish", + } { + if !strings.Contains(out, want) { + t.Fatalf("dry-run should include %q for large file, got: %s", want, out) + } + } + if strings.Contains(out, "upload_all") { + t.Fatalf("dry-run should not use upload_all for large file, got: %s", out) + } +} + +func TestSheetMediaUploadExecuteSuccess(t *testing.T) { + dir := t.TempDir() + withSheetsTestWorkingDir(t, dir) + if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil { + t.Fatal(err) + } + + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"file_token": "boxTOK123"}, + }, + } + reg.Register(stub) + + err := mountAndRunSheets(t, SheetMediaUpload, []string{ + "+media-upload", + "--spreadsheet-token", "shtSTUB", + "--file", "img.png", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("parse output: %v", err) + } + data, _ := envelope["data"].(map[string]interface{}) + if data["file_token"] != "boxTOK123" { + t.Fatalf("file_token = %v, want boxTOK123", data["file_token"]) + } + if data["spreadsheet_token"] != "shtSTUB" { + t.Fatalf("spreadsheet_token = %v, want shtSTUB", data["spreadsheet_token"]) + } + + body := decodeSheetsMultipartBody(t, stub) + if got := body.Fields["parent_type"]; got != sheetImageParentType { + t.Fatalf("parent_type = %q, want %q", got, sheetImageParentType) + } + if got := body.Fields["parent_node"]; got != "shtSTUB" { + t.Fatalf("parent_node = %q, want shtSTUB", got) + } + if got := body.Fields["file_name"]; got != "img.png" { + t.Fatalf("file_name = %q, want img.png", got) + } + if got := body.Fields["size"]; got != "9" { + t.Fatalf("size = %q, want 9 (len of png-bytes)", got) + } +} + +func TestSheetMediaUploadFileNotFound(t *testing.T) { + dir := t.TempDir() + withSheetsTestWorkingDir(t, dir) + + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetMediaUpload, []string{ + "+media-upload", + "--spreadsheet-token", "shtSTUB", + "--file", "missing.png", + "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected error for missing file") + } + if !strings.Contains(err.Error(), "file not found") && !strings.Contains(err.Error(), "no such file") { + t.Fatalf("err = %v, want file-not-found error", err) + } +} + +// withSheetsTestWorkingDir chdirs to dir for this test. Not compatible with +// t.Parallel — chdir is process-wide. +func withSheetsTestWorkingDir(t *testing.T, dir string) { + t.Helper() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(cwd) }) +} + +type capturedSheetsMultipart struct { + Fields map[string]string + Files map[string][]byte +} + +func decodeSheetsMultipartBody(t *testing.T, stub *httpmock.Stub) capturedSheetsMultipart { + t.Helper() + contentType := stub.CapturedHeaders.Get("Content-Type") + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil { + t.Fatalf("parse content-type %q: %v", contentType, err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("content type = %q, want multipart/form-data", mediaType) + } + reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"]) + body := capturedSheetsMultipart{Fields: map[string]string{}, Files: map[string][]byte{}} + for { + part, err := reader.NextPart() + if err != nil { + break + } + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(part) + if part.FileName() != "" { + body.Files[part.FormName()] = buf.Bytes() + continue + } + body.Fields[part.FormName()] = buf.String() + } + return body +} diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_ranges_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_ranges_test.go new file mode 100644 index 000000000..6aaea0130 --- /dev/null +++ b/shortcuts/sheets/backward/lark_sheets_sheet_ranges_test.go @@ -0,0 +1,268 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func mustMarshalSheetsDryRun(t *testing.T, v interface{}) string { + t.Helper() + + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + return string(b) +} + +func newSheetsTestRuntime(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext { + t.Helper() + + cmd := &cobra.Command{Use: "test"} + for name := range stringFlags { + cmd.Flags().String(name, "", "") + } + for name := range boolFlags { + cmd.Flags().Bool(name, false, "") + } + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + for name, value := range stringFlags { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("Flags().Set(%q) error = %v", name, err) + } + } + for name, value := range boolFlags { + if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[value]); err != nil { + t.Fatalf("Flags().Set(%q) error = %v", name, err) + } + } + return &common.RuntimeContext{Cmd: cmd} +} + +func TestNormalizeSheetRangeSeparators(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + }{ + {name: "standard", input: "sheet_123!A1:B2", want: "sheet_123!A1:B2"}, + {name: "escaped ascii", input: `sheet_123\!A1:B2`, want: "sheet_123!A1:B2"}, + {name: "fullwidth", input: "sheet_123!A1:B2", want: "sheet_123!A1:B2"}, + {name: "escaped fullwidth", input: `sheet_123\!A1:B2`, want: "sheet_123!A1:B2"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := normalizeSheetRangeSeparators(tt.input); got != tt.want { + t.Fatalf("normalizeSheetRangeSeparators(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestValidateSheetRangeInputAcceptsEscapedSeparator(t *testing.T) { + t.Parallel() + + if err := validateSheetRangeInput("", `sheet_123\!A1:B2`); err != nil { + t.Fatalf("validateSheetRangeInput() error = %v, want nil", err) + } +} + +func TestSheetReadDryRunNormalizesEscapedSeparator(t *testing.T) { + t.Parallel() + + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "sht_test", + "range": `sheet_123\!A1`, + "sheet-id": "", + }, nil) + + got := mustMarshalSheetsDryRun(t, SheetRead.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"range":"sheet_123!A1:A1"`) { + t.Fatalf("SheetRead.DryRun() = %s, want normalized escaped separator", got) + } +} + +func TestSheetWriteDryRunNormalizesEscapedSeparator(t *testing.T) { + t.Parallel() + + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "sht_test", + "range": `sheet_123\!A1:B2`, + "values": `[[1,2],[3,4]]`, + }, nil) + + got := mustMarshalSheetsDryRun(t, SheetWrite.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"range":"sheet_123!A1:B2"`) { + t.Fatalf("SheetWrite.DryRun() = %s, want normalized escaped separator", got) + } +} + +func TestSheetAppendDryRunNormalizesEscapedSeparator(t *testing.T) { + t.Parallel() + + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "sht_test", + "range": `sheet_123\!A1:B2`, + "values": `[["foo","bar"]]`, + }, nil) + + got := mustMarshalSheetsDryRun(t, SheetAppend.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"range":"sheet_123!A1:B2"`) { + t.Fatalf("SheetAppend.DryRun() = %s, want normalized escaped separator", got) + } +} + +func TestSheetFindDryRunNormalizesEscapedSeparator(t *testing.T) { + t.Parallel() + + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "sht_test", + "sheet-id": "sheet_123", + "find": "target", + "range": `sheet_123\!A1:B2`, + }, map[string]bool{ + "ignore-case": false, + "match-entire-cell": false, + "search-by-regex": false, + "include-formulas": false, + }) + + got := mustMarshalSheetsDryRun(t, SheetFind.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"range":"sheet_123!A1:B2"`) { + t.Fatalf("SheetFind.DryRun() = %s, want normalized escaped separator", got) + } +} + +func TestSheetFindValidateMismatchedRangeSheetID(t *testing.T) { + t.Parallel() + + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", "find": "target", + "range": "sheet2!A1:B2", + }, map[string]bool{ + "ignore-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false, + }) + err := SheetFind.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "does not match --sheet-id") { + t.Fatalf("expected mismatch error, got: %v", err) + } +} + +func TestCellDataValidateRejectsURLAndTokenTogether(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + shortcut common.Shortcut + strFlags map[string]string + boolFlags map[string]bool + }{ + { + name: "read", + shortcut: SheetRead, + strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN"}, + }, + { + name: "write", + shortcut: SheetWrite, + strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "values": `[[1]]`}, + }, + { + name: "append", + shortcut: SheetAppend, + strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "values": `[[1]]`}, + }, + { + name: "find", + shortcut: SheetFind, + strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "find": "x"}, + boolFlags: map[string]bool{"ignore-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false}, + }, + { + name: "replace", + shortcut: SheetReplace, + strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "find": "a", "replacement": "b"}, + boolFlags: map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, tt.strFlags, tt.boolFlags) + err := tt.shortcut.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("expected mutual exclusivity error, got: %v", err) + } + }) + } +} + +func TestCellDataValidateRejectsInvalidSpreadsheetURL(t *testing.T) { + t.Parallel() + + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "https://example.feishu.cn/docx/doxcnNotSheet", + "spreadsheet-token": "", + }, nil) + err := SheetRead.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "spreadsheet URL") { + t.Fatalf("expected invalid spreadsheet URL error, got: %v", err) + } +} + +func TestCellDataValidateRejectsNon2DValues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + shortcut common.Shortcut + strFlags map[string]string + }{ + { + name: "write 1d array", + shortcut: SheetWrite, + strFlags: map[string]string{"spreadsheet-token": "sht1", "values": `[1,2]`}, + }, + { + name: "write object", + shortcut: SheetWrite, + strFlags: map[string]string{"spreadsheet-token": "sht1", "values": `{"a":1}`}, + }, + { + name: "append string", + shortcut: SheetAppend, + strFlags: map[string]string{"spreadsheet-token": "sht1", "values": `"x"`}, + }, + { + name: "append null", + shortcut: SheetAppend, + strFlags: map[string]string{"spreadsheet-token": "sht1", "values": `null`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, tt.strFlags, nil) + err := tt.shortcut.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "must be a 2D array") { + t.Fatalf("expected 2D-array validation error, got: %v", err) + } + }) + } +} diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_write_image_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_write_image_test.go new file mode 100644 index 000000000..02b6701a4 --- /dev/null +++ b/shortcuts/sheets/backward/lark_sheets_sheet_write_image_test.go @@ -0,0 +1,632 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import ( + "bytes" + "context" + "encoding/json" + "io" + "io/fs" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func sheetsTestConfig() *core.CliConfig { + return &core.CliConfig{ + AppID: "sheets-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + } +} + +func mountAndRunSheets(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error { + t.Helper() + parent := &cobra.Command{Use: "sheets"} + s.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +type sheetWriteImageStaticFileIOProvider struct { + fio fileio.FileIO +} + +func (p *sheetWriteImageStaticFileIOProvider) Name() string { return "sheet-write-image-static" } + +func (p *sheetWriteImageStaticFileIOProvider) ResolveFileIO(context.Context) fileio.FileIO { + return p.fio +} + +type sheetWriteImageMemoryFileIO struct { + files map[string][]byte +} + +func (f *sheetWriteImageMemoryFileIO) Open(name string) (fileio.File, error) { + data, ok := f.files[name] + if !ok { + return nil, os.ErrNotExist + } + return sheetWriteImageMemoryFile{Reader: bytes.NewReader(data)}, nil +} + +func (f *sheetWriteImageMemoryFileIO) Stat(name string) (fileio.FileInfo, error) { + data, ok := f.files[name] + if !ok { + return nil, os.ErrNotExist + } + return sheetWriteImageFileInfo{size: int64(len(data))}, nil +} + +func (f *sheetWriteImageMemoryFileIO) ResolvePath(path string) (string, error) { return path, nil } + +func (f *sheetWriteImageMemoryFileIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) { + return nil, nil +} + +type sheetWriteImageMemoryFile struct { + *bytes.Reader +} + +func (sheetWriteImageMemoryFile) Close() error { return nil } + +type sheetWriteImageFileInfo struct { + size int64 +} + +func (i sheetWriteImageFileInfo) Size() int64 { return i.size } +func (i sheetWriteImageFileInfo) IsDir() bool { return false } +func (i sheetWriteImageFileInfo) Mode() fs.FileMode { return 0 } + +const existingWriteImageTestFile = "./lark_sheets_cell_images.go" + +// ── Validate ───────────────────────────────────────────────────────────────── + +func TestSheetWriteImageValidateRequiresToken(t *testing.T) { + t.Parallel() + runtime := newSheetsTestRuntime(t, map[string]string{ + "image": "./logo.png", + "range": "A1", + }, nil) + err := SheetWriteImage.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestSheetWriteImageValidateAcceptsURL(t *testing.T) { + t.Parallel() + runtime := newSheetsTestRuntime(t, map[string]string{ + "url": "https://example.larksuite.com/sheets/shtABC123", + "image": existingWriteImageTestFile, + "range": "sheetId!A1:A1", + "sheet-id": "", + }, nil) + err := SheetWriteImage.Validate(context.Background(), runtime) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetWriteImageValidateAcceptsSpreadsheetToken(t *testing.T) { + t.Parallel() + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "shtABC123", + "image": existingWriteImageTestFile, + "range": "sheetId!A1:A1", + "sheet-id": "", + }, nil) + err := SheetWriteImage.Validate(context.Background(), runtime) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetWriteImageValidateRejectsRelativeRangeWithoutSheetID(t *testing.T) { + t.Parallel() + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "shtABC123", + "image": "./logo.png", + "range": "A1", + "sheet-id": "", + }, nil) + err := SheetWriteImage.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--sheet-id") { + t.Fatalf("expected sheet-id error, got: %v", err) + } +} + +func TestSheetWriteImageValidateAcceptsRelativeRangeWithSheetID(t *testing.T) { + t.Parallel() + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "shtABC123", + "image": existingWriteImageTestFile, + "range": "A1", + "sheet-id": "sheet1", + }, nil) + err := SheetWriteImage.Validate(context.Background(), runtime) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetWriteImageValidateRejectsMultiCellRange(t *testing.T) { + t.Parallel() + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "shtABC123", + "image": "./logo.png", + "range": "sheet1!A1:B2", + "sheet-id": "", + }, nil) + err := SheetWriteImage.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "single cell") { + t.Fatalf("expected single cell error, got: %v", err) + } +} + +func TestSheetWriteImageValidateAcceptsSameCellSpan(t *testing.T) { + t.Parallel() + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "shtABC123", + "image": existingWriteImageTestFile, + "range": "sheet1!A1:A1", + "sheet-id": "", + }, nil) + err := SheetWriteImage.Validate(context.Background(), runtime) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// ── DryRun ─────────────────────────────────────────────────────────────────── + +func TestSheetWriteImageDryRun(t *testing.T) { + t.Parallel() + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "sht_test", + "range": "sheet1!B2", + "sheet-id": "", + "image": "./chart.png", + "name": "", + "url": "", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetWriteImage.DryRun(context.Background(), runtime)) + + if !strings.Contains(got, `"range":"sheet1!B2:B2"`) { + t.Fatalf("DryRun range not normalized: %s", got) + } + if !strings.Contains(got, `"name":"chart.png"`) { + t.Fatalf("DryRun name not derived from image path: %s", got) + } + // JSON escapes < and > to \u003c and \u003e. + if !strings.Contains(got, `binary: ./chart.png`) { + t.Fatalf("DryRun image field not showing binary placeholder: %s", got) + } + if !strings.Contains(got, `"description":"JSON upload with inline image bytes"`) { + t.Fatalf("DryRun description incorrect: %s", got) + } +} + +func TestSheetWriteImageDryRunCustomName(t *testing.T) { + t.Parallel() + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "sht_test", + "range": "sheet1!A1:A1", + "sheet-id": "", + "image": "./output.png", + "name": "revenue_chart.png", + "url": "", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetWriteImage.DryRun(context.Background(), runtime)) + + if !strings.Contains(got, `"name":"revenue_chart.png"`) { + t.Fatalf("DryRun should use custom name: %s", got) + } +} + +func TestSheetWriteImageDryRunUsesURL(t *testing.T) { + t.Parallel() + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "", + "range": "sheet1!C3", + "sheet-id": "", + "image": "./logo.png", + "name": "", + "url": "https://example.larksuite.com/sheets/shtFromURL", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetWriteImage.DryRun(context.Background(), runtime)) + + if !strings.Contains(got, `shtFromURL`) { + t.Fatalf("DryRun should extract token from URL: %s", got) + } + if !strings.Contains(got, `"range":"sheet1!C3:C3"`) { + t.Fatalf("DryRun range not normalized: %s", got) + } +} + +func TestSheetWriteImageDryRunWithSheetID(t *testing.T) { + t.Parallel() + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "sht_test", + "range": "A1", + "sheet-id": "mySheet", + "image": "./img.png", + "name": "", + "url": "", + }, nil) + got := mustMarshalSheetsDryRun(t, SheetWriteImage.DryRun(context.Background(), runtime)) + + if !strings.Contains(got, `"range":"mySheet!A1:A1"`) { + t.Fatalf("DryRun should normalize relative range with sheet-id: %s", got) + } +} + +func TestSheetWriteImageDryRunDoesNotValidateImageFile(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetWriteImage, []string{ + "+write-image", + "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:A1", + "--image", "/__bridge_url__/qKrk1wSAtS", + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("dry-run should not stat or open image files, got: %v", err) + } + if !strings.Contains(stdout.String(), "/__bridge_url__/qKrk1wSAtS") { + t.Fatalf("dry-run output should preserve image path: %s", stdout.String()) + } +} + +// ── Execute ────────────────────────────────────────────────────────────────── + +func TestSheetWriteImageExecuteSendsJSON(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/values_image", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "spreadsheetToken": "shtTOKEN", + "revision": float64(5), + "updateRange": "sheet1!A1:A1", + }, + }, + } + reg.Register(stub) + + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + + // Create a small test image file. + imgData := []byte{0x89, 0x50, 0x4E, 0x47} // PNG magic bytes + if err := os.WriteFile("test.png", imgData, 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunSheets(t, SheetWriteImage, []string{ + "+write-image", + "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:A1", + "--image", "./test.png", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify the request was sent as JSON (not multipart/form-data). + if stub.CapturedHeaders == nil { + t.Fatal("request headers not captured") + } + ct := stub.CapturedHeaders.Get("Content-Type") + if !strings.Contains(ct, "application/json") { + t.Fatalf("Content-Type = %q, want application/json", ct) + } + + // Verify the captured body contains the image as base64 in JSON. + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("request body is not valid JSON: %v", err) + } + if body["range"] != "sheet1!A1:A1" { + t.Fatalf("body range = %v, want sheet1!A1:A1", body["range"]) + } + if body["name"] != "test.png" { + t.Fatalf("body name = %v, want test.png", body["name"]) + } + if body["image"] == nil { + t.Fatal("body image field is nil") + } + + // Verify output contains expected fields. + if !strings.Contains(stdout.String(), "spreadsheetToken") { + t.Fatalf("stdout missing spreadsheetToken: %s", stdout.String()) + } +} + +func TestSheetWriteImageExecuteUsesFileIOForBridgeSentinelPath(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + imagePath := "/__bridge_url__/qKrk1wSAtS" + imageData := []byte{0x89, 0x50, 0x4E, 0x47} + f.FileIOProvider = &sheetWriteImageStaticFileIOProvider{ + fio: &sheetWriteImageMemoryFileIO{ + files: map[string][]byte{imagePath: imageData}, + }, + } + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/values_image", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "spreadsheetToken": "shtTOKEN", + "revision": float64(5), + "updateRange": "sheet1!A1:A1", + }, + }, + } + reg.Register(stub) + + err := mountAndRunSheets(t, SheetWriteImage, []string{ + "+write-image", + "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:A1", + "--image", imagePath, + "--name", "bridge.png", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("request body is not valid JSON: %v", err) + } + if body["name"] != "bridge.png" { + t.Fatalf("body name = %v, want bridge.png", body["name"]) + } + if body["image"] == nil { + t.Fatal("body image field is nil") + } +} + +func TestSheetWriteImageExecuteRejectsNonexistentFile(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + + err := mountAndRunSheets(t, SheetWriteImage, []string{ + "+write-image", + "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:A1", + "--image", "./nonexistent.png", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected error for nonexistent file, got nil") + } + if !strings.Contains(err.Error(), "not found") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetWriteImageExecuteRejectsDirectory(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + + // Create a directory where the image path points. + if err := os.Mkdir("not_a_file", 0755); err != nil { + t.Fatalf("Mkdir() error: %v", err) + } + + err := mountAndRunSheets(t, SheetWriteImage, []string{ + "+write-image", + "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:A1", + "--image", "./not_a_file", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected error for directory, got nil") + } + if !strings.Contains(err.Error(), "regular file") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetWriteImageExecuteWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/values_image", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "spreadsheetToken": "shtFromURL", + "revision": float64(1), + "updateRange": "sheet1!B2:B2", + }, + }, + } + reg.Register(stub) + + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + + if err := os.WriteFile("pic.png", []byte{0x89, 0x50, 0x4E, 0x47}, 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunSheets(t, SheetWriteImage, []string{ + "+write-image", + "--url", "https://example.larksuite.com/sheets/shtFromURL", + "--range", "sheet1!B2:B2", + "--image", "./pic.png", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "shtFromURL") { + t.Fatalf("stdout missing token: %s", stdout.String()) + } +} + +func TestSheetWriteImageExecuteCustomName(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/values_image", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "spreadsheetToken": "shtTOKEN", + "revision": float64(2), + "updateRange": "sheet1!A1:A1", + }, + }, + } + reg.Register(stub) + + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + + if err := os.WriteFile("raw.png", []byte{0x89, 0x50, 0x4E, 0x47}, 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunSheets(t, SheetWriteImage, []string{ + "+write-image", + "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:A1", + "--image", "./raw.png", + "--name", "custom_chart.png", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("request body is not valid JSON: %v", err) + } + if body["name"] != "custom_chart.png" { + t.Fatalf("body name = %v, want custom_chart.png", body["name"]) + } +} + +func TestSheetWriteImageExecuteAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/values_image", + Status: 400, + Body: map[string]interface{}{ + "code": 90001, + "msg": "invalid range", + }, + }) + + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + + if err := os.WriteFile("bad.png", []byte{0x89, 0x50}, 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunSheets(t, SheetWriteImage, []string{ + "+write-image", + "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:A1", + "--image", "./bad.png", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected API error, got nil") + } +} + +func TestSheetWriteImageExecuteRejectsOversizedFile(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + + // Create a sparse file that reports > 20MB without writing actual data. + fh, err := os.Create("huge.png") + if err != nil { + t.Fatalf("Create() error: %v", err) + } + if err := fh.Truncate(21 * 1024 * 1024); err != nil { + t.Fatalf("Truncate() error: %v", err) + } + fh.Close() + + err = mountAndRunSheets(t, SheetWriteImage, []string{ + "+write-image", + "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:A1", + "--image", "./huge.png", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected error for oversized file, got nil") + } + if !strings.Contains(err.Error(), "exceeds 20MB limit") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetWriteImageExecuteRejectsAbsolutePath(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + + if err := os.WriteFile("abs.png", []byte{0x89, 0x50}, 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunSheets(t, SheetWriteImage, []string{ + "+write-image", + "--spreadsheet-token", "shtTOKEN", + "--range", "sheet1!A1:A1", + "--image", "/etc/passwd", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected error for absolute path, got nil") + } + if !strings.Contains(err.Error(), "unsafe image path") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go b/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go new file mode 100644 index 000000000..5aa1fdaec --- /dev/null +++ b/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go @@ -0,0 +1,323 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var SheetInfo = common.Shortcut{ + Service: "sheets", + Command: "+info", + Description: "View spreadsheet metadata and sheet information", + Risk: "read", + Scopes: []string{"sheets:spreadsheet.meta:read", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + if token == "" { + return common.FlagErrorf("specify --url or --spreadsheet-token") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + return common.NewDryRunAPI(). + GET("/open-apis/sheets/v3/spreadsheets/:token"). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + + spreadsheetData, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s", validate.EncodePathSegment(token)), nil, nil) + if err != nil { + return err + } + + var sheetsData interface{} + sheetsResult, sheetsErr := runtime.RawAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(token)), nil, nil) + if sheetsErr == nil { + if sheetsMap, ok := sheetsResult.(map[string]interface{}); ok { + if d, ok := sheetsMap["data"].(map[string]interface{}); ok { + sheetsData = d + } + } + } + + runtime.Out(map[string]interface{}{ + "spreadsheet": spreadsheetData, + "sheets": sheetsData, + }, nil) + return nil + }, +} + +var SheetCreate = common.Shortcut{ + Service: "sheets", + Command: "+create", + Description: "Create a spreadsheet (optional header row and initial data)", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:create", "sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "title", Desc: "spreadsheet title", Required: true}, + {Name: "folder-token", Desc: "target folder token"}, + {Name: "headers", Desc: "header row JSON array"}, + {Name: "data", Desc: "initial data JSON 2D array"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if headersStr := runtime.Str("headers"); headersStr != "" { + var headers []interface{} + if err := json.Unmarshal([]byte(headersStr), &headers); err != nil { + return common.FlagErrorf("--headers invalid JSON, must be a 1D array") + } + } + if dataStr := runtime.Str("data"); dataStr != "" { + var rows [][]interface{} + if err := json.Unmarshal([]byte(dataStr), &rows); err != nil { + return common.FlagErrorf("--data invalid JSON, must be a 2D array") + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{"title": runtime.Str("title")} + if folderToken := runtime.Str("folder-token"); folderToken != "" { + body["folder_token"] = folderToken + } + d := common.NewDryRunAPI(). + POST("/open-apis/sheets/v3/spreadsheets"). + Body(body) + if runtime.IsBot() { + d.Desc("After spreadsheet creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new spreadsheet.") + } + return d + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + title := runtime.Str("title") + folderToken := runtime.Str("folder-token") + headersStr := runtime.Str("headers") + dataStr := runtime.Str("data") + var allRows []interface{} + + if headersStr != "" { + var headers []interface{} + if err := json.Unmarshal([]byte(headersStr), &headers); err != nil { + return common.FlagErrorf("--headers invalid JSON, must be a 1D array") + } + if len(headers) > 0 { + allRows = append(allRows, any(headers)) + } + } + + if dataStr != "" { + var rows []interface{} + if err := json.Unmarshal([]byte(dataStr), &rows); err != nil { + return common.FlagErrorf("--data invalid JSON, must be a 2D array") + } + if len(rows) > 0 { + allRows = append(allRows, rows...) + } + } + + createData := map[string]interface{}{"title": title} + if folderToken != "" { + createData["folder_token"] = folderToken + } + + data, err := runtime.CallAPI("POST", "/open-apis/sheets/v3/spreadsheets", nil, createData) + if err != nil { + return err + } + + spreadsheet, _ := data["spreadsheet"].(map[string]interface{}) + token, _ := spreadsheet["spreadsheet_token"].(string) + + if len(allRows) > 0 && token != "" { + appendRange, err := getFirstSheetID(runtime, token) + if err != nil { + return err + } + if _, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{ + "valueRange": map[string]interface{}{ + "range": appendRange, + "values": allRows, + }, + }); err != nil { + return err + } + } + + out := map[string]interface{}{ + "spreadsheet_token": token, + "title": title, + } + url, _ := spreadsheet["url"].(string) + if url = strings.TrimSpace(url); url != "" { + out["url"] = url + } else if u := common.BuildResourceURL(runtime.Config.Brand, "sheet", token); u != "" { + out["url"] = u + } + if grant := common.AutoGrantCurrentUserDrivePermission(runtime, token, "sheet"); grant != nil { + out["permission_grant"] = grant + } + + runtime.Out(out, nil) + return nil + }, +} + +var SheetExport = common.Shortcut{ + Service: "sheets", + Command: "+export", + Description: "Export a spreadsheet (async task polling + optional download)", + Risk: "read", + Scopes: []string{"docs:document:export", "drive:file:download"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "file-extension", Desc: "export format: xlsx | csv", Required: true, Enum: []string{"xlsx", "csv"}}, + {Name: "output-path", Desc: "local save path"}, + {Name: "sheet-id", Desc: "sheet ID (required for CSV)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateSheetManageToken(runtime); err != nil { + return err + } + if runtime.Str("file-extension") == "csv" && strings.TrimSpace(runtime.Str("sheet-id")) == "" { + return common.FlagErrorf("--sheet-id is required when --file-extension is csv") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := validateSheetManageToken(runtime) + body := map[string]interface{}{ + "token": token, + "type": "sheet", + "file_extension": runtime.Str("file-extension"), + } + if sheetID := strings.TrimSpace(runtime.Str("sheet-id")); sheetID != "" { + body["sub_id"] = sheetID + } + return common.NewDryRunAPI(). + POST("/open-apis/drive/v1/export_tasks"). + Body(body). + Set("token", token).Set("ext", runtime.Str("file-extension")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, _ := validateSheetManageToken(runtime) + + fileExt := runtime.Str("file-extension") + outputPath := runtime.Str("output-path") + sheetID := runtime.Str("sheet-id") + + if outputPath != "" { + if _, err := runtime.ResolveSavePath(outputPath); err != nil { + return output.ErrValidation("unsafe output path: %s", err) + } + } + + exportData := map[string]interface{}{ + "token": token, + "type": "sheet", + "file_extension": fileExt, + } + if sheetID != "" { + exportData["sub_id"] = sheetID + } + + data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, exportData) + if err != nil { + return err + } + ticket, _ := data["ticket"].(string) + + fmt.Fprintf(runtime.IO().ErrOut, "Waiting for export task to complete...\n") + var fileToken string + for i := 0; i < 50; i++ { + time.Sleep(600 * time.Millisecond) + pollResult, err := runtime.RawAPI("GET", "/open-apis/drive/v1/export_tasks/"+ticket, map[string]interface{}{"token": token}, nil) + if err != nil { + continue + } + pollMap, _ := pollResult.(map[string]interface{}) + pollData, _ := pollMap["data"].(map[string]interface{}) + pollResult2, _ := pollData["result"].(map[string]interface{}) + if pollResult2 != nil { + ft, _ := pollResult2["file_token"].(string) + if ft != "" { + fileToken = ft + break + } + } + } + + if fileToken == "" { + return output.Errorf(output.ExitAPI, "api_error", "export task timed out") + } + + fmt.Fprintf(runtime.IO().ErrOut, "Export complete: file_token=%s\n", fileToken) + + if outputPath == "" { + runtime.Out(map[string]interface{}{ + "file_token": fileToken, + "ticket": ticket, + }, nil) + return nil + } + + resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)), + }) + if err != nil { + return output.ErrNetwork("download failed: %s", err) + } + defer resp.Body.Close() + + result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{ + ContentType: resp.Header.Get("Content-Type"), + ContentLength: resp.ContentLength, + }, resp.Body) + if err != nil { + return common.WrapSaveErrorByCategory(err, "io") + } + + savedPath, _ := runtime.ResolveSavePath(outputPath) + if savedPath == "" { + savedPath = outputPath + } + runtime.Out(map[string]interface{}{ + "saved_path": savedPath, + "size_bytes": result.Size(), + }, nil) + return nil + }, +} diff --git a/shortcuts/sheets/backward/shortcuts.go b/shortcuts/sheets/backward/shortcuts.go new file mode 100644 index 000000000..8fd8bb2ac --- /dev/null +++ b/shortcuts/sheets/backward/shortcuts.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import "github.com/larksuite/cli/shortcuts/common" + +// Shortcuts returns all sheets shortcuts. +func Shortcuts() []common.Shortcut { + return []common.Shortcut{ + // Spreadsheet management + SheetCreate, + SheetInfo, + SheetExport, + + // Sheet management + SheetCreateSheet, + SheetCopySheet, + SheetDeleteSheet, + SheetUpdateSheet, + + // Cell data + SheetRead, + SheetWrite, + SheetAppend, + SheetFind, + SheetReplace, + + // Cell style and merge + SheetSetStyle, + SheetBatchSetStyle, + SheetMergeCells, + SheetUnmergeCells, + + // Cell images + SheetWriteImage, + + // Row/column management + SheetAddDimension, + SheetInsertDimension, + SheetUpdateDimension, + SheetMoveDimension, + SheetDeleteDimension, + + // Filter views + SheetCreateFilterView, + SheetUpdateFilterView, + SheetListFilterViews, + SheetGetFilterView, + SheetDeleteFilterView, + SheetCreateFilterViewCondition, + SheetUpdateFilterViewCondition, + SheetListFilterViewConditions, + SheetGetFilterViewCondition, + SheetDeleteFilterViewCondition, + + // Dropdown + SheetSetDropdown, + SheetUpdateDropdown, + SheetGetDropdown, + SheetDeleteDropdown, + + // Float images + SheetMediaUpload, + SheetCreateFloatImage, + SheetUpdateFloatImage, + SheetGetFloatImage, + SheetListFloatImages, + SheetDeleteFloatImage, + } +} From 4c24c6eb9433ba0bd4ca122aa7f99b71861e8f28 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Tue, 2 Jun 2026 20:23:05 +0800 Subject: [PATCH 093/114] =?UTF-8?q?fix(sheets):=20resolve=2030=20golangci-?= =?UTF-8?q?lint=20v2.1.6=20issues=20=E2=80=94=20copyloopvar,=20nilerr,=20u?= =?UTF-8?q?nused?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed 25 Go 1.22+ loop variable copies (copyloopvar) from test files where tc := tc / tt := tt / c := c are no longer needed. Fixed 4 nilerr false positives in flag_schema_validate.go by making intentional error discards explicit (schema validation failures skip silently — best-effort guard). Dropped unused batchOpDispatchKeys helper in batch_op_dispatch.go. --- shortcuts/sheets/batch_op_contract_test.go | 4 ---- shortcuts/sheets/batch_op_dispatch.go | 9 --------- shortcuts/sheets/flag_schema.go | 2 +- shortcuts/sheets/flag_schema_validate.go | 12 +++++------- shortcuts/sheets/flag_schema_validate_test.go | 1 - shortcuts/sheets/lark_sheet_batch_update_test.go | 1 - shortcuts/sheets/lark_sheet_object_crud_test.go | 3 --- shortcuts/sheets/lark_sheet_object_list_test.go | 1 - shortcuts/sheets/lark_sheet_range_operations_test.go | 3 --- shortcuts/sheets/lark_sheet_read_data_test.go | 2 -- shortcuts/sheets/lark_sheet_search_replace_test.go | 1 - shortcuts/sheets/lark_sheet_sheet_structure_test.go | 3 --- shortcuts/sheets/lark_sheet_workbook_test.go | 4 ---- shortcuts/sheets/lark_sheet_write_cells_test.go | 2 -- 14 files changed, 6 insertions(+), 42 deletions(-) diff --git a/shortcuts/sheets/batch_op_contract_test.go b/shortcuts/sheets/batch_op_contract_test.go index d758c0dcc..f8ade3f1e 100644 --- a/shortcuts/sheets/batch_op_contract_test.go +++ b/shortcuts/sheets/batch_op_contract_test.go @@ -278,7 +278,6 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) { } for _, tc := range cases { - tc := tc t.Run(tc.shortcut, func(t *testing.T) { t.Parallel() @@ -424,7 +423,6 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) { } for _, tc := range cases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -584,7 +582,6 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) { } for _, tc := range cases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() var subInput map[string]interface{} @@ -651,7 +648,6 @@ func TestBatchOp_SchemaValidatesSubOps(t *testing.T) { } for _, tc := range cases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() var subInput map[string]interface{} diff --git a/shortcuts/sheets/batch_op_dispatch.go b/shortcuts/sheets/batch_op_dispatch.go index f0b36e64e..82a0cd258 100644 --- a/shortcuts/sheets/batch_op_dispatch.go +++ b/shortcuts/sheets/batch_op_dispatch.go @@ -321,12 +321,3 @@ func translateBatchOperations(rawOps []interface{}, token string) ([]interface{} } return out, nil } - -// 仅供测试 / 调试:暴露已知 shortcut 列表,便于做 enum 漂移对账。 -func batchOpDispatchKeys() []string { - keys := make([]string, 0, len(batchOpDispatch)) - for k := range batchOpDispatch { - keys = append(keys, k) - } - return keys -} diff --git a/shortcuts/sheets/flag_schema.go b/shortcuts/sheets/flag_schema.go index bcd692c4f..4fce37408 100644 --- a/shortcuts/sheets/flag_schema.go +++ b/shortcuts/sheets/flag_schema.go @@ -117,7 +117,7 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) { // Reformat for readability — schema files store compact JSON. var pretty interface{} if err := json.Unmarshal(schema, &pretty); err != nil { - return schema, nil + return nil, err } return json.MarshalIndent(pretty, "", " ") } diff --git a/shortcuts/sheets/flag_schema_validate.go b/shortcuts/sheets/flag_schema_validate.go index cad22b1a5..06d777c83 100644 --- a/shortcuts/sheets/flag_schema_validate.go +++ b/shortcuts/sheets/flag_schema_validate.go @@ -75,8 +75,8 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err if command == "" { return nil } - idx, err := loadFlagSchemas() - if err != nil || idx == nil { + idx, _ := loadFlagSchemas() + if idx == nil { return nil } entry, ok := idx.Flags[command] @@ -88,9 +88,7 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err return nil } var schema schemaProperty - if err := json.Unmarshal(raw, &schema); err != nil { - return nil - } + json.Unmarshal(raw, &schema) if vErr := validateAgainstSchema(value, &schema, ""); vErr != nil { return common.FlagErrorf("--%s: %s", name, vErr.Error()) } @@ -113,8 +111,8 @@ func validateInputAgainstSchema(fv flagView, input map[string]interface{}) error if command == "" { return nil } - idx, err := loadFlagSchemas() - if err != nil || idx == nil { + idx, _ := loadFlagSchemas() + if idx == nil { return nil } entry, ok := idx.Flags[command] diff --git a/shortcuts/sheets/flag_schema_validate_test.go b/shortcuts/sheets/flag_schema_validate_test.go index 409d01929..1646950bc 100644 --- a/shortcuts/sheets/flag_schema_validate_test.go +++ b/shortcuts/sheets/flag_schema_validate_test.go @@ -226,7 +226,6 @@ func TestValidateAgainstSchema(t *testing.T) { } for _, tc := range cases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() s := parseSchema(t, tc.schema) diff --git a/shortcuts/sheets/lark_sheet_batch_update_test.go b/shortcuts/sheets/lark_sheet_batch_update_test.go index a9ef16ac3..0d957fca4 100644 --- a/shortcuts/sheets/lark_sheet_batch_update_test.go +++ b/shortcuts/sheets/lark_sheet_batch_update_test.go @@ -384,7 +384,6 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) { }, } for _, tc := range cases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{ diff --git a/shortcuts/sheets/lark_sheet_object_crud_test.go b/shortcuts/sheets/lark_sheet_object_crud_test.go index c5bc4480b..728198298 100644 --- a/shortcuts/sheets/lark_sheet_object_crud_test.go +++ b/shortcuts/sheets/lark_sheet_object_crud_test.go @@ -434,7 +434,6 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() body := parseDryRunBody(t, tt.sc, tt.args) @@ -579,7 +578,6 @@ func TestObjectCreate_RequiresSheetSelector(t *testing.T) { {"filter-view", FilterViewCreate, []string{"--url", testURL, "--properties", `{}`, "--range", "A1:F10"}}, } for _, tt := range cases { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() _, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args) @@ -659,7 +657,6 @@ func TestObjectDelete_AllHighRisk(t *testing.T) { {"float-image", FloatImageDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--float-image-id", "x"}}, } for _, tt := range cases { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args) diff --git a/shortcuts/sheets/lark_sheet_object_list_test.go b/shortcuts/sheets/lark_sheet_object_list_test.go index 4df0973db..25f82fd65 100644 --- a/shortcuts/sheets/lark_sheet_object_list_test.go +++ b/shortcuts/sheets/lark_sheet_object_list_test.go @@ -101,7 +101,6 @@ func TestObjectListShortcuts_DryRun(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() body := parseDryRunBody(t, tt.sc, tt.args) diff --git a/shortcuts/sheets/lark_sheet_range_operations_test.go b/shortcuts/sheets/lark_sheet_range_operations_test.go index 909757147..e0f464709 100644 --- a/shortcuts/sheets/lark_sheet_range_operations_test.go +++ b/shortcuts/sheets/lark_sheet_range_operations_test.go @@ -256,7 +256,6 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() body := parseDryRunBody(t, tt.sc, tt.args) @@ -285,7 +284,6 @@ func TestRangeSort_RejectsMalformedKeys(t *testing.T) { {"non-object item", `["B"]`, `[0]: expected type "object"`}, } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() stdout, stderr, err := runShortcutCapturingErr(t, RangeSort, []string{ @@ -348,7 +346,6 @@ func TestResize_TypeAndSizeGuards(t *testing.T) { }, } for _, tt := range cases { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run")) diff --git a/shortcuts/sheets/lark_sheet_read_data_test.go b/shortcuts/sheets/lark_sheet_read_data_test.go index c5d878c5f..01e8001de 100644 --- a/shortcuts/sheets/lark_sheet_read_data_test.go +++ b/shortcuts/sheets/lark_sheet_read_data_test.go @@ -79,7 +79,6 @@ func TestReadDataShortcuts_DryRun(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() body := parseDryRunBody(t, tt.sc, tt.args) @@ -122,7 +121,6 @@ func TestReadData_RequiresRange(t *testing.T) { {"+dropdown-get", DropdownGet}, } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() stdout, stderr, err := runShortcutCapturingErr(t, c.sc, []string{ diff --git a/shortcuts/sheets/lark_sheet_search_replace_test.go b/shortcuts/sheets/lark_sheet_search_replace_test.go index bd2ad96bd..7e58abee3 100644 --- a/shortcuts/sheets/lark_sheet_search_replace_test.go +++ b/shortcuts/sheets/lark_sheet_search_replace_test.go @@ -67,7 +67,6 @@ func TestSearchReplaceShortcuts_DryRun(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() body := parseDryRunBody(t, tt.sc, tt.args) diff --git a/shortcuts/sheets/lark_sheet_sheet_structure_test.go b/shortcuts/sheets/lark_sheet_sheet_structure_test.go index efe73d85f..827f33e75 100644 --- a/shortcuts/sheets/lark_sheet_sheet_structure_test.go +++ b/shortcuts/sheets/lark_sheet_sheet_structure_test.go @@ -160,7 +160,6 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() body := parseDryRunBody(t, tt.sc, tt.args) @@ -197,7 +196,6 @@ func TestDimRange_Validation(t *testing.T) { }, } for _, tt := range cases { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() stdout, stderr, err := runShortcutCapturingErr(t, DimHide, tt.args) @@ -306,7 +304,6 @@ func TestParseA1Range(t *testing.T) { {"0", "", 0, 0, true}, // rows are 1-based } for _, c := range cases { - c := c t.Run(c.in, func(t *testing.T) { t.Parallel() dim, start, end, err := parseA1Range(c.in) diff --git a/shortcuts/sheets/lark_sheet_workbook_test.go b/shortcuts/sheets/lark_sheet_workbook_test.go index f7b257fc2..c7cad75f9 100644 --- a/shortcuts/sheets/lark_sheet_workbook_test.go +++ b/shortcuts/sheets/lark_sheet_workbook_test.go @@ -142,7 +142,6 @@ func TestWorkbookShortcuts_DryRun(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() body := parseDryRunBody(t, tt.sc, tt.args) @@ -184,7 +183,6 @@ func TestSheetMove_DryRunResolvePlaceholders(t *testing.T) { }, } for _, tt := range cases { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() body := parseDryRunBody(t, SheetMove, tt.args) @@ -260,7 +258,6 @@ func TestWorkbook_Validation(t *testing.T) { }, } for _, tt := range cases { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run")) @@ -332,7 +329,6 @@ func TestWorkbookCreate_DataValidation(t *testing.T) { {"values not 2D", []string{"--title", "X", "--values", `["a","b"]`}, "must be an array"}, } for _, tt := range cases { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() stdout, stderr, err := runShortcutCapturingErr(t, WorkbookCreate, append(tt.args, "--dry-run")) diff --git a/shortcuts/sheets/lark_sheet_write_cells_test.go b/shortcuts/sheets/lark_sheet_write_cells_test.go index ca5e37ca2..00ba2c810 100644 --- a/shortcuts/sheets/lark_sheet_write_cells_test.go +++ b/shortcuts/sheets/lark_sheet_write_cells_test.go @@ -106,7 +106,6 @@ func TestWriteCellsShortcuts_DryRun(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() body := parseDryRunBody(t, tt.sc, tt.args) @@ -217,7 +216,6 @@ func TestDropdownSet_HighlightTriState(t *testing.T) { }, } for _, tc := range cases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() body := parseDryRunBody(t, DropdownSet, tc.args) From fe1b6b7bbbafe7b430f7fbafc7e880613f596ee7 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Tue, 2 Jun 2026 21:17:56 +0800 Subject: [PATCH 094/114] feat(sheets): flag pre-refactor backward aliases via _notice and --help grouping Nudge users whose lark-sheets skill predates the refactor to migrate off the pre-refactor aliases (+read, +write, ...), without requiring anyone to read --help. - internal/deprecation: process-level pending Notice slot (mirrors internal/skillscheck), surfaced in the JSON "_notice" envelope under a "deprecated_command" key. - internal/cmdutil: shared DeprecatedGroupID cobra group + helper so both --help rendering and the unknown-subcommand path classify aliases the same way. - shortcuts/register.go: applySheetsCompatGroups splits the aliases into a dedicated "update your skill" help group with "(-> +new)" pointers; wrapSheetsBackwardDeprecation records the notice from Validate/Execute so direct callers that never read --help still get flagged. - cmd/root.go: extract composePendingNotice (now unit-testable) and split availableSubcommandNames into current vs deprecated buckets while still ranking unknown-subcommand suggestions across both. --- cmd/notice_test.go | 58 +++++++++ cmd/root.go | 100 ++++++++++----- cmd/unknown_subcommand_test.go | 61 ++++++++- internal/cmdutil/groups.go | 18 +++ internal/deprecation/deprecation.go | 57 +++++++++ internal/deprecation/deprecation_test.go | 58 +++++++++ shortcuts/register.go | 149 +++++++++++++++++++++- shortcuts/register_test.go | 152 +++++++++++++++++++++++ 8 files changed, 619 insertions(+), 34 deletions(-) create mode 100644 cmd/notice_test.go create mode 100644 internal/cmdutil/groups.go create mode 100644 internal/deprecation/deprecation.go create mode 100644 internal/deprecation/deprecation_test.go diff --git a/cmd/notice_test.go b/cmd/notice_test.go new file mode 100644 index 000000000..53f7e6acb --- /dev/null +++ b/cmd/notice_test.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/deprecation" +) + +// composePendingNotice must surface a deprecated-command alias under the +// "deprecated_command" key, with the migration target and a skill-update hint, +// so the JSON "_notice" envelope reaches users who run pre-refactor commands +// without ever reading --help. +func TestComposePendingNoticeDeprecatedCommand(t *testing.T) { + t.Cleanup(func() { deprecation.SetPending(nil) }) + + deprecation.SetPending(&deprecation.Notice{ + Command: "+read", + Replacement: "+cells-get", + Skill: "lark-sheets", + }) + + got := composePendingNotice() + if got == nil { + t.Fatal("composePendingNotice() = nil, want deprecated_command entry") + } + entry, ok := got["deprecated_command"].(map[string]interface{}) + if !ok { + t.Fatalf("missing deprecated_command key: %#v", got) + } + if entry["command"] != "+read" { + t.Errorf("command = %v, want +read", entry["command"]) + } + if entry["replacement"] != "+cells-get" { + t.Errorf("replacement = %v, want +cells-get", entry["replacement"]) + } + if msg, _ := entry["message"].(string); !strings.Contains(msg, "update your lark-sheets skill") { + t.Errorf("message missing skill-update hint: %q", msg) + } +} + +// With nothing pending, the provider returns nil so no "_notice" field is +// emitted on a clean run. +func TestComposePendingNoticeEmpty(t *testing.T) { + t.Cleanup(func() { deprecation.SetPending(nil) }) + deprecation.SetPending(nil) + + if got := composePendingNotice(); got != nil { + // update/skills pending are process-global; only assert the absence of + // our own key to stay robust against unrelated pending state. + if _, ok := got["deprecated_command"]; ok { + t.Fatalf("deprecated_command present after clear: %#v", got) + } + } +} diff --git a/cmd/root.go b/cmd/root.go index 4a988743a..b7321ae74 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -18,6 +18,7 @@ import ( "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/deprecation" "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/errcompat" "github.com/larksuite/cli/internal/hook" @@ -149,29 +150,46 @@ func setupNotices() { skillscheck.Init(build.Version) // Composed notice provider — emits keys only when each pending is set. - output.PendingNotice = func() map[string]interface{} { - notice := map[string]interface{}{} - if info := update.GetPending(); info != nil { - notice["update"] = map[string]interface{}{ - "current": info.Current, - "latest": info.Latest, - "message": info.Message(), - "command": "lark-cli update", - } + output.PendingNotice = composePendingNotice +} + +// composePendingNotice merges all process-level pending notices (available +// update, skills/binary drift, deprecated-command alias) into the map surfaced +// as the JSON "_notice" envelope field. Returns nil when nothing is pending. +// Extracted from Execute so the composition is unit-testable. +func composePendingNotice() map[string]interface{} { + notice := map[string]interface{}{} + if info := update.GetPending(); info != nil { + notice["update"] = map[string]interface{}{ + "current": info.Current, + "latest": info.Latest, + "message": info.Message(), + "command": "lark-cli update", } - if stale := skillscheck.GetPending(); stale != nil { - notice["skills"] = map[string]interface{}{ - "current": stale.Current, - "target": stale.Target, - "message": stale.Message(), - "command": "lark-cli update", - } + } + if stale := skillscheck.GetPending(); stale != nil { + notice["skills"] = map[string]interface{}{ + "current": stale.Current, + "target": stale.Target, + "message": stale.Message(), + "command": "lark-cli update", + } + } + if dep := deprecation.GetPending(); dep != nil { + entry := map[string]interface{}{ + "command": dep.Command, + "message": dep.Message(), + "action": "lark-cli update", } - if len(notice) == 0 { - return nil + if dep.Replacement != "" { + entry["replacement"] = dep.Replacement } - return notice + notice["deprecated_command"] = entry + } + if len(notice) == 0 { + return nil } + return notice } // isCompletionCommand returns true if args indicate a shell completion request. @@ -338,32 +356,45 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error { return cmd.Help() } unknown := args[0] - available := availableSubcommandNames(cmd) - suggestions := suggest.Closest(unknown, available, 6) + available, deprecated := availableSubcommandNames(cmd) + // Rank suggestions across both current and deprecated names so a mistyped + // legacy command (e.g. +raed → +read) still resolves; the alias stays + // runnable and self-flags via the _notice on execution. + suggestions := suggest.Closest(unknown, append(append([]string{}, available...), deprecated...), 6) msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath()) hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath()) if len(suggestions) > 0 { hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)", strings.Join(suggestions, ", "), cmd.CommandPath()) } + detail := map[string]any{ + "unknown": unknown, + "command_path": cmd.CommandPath(), + "suggestions": suggestions, + "available": available, + } + // Only services with backward-compat aliases (currently sheets) carry a + // deprecated bucket; omit the key elsewhere so every other service's + // envelope is unchanged. + if len(deprecated) > 0 { + detail["deprecated"] = deprecated + } return &output.ExitError{ Code: output.ExitValidation, Detail: &output.ErrDetail{ Type: "unknown_subcommand", Message: msg, Hint: hint, - Detail: map[string]any{ - "unknown": unknown, - "command_path": cmd.CommandPath(), - "suggestions": suggestions, - "available": available, - }, + Detail: detail, }, } } -func availableSubcommandNames(cmd *cobra.Command) []string { - subs := make([]string, 0, len(cmd.Commands())) +// availableSubcommandNames returns the invokable subcommand names of cmd, split +// into current commands and backward-compatibility aliases (those tagged into +// the deprecated cobra group via cmdutil.DeprecatedGroupID). Both slices are +// sorted; hidden commands plus help/completion are omitted. +func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []string) { for _, c := range cmd.Commands() { if c.Hidden || !c.IsAvailableCommand() { continue @@ -372,10 +403,15 @@ func availableSubcommandNames(cmd *cobra.Command) []string { if name == "help" || name == "completion" { continue } - subs = append(subs, name) + if cmdutil.IsDeprecatedCommand(c) { + deprecated = append(deprecated, name) + } else { + available = append(available, name) + } } - sort.Strings(subs) - return subs + sort.Strings(available) + sort.Strings(deprecated) + return available, deprecated } // flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It diff --git a/cmd/unknown_subcommand_test.go b/cmd/unknown_subcommand_test.go index 2cc6f2d84..67c1a3e59 100644 --- a/cmd/unknown_subcommand_test.go +++ b/cmd/unknown_subcommand_test.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/output" ) @@ -164,7 +165,7 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) { &cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }}, ) - got := availableSubcommandNames(root) + got, _ := availableSubcommandNames(root) want := []string{"alpha", "gamma"} if len(got) != len(want) { t.Fatalf("expected %v, got %v", want, got) @@ -175,3 +176,61 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) { } } } + +func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + root.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"}) + root.AddCommand( + &cobra.Command{Use: "+new-cmd", RunE: func(*cobra.Command, []string) error { return nil }}, + &cobra.Command{Use: "+old-cmd", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }}, + ) + + available, deprecated := availableSubcommandNames(root) + if len(available) != 1 || available[0] != "+new-cmd" { + t.Errorf("available = %v, want [+new-cmd]", available) + } + if len(deprecated) != 1 || deprecated[0] != "+old-cmd" { + t.Errorf("deprecated = %v, want [+old-cmd]", deprecated) + } +} + +// unknownSubcommandRunE must split current vs deprecated subcommands into +// separate detail buckets, while suggestions still rank across both so a +// mistyped legacy alias resolves. +func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) { + svc := &cobra.Command{Use: "sheets"} + svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"}) + svc.AddCommand( + &cobra.Command{Use: "+cells-get", RunE: func(*cobra.Command, []string) error { return nil }}, + &cobra.Command{Use: "+read", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }}, + ) + + err := unknownSubcommandRunE(svc, []string{"+reat"}) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + detail, ok := exitErr.Detail.Detail.(map[string]any) + if !ok { + t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail) + } + + if available, _ := detail["available"].([]string); len(available) != 1 || available[0] != "+cells-get" { + t.Errorf("available = %v, want [+cells-get]", available) + } + deprecated, ok := detail["deprecated"].([]string) + if !ok || len(deprecated) != 1 || deprecated[0] != "+read" { + t.Errorf("deprecated = %v, want [+read]", deprecated) + } + // suggestions rank across both buckets: "+reat" is closest to +read. + suggestions, _ := detail["suggestions"].([]string) + found := false + for _, s := range suggestions { + if s == "+read" { + found = true + } + } + if !found { + t.Errorf("suggestions %v should include +read (typo target)", suggestions) + } +} diff --git a/internal/cmdutil/groups.go b/internal/cmdutil/groups.go new file mode 100644 index 000000000..5045f555b --- /dev/null +++ b/internal/cmdutil/groups.go @@ -0,0 +1,18 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import "github.com/spf13/cobra" + +// DeprecatedGroupID is the cobra GroupID that marks a backward-compatibility +// command — one kept alive for users whose skill predates a refactor. Service +// registration assigns it (e.g. the sheets pre-refactor aliases); both --help +// rendering and unknown-subcommand suggestions read it to separate these +// aliases from the current commands. +const DeprecatedGroupID = "deprecated" + +// IsDeprecatedCommand reports whether c was tagged into the deprecated group. +func IsDeprecatedCommand(c *cobra.Command) bool { + return c != nil && c.GroupID == DeprecatedGroupID +} diff --git a/internal/deprecation/deprecation.go b/internal/deprecation/deprecation.go new file mode 100644 index 000000000..ad5b4be5b --- /dev/null +++ b/internal/deprecation/deprecation.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package deprecation carries a process-level notice that the command currently +// being executed is a backward-compatibility alias, kept alive for users whose +// skill predates a refactor. The notice is surfaced in JSON output envelopes via +// output.PendingNotice (wired in cmd/root.go), mirroring internal/skillscheck. +// +// A CLI process runs exactly one shortcut, so a single process-level slot is +// sufficient: the command's Execute records the notice before producing output, +// and the output layer reads it back when building the envelope. +package deprecation + +import ( + "strings" + "sync/atomic" +) + +// Notice describes a deprecated command alias and the current command that +// replaces it. Replacement and Skill are optional. +type Notice struct { + Command string `json:"command"` + Replacement string `json:"replacement,omitempty"` + Skill string `json:"skill,omitempty"` +} + +// Message returns a single-line, AI-agent-parseable description of the alias +// plus the canonical fix (update the skill). Mirrors the style of +// internal/skillscheck.StaleNotice.Message ("..., run: lark-cli update"). +func (n *Notice) Message() string { + var b strings.Builder + b.WriteString(n.Command) + b.WriteString(" is a pre-refactor compatibility alias") + if n.Replacement != "" { + b.WriteString("; use ") + b.WriteString(n.Replacement) + b.WriteString(" instead") + } + if n.Skill != "" { + b.WriteString("; update your ") + b.WriteString(n.Skill) + b.WriteString(" skill, run: lark-cli update") + } else { + b.WriteString("; update your skill, run: lark-cli update") + } + return b.String() +} + +// pending stores the latest deprecation notice for the current process. +var pending atomic.Pointer[Notice] + +// SetPending stores the notice for consumption by output decorators. +// Pass nil to clear. +func SetPending(n *Notice) { pending.Store(n) } + +// GetPending returns the pending deprecation notice, or nil. +func GetPending() *Notice { return pending.Load() } diff --git a/internal/deprecation/deprecation_test.go b/internal/deprecation/deprecation_test.go new file mode 100644 index 000000000..69237c9f4 --- /dev/null +++ b/internal/deprecation/deprecation_test.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deprecation + +import "testing" + +func TestNoticeMessage(t *testing.T) { + tests := []struct { + name string + notice Notice + want string + }{ + { + name: "replacement and skill", + notice: Notice{Command: "+read", Replacement: "+cells-get", Skill: "lark-sheets"}, + want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your lark-sheets skill, run: lark-cli update", + }, + { + name: "no replacement", + notice: Notice{Command: "+read", Skill: "lark-sheets"}, + want: "+read is a pre-refactor compatibility alias; update your lark-sheets skill, run: lark-cli update", + }, + { + name: "no skill", + notice: Notice{Command: "+read", Replacement: "+cells-get"}, + want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your skill, run: lark-cli update", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.notice.Message(); got != tt.want { + t.Errorf("Message() =\n %q\nwant\n %q", got, tt.want) + } + }) + } +} + +func TestSetGetPending(t *testing.T) { + t.Cleanup(func() { SetPending(nil) }) + + SetPending(nil) + if got := GetPending(); got != nil { + t.Fatalf("expected nil pending after clear, got %#v", got) + } + + n := &Notice{Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets"} + SetPending(n) + got := GetPending() + if got == nil || got.Command != "+write" || got.Replacement != "+cells-set" { + t.Fatalf("GetPending() = %#v, want %#v", got, n) + } + + SetPending(nil) + if GetPending() != nil { + t.Fatal("expected nil after clearing") + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index e0b140163..c5cfee5fe 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -14,6 +14,7 @@ import ( "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/deprecation" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/shortcuts/apps" @@ -69,7 +70,7 @@ func init() { // kept under shortcuts/sheets/backward so external callers relying on the // old `+create`, `+read`, `+write`, ... commands keep working alongside the // refactored ones. Command names are disjoint from sheets.Shortcuts(). - allShortcuts = append(allShortcuts, sheetsbackward.Shortcuts()...) + allShortcuts = append(allShortcuts, wrapSheetsBackwardDeprecation(sheetsbackward.Shortcuts())...) allShortcuts = append(allShortcuts, base.Shortcuts()...) allShortcuts = append(allShortcuts, event.Shortcuts()...) allShortcuts = append(allShortcuts, mail.Shortcuts()...) @@ -152,6 +153,9 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f if service == "mail" { mail.InstallOnMail(svc) } + if service == "sheets" { + applySheetsCompatGroups(svc) + } if !IsShortcutServiceAvailable(service, brand) { installBrandRestrictionGuard(svc, service, brand) @@ -195,3 +199,146 @@ func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core // --help bypasses RunE, so surface the restriction in Long too. svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand) } + +// Sheets backward-compatibility help grouping. +// +// shortcuts/sheets/backward keeps the pre-refactor command names alive so that +// users whose lark-sheets skill predates the refactor keep working even after +// upgrading only the binary. In `sheets --help` those aliases would otherwise +// sort alphabetically into the same flat list as the current commands, +// indistinguishable from them. applySheetsCompatGroups splits them into a +// dedicated cobra group whose heading tells the user to update their skill, and +// appends a "(→ +new-command)" pointer to each alias so the migration target is +// obvious. Pure presentation — the aliases stay fully executable. +const ( + sheetsCurrentGroupID = "sheets-current" + // sheetsDeprecatedGroupID aliases the shared deprecated-group id so both + // `sheets --help` grouping and the generic unknown-subcommand path + // (cmd/root.go) classify these aliases the same way. + sheetsDeprecatedGroupID = cmdutil.DeprecatedGroupID +) + +// 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. +var sheetsAliasReplacement = map[string]string{ + // spreadsheet / sheet management + "+create": "+workbook-create", + "+info": "+workbook-info", + "+export": "+workbook-export", + "+create-sheet": "+sheet-create", + "+copy-sheet": "+sheet-copy", + "+delete-sheet": "+sheet-delete", + "+update-sheet": "+sheet-rename / +sheet-move / …", + // cell data + "+read": "+cells-get", + "+write": "+cells-set", + "+append": "+cells-set", + "+find": "+cells-search", + "+replace": "+cells-replace", + // cell style / merge / image + "+set-style": "+cells-set-style", + "+batch-set-style": "+cells-batch-set-style", + "+merge-cells": "+cells-merge", + "+unmerge-cells": "+cells-unmerge", + "+write-image": "+cells-set-image", + // row / column dimensions + "+add-dimension": "+dim-insert", + "+insert-dimension": "+dim-insert", + "+update-dimension": "+rows-resize / +dim-hide / …", + "+move-dimension": "+dim-move", + "+delete-dimension": "+dim-delete", + // filter views (conditions folded into the view flags) + "+create-filter-view": "+filter-view-create", + "+update-filter-view": "+filter-view-update", + "+list-filter-views": "+filter-view-list", + "+get-filter-view": "+filter-view-list", + "+delete-filter-view": "+filter-view-delete", + "+create-filter-view-condition": "+filter-view-update", + "+update-filter-view-condition": "+filter-view-update", + "+list-filter-view-conditions": "+filter-view-list", + "+get-filter-view-condition": "+filter-view-list", + "+delete-filter-view-condition": "+filter-view-update", + // dropdowns + "+set-dropdown": "+dropdown-set", + "+update-dropdown": "+dropdown-update", + "+get-dropdown": "+dropdown-get", + "+delete-dropdown": "+dropdown-delete", + // float images (media-upload folded into create) + "+media-upload": "+float-image-create", + "+create-float-image": "+float-image-create", + "+update-float-image": "+float-image-update", + "+get-float-image": "+float-image-list", + "+list-float-images": "+float-image-list", + "+delete-float-image": "+float-image-delete", +} + +func applySheetsCompatGroups(svc *cobra.Command) { + svc.AddGroup( + &cobra.Group{ID: sheetsCurrentGroupID, Title: "Available Commands:"}, + &cobra.Group{ + ID: sheetsDeprecatedGroupID, + Title: "Deprecated pre-refactor commands (still work) — update your lark-sheets skill, then: lark-cli update", + }, + ) + + deprecated := make(map[string]struct{}) + for _, s := range sheetsbackward.Shortcuts() { + deprecated[s.Command] = struct{}{} + } + + for _, c := range svc.Commands() { + name := c.Name() + if _, ok := deprecated[name]; ok { + c.GroupID = sheetsDeprecatedGroupID + if repl := sheetsAliasReplacement[name]; repl != "" { + c.Short = c.Short + " (→ " + repl + ")" + } + continue + } + // Only the refactored shortcuts (all "+"-prefixed) belong in the current + // group. Leave the OpenAPI metaapi subcommands (spreadsheets, ...) and the + // auto-added help/completion ungrouped so cobra files them under + // "Additional Commands". + if len(name) > 0 && name[0] == '+' { + c.GroupID = sheetsCurrentGroupID + } + } +} + +// wrapSheetsBackwardDeprecation decorates each backward-compatibility sheets +// alias so that invoking it records a process-level deprecation notice, which +// cmd/root.go surfaces in the JSON "_notice" envelope. This reaches the users +// the --help grouping cannot: those whose pre-refactor skill calls +read / +// +write directly and never reads --help. Replacement targets come from +// sheetsAliasReplacement — the same single source of truth that drives the +// "(→ +new)" help pointers. +func wrapSheetsBackwardDeprecation(list []common.Shortcut) []common.Shortcut { + for i := range list { + notice := &deprecation.Notice{ + Command: list[i].Command, + Replacement: sheetsAliasReplacement[list[i].Command], + Skill: "lark-sheets", + } + // Record the notice as soon as the command's own logic runs, so it is + // surfaced even when Validate rejects the call — an out-of-date skill + // can pass pre-refactor argument shapes (e.g. a range without the new + // sheet-id prefix) and fail validation before Execute — and when + // --dry-run short-circuits before Execute. Both hooks store the same + // pointer, so setting it twice is harmless. + if origValidate := list[i].Validate; origValidate != nil { + list[i].Validate = func(ctx context.Context, runtime *common.RuntimeContext) error { + deprecation.SetPending(notice) + return origValidate(ctx, runtime) + } + } + if origExecute := list[i].Execute; origExecute != nil { + list[i].Execute = func(ctx context.Context, runtime *common.RuntimeContext) error { + deprecation.SetPending(notice) + return origExecute(ctx, runtime) + } + } + } + return list +} diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 82b067c62..4f7cb52d0 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -5,6 +5,7 @@ package shortcuts import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -16,7 +17,9 @@ import ( "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/deprecation" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -471,3 +474,152 @@ func TestGenerateShortcutsJSON(t *testing.T) { } t.Logf("wrote %d bytes to %s", len(data), output) } + +// applySheetsCompatGroups must split the sheets service into a current group +// (refactored "+"-shortcuts) and a deprecated group (backward-compat aliases), +// append a "(→ +new)" migration pointer to each alias, and leave non-"+" +// subcommands (OpenAPI metaapi, help/completion) ungrouped so cobra files them +// under "Additional Commands". +func TestApplySheetsCompatGroups(t *testing.T) { + svc := &cobra.Command{Use: "sheets"} + newCmd := &cobra.Command{Use: "+cells-get", Short: "Read ranges"} + aliasCmd := &cobra.Command{Use: "+read", Short: "Read spreadsheet cell values"} + metaCmd := &cobra.Command{Use: "spreadsheets", Short: "spreadsheets operations"} + svc.AddCommand(newCmd, aliasCmd, metaCmd) + + applySheetsCompatGroups(svc) + + if !svc.ContainsGroup(sheetsCurrentGroupID) { + t.Errorf("current group %q not registered", sheetsCurrentGroupID) + } + if !svc.ContainsGroup(sheetsDeprecatedGroupID) { + t.Errorf("deprecated group %q not registered", sheetsDeprecatedGroupID) + } + if newCmd.GroupID != sheetsCurrentGroupID { + t.Errorf("+cells-get GroupID = %q, want %q", newCmd.GroupID, sheetsCurrentGroupID) + } + if aliasCmd.GroupID != sheetsDeprecatedGroupID { + t.Errorf("+read GroupID = %q, want %q", aliasCmd.GroupID, sheetsDeprecatedGroupID) + } + if !strings.Contains(aliasCmd.Short, "(→ +cells-get)") { + t.Errorf("+read Short missing migration pointer, got %q", aliasCmd.Short) + } + if metaCmd.GroupID != "" { + t.Errorf("metaapi spreadsheets should stay ungrouped, got GroupID %q", metaCmd.GroupID) + } +} + +// End-to-end: the rendered `sheets --help` must surface the deprecated-group +// heading (telling users to update their skill) plus the per-alias migration +// pointers, while keeping the refactored shortcuts under Available Commands. +func TestRegisterShortcutsSheetsHelpGroupsDeprecatedAliases(t *testing.T) { + program := &cobra.Command{Use: "root"} + RegisterShortcuts(program, newRegisterTestFactory(t)) + + sheetsCmd, _, err := program.Find([]string{"sheets"}) + if err != nil { + t.Fatalf("find sheets command: %v", err) + } + + var out bytes.Buffer + sheetsCmd.SetOut(&out) + if err := sheetsCmd.Help(); err != nil { + t.Fatalf("sheets help failed: %v", err) + } + got := out.String() + + for _, want := range []string{ + "Available Commands:", + "Deprecated pre-refactor commands", + "update your lark-sheets skill", + "+read", + "(→ +cells-get)", + "+write", + "(→ +cells-set)", + } { + if !strings.Contains(got, want) { + t.Fatalf("sheets help missing %q:\n%s", want, got) + } + } +} + +// wrapSheetsBackwardDeprecation must decorate each alias's Execute so that +// invoking it records a process-level deprecation notice (reusing +// sheetsAliasReplacement for the migration target) while still calling the +// original Execute. cmd/root.go reads that notice into the JSON "_notice". +func TestWrapSheetsBackwardDeprecation(t *testing.T) { + t.Cleanup(func() { deprecation.SetPending(nil) }) + deprecation.SetPending(nil) + + called := false + in := []common.Shortcut{{ + Service: "sheets", + Command: "+read", + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + called = true + return nil + }, + }} + + out := wrapSheetsBackwardDeprecation(in) + if len(out) != 1 { + t.Fatalf("wrapped list len = %d, want 1", len(out)) + } + if deprecation.GetPending() != nil { + t.Fatal("notice set before wrapped Execute ran") + } + + if err := out[0].Execute(context.Background(), nil); err != nil { + t.Fatalf("wrapped Execute returned error: %v", err) + } + if !called { + t.Fatal("original Execute was not invoked by the wrapper") + } + + dep := deprecation.GetPending() + if dep == nil { + t.Fatal("expected a pending deprecation notice after Execute") + } + if dep.Command != "+read" { + t.Errorf("notice Command = %q, want +read", dep.Command) + } + if dep.Replacement != "+cells-get" { + t.Errorf("notice Replacement = %q, want +cells-get (from sheetsAliasReplacement)", dep.Replacement) + } + if dep.Skill != "lark-sheets" { + t.Errorf("notice Skill = %q, want lark-sheets", dep.Skill) + } +} + +// The wrapper must also decorate Validate, so an out-of-date skill whose +// pre-refactor argument shape fails validation (before Execute) still gets the +// deprecation notice in its error envelope. +func TestWrapSheetsBackwardDeprecationValidateHook(t *testing.T) { + t.Cleanup(func() { deprecation.SetPending(nil) }) + deprecation.SetPending(nil) + + validated := false + in := []common.Shortcut{{ + Service: "sheets", + Command: "+write", + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + validated = true + return nil + }, + }} + + out := wrapSheetsBackwardDeprecation(in) + if out[0].Validate == nil { + t.Fatal("Validate hook was dropped by the wrapper") + } + if err := out[0].Validate(context.Background(), nil); err != nil { + t.Fatalf("wrapped Validate returned error: %v", err) + } + if !validated { + t.Fatal("original Validate was not invoked") + } + dep := deprecation.GetPending() + if dep == nil || dep.Command != "+write" || dep.Replacement != "+cells-set" { + t.Fatalf("Validate hook did not record expected notice: %#v", dep) + } +} From aec1bd4b0c496aaece1018b95bd946bde020d333 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Tue, 2 Jun 2026 21:25:57 +0800 Subject: [PATCH 095/114] chore: drop hardcoded ppe lane routing from base security headers The x-tt-env/x-use-ppe headers forced every request onto the ppe_moa_canvas pre-release lane; they were only meant for exercising the sheets refactor against the staging backend. Remove them so the CLI routes to production by default. --- internal/cmdutil/secheader.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/cmdutil/secheader.go b/internal/cmdutil/secheader.go index 97028109d..9b6fa83ea 100644 --- a/internal/cmdutil/secheader.go +++ b/internal/cmdutil/secheader.go @@ -75,8 +75,6 @@ func BaseSecurityHeaders() http.Header { h.Set(HeaderVersion, build.Version) h.Set(HeaderBuild, DetectBuildKind()) h.Set(HeaderUserAgent, UserAgentValue()) - h.Set("x-tt-env", "ppe_moa_canvas") - h.Set("x-use-ppe", "1") if v := AgentTraceValue(); v != "" { h.Set(HeaderAgentTrace, v) } From 6e18185eaad81f376b82c1e29dc7364ea7d3b173 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Wed, 3 Jun 2026 10:34:49 +0800 Subject: [PATCH 096/114] chore(sheets): promote lark-sheets skill to 2.0.0 Drop the -draft suffix now that the refactored sheets skill is ready to ship. --- skills/lark-sheets/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index 6d4a27e81..f9b1f5b61 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -1,6 +1,6 @@ --- name: lark-sheets -version: 2.0.0-draft +version: 2.0.0 description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。仅针对飞书在线电子表格,不适用于本地 Excel 文件。" metadata: requires: From ad7b20935c3da82d45b5789fb511999fbef4bce7 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Wed, 3 Jun 2026 12:21:05 +0800 Subject: [PATCH 097/114] fix(sheets): correct +dropdown-get sheet-locator doc, finalize skill to 2.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit +dropdown-get requires a mandatory sheet selector — its Validate calls resolveSheetSelector — so drop it from the "no sheet locator" exception list in SKILL.md. It was wrongly grouped with +dropdown-update/+dropdown-delete, which take only --ranges. +dropdown-get's own per-shortcut badge (公共四件套) was already correct. Also finalize the skill version 2.0.0-draft -> 2.0.0. --- skills/lark-sheets/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index f9b1f5b61..0190018e9 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -107,7 +107,7 @@ metadata: - ⚠️ **不确定 sheet 名时禁止直接猜 `Sheet1`**:除非用户对话明确说出 sheet 名 / id,或上下文(之前的工具调用 / URL 锚点 `?sheet=xxx`)已经出现过具体值,否则**第一步先调 `+workbook-info --url "..."`**(或 `--spreadsheet-token`)拿 `sheets[].sheet_id` / `sheets[].title` 列表再选。中文环境下子表常叫"数据" / "Sheet"(无数字)/ "工作表 1" / 业务名,猜 `Sheet1` 大概率撞 `sheet not found`,比先查多耗一次失败调用 + 重试。 - ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:即使写了 `--range 'Sheet1!A1:B2'`,仍**必须**额外传 `--sheet-id` 或 `--sheet-name`,否则照样报上面的错。 - ⚠️ **A1 reference 含 `!`**(`--source` / `--range` / `--ranges`)**:shell session 起手先 `set +H`** 关 bash history expansion,否则 `"Sheet1!A1"` 会被拦成 `event not found`;含特殊字符(`-` / 空格 / 非 ASCII)的 sheet 名还要内部 single-quote 包,如 `--source "'Sales-2025'!A1:D100"`。 - - **例外**:徽章标为 `_公共:URL/token(无 sheet 定位)…_` 的 shortcut(如 `+workbook-info` / `+workbook-export` / `+batch-update` / `+dropdown-get|update|delete` / `+cells-batch-set-style` / `+cells-batch-clear` / `+sheet-create`)**不接受也不需要** sheet 定位,只给一组 spreadsheet 定位即可。`+pivot-create` 用 `--target-sheet-id` / `--target-sheet-name`(XOR,可都不传,落点细节见 `lark-sheets-pivot-table`)。 + - **例外**:徽章标为 `_公共:URL/token(无 sheet 定位)…_` 的 shortcut(如 `+workbook-info` / `+workbook-export` / `+batch-update` / `+dropdown-update|delete` / `+cells-batch-set-style` / `+cells-batch-clear` / `+sheet-create`)**不接受也不需要** sheet 定位,只给一组 spreadsheet 定位即可。`+pivot-create` 用 `--target-sheet-id` / `--target-sheet-name`(XOR,可都不传,落点细节见 `lark-sheets-pivot-table`)。 | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | From da198cf06a7db2d8e69e2e13dbae643251c6ed36 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Wed, 3 Jun 2026 12:31:57 +0800 Subject: [PATCH 098/114] fix(sheets): enforce required-flag contract in batch sub-ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch sub-ops reuse each shortcut's shared *Input builder through mapFlagView, which seeds flag-defs defaults — so any required check that lives OUTSIDE the builder (cobra MarkFlagsOneRequired, or a shortcut's own Validate) is silently bypassed and the default value wins. Two gaps surfaced in PR review: - +csv-put: with neither --start-cell nor --range set, start-cell's "A1" default won and the paste silently anchored at A1. Require an explicit anchor (guard on Changed, mirroring the standalone MarkFlagsOneRequired). - +sheet-move: --index (plus >=0 bounds for index / source-index) was not enforced in the batch path; a missing --index silently moved the sheet to the front. Mirror SheetMove.Validate. Also from the same review: - +batch-update: an explicit --continue-on-error=false now wins over an --operations envelope's continue_on_error:true (guard on Changed, not value). - validateDropdownRanges rejects malformed sheet!range ("!A1", "Sheet1!", "Sheet1!bad") at Validate instead of deferring to the server. Tests added/updated for each path; full sheets suite green. --- shortcuts/sheets/batch_op_contract_test.go | 63 +++++++++++++++++++ shortcuts/sheets/batch_op_dispatch.go | 13 ++++ shortcuts/sheets/csv_put_range_alias_test.go | 25 +++++--- shortcuts/sheets/execute_paths_test.go | 46 ++++++++++++++ shortcuts/sheets/lark_sheet_batch_update.go | 23 +++++-- .../sheets/lark_sheet_batch_update_test.go | 33 ++++++++++ shortcuts/sheets/lark_sheet_write_cells.go | 12 +++- 7 files changed, 200 insertions(+), 15 deletions(-) diff --git a/shortcuts/sheets/batch_op_contract_test.go b/shortcuts/sheets/batch_op_contract_test.go index f8ade3f1e..3129fc1b0 100644 --- a/shortcuts/sheets/batch_op_contract_test.go +++ b/shortcuts/sheets/batch_op_contract_test.go @@ -467,6 +467,69 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) { } } +// TestBatchOp_GuardsBeyondCobra locks the two batch sub-ops whose standalone +// required-flag enforcement lives OUTSIDE the shared *Input builder — so it is +// invisible to TestBatchOp_ErrorEquivalence and was missed by the refactor: +// - +csv-put: standalone requires one-of(start-cell, range) via cobra's +// MarkFlagsOneRequired (PostMount); a batch sub-op never runs cobra. +// - +sheet-move: standalone requires --index (>=0) and source-index>=0 in +// SheetMove.Validate; the batch path uses a dedicated builder. +// +// Without an explicit guard, mapFlagView's flag-default fallback silently wins +// (start-cell→"A1", index→0), so the batch sub-op diverges from the standalone +// contract instead of failing. +func TestBatchOp_GuardsBeyondCobra(t *testing.T) { + t.Parallel() + cases := []struct { + name string + subShortcut string + subInput string + wantContains string + }{ + { + name: "+csv-put without start-cell or range", + subShortcut: "+csv-put", + subInput: `{"sheet-id":"sh1","csv":"a,b"}`, + wantContains: "--start-cell or --range is required", + }, + { + name: "+sheet-move without index", + subShortcut: "+sheet-move", + subInput: `{"sheet-id":"sh1","source-index":2}`, + wantContains: "requires index", + }, + { + name: "+sheet-move negative index", + subShortcut: "+sheet-move", + subInput: `{"sheet-id":"sh1","source-index":2,"index":-1}`, + wantContains: "--index must be >= 0", + }, + { + name: "+sheet-move negative source-index", + subShortcut: "+sheet-move", + subInput: `{"sheet-id":"sh1","source-index":-1,"index":0}`, + wantContains: "--source-index must be >= 0", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var subInput map[string]interface{} + if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil { + t.Fatalf("bad subInput JSON: %v", err) + } + rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput} + _, err := translateBatchOp(rawOp, testToken, 0) + if err == nil { + t.Fatalf("translateBatchOp accepted bad input; want error containing %q", tc.wantContains) + } + if !strings.Contains(err.Error(), tc.wantContains) { + t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains) + } + }) + } +} + // TestBatchOp_RejectsBadSubOpInput pins down the secondary guard: for // inputs that cobra's MarkFlagRequired catches on the standalone path, // the +batch-update sub-op (which has no cobra layer) must still reject diff --git a/shortcuts/sheets/batch_op_dispatch.go b/shortcuts/sheets/batch_op_dispatch.go index 82a0cd258..a348f3d4c 100644 --- a/shortcuts/sheets/batch_op_dispatch.go +++ b/shortcuts/sheets/batch_op_dispatch.go @@ -213,6 +213,19 @@ func sheetMoveBatchInput(fv flagView, token, sheetID, sheetName string) (map[str if !fv.Changed("source-index") { return nil, common.FlagErrorf("+sheet-move in +batch-update requires source_index (auto-derive needs a network lookup unavailable mid-batch)") } + if fv.Int("source-index") < 0 { + return nil, common.FlagErrorf("--source-index must be >= 0") + } + // Standalone +sheet-move requires --index (see SheetMove.Validate). A batch + // sub-op skips that path, and mapFlagView falls back to the flag default (0), + // which would silently move the sheet to the front. Require it explicitly so + // the batch contract matches the standalone one. + if !fv.Changed("index") { + return nil, common.FlagErrorf("+sheet-move in +batch-update requires index") + } + if fv.Int("index") < 0 { + return nil, common.FlagErrorf("--index must be >= 0") + } return map[string]interface{}{ "excel_id": token, "operation": "move", diff --git a/shortcuts/sheets/csv_put_range_alias_test.go b/shortcuts/sheets/csv_put_range_alias_test.go index 707eebd1d..4a631d6f6 100644 --- a/shortcuts/sheets/csv_put_range_alias_test.go +++ b/shortcuts/sheets/csv_put_range_alias_test.go @@ -3,7 +3,10 @@ package sheets -import "testing" +import ( + "strings" + "testing" +) // +csv-put locates with --start-cell, while +csv-get / +cells-set locate with // --range. Agents routinely carry --range over to +csv-put and hit a guaranteed @@ -35,16 +38,20 @@ func TestCsvPutInput_RangeAliasForStartCell(t *testing.T) { } } -// With neither --start-cell nor --range set, +csv-put keeps its existing -// behavior: --start-cell defaults to A1, so the paste anchors at A1. -func TestCsvPutInput_DefaultsToA1(t *testing.T) { +// With neither --start-cell nor --range explicitly set, csvPutInput rejects the +// call instead of silently anchoring at the "A1" flag default. Standalone never +// reaches this path — cobra's MarkFlagsOneRequired(start-cell, range) catches it +// first — but a +batch-update sub-op skips cobra, so the guard must live in the +// shared builder too. Otherwise a batch +csv-put with no anchor silently pastes +// at A1, diverging from the standalone contract. +func TestCsvPutInput_RequiresStartCellOrRange(t *testing.T) { fv := newMapFlagViewForCommand("+csv-put", map[string]interface{}{"csv": "a,b"}) - input, err := csvPutInput(fv, "tok", "sid", "") - if err != nil { - t.Fatalf("csvPutInput returned error: %v", err) + _, err := csvPutInput(fv, "tok", "sid", "") + if err == nil { + t.Fatal("csvPutInput accepted missing start-cell/range; want a required-flag error") } - if got, _ := input["start_cell"].(string); got != "A1" { - t.Errorf("start_cell = %q, want %q (default)", got, "A1") + if !strings.Contains(err.Error(), "--start-cell or --range is required") { + t.Errorf("error = %q, want it to mention '--start-cell or --range is required'", err.Error()) } } diff --git a/shortcuts/sheets/execute_paths_test.go b/shortcuts/sheets/execute_paths_test.go index 25932768c..1906faf53 100644 --- a/shortcuts/sheets/execute_paths_test.go +++ b/shortcuts/sheets/execute_paths_test.go @@ -271,6 +271,52 @@ func TestExecute_BatchUpdate_Translated(t *testing.T) { } } +// TestExecute_BatchUpdate_ContinueOnErrorPrecedence locks the flag-vs-envelope +// precedence: an explicit --continue-on-error=false must keep the strict +// transaction even when the --operations envelope carries continue_on_error:true, +// while an envelope value still applies when the flag is absent. Guards against +// the regression where the flag was read by value (runtime.Bool) rather than by +// Changed(). +func TestExecute_BatchUpdate_ContinueOnErrorPrecedence(t *testing.T) { + t.Parallel() + envelope := `{"operations":[{"shortcut":"+cells-set","input":{"sheet-id":"sh1","range":"A1","cells":[[{"value":1}]]}}],"continue_on_error":true}` + + t.Run("explicit false overrides envelope", func(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`) + _, err := runShortcutWithStubs(t, BatchUpdate, []string{ + "--url", testURL, + "--operations", envelope, + "--continue-on-error=false", + "--yes", + }, stub) + if err != nil { + t.Fatalf("execute failed: %v", err) + } + input := decodeToolInput(t, decodeRawEnvelopeBody(t, stub.CapturedBody), "batch_update") + if input["continue_on_error"] == true { + t.Errorf("explicit --continue-on-error=false must win over envelope; got continue_on_error=%#v", input["continue_on_error"]) + } + }) + + t.Run("envelope applies when flag absent", func(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`) + _, err := runShortcutWithStubs(t, BatchUpdate, []string{ + "--url", testURL, + "--operations", envelope, + "--yes", + }, stub) + if err != nil { + t.Fatalf("execute failed: %v", err) + } + input := decodeToolInput(t, decodeRawEnvelopeBody(t, stub.CapturedBody), "batch_update") + if input["continue_on_error"] != true { + t.Errorf("envelope continue_on_error:true should apply when --continue-on-error absent; got %#v", input["continue_on_error"]) + } + }) +} + // TestExecute_WorkbookCreate covers the create POST + first-sheet lookup + // set_cell_range follow-up. Stubs all three endpoints. func TestExecute_WorkbookCreate(t *testing.T) { diff --git a/shortcuts/sheets/lark_sheet_batch_update.go b/shortcuts/sheets/lark_sheet_batch_update.go index 3df12ca8f..696c40f23 100644 --- a/shortcuts/sheets/lark_sheet_batch_update.go +++ b/shortcuts/sheets/lark_sheet_batch_update.go @@ -104,11 +104,16 @@ func batchUpdateInput(runtime *common.RuntimeContext, token string) (map[string] "excel_id": token, "operations": translated, } - if runtime.Bool("continue-on-error") { - input["continue_on_error"] = true + if runtime.Changed("continue-on-error") { + // An explicit --continue-on-error always wins over the envelope, so + // --continue-on-error=false keeps the strict-transaction default even + // when the --operations envelope carries continue_on_error:true. + if runtime.Bool("continue-on-error") { + input["continue_on_error"] = true + } } else if envelope, _ := parseJSONFlag(runtime, "operations"); envelope != nil { - // Honor an inline override when --operations is an envelope object - // rather than a bare operations array. + // No explicit flag: honor an inline override when --operations is an + // envelope object rather than a bare operations array. if m, ok := envelope.(map[string]interface{}); ok { if v, ok := m["continue_on_error"].(bool); ok && v { input["continue_on_error"] = true @@ -472,6 +477,16 @@ func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) { if !strings.Contains(s, "!") { return nil, common.FlagErrorf("--ranges[%d] (%q) must include a sheet prefix", i, s) } + // Validate the sheet!range shape up front so malformed entries like + // "!A1" (no sheet), "Sheet1!" (no range) or "Sheet1!bad" (bad ref) fail + // here at Validate instead of slipping through to DryRun/Execute. + _, sub, err := splitSheetPrefixedRange(s) + if err != nil { + return nil, common.FlagErrorf("--ranges[%d]: %v", i, err) + } + if _, _, err := rangeDimensions(sub); err != nil { + return nil, common.FlagErrorf("--ranges[%d] (%q): %v", i, s, err) + } out = append(out, s) } return out, nil diff --git a/shortcuts/sheets/lark_sheet_batch_update_test.go b/shortcuts/sheets/lark_sheet_batch_update_test.go index 0d957fca4..e122c1355 100644 --- a/shortcuts/sheets/lark_sheet_batch_update_test.go +++ b/shortcuts/sheets/lark_sheet_batch_update_test.go @@ -302,6 +302,39 @@ func TestBatchUpdate_ValidationGuards(t *testing.T) { } } +// TestValidateDropdownRanges_RejectsMalformedRange locks the up-front sheet!range +// validation: entries that merely contain "!" but are otherwise malformed (empty +// sheet, empty range, or an unparseable A1 ref) must fail at Validate rather than +// slip through to DryRun/Execute. Covers +dropdown-update / +dropdown-delete, +// which fan out over --ranges. +func TestValidateDropdownRanges_RejectsMalformedRange(t *testing.T) { + t.Parallel() + cases := []struct { + name string + ranges string + want string + }{ + {"no sheet prefix at all", `["A1:A5"]`, "must include a sheet prefix"}, + {"empty sheet name", `["!A1:A5"]`, "must use sheet!range form"}, + {"empty range after prefix", `["Sheet1!"]`, "must use sheet!range form"}, + {"unparseable ref", `["Sheet1!bad"]`, "invalid cell ref"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, DropdownUpdate, []string{ + "--url", testURL, + "--ranges", tc.ranges, + "--options", `["a"]`, + "--dry-run", + }) + if err == nil || !strings.Contains(stdout+stderr+err.Error(), tc.want) { + t.Errorf("ranges=%s: expected error containing %q; got=%s|%s|%v", tc.ranges, tc.want, stdout, stderr, err) + } + }) + } +} + // TestBatchUpdate_TranslatorRejects covers per-op shape errors caught by // translateBatchOp: unknown shortcut, missing shortcut, banned (read / // fan-out / legacy v2) shortcuts, hand-filled reserved keys, etc. diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index 3374d2d91..07768614f 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -310,10 +310,18 @@ func csvPutInput(runtime flagView, token, sheetID, sheetName string) (map[string // defaults to "A1" and is therefore never empty. A range like "A1:H17" // collapses to its top-left cell; +csv-put pastes from the anchor and // auto-expands, so the range's lower-right bound is irrelevant. + // + // Standalone enforces "one of --start-cell / --range" via cobra's + // MarkFlagsOneRequired (see PostMount). A +batch-update sub-op never runs + // cobra, so without an explicit check the default "A1" silently wins and the + // paste lands at A1 instead of failing like the standalone command. Mirror + // the standalone contract: when --start-cell is absent, --range is mandatory. if !runtime.Changed("start-cell") { - if rng := strings.TrimSpace(runtime.Str("range")); rng != "" { - anchor = strings.TrimSpace(strings.SplitN(rng, ":", 2)[0]) + rng := strings.TrimSpace(runtime.Str("range")) + if rng == "" { + return nil, common.FlagErrorf("--start-cell or --range is required") } + anchor = strings.TrimSpace(strings.SplitN(rng, ":", 2)[0]) } if anchor == "" { return nil, common.FlagErrorf("--start-cell is required") From d6a0aadbe4872b6349816afabd58b976c74edab2 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Wed, 3 Jun 2026 12:31:59 +0800 Subject: [PATCH 099/114] fix(cli): surface skill in deprecated_command notice deprecation.Notice carries Skill, but the _notice.deprecated_command payload dropped it, forcing callers to parse `message` to learn which skill to update. Emit `skill` when set, alongside the existing `replacement`. --- cmd/notice_test.go | 3 +++ cmd/root.go | 3 +++ 2 files changed, 6 insertions(+) diff --git a/cmd/notice_test.go b/cmd/notice_test.go index 53f7e6acb..51842ad8b 100644 --- a/cmd/notice_test.go +++ b/cmd/notice_test.go @@ -37,6 +37,9 @@ func TestComposePendingNoticeDeprecatedCommand(t *testing.T) { if entry["replacement"] != "+cells-get" { t.Errorf("replacement = %v, want +cells-get", entry["replacement"]) } + if entry["skill"] != "lark-sheets" { + t.Errorf("skill = %v, want lark-sheets", entry["skill"]) + } if msg, _ := entry["message"].(string); !strings.Contains(msg, "update your lark-sheets skill") { t.Errorf("message missing skill-update hint: %q", msg) } diff --git a/cmd/root.go b/cmd/root.go index b7321ae74..752be6096 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -184,6 +184,9 @@ func composePendingNotice() map[string]interface{} { if dep.Replacement != "" { entry["replacement"] = dep.Replacement } + if dep.Skill != "" { + entry["skill"] = dep.Skill + } notice["deprecated_command"] = entry } if len(notice) == 0 { From e4309bb5b2dfac1d383bd66a1f01d89787b85bc0 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Wed, 3 Jun 2026 14:10:59 +0800 Subject: [PATCH 100/114] fix(sheets): harden batch type-checking and +workbook-create edge cases From the branch code-review doc (3 findings): - +batch-update sub-ops: `operations` is skipped by parse-time schema validation and mapFlagView coerces a type-mismatched scalar to its zero value, so "index":"abc" or "multiple":"true" silently became 0 / false and wrote to the wrong place. translateBatchOp now runs validateRawTypes, which checks each sub-op scalar against its flag-defs type and rejects mismatches. - +workbook-create with empty arrays: buildInitialFillInput returned (nil,nil) for empty rows while the caller wrote fill["excel_id"] unconditionally, so --values '[]' panicked on a nil map and --headers '[]' produced an illegal "A1:1" range. It now also returns nil when no cells survive (maxCols==0 guard) and Execute/DryRun skip the fill when fill==nil. - +workbook-create partial failure: after the spreadsheet was created, a first-sheet lookup or fill failure returned a bare fmt.Errorf, losing the new token. It now returns a structured partial_success error carrying spreadsheet_token in the detail so callers can retry or clean up. Tests added for each path; sheets suite green. --- shortcuts/sheets/batch_op_contract_test.go | 51 ++++++++++++++ shortcuts/sheets/batch_op_dispatch.go | 6 ++ shortcuts/sheets/execute_paths_test.go | 77 ++++++++++++++++++++++ shortcuts/sheets/flag_view.go | 73 ++++++++++++++++++++ shortcuts/sheets/lark_sheet_workbook.go | 51 ++++++++++---- 5 files changed, 247 insertions(+), 11 deletions(-) diff --git a/shortcuts/sheets/batch_op_contract_test.go b/shortcuts/sheets/batch_op_contract_test.go index 3129fc1b0..a2750b2de 100644 --- a/shortcuts/sheets/batch_op_contract_test.go +++ b/shortcuts/sheets/batch_op_contract_test.go @@ -467,6 +467,57 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) { } } +// 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 +// whose JSON type contradicts its flag-defs type must be rejected up front +// rather than landing as 0 / false in the wrong place. +func TestBatchOp_RejectsWrongScalarType(t *testing.T) { + t.Parallel() + cases := []struct { + name string + subShortcut string + subInput string + wantContains string + }{ + { + name: "int flag given a string", + subShortcut: "+sheet-move", + subInput: `{"sheet-id":"sh1","source-index":2,"index":"abc"}`, + wantContains: "--index must be a number", + }, + { + name: "int flag given a boolean", + subShortcut: "+sheet-move", + subInput: `{"sheet-id":"sh1","source-index":true,"index":0}`, + wantContains: "--source-index must be a number", + }, + { + name: "bool flag given a string", + subShortcut: "+cells-set", + subInput: `{"sheet-id":"sh1","range":"A1","cells":[[{"value":1}]],"allow-overwrite":"true"}`, + wantContains: "--allow-overwrite must be a boolean", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var subInput map[string]interface{} + if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil { + t.Fatalf("bad subInput JSON: %v", err) + } + rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput} + _, err := translateBatchOp(rawOp, testToken, 0) + if err == nil { + t.Fatalf("translateBatchOp accepted wrong-typed field; want error containing %q", tc.wantContains) + } + if !strings.Contains(err.Error(), tc.wantContains) { + t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains) + } + }) + } +} + // TestBatchOp_GuardsBeyondCobra locks the two batch sub-ops whose standalone // required-flag enforcement lives OUTSIDE the shared *Input builder — so it is // invisible to TestBatchOp_ErrorEquivalence and was missed by the refactor: diff --git a/shortcuts/sheets/batch_op_dispatch.go b/shortcuts/sheets/batch_op_dispatch.go index a348f3d4c..271d4eeca 100644 --- a/shortcuts/sheets/batch_op_dispatch.go +++ b/shortcuts/sheets/batch_op_dispatch.go @@ -306,6 +306,12 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte } } fv := newMapFlagViewForCommand(sc, input) + // operations is skipped by parse-time schema validation, so type-check the + // sub-op's scalar fields here before the translator reads them via + // Int/Bool/Float64 (which would otherwise coerce a wrong type to zero). + if err := fv.validateRawTypes(); err != nil { + return nil, common.FlagErrorf("operations[%d] (%s): %v", index, sc, err) + } sheetIDFlag, sheetNameFlag := sheetSelectorFlagsForSubOp(sc) sheetID := strings.TrimSpace(fv.Str(sheetIDFlag)) sheetName := strings.TrimSpace(fv.Str(sheetNameFlag)) diff --git a/shortcuts/sheets/execute_paths_test.go b/shortcuts/sheets/execute_paths_test.go index 1906faf53..27eb2b2e2 100644 --- a/shortcuts/sheets/execute_paths_test.go +++ b/shortcuts/sheets/execute_paths_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" ) // TestExecute_WorkbookInfo_Happy stubs the invoke_read endpoint and @@ -362,6 +363,82 @@ func TestExecute_WorkbookCreate(t *testing.T) { } } +// TestExecute_WorkbookCreate_EmptyArraysSkipFill locks the fix for the nil-map +// panic / illegal-range bug: --values '[]' or --headers '[]' must short-circuit +// the initial fill (no structure/fill calls fire) and finish with the +// spreadsheet created but no initial_fill — never panic on a nil fill map. +func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) { + t.Parallel() + for _, tc := range []struct{ name, flag, val string }{ + {"empty values", "--values", "[]"}, + {"empty headers", "--headers", "[]"}, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + create := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "spreadsheet": map[string]interface{}{"spreadsheet_token": "shtNEW", "title": "X"}, + }, + }, + } + // Only the create stub is provided: an empty array must skip the fill + // entirely, so no structure/fill call fires (and no nil-map panic). + out, err := runShortcutWithStubs(t, WorkbookCreate, []string{"--title", "X", tc.flag, tc.val}, create) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + data := decodeEnvelopeData(t, out) + if data["initial_fill"] != nil { + t.Errorf("initial_fill should be absent for %s %s; got %#v", tc.flag, tc.val, data["initial_fill"]) + } + if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtNEW" { + t.Errorf("spreadsheet_token = %v, want shtNEW", ss["spreadsheet_token"]) + } + }) + } +} + +// TestExecute_WorkbookCreate_FillFailureKeepsToken locks the partial-success +// contract: when the spreadsheet is created but the follow-up fill can't resolve +// its first sheet, the error must be structured and retain spreadsheet_token so +// the caller can recover instead of orphaning the new workbook. +func TestExecute_WorkbookCreate_FillFailureKeepsToken(t *testing.T) { + t.Parallel() + create := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "spreadsheet": map[string]interface{}{"spreadsheet_token": "shtNEW", "title": "X"}, + }, + }, + } + // Structure comes back with no sheets, so lookupFirstSheetID fails AFTER the + // spreadsheet already exists — exercising the partial-success path. + structure := toolOutputStub("shtNEW", "read", `{"sheets":[]}`) + out, err := runShortcutWithStubs(t, WorkbookCreate, []string{"--title", "X", "--values", `[["a"]]`}, create, structure) + if err == nil { + t.Fatalf("expected a partial-success error; got nil\nout=%s", out) + } + exitErr, ok := err.(*output.ExitError) + if !ok { + t.Fatalf("error type = %T, want *output.ExitError (structured)", err) + } + if exitErr.Detail == nil { + t.Fatal("ExitError.Detail is nil; want structured detail carrying the token") + } + detail, _ := exitErr.Detail.Detail.(map[string]interface{}) + if detail["spreadsheet_token"] != "shtNEW" { + t.Errorf("detail.spreadsheet_token = %v, want shtNEW (must survive the fill failure)", detail["spreadsheet_token"]) + } +} + // TestExecute_DimMove covers the native v3 move_dimension call. CLI's // --source-range "1:3" (1-based inclusive) is parsed into v3's // source.{start_index=0,end_index=2} (0-based inclusive); --target "11" is diff --git a/shortcuts/sheets/flag_view.go b/shortcuts/sheets/flag_view.go index 3e922c527..1d906baf0 100644 --- a/shortcuts/sheets/flag_view.go +++ b/shortcuts/sheets/flag_view.go @@ -254,3 +254,76 @@ func (m mapFlagView) Changed(name string) bool { _, ok := m.lookupRaw(name) return ok } + +// validateRawTypes rejects sub-op input fields whose JSON type contradicts the +// flag's declared type in flag-defs. +batch-update skips parse-time schema +// validation for `operations`, and Int/Int64/Float64/Bool silently fall back to +// the zero value on a type mismatch — so without this guard a wrong-typed scalar +// (e.g. "index":"abc" or "multiple":"true") would land as 0 / false instead of +// erroring, writing to the wrong place. Only numeric and boolean flags are +// checked; string and composite (array/object) flags stay permissive because +// Str() intentionally coerces them and the translator/schema validates shape. +// +// Returns a bare error; the +batch-update translator wraps it with the +// operations[i] () context. +func (m mapFlagView) validateRawTypes() error { + if len(m.raw) == 0 { + return nil + } + defs, err := loadFlagDefs() + if err != nil { + return nil + } + spec, ok := defs[m.command] + if !ok { + return nil + } + declaredType := make(map[string]string, len(spec.Flags)) + for _, df := range spec.Flags { + declaredType[df.Name] = df.Type + } + for rawKey, val := range m.raw { + name := rawKey + typ, ok := declaredType[name] + if !ok { + // flag-defs use hyphen names; tolerate the underscore form users send. + name = strings.ReplaceAll(rawKey, "_", "-") + typ, ok = declaredType[name] + } + if !ok { + continue // unknown key — leave it for the translator / schema layer + } + switch typ { + case "int", "int64", "float64": + if _, isNum := val.(float64); !isNum { + return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) + } + case "bool": + if _, isBool := val.(bool); !isBool { + return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val)) + } + } + } + return nil +} + +// jsonTypeName names the JSON kind of a value decoded by encoding/json, for +// type-mismatch error messages. +func jsonTypeName(v interface{}) string { + switch v.(type) { + case nil: + return "null" + case bool: + return "boolean" + case float64: + return "number" + case string: + return "string" + case []interface{}: + return "array" + case map[string]interface{}: + return "object" + default: + return fmt.Sprintf("%T", v) + } +} diff --git a/shortcuts/sheets/lark_sheet_workbook.go b/shortcuts/sheets/lark_sheet_workbook.go index 2d788421c..cf4ab6498 100644 --- a/shortcuts/sheets/lark_sheet_workbook.go +++ b/shortcuts/sheets/lark_sheet_workbook.go @@ -598,8 +598,7 @@ var WorkbookCreate = common.Shortcut{ POST("/open-apis/sheets/v3/spreadsheets"). Desc("create spreadsheet"). Body(body) - if runtime.Str("headers") != "" || runtime.Str("values") != "" { - fill, _ := buildInitialFillInput(runtime) + if fill, _ := buildInitialFillInput(runtime); fill != nil { fill["excel_id"] = "" fill["sheet_id"] = "" // resolved from the workbook at execute time wireBody, _ := buildToolBody("set_cell_range", fill) @@ -629,24 +628,27 @@ var WorkbookCreate = common.Shortcut{ result := map[string]interface{}{"spreadsheet": ss} - if runtime.Str("headers") != "" || runtime.Str("values") != "" { - fill, err := buildInitialFillInput(runtime) - if err != nil { - return err - } + // --headers / --values are optional. buildInitialFillInput returns + // (nil, nil) when both are absent or empty, in which case we skip the + // fill entirely rather than dereferencing a nil map. + fill, err := buildInitialFillInput(runtime) + if err != nil { + return err + } + if fill != nil { fill["excel_id"] = token // set_cell_range needs a concrete sheet selector; the create // response doesn't echo the default sheet's id, so read it back. firstSheetID, err := lookupFirstSheetID(ctx, runtime, token) if err != nil { - return fmt.Errorf("spreadsheet %s created but resolving its first sheet for initial fill failed: %w", token, err) + return workbookCreatedButFillFailed(token, ss, + fmt.Sprintf("resolving its first sheet for initial fill failed: %v", err)) } fill["sheet_id"] = firstSheetID fillOut, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", fill) if err != nil { - // Spreadsheet exists; surface the fill failure but keep the new - // token in the envelope so the caller can recover or retry. - return fmt.Errorf("spreadsheet %s created but initial fill failed: %w", token, err) + return workbookCreatedButFillFailed(token, ss, + fmt.Sprintf("initial fill failed: %v", err)) } result["initial_fill"] = fillOut } @@ -658,6 +660,26 @@ var WorkbookCreate = common.Shortcut{ }, } +// workbookCreatedButFillFailed builds a structured partial-success error for the +// window where the spreadsheet POST succeeded but the follow-up initial fill did +// not. The new spreadsheet_token is surfaced in the error detail so callers can +// retry the fill (+cells-set / +csv-put) or delete the orphan, instead of only +// finding the token interpolated into a bare error string. +func workbookCreatedButFillFailed(token string, spreadsheet interface{}, reason string) error { + return &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{ + Type: "partial_success", + Message: fmt.Sprintf("spreadsheet %s created but %s", token, reason), + Hint: "the spreadsheet exists; retry the fill with the returned spreadsheet_token, or delete it", + Detail: map[string]interface{}{ + "spreadsheet_token": token, + "spreadsheet": spreadsheet, + }, + }, + } +} + // buildInitialFillInput zips --headers + --values into a single set_cell_range // payload writing to the first sheet starting at A1. func buildInitialFillInput(runtime *common.RuntimeContext) (map[string]interface{}, error) { @@ -692,6 +714,13 @@ func buildInitialFillInput(runtime *common.RuntimeContext) (map[string]interface maxCols = len(r) } } + if maxCols == 0 { + // --headers '[]' / --values '[]' parse to rows that carry no cells. + // There is nothing to write and a 0-width range ("A1:1") would be + // illegal, so treat it as "no initial fill" — same contract as the + // len(rows)==0 case above — and let the caller skip the write. + return nil, nil + } // Normalize rows to the same length so cells matrix is rectangular. for i := range rows { for len(rows[i]) < maxCols { From 6bd21ac8c9af4a2ec5009dc7b8cd24159385c613 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Wed, 3 Jun 2026 14:11:02 +0800 Subject: [PATCH 101/114] fix(cli): structured errors for unknown flags, print-schema, deprecated aliases From the branch code-review doc (3 findings): - pure-group UnknownFlags: installUnknownSubcommandGuard whitelists unknown flags so a mistyped subcommand still reaches the suggestion path, but a lone unknown flag before any subcommand (`sheets --badflag`) was swallowed and the group fell through to help + exit 0. unknownSubcommandRunE now recovers the swallowed tokens (from os.Args captured at Execute entry) and fails with a structured unknown_flag error; a misplaced but known flag (e.g. --format) still prints help. - deprecated-alias notice: a backward-compat alias that fails a cobra-level required flag short-circuits before RunE, so the Validate/Execute-wrapped deprecation notice was dropped. Added Shortcut.OnInvoke, fired from PreRunE (ahead of ValidateRequiredFlags); and the root legacy error fallback now routes through the structured envelope when a deprecation is pending so the migration hint survives. Non-deprecated errors keep the plain output. - --print-schema: runShortcut returned the bare error from PrintFlagSchema. It is now wrapped as a structured output.ExitError (type print_schema_error) so agent introspection can parse the failure. Tests added for each path; cmd + sheets suites green. --- cmd/root.go | 91 ++++++++++++++++++++++++++++ cmd/root_test.go | 49 +++++++++++++++ cmd/unknown_subcommand_test.go | 54 +++++++++++++++++ shortcuts/common/runner.go | 36 +++++++---- shortcuts/common/types.go | 8 +++ shortcuts/register.go | 7 +++ shortcuts/sheets/flag_schema_test.go | 29 +++++++++ 7 files changed, 263 insertions(+), 11 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 752be6096..eebbfa67c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -86,7 +86,15 @@ COMMUNITY: More help: lark-cli --help` // Execute runs the root command and returns the process exit code. +// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's +// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags +// before they reach a group's RunE, so unknownSubcommandRunE re-derives them +// from here. It stays nil in unit tests that invoke a RunE directly with +// explicit args — correct, since those don't exercise the whitelist path. +var rawInvocationArgs []string + func Execute() int { + rawInvocationArgs = os.Args[1:] inv, err := BootstrapInvocationContext(os.Args[1:]) if err != nil { fmt.Fprintln(os.Stderr, "Error:", err) @@ -290,6 +298,19 @@ func handleRootError(f *cmdutil.Factory, err error) int { return exitErr.Code } + // A backward-compat alias records its deprecation notice in PreRunE, which + // runs before cobra's required-flag validation — but a missing required flag + // fails before RunE and lands here, where the bare "Error:" line would drop + // the notice. When a deprecation is pending, route through the structured + // envelope so the migration hint still reaches the caller; all other errors + // keep the existing plain output. + if deprecation.GetPending() != nil { + output.WriteErrorEnvelope(errOut, &output.ExitError{ + Code: 1, + Detail: &output.ErrDetail{Type: "validation", Message: err.Error()}, + }, string(f.ResolvedIdentity)) + return 1 + } fmt.Fprintln(errOut, "Error:", err) return 1 } @@ -356,6 +377,26 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) { // they have moved to the typed surface. func unknownSubcommandRunE(cmd *cobra.Command, args []string) error { if len(args) == 0 { + // A bare group (e.g. `sheets`) legitimately prints help. But an unknown + // flag placed before any subcommand (`sheets --badflag`) is whitelisted + // away by installUnknownSubcommandGuard, which also leaves args empty — + // without this check it would silently fall through to help + exit 0. + // Recover the swallowed flag tokens and fail structured so agents (and + // the flagDidYouMean contract) still see a real error. + if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 { + return &output.ExitError{ + Code: output.ExitValidation, + Detail: &output.ErrDetail{ + Type: "unknown_flag", + Message: fmt.Sprintf("unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()), + Hint: fmt.Sprintf("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()), + Detail: map[string]any{ + "unknown_flags": unknown, + "command_path": cmd.CommandPath(), + }, + }, + } + } return cmd.Help() } unknown := args[0] @@ -393,6 +434,56 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error { } } +// unknownFlagTokens returns the -/-- tokens in rawArgs that cmd does not define. +// installUnknownSubcommandGuard whitelists unknown flags on pure groups so a +// mistyped subcommand still reaches the suggestion path; the side effect is that +// a lone unknown flag (no subcommand) is swallowed, leaving the group to fall +// through to help. This recovers those tokens so the caller can fail structured. +func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string { + var unknown []string + for _, a := range rawArgs { + if a == "--" { + break // everything after -- is positional + } + if len(a) < 2 || a[0] != '-' { + continue + } + name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0] + if name != "" && !flagDefinedInTree(cmd, name) { + unknown = append(unknown, a) + } + } + return unknown +} + +// flagDefinedInTree reports whether name is defined on cmd, its inherited +// (persistent) flags, or any direct subcommand. The subcommand case covers a +// user who merely omitted the subcommand — e.g. `sheets --format json`, where +// --format is injected on every leaf shortcut, not on the group — so only a +// genuinely unknown flag like `sheets --badflag` is reported. +func flagDefinedInTree(cmd *cobra.Command, name string) bool { + short := len(name) == 1 + known := func(c *cobra.Command, inherited bool) bool { + fs := c.Flags() + if inherited { + fs = c.InheritedFlags() + } + if short { + return fs.ShorthandLookup(name) != nil + } + return fs.Lookup(name) != nil + } + if known(cmd, false) || known(cmd, true) { + return true + } + for _, c := range cmd.Commands() { + if known(c, false) { + return true + } + } + return false +} + // availableSubcommandNames returns the invokable subcommand names of cmd, split // into current commands and backward-compatibility aliases (those tagged into // the deprecated cobra group via cmdutil.DeprecatedGroupID). Both slices are diff --git a/cmd/root_test.go b/cmd/root_test.go index 3ab78ceb2..fb1759e8f 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -21,6 +21,7 @@ import ( internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/deprecation" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" ) @@ -268,6 +269,54 @@ func (f *failingWriter) Write(p []byte) (int, error) { return len(p), nil } +// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins issue #4: a +// backward-compat alias that fails on a cobra-level required flag (which +// short-circuits before RunE) still routes through the structured envelope, +// because OnInvoke records the deprecation in PreRunE and the legacy fallback +// switches to WriteErrorEnvelope when a deprecation is pending — so the +// migration notice is no longer dropped on the plain "Error:" line. +func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Cleanup(func() { deprecation.SetPending(nil) }) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + errOut := &bytes.Buffer{} + f.IOStreams.ErrOut = errOut + + deprecation.SetPending(&deprecation.Notice{ + Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets", + }) + // The bare error shape cobra's ValidateRequiredFlags produces: neither typed + // nor an *output.ExitError, so it reaches the legacy fallback. + handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values")) + + out := errOut.String() + if strings.HasPrefix(strings.TrimSpace(out), "Error:") { + t.Fatalf("deprecation pending: want a structured envelope, got a plain Error: line:\n%s", out) + } + if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") { + t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out) + } +} + +// TestHandleRootError_NoDeprecationKeepsPlainError pins the other half: with no +// deprecation pending, the legacy fallback stays a plain "Error:" line, so the +// fix does not reshape every unrecognized cobra error. +func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Cleanup(func() { deprecation.SetPending(nil) }) + deprecation.SetPending(nil) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + errOut := &bytes.Buffer{} + f.IOStreams.ErrOut = errOut + + handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values")) + if !strings.HasPrefix(errOut.String(), "Error:") { + t.Errorf("no deprecation pending: want a plain 'Error:' line, got:\n%s", errOut.String()) + } +} + // TestHandleRootError_PartialWritePreservesExitCode pins that when the // stderr write fails mid-envelope, handleRootError still returns the typed // exit code (ExitAuth=3 for AuthenticationError), not fall through to the diff --git a/cmd/unknown_subcommand_test.go b/cmd/unknown_subcommand_test.go index 67c1a3e59..7f68fdaaa 100644 --- a/cmd/unknown_subcommand_test.go +++ b/cmd/unknown_subcommand_test.go @@ -73,6 +73,60 @@ func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) { } } +func TestUnknownFlagTokens(t *testing.T) { + _, drive, _ := newGroupTree() + // Give a subcommand a flag so a misplaced-but-known flag (the user omitted + // the subcommand) is distinguished from a genuinely unknown one. + for _, c := range drive.Commands() { + if c.Name() == "+search" { + c.Flags().String("query", "", "") + } + } + cases := []struct { + name string + rawArgs []string + want []string + }{ + {"genuinely unknown long flag", []string{"drive", "--badflag"}, []string{"--badflag"}}, + {"flag known on a subcommand (misplaced)", []string{"drive", "--query", "x"}, nil}, + {"no flags at all", []string{"drive"}, nil}, + {"tokens after -- are positional", []string{"drive", "--", "--badflag"}, nil}, + {"unknown shorthand", []string{"drive", "-Z"}, []string{"-Z"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := unknownFlagTokens(drive, tc.rawArgs) + if len(got) != len(tc.want) { + t.Fatalf("unknownFlagTokens(%v) = %v, want %v", tc.rawArgs, got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("token[%d] = %q, want %q", i, got[i], tc.want[i]) + } + } + }) + } +} + +func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) { + _, drive, _ := newGroupTree() + installUnknownSubcommandGuard(drive.Root()) + + // Simulate `lark-cli drive --badflag`: the UnknownFlags whitelist swallows + // --badflag, so RunE sees no args; the guard must recover it from + // rawInvocationArgs and fail structured rather than print help + exit 0. + rawInvocationArgs = []string{"drive", "--badflag"} + t.Cleanup(func() { rawInvocationArgs = nil }) + + err := drive.RunE(drive, nil) + if err == nil { + t.Fatal("expected a structured unknown_flag error, got nil (help fallthrough)") + } + if !strings.Contains(err.Error(), "unknown flag") { + t.Errorf("error = %q, want it to mention an unknown flag", err.Error()) + } +} + func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) { _, drive, _ := newGroupTree() installUnknownSubcommandGuard(drive.Root()) diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index a2109c669..079a2c863 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -771,19 +771,26 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f return runShortcut(cmd, f, &shortcut, botOnly) }, } - if shortcut.PrintFlagSchema != nil { - // --print-schema is pure local introspection; relax cobra's - // required-flag gate so callers don't need to fill in unrelated - // flags just to ask for a schema. ValidateRequiredFlags runs - // after PreRunE in cobra, so clearing the annotation here is the - // supported way to opt out. + if shortcut.PrintFlagSchema != nil || shortcut.OnInvoke != nil { + onInvoke := shortcut.OnInvoke + relaxRequiredForSchema := shortcut.PrintFlagSchema != nil + // PreRunE runs before cobra's ValidateRequiredFlags. Two opt-in uses: + // - OnInvoke: fire a side effect (e.g. a deprecation notice) that must + // surface even when the call later fails on a missing required flag. + // - --print-schema: pure local introspection; relax the required-flag + // gate so callers don't fill in unrelated flags just to ask for a + // schema (clearing the annotation here is the supported opt-out). cmd.PreRunE = func(c *cobra.Command, _ []string) error { - if want, _ := c.Flags().GetBool("print-schema"); !want { - return nil + if onInvoke != nil { + onInvoke() + } + if relaxRequiredForSchema { + if want, _ := c.Flags().GetBool("print-schema"); want { + c.Flags().VisitAll(func(fl *pflag.Flag) { + delete(fl.Annotations, cobra.BashCompOneRequiredFlag) + }) + } } - c.Flags().VisitAll(func(fl *pflag.Flag) { - delete(fl.Annotations, cobra.BashCompOneRequiredFlag) - }) return nil } } @@ -808,6 +815,13 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo flagName, _ := cmd.Flags().GetString("flag-name") out, err := s.PrintFlagSchema(strings.TrimSpace(flagName)) if err != nil { + // PrintFlagSchema implementations return bare errors; wrap as a + // structured ExitError so --print-schema (an agent-facing + // introspection path) yields a parseable envelope, not a plain + // string. + if _, ok := err.(*output.ExitError); !ok { + err = output.Errorf(output.ExitValidation, "print_schema_error", "%s", err.Error()) + } return err } if len(out) == 0 { diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 0abbee9d0..0abe1d70f 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -58,6 +58,14 @@ type Shortcut struct { Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic + // OnInvoke, when non-nil, runs from the command's cobra PreRunE — before + // cobra validates required flags — so its side effect fires even when the + // call later fails on a missing required flag (which short-circuits before + // Validate/Execute). The backward-compat aliases use it to record a + // deprecation notice that must surface regardless of whether the call + // validates. Fire-and-forget: no args, no return (e.g. deprecation.SetPending). + OnInvoke func() + // PrintFlagSchema, when non-nil, opts this shortcut into the // `--print-schema --flag-name ` runtime introspection contract. // The framework auto-injects those two system flags and short-circuits diff --git a/shortcuts/register.go b/shortcuts/register.go index c5cfee5fe..8efa979a8 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -339,6 +339,13 @@ func wrapSheetsBackwardDeprecation(list []common.Shortcut) []common.Shortcut { return origExecute(ctx, runtime) } } + // The Validate/Execute wrappers above miss one path: a cobra-level + // required flag (MarkFlagRequired) that is absent fails at + // ValidateRequiredFlags, before RunE — so neither hook runs and the + // notice would be lost on exactly the "stale skill calls the old command + // and mis-supplies flags" case it exists for. OnInvoke runs from PreRunE, + // ahead of ValidateRequiredFlags, so the notice still surfaces there. + list[i].OnInvoke = func() { deprecation.SetPending(notice) } } return list } diff --git a/shortcuts/sheets/flag_schema_test.go b/shortcuts/sheets/flag_schema_test.go index 6a541ea96..69e882da4 100644 --- a/shortcuts/sheets/flag_schema_test.go +++ b/shortcuts/sheets/flag_schema_test.go @@ -5,8 +5,11 @@ package sheets import ( "encoding/json" + "errors" "strings" "testing" + + "github.com/larksuite/cli/internal/output" ) // TestFlagSchemas_EmbedParses asserts the synced flag-schemas.json @@ -171,6 +174,32 @@ func TestPrintSchema_SystemFlagAbsentForReadOnlyShortcut(t *testing.T) { } } +// TestPrintSchema_UnknownFlagNameIsStructured pins issue #6: an unregistered +// --flag-name passed to --print-schema must surface as a structured +// *output.ExitError (type print_schema_error), not a bare error string, so the +// agent-facing introspection path stays machine-parseable. +func TestPrintSchema_UnknownFlagNameIsStructured(t *testing.T) { + t.Parallel() + // PrintFlagSchema is wired during registration (shortcuts.go), not on the + // literal, so replicate that here to make Mount inject the --print-schema / + // --flag-name system flags. + sc := CellsSet + sc.PrintFlagSchema = printFlagSchemaFor(sc.Command) + _, _, err := runShortcutCapturingErr(t, sc, []string{ + "--print-schema", "--flag-name", "nonexistent", + }) + if err == nil { + t.Fatal("expected an error for --print-schema with an unregistered flag name") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("error type = %T, want a structured *output.ExitError", err) + } + if exitErr.Detail == nil || exitErr.Detail.Type != "print_schema_error" { + t.Errorf("error detail = %+v, want type print_schema_error", exitErr.Detail) + } +} + func keysOf(m map[string]interface{}) []string { out := make([]string, 0, len(m)) for k := range m { From 27c633362027de177b5aa9bfc079326896b69f3c Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Wed, 3 Jun 2026 14:25:43 +0800 Subject: [PATCH 102/114] fix(sheets): resolve --sheet-name via title + keep bare sheet selectors verbatim Two review findings on the backward-compat layer: - lookupSheetIndex matched only sm["sheet_name"], but get_workbook_structure surfaces the sub-sheet display name as "title". Every --sheet-name path that relies on the lookup (e.g. +sheet-move) failed to resolve. Fall back to "title" when "sheet_name" is absent so either field resolves. - +read / +write / +append fell back to --sheet-id when --range was omitted, then routed that bare sheet id through the range normalizer. A sheet id that looks A1-ish (letters+digits, e.g. "shtABC123") got mangled into "shtABC123!shtABC123:shtABC123". Split the sheet-only path from the range-normalization path: read/append pass the selector through verbatim, write builds the rect from the selector's A1. Regression tests added for both paths; sheets suite green. --- .../sheets/backward/lark_sheets_cell_data.go | 85 +++++++++++-------- .../backward/lark_sheets_sheet_ranges_test.go | 54 ++++++++++++ shortcuts/sheets/execute_paths_test.go | 28 ++++++ shortcuts/sheets/lark_sheet_workbook.go | 6 ++ 4 files changed, 138 insertions(+), 35 deletions(-) diff --git a/shortcuts/sheets/backward/lark_sheets_cell_data.go b/shortcuts/sheets/backward/lark_sheets_cell_data.go index be04c0b05..59d6fa74e 100644 --- a/shortcuts/sheets/backward/lark_sheets_cell_data.go +++ b/shortcuts/sheets/backward/lark_sheets_cell_data.go @@ -54,10 +54,14 @@ var SheetRead = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := validateSheetManageToken(runtime) readRange := runtime.Str("range") - if readRange == "" && runtime.Str("sheet-id") != "" { + if readRange == "" { + // Sheet-only selector: pass the bare sheet id through verbatim. + // Routing it via the range normalizer mangles ids that look + // A1-ish (e.g. "shtABC123" -> "shtABC123!shtABC123:shtABC123"). readRange = runtime.Str("sheet-id") + } else { + readRange = normalizePointRange(runtime.Str("sheet-id"), readRange) } - readRange = normalizePointRange(runtime.Str("sheet-id"), readRange) return common.NewDryRunAPI(). GET("/open-apis/sheets/v2/spreadsheets/:token/values/:range"). Set("token", token).Set("range", readRange) @@ -66,18 +70,19 @@ var SheetRead = common.Shortcut{ token, _ := validateSheetManageToken(runtime) readRange := runtime.Str("range") - if readRange == "" && runtime.Str("sheet-id") != "" { - readRange = runtime.Str("sheet-id") - } - if readRange == "" { - var err error - readRange, err = getFirstSheetID(runtime, token) - if err != nil { - return err + // Sheet-only selector: keep the resolved sheet id verbatim (see DryRun). + readRange = runtime.Str("sheet-id") + if readRange == "" { + var err error + readRange, err = getFirstSheetID(runtime, token) + if err != nil { + return err + } } + } else { + readRange = normalizePointRange(runtime.Str("sheet-id"), readRange) } - readRange = normalizePointRange(runtime.Str("sheet-id"), readRange) params := map[string]interface{}{} renderOption := runtime.Str("value-render-option") @@ -124,11 +129,14 @@ var SheetWrite = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := validateSheetManageToken(runtime) writeRange := runtime.Str("range") - if writeRange == "" && runtime.Str("sheet-id") != "" { - writeRange = runtime.Str("sheet-id") - } values, _ := parseValues2DJSON(runtime.Str("values")) - writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values) + if writeRange == "" { + // Sheet-only selector: build the write rect from the selector's + // A1 instead of treating the bare sheet id as a cell anchor. + writeRange = normalizeWriteRange(runtime.Str("sheet-id"), "", values) + } else { + writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values) + } return common.NewDryRunAPI(). PUT("/open-apis/sheets/v2/spreadsheets/:token/values"). Body(map[string]interface{}{"valueRange": map[string]interface{}{"range": writeRange, "values": values}}). @@ -143,18 +151,21 @@ var SheetWrite = common.Shortcut{ } writeRange := runtime.Str("range") - if writeRange == "" && runtime.Str("sheet-id") != "" { - writeRange = runtime.Str("sheet-id") - } - if writeRange == "" { - var err error - writeRange, err = getFirstSheetID(runtime, token) - if err != nil { - return err + // Sheet-only selector: build the write rect from the selector's + // A1 (see DryRun). Resolve the first sheet when none was given. + sel := runtime.Str("sheet-id") + if sel == "" { + var err error + sel, err = getFirstSheetID(runtime, token) + if err != nil { + return err + } } + writeRange = normalizeWriteRange(sel, "", values) + } else { + writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values) } - writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values) data, err := runtime.CallAPI("PUT", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values", validate.EncodePathSegment(token)), nil, map[string]interface{}{ "valueRange": map[string]interface{}{ @@ -200,11 +211,14 @@ var SheetAppend = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := validateSheetManageToken(runtime) appendRange := runtime.Str("range") - if appendRange == "" && runtime.Str("sheet-id") != "" { + if appendRange == "" { + // Sheet-only selector: pass the bare sheet id through verbatim + // (see SheetRead.DryRun for the normalizer-mangling rationale). appendRange = runtime.Str("sheet-id") + } else { + appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange) } values, _ := parseValues2DJSON(runtime.Str("values")) - appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange) return common.NewDryRunAPI(). POST("/open-apis/sheets/v2/spreadsheets/:token/values_append"). Body(map[string]interface{}{"valueRange": map[string]interface{}{"range": appendRange, "values": values}}). @@ -219,18 +233,19 @@ var SheetAppend = common.Shortcut{ } appendRange := runtime.Str("range") - if appendRange == "" && runtime.Str("sheet-id") != "" { - appendRange = runtime.Str("sheet-id") - } - if appendRange == "" { - var err error - appendRange, err = getFirstSheetID(runtime, token) - if err != nil { - return err + // Sheet-only selector: keep the resolved sheet id verbatim (see DryRun). + appendRange = runtime.Str("sheet-id") + if appendRange == "" { + var err error + appendRange, err = getFirstSheetID(runtime, token) + if err != nil { + return err + } } + } else { + appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange) } - appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange) data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{ "valueRange": map[string]interface{}{ diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_ranges_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_ranges_test.go index 6aaea0130..e9961eb97 100644 --- a/shortcuts/sheets/backward/lark_sheets_sheet_ranges_test.go +++ b/shortcuts/sheets/backward/lark_sheets_sheet_ranges_test.go @@ -126,6 +126,60 @@ func TestSheetAppendDryRunNormalizesEscapedSeparator(t *testing.T) { } } +// A bare sheet selector (no --range) must pass through verbatim. Sheet ids +// that look A1-ish (letters+digits) would otherwise be mangled by the range +// normalizer into "!:". + +func TestSheetReadDryRunSheetOnlyVerbatim(t *testing.T) { + t.Parallel() + + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "sht_test", + "range": "", + "sheet-id": "shtABC123", + }, nil) + + got := mustMarshalSheetsDryRun(t, SheetRead.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"range":"shtABC123"`) { + t.Fatalf("SheetRead.DryRun() = %s, want bare sheet id verbatim", got) + } +} + +func TestSheetWriteDryRunSheetOnlyBuildsRect(t *testing.T) { + t.Parallel() + + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "sht_test", + "range": "", + "sheet-id": "shtABC123", + "values": `[["x"]]`, + }, nil) + + got := mustMarshalSheetsDryRun(t, SheetWrite.DryRun(context.Background(), runtime)) + // Built from the sheet's A1 (A1:A1 for a 1x1 write), NOT the mangled + // "shtABC123!shtABC123:shtABC123" that piping a bare id through the + // range normalizer produced. + if !strings.Contains(got, `"range":"shtABC123!A1:A1"`) { + t.Fatalf("SheetWrite.DryRun() = %s, want rect built from sheet A1", got) + } +} + +func TestSheetAppendDryRunSheetOnlyVerbatim(t *testing.T) { + t.Parallel() + + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "sht_test", + "range": "", + "sheet-id": "shtABC123", + "values": `[["foo"]]`, + }, nil) + + got := mustMarshalSheetsDryRun(t, SheetAppend.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"range":"shtABC123"`) { + t.Fatalf("SheetAppend.DryRun() = %s, want bare sheet id verbatim", got) + } +} + func TestSheetFindDryRunNormalizesEscapedSeparator(t *testing.T) { t.Parallel() diff --git a/shortcuts/sheets/execute_paths_test.go b/shortcuts/sheets/execute_paths_test.go index 27eb2b2e2..9470a3c85 100644 --- a/shortcuts/sheets/execute_paths_test.go +++ b/shortcuts/sheets/execute_paths_test.go @@ -95,6 +95,34 @@ func TestExecute_SheetMove_LookupsIndex(t *testing.T) { } } +// TestExecute_SheetMove_LookupsIndexByTitle covers the same lookup path as +// above but with get_workbook_structure exposing the display name as "title" +// (the field the real tool returns) instead of "sheet_name". lookupSheetIndex +// must resolve --sheet-name against either key. +func TestExecute_SheetMove_LookupsIndexByTitle(t *testing.T) { + t.Parallel() + lookup := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","title":"汇总","index":3}]}`) + move := toolOutputStub(testToken, "write", `{"sheet_id":"sh1"}`) + out, err := runShortcutWithStubs(t, SheetMove, + []string{"--url", testURL, "--sheet-name", "汇总", "--index", "0"}, + lookup, move, + ) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + if move.CapturedBody == nil { + t.Fatal("move stub didn't capture a body") + } + body := decodeRawEnvelopeBody(t, move.CapturedBody) + input := decodeToolInput(t, body, "modify_workbook_structure") + if input["sheet_id"] != "sh1" { + t.Errorf("sheet_id = %v, want sh1 (resolved from --sheet-name via title)", input["sheet_id"]) + } + if input["source_index"].(float64) != 3 { + t.Errorf("source_index = %v, want 3 (from lookup)", input["source_index"]) + } +} + // TestExecute_CellsGet covers a multi-range read end-to-end. func TestExecute_CellsGet(t *testing.T) { t.Parallel() diff --git a/shortcuts/sheets/lark_sheet_workbook.go b/shortcuts/sheets/lark_sheet_workbook.go index cf4ab6498..6ea725b5a 100644 --- a/shortcuts/sheets/lark_sheet_workbook.go +++ b/shortcuts/sheets/lark_sheet_workbook.go @@ -965,7 +965,13 @@ func lookupSheetIndex(ctx context.Context, runtime *common.RuntimeContext, token continue } id, _ := sm["sheet_id"].(string) + // get_workbook_structure surfaces the sub-sheet's display name as + // "title"; older/alt payloads use "sheet_name". Match either so a + // --sheet-name lookup resolves regardless of the field name. name, _ := sm["sheet_name"].(string) + if name == "" { + name, _ = sm["title"].(string) + } if (sheetID != "" && id == sheetID) || (sheetName != "" && name == sheetName) { idx, ok := util.ToFloat64(sm["index"]) if !ok { From 2132472b87ce7f081bdd4016b1167fe5b0da870f Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Wed, 3 Jun 2026 14:33:19 +0800 Subject: [PATCH 103/114] fix(sheets): silence nilerr/copyloopvar lint in batch type-check additions - flag_view.go: annotate the fail-open return in validateRawTypes with //nolint:nilerr (matches the repo convention for intentional fail-open). - execute_paths_test.go: drop the redundant tc := tc copy (Go 1.22+ scopes the loop var per iteration). --- shortcuts/sheets/execute_paths_test.go | 1 - shortcuts/sheets/flag_view.go | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/shortcuts/sheets/execute_paths_test.go b/shortcuts/sheets/execute_paths_test.go index 9470a3c85..8cd24bb25 100644 --- a/shortcuts/sheets/execute_paths_test.go +++ b/shortcuts/sheets/execute_paths_test.go @@ -401,7 +401,6 @@ func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) { {"empty values", "--values", "[]"}, {"empty headers", "--headers", "[]"}, } { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() create := &httpmock.Stub{ diff --git a/shortcuts/sheets/flag_view.go b/shortcuts/sheets/flag_view.go index 1d906baf0..c369d538e 100644 --- a/shortcuts/sheets/flag_view.go +++ b/shortcuts/sheets/flag_view.go @@ -272,7 +272,7 @@ func (m mapFlagView) validateRawTypes() error { } defs, err := loadFlagDefs() if err != nil { - return nil + return nil //nolint:nilerr // fail-open: if flag-defs can't load, skip type validation rather than block the batch } spec, ok := defs[m.command] if !ok { From cf47d9b5f95782d0f16a7ac7ca3fb166128a7899 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Wed, 3 Jun 2026 14:40:19 +0800 Subject: [PATCH 104/114] test(sheets): data-driven required-flag parity contract for batch sub-ops Adds TestBatchOp_RequiredFlagParity, the systematic standalone-vs-batch parity check the branch review asked for. Data-driven over batchOpDispatch + flag-defs, it asserts that for every batchable shortcut a +batch-update sub-op which satisfies the sheet locator but omits the shortcut's business-required flags fails in translateBatchOp, never silently defaulting. This generalizes the hand-picked TestBatchOp_ErrorEquivalence / GuardsBeyondCobra cases to the full 50-command surface and auto-covers shortcuts added later, so a future refactor that moves a required check out of the shared *Input builder (the failure mode behind the csv-put / sheet-move gaps) is caught here. 45 sub-tests run; locator-only commands (+sheet-delete / +sheet-hide / ...) have no business-required flag to omit and are skipped. A missing-locator error is also rejected so a bad fixture can't mask a real gap. --- shortcuts/sheets/batch_op_contract_test.go | 73 ++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/shortcuts/sheets/batch_op_contract_test.go b/shortcuts/sheets/batch_op_contract_test.go index a2750b2de..ea7f7fbac 100644 --- a/shortcuts/sheets/batch_op_contract_test.go +++ b/shortcuts/sheets/batch_op_contract_test.go @@ -824,3 +824,76 @@ func TestBatchOp_DispatchCoversReportedBugs(t *testing.T) { t.Errorf("+rows-resize resize_height wrong: %#v", resizeIn) } } + +// TestBatchOp_RequiredFlagParity is the systematic standalone-vs-batch parity +// contract: for EVERY batchable shortcut, a +batch-update sub-op that satisfies +// the sheet locator but omits all of the shortcut's business-required flags must +// fail in translateBatchOp — never silently fall back to a default. The earlier +// cases (TestBatchOp_ErrorEquivalence / GuardsBeyondCobra) cover hand-picked +// shortcuts; this one is data-driven over batchOpDispatch + flag-defs, so it +// guards the whole surface and auto-covers any shortcut added later. If a future +// refactor moves a required check out of the shared *Input builder (the exact +// failure mode behind the csv-put / sheet-move gaps), the corresponding sub-op +// would start accepting missing args and this test fails. +func TestBatchOp_RequiredFlagParity(t *testing.T) { + t.Parallel() + defs, err := loadFlagDefs() + if err != nil { + t.Fatalf("loadFlagDefs: %v", err) + } + // Flags supplied by the +batch-update top level (url/token), or that form the + // sub-op's own sheet selector, are context — not "business" inputs. + locator := map[string]bool{ + "url": true, "spreadsheet-token": true, + "sheet-id": true, "sheet-name": true, + "target-sheet-id": true, "target-sheet-name": true, + } + // How each command expresses its sheet locator in a sub-op, so the error we + // trigger is the business one, not a missing-locator error. + sheetSel := func(cmd string) map[string]interface{} { + switch cmd { + case "+sheet-create": // create needs no existing-sheet anchor + return map[string]interface{}{} + case "+pivot-create": // placement selector is target-sheet-*; data source is --source + return map[string]interface{}{"target-sheet-id": "sh1"} + default: + return map[string]interface{}{"sheet-id": "sh1"} + } + } + for cmd := range batchOpDispatch { + spec, ok := defs[cmd] + if !ok { + t.Errorf("%s is in batchOpDispatch but has no flag-defs entry", cmd) + continue + } + var business []string + for _, fl := range spec.Flags { + if fl.Kind == "system" || locator[fl.Name] { + continue + } + if fl.Required == "required" || fl.Required == "xor" { + business = append(business, fl.Name) + } + } + if len(business) == 0 { + continue // only-locator commands (sheet-delete/hide/unhide/copy/filter-delete): nothing to omit + } + cmd, business := cmd, business + t.Run(cmd, func(t *testing.T) { + t.Parallel() + rawOp := map[string]interface{}{"shortcut": cmd, "input": sheetSel(cmd)} + _, err := translateBatchOp(rawOp, testToken, 0) + if err == nil { + t.Errorf("%s: a sub-op omitting business-required %v was accepted; want an error "+ + "(batch must reject missing required flags, not silently default)", cmd, business) + return + } + // The sub-op DID supply a sheet selector, so a missing-locator error + // would mean the fixture is wrong and the business-required check never + // actually ran — reject that shape so the parity check stays honest. + if strings.Contains(err.Error(), "specify at least one of") { + t.Errorf("%s: got a missing-locator error, not a business-required one (fixture bug): %v", cmd, err) + } + }) + } +} From d05fbcf041062b1cde8adff9eaced8b9df79c232 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Wed, 3 Jun 2026 14:43:51 +0800 Subject: [PATCH 105/114] refactor(sheets): drop unused int64 flag-type plumbing No sheets flag-def declares an int64 type and RuntimeContext.Int64 had zero callers, so remove the premature support: the RuntimeContext.Int64 helper, the registerShortcutFlagsWithContext int64 branch, the flagView Int64 method + mapFlagView impl, and the typedDefault/validateRawTypes int64 cases. float64 (consumed by --font-size) is kept. --- shortcuts/common/runner.go | 10 ---------- shortcuts/common/types.go | 2 +- shortcuts/sheets/flag_view.go | 25 ++----------------------- 3 files changed, 3 insertions(+), 34 deletions(-) diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 079a2c863..2906830e4 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -210,12 +210,6 @@ func (ctx *RuntimeContext) Int(name string) int { return v } -// Int64 returns an int64 flag value. -func (ctx *RuntimeContext) Int64(name string) int64 { - v, _ := ctx.Cmd.Flags().GetInt64(name) - return v -} - // Float64 returns a float64 flag value (non-integer numbers). func (ctx *RuntimeContext) Float64(name string) float64 { v, _ := ctx.Cmd.Flags().GetFloat64(name) @@ -1087,10 +1081,6 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f var d int fmt.Sscanf(fl.Default, "%d", &d) cmd.Flags().Int(fl.Name, d, desc) - case "int64": - var d int64 - fmt.Sscanf(fl.Default, "%d", &d) - cmd.Flags().Int64(fl.Name, d, desc) case "float64": var d float64 fmt.Sscanf(fl.Default, "%g", &d) diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 0abe1d70f..86eb80b74 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -18,7 +18,7 @@ const ( // Flag describes a CLI flag for a shortcut. type Flag struct { Name string // flag name (e.g. "calendar-id") - Type string // "string" (default) | "bool" | "int" | "int64" | "float64" | "string_array" | "string_slice" + Type string // "string" (default) | "bool" | "int" | "float64" | "string_array" | "string_slice" Default string // default value as string Desc string // help text Hidden bool // hidden from --help, still readable at runtime diff --git a/shortcuts/sheets/flag_view.go b/shortcuts/sheets/flag_view.go index c369d538e..3c799bba8 100644 --- a/shortcuts/sheets/flag_view.go +++ b/shortcuts/sheets/flag_view.go @@ -21,7 +21,6 @@ import ( type flagView interface { Str(name string) string Int(name string) int - Int64(name string) int64 Float64(name string) float64 Bool(name string) bool StrArray(name string) []string @@ -90,10 +89,6 @@ func typedDefault(df flagDef) interface{} { var n int fmt.Sscanf(df.Default, "%d", &n) return n - case "int64": - var n int64 - fmt.Sscanf(df.Default, "%d", &n) - return n case "float64": var f float64 fmt.Sscanf(df.Default, "%g", &f) @@ -175,22 +170,6 @@ func (m mapFlagView) Int(name string) int { return 0 } -func (m mapFlagView) Int64(name string) int64 { - v, ok := m.lookup(name) - if !ok { - return 0 - } - switch t := v.(type) { - case float64: - return int64(t) - case int: - return int64(t) - case int64: - return t - } - return 0 -} - func (m mapFlagView) Float64(name string) float64 { v, ok := m.lookup(name) if !ok { @@ -257,7 +236,7 @@ func (m mapFlagView) Changed(name string) bool { // validateRawTypes rejects sub-op input fields whose JSON type contradicts the // flag's declared type in flag-defs. +batch-update skips parse-time schema -// validation for `operations`, and Int/Int64/Float64/Bool silently fall back to +// validation for `operations`, and Int/Float64/Bool silently fall back to // the zero value on a type mismatch — so without this guard a wrong-typed scalar // (e.g. "index":"abc" or "multiple":"true") would land as 0 / false instead of // erroring, writing to the wrong place. Only numeric and boolean flags are @@ -294,7 +273,7 @@ func (m mapFlagView) validateRawTypes() error { continue // unknown key — leave it for the translator / schema layer } switch typ { - case "int", "int64", "float64": + case "int", "float64": if _, isNum := val.(float64); !isNum { return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) } From 1decb4399d2fa9a5aaff197870cb365c0955dc63 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Wed, 3 Jun 2026 14:46:05 +0800 Subject: [PATCH 106/114] test(sheets): drop redundant copyloopvar copy in required-flag parity test Go 1.22+ scopes the loop var per iteration, so `cmd, business := cmd, business` in TestBatchOp_RequiredFlagParity is a no-op that trips the repo's copyloopvar linter (same cleanup as 2132472). Behavior unchanged; 45 sub-tests still pass. --- shortcuts/sheets/batch_op_contract_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/shortcuts/sheets/batch_op_contract_test.go b/shortcuts/sheets/batch_op_contract_test.go index ea7f7fbac..16f99220a 100644 --- a/shortcuts/sheets/batch_op_contract_test.go +++ b/shortcuts/sheets/batch_op_contract_test.go @@ -878,7 +878,6 @@ func TestBatchOp_RequiredFlagParity(t *testing.T) { if len(business) == 0 { continue // only-locator commands (sheet-delete/hide/unhide/copy/filter-delete): nothing to omit } - cmd, business := cmd, business t.Run(cmd, func(t *testing.T) { t.Parallel() rawOp := map[string]interface{}{"shortcut": cmd, "input": sheetSel(cmd)} From 5ac35fd9fd36caf72176dbfcdc6ca5211da2aded Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Wed, 3 Jun 2026 14:58:17 +0800 Subject: [PATCH 107/114] revert(cli): drop non-interactive proxy-warning silencing WarnIfProxied's interactivity gate is a generic CLI/agent-UX change unrelated to the sheets refactor / backward-compat scope of this branch. Split out to a dedicated PR; restore WarnIfProxied to its single-arg form here (warn.go, warn_test.go, factory_default.go callers). --- internal/cmdutil/factory_default.go | 4 ++-- internal/transport/warn.go | 13 +---------- internal/transport/warn_test.go | 35 ++++++++--------------------- 3 files changed, 12 insertions(+), 40 deletions(-) diff --git a/internal/cmdutil/factory_default.go b/internal/cmdutil/factory_default.go index 39f8a7b50..514aaf93f 100644 --- a/internal/cmdutil/factory_default.go +++ b/internal/cmdutil/factory_default.go @@ -102,7 +102,7 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error { func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) { return sync.OnceValues(func() (*http.Client, error) { - transport.WarnIfProxied(f.IOStreams.ErrOut, f.IOStreams.IsTerminal) + transport.WarnIfProxied(f.IOStreams.ErrOut) var rt http.RoundTripper = transport.Shared() rt = &RetryTransport{Base: rt} @@ -129,7 +129,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) { lark.WithLogLevel(larkcore.LogLevelError), lark.WithHeaders(BaseSecurityHeaders()), } - transport.WarnIfProxied(f.IOStreams.ErrOut, f.IOStreams.IsTerminal) + transport.WarnIfProxied(f.IOStreams.ErrOut) opts = append(opts, lark.WithHttpClient(&http.Client{ Transport: buildSDKTransport(), CheckRedirect: safeRedirectPolicy, diff --git a/internal/transport/warn.go b/internal/transport/warn.go index 9cb68688f..cac050f72 100644 --- a/internal/transport/warn.go +++ b/internal/transport/warn.go @@ -73,18 +73,7 @@ func redactProxyURL(raw string) string { // WarnIfProxied prints a one-time warning to w when a proxy environment variable // is detected and proxy is not disabled via LARK_CLI_NO_PROXY. Proxy credentials // are redacted. Safe to call multiple times; only the first call prints. -// -// The warning is suppressed entirely when interactive is false — i.e. stdin is -// not a TTY, which is the case for agent / CI / piped invocations. Those callers -// frequently parse the CLI's stdout as JSON and merge streams with `2>&1`; a -// stray stderr warning then corrupts the parsed payload. Suppressing in the -// non-interactive case keeps machine-consumed output clean, while human -// interactive sessions still get the security notice. Passing interactive=false -// does not consume the once guard, so a later interactive call can still warn. -func WarnIfProxied(w io.Writer, interactive bool) { - if !interactive { - return - } +func WarnIfProxied(w io.Writer) { proxyWarningOnce.Do(func() { // Proxy plugin mode overrides env proxies and LARK_CLI_NO_PROXY (see // Shared), so its warning and disable instructions take precedence. diff --git a/internal/transport/warn_test.go b/internal/transport/warn_test.go index 6f1eb530c..13708ca7e 100644 --- a/internal/transport/warn_test.go +++ b/internal/transport/warn_test.go @@ -44,7 +44,7 @@ func TestWarnIfProxied_WithProxy(t *testing.T) { t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128") var buf bytes.Buffer - WarnIfProxied(&buf, true) + WarnIfProxied(&buf) out := buf.String() if out == "" { @@ -70,30 +70,13 @@ func TestWarnIfProxied_WithoutProxy(t *testing.T) { } var buf bytes.Buffer - WarnIfProxied(&buf, true) + WarnIfProxied(&buf) if buf.Len() != 0 { t.Errorf("expected no output when no proxy is set, got: %s", buf.String()) } } -func TestWarnIfProxied_SilentWhenNonInteractive(t *testing.T) { - proxyWarningOnce = sync.Once{} - - // Non-interactive (interactive=false) mirrors agent / CI / piped invocations - // where stdin is not a TTY. The proxy warning must be suppressed so callers - // that parse stdout as JSON — often merging streams with `2>&1` — are not - // corrupted by a stray stderr line. - t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128") - - var buf bytes.Buffer - WarnIfProxied(&buf, false) - - if buf.Len() != 0 { - t.Errorf("expected no warning in non-interactive mode, got: %s", buf.String()) - } -} - // TestWarnIfProxied_SilentWhenDisabled verifies that LARK_CLI_NO_PROXY suppresses warnings. func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) @@ -105,7 +88,7 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) { t.Setenv(EnvNoProxy, "1") var buf bytes.Buffer - WarnIfProxied(&buf, true) + WarnIfProxied(&buf) if buf.Len() != 0 { t.Errorf("expected no warning when proxy is disabled, got: %s", buf.String()) @@ -122,10 +105,10 @@ func TestWarnIfProxied_OnlyOnce(t *testing.T) { t.Setenv("HTTP_PROXY", "http://proxy:1234") var buf bytes.Buffer - WarnIfProxied(&buf, true) + WarnIfProxied(&buf) first := buf.String() - WarnIfProxied(&buf, true) + WarnIfProxied(&buf) second := buf.String() if first == "" { @@ -154,7 +137,7 @@ func TestWarnIfProxied_ProxyPluginEnabled(t *testing.T) { t.Setenv(EnvNoProxy, "1") var buf bytes.Buffer - WarnIfProxied(&buf, true) + WarnIfProxied(&buf) out := buf.String() if !strings.Contains(out, "127.0.0.1:3128") { @@ -186,7 +169,7 @@ func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) { t.Cleanup(func() { proxyPluginStatus = old }) var buf bytes.Buffer - WarnIfProxied(&buf, true) + WarnIfProxied(&buf) out := buf.String() if !strings.Contains(out, "custom CA") { @@ -212,7 +195,7 @@ func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) { t.Cleanup(func() { proxyPluginStatus = old }) var buf bytes.Buffer - WarnIfProxied(&buf, true) + WarnIfProxied(&buf) out := buf.String() if strings.Contains(out, "s3cret") { @@ -260,7 +243,7 @@ func TestWarnIfProxied_RedactsCredentials(t *testing.T) { t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080") var buf bytes.Buffer - WarnIfProxied(&buf, true) + WarnIfProxied(&buf) out := buf.String() if bytes.Contains([]byte(out), []byte("s3cret")) { From 46712cf939098cfb35de7d3ce21a45a564ee834b Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Wed, 3 Jun 2026 16:06:28 +0800 Subject: [PATCH 108/114] docs(sheets): correct +workbook-info output field and batch +sheet-move index requirement Sync from spec: +workbook-info returns sheet display name as 'title' (sheet_name only as legacy fallback), and +sheet-move inside +batch-update also requires --index, not just --sheet-id/--source-index. --- skills/lark-sheets/references/lark-sheets-workbook.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/lark-sheets/references/lark-sheets-workbook.md b/skills/lark-sheets/references/lark-sheets-workbook.md index 514ffb704..7b9d03b4a 100644 --- a/skills/lark-sheets/references/lark-sheets-workbook.md +++ b/skills/lark-sheets/references/lark-sheets-workbook.md @@ -142,7 +142,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ ### `+workbook-info` -输出契约:返回 `sheets[]`,每个含 `sheet_id` / `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`)/ `row_count` / `column_count` / `index` / `is_hidden`,以及计数字段 `merged_cells_count` / `chart_count` / `pivot_table_count` / `float_image_count`(无 `frozen_*` 字段,冻结信息请用 `+sheet-info` 读取)。是操作飞书表格的第一步——任何后续 sheet 级动作都需要先拿这里的 sheet_id。 ### `+sheet-create` @@ -167,7 +167,7 @@ lark-cli sheets +sheet-rename --url "..." --sheet-id "$SID" --title "汇总" standalone 路径在缺 `--source-index` / 只给 `--sheet-name` 时会自动发起一次 `+workbook-info` 读把它们解出来。 -> ⚠️ **在 `+batch-update` 内调用 `+sheet-move`**:必须同时显式传 `--sheet-id` 和 `--source-index`。batch 中途无法发起结构查询,所以 batch translator 强制要求两者都显式。 +> ⚠️ **在 `+batch-update` 内调用 `+sheet-move`**:必须同时显式传 `--sheet-id`、`--source-index` 和 `--index`(目标位置)。batch 中途无法发起结构查询,且 `--index` 不显式给会静默落到默认位置 0,所以 batch translator 强制要求三者都显式。 ### `+sheet-copy` From bd1d8dde3c305d5a0050f7607d5d49e9dde478e7 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Wed, 3 Jun 2026 16:24:27 +0800 Subject: [PATCH 109/114] fix(sheets): reject non-integer numbers for batch int flags validateRawTypes treated int and float64 identically (both only required a JSON number), but mapFlagView.Int() truncates float64 via int(t), so a batch sub-op accepted 1.9 for an int flag (e.g. --index) and silently floored it to 1. Standalone cobra rejects non-integer input for int flags at parse time; enforce the same in the batch path with a math.Trunc check so batch/standalone parity holds and positional fields can't land on a floored value. --- shortcuts/sheets/batch_op_contract_test.go | 9 +++++++++ shortcuts/sheets/flag_view.go | 15 ++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/shortcuts/sheets/batch_op_contract_test.go b/shortcuts/sheets/batch_op_contract_test.go index 16f99220a..b5d552a79 100644 --- a/shortcuts/sheets/batch_op_contract_test.go +++ b/shortcuts/sheets/batch_op_contract_test.go @@ -492,6 +492,15 @@ func TestBatchOp_RejectsWrongScalarType(t *testing.T) { subInput: `{"sheet-id":"sh1","source-index":true,"index":0}`, wantContains: "--source-index must be a number", }, + { + // Standalone cobra rejects 1.5 for an int flag at parse time; + // mapFlagView.Int would silently truncate it to 1, so the batch + // path must reject it too instead of executing on a floored index. + name: "int flag given a non-integer number", + subShortcut: "+sheet-move", + subInput: `{"sheet-id":"sh1","source-index":2,"index":1.5}`, + wantContains: "--index must be an integer", + }, { name: "bool flag given a string", subShortcut: "+cells-set", diff --git a/shortcuts/sheets/flag_view.go b/shortcuts/sheets/flag_view.go index 3c799bba8..b31ed7b38 100644 --- a/shortcuts/sheets/flag_view.go +++ b/shortcuts/sheets/flag_view.go @@ -6,6 +6,8 @@ package sheets import ( "encoding/json" "fmt" + "math" + "strconv" "strings" ) @@ -273,7 +275,18 @@ func (m mapFlagView) validateRawTypes() error { continue // unknown key — leave it for the translator / schema layer } switch typ { - case "int", "float64": + case "int": + // Int(): float64 → int(t) truncates, so a non-integer number would + // be silently floored (1.9 → 1). Standalone cobra rejects it at + // 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)) + } + if math.Trunc(f) != f { + return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64)) + } + case "float64": if _, isNum := val.(float64); !isNum { return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) } From fc103382535a471af4e915fcfbd8e4ab02ca855c Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Wed, 3 Jun 2026 16:34:20 +0800 Subject: [PATCH 110/114] fix(cli): align flag-before-subcommand unknown_flag detail schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flag-before-subcommand recovery path emitted a Type: unknown_flag whose detail only carried unknown_flags + command_path, diverging from flagDidYouMean's unknown_flag detail (unknown, command_path, suggestions, valid_flags). A consumer keyed on Type then saw two shapes for one Type. Emit the same keys from both paths: add unknown (the offending flag; joined when multiple), plus empty suggestions/valid_flags — the subcommand isn't resolved at this point, so there is no meaningful flag universe to suggest from, and the group's own flags would mislead. unknown_flags is retained as the authoritative multi-flag field. Test locks the shared schema. --- cmd/root.go | 10 ++++++++++ cmd/unknown_subcommand_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/cmd/root.go b/cmd/root.go index eebbfa67c..425bded7a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -391,8 +391,18 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error { Message: fmt.Sprintf("unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()), Hint: fmt.Sprintf("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()), Detail: map[string]any{ + // Keep the same detail keys as flagDidYouMean's unknown_flag + // so a consumer keyed on Type can read a stable shape. The + // subcommand isn't resolved here, so suggestions/valid_flags + // have no meaningful universe to draw from — emit empty + // rather than the group's own (misleading) flags. unknown is + // the back-compat singular field; unknown_flags carries the + // full list when more than one flag was supplied. + "unknown": strings.Join(unknown, ", "), "unknown_flags": unknown, "command_path": cmd.CommandPath(), + "suggestions": []string{}, + "valid_flags": []string{}, }, }, } diff --git a/cmd/unknown_subcommand_test.go b/cmd/unknown_subcommand_test.go index 7f68fdaaa..e765341ea 100644 --- a/cmd/unknown_subcommand_test.go +++ b/cmd/unknown_subcommand_test.go @@ -125,6 +125,31 @@ func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) { if !strings.Contains(err.Error(), "unknown flag") { t.Errorf("error = %q, want it to mention an unknown flag", err.Error()) } + + // The detail must stay schema-compatible with flagDidYouMean's unknown_flag + // (same Type → same keys), so a consumer keyed on Type reads a stable shape. + exitErr, ok := err.(*output.ExitError) + if !ok || exitErr.Detail == nil { + t.Fatalf("expected *output.ExitError with Detail, got %T", err) + } + if exitErr.Detail.Type != "unknown_flag" { + t.Errorf("detail.Type = %q, want unknown_flag", exitErr.Detail.Type) + } + detail, ok := exitErr.Detail.Detail.(map[string]any) + if !ok { + t.Fatalf("expected detail to be map[string]any, got %T", exitErr.Detail.Detail) + } + if detail["unknown"] != "--badflag" { + t.Errorf("detail.unknown = %v, want --badflag", detail["unknown"]) + } + if got, _ := detail["unknown_flags"].([]string); len(got) != 1 || got[0] != "--badflag" { + t.Errorf("detail.unknown_flags = %v, want [--badflag]", detail["unknown_flags"]) + } + for _, key := range []string{"suggestions", "valid_flags"} { + if _, present := detail[key]; !present { + t.Errorf("detail.%s missing; must be present (empty) to match the unknown_flag schema", key) + } + } } func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) { From 3bc2308b16a1f80ea326b70e0bd7d1364f03a887 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Wed, 3 Jun 2026 18:27:46 +0800 Subject: [PATCH 111/114] perf(sheets): compile flag specs to Go to drop startup JSON parse Every lark-cli invocation (sheets or not) unmarshaled data/flag-defs.json (122KB) and data/flag-schemas.json (256KB) during package init, before main(): flag-defs via the shortcut package vars (flagsFor runs at init), flag-schemas via shortcuts.init() -> Shortcuts() -> commandsWithFlagSchema(). On a 0.5-core sandbox this cold-start cost lands on every command. Compile both specs to Go at build time instead of parsing at runtime: - flag-defs.json -> flag_defs_gen.go: flagDefs is a compiled map literal; loadFlagDefs() returns it directly (no embed, no Unmarshal). ~3.3ms/4110 allocs -> ~0.57ms/539 allocs at sheets package init. - flag-schemas.json -> flag_schemas_gen.go: only the command-name set (commandsWithSchema) is compiled in; registration and the validate fast-path gate on it without touching the 256KB blob. The blob stays embedded and is unmarshaled lazily only on --print-schema or when validating a command that has a schema. Removes the 256KB parse from init entirely. data/*.json remain the canonical source; *_gen.go are committed, derived artifacts regenerated with `go generate ./shortcuts/sheets/...` (shortcuts/sheets/internal/gen). *_gen_test.go guard source/generated drift. No behavior change: flag rendering, required/enum/default, --print-schema, and composite-flag schema validation verified unchanged; ./shortcuts/... tests pass. --- shortcuts/sheets/flag_defs.go | 34 +- shortcuts/sheets/flag_defs_gen.go | 927 ++++++++++++++++++++++ shortcuts/sheets/flag_defs_gen_test.go | 33 + shortcuts/sheets/flag_schema_validate.go | 10 + shortcuts/sheets/flag_schemas_gen.go | 35 + shortcuts/sheets/flag_schemas_gen_test.go | 23 + shortcuts/sheets/generate.go | 13 + shortcuts/sheets/internal/gen/main.go | 208 +++++ shortcuts/sheets/shortcuts.go | 7 +- 9 files changed, 1265 insertions(+), 25 deletions(-) create mode 100644 shortcuts/sheets/flag_defs_gen.go create mode 100644 shortcuts/sheets/flag_defs_gen_test.go create mode 100644 shortcuts/sheets/flag_schemas_gen.go create mode 100644 shortcuts/sheets/flag_schemas_gen_test.go create mode 100644 shortcuts/sheets/generate.go create mode 100644 shortcuts/sheets/internal/gen/main.go diff --git a/shortcuts/sheets/flag_defs.go b/shortcuts/sheets/flag_defs.go index f16f19eb8..9aa371af3 100644 --- a/shortcuts/sheets/flag_defs.go +++ b/shortcuts/sheets/flag_defs.go @@ -4,10 +4,7 @@ package sheets import ( - _ "embed" - "encoding/json" "fmt" - "sync" "github.com/larksuite/cli/shortcuts/common" ) @@ -16,16 +13,16 @@ import ( // // data/flag-defs.json is the canonical, full definition of every CLI flag // (name, type, default, desc, enum, input, hidden, required, kind), -// generated by sheet-skill-spec's sync script. We embed it and build each -// shortcut's []common.Flag from it at assembly time, so flag metadata never -// has to be hand-written in Go. +// generated by sheet-skill-spec's sync script. The sync script also emits +// flag_defs_gen.go — the compiled `flagDefs` map — so command startup pays +// no JSON unmarshal (the parse cost used to land on every CLI invocation, +// sheets or not). We build each shortcut's []common.Flag from flagDefs at +// assembly time, so flag metadata never has to be hand-written in Go. // // Flags with kind == "system" (--dry-run, --yes, ...) are NOT materialized // here: the framework auto-injects them based on Risk / DryRun / HasFormat. -// Do not hand-edit data/flag-defs.json; regenerate via the sync script. - -//go:embed data/flag-defs.json -var flagDefsJSON []byte +// Do not hand-edit flag_defs_gen.go or data/flag-defs.json; regenerate via +// the sync script. flag_defs_gen_test.go guards the two against drift. type flagDef struct { Name string `json:"name"` @@ -44,20 +41,11 @@ type commandDef struct { Flags []flagDef `json:"flags"` } -var ( - flagDefsOnce sync.Once - flagDefs map[string]commandDef - flagDefsErr error -) - +// loadFlagDefs returns the compiled flag definitions (flag_defs_gen.go). +// The error return is always nil; it is retained so existing call sites that +// handled a parse error keep compiling. There is no longer a runtime parse. func loadFlagDefs() (map[string]commandDef, error) { - flagDefsOnce.Do(func() { - flagDefs = make(map[string]commandDef) - if err := json.Unmarshal(flagDefsJSON, &flagDefs); err != nil { - flagDefsErr = fmt.Errorf("flag-defs.json: %w", err) - } - }) - return flagDefs, flagDefsErr + return flagDefs, nil } // flagsFor builds the []common.Flag for a shortcut command directly from diff --git a/shortcuts/sheets/flag_defs_gen.go b/shortcuts/sheets/flag_defs_gen.go new file mode 100644 index 000000000..686ffdf2e --- /dev/null +++ b/shortcuts/sheets/flag_defs_gen.go @@ -0,0 +1,927 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Code generated from data/flag-defs.json; DO NOT EDIT. + +package sheets + +// flagDefs is the compiled form of data/flag-defs.json — every CLI flag's +// metadata for every shortcut, emitted as a Go literal so command startup +// pays no JSON unmarshal (see flag_defs.go). Do not hand-edit; regenerate +// with `go generate ./shortcuts/sheets/...` after data/flag-defs.json +// changes. +var flagDefs = map[string]commandDef{ + "+batch-update": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"}, + {Name: "operations", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets --help; for composite JSON flags use --print-schema --flag-name . Do not pass an explicit operation field. Strict transaction by default, pass --continue-on-error for soft batch; no nesting; executed serially.", Input: []string{"file", "stdin"}}, + {Name: "continue-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "Continue with remaining operations when a sub-operation fails; default false (abort on first failure)"}, + {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template for each sub-operation; no network side effects"}, + }, + }, + "+cells-batch-clear": { + Risk: "high-risk-write", + 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: "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"}, + }, + }, + "+cells-batch-set-style": { + Risk: "write", + 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: "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-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"}}, + {Name: "font-line", Kind: "own", Type: "string", Required: "optional", Desc: "Font line style", Enum: []string{"none", "underline", "line-through"}}, + {Name: "horizontal-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Horizontal alignment", Enum: []string{"left", "center", "right"}}, + {Name: "vertical-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Vertical alignment", Enum: []string{"top", "middle", "bottom"}}, + {Name: "word-wrap", Kind: "own", Type: "string", Required: "optional", Desc: "Word-wrap strategy", Enum: []string{"overflow", "auto-wrap", "word-clip"}}, + {Name: "number-format", Kind: "own", Type: "string", Required: "optional", Desc: "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)"}, + {Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON (same shape as in +cells-set-style)", Input: []string{"file", "stdin"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cells-clear": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to clear (A1 notation)"}, + {Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}}, + {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); clear is irreversible"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cells-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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"}, + {Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}}, + {Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true}, + {Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cells-merge": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to merge / unmerge (A1 notation)"}, + {Name: "merge-type", Kind: "own", Type: "string", Required: "optional", Desc: "Merge direction (`+cells-merge` only)", Default: "all", Enum: []string{"all", "rows", "columns"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cells-replace": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "find", Kind: "own", Type: "string", Required: "required", Desc: "Text to find for replacement"}, + {Name: "replacement", Kind: "own", Type: "string", Required: "required", Desc: "Replacement text; pass empty string `\"\"` to delete matched content"}, + {Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Replace range (A1 notation); whole sheet when omitted"}, + {Name: "match-case", Kind: "own", Type: "bool", Required: "optional", Desc: "Case-sensitive match"}, + {Name: "match-entire-cell", Kind: "own", Type: "bool", Required: "optional", Desc: "Match the entire cell content"}, + {Name: "regex", Kind: "own", Type: "bool", Required: "optional", Desc: "Interpret `--find` as a regex pattern"}, + {Name: "include-formulas", Kind: "own", Type: "bool", Required: "optional", Desc: "Also replace within formula text"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Required preflight: outputs `would_replace_count` for user confirmation before the actual replace"}, + }, + }, + "+cells-search": { + 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", 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: "find", Kind: "own", Type: "string", Required: "required", Desc: "Text to find (interpreted as regex when `--regex` is set)"}, + {Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Search range (A1 notation); whole sheet when omitted"}, + {Name: "match-case", Kind: "own", Type: "bool", Required: "optional", Desc: "Case-sensitive match"}, + {Name: "match-entire-cell", Kind: "own", Type: "bool", Required: "optional", Desc: "Match the entire cell content"}, + {Name: "regex", Kind: "own", Type: "bool", Required: "optional", Desc: "Interpret `--find` as a regex pattern"}, + {Name: "include-formulas", Kind: "own", Type: "bool", Required: "optional", Desc: "Also search within formula text"}, + {Name: "max-matches", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 5000", Default: "5000", Hidden: true}, + {Name: "offset", Kind: "own", Type: "int", Required: "optional", Desc: "Skip the first N matches (for pagination); default 0", Default: "0"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cells-set": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Write range (A1 notation)"}, + {Name: "cells", Kind: "own", Type: "string", Required: "required", Desc: "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", Input: []string{"file", "stdin"}}, + {Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting non-empty cells (default true); set false to error if any target cell is non-empty", Default: "true"}, + {Name: "max-cells", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 50000", Default: "50000", Hidden: true}, + {Name: "copy-to-range", Kind: "own", Type: "string", Required: "optional", Desc: "Copy-to range (A1 notation): replicate what --cells wrote into --range (values/formulas/styles, per the fields actually passed) to this range; formula refs auto-shift (C2=B2 -> C3=B3). Write a one-row/one-block template then fill a whole column/area. Supports full rows '3:6', full columns 'C:E', to-col-end 'D3:D', to-row-end 'D3:3', and comma-separated multiple targets like 'C1:D2,E5:F6'."}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cells-set-image": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target cell (A1 notation; must be a single cell, e.g. `A1`; start and end must be identical)"}, + {Name: "image", Kind: "own", Type: "string", Required: "required", Desc: "Local image path (PNG / JPEG / JPG / GIF / BMP / JFIF / EXIF / TIFF / BPG / HEIC)"}, + {Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Image file name (with extension); defaults to the basename of `--image`"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cells-set-style": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "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-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"}}, + {Name: "font-line", Kind: "own", Type: "string", Required: "optional", Desc: "Font line style", Enum: []string{"none", "underline", "line-through"}}, + {Name: "horizontal-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Horizontal alignment", Enum: []string{"left", "center", "right"}}, + {Name: "vertical-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Vertical alignment", Enum: []string{"top", "middle", "bottom"}}, + {Name: "word-wrap", Kind: "own", Type: "string", Required: "optional", Desc: "Word-wrap strategy", Enum: []string{"overflow", "auto-wrap", "word-clip"}}, + {Name: "number-format", Kind: "own", Type: "string", Required: "optional", Desc: "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)"}, + {Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON: `{ top: {style,color,weight}, bottom: ..., left: ..., right: ... }`; same shape for all 4 sides", Input: []string{"file", "stdin"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cells-unmerge": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to merge / unmerge (A1 notation)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+chart-create": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"}, + }, + }, + "+chart-delete": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"}, + {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+chart-list": { + 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", 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: "chart-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter to a single chart reference_id"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+chart-update": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"}, + {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete chart config JSON (read back with `+chart-list` first, then patch)", Input: []string{"file", "stdin"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cols-resize": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default column width)", Enum: []string{"pixel", "standard"}}, + {Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise", Default: "0"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Column closed range to resize; column letters like `A:E` or `C` (single column)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cond-format-create": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Rule config JSON: `style` (required, applied on match), `attrs?` (rule-type-dependent params), `has_ref?`. `rule_type` and `ranges` are separate flags", Input: []string{"file", "stdin"}}, + {Name: "rule-type", Kind: "own", Type: "string", Required: "required", Desc: "Conditional format rule type; takes precedence over the same-named field inside `--properties`", Enum: []string{"duplicateValues", "uniqueValues", "cellIs", "containsText", "timePeriod", "containsBlanks", "notContainsBlanks", "dataBar", "colorScale", "rank", "aboveAverage", "expression", "iconSet"}}, + {Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`", Input: []string{"file", "stdin"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cond-format-delete": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "rule-id", Kind: "own", Type: "string", Required: "required", Desc: "Target rule id"}, + {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cond-format-list": { + 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", 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: "rule-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by rule id"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cond-format-update": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "rule-id", Kind: "own", Type: "string", Required: "required", Desc: "Target rule id"}, + {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Rule config JSON, same shape as `+cond-format-create --properties`; update overwrites the entire rule", Input: []string{"file", "stdin"}}, + {Name: "rule-type", Kind: "own", Type: "string", Required: "required", Desc: "Conditional format rule type; takes precedence over the same-named field inside `--properties`", Enum: []string{"duplicateValues", "uniqueValues", "cellIs", "containsText", "timePeriod", "containsBlanks", "notContainsBlanks", "dataBar", "colorScale", "rank", "aboveAverage", "expression", "iconSet"}}, + {Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`", Input: []string{"file", "stdin"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+csv-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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"}, + {Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true}, + {Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"}, + {Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"}, + {Name: "rows-json", Kind: "own", Type: "bool", Required: "optional", Desc: "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false", Default: "false"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"}, + }, + }, + "+csv-put": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "start-cell", Kind: "own", Type: "string", Required: "required", Desc: "Top-left A1 anchor (e.g. `A1`, `B5`; no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet); must be a single cell, range notation not accepted; the bottom-right is inferred from CSV row/column counts", Default: "A1"}, + {Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; plain values only (no formulas / styles / comments)", Input: []string{"file", "stdin"}}, + {Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting (default true); set false to error if any target cell is non-empty", Default: "true"}, + {Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "alias for --start-cell (parity with +csv-get / +cells-set, which locate with --range); a range like A1:H17 collapses to its top-left cell", Hidden: true}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+dim-delete": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`"}, + {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); row/column deletion is irreversible"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+dim-freeze": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "dimension", Kind: "own", Type: "string", Required: "required", Desc: "Dimension (row or column)", Enum: []string{"row", "column"}}, + {Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Freeze the first N rows/columns; pass 0 to unfreeze"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+dim-group": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Nesting level for grouping; default 1", Default: "1"}, + {Name: "group-state", Kind: "own", Type: "string", Required: "optional", Desc: "Initial group expand state", Default: "expand", Enum: []string{"expand", "fold"}}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to group; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+dim-hide": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to hide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+dim-insert": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "inherit-style", Kind: "own", Type: "string", Required: "optional", Desc: "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)", Default: "none", Enum: []string{"before", "after", "none"}}, + {Name: "position", Kind: "own", Type: "string", Required: "required", Desc: "Insert position (1-based row number like `3` or column letter like `C`); new rows/columns are inserted *before* this position"}, + {Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Number of rows/columns to insert (must be > 0)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+dim-move": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source row/column closed range to move; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"}, + {Name: "target", Kind: "own", Type: "string", Required: "required", Desc: "Destination position (the moved rows/columns are placed *before* this position); rows use 1-based row number like `12`, columns use column letter like `H`. Must match the dimension of --source-range"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+dim-ungroup": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (outermost)", Default: "1"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+dim-unhide": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to unhide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+dropdown-delete": { + Risk: "high-risk-write", + 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: "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"}, + }, + }, + "+dropdown-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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range in A1 notation, e.g. `A2:A100` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+dropdown-set": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A2:A100`)"}, + {Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}}, + {Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}}, + {Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select; default `false`"}, + {Name: "highlight", Kind: "own", Type: "bool", Required: "optional", Desc: "Pill-highlight switch. **Omitted = ON** (options cycle through a 10-color palette). Pass `--highlight=false` for a plain dropdown. Override colors via `--colors`."}, + {Name: "source-range", Kind: "own", Type: "string", Required: "xor", Desc: "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress."}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+dropdown-update": { + Risk: "write", + 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: "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"}, + {Name: "highlight", Kind: "own", Type: "bool", Required: "optional", Desc: "Pill-highlight switch. **Omitted = ON** (options cycle through a 10-color palette). Pass `--highlight=false` for a plain dropdown. Override colors via `--colors`."}, + {Name: "source-range", Kind: "own", Type: "string", Required: "xor", Desc: "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress."}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+filter-create": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Filter range (A1 notation, including header row, e.g. `A1:F1000`); do not duplicate the range field inside `--properties`"}, + {Name: "properties", Kind: "own", Type: "string", Required: "optional", Desc: "Filter rule JSON: `rules` (per-column rule array), `filtered_columns?` (active column index hint). The flag is optional overall — if provided, `rules` must be non-empty; if omitted, an empty filter is created on `--range` (no column conditions). `range` is a separate flag (do not duplicate inside this JSON)", Input: []string{"file", "stdin"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+filter-delete": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+filter-list": { + 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", 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: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+filter-update": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter rule JSON: `rules` and `filtered_columns?`; update overwrites the entire rule set (pass `rules: []` to clear). `range` is a separate flag", Input: []string{"file", "stdin"}}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+filter-view-create": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "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: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+filter-view-delete": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "view-id", Kind: "own", Type: "string", Required: "required", Desc: "Target filter-view reference_id"}, + {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+filter-view-list": { + 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", 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: "view-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by filter-view reference_id (returns the matching single view)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+filter-view-update": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "view-id", Kind: "own", Type: "string", Required: "required", Desc: "Target filter-view reference_id"}, + {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?`, `filtered_columns?`; update overwrites the entire rule set (read back with `+filter-view-list` first, then patch; pass `rules: []` to clear). `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}}, + {Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; omit to keep the current range on update"}, + {Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+float-image-create": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "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: "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"}, + {Name: "size-height", Kind: "own", Type: "int", Required: "required", Desc: "Image height in pixels"}, + {Name: "offset-row", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor row, on top of `--position-row`"}, + {Name: "offset-col", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor column, on top of `--position-col`"}, + {Name: "z-index", Kind: "own", Type: "int", Required: "optional", Desc: "Image z-index controlling stacking order"}, + {Name: "image", Kind: "own", Type: "string", Required: "xor", Desc: "Local image path; the CLI uploads it as a sheet_image and uses the returned file_token (XOR with --image-token / --image-uri)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+float-image-delete": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"}, + {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+float-image-list": { + 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", 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: "float-image-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by id; lists all float images on the sheet when omitted"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+float-image-update": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "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: "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"}, + {Name: "size-height", Kind: "own", Type: "int", Required: "required", Desc: "Image height in pixels"}, + {Name: "offset-row", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor row, on top of `--position-row`"}, + {Name: "offset-col", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor column, on top of `--position-col`"}, + {Name: "z-index", Kind: "own", Type: "int", Required: "optional", Desc: "Image z-index controlling stacking order"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+pivot-create": { + Risk: "write", + 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: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: {\"rows\":[...],\"columns\":[...],\"values\":[...],\"filters\":[...],\"show_row_grand_total\":true,\"show_col_grand_total\":true} (data source goes through --source; do not put source here)", Input: []string{"file", "stdin"}}, + {Name: "target-position", Kind: "own", Type: "string", Required: "optional", Desc: "Top-left cell within the target sub-sheet (A1 notation, e.g. `A1`); maps to the top-level `target_position`, default `A1` (not sent when the value is A1). It and `--range` both express placement but map to different wire fields — avoid passing conflicting values for both.", Default: "A1"}, + {Name: "target-sheet-id", Kind: "own", Type: "string", Required: "xor", Desc: "Reference_id of the target sub-sheet where the pivot table will be placed (mutually exclusive with `--target-sheet-name`; takes priority when both given; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended). Distinct from the data-source sheet, which lives inside --source as a 'Sheet'-prefixed A1 reference like 'Sheet1'!A1:D100."}, + {Name: "target-sheet-name", Kind: "own", Type: "string", Required: "xor", Desc: "Name of the target sub-sheet where the pivot table will be placed (mutually exclusive with `--target-sheet-id`; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended). Distinct from the data-source sheet, which lives inside --source as a 'Sheet'-prefixed A1 reference like 'Sheet1'!A1:D100."}, + {Name: "source", Kind: "own", Type: "string", Required: "required", Desc: "Pivot table source range (A1 notation; format `'SheetName'!StartCell:EndCell`, e.g. `'Sheet1'!A1:D100`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Pivot table top-left placement (single A1 value, e.g. `F1`; create only), maps to `properties.range`; placed at the top-left of the target sub-sheet (a newly created one by default) when omitted. It and `--target-position` both express placement but map to different wire fields — avoid passing conflicting values for both."}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+pivot-delete": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "pivot-table-id", Kind: "own", Type: "string", Required: "required", Desc: "Target pivot table id"}, + {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+pivot-list": { + 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", 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: "pivot-table-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by id"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+pivot-update": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "pivot-table-id", Kind: "own", Type: "string", Required: "required", Desc: "Target pivot table id"}, + {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete pivot config (read back with `+pivot-list --pivot-table-id ` first, then patch)", Input: []string{"file", "stdin"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+range-copy": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source A1 range"}, + {Name: "target-sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Destination sub-sheet id; defaults to the same sheet as the source"}, + {Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination A1 range (anchor cell is enough; size inferred from the source)"}, + {Name: "paste-type", Kind: "own", Type: "string", Required: "optional", Desc: "Paste content type (`+range-copy` only)", Default: "all", Enum: []string{"values", "formulas", "formats", "all"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+range-fill": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Fill template range (seed cells for the series)"}, + {Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination fill range (A1 notation)"}, + {Name: "series-type", Kind: "own", Type: "string", Required: "optional", Desc: "Fill series type", Default: "auto", Enum: []string{"auto", "linear", "growth", "date", "copy"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+range-move": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source A1 range"}, + {Name: "target-sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Destination sub-sheet id; defaults to the same sheet as the source"}, + {Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination A1 range (anchor cell is enough; size inferred from the source)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+range-sort": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Sort range (A1 notation; whether the header is included depends on `--has-header`)"}, + {Name: "sort-keys", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: `[{\"column\":\"\",\"ascending\":}, ...]`", Input: []string{"file", "stdin"}}, + {Name: "has-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as a header and exclude from sort; default `false`"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+rows-resize": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default row height) / `auto` (fit content)", Enum: []string{"pixel", "standard", "auto"}}, + {Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Row height in pixels (e.g. 30 / 40 / 60); required when `--type pixel`, ignored otherwise", Default: "0"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sheet-copy": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "title", Kind: "own", Type: "string", Required: "optional", Desc: "Copy title; auto-generated by the server when omitted"}, + {Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position for the copy (0-based); appended to the end when omitted", Default: "-1"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sheet-create": { + Risk: "write", + 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: "title", Kind: "own", Type: "string", Required: "required", Desc: "New sheet title"}, + {Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position; 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: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sheet-delete": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "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"}, + }, + }, + "+sheet-hide": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sheet-info": { + 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", 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: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated structure info categories to return", Enum: []string{"merges", "row_heights", "col_widths", "hidden_rows", "hidden_cols", "groups", "frozen"}}, + {Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Limit structure info to this A1 range; whole sheet when omitted"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sheet-move": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "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: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sheet-rename": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New title"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sheet-set-tab-color": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "color", Kind: "own", Type: "string", Required: "required", Desc: "Hex color like `#FF0000`; pass empty string `\"\"` to clear"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sheet-unhide": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sparkline-create": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: `{config (shared style), sparklines (array of mini-charts)}`; run `--print-schema` for the full structure", Input: []string{"file", "stdin"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sparkline-delete": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "group-id", Kind: "own", Type: "string", Required: "required", Desc: "Target group id"}, + {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sparkline-list": { + 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", 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: "group-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by group_id"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sparkline-update": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "group-id", Kind: "own", Type: "string", Required: "required", Desc: "Target group id"}, + {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: `{config, sparklines}`; read back with `+sparkline-list --group-id ` first, then patch; run `--print-schema` for the full structure", Input: []string{"file", "stdin"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+workbook-create": { + Risk: "write", + 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: "headers", Kind: "own", Type: "string", Required: "optional", Desc: "Header row as a JSON array: `[\"Col A\",\"Col B\"]`", Input: []string{"file", "stdin"}}, + {Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Initial data as a 2D JSON array: `[[\"alice\",95]]`", Input: []string{"file", "stdin"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+workbook-export": { + 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: "file-extension", Kind: "own", Type: "string", Required: "optional", Desc: "Export file format; `csv` mode requires `--sheet-id`", Default: "xlsx", Enum: []string{"xlsx", "csv"}}, + {Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Required only in csv mode: which sheet to export as CSV. This is a `+workbook-export`-specific flag, unrelated to the common four-tuple sheet locator (this shortcut does not accept the common sheet locator)"}, + {Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Local save path; export is triggered but not downloaded when omitted"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+workbook-info": { + 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"}, + }, + }, +} diff --git a/shortcuts/sheets/flag_defs_gen_test.go b/shortcuts/sheets/flag_defs_gen_test.go new file mode 100644 index 000000000..6f253f7fa --- /dev/null +++ b/shortcuts/sheets/flag_defs_gen_test.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + _ "embed" + "encoding/json" + "reflect" + "testing" +) + +// flagDefsJSONForTest embeds the source data only in tests; production code +// reads the compiled flagDefs map (flag_defs_gen.go) and never unmarshals. +// +//go:embed data/flag-defs.json +var flagDefsJSONForTest []byte + +// TestFlagDefsGen_MatchesJSON guards against drift between the compiled +// flagDefs map (flag_defs_gen.go) and its source data/flag-defs.json: if the +// JSON is regenerated without re-running the codegen (or vice versa), this +// fails. This equivalence is exactly what lets production code skip the +// runtime unmarshal. +func TestFlagDefsGen_MatchesJSON(t *testing.T) { + t.Parallel() + var fromJSON map[string]commandDef + if err := json.Unmarshal(flagDefsJSONForTest, &fromJSON); err != nil { + t.Fatalf("unmarshal flag-defs.json: %v", err) + } + if !reflect.DeepEqual(fromJSON, flagDefs) { + t.Error("compiled flagDefs differs from data/flag-defs.json; regenerate flag_defs_gen.go") + } +} diff --git a/shortcuts/sheets/flag_schema_validate.go b/shortcuts/sheets/flag_schema_validate.go index 06d777c83..fb3299f2c 100644 --- a/shortcuts/sheets/flag_schema_validate.go +++ b/shortcuts/sheets/flag_schema_validate.go @@ -75,6 +75,11 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err if command == "" { return nil } + // Fast path: commands without a registered schema can't fail this check, + // so skip the 256KB flag-schemas.json parse entirely for them. + if _, ok := commandsWithSchema[command]; !ok { + return nil + } idx, _ := loadFlagSchemas() if idx == nil { return nil @@ -111,6 +116,11 @@ func validateInputAgainstSchema(fv flagView, input map[string]interface{}) error if command == "" { return nil } + // Fast path: commands without a registered schema have nothing to + // validate, so skip the 256KB flag-schemas.json parse entirely. + if _, ok := commandsWithSchema[command]; !ok { + return nil + } idx, _ := loadFlagSchemas() if idx == nil { return nil diff --git a/shortcuts/sheets/flag_schemas_gen.go b/shortcuts/sheets/flag_schemas_gen.go new file mode 100644 index 000000000..6dedf8e94 --- /dev/null +++ b/shortcuts/sheets/flag_schemas_gen.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Code generated from data/flag-schemas.json; DO NOT EDIT. + +package sheets + +// commandsWithSchema is the set of shortcut commands that have at least one +// introspectable composite flag in data/flag-schemas.json. Codegen'd so the +// registration loop (shortcuts.go) and the validate fast-path can gate on it +// without parsing the 256KB schema blob at startup (that parse used to run on +// every CLI invocation, sheets or not). The 256KB is now only unmarshaled +// on --print-schema or when validating a command that is in this set. Do not +// hand-edit; regenerate with `go generate ./shortcuts/sheets/...`. +var commandsWithSchema = map[string]struct{}{ + "+batch-update": {}, + "+cells-batch-set-style": {}, + "+cells-set": {}, + "+cells-set-style": {}, + "+chart-create": {}, + "+chart-update": {}, + "+cond-format-create": {}, + "+cond-format-update": {}, + "+dropdown-set": {}, + "+dropdown-update": {}, + "+filter-create": {}, + "+filter-update": {}, + "+filter-view-create": {}, + "+filter-view-update": {}, + "+pivot-create": {}, + "+pivot-update": {}, + "+range-sort": {}, + "+sparkline-create": {}, + "+sparkline-update": {}, +} diff --git a/shortcuts/sheets/flag_schemas_gen_test.go b/shortcuts/sheets/flag_schemas_gen_test.go new file mode 100644 index 000000000..418fd6b93 --- /dev/null +++ b/shortcuts/sheets/flag_schemas_gen_test.go @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "reflect" + "testing" +) + +// TestCommandsWithSchemaGen_MatchesJSON guards against drift between the +// codegen'd commandsWithSchema set (flag_schemas_gen.go) and the actual keys +// in data/flag-schemas.json — commandsWithFlagSchema() derives the set by +// parsing the embedded blob. This equivalence is what lets registration and +// the validate fast-path gate on the cheap set instead of parsing the 256KB +// schema at startup. +func TestCommandsWithSchemaGen_MatchesJSON(t *testing.T) { + t.Parallel() + fromJSON := commandsWithFlagSchema() + if !reflect.DeepEqual(fromJSON, commandsWithSchema) { + t.Error("commandsWithSchema differs from data/flag-schemas.json; regenerate flag_schemas_gen.go") + } +} diff --git a/shortcuts/sheets/generate.go b/shortcuts/sheets/generate.go new file mode 100644 index 000000000..636650638 --- /dev/null +++ b/shortcuts/sheets/generate.go @@ -0,0 +1,13 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +// flag_defs_gen.go and flag_schemas_gen.go are generated from the canonical +// data/*.json spec artifacts (synced from sheet-skill-spec). After the sync +// script updates data/flag-defs.json or data/flag-schemas.json, regenerate +// the compiled Go with: +// +// go generate ./shortcuts/sheets/... +// +//go:generate go run ./internal/gen diff --git a/shortcuts/sheets/internal/gen/main.go b/shortcuts/sheets/internal/gen/main.go new file mode 100644 index 000000000..4f481ca5e --- /dev/null +++ b/shortcuts/sheets/internal/gen/main.go @@ -0,0 +1,208 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Command gen regenerates flag_defs_gen.go and flag_schemas_gen.go from the +// data/*.json spec artifacts, so command startup pays no JSON unmarshal. +// +// Invoked via `go generate ./shortcuts/sheets/...` (see ../../generate.go). +// data/*.json stays the canonical source (synced from sheet-skill-spec); the +// *_gen.go files are committed, derived artifacts. CI should run go generate +// and fail on a dirty tree to keep them in lockstep. +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "go/format" + "log" + "os" + "path/filepath" + "runtime" + "sort" + "strings" +) + +type flagDef struct { + Name string `json:"name"` + Kind string `json:"kind"` + Type string `json:"type"` + Required string `json:"required"` + Desc string `json:"desc"` + Default string `json:"default"` + Hidden bool `json:"hidden"` + Enum []string `json:"enum"` + Input []string `json:"input"` +} + +type commandDef struct { + Risk string `json:"risk"` + Flags []flagDef `json:"flags"` +} + +// sheetsDir resolves shortcuts/sheets from this generator's own location, so +// the tool works regardless of the caller's working directory. +func sheetsDir() string { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + log.Fatal("gen: cannot resolve caller path") + } + // thisFile = /shortcuts/sheets/internal/gen/main.go + return filepath.Join(filepath.Dir(thisFile), "..", "..") +} + +func writeFormatted(path string, b *bytes.Buffer) { + out, err := format.Source(b.Bytes()) + if err != nil { + log.Fatalf("gen: format %s: %v", filepath.Base(path), err) + } + if err := os.WriteFile(path, out, 0o644); err != nil { + log.Fatal(err) + } + fmt.Printf("wrote %s (%d bytes)\n", filepath.Base(path), len(out)) +} + +func main() { + dir := sheetsDir() + genFlagDefs(dir) + genFlagSchemas(dir) +} + +const flagDefsHeader = `// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Code generated from data/flag-defs.json; DO NOT EDIT. + +package sheets + +// flagDefs is the compiled form of data/flag-defs.json — every CLI flag's +// metadata for every shortcut, emitted as a Go literal so command startup +// pays no JSON unmarshal (see flag_defs.go). Do not hand-edit; regenerate +// with ` + "`go generate ./shortcuts/sheets/...`" + ` after data/flag-defs.json +// changes. +var flagDefs = map[string]commandDef{ +` + +func sliceLit(s []string) string { + parts := make([]string, len(s)) + for i, v := range s { + parts[i] = fmt.Sprintf("%q", v) + } + return "[]string{" + strings.Join(parts, ", ") + "}" +} + +func flagLit(f flagDef) string { + var p []string + if f.Name != "" { + p = append(p, fmt.Sprintf("Name: %q", f.Name)) + } + if f.Kind != "" { + p = append(p, fmt.Sprintf("Kind: %q", f.Kind)) + } + if f.Type != "" { + p = append(p, fmt.Sprintf("Type: %q", f.Type)) + } + if f.Required != "" { + p = append(p, fmt.Sprintf("Required: %q", f.Required)) + } + if f.Desc != "" { + p = append(p, fmt.Sprintf("Desc: %q", f.Desc)) + } + if f.Default != "" { + p = append(p, fmt.Sprintf("Default: %q", f.Default)) + } + if f.Hidden { + p = append(p, "Hidden: true") + } + if f.Enum != nil { + p = append(p, "Enum: "+sliceLit(f.Enum)) + } + if f.Input != nil { + p = append(p, "Input: "+sliceLit(f.Input)) + } + return "{" + strings.Join(p, ", ") + "}" +} + +func genFlagDefs(dir string) { + raw, err := os.ReadFile(filepath.Join(dir, "data", "flag-defs.json")) + if err != nil { + log.Fatal(err) + } + var defs map[string]commandDef + if err := json.Unmarshal(raw, &defs); err != nil { + log.Fatal(err) + } + + keys := make([]string, 0, len(defs)) + for k := range defs { + keys = append(keys, k) + } + sort.Strings(keys) + + var b bytes.Buffer + b.WriteString(flagDefsHeader) + for _, k := range keys { + cd := defs[k] + fmt.Fprintf(&b, "%q: {\n", k) + if cd.Risk != "" { + fmt.Fprintf(&b, "Risk: %q,\n", cd.Risk) + } + if cd.Flags != nil { + b.WriteString("Flags: []flagDef{\n") + for _, f := range cd.Flags { + b.WriteString(flagLit(f)) + b.WriteString(",\n") + } + b.WriteString("},\n") + } + b.WriteString("},\n") + } + b.WriteString("}\n") + + writeFormatted(filepath.Join(dir, "flag_defs_gen.go"), &b) +} + +const flagSchemasHeader = `// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Code generated from data/flag-schemas.json; DO NOT EDIT. + +package sheets + +// commandsWithSchema is the set of shortcut commands that have at least one +// introspectable composite flag in data/flag-schemas.json. Codegen'd so the +// registration loop (shortcuts.go) and the validate fast-path can gate on it +// without parsing the 256KB schema blob at startup (that parse used to run on +// every CLI invocation, sheets or not). The 256KB is now only unmarshaled +// on --print-schema or when validating a command that is in this set. Do not +// hand-edit; regenerate with ` + "`go generate ./shortcuts/sheets/...`" + `. +var commandsWithSchema = map[string]struct{}{ +` + +func genFlagSchemas(dir string) { + raw, err := os.ReadFile(filepath.Join(dir, "data", "flag-schemas.json")) + if err != nil { + log.Fatal(err) + } + var doc struct { + Flags map[string]json.RawMessage `json:"flags"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + log.Fatal(err) + } + + keys := make([]string, 0, len(doc.Flags)) + for k := range doc.Flags { + keys = append(keys, k) + } + sort.Strings(keys) + + var b bytes.Buffer + b.WriteString(flagSchemasHeader) + for _, k := range keys { + fmt.Fprintf(&b, "%q: {},\n", k) + } + b.WriteString("}\n") + + writeFormatted(filepath.Join(dir, "flag_schemas_gen.go"), &b) +} diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index c13b1e2f5..2f1e76ea7 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -14,9 +14,12 @@ import "github.com/larksuite/cli/shortcuts/common" // `--print-schema --flag-name ` locally. func Shortcuts() []common.Shortcut { all := shortcutList() - withSchema := commandsWithFlagSchema() + // Gate on the codegen'd command set (flag_schemas_gen.go) so registration + // — which runs on every CLI invocation — does not parse the 256KB + // flag-schemas.json. The blob is unmarshaled lazily (printFlagSchemaFor / + // the validate fast-path) only when actually needed. for i := range all { - if _, ok := withSchema[all[i].Command]; ok { + if _, ok := commandsWithSchema[all[i].Command]; ok { all[i].PrintFlagSchema = printFlagSchemaFor(all[i].Command) } } From 65cbce43d5ae2541d8046840bff73cd2de5705f6 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Wed, 3 Jun 2026 19:06:56 +0800 Subject: [PATCH 112/114] ci(sheets): exempt internal/gen generators from forbidigo The shortcuts/sheets/internal/gen code generator is a standalone `package main` run via go:generate, not shortcut runtime code, so the forbidigo bans on log.Fatal / os.ReadFile / fmt.Printf do not apply. Making it "compliant" is impossible anyway: a structured error return needs os.Exit (also banned), and the vfs alternative is blocked by depguard shortcuts-no-vfs. Exempt shortcut internal/gen paths, matching the existing _test.go and internal/vfs forbidigo exemptions. --- .golangci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.golangci.yml b/.golangci.yml index 2a16994f5..f268e2af4 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -57,6 +57,14 @@ linters: - path: internal/vfs/ linters: - forbidigo + # internal/gen build-time generators (standalone `package main` run via + # go:generate) are not shortcut runtime code — no ctx/runtime/framework — + # so the shortcut forbidigo bans don't apply. Going "compliant" is also + # impossible here: a structured error return needs os.Exit (also banned), + # and the vfs.Xxx() alternative is blocked by depguard shortcuts-no-vfs. + - path: shortcuts/.*/internal/gen/ + linters: + - forbidigo # shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP # for the client / credential layer. - path-except: shortcuts/ From 9f8dfa72ad849bae963219de58d2e4232e31af8e Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Wed, 3 Jun 2026 19:53:36 +0800 Subject: [PATCH 113/114] fix(cli): fail structured on flags before a missing subcommand A pure group invoked with flags but no subcommand (e.g. `im --format=json`, `sheets --format json`) silently fell through to help + exit 0, so an agent could mistake a malformed call for success. The unknown-subcommand guard's FParseErrWhitelist swallows the flags and leaves RunE with empty args; it now recovers the raw flag tokens and fails structured: - unknown flag(s) -> unknown_flag (unchanged) - valid flag, no subcmd -> missing_subcommand (new, exit 2) - bare group -> help, exit 0 (unchanged) Because the group RunE is hook-wrapped, returning a real error also makes plugin observers record the call as failed instead of ok (the lifecycle Err is no longer flipped to nil). Hardening from the same review: - document the cobra error-text contract unknownFlagName relies on, in both cmd/root.go and go.mod, so an i18n/reword is caught on upgrade. - guard the reserved --print-schema/--flag-name registration with a Lookup so a shortcut declaring same-named flags can't panic pflag. Tests cover the new missing_subcommand path and the reserved-flag collision. --- cmd/root.go | 69 +++++++++++++++---- cmd/unknown_subcommand_test.go | 42 +++++++++++ go.mod | 2 +- shortcuts/common/runner.go | 11 ++- .../common/runner_flag_completion_test.go | 40 +++++++++++ 5 files changed, 147 insertions(+), 17 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 425bded7a..c25ed996f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -377,12 +377,18 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) { // they have moved to the typed surface. func unknownSubcommandRunE(cmd *cobra.Command, args []string) error { if len(args) == 0 { - // A bare group (e.g. `sheets`) legitimately prints help. But an unknown - // flag placed before any subcommand (`sheets --badflag`) is whitelisted - // away by installUnknownSubcommandGuard, which also leaves args empty — - // without this check it would silently fall through to help + exit 0. - // Recover the swallowed flag tokens and fail structured so agents (and - // the flagDidYouMean contract) still see a real error. + // A truly bare group (e.g. `sheets`) legitimately prints help. But any + // flag token with no subcommand is a user error: a pure group consumes + // no flags of its own, so the flag must belong to a (missing) subcommand. + // installUnknownSubcommandGuard whitelists those flags and leaves args + // empty, so without this they would silently fall through to help + + // exit 0 — letting an agent mistake a malformed call (`im --format json`, + // `sheets --badflag`) for success. Recover the swallowed tokens from the + // raw invocation and fail structured instead. + flags := flagTokensInArgs(rawInvocationArgs) + if len(flags) == 0 { + return cmd.Help() + } if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 { return &output.ExitError{ Code: output.ExitValidation, @@ -407,7 +413,22 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error { }, } } - return cmd.Help() + // Every flag is valid for some subcommand, but no subcommand was given + // (e.g. `im --format json`). Distinct from unknown_flag: the flags are + // real, the subcommand is what's missing. + return &output.ExitError{ + Code: output.ExitValidation, + Detail: &output.ErrDetail{ + Type: "missing_subcommand", + Message: fmt.Sprintf("missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(flags, ", ")), + Hint: fmt.Sprintf("run `%s --help` to list subcommands and their flags", cmd.CommandPath()), + Detail: map[string]any{ + "command_path": cmd.CommandPath(), + "flags": flags, + "suggestions": []string{}, + }, + }, + } } unknown := args[0] available, deprecated := availableSubcommandNames(cmd) @@ -444,13 +465,14 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error { } } -// unknownFlagTokens returns the -/-- tokens in rawArgs that cmd does not define. -// installUnknownSubcommandGuard whitelists unknown flags on pure groups so a -// mistyped subcommand still reaches the suggestion path; the side effect is that -// a lone unknown flag (no subcommand) is swallowed, leaving the group to fall -// through to help. This recovers those tokens so the caller can fail structured. -func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string { - var unknown []string +// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in +// rawArgs, stopping at the "--" positional terminator. Whether a flag is +// defined is not considered (see unknownFlagTokens for that). A pure group +// with any flag token but no subcommand is a user error — a pure group +// consumes no flags of its own, so the flag must belong to a subcommand — so +// the caller fails structured instead of falling through to help. +func flagTokensInArgs(rawArgs []string) []string { + var toks []string for _, a := range rawArgs { if a == "--" { break // everything after -- is positional @@ -458,6 +480,20 @@ func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string { if len(a) < 2 || a[0] != '-' { continue } + toks = append(toks, a) + } + return toks +} + +// unknownFlagTokens returns the flag tokens in rawArgs that cmd does not define +// (on itself, inherited, or any direct subcommand). installUnknownSubcommandGuard +// whitelists unknown flags on pure groups so a mistyped subcommand still reaches +// the suggestion path; the side effect is that flags before a subcommand are +// swallowed. This recovers the genuinely-unknown ones so the caller can name +// them in a "did you mean" envelope. +func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string { + var unknown []string + for _, a := range flagTokensInArgs(rawArgs) { name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0] if name != "" && !flagDefinedInTree(cmd, name) { unknown = append(unknown, a) @@ -566,6 +602,11 @@ func flagDidYouMean(c *cobra.Command, ferr error) error { // error text ("unknown flag: --query" → "query"). Returns ok=false for anything // else (missing argument, invalid value, unknown shorthand) so the caller keeps // those structured but generic — hallucinated flags are essentially always long. +// +// CONTRACT: this matches cobra's English wording "unknown flag: --" (go.mod +// pins github.com/spf13/cobra). If cobra rewords this or gains i18n the match +// silently fails and unknown flags degrade to a generic flag_error — re-verify +// this prefix when bumping cobra. func unknownFlagName(err error) (string, bool) { const p = "unknown flag: --" msg := err.Error() diff --git a/cmd/unknown_subcommand_test.go b/cmd/unknown_subcommand_test.go index e765341ea..7153b647e 100644 --- a/cmd/unknown_subcommand_test.go +++ b/cmd/unknown_subcommand_test.go @@ -152,6 +152,48 @@ func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) { } } +func TestUnknownSubcommandRunE_ValidFlagWithoutSubcommandIsStructured(t *testing.T) { + _, drive, _ := newGroupTree() + // --query is defined on the +search subcommand, so it is a *valid* flag that + // was placed before the (omitted) subcommand. Unlike an unknown flag, this + // must still fail structured (missing_subcommand) rather than fall through to + // help + exit 0 — `drive --query x` is a malformed call, not a help request. + for _, c := range drive.Commands() { + if c.Name() == "+search" { + c.Flags().String("query", "", "") + } + } + installUnknownSubcommandGuard(drive.Root()) + + rawInvocationArgs = []string{"drive", "--query", "x"} + t.Cleanup(func() { rawInvocationArgs = nil }) + + err := drive.RunE(drive, nil) + if err == nil { + t.Fatal("expected a structured missing_subcommand error, got nil (help fallthrough)") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + if exitErr.Code != output.ExitValidation { + t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) + } + if exitErr.Detail == nil || exitErr.Detail.Type != "missing_subcommand" { + t.Fatalf("detail.Type = %v, want missing_subcommand", exitErr.Detail) + } + detail, ok := exitErr.Detail.Detail.(map[string]any) + if !ok { + t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail) + } + if flags, _ := detail["flags"].([]string); len(flags) != 1 || flags[0] != "--query" { + t.Errorf("detail.flags = %v, want [--query]", detail["flags"]) + } + if detail["command_path"] != "lark-cli drive" { + t.Errorf("detail.command_path = %v, want lark-cli drive", detail["command_path"]) + } +} + func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) { _, drive, _ := newGroupTree() installUnknownSubcommandGuard(drive.Root()) diff --git a/go.mod b/go.mod index 1ee4b73cc..8c76bd7d7 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/sergi/go-diff v1.4.0 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/smartystreets/goconvey v1.8.1 - github.com/spf13/cobra v1.10.2 + github.com/spf13/cobra v1.10.2 // flag-error-text contract: see cmd/root.go unknownFlagName github.com/spf13/pflag v1.0.9 github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 2906830e4..aa507f482 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -1117,8 +1117,15 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f cmd.Flags().Bool("yes", false, "confirm high-risk operation") } if s.PrintFlagSchema != nil { - cmd.Flags().Bool("print-schema", false, "print JSON Schema for a composite flag instead of executing") - cmd.Flags().String("flag-name", "", "flag whose schema to print (omit to list introspectable flags); used with --print-schema") + // Guard against a shortcut that already declares these reserved + // introspection flags: pflag panics on a duplicate registration. + // Mirrors the Lookup guard on --format above. + if cmd.Flags().Lookup("print-schema") == nil { + cmd.Flags().Bool("print-schema", false, "print JSON Schema for a composite flag instead of executing") + } + if cmd.Flags().Lookup("flag-name") == nil { + cmd.Flags().String("flag-name", "", "flag whose schema to print (omit to list introspectable flags); used with --print-schema") + } } cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output") cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes) diff --git a/shortcuts/common/runner_flag_completion_test.go b/shortcuts/common/runner_flag_completion_test.go index 3c96dbd16..b984397d3 100644 --- a/shortcuts/common/runner_flag_completion_test.go +++ b/shortcuts/common/runner_flag_completion_test.go @@ -96,3 +96,43 @@ func TestShortcutMount_FlagCompletionsDisabled(t *testing.T) { t.Fatal("did not expect completion func for --format when disabled") } } + +// TestShortcutMount_ReservedIntrospectionFlagCollision verifies the reserved +// --print-schema / --flag-name flags are registered defensively: a shortcut +// that already declares same-named flags must not trigger pflag's duplicate- +// registration panic (the Lookup guard in registerShortcutFlagsWithContext). +func TestShortcutMount_ReservedIntrospectionFlagCollision(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + parent := &cobra.Command{Use: "root"} + shortcut := Shortcut{ + Service: "docs", + Command: "+introspect", + Description: "x", + // The shortcut's own flags collide with the names the runner auto- + // injects when PrintFlagSchema is set. Without the guard, pflag panics. + Flags: []Flag{ + {Name: "print-schema", Desc: "user-defined collision"}, + {Name: "flag-name", Desc: "user-defined collision"}, + }, + PrintFlagSchema: func(string) ([]byte, error) { return nil, nil }, + Execute: func(context.Context, *RuntimeContext) error { return nil }, + } + + defer func() { + if r := recover(); r != nil { + t.Fatalf("Mount panicked on a reserved-flag name collision (Lookup guard missing?): %v", r) + } + }() + shortcut.Mount(parent, f) + + cmd, _, err := parent.Find([]string{"+introspect"}) + if err != nil { + t.Fatalf("Find() error = %v", err) + } + if cmd.Flags().Lookup("print-schema") == nil { + t.Error("print-schema flag should still exist after the guarded registration") + } + if cmd.Flags().Lookup("flag-name") == nil { + t.Error("flag-name flag should still exist after the guarded registration") + } +} From f20aad62886597c0867f1492162e469bb078bd82 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Wed, 3 Jun 2026 20:06:52 +0800 Subject: [PATCH 114/114] fix(cli): don't flag group-valid globals as a missing subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9f8dfa72 made a pure group invoked with flags but no subcommand fail with missing_subcommand, keying on "any flag defined in the tree". That also matches inherited global flags (--profile, ...), so `lark-cli --profile p im` and `lark-cli im --profile p` errored with a misleading "flag --profile belongs to a subcommand" instead of printing the group's help — a regression, since a bare group carrying a global flag should print help. Only treat a flag as missing_subcommand when it is valid on a subcommand but not on the group itself or inherited (subcommandOnlyFlagTokens). A bare group carrying only group-valid/global flags falls through to help; flags that genuinely belong to an omitted subcommand (`im --format json`) still fail structured, and unknown flags (`im --badflag`) still report unknown_flag. Test covers a global flag on a bare group resolving to help. --- cmd/root.go | 60 ++++++++++++++++++++++++++++------ cmd/unknown_subcommand_test.go | 22 +++++++++++++ 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index c25ed996f..cb65913d9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -377,12 +377,12 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) { // they have moved to the typed surface. func unknownSubcommandRunE(cmd *cobra.Command, args []string) error { if len(args) == 0 { - // A truly bare group (e.g. `sheets`) legitimately prints help. But any - // flag token with no subcommand is a user error: a pure group consumes - // no flags of its own, so the flag must belong to a (missing) subcommand. - // installUnknownSubcommandGuard whitelists those flags and leaves args - // empty, so without this they would silently fall through to help + - // exit 0 — letting an agent mistake a malformed call (`im --format json`, + // A bare group (e.g. `sheets`), or one carrying only group-valid flags + // like the global --profile, legitimately prints help. But a flag that + // belongs to a (missing) subcommand is a user error: the guard's + // FParseErrWhitelist swallows such flags and leaves args empty, so without + // the checks below they would silently fall through to help + exit 0 — + // letting an agent mistake a malformed call (`im --format json`, // `sheets --badflag`) for success. Recover the swallowed tokens from the // raw invocation and fail structured instead. flags := flagTokensInArgs(rawInvocationArgs) @@ -413,18 +413,25 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error { }, } } - // Every flag is valid for some subcommand, but no subcommand was given - // (e.g. `im --format json`). Distinct from unknown_flag: the flags are + // The remaining flags are all defined somewhere in the tree. Those valid + // on the group itself or inherited (e.g. the global --profile) do not + // require a subcommand, so a bare group carrying only those still prints + // help. Anything left belongs to a subcommand that was omitted + // (e.g. `im --format json`): distinct from unknown_flag — the flags are // real, the subcommand is what's missing. + misplaced := subcommandOnlyFlagTokens(cmd, rawInvocationArgs) + if len(misplaced) == 0 { + return cmd.Help() + } return &output.ExitError{ Code: output.ExitValidation, Detail: &output.ErrDetail{ Type: "missing_subcommand", - Message: fmt.Sprintf("missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(flags, ", ")), + Message: fmt.Sprintf("missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")), Hint: fmt.Sprintf("run `%s --help` to list subcommands and their flags", cmd.CommandPath()), Detail: map[string]any{ "command_path": cmd.CommandPath(), - "flags": flags, + "flags": misplaced, "suggestions": []string{}, }, }, @@ -502,6 +509,39 @@ func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string { return unknown } +// flagKnownOnGroup reports whether name is a flag defined on cmd itself or +// inherited (a global persistent flag like --profile) — i.e. valid on the bare +// group and therefore not requiring a subcommand. +func flagKnownOnGroup(cmd *cobra.Command, name string) bool { + short := len(name) == 1 + lookup := func(fs *pflag.FlagSet) bool { + if short { + return fs.ShorthandLookup(name) != nil + } + return fs.Lookup(name) != nil + } + return lookup(cmd.Flags()) || lookup(cmd.InheritedFlags()) +} + +// subcommandOnlyFlagTokens returns the flag tokens in rawArgs that are valid on +// a subcommand of cmd but not on cmd itself/inherited — flags supplied while +// omitting the subcommand they belong to (`im --format json`). Global flags +// valid on the bare group (e.g. --profile) are excluded so +// `lark-cli --profile p im` still prints help rather than erroring. +func subcommandOnlyFlagTokens(cmd *cobra.Command, rawArgs []string) []string { + var misplaced []string + for _, a := range flagTokensInArgs(rawArgs) { + name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0] + if name == "" || flagKnownOnGroup(cmd, name) { + continue + } + if flagDefinedInTree(cmd, name) { + misplaced = append(misplaced, a) + } + } + return misplaced +} + // flagDefinedInTree reports whether name is defined on cmd, its inherited // (persistent) flags, or any direct subcommand. The subcommand case covers a // user who merely omitted the subcommand — e.g. `sheets --format json`, where diff --git a/cmd/unknown_subcommand_test.go b/cmd/unknown_subcommand_test.go index 7153b647e..6bff7e7e4 100644 --- a/cmd/unknown_subcommand_test.go +++ b/cmd/unknown_subcommand_test.go @@ -194,6 +194,28 @@ func TestUnknownSubcommandRunE_ValidFlagWithoutSubcommandIsStructured(t *testing } } +// A bare group carrying only a group-valid global flag (e.g. the inherited +// --profile) is not missing a subcommand — those flags do not belong to a +// subcommand — so it must print help, not fail with missing_subcommand. +func TestUnknownSubcommandRunE_GroupValidGlobalFlagShowsHelp(t *testing.T) { + _, drive, _ := newGroupTree() + drive.Root().PersistentFlags().String("profile", "", "") // global, inherited by drive + installUnknownSubcommandGuard(drive.Root()) + + rawInvocationArgs = []string{"--profile", "p", "drive"} + t.Cleanup(func() { rawInvocationArgs = nil }) + + var buf bytes.Buffer + drive.SetOut(&buf) + drive.SetErr(&buf) + if err := drive.RunE(drive, nil); err != nil { + t.Fatalf("bare group with only a global flag should print help, got error: %v", err) + } + if !strings.Contains(buf.String(), "drive ops") { + t.Errorf("expected help output, got:\n%s", buf.String()) + } +} + func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) { _, drive, _ := newGroupTree() installUnknownSubcommandGuard(drive.Root())