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..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", @@ -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,14 @@ "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": "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", @@ -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", @@ -2807,7 +2814,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 +2832,14 @@ "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": "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", diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json index 8135bd147..f7646942c 100644 --- a/shortcuts/sheets/data/flag-schemas.json +++ b/shortcuts/sheets/data/flag-schemas.json @@ -558,7 +558,7 @@ ] }, "items": { - "description": "列表选项", + "description": "列表选项(type='list' 时必填)", "type": "array", "items": { "type": "string" @@ -603,6 +603,17 @@ "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": [ @@ -1759,10 +1770,6 @@ } } }, - "required": [ - "position", - "size" - ], "additionalProperties": {} } }, @@ -2789,10 +2796,6 @@ } } }, - "required": [ - "position", - "size" - ], "additionalProperties": {} } }, @@ -3568,7 +3571,7 @@ }, "+dropdown-set": { "options": { - "description": "列表选项", + "description": "列表选项(type='list' 时必填)", "type": "array", "items": { "type": "string" @@ -3577,7 +3580,7 @@ }, "+dropdown-update": { "options": { - "description": "列表选项", + "description": "列表选项(type='list' 时必填)", "type": "array", "items": { "type": "string" 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_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_batch_update_test.go b/shortcuts/sheets/lark_sheet_batch_update_test.go index 26220ff2a..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{}) @@ -215,8 +217,19 @@ 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"]) + } + 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_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_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index 3693129de..0d451890f 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -331,40 +331,85 @@ 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 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 -> {type: "list", items: } +// --source-range -> {type: "listFromRange", range: } +// --multiple -> support_multiple_values (bool) +// --colors -> highlight_colors (string array, hex) +// --highlight -> enable_highlight (bool) +// +// --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", - "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)) + if len(colors) > sourceSize { + return nil, common.FlagErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize) } - dv["colors"] = colors + dv["highlight_colors"] = colors } if runtime.Bool("multiple") { - dv["multiple_values"] = true + dv["support_multiple_values"] = true } if runtime.Bool("highlight") { - dv["highlight_options"] = true + dv["enable_highlight"] = 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) { - 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 } @@ -373,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 equal --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 e9a0b289e..df350a19c 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{}{ @@ -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{}) @@ -143,9 +147,164 @@ 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) + } + 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_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 +// 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"]`, + "--colors", `["#FFE699","#bff7d9","#ffb3b3"]`, + "--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 length-overflow 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)) + } +} + +// 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) } } diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index ba3160e0b..e6ab669e4 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` 的选项模式(`--options` / `--source-range` 二选一)+ 配色规则**(`--colors` 长度可短不能长、必须配 `--highlight=true` 才生效、不传按内置 10 色色板循环补色)见 [`lark-sheets-write-cells`](./lark-sheets-write-cells.md) 的「Dropdown 选项 + 配色」节,本 skill 不重复。`+dropdown-delete` 不涉及这些 flag。 + ## Shortcuts | Shortcut | Risk | 分组 | @@ -69,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"]`) | -| `--colors` | string + File + Stdin(简单 JSON) | optional | 颜色数组(与 `--options` 等长) | +| `--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 | 选项配色 | +| `--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` @@ -115,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 6e1887031..2bb12a8de 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -120,6 +120,59 @@ 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`) + +### 选项怎么来:`--options` 与 `--source-range` 二选一 + +| flag | 选项来源 | 适用场景 | +|---|---|---| +| `--options '["a","b","c"]'` | 写在命令里的固定列表 | 选项集是常量、不需要事后维护 | +| `--source-range 'Sheet1!T1:T3'` | 已有单元格里的值 | 选项要跟数据动态同步;想维护一张「枚举值」列后多处引用 | + +两个 flag **必须传一个、且只能传一个**——同时传或都不传,CLI 会立刻报错。`--source-range` 用 A1 + sheet 前缀写法(如 `Sheet1!T1:T3`),可以指同 sheet 也可以指其它 sheet(如 `Refs!A1:A10`)。 + +### 配色:`--colors` 与 `--highlight`(与选项模式无关) + +- `--highlight`:开下拉选项的胶囊背景色总开关;不传默认 `false`、无配色。 +- `--colors '["#hex","#hex",...]'`:每个选项对应一种胶囊背景色;只在 `--highlight` 也传时生效。 + +长度规则: +- `--colors` 长度**可以短于**选项数(list 模式短于 `--options` 长度,listFromRange 模式短于 `--source-range` 的单元格数),未指定的选项按内置 10 色色板循环补色; +- `--colors` 长度**不能长于**选项数——CLI 端 Validate 阶段就会拦截,错误信息形如 `--colors length (4) must not exceed dropdown source size (3)`。 + +排列组合: +- 想要纯文本下拉、无配色 → 都不传 `--highlight` / `--colors`; +- 想要每个选项指定确切颜色 → 同时传 `--highlight` 和 `--colors`; +- 想要所有选项都有颜色但不指定具体色 → 只传 `--highlight`,颜色按内置色板循环。 + +### 最小用例 + +**`--options` 模式**(4 个选项配 3 个颜色——前 3 个指定色,第 4 个按内置色板补): + +``` +lark-cli sheets +dropdown-set \ + --url https://... --sheet-id \ + --range A2:A100 \ + --options '["待开始","进行中","已完成","已取消"]' \ + --colors '["#bff7d9","#FFE699","#bacefd"]' \ + --highlight +``` + +**`--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 +``` + +> ⚠️ **`--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。 + ## 工具选择 本 skill 提供以下 CLI shortcut,按数据来源 + 内容形态选: @@ -198,10 +251,11 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | | `--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` 一致 | +| `--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` | +| `--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` @@ -228,7 +282,7 @@ _公共四件套 · 系统:`--dry-run`_ - `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 项 } +- `data_validation` (object?) — 数据验证配置 { type: enum, items?: array, range?: string, operator?: enum, values?: array, …共 9 项 } ### `+cells-set-style` `--border-styles` @@ -242,7 +296,7 @@ _单元格边框配置,含 top/bottom/left/right 四个方向,每个方向 ### `+dropdown-set` `--options` -_列表选项_ +_列表选项(type='list' 时必填)_ **数组项**(类型 string): - 标量:string