Files
larksuite__cli/shortcuts/sheets/lark_sheet_sheet_structure.go
xiongyuanwen-byted be2a96f490 feat(sheets): harden error prescriptions, batch updates, and read workflows
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
2026-08-07 11:21:12 +08:00

1012 lines
40 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_sheet_structure ───────────────────────────────────────
//
// Wraps get_sheet_structure (read) and modify_sheet_structure (write,
// operation-enum dispatch). All region/position arguments use A1-style
// strings (1-based row numbers like "3:7" / "5", or column letters like
// "C:F" / "C"); dim-* / resize never expose 0-based int indices on the CLI
// surface, so there is no inclusive/exclusive ambiguity across commands.
// parseA1Range / parseA1Position handle parsing into the 0-based ints that
// dim-move's native v3 endpoint expects.
//
// +rows-resize / +cols-resize live in lark_sheet_range_operations (different
// tool); they are only grouped under "工作表" for discoverability.
// SheetInfo wraps get_sheet_structure: row heights, column widths, hidden
// rows/cols, merged cells, row/column groups, and freeze counts for one
// sub-sheet (optionally limited to a range).
var SheetInfo = common.Shortcut{
Service: "sheets",
Command: "+sheet-info",
Description: "Get a sub-sheet's layout metadata: row heights, column widths, hidden rows/cols, merges, groups, freeze.",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+sheet-info"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
_, _, err := resolveSheetSelector(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
return invokeToolDryRun(token, ToolKindRead, "get_sheet_structure", sheetInfoInput(runtime, token, sheetID, sheetName))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := 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_sheet_structure", sheetInfoInput(runtime, token, sheetID, sheetName))
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
Tips: []string{
"Frozen rows / columns are top-level fields and are returned regardless of --include.",
},
}
func sheetInfoInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} {
input := map[string]interface{}{"excel_id": token}
sheetSelectorForToolInput(input, sheetID, sheetName)
if r := strings.TrimSpace(runtime.Str("range")); r != "" {
input["range"] = r
}
if include := runtime.StrSlice("include"); len(include) > 0 {
if t := infoTypeFromInclude(include); t != "" {
input["info_type"] = t
}
}
return input
}
// infoTypeFromInclude maps the fine-grained --include vocabulary to the
// tool's coarse info_type enum. When --include spans multiple categories
// (or asks for "frozen", which is always returned), we fall back to "all".
func infoTypeFromInclude(include []string) string {
groups := map[string]string{
"row_heights": "row_heights_column_widths",
"col_widths": "row_heights_column_widths",
"hidden_rows": "hidden_infos",
"hidden_cols": "hidden_infos",
"groups": "group_infos",
"merges": "merged_cells_infos",
"frozen": "", // any info_type returns frozen; falling back to all is fine
}
seen := map[string]struct{}{}
for _, v := range include {
g, ok := groups[v]
if !ok || g == "" {
return "all"
}
seen[g] = struct{}{}
}
if len(seen) != 1 {
return "all"
}
for g := range seen {
return g
}
return "all"
}
// ─── +dim-* (modify_sheet_structure) ──────────────────────────────────
// DimInsert inserts blank rows / columns and optionally inherits style from
// the adjacent dimension.
var DimInsert = common.Shortcut{
Service: "sheets",
Command: "+dim-insert",
Description: "Insert blank rows or columns at a given position.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+dim-insert"),
Tips: []string{
"Example: lark-cli sheets +dim-insert --url <URL> --sheet-name Sheet1 --position 3 --count 2 --inherit-style before",
"Rows vs columns comes from --position alone: a row number (3) inserts rows, a column letter (C) inserts columns — there is no --dimension flag.",
},
Validate: validateViaInput(dimInsertInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := dimInsertInput(runtime, token, sheetID, sheetName)
dr := invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
switch {
case dimInsertNeedsBeforeStyleWarning(runtime):
dr.Set("warning_message", dimInsertBeforeStyleWarning)
case dimInsertAnchorShifted(runtime, input):
// --inherit-style before anchors one unit earlier (see
// dimInsertInput), so the previewed body carries a position the
// caller never typed. Unexplained, that reads as an off-by-one bug in
// exactly the artifact people dry-run to check for off-by-one bugs.
dr.Set("warning_message", fmt.Sprintf(
"note: the previewed position is %q, not the %q you passed — this is not an off-by-one. --inherit-style before is emulated by anchoring one row/column earlier and inserting after it, which lands in the same place while copying the PRECEDING style. The row/column still appears at %q.",
input["position"], strings.TrimSpace(runtime.Str("position")), strings.TrimSpace(runtime.Str("position"))))
}
return dr
},
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
}
input, err := dimInsertInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
if dimInsertNeedsBeforeStyleWarning(runtime) {
fmt.Fprintln(runtime.IO().ErrOut, dimInsertBeforeStyleWarning)
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
// dimInsertBeforeStyleWarning fires only when the preceding-side style cannot
// be copied: --inherit-style before at the first row/column, where no
// preceding row/column exists. The row/column is still inserted before
// --position, just without style inheritance. (--inherit-style after has no
// such edge — a plain before-insert always has a following row/column.)
const dimInsertBeforeStyleWarning = "warning: --inherit-style before cannot copy the preceding row/column's style at the first row/column (no preceding row/column exists); inserting before --position without style inheritance. Copy styles separately if needed."
// dimInsertAnchorShifted reports whether the built body carries an anchor
// position different from the one the caller passed — true exactly when the
// --inherit-style before emulation moved it back one unit. Compared against the
// built input rather than recomputed, so the note can never claim a shift the
// request does not have.
func dimInsertAnchorShifted(runtime flagView, input map[string]interface{}) bool {
built, ok := input["position"].(string)
return ok && built != strings.TrimSpace(runtime.Str("position"))
}
func dimInsertNeedsBeforeStyleWarning(runtime flagView) bool {
return false
}
// dimInsertInput passes --position (1-based row number "3" or column letter
// "C") to the tool's `position` field; --count maps to `count`.
//
// +dim-insert's public contract is always "insert before --position";
// --inherit-style only selects which side's style the new row/column copies,
// never the insertion side. The sheet-ai tool always copies the *anchor*
// column's style (the target passed as position), regardless of side — so
// --inherit-style before is emulated by anchoring one unit earlier. See the
// switch below.
func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
if !runtime.Changed("position") {
return nil, sheetsValidationForFlag("position", "--position is required")
}
if !runtime.Changed("count") {
return nil, sheetsValidationForFlag("count", "--count is required")
}
position := strings.TrimSpace(runtime.Str("position"))
if _, _, err := parseA1Position(position); err != nil {
return nil, sheetsValidationForFlag("position", "invalid --position %q: %v", position, err)
}
count := runtime.Int("count")
if count <= 0 {
return nil, sheetsValidationForFlag("count", "--count must be > 0 (got %d)", count)
}
input := map[string]interface{}{
"excel_id": token,
"operation": "insert",
"position": position,
"count": count,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
// --inherit-style selects which side's style the blank row/column copies;
// the insertion always lands *before* --position. Empirically the addCol
// backend copies the *anchor* column's style (the target passed as
// position), regardless of side — side only decides whether the blank lands
// before or after that anchor (verified live, see
// TestDimInsertInheritStyleSideMapping):
// after → side=before at P: the blank lands at P and anchor P becomes the
// *following* neighbour, so the blank copies it. Position unchanged.
// before → side=after at P-1: the blank still lands at P (insert-after-(P-1)
// == insert-before-P) and anchor P-1 becomes the *preceding*
// neighbour, so the blank copies it.
//
// The flag documents `after` as its default, and the omitted case takes that
// branch rather than leaving `side` off the request. This is belt-and-braces,
// not a fix: the backend's own default IS `before`, verified live 07-31 on a
// 4-way sheet (omitted / after / before / no-side-at-all all place the blank
// at --position, and omitted inherits the FOLLOWING row's style exactly as
// `after` does). Sending it explicitly just stops the documented default from
// depending on an undocumented server-side one.
// Pinned by TestDimInsertOmittedMatchesAfter.
switch runtime.Str("inherit-style") {
case "before":
if prev, ok := a1PositionBefore(position); ok {
input["side"] = "after"
input["position"] = prev
}
// First row/column: no preceding row/column exists, so fall back to a
// plain before-insert (dimInsertNeedsBeforeStyleWarning surfaces this).
default: // "after", and the omitted case it is the default for.
input["side"] = "before"
}
return input, nil
}
// DimDelete deletes rows / columns — irreversible, high-risk-write.
var DimDelete = common.Shortcut{
Service: "sheets",
Command: "+dim-delete",
Description: "Delete rows or columns (irreversible).",
Risk: "high-risk-write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+dim-delete"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Changed("ranges") {
if runtime.Changed("range") {
return sheetsValidationForFlag("ranges", "--range and --ranges are mutually exclusive; put every range into --ranges")
}
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
_, err = dimDeleteRangesOps(runtime, token, sheetID, sheetName)
return err
}
return validateDimRangeOp("delete")(ctx, runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
if runtime.Changed("ranges") {
ops, _ := dimDeleteRangesOps(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "batch_update", map[string]interface{}{
"excel_id": token,
"operations": ops,
})
}
input, _ := dimRangeOpInput(runtime, token, sheetID, sheetName, "delete")
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
if runtime.Changed("ranges") {
ops, err := dimDeleteRangesOps(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", map[string]interface{}{
"excel_id": token,
"operations": ops,
})
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
}
input, err := dimRangeOpInput(runtime, token, sheetID, sheetName, "delete")
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
Tips: []string{
"Row/column deletion is irreversible. Always preview with --dry-run first.",
`Scattered ranges: --ranges '["5:5","8:8","11:13"]' deletes them in one batch request (fail-fast, no rollback) — the CLI orders positions descending, so indexes never shift under you.`,
},
}
// dimDeleteRangesOps parses --ranges into one atomic batch of
// modify_sheet_structure delete ops, ordered DESCENDING by start position:
// deleting an earlier row shifts every later index up, so ascending
// execution deletes the wrong rows — the recurring failure of hand-built
// dim-delete batches in eval traces. Same-dimension and non-overlap are
// enforced up front.
func dimDeleteRangesOps(runtime flagView, token, sheetID, sheetName string) ([]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
raw, err := requireJSONArray(runtime, "ranges")
if err != nil {
return nil, err
}
if len(raw) == 0 {
return nil, sheetsValidationForFlag("ranges", "--ranges must be a non-empty JSON array")
}
if len(raw) > maxBatchRanges {
return nil, sheetsValidationForFlag("ranges", "--ranges accepts at most %d entries; got %d", maxBatchRanges, len(raw))
}
type span struct {
raw string
start, end int
}
spans := make([]span, 0, len(raw))
dimension := ""
for i, v := range raw {
s, ok := v.(string)
if !ok {
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] must be a string", i)
}
dim, start, end, err := parseA1Range(s)
if err != nil {
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] %q: %v", i, s, err)
}
if dimension == "" {
dimension = dim
} else if dim != dimension {
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] %q is a %s range but earlier entries are %s ranges; one call deletes rows OR columns, not both", i, s, dim, dimension)
}
spans = append(spans, span{raw: strings.TrimSpace(s), start: start, end: end})
}
sort.Slice(spans, func(i, j int) bool { return spans[i].start > spans[j].start })
for i := 1; i < len(spans); i++ {
// Descending order: spans[i-1] starts at or after spans[i]. Overlap
// (or duplicate) makes the later delete hit already-shifted positions.
if spans[i].end >= spans[i-1].start {
return nil, sheetsValidationForFlag("ranges", "--ranges entries %q and %q overlap; merge them into one range", spans[i].raw, spans[i-1].raw)
}
}
ops := make([]interface{}, 0, len(spans))
for _, sp := range spans {
input := map[string]interface{}{
"excel_id": token,
"operation": "delete",
"range": sp.raw,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
ops = append(ops, map[string]interface{}{
"tool_name": "modify_sheet_structure",
"input": input,
})
}
return ops, nil
}
// validateDimRangeOp returns a Validate closure that delegates to
// dimRangeOpInput for shortcuts (delete/hide/unhide) whose builder takes an
// extra `op` argument. Token check happens here; the rest is the builder.
func validateDimRangeOp(op string) func(ctx context.Context, runtime *common.RuntimeContext) error {
return func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID := strings.TrimSpace(runtime.Str("sheet-id"))
sheetName := strings.TrimSpace(runtime.Str("sheet-name"))
_, err = dimRangeOpInput(runtime, token, sheetID, sheetName, op)
return err
}
}
// validateDimGroupOp is the group/ungroup counterpart of validateDimRangeOp.
func validateDimGroupOp(op string) func(ctx context.Context, runtime *common.RuntimeContext) error {
return func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID := strings.TrimSpace(runtime.Str("sheet-id"))
sheetName := strings.TrimSpace(runtime.Str("sheet-name"))
_, err = dimGroupInput(runtime, token, sheetID, sheetName, op)
return err
}
}
// DimHide / DimUnhide toggle visibility on a row/column range.
var DimHide = newDimRangeOpShortcut(
"+dim-hide", "Hide rows or columns within a range.", "hide", "write",
)
var DimUnhide = newDimRangeOpShortcut(
"+dim-unhide", "Unhide rows or columns within a range.", "unhide", "write",
)
// DimGroup / DimUngroup manage row/column outline groups.
var DimGroup = newDimGroupShortcut(
"+dim-group", "Group rows or columns into an outline (collapsible).", "group",
)
var DimUngroup = newDimGroupShortcut(
"+dim-ungroup", "Remove a row/column outline group.", "ungroup",
)
// DimFreeze sets the sheet's freeze state. Freeze is full-state replacement
// server-side (verified 07-31 live), so every call states the WHOLE state:
// --rows/--cols name both axes at once, while the older --dimension/--count
// pair can only name one and therefore unfreezes the other.
var DimFreeze = common.Shortcut{
Service: "sheets",
Command: "+dim-freeze",
Description: "Freeze the first N rows and/or columns; this sets the whole freeze state, so an axis you do not name ends up unfrozen.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+dim-freeze"),
Tips: []string{
"Example: lark-cli sheets +dim-freeze --url <URL> --sheet-name Sheet1 --rows 1 --cols 2 (holds the header row and the first 2 columns in one call)",
"Freezing is not additive: --dimension row --count 1 followed by --dimension column --count 2 leaves ONLY the columns frozen. Pass --rows/--cols together instead of calling twice",
"To unfreeze one axis but keep the other, state the survivor: --rows 0 --cols 2. Bare --count 0 clears both",
},
Validate: validateViaInput(dimFreezeInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := dimFreezeInput(runtime, token, sheetID, sheetName)
dr := invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
// Surface the deprecation steer during the preview too: agents dry-run
// before executing, so a note only on the execute path arrives after the
// spelling is already committed to.
if note := dimFreezeLegacyNote(runtime); note != "" {
dr.Set("warning_message", note)
}
return dr
},
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
}
input, err := dimFreezeInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
if note := dimFreezeLegacyNote(runtime); note != "" {
fmt.Fprintln(runtime.IO().ErrOut, note)
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
// DEPRECATED(phase-2): +dim-freeze --dimension / --count — replaced by
// --rows / --cols. Phase 1: the flags keep working, are retired from the skill
// docs via bundle.json doc_hidden_flags in sheet-skill-spec and from --help via
// their hidden mark, and every use is steered by dimFreezeLegacyNote.
// Phase 2 removal: drop both rows from spec-tables/flags.json + their
// doc_hidden_flags entry, then dimFreezeLegacyNote, dimFreezeEquivalent, their
// call sites (this shortcut's DryRun/Execute and batchLegacyDimFreezeNotes) and
// the legacy branch in dimFreezeInput.
//
// The pair is a strict subset of --rows/--cols — every --dimension/--count call
// has a byte-identical --rows/--cols spelling (TestDimFreezeEquivalent pins
// this) — and it is the form that reads as if it scoped to one axis when the
// backend replaces the whole freeze state.
//
// dimFreezeLegacyNote returns "" for the modern form. It takes a flagView
// rather than a RuntimeContext so +batch-update can render the identical
// wording for a sub-op (see batchLegacyDimFreezeNotes).
func dimFreezeLegacyNote(runtime flagView) string {
if !runtime.Changed("dimension") && !runtime.Changed("count") {
return ""
}
return fmt.Sprintf(
"note: --dimension/--count is superseded by --rows/--cols, which state both axes at once; this call is equivalent to %s",
dimFreezeEquivalent(runtime))
}
// dimFreezeEquivalent renders the --rows/--cols spelling of a legacy
// --dimension/--count call, so the deprecation note carries the exact
// replacement instead of a generic pointer.
func dimFreezeEquivalent(runtime flagView) string {
rows, cols, _ := dimFreezeAxes(runtime)
return dimFreezeSpelling(rows, cols)
}
// dimFreezeAxes maps either request form onto the (rows, cols) freeze state it
// asks for. Pure mapping, no validation — dimFreezeInput validates first and
// then calls this, so the request body, the deprecation note and the batch
// collision note can never disagree about what a call means. ok is false when
// the flags name no state at all, or when the legacy pair is half-given
// (--count without --dimension); dimFreezeInput reports both.
func dimFreezeAxes(runtime flagView) (rows, cols int, ok bool) {
pairForm := runtime.Changed("dimension") || runtime.Changed("count")
axisForm := runtime.Changed("rows") || runtime.Changed("cols")
switch {
case axisForm && !pairForm:
return runtime.Int("rows"), runtime.Int("cols"), true
case pairForm && !axisForm:
if !runtime.Changed("dimension") || !runtime.Changed("count") {
return 0, 0, false
}
// A zero count clears BOTH axes — it is the bare unfreeze operation,
// which carries no dimension.
if count := runtime.Int("count"); count > 0 {
if runtime.Str("dimension") == "row" {
return count, 0, true
}
return 0, count, true
}
return 0, 0, true
}
return 0, 0, false
}
// dimFreezeSpelling renders a freeze state as the --rows/--cols flags that
// produce it. Single source of the replacement wording, shared by the
// deprecation note and the batch collision note.
func dimFreezeSpelling(rows, cols int) string {
switch {
case rows > 0 && cols > 0:
return fmt.Sprintf("--rows %d --cols %d", rows, cols)
case rows > 0:
return fmt.Sprintf("--rows %d", rows)
case cols > 0:
return fmt.Sprintf("--cols %d", cols)
}
return "--rows 0 --cols 0"
}
// dimFreezeInput builds the freeze body for both the standalone shortcut and
// the +batch-update sub-op, so the two stay byte-identical (see
// TestBatchOp_BodyMatchesStandalone).
//
// Two request forms, deliberately not mixable:
//
// - --rows / --cols state the complete target state in ONE operation. This
// is the only form that can hold both axes, because freeze is full-state
// replacement server-side (verified 07-31 live: freeze rows=1 then
// columns=2 in two calls ends at 0 rows / 2 columns — the second call
// drops the first axis). It is also the only form usable inside
// +batch-update, whose sub-ops are a static array that cannot read the
// current state to preserve an axis.
// - --dimension + --count is the original single-axis form, kept for
// compatibility. It necessarily unfreezes the axis it does not name.
func dimFreezeInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
pairForm := runtime.Changed("dimension") || runtime.Changed("count")
axisForm := runtime.Changed("rows") || runtime.Changed("cols")
switch {
case pairForm && axisForm:
return nil, sheetsValidationForFlag("rows",
"give either --rows/--cols or --dimension/--count, not both — they are two ways to say the same thing; --rows/--cols is the one that can hold both axes at once")
case !pairForm && !axisForm:
// Prescribes only --rows/--cols: --dimension/--count is retired
// (DEPRECATED(phase-2)) and steering a caller into it here would earn
// them a deprecation note on the very next call.
return nil, sheetsValidationForFlag("rows",
"nothing to freeze: pass --rows N and/or --cols N — e.g. --rows 1 holds the header row, --rows 1 --cols 2 holds it plus the first 2 columns, --rows 0 --cols 0 unfreezes everything")
}
if axisForm {
for _, name := range []string{"rows", "cols"} {
if runtime.Changed(name) && runtime.Int(name) < 0 {
return nil, sheetsValidationForFlag(name, "--%s must be >= 0 (0 leaves that axis unfrozen)", name)
}
}
} else {
if !runtime.Changed("dimension") {
return nil, sheetsValidationForFlag("dimension", "--dimension is required alongside --count (or use --rows/--cols to set both axes at once)")
}
if !runtime.Changed("count") {
return nil, sheetsValidationForFlag("count", "--count is required alongside --dimension (0 unfreezes)")
}
if runtime.Int("count") < 0 {
return nil, sheetsValidationForFlag("count", "--count must be >= 0")
}
}
// Validation done; the flags-to-state mapping is dimFreezeAxes', shared with
// the deprecation and collision notes so the three cannot disagree.
rows, cols, _ := dimFreezeAxes(runtime)
// An all-zero target is the bare "unfreeze" operation, which carries no
// dimension and clears everything — the same request the old --count 0
// always sent.
input := map[string]interface{}{"excel_id": token, "operation": "unfreeze"}
if rows > 0 || cols > 0 {
input["operation"] = "freeze"
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if rows > 0 {
input["freeze_rows"] = rows
}
if cols > 0 {
input["freeze_columns"] = cols
}
return input, nil
}
// dimRangeOpInput builds the tool input for delete/hide/unhide/group/ungroup
// which all take a `range` string field. --range is a 1-based A1 closed range
// ("3:7" / "5" for rows, "C:F" / "C" for columns) and passes straight through
// after format validation.
func dimRangeOpInput(runtime flagView, token, sheetID, sheetName, op string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
if !runtime.Changed("range") {
return nil, sheetsValidationForFlag("range", "--range is required")
}
rangeStr := strings.TrimSpace(runtime.Str("range"))
if _, _, _, err := parseA1Range(rangeStr); err != nil {
return nil, sheetsValidationForFlag("range", "invalid --range %q: %v", rangeStr, err)
}
input := map[string]interface{}{
"excel_id": token,
"operation": op,
"range": rangeStr,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
return input, nil
}
// newDimRangeOpShortcut builds the shared shape for hide / unhide.
func newDimRangeOpShortcut(command, desc, op, risk string) common.Shortcut {
return common.Shortcut{
Service: "sheets",
Command: command,
Description: desc,
Risk: risk,
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor(command),
Validate: validateDimRangeOp(op),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := dimRangeOpInput(runtime, token, sheetID, sheetName, op)
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := dimRangeOpInput(runtime, token, sheetID, sheetName, op)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
}
// newDimGroupShortcut builds the shared shape for group / ungroup. It adds
// --depth (currently unused server-side — accepted for forward-compat per
// the canonical spec) and --group-state (group only, defaults to expand).
func newDimGroupShortcut(command, desc, op string) common.Shortcut {
flags := flagsFor(command)
return common.Shortcut{
Service: "sheets",
Command: command,
Description: desc,
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flags,
Validate: validateDimGroupOp(op),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := dimGroupInput(runtime, token, sheetID, sheetName, op)
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := dimGroupInput(runtime, token, sheetID, sheetName, op)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
}
func dimGroupInput(runtime flagView, token, sheetID, sheetName, op string) (map[string]interface{}, error) {
input, err := dimRangeOpInput(runtime, token, sheetID, sheetName, op)
if err != nil {
return nil, err
}
if op == "group" {
if gs := runtime.Str("group-state"); gs != "" {
input["group_state"] = gs
}
}
return input, nil
}
// ─── A1 parsing helpers ───────────────────────────────────────────────
// parseA1Range parses an A1 closed range ("3:7" / "5" / "C:F" / "C") into
// the inferred dimension ("row" or "column") and 0-based inclusive indices.
// Single-element form yields startIdx == endIdx. Mixing digits and letters
// across the two sides ("3:C") is rejected.
func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error) {
s = strings.TrimSpace(s)
if s == "" {
return "", 0, 0, fmt.Errorf("range is empty") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
parts := strings.Split(s, ":")
if len(parts) > 2 {
return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
dim1, idx1, err := parseA1Position(parts[0])
if err != nil {
return "", 0, 0, err
}
if len(parts) == 1 {
return dim1, idx1, idx1, nil
}
dim2, idx2, err := parseA1Position(parts[1])
if err != nil {
return "", 0, 0, err
}
if dim1 != dim2 {
return "", 0, 0, fmt.Errorf("cannot mix row (digits) and column (letters) in one range") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
if idx2 < idx1 {
return "", 0, 0, fmt.Errorf("end position is before start") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
return dim1, idx1, idx2, nil
}
// parseA1Position parses a single A1 position element: pure digits → row
// (1-based number, returned as 0-based idx); pure letters → column (letters
// case-insensitive, "A" → 0, "AA" → 26).
func parseA1Position(s string) (dimension string, idx int, err error) {
s = strings.TrimSpace(s)
if s == "" {
return "", 0, fmt.Errorf("position is empty") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
isDigits := true
isLetters := true
for _, r := range s {
if r < '0' || r > '9' {
isDigits = false
}
if !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) {
isLetters = false
}
}
if isDigits {
n, _ := strconv.Atoi(s)
if n <= 0 {
return "", 0, fmt.Errorf("row number must be >= 1 (got %q)", s) //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
return "row", n - 1, nil
}
if isLetters {
return "column", letterToColumnIndex(s), nil
}
return "", 0, fmt.Errorf("expected pure digits (row number) or letters (column letter), got %q", s) //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
// columnIndexToLetter converts a 0-based column index to the spreadsheet
// letter notation (0 → "A", 25 → "Z", 26 → "AA", 701 → "ZZ", 702 → "AAA").
// Used by +workbook helpers that need to format absolute column references.
func columnIndexToLetter(idx int) string {
if idx < 0 {
return ""
}
idx++
var out []byte
for idx > 0 {
idx--
out = append([]byte{byte('A' + idx%26)}, out...)
idx /= 26
}
return string(out)
}
// a1PositionBefore returns the A1 position one unit before s ("6" → "5",
// "C" → "B"), preserving row/column form. ok is false when s is the first
// row/column (row 1 / column A) — no earlier position — or is not a valid A1
// position. Callers validate via parseA1Position first, so in practice ok is
// false only at the first row/column.
func a1PositionBefore(s string) (pos string, ok bool) {
dimension, idx, err := parseA1Position(s)
if err != nil || idx == 0 {
return "", false
}
if dimension == "row" {
// idx is 0-based; the 1-based number one row earlier is idx itself.
return strconv.Itoa(idx), true
}
return columnIndexToLetter(idx - 1), true
}
// ─── +dim-move (native v3 move_dimension, cli_status: cli-only) ──────
//
// Moves a contiguous block of rows or columns to a new index in the same
// sheet via the native v3 move_dimension endpoint (not the One-OpenAPI
// dispatcher). CLI accepts --source-range (A1 closed range like "3:7" or
// "C:F") + --target (A1 single position like "12" or "H"); both are parsed
// into the 0-based int indices that v3 move_dimension expects.
var DimMove = common.Shortcut{
Service: "sheets",
Command: "+dim-move",
Description: "Move a contiguous block of rows or columns to a new position (re-numbers neighbors).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+dim-move"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
if _, _, err := resolveSheetSelector(runtime); err != nil {
return err
}
_, err := buildDimMovePlan(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
return common.NewDryRunAPI().
POST(dimMovePath(token, sheetSelectorPlaceholder(sheetID, sheetName))).
Body(dimMoveBody(runtime)).
Set("spreadsheet_token", token)
},
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
}
// v3 move_dimension carries sheet_id in the path. Resolve
// sheet_name client-side when needed (reuses lookupSheetIndex
// which fetches workbook structure).
if sheetID == "" {
lookedID, _, err := lookupSheetIndex(ctx, runtime, token, "", sheetName)
if err != nil {
return err
}
sheetID = lookedID
}
data, err := runtime.CallAPITyped("POST", dimMovePath(token, sheetID), nil, dimMoveBody(runtime))
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// dimMovePlan is the parsed form of --source-range / --target.
type dimMovePlan struct {
dimension string // "row" / "column"
startIdx int // 0-based inclusive
endIdx int // 0-based inclusive
targetIdx int // 0-based; destination position (move inserts before this)
}
// buildDimMovePlan parses --source-range + --target and enforces that the
// target dimension matches the source. Used by both Validate and Execute.
func buildDimMovePlan(runtime flagView) (*dimMovePlan, error) {
if !runtime.Changed("source-range") || !runtime.Changed("target") {
return nil, common.ValidationErrorf("--source-range and --target are required").WithParams(sheetsInvalidParam("source-range", "required"), sheetsInvalidParam("target", "required"))
}
src := strings.TrimSpace(runtime.Str("source-range"))
dim, startIdx, endIdx, err := parseA1Range(src)
if err != nil {
return nil, sheetsValidationForFlag("source-range", "invalid --source-range %q: %v", src, err)
}
tgt := strings.TrimSpace(runtime.Str("target"))
tgtDim, tgtIdx, err := parseA1Position(tgt)
if err != nil {
return nil, sheetsValidationForFlag("target", "invalid --target %q: %v", tgt, err)
}
if tgtDim != dim {
return nil, common.ValidationErrorf("--target %q dimension (%s) must match --source-range %q dimension (%s)", tgt, tgtDim, src, dim).WithParams(sheetsInvalidParam("target", "dimension mismatch"), sheetsInvalidParam("source-range", "dimension mismatch"))
}
return &dimMovePlan{dimension: dim, startIdx: startIdx, endIdx: endIdx, targetIdx: tgtIdx}, nil
}
// dimMovePath builds the native v3 move_dimension endpoint. sheet_id lives in
// the path (unlike the v2 dimension_range body that the earlier build used).
func dimMovePath(token, sheetID string) string {
return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/move_dimension",
validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID))
}
func dimMoveBody(runtime *common.RuntimeContext) map[string]interface{} {
plan, err := buildDimMovePlan(runtime)
if err != nil {
// Validate has already rejected this case; emit an empty body
// rather than panic on the dry-run path.
return map[string]interface{}{}
}
dim := "ROWS"
if plan.dimension == "column" {
dim = "COLUMNS"
}
return map[string]interface{}{
"source": map[string]interface{}{
"major_dimension": dim,
"start_index": plan.startIdx,
"end_index": plan.endIdx,
},
"destination_index": plan.targetIdx,
}
}