bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
299 lines
10 KiB
Go
299 lines
10 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package sheets
|
|
|
|
import (
|
|
"context"
|
|
"strconv"
|
|
"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.
|
|
|
|
// unboundedReadLimit is pinned into the tool's cell_limit / max_rows so that
|
|
// --max-chars is the single effective read cap. The underlying tools default
|
|
// those two to smaller values; without an explicit high value they could
|
|
// truncate before max_chars. The CLI no longer exposes --cell-limit / --max-rows
|
|
// (only --max-chars), so we pass this sentinel to neutralize the tool defaults.
|
|
// Large enough to never bind on any real sheet.
|
|
const unboundedReadLimit = 1_000_000_000
|
|
|
|
// CellsGet wraps get_cell_ranges: read multiple A1 ranges and return per-cell
|
|
// values, formulas, styles, and other metadata as requested via --include.
|
|
var CellsGet = common.Shortcut{
|
|
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: flagsFor("+cells-get"),
|
|
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 sheetsValidationForFlag("range", "--range is required")
|
|
}
|
|
return nil
|
|
},
|
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
token, _ := resolveSpreadsheetToken(runtime)
|
|
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
|
return invokeToolDryRun(token, ToolKindRead, "get_cell_ranges", cellsGetInput(runtime, token, sheetID, sheetName))
|
|
},
|
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
token, err := resolveSpreadsheetTokenExec(runtime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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": []string{strings.TrimSpace(runtime.Str("range"))},
|
|
}
|
|
sheetSelectorForToolInput(input, sheetID, sheetName)
|
|
applyIncludeToCellsGet(input, runtime.StrSlice("include"))
|
|
if runtime.Bool("skip-hidden") {
|
|
input["skip_hidden"] = true
|
|
}
|
|
// --cell-limit was removed from the CLI surface; --max-chars is the single
|
|
// read cap. Pin cell_limit very high so the tool's own default never binds
|
|
// before max_chars.
|
|
input["cell_limit"] = unboundedReadLimit
|
|
if n := runtime.Int("max-chars"); n > 0 {
|
|
input["max_chars"] = n
|
|
}
|
|
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: flagsFor("+csv-get"),
|
|
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 sheetsValidationForFlag("range", "--range is required")
|
|
}
|
|
return nil
|
|
},
|
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
token, _ := resolveSpreadsheetToken(runtime)
|
|
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
|
return invokeToolDryRun(token, ToolKindRead, "get_range_as_csv", csvGetInput(runtime, token, sheetID, sheetName))
|
|
},
|
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
token, err := resolveSpreadsheetTokenExec(runtime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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 runtime.Bool("skip-hidden") {
|
|
input["skip_hidden"] = true
|
|
}
|
|
// --max-rows was removed from the CLI surface; --max-chars is the single
|
|
// read cap. Pin max_rows very high so the tool's own default never binds
|
|
// before max_chars.
|
|
input["max_rows"] = unboundedReadLimit
|
|
if n := runtime.Int("max-chars"); n > 0 {
|
|
input["max_chars"] = n
|
|
}
|
|
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
|
|
}
|
|
|
|
// a1EndRow extracts the ending row number from an A1 range, e.g. "A1:N51" → 51,
|
|
// "Sheet1!B2:D9" → 9, "C5" → 5. Returns 0 when no row number is present.
|
|
func a1EndRow(rng string) int {
|
|
rng = strings.TrimSpace(rng)
|
|
if i := strings.LastIndex(rng, "!"); i >= 0 {
|
|
rng = rng[i+1:]
|
|
}
|
|
if i := strings.LastIndex(rng, ":"); i >= 0 {
|
|
rng = rng[i+1:]
|
|
}
|
|
var digits strings.Builder
|
|
for _, c := range rng {
|
|
if c >= '0' && c <= '9' {
|
|
digits.WriteRune(c)
|
|
}
|
|
}
|
|
if digits.Len() == 0 {
|
|
return 0
|
|
}
|
|
n, _ := strconv.Atoi(digits.String())
|
|
return n
|
|
}
|
|
|
|
// DropdownGet wraps get_cell_ranges scoped to data_validation: read the
|
|
// dropdown configuration on a range. Aligned with its sibling +cells-get
|
|
// — sheet selection is via --sheet-id / --sheet-name (XOR), and --range
|
|
// is a bare A1 reference. The earlier "must include a sheet prefix"
|
|
// shape was the odd one out among the get_cell_ranges wrappers and made
|
|
// callers treat the prefix as either name or id; folding it into the
|
|
// canonical --sheet-id selector removes that ambiguity.
|
|
var DropdownGet = common.Shortcut{
|
|
Service: "sheets",
|
|
Command: "+dropdown-get",
|
|
Description: "Read the dropdown / data-validation configuration on a range.",
|
|
Risk: "read",
|
|
Scopes: []string{"sheets:spreadsheet:read"},
|
|
AuthTypes: []string{"user", "bot"},
|
|
HasFormat: true,
|
|
Flags: flagsFor("+dropdown-get"),
|
|
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 sheetsValidationForFlag("range", "--range is required")
|
|
}
|
|
return nil
|
|
},
|
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
token, _ := resolveSpreadsheetToken(runtime)
|
|
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
|
return invokeToolDryRun(token, ToolKindRead, "get_cell_ranges", dropdownGetInput(runtime, token, sheetID, sheetName))
|
|
},
|
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
token, err := resolveSpreadsheetTokenExec(runtime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_cell_ranges", dropdownGetInput(runtime, token, sheetID, sheetName))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
runtime.Out(out, nil)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
func dropdownGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} {
|
|
input := map[string]interface{}{
|
|
"excel_id": token,
|
|
"ranges": []string{strings.TrimSpace(runtime.Str("range"))},
|
|
"include_styles": false,
|
|
"value_render_option": "formatted_value",
|
|
}
|
|
sheetSelectorForToolInput(input, sheetID, sheetName)
|
|
return input
|
|
}
|