mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
be2a96f490
Aggregate the sheets work from feat/lark-sheets-develop: - Improve validation errors with schema hints, aggregated issues, enum guidance, and prescriptive flag/style-field messages. - Harden +batch-update input contracts, key normalization, style vocabulary handling, and resource-budget checks. - Add read offload and truncation handling for cells, csv, and table-get, with typed output-path errors and safer jq/output-path semantics. - Correct freeze semantics by emitting full-state freeze/unfreeze operations and adding --rows/--cols for +dim-freeze. - Improve +styles-put and shared --styles parsing for styles, merges, row/column sizing, freeze, and sheet-prefixed range validation. - Fix dim-insert inherit-style mapping, table-get date/time handling, table-put style anchors, and CSV path-shaped input guards. - Update lark-sheets skill docs, scripts, tests, and generated flag data. Tested with: - go test ./shortcuts/common ./shortcuts/sheets/... - go test ./shortcuts/... ./internal/... - python3 -m py_compile skills/lark-sheets/scripts/*.py
309 lines
11 KiB
Go
309 lines
11 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
|
|
}
|
|
return emitReadResult(runtime, out)
|
|
},
|
|
}
|
|
|
|
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, ok := maxCharsInput(runtime); ok {
|
|
input["max_chars"] = n
|
|
}
|
|
return input
|
|
}
|
|
|
|
// applyIncludeToCellsGet maps the fine-grained --include vocabulary to the
|
|
// tool's switches:
|
|
//
|
|
// - include_styles (bool) — toggled by "style" presence
|
|
// - value_render_option (enum) — "formula" → formula; otherwise omitted
|
|
// - include_truncation_info (bool) — toggled by "truncation" presence; makes
|
|
// the tool estimate and return per-cell isRowTruncated / isColTruncated
|
|
//
|
|
// "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"
|
|
}
|
|
if want["truncation"] {
|
|
input["include_truncation_info"] = true
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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)
|
|
}
|
|
return emitReadResult(runtime, out)
|
|
},
|
|
}
|
|
|
|
// csvGetFullSheetRange is the range sent when --range is omitted: the tool
|
|
// requires one, but clips anything past the grid bounds and reports the clip
|
|
// in actual_range — so an over-wide whole-columns range reads the entire
|
|
// sheet in one call, with no workbook-info pre-flight. Eval traces show
|
|
// "read the whole sheet" as a recurring intent (--range was the single most
|
|
// missed required flag once the rest of the surface was fixed).
|
|
const csvGetFullSheetRange = "A:ZZZ"
|
|
|
|
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
|
|
} else {
|
|
input["range"] = csvGetFullSheetRange
|
|
}
|
|
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, ok := maxCharsInput(runtime); ok {
|
|
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
|
|
}
|