mirror of
https://github.com/larksuite/cli.git
synced 2026-07-09 02:14:02 +08:00
feat(sheets): implement read_data / search_replace / write_cells shortcuts (B3)
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).
This commit is contained in:
@@ -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 "<resolve:" + sheetName + ">"
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
271
shortcuts/sheets/lark_sheet_read_data.go
Normal file
271
shortcuts/sheets/lark_sheet_read_data.go
Normal file
@@ -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",
|
||||
}
|
||||
}
|
||||
189
shortcuts/sheets/lark_sheet_search_replace.go
Normal file
189
shortcuts/sheets/lark_sheet_search_replace.go
Normal file
@@ -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
|
||||
}
|
||||
750
shortcuts/sheets/lark_sheet_write_cells.go
Normal file
750
shortcuts/sheets/lark_sheet_write_cells.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <shortcut> --print-schema --flag <name>`(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 约束。
|
||||
|
||||
@@ -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 | 必填 | 说明 |
|
||||
|
||||
@@ -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<object>?) — 富文本内容 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`
|
||||
|
||||
_单元格样式属性,包括字体、颜色、对齐方式和数字格式_
|
||||
|
||||
|
||||
Reference in New Issue
Block a user