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
680 lines
23 KiB
Go
680 lines
23 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package sheets
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"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"),
|
|
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)
|
|
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 := dimInsertInput(runtime, token, sheetID, sheetName)
|
|
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
|
|
},
|
|
}
|
|
|
|
// dimInsertInput passes --position (1-based row number "3" or column letter
|
|
// "C") straight to the tool's `position` field; --count maps to `count`.
|
|
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)
|
|
switch runtime.Str("inherit-style") {
|
|
case "before":
|
|
input["side"] = "before"
|
|
case "after":
|
|
input["side"] = "after"
|
|
}
|
|
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: validateDimRangeOp("delete"),
|
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
token, _ := resolveSpreadsheetToken(runtime)
|
|
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
|
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
|
|
}
|
|
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.",
|
|
},
|
|
}
|
|
|
|
// 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 freezes the first N rows or columns; --count 0 unfreezes that
|
|
// dimension.
|
|
var DimFreeze = common.Shortcut{
|
|
Service: "sheets",
|
|
Command: "+dim-freeze",
|
|
Description: "Freeze the first N rows or columns; --count 0 unfreezes the chosen dimension.",
|
|
Risk: "write",
|
|
Scopes: []string{"sheets:spreadsheet:write_only"},
|
|
AuthTypes: []string{"user", "bot"},
|
|
HasFormat: true,
|
|
Flags: flagsFor("+dim-freeze"),
|
|
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)
|
|
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 := dimFreezeInput(runtime, token, sheetID, sheetName)
|
|
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 dimFreezeInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
|
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
|
return nil, err
|
|
}
|
|
if !runtime.Changed("dimension") {
|
|
return nil, sheetsValidationForFlag("dimension", "--dimension is required")
|
|
}
|
|
if !runtime.Changed("count") {
|
|
return nil, sheetsValidationForFlag("count", "--count is required (0 unfreezes)")
|
|
}
|
|
if runtime.Int("count") < 0 {
|
|
return nil, sheetsValidationForFlag("count", "--count must be >= 0")
|
|
}
|
|
dim := runtime.Str("dimension")
|
|
count := runtime.Int("count")
|
|
op := "freeze"
|
|
if count == 0 {
|
|
op = "unfreeze"
|
|
}
|
|
input := map[string]interface{}{"excel_id": token, "operation": op}
|
|
sheetSelectorForToolInput(input, sheetID, sheetName)
|
|
if op == "freeze" {
|
|
if dim == "row" {
|
|
input["freeze_rows"] = count
|
|
} else {
|
|
input["freeze_columns"] = count
|
|
}
|
|
}
|
|
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")
|
|
}
|
|
parts := strings.Split(s, ":")
|
|
if len(parts) > 2 {
|
|
return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element")
|
|
}
|
|
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")
|
|
}
|
|
if idx2 < idx1 {
|
|
return "", 0, 0, fmt.Errorf("end position is before start")
|
|
}
|
|
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")
|
|
}
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// ─── +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,
|
|
}
|
|
}
|