Files
larksuite--cli/shortcuts/sheets/lark_sheet_sheet_structure.go
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

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,
}
}