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
2490 lines
93 KiB
Go
2490 lines
93 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package sheets
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
|
||
"github.com/larksuite/cli/errs"
|
||
"github.com/larksuite/cli/extension/fileio"
|
||
"github.com/larksuite/cli/internal/suggest"
|
||
"github.com/larksuite/cli/internal/util"
|
||
"github.com/larksuite/cli/shortcuts/common"
|
||
"github.com/larksuite/cli/shortcuts/drive"
|
||
)
|
||
|
||
// ─── lark_sheet_workbook ──────────────────────────────────────────────
|
||
//
|
||
// Wraps two tools behind the One-OpenAPI: get_workbook_structure (read) and
|
||
// modify_workbook_structure (write, dispatched by `operation` enum).
|
||
//
|
||
// CLI Risk tiers diverge intentionally from the tool's single endpoint:
|
||
// - +sheet-delete is high-risk-write (irreversible)
|
||
// - everything else is plain write
|
||
//
|
||
// +sheet-create only carries --url / --spreadsheet-token (no sheet selector):
|
||
// the create tool path needs no existing-sheet anchor, so the public sheet
|
||
// selector pair is dropped here to avoid a misleading XOR requirement.
|
||
|
||
// WorkbookInfo wraps get_workbook_structure: list a workbook's sub-sheets
|
||
// with their metadata (sheet_id, title, dimensions, freeze rows and cols,
|
||
// index, hidden). First step for every sheets task — downstream sheet-level
|
||
// operations all depend on the sheet_id returned here.
|
||
var WorkbookInfo = common.Shortcut{
|
||
Service: "sheets",
|
||
Command: "+workbook-info",
|
||
Description: "List sub-sheets of a spreadsheet with metadata (sheet_id, title, dimensions, freeze, hidden).",
|
||
Risk: "read",
|
||
Scopes: []string{"sheets:spreadsheet:read"},
|
||
AuthTypes: []string{"user", "bot"},
|
||
HasFormat: true,
|
||
Flags: flagsFor("+workbook-info"),
|
||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
_, err := resolveSpreadsheetToken(runtime)
|
||
return err
|
||
},
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
token, _ := resolveSpreadsheetToken(runtime)
|
||
return invokeToolDryRun(token, ToolKindRead, "get_workbook_structure", map[string]interface{}{
|
||
"excel_id": token,
|
||
})
|
||
},
|
||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_workbook_structure", map[string]interface{}{
|
||
"excel_id": token,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
runtime.Out(out, nil)
|
||
return nil
|
||
},
|
||
Tips: []string{
|
||
"First step for every sheets task — capture sheet_id from the result before doing any sheet-level operation.",
|
||
},
|
||
}
|
||
|
||
// SheetCreate creates a new sub-sheet. --title is the new sheet's name;
|
||
// --index inserts at a specific position (omitted → appended). Default
|
||
// dimensions match the canonical schema (rows=100, cols=26 when omitted —
|
||
// tool's defaults differ but CLI surface stays predictable).
|
||
var SheetCreate = common.Shortcut{
|
||
Service: "sheets",
|
||
Command: "+sheet-create",
|
||
Description: "Create a new sub-sheet with an optional position and initial dimensions.",
|
||
Risk: "write",
|
||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||
AuthTypes: []string{"user", "bot"},
|
||
HasFormat: true,
|
||
Flags: flagsFor("+sheet-create"),
|
||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
token, err := resolveSpreadsheetToken(runtime)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = sheetCreateInput(runtime, token)
|
||
return err
|
||
},
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
token, _ := resolveSpreadsheetToken(runtime)
|
||
input, _ := sheetCreateInput(runtime, token)
|
||
return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input)
|
||
},
|
||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
input, err := sheetCreateInput(runtime, token)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
runtime.Out(out, nil)
|
||
return nil
|
||
},
|
||
Tips: []string{
|
||
"+sheet-create makes an empty sub-sheet. To create a sub-sheet and fill it with typed data and/or styles in one step, use +table-put (missing sheets named in the payload are created automatically) with its --sheets and --styles flags.",
|
||
},
|
||
}
|
||
|
||
func sheetCreateInput(runtime flagView, token string) (map[string]interface{}, error) {
|
||
if strings.TrimSpace(runtime.Str("title")) == "" {
|
||
return nil, common.ValidationErrorf("--title is required")
|
||
}
|
||
sheetType := strings.TrimSpace(runtime.Str("type"))
|
||
if sheetType == "" {
|
||
sheetType = "sheet"
|
||
}
|
||
if sheetType != "sheet" {
|
||
return nil, common.ValidationErrorf("--type must be 'sheet'")
|
||
}
|
||
if n := runtime.Int("row-count"); n < 0 || n > 50000 {
|
||
return nil, common.ValidationErrorf("--row-count must be between 0 and 50000")
|
||
}
|
||
if n := runtime.Int("col-count"); n < 0 || n > 200 {
|
||
return nil, common.ValidationErrorf("--col-count must be between 0 and 200")
|
||
}
|
||
input := map[string]interface{}{
|
||
"excel_id": token,
|
||
"operation": "create",
|
||
"sheet_name": strings.TrimSpace(runtime.Str("title")),
|
||
}
|
||
if runtime.Changed("index") {
|
||
input["target_index"] = runtime.Int("index")
|
||
}
|
||
if n := runtime.Int("row-count"); n > 0 {
|
||
input["rows"] = n
|
||
}
|
||
if n := runtime.Int("col-count"); n > 0 {
|
||
input["columns"] = n
|
||
}
|
||
return input, nil
|
||
}
|
||
|
||
// sheetDeleteInput / sheetRenameInput / sheetVisibilityInput /
|
||
// sheetSetTabColorInput build the modify_workbook_structure body for the
|
||
// matching shortcut. Shared by standalone DryRun/Execute and by the
|
||
// +batch-update sub-op dispatch so both paths emit an identical body and the
|
||
// same friendly error when --sheet-id/--sheet-name (or the shortcut's own
|
||
// required flags) are missing.
|
||
func sheetDeleteInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||
return nil, err
|
||
}
|
||
input := map[string]interface{}{"excel_id": token, "operation": "delete"}
|
||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||
return input, nil
|
||
}
|
||
|
||
func sheetRenameInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||
return nil, err
|
||
}
|
||
if strings.TrimSpace(runtime.Str("title")) == "" {
|
||
return nil, common.ValidationErrorf("--title is required")
|
||
}
|
||
input := map[string]interface{}{
|
||
"excel_id": token,
|
||
"operation": "rename",
|
||
"new_name": strings.TrimSpace(runtime.Str("title")),
|
||
}
|
||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||
return input, nil
|
||
}
|
||
|
||
func sheetVisibilityInput(runtime flagView, token, sheetID, sheetName, op string) (map[string]interface{}, error) {
|
||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||
return nil, err
|
||
}
|
||
input := map[string]interface{}{"excel_id": token, "operation": op}
|
||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||
return input, nil
|
||
}
|
||
|
||
func sheetSetTabColorInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||
return nil, err
|
||
}
|
||
if !runtime.Changed("color") {
|
||
return nil, common.ValidationErrorf("--color is required (empty string clears)")
|
||
}
|
||
input := map[string]interface{}{
|
||
"excel_id": token,
|
||
"operation": "set_tab_color",
|
||
"tab_color": runtime.Str("color"),
|
||
}
|
||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||
return input, nil
|
||
}
|
||
|
||
// SheetDelete deletes a sub-sheet. high-risk-write — framework rejects
|
||
// without --yes. Always preview with --dry-run first to confirm the target.
|
||
var SheetDelete = common.Shortcut{
|
||
Service: "sheets",
|
||
Command: "+sheet-delete",
|
||
Description: "Delete a sub-sheet (irreversible).",
|
||
Risk: "high-risk-write",
|
||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||
AuthTypes: []string{"user", "bot"},
|
||
HasFormat: true,
|
||
Flags: flagsFor("+sheet-delete"),
|
||
Validate: validateViaInput(sheetDeleteInput),
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
token, _ := resolveSpreadsheetToken(runtime)
|
||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||
input, _ := sheetDeleteInput(runtime, token, sheetID, sheetName)
|
||
return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_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 := sheetDeleteInput(runtime, token, sheetID, sheetName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
runtime.Out(out, nil)
|
||
return nil
|
||
},
|
||
Tips: []string{
|
||
"Sheet deletion is irreversible. Always run with --dry-run first to verify the target sheet_id/sheet_name.",
|
||
},
|
||
}
|
||
|
||
// SheetRename renames a sub-sheet via --title (mapped to tool's new_name).
|
||
var SheetRename = common.Shortcut{
|
||
Service: "sheets",
|
||
Command: "+sheet-rename",
|
||
Description: "Rename a sub-sheet.",
|
||
Risk: "write",
|
||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||
AuthTypes: []string{"user", "bot"},
|
||
HasFormat: true,
|
||
Flags: flagsFor("+sheet-rename"),
|
||
Validate: validateViaInput(sheetRenameInput),
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
token, _ := resolveSpreadsheetToken(runtime)
|
||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||
input, _ := sheetRenameInput(runtime, token, sheetID, sheetName)
|
||
return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_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 := sheetRenameInput(runtime, token, sheetID, sheetName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
runtime.Out(out, nil)
|
||
return nil
|
||
},
|
||
}
|
||
|
||
// SheetMove moves a sub-sheet to a new index. The tool requires sheet_id
|
||
// and source_index in addition to target_index. The CLI accepts:
|
||
// - --sheet-id / --sheet-name to identify the sheet
|
||
// - --source-index (optional) for explicit source position
|
||
//
|
||
// When --source-index is omitted, or when --sheet-name is used instead of
|
||
// --sheet-id, Execute issues a single get_workbook_structure read to derive
|
||
// the missing pieces. DryRun stays network-free: it uses <resolve> placeholders
|
||
// for any field that would need that read.
|
||
var SheetMove = common.Shortcut{
|
||
Service: "sheets",
|
||
Command: "+sheet-move",
|
||
Description: "Move a sub-sheet to a new position.",
|
||
Risk: "write",
|
||
Scopes: []string{"sheets:spreadsheet:read", "sheets:spreadsheet:write_only"},
|
||
AuthTypes: []string{"user", "bot"},
|
||
HasFormat: true,
|
||
Flags: flagsFor("+sheet-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
|
||
}
|
||
if !runtime.Changed("index") {
|
||
return common.ValidationErrorf("--index is required")
|
||
}
|
||
if runtime.Int("index") < 0 {
|
||
return common.ValidationErrorf("--index must be >= 0")
|
||
}
|
||
if runtime.Changed("source-index") && runtime.Int("source-index") < 0 {
|
||
return common.ValidationErrorf("--source-index must be >= 0")
|
||
}
|
||
return nil
|
||
},
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
token, _ := resolveSpreadsheetToken(runtime)
|
||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||
input := map[string]interface{}{
|
||
"excel_id": token,
|
||
"operation": "move",
|
||
"sheet_id": sheetSelectorPlaceholder(sheetID, sheetName),
|
||
"target_index": runtime.Int("index"),
|
||
"source_index": sourceIndexOrPlaceholder(runtime),
|
||
}
|
||
return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_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
|
||
}
|
||
|
||
resolvedID := sheetID
|
||
var sourceIndex int
|
||
needIDLookup := sheetID == ""
|
||
needIndexLookup := !runtime.Changed("source-index")
|
||
if needIDLookup || needIndexLookup {
|
||
lookedID, lookedIdx, err := lookupSheetIndex(ctx, runtime, token, sheetID, sheetName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
resolvedID = lookedID
|
||
sourceIndex = lookedIdx
|
||
}
|
||
if runtime.Changed("source-index") {
|
||
sourceIndex = runtime.Int("source-index")
|
||
}
|
||
|
||
input := map[string]interface{}{
|
||
"excel_id": token,
|
||
"operation": "move",
|
||
"sheet_id": resolvedID,
|
||
"source_index": sourceIndex,
|
||
"target_index": runtime.Int("index"),
|
||
}
|
||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
runtime.Out(out, nil)
|
||
return nil
|
||
},
|
||
Tips: []string{
|
||
"Pass --source-index when you already know it to avoid the extra read; otherwise CLI derives it from --sheet-id/--sheet-name.",
|
||
},
|
||
}
|
||
|
||
// sourceIndexOrPlaceholder returns the user-supplied source-index, or the
|
||
// string "<resolve>" when DryRun should signal that Execute will derive it.
|
||
func sourceIndexOrPlaceholder(runtime *common.RuntimeContext) interface{} {
|
||
if runtime.Changed("source-index") {
|
||
return runtime.Int("source-index")
|
||
}
|
||
return "<resolve>"
|
||
}
|
||
|
||
// SheetCopy duplicates a sub-sheet. --title (optional) names the copy;
|
||
// --index (optional) places it.
|
||
var SheetCopy = common.Shortcut{
|
||
Service: "sheets",
|
||
Command: "+sheet-copy",
|
||
Description: "Duplicate a sub-sheet, optionally renaming and repositioning the copy.",
|
||
Risk: "write",
|
||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||
AuthTypes: []string{"user", "bot"},
|
||
HasFormat: true,
|
||
Flags: flagsFor("+sheet-copy"),
|
||
Tips: []string{
|
||
"Example: lark-cli sheets +sheet-copy --url <URL> --sheet-name 数据源 --title 数据源-副本",
|
||
"--sheet-name / --sheet-id selects the SOURCE sheet; the copy's new name goes in --title.",
|
||
},
|
||
Validate: validateViaInput(sheetCopyInput),
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
token, _ := resolveSpreadsheetToken(runtime)
|
||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||
input, _ := sheetCopyInput(runtime, token, sheetID, sheetName)
|
||
return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_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 := sheetCopyInput(runtime, token, sheetID, sheetName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
runtime.Out(out, nil)
|
||
return nil
|
||
},
|
||
}
|
||
|
||
func sheetCopyInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||
return nil, err
|
||
}
|
||
input := map[string]interface{}{"excel_id": token, "operation": "duplicate"}
|
||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||
if t := strings.TrimSpace(runtime.Str("title")); t != "" {
|
||
input["new_name"] = t
|
||
}
|
||
if runtime.Changed("index") {
|
||
input["target_index"] = runtime.Int("index")
|
||
}
|
||
return input, nil
|
||
}
|
||
|
||
// SheetHide / SheetUnhide toggle visibility. Visible bool semantics live in
|
||
// the operation enum so callers don't need a --visible flag.
|
||
var SheetHide = newSheetVisibilityShortcut(
|
||
"+sheet-hide", "Hide a sub-sheet from the tabs bar.", "hide",
|
||
)
|
||
|
||
var SheetUnhide = newSheetVisibilityShortcut(
|
||
"+sheet-unhide", "Restore a hidden sub-sheet.", "unhide",
|
||
)
|
||
|
||
func newSheetVisibilityShortcut(command, desc, op string) common.Shortcut {
|
||
return common.Shortcut{
|
||
Service: "sheets",
|
||
Command: command,
|
||
Description: desc,
|
||
Risk: "write",
|
||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||
AuthTypes: []string{"user", "bot"},
|
||
HasFormat: true,
|
||
Flags: flagsFor(command),
|
||
Validate: 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 = sheetVisibilityInput(runtime, token, sheetID, sheetName, op)
|
||
return err
|
||
},
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
token, _ := resolveSpreadsheetToken(runtime)
|
||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||
input, _ := sheetVisibilityInput(runtime, token, sheetID, sheetName, op)
|
||
return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_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 := sheetVisibilityInput(runtime, token, sheetID, sheetName, op)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
runtime.Out(out, nil)
|
||
return nil
|
||
},
|
||
}
|
||
}
|
||
|
||
// SheetSetTabColor sets the tab color of a sub-sheet. --color "" clears.
|
||
var SheetSetTabColor = common.Shortcut{
|
||
Service: "sheets",
|
||
Command: "+sheet-set-tab-color",
|
||
Description: "Set or clear the tab color of a sub-sheet.",
|
||
Risk: "write",
|
||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||
AuthTypes: []string{"user", "bot"},
|
||
HasFormat: true,
|
||
Flags: flagsFor("+sheet-set-tab-color"),
|
||
Validate: validateViaInput(sheetSetTabColorInput),
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
token, _ := resolveSpreadsheetToken(runtime)
|
||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||
input, _ := sheetSetTabColorInput(runtime, token, sheetID, sheetName)
|
||
return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_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 := sheetSetTabColorInput(runtime, token, sheetID, sheetName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
runtime.Out(out, nil)
|
||
return nil
|
||
},
|
||
}
|
||
|
||
// SheetShowGridline / SheetHideGridline toggle a sub-sheet's gridline display.
|
||
// Gridline show/hide is the same two-state-via-operation shape as
|
||
// +sheet-hide/+sheet-unhide (no --visible flag), so they reuse
|
||
// newSheetVisibilityShortcut; only the operation enum differs.
|
||
var SheetShowGridline = newSheetVisibilityShortcut(
|
||
"+sheet-show-gridline", "Show gridlines on a sub-sheet.", "show_gridline",
|
||
)
|
||
|
||
var SheetHideGridline = newSheetVisibilityShortcut(
|
||
"+sheet-hide-gridline", "Hide gridlines on a sub-sheet.", "hide_gridline",
|
||
)
|
||
|
||
// ─── +workbook-create (legacy OAPI, cli_status: cli-only) ────────────
|
||
//
|
||
// Creates a brand-new spreadsheet via POST /sheets/v3/spreadsheets, then
|
||
// optionally fills the first sheet's header row and initial data block
|
||
// via a follow-up callTool(set_cell_range). Not exposed as an MCP tool —
|
||
// hence the direct legacy OAPI call instead of going through callTool.
|
||
|
||
// WorkbookCreate creates a brand-new spreadsheet in the user's drive
|
||
// (optionally inside --folder-token) and can pre-fill the first row of
|
||
// headers and an initial data block.
|
||
var WorkbookCreate = common.Shortcut{
|
||
Service: "sheets",
|
||
Command: "+workbook-create",
|
||
Description: "Create a new spreadsheet, optionally pre-filled with untyped --values or typed --sheets (type-faithful one-step create + write).",
|
||
Risk: "write",
|
||
Scopes: []string{"sheets:spreadsheet:create", "sheets:spreadsheet:write_only"},
|
||
AuthTypes: []string{"user", "bot"},
|
||
HasFormat: true,
|
||
Flags: flagsFor("+workbook-create"),
|
||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
if strings.TrimSpace(runtime.Str("title")) == "" {
|
||
return common.ValidationErrorf("--title is required")
|
||
}
|
||
// --sheets (typed JSON) is the typed data entry, mutually exclusive
|
||
// with the untyped --values. Gating on Changed (not just non-empty)
|
||
// catches an explicitly-given but empty payload as an error instead
|
||
// of letting it fall through to creating an empty workbook.
|
||
sheetsGiven := runtime.Changed("sheets")
|
||
if sheetsGiven && runtime.Str("values") != "" {
|
||
return common.ValidationErrorf("--values is mutually exclusive with --sheets")
|
||
}
|
||
if sheetsGiven {
|
||
if strings.TrimSpace(runtime.Str("sheets")) == "" {
|
||
return common.ValidationErrorf("--sheets was given but resolved to empty (empty stdin/file?); pass a typed payload, or drop --sheets to create an empty workbook")
|
||
}
|
||
payload, err := parseTablePutPayload(runtime)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
styles, err := parseWorkbookCreateSheetStyles(runtime, payload, false)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := payload.checkCellBudgetWithStyles(styles); err != nil {
|
||
return err
|
||
}
|
||
return checkStylesAnchors(payload, styles, true)
|
||
}
|
||
// Untyped --values path: parse (and validate) --styles as a single sheet
|
||
// style item, then synthesize --values into a type-less typed payload —
|
||
// the same construction buildValuesPayload runs at execute time, so any
|
||
// malformed --values / --styles is caught here before a workbook is made.
|
||
sheetStyles, err := parseValuesSheetStyles(runtime)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
payload, err := buildValuesPayload(runtime, sheetStyles)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return checkStylesAnchors(payload, sheetStyles, true)
|
||
},
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
body := map[string]interface{}{"title": strings.TrimSpace(runtime.Str("title"))}
|
||
if v := strings.TrimSpace(runtime.Str("folder-token")); v != "" {
|
||
body["folder_token"] = v
|
||
}
|
||
dry := common.NewDryRunAPI().
|
||
POST("/open-apis/sheets/v3/spreadsheets").
|
||
Desc("create spreadsheet").
|
||
Body(body)
|
||
// Both data entries (typed --sheets and untyped --values) resolve to the
|
||
// same typed payload and preview through the same set_cell_range path: one
|
||
// write per sheet, the first adopting the new workbook's default sheet.
|
||
// Mirrors +table-put's dry-run against a placeholder token.
|
||
payload, sheetStyles, _ := workbookCreateData(runtime)
|
||
if payload == nil {
|
||
// Style-only payload with no cell-rectangle extent (e.g. only
|
||
// row_sizes or col_sizes). No set_cell_range to render, but the
|
||
// visual ops (merges / row+col sizes) still run in Execute, so
|
||
// they should show up in the dry-run plan too.
|
||
if styles := sheetStyles.styleFor(0); styles != nil {
|
||
appendWorkbookCreateVisualOpsDryRun(dry, "<new-token>", "", valuesSheetName, styles)
|
||
}
|
||
return dry
|
||
}
|
||
for i := range payload.Sheets {
|
||
s := &payload.Sheets[i]
|
||
matrix, _ := buildSheetMatrix(s, headerOn(s))
|
||
_, col0, row0, _ := sheetAnchor(s)
|
||
matrix, _ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
|
||
// Padding can widen / lengthen the matrix past the data, so build the
|
||
// range from the padded dims to match what Execute writes.
|
||
rng := tablePutFullRange(s, len(matrix))
|
||
writeCols := len(s.Columns)
|
||
if len(matrix) > 0 {
|
||
writeCols = len(matrix[0])
|
||
rng = fmt.Sprintf("%s%d:%s%d",
|
||
columnIndexToLetter(col0), row0+1,
|
||
columnIndexToLetter(col0+writeCols-1), row0+len(matrix))
|
||
}
|
||
input := map[string]interface{}{
|
||
"excel_id": "<new-token>",
|
||
"sheet_name": s.Name,
|
||
"range": rng,
|
||
"cells": matrix,
|
||
}
|
||
wireBody, _ := buildToolBody("set_cell_range", input)
|
||
dry.POST("/open-apis/sheet_ai/v2/spreadsheets/<new-token>/tools/invoke_write").
|
||
Desc(fmt.Sprintf("write sheet %q (%d data rows × %d cols) via set_cell_range", s.Name, len(s.Rows), writeCols)).
|
||
Body(wireBody)
|
||
appendWorkbookCreateVisualOpsDryRun(dry, "<new-token>", "", s.Name, sheetStyles.styleFor(i))
|
||
}
|
||
return dry
|
||
},
|
||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
body := map[string]interface{}{"title": strings.TrimSpace(runtime.Str("title"))}
|
||
if v := strings.TrimSpace(runtime.Str("folder-token")); v != "" {
|
||
body["folder_token"] = v
|
||
}
|
||
data, err := runtime.CallAPITyped("POST", "/open-apis/sheets/v3/spreadsheets", nil, body)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
ss := common.GetMap(data, "spreadsheet")
|
||
token := common.GetString(ss, "spreadsheet_token")
|
||
if token == "" {
|
||
token = common.GetString(ss, "token")
|
||
}
|
||
if token == "" {
|
||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "spreadsheet created but token missing in response")
|
||
}
|
||
|
||
result := map[string]interface{}{"spreadsheet": ss}
|
||
|
||
// Both data entries resolve to the same typed payload: --sheets directly,
|
||
// --values synthesized into a type-less payload. Both write through
|
||
// writeTypedSheets, adopting the brand-new workbook's default sheet as the
|
||
// first payload sheet so no empty "Sheet1" is left behind.
|
||
payload, sheetStyles, err := workbookCreateData(runtime)
|
||
if err != nil {
|
||
return err // already validated; defensive
|
||
}
|
||
if payload != nil {
|
||
firstSheetID, err := lookupFirstSheetID(ctx, runtime, token)
|
||
if err != nil {
|
||
return workbookCreatedButFillFailed(runtime, token, "resolving its default sheet for the write failed", err)
|
||
}
|
||
written, err := writeTypedSheets(ctx, runtime, token, payload, firstSheetID, sheetStyles)
|
||
if err != nil {
|
||
return workbookCreatedButFillFailed(runtime, token, "initial fill failed", err)
|
||
}
|
||
result["sheets"] = written
|
||
} else if styles := sheetStyles.styleFor(0); styles != nil {
|
||
// Style-only payloads (e.g. --styles with only row_sizes or col_sizes
|
||
// and no --values/--sheets) don't write any cells but still need their
|
||
// visual ops applied — otherwise the merges/sizes would be silently
|
||
// dropped. workbookCreateStyleDimensions can't expand a row-only or
|
||
// column-only range into a cell rectangle, so the no-data branch lives
|
||
// here.
|
||
firstSheetID, err := lookupFirstSheetID(ctx, runtime, token)
|
||
if err != nil {
|
||
return workbookCreatedButFillFailed(runtime, token, "resolving its default sheet for the write failed", err)
|
||
}
|
||
if err := applyWorkbookCreateVisualOps(ctx, runtime, token, firstSheetID, styles); err != nil {
|
||
return workbookCreatedButFillFailed(runtime, token, "applying visual styles failed", err)
|
||
}
|
||
}
|
||
runtime.Out(result, nil)
|
||
return nil
|
||
},
|
||
Tips: []string{
|
||
"--values is an optional untyped fill (one JSON 2D array). It writes through the same batched set_cell_range path as --sheets; pair it with --styles to set number formats, colors, merges, and row/col sizes. Partial failure leaves the spreadsheet created but empty.",
|
||
"--sheets writes typed, type-faithful data (dates → real dates, numbers keep precision) in one step — the create + typed write that +table-put can't do on its own. Mutually exclusive with --values; the new workbook's default sheet becomes the first sheet (no empty Sheet1 left behind).",
|
||
},
|
||
}
|
||
|
||
// workbookCreatedButFillFailed reports a workbook-create where the spreadsheet
|
||
// POST succeeded but the follow-up initial fill did not. It is the same
|
||
// partial-state shape as +table-put's multi-sheet half-write: stdout carries an
|
||
// ok:false envelope with the new spreadsheet_token (so the caller can retry the
|
||
// fill via +cells-set / +csv-put, or delete the orphan), and the process exits
|
||
// with the partial-failure signal — keeping a single sheets-domain contract for
|
||
// "the side effect landed but the follow-up didn't" instead of two (this used to
|
||
// surface as a typed failed_precondition on stderr, which agents couldn't tell
|
||
// apart from a plain validation refusal). The underlying cause's typed shape is
|
||
// flattened into a structured `cause` field so the inner subtype / category /
|
||
// message stays diagnosable from the JSON envelope alone.
|
||
func workbookCreatedButFillFailed(runtime *common.RuntimeContext, token, reason string, cause error) error {
|
||
data := map[string]interface{}{
|
||
"spreadsheet_token": token,
|
||
"reason": fmt.Sprintf("spreadsheet %s created but %s", token, reason),
|
||
"hint": "the spreadsheet exists; retry the fill with the returned spreadsheet_token (+cells-set / +csv-put), or delete it",
|
||
}
|
||
if cause != nil {
|
||
if p, ok := errs.ProblemOf(cause); ok {
|
||
data["cause"] = map[string]interface{}{
|
||
"category": string(p.Category),
|
||
"subtype": string(p.Subtype),
|
||
"message": p.Message,
|
||
}
|
||
} else {
|
||
data["cause"] = map[string]interface{}{"message": cause.Error()}
|
||
}
|
||
}
|
||
return runtime.OutPartialFailure(data, nil)
|
||
}
|
||
|
||
// valuesSheetName is the synthesized sheet name for the untyped --values path.
|
||
// It matches a freshly created workbook's default sheet, so writeTypedSheets
|
||
// adopts that sheet in place (no rename, no stray sheet) — see its adopt logic.
|
||
// Lark Sheets names the default sheet "Sheet1" on create.
|
||
const valuesSheetName = "Sheet1"
|
||
|
||
// workbookCreateData resolves the data to write into a freshly created workbook:
|
||
// typed --sheets directly, or untyped --values synthesized as a single sheet of
|
||
// type-less (raw passthrough) columns. Both go through writeTypedSheets so the
|
||
// two entries share one batched set_cell_range writer. Returns (nil, nil, nil)
|
||
// when there's nothing to fill (no --sheets, and no --values/--styles extent).
|
||
func workbookCreateData(runtime *common.RuntimeContext) (*tablePayload, *workbookCreateSheetStyles, error) {
|
||
if runtime.Changed("sheets") {
|
||
payload, err := parseTablePutPayload(runtime)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
styles, err := parseWorkbookCreateSheetStyles(runtime, payload, false)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
return payload, styles, nil
|
||
}
|
||
styles, err := parseValuesSheetStyles(runtime)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
payload, err := buildValuesPayload(runtime, styles)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
return payload, styles, nil
|
||
}
|
||
|
||
// parseValuesSheetStyles parses --styles for the untyped --values path and wraps
|
||
// the single style item as a one-sheet workbookCreateSheetStyles, so --values
|
||
// reuses writeSheetData's styleFor application. The item's name is ignored (the
|
||
// synthesized sheet is always index 0). Returns nil when --styles is absent.
|
||
func parseValuesSheetStyles(runtime flagView) (*workbookCreateSheetStyles, error) {
|
||
p, err := parseWorkbookCreateStyles(runtime)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if p == nil {
|
||
return nil, nil
|
||
}
|
||
return &workbookCreateSheetStyles{ByIndex: []*workbookCreateStylePayload{p}}, nil
|
||
}
|
||
|
||
// buildValuesPayload turns untyped --values into a single-sheet typed payload of
|
||
// type-less columns (Header=false), so --values shares --sheets' batched
|
||
// set_cell_range writer. Rows are normalized to a rectangle wide/long enough to
|
||
// also cover any --styles cell ranges (matching the old buildInitialFillInput,
|
||
// where a style on B3 extends the written block). Returns (nil, nil) when there
|
||
// is nothing to write — no --values rows and no style-driven extent.
|
||
func buildValuesPayload(runtime flagView, sheetStyles *workbookCreateSheetStyles) (*tablePayload, error) {
|
||
rows, err := parseValuesRows(runtime)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
maxCols := 0
|
||
for _, r := range rows {
|
||
if len(r) > maxCols {
|
||
maxCols = len(r)
|
||
}
|
||
}
|
||
var styleRows, styleCols int
|
||
if sheetStyles != nil {
|
||
styleRows, styleCols = workbookCreateStyleDimensions(sheetStyles.styleFor(0), 0, 0)
|
||
}
|
||
if styleCols > maxCols {
|
||
maxCols = styleCols
|
||
}
|
||
nrows := len(rows)
|
||
if styleRows > nrows {
|
||
nrows = styleRows
|
||
}
|
||
if maxCols == 0 || nrows == 0 {
|
||
return nil, nil // nothing to write (e.g. --values '[]' with no styles)
|
||
}
|
||
if err := checkTablePutCellBudget(int64(nrows) * int64(maxCols)); err != nil {
|
||
return nil, err
|
||
}
|
||
// Pad to a rectangle; nil cells become empty cells in buildTypedCell.
|
||
for len(rows) < nrows {
|
||
rows = append(rows, nil)
|
||
}
|
||
for i := range rows {
|
||
for len(rows[i]) < maxCols {
|
||
rows[i] = append(rows[i], nil)
|
||
}
|
||
}
|
||
cols := make([]tableColumnSpec, maxCols)
|
||
for i := range cols {
|
||
cols[i] = tableColumnSpec{Name: fmt.Sprintf("col%d", i+1)} // type-less
|
||
}
|
||
noHeader := false
|
||
payload := &tablePayload{Sheets: []tableSheetSpec{{
|
||
Name: valuesSheetName,
|
||
Mode: "overwrite",
|
||
Header: &noHeader,
|
||
Columns: cols,
|
||
Rows: rows,
|
||
}}}
|
||
// --values bypasses tablePayload.validate(), so enforce the cell budget here
|
||
// too — otherwise a giant --values array materializes unbounded.
|
||
if err := payload.checkCellBudget(); err != nil {
|
||
return nil, err
|
||
}
|
||
return payload, nil
|
||
}
|
||
|
||
// parseValuesRows decodes --values (JSON 2D array, with @file/stdin already
|
||
// resolved by the flag layer) using UseNumber so numeric cells keep full
|
||
// precision (large order IDs survive). Empty --values yields no rows.
|
||
func parseValuesRows(runtime flagView) ([][]interface{}, error) {
|
||
raw := strings.TrimSpace(runtime.Str("values"))
|
||
if raw == "" {
|
||
return nil, nil
|
||
}
|
||
dec := json.NewDecoder(strings.NewReader(raw))
|
||
dec.UseNumber()
|
||
var v interface{}
|
||
if err := dec.Decode(&v); err != nil {
|
||
return nil, common.ValidationErrorf("--values: invalid JSON: %v", err)
|
||
}
|
||
// Reject trailing non-whitespace after the first JSON value: see
|
||
// decoderExpectEOF in lark_sheet_table_io.go for the rationale.
|
||
if err := decoderExpectEOF(dec); err != nil {
|
||
return nil, common.ValidationErrorf("--values: %v", err).WithCause(err)
|
||
}
|
||
arr, ok := v.([]interface{})
|
||
if !ok {
|
||
return nil, common.ValidationErrorf("--values must be a JSON 2D array")
|
||
}
|
||
rows := make([][]interface{}, len(arr))
|
||
for i, r := range arr {
|
||
cells, ok := r.([]interface{})
|
||
if !ok {
|
||
return nil, common.ValidationErrorf("--values[%d] must be an array", i)
|
||
}
|
||
rows[i] = cells
|
||
}
|
||
return rows, nil
|
||
}
|
||
|
||
type workbookCreateStylePayload struct {
|
||
CellStyles []workbookCreateCellStyleOp
|
||
RowSizes []workbookCreateResizeOp
|
||
ColSizes []workbookCreateResizeOp
|
||
CellMerges []workbookCreateMergeOp
|
||
Freeze *workbookCreateFreezeOp
|
||
}
|
||
|
||
// workbookCreateFreezeOp freezes the first Rows rows / Cols columns.
|
||
// Zero means "that axis ends up UNFROZEN", not "leave it alone": freeze is
|
||
// full-state replacement server-side (see workbookCreateVisualOpInput's freeze
|
||
// branch), so a declarative spec that omits an axis is stating it should not be
|
||
// frozen. All-zero means "unfreeze both axes" and is only accepted on carriers
|
||
// targeting an existing sheet (parseWorkbookCreateFreezeOp rejects it on the
|
||
// create path, where a new sheet starts unfrozen anyway).
|
||
type workbookCreateFreezeOp struct {
|
||
Rows int
|
||
Cols int
|
||
}
|
||
|
||
type workbookCreateCellStyleOp struct {
|
||
Range string
|
||
Style map[string]interface{}
|
||
}
|
||
|
||
type workbookCreateMergeOp struct {
|
||
Range string
|
||
MergeType string
|
||
}
|
||
|
||
type workbookCreateResizeOp struct {
|
||
Range string
|
||
ResizeType string
|
||
Size int
|
||
}
|
||
|
||
type workbookCreateSheetStyles struct {
|
||
ByIndex []*workbookCreateStylePayload
|
||
ByName map[string]*workbookCreateStylePayload
|
||
}
|
||
|
||
func (s *workbookCreateSheetStyles) styleFor(index int) *workbookCreateStylePayload {
|
||
if s == nil {
|
||
return nil
|
||
}
|
||
if index >= 0 && index < len(s.ByIndex) && s.ByIndex[index] != nil {
|
||
return s.ByIndex[index]
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// parseWorkbookCreateStyles parses --styles for +workbook-create's untyped
|
||
// initial-fill path. The outer protocol is always {"styles":[...]}; untyped
|
||
// initial fill consumes exactly one item from that array.
|
||
func parseWorkbookCreateStyles(runtime flagView) (*workbookCreateStylePayload, error) {
|
||
if strings.TrimSpace(runtime.Str("styles")) == "" {
|
||
return nil, nil
|
||
}
|
||
v, err := parseJSONFlag(runtime, "styles")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
items, err := parseWorkbookCreateStylesItems(v)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(items) != 1 {
|
||
return nil, common.ValidationErrorf("--styles.styles must contain exactly one item when using --values")
|
||
}
|
||
payload, probs := parseWorkbookCreateStyleItem(items[0], "--styles.styles[0]", false)
|
||
if err := joinStyleValidationErrors(probs); err != nil {
|
||
return nil, err
|
||
}
|
||
return payload, nil
|
||
}
|
||
|
||
// parseWorkbookCreateSheetStyles parses --styles for the typed --sheets path.
|
||
// The outer protocol is always {"styles":[...]}, and the array is aligned with
|
||
// --sheets.sheets. Each item must name the same sheet at the same index.
|
||
// existingSheet says whether the carrier targets an existing spreadsheet
|
||
// (+table-put) rather than one being created (+workbook-create) — it gates
|
||
// whether an all-zero freeze may express "unfreeze both axes".
|
||
func parseWorkbookCreateSheetStyles(runtime flagView, payload *tablePayload, existingSheet bool) (*workbookCreateSheetStyles, error) {
|
||
if strings.TrimSpace(runtime.Str("styles")) == "" {
|
||
return nil, nil
|
||
}
|
||
v, err := parseJSONFlag(runtime, "styles")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
items, err := parseWorkbookCreateStylesItems(v)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(items) != len(payload.Sheets) {
|
||
return nil, common.ValidationErrorf("--styles.styles has %d items, want %d to match --sheets.sheets", len(items), len(payload.Sheets))
|
||
}
|
||
out := &workbookCreateSheetStyles{ByName: map[string]*workbookCreateStylePayload{}}
|
||
out.ByIndex = make([]*workbookCreateStylePayload, len(payload.Sheets))
|
||
var probs []error
|
||
for i, item := range items {
|
||
name, _ := item["name"].(string)
|
||
if strings.TrimSpace(name) == "" {
|
||
probs = append(probs, common.ValidationErrorf("--styles.styles[%d].name is required", i))
|
||
continue
|
||
}
|
||
if name != payload.Sheets[i].Name {
|
||
probs = append(probs, common.ValidationErrorf("--styles.styles[%d].name %q must match --sheets.sheets[%d].name %q", i, name, i, payload.Sheets[i].Name))
|
||
continue
|
||
}
|
||
style, itemProbs := parseWorkbookCreateStyleItem(item, fmt.Sprintf("--styles.styles[%d]", i), existingSheet)
|
||
if len(itemProbs) > 0 {
|
||
probs = append(probs, itemProbs...)
|
||
continue
|
||
}
|
||
out.ByIndex[i] = style
|
||
out.ByName[name] = style
|
||
}
|
||
if err := joinStyleValidationErrors(probs); err != nil {
|
||
return nil, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
const (
|
||
// Keep declarative style payloads bounded before parsing allocates per-entry
|
||
// error objects or the coalescer scans them. The operation budget is 100,
|
||
// while a larger raw list is useful when adjacent same-style stamps merge.
|
||
maxStyleItems = 100
|
||
maxStyleSectionItems = 1000
|
||
maxStyleProblems = 9 // eight displayed plus one truncation marker
|
||
)
|
||
|
||
func parseWorkbookCreateStylesItems(v interface{}) ([]map[string]interface{}, error) {
|
||
root, ok := v.(map[string]interface{})
|
||
if !ok {
|
||
return nil, common.ValidationErrorf("--styles must be a JSON object shaped as {\"styles\":[...]}")
|
||
}
|
||
rawItems, ok := root["styles"]
|
||
if !ok {
|
||
return nil, common.ValidationErrorf("--styles.styles is required")
|
||
}
|
||
arr, ok := rawItems.([]interface{})
|
||
if !ok {
|
||
return nil, common.ValidationErrorf("--styles.styles must be an array")
|
||
}
|
||
if len(arr) > maxStyleItems {
|
||
return nil, common.ValidationErrorf("--styles.styles accepts at most %d items; got %d", maxStyleItems, len(arr))
|
||
}
|
||
items := make([]map[string]interface{}, len(arr))
|
||
for i, raw := range arr {
|
||
item, ok := raw.(map[string]interface{})
|
||
if !ok {
|
||
return nil, common.ValidationErrorf("--styles.styles[%d] must be an object", i)
|
||
}
|
||
items[i] = item
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
// parseWorkbookCreateStyleItem parses one --styles item. All four sections
|
||
// are validated even after one fails, and every issue is returned in the
|
||
// slice: eval traces show agents fixing --styles errors one round trip per
|
||
// error (border side, then row_sizes.type, then size…) because only the
|
||
// first was ever reported.
|
||
// workbookCreateStyleItemKeys is the full top-level vocabulary of one
|
||
// --styles item, shared by the three carriers (+workbook-create /
|
||
// +table-put / +styles-put).
|
||
var workbookCreateStyleItemKeys = []string{"name", "cell_styles", "row_sizes", "col_sizes", "cell_merges", "freeze"}
|
||
|
||
func boundedStyleProblems(probs *[]error, extra []error) {
|
||
for _, err := range extra {
|
||
if len(*probs) >= maxStyleProblems {
|
||
return
|
||
}
|
||
*probs = append(*probs, err)
|
||
}
|
||
}
|
||
|
||
func parseWorkbookCreateStyleItem(item map[string]interface{}, path string, existingSheet bool) (*workbookCreateStylePayload, []error) {
|
||
payload := &workbookCreateStylePayload{}
|
||
var probs []error
|
||
oversized := make(map[string]bool)
|
||
for _, section := range styleItemRangeSections {
|
||
if raw, ok := item[section]; ok {
|
||
if arr, ok := raw.([]interface{}); ok && len(arr) > maxStyleSectionItems {
|
||
probs = append(probs, common.ValidationErrorf("%s.%s accepts at most %d items; got %d", path, section, maxStyleSectionItems, len(arr)))
|
||
oversized[section] = true
|
||
}
|
||
}
|
||
}
|
||
// Reject unknown top-level keys first: a typo like "freezee" would
|
||
// otherwise be silently dropped while the rest of the item applies.
|
||
var unknown []string
|
||
unknownCount := 0
|
||
for k := range item {
|
||
known := false
|
||
for _, lk := range workbookCreateStyleItemKeys {
|
||
if k == lk {
|
||
known = true
|
||
break
|
||
}
|
||
}
|
||
if !known {
|
||
unknownCount++
|
||
if len(unknown) < maxStyleProblems {
|
||
unknown = append(unknown, k)
|
||
}
|
||
}
|
||
}
|
||
if unknownCount > len(unknown) {
|
||
// Keep the deterministic sorted sample bounded; the aggregate formatter
|
||
// reports the omitted count without retaining every unknown key.
|
||
probs = append(probs, common.ValidationErrorf("%s has %d unknown keys (showing the first %d)", path, unknownCount, len(unknown)))
|
||
}
|
||
sort.Strings(unknown)
|
||
for _, k := range unknown {
|
||
if len(probs) >= maxStyleProblems {
|
||
break
|
||
}
|
||
msg := fmt.Sprintf("%s has unknown key %q", path, k)
|
||
if match := suggest.Closest(strings.ToLower(k), workbookCreateStyleItemKeys, 1); len(match) > 0 {
|
||
msg += fmt.Sprintf(" — did you mean %q?", match[0])
|
||
}
|
||
probs = append(probs, common.ValidationErrorf("%s", msg))
|
||
}
|
||
// Normalize "Sheet!" range prefixes before the section parsers see them:
|
||
// the target sheet is named by the item (or, on +workbook-create --values,
|
||
// by the single sheet being created), so a prefix is at best redundant and
|
||
// at worst a silent retarget. Stripping is unconditional — an item without
|
||
// a name (the --values path, where name is optional) must not be left with
|
||
// prefixed ranges the section parsers then reject as malformed. Only the
|
||
// "names a DIFFERENT sheet" report needs a name to compare against, so it
|
||
// is skipped when there is none.
|
||
name, _ := item["name"].(string)
|
||
probs = append(probs, normalizeStyleItemRangePrefixes(item, path, strings.TrimSpace(name))...)
|
||
if raw, ok := item["cell_styles"]; ok && !oversized["cell_styles"] {
|
||
var errsHere []error
|
||
payload.CellStyles, errsHere = parseWorkbookCreateCellStyleOps(raw, path+".cell_styles")
|
||
boundedStyleProblems(&probs, errsHere)
|
||
}
|
||
if raw, ok := item["row_sizes"]; ok && !oversized["row_sizes"] {
|
||
var errsHere []error
|
||
payload.RowSizes, errsHere = parseWorkbookCreateResizeOps(raw, path+".row_sizes", "row")
|
||
boundedStyleProblems(&probs, errsHere)
|
||
}
|
||
if raw, ok := item["col_sizes"]; ok && !oversized["col_sizes"] {
|
||
var errsHere []error
|
||
payload.ColSizes, errsHere = parseWorkbookCreateResizeOps(raw, path+".col_sizes", "column")
|
||
boundedStyleProblems(&probs, errsHere)
|
||
}
|
||
if raw, ok := item["cell_merges"]; ok && !oversized["cell_merges"] {
|
||
var errsHere []error
|
||
payload.CellMerges, errsHere = parseWorkbookCreateMergeOps(raw, path+".cell_merges")
|
||
boundedStyleProblems(&probs, errsHere)
|
||
}
|
||
if raw, ok := item["freeze"]; ok {
|
||
freeze, err := parseWorkbookCreateFreezeOp(raw, path+".freeze", existingSheet)
|
||
if err != nil {
|
||
boundedStyleProblems(&probs, []error{err})
|
||
} else {
|
||
payload.Freeze = freeze
|
||
}
|
||
}
|
||
if len(probs) > 0 {
|
||
return nil, probs
|
||
}
|
||
if len(payload.CellStyles) == 0 && len(payload.RowSizes) == 0 && len(payload.ColSizes) == 0 && len(payload.CellMerges) == 0 && payload.Freeze == nil {
|
||
return nil, []error{common.ValidationErrorf("%s must include at least one of cell_styles/row_sizes/col_sizes/cell_merges/freeze", path)}
|
||
}
|
||
return payload, nil
|
||
}
|
||
|
||
// styleItemRangeSections are the --styles item sections whose entries carry an
|
||
// A1 range that may be written with a redundant "Sheet!" prefix.
|
||
var styleItemRangeSections = []string{"cell_styles", "row_sizes", "col_sizes", "cell_merges"}
|
||
|
||
// normalizeStyleItemRangePrefixes strips an optional "Sheet!" prefix from every
|
||
// range in one --styles item, in place, and reports the ones naming a sheet
|
||
// other than the item's own.
|
||
//
|
||
// Stripping has to happen before the section parsers run: parseWorkbookCreateResizeOp
|
||
// feeds the range straight to parseA1Range, so row_sizes like "Sheet1!2:3" fail
|
||
// as malformed even though the intent is unambiguous — the target sheet is
|
||
// already carried by the item name and by each expanded sub-op's sheet selector.
|
||
// A prefix naming a DIFFERENT sheet is an error rather than a strip, because
|
||
// stripping alone would silently retarget the operation onto the item's sheet
|
||
// (name "Summary" + range "Detail!A1:D1" applying to Summary). It is stripped
|
||
// anyway so the section parser reports the entry's own issues instead of piling
|
||
// a redundant syntax error on top of the mismatch.
|
||
//
|
||
// name is "" on +workbook-create --values, whose single styles item needs no
|
||
// name (the workbook has exactly one sheet, still unnamed at spec time). There
|
||
// is then no sheet to disagree with, so ranges are stripped without the
|
||
// mismatch report — stripping still has to happen, or the section parsers see
|
||
// a prefixed range and reject it as malformed.
|
||
func normalizeStyleItemRangePrefixes(item map[string]interface{}, path, name string) []error {
|
||
var probs []error
|
||
rewrite := func(section, rangeStr string) (string, bool) {
|
||
idx := strings.Index(rangeStr, "!")
|
||
if idx < 0 {
|
||
return "", false
|
||
}
|
||
prefix := strings.Trim(strings.TrimSpace(rangeStr[:idx]), "'")
|
||
if name != "" && prefix != name {
|
||
probs = append(probs, common.ValidationErrorf(
|
||
"%s.%s range %q names sheet %q but the item targets %q — drop the prefix, or move the entry into the item for %q",
|
||
path, section, rangeStr, prefix, name, prefix))
|
||
}
|
||
return strings.TrimSpace(rangeStr[idx+1:]), true
|
||
}
|
||
for _, key := range styleItemRangeSections {
|
||
arr, ok := item[key].([]interface{})
|
||
if !ok {
|
||
continue // a wrong-shaped section is the section parser's to report.
|
||
}
|
||
if len(arr) > maxStyleSectionItems {
|
||
continue // parseWorkbookCreateStyleItem reports the bounded-size error.
|
||
}
|
||
for i, elem := range arr {
|
||
section := fmt.Sprintf("%s[%d]", key, i)
|
||
switch v := elem.(type) {
|
||
case map[string]interface{}:
|
||
rangeStr, ok := v["range"].(string)
|
||
if !ok {
|
||
continue // non-string/missing range: the section parser reports it.
|
||
}
|
||
if stripped, changed := rewrite(section, rangeStr); changed {
|
||
v["range"] = stripped
|
||
}
|
||
case string:
|
||
// cell_merges also accepts a bare range string.
|
||
if key != "cell_merges" {
|
||
continue
|
||
}
|
||
if stripped, changed := rewrite(section, v); changed {
|
||
arr[i] = stripped
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return probs
|
||
}
|
||
|
||
// parseWorkbookCreateFreezeOp parses a {rows, cols} freeze section. Freeze is
|
||
// full-state replacement server-side, so on a carrier that targets an EXISTING
|
||
// sheet (existingSheet true: +styles-put, +table-put) an explicit all-zero op
|
||
// states "both axes unfrozen" and emits the bare unfreeze operation — the same
|
||
// state +dim-freeze --rows 0 --cols 0 expresses. On a create-path carrier
|
||
// (+workbook-create) a new sheet starts unfrozen, so all-zero is a no-op the
|
||
// caller almost certainly didn't mean and is rejected.
|
||
func parseWorkbookCreateFreezeOp(raw interface{}, path string, existingSheet bool) (*workbookCreateFreezeOp, error) {
|
||
obj, ok := raw.(map[string]interface{})
|
||
if !ok {
|
||
return nil, common.ValidationErrorf("%s must be an object like {\"rows\":1} or {\"rows\":1,\"cols\":2}", path)
|
||
}
|
||
// "cols" and "columns" are aliases for the same field, so accepting both in
|
||
// one object would make the result depend on Go's randomized map iteration
|
||
// order — the same payload could freeze 1 column on one run and 2 on the
|
||
// next. Reject the conflict instead of silently picking a winner.
|
||
if _, hasCols := obj["cols"]; hasCols {
|
||
if _, hasColumns := obj["columns"]; hasColumns {
|
||
if !jsonEqual(obj["cols"], obj["columns"]) {
|
||
return nil, common.ValidationErrorf("%s got conflicting values for \"cols\" and \"columns\" (aliases of the same field) — keep one", path)
|
||
}
|
||
}
|
||
}
|
||
out := &workbookCreateFreezeOp{}
|
||
// Iterate deterministically so error reporting is stable across runs too.
|
||
keys := make([]string, 0, len(obj))
|
||
for k := range obj {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
for _, k := range keys {
|
||
v := obj[k]
|
||
n, isNum := v.(float64)
|
||
if !isNum || n != float64(int(n)) || n < 0 {
|
||
return nil, common.ValidationErrorf("%s.%s must be a non-negative integer", path, k)
|
||
}
|
||
switch k {
|
||
case "rows":
|
||
out.Rows = int(n)
|
||
case "cols", "columns":
|
||
out.Cols = int(n)
|
||
default:
|
||
return nil, common.ValidationErrorf("%s.%s is not a supported field (want rows/cols)", path, k)
|
||
}
|
||
}
|
||
if out.Rows == 0 && out.Cols == 0 {
|
||
if !existingSheet {
|
||
return nil, common.ValidationErrorf("%s must freeze at least one dimension (rows or cols > 0) — a newly created sheet starts unfrozen", path)
|
||
}
|
||
if len(obj) == 0 {
|
||
return nil, common.ValidationErrorf("%s must specify rows or cols; use {\"rows\":0,\"cols\":0} explicitly to unfreeze both axes", path)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// joinStyleValidationErrors folds the issues collected across one --styles
|
||
// parse into a single typed error that lists them all, so the caller can fix
|
||
// the whole payload in one retry instead of one error per round trip.
|
||
func joinStyleValidationErrors(probs []error) error {
|
||
switch len(probs) {
|
||
case 0:
|
||
return nil
|
||
case 1:
|
||
// Re-attribute to the outer flag even for a single issue: the inner
|
||
// error is scoped to a nested path and carries no Param, so an agent
|
||
// would have to parse prose to learn which flag to fix. Message text
|
||
// is preserved; only the typed attribution is added — and the inner
|
||
// hint rides along, since a lone issue has the outer Hint slot free.
|
||
msg, hint := aggregatedIssueParts(probs[0])
|
||
verr := sheetsValidationForFlag("styles", "%s", msg).WithCause(probs[0])
|
||
if hint != "" {
|
||
verr = verr.WithHint("%s", hint)
|
||
}
|
||
return verr
|
||
}
|
||
const maxShown = 8
|
||
shown := probs
|
||
if len(shown) > maxShown {
|
||
shown = shown[:maxShown]
|
||
}
|
||
msgs := make([]string, 0, len(shown))
|
||
for _, e := range shown {
|
||
msgs = append(msgs, aggregatedIssueText(e))
|
||
}
|
||
suffix := ""
|
||
if len(probs) > maxShown {
|
||
suffix = fmt.Sprintf(" (+%d more)", len(probs)-maxShown)
|
||
}
|
||
return sheetsValidationForFlag("styles", "--styles has %d issues: %s%s", len(probs), strings.Join(msgs, " | "), suffix).
|
||
WithCause(probs[0])
|
||
}
|
||
|
||
func parseWorkbookCreateCellStyleOps(v interface{}, path string) ([]workbookCreateCellStyleOp, []error) {
|
||
arr, ok := v.([]interface{})
|
||
if !ok {
|
||
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
|
||
}
|
||
ops := make([]workbookCreateCellStyleOp, 0, len(arr))
|
||
var probs []error
|
||
for i, raw := range arr {
|
||
op, err := parseWorkbookCreateCellStyleOp(raw, fmt.Sprintf("%s[%d]", path, i))
|
||
if err != nil {
|
||
probs = append(probs, err)
|
||
continue
|
||
}
|
||
ops = append(ops, op)
|
||
}
|
||
return ops, probs
|
||
}
|
||
|
||
func parseWorkbookCreateCellStyleOp(raw interface{}, path string) (workbookCreateCellStyleOp, error) {
|
||
op, ok := raw.(map[string]interface{})
|
||
if !ok {
|
||
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s must be an object", path)
|
||
}
|
||
rangeStr, err := requireWorkbookCreateRange(op, path)
|
||
if err != nil {
|
||
return workbookCreateCellStyleOp{}, err
|
||
}
|
||
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
|
||
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s.range %q: %v", path, rangeStr, err)
|
||
}
|
||
styleObj := make(map[string]interface{}, len(op)-1)
|
||
for k, v := range op {
|
||
if k == "range" {
|
||
continue
|
||
}
|
||
styleObj[k] = v
|
||
}
|
||
style, err := normalizeWorkbookCreateStyleObject(styleObj, path)
|
||
if err != nil {
|
||
return workbookCreateCellStyleOp{}, err
|
||
}
|
||
if len(style) == 0 {
|
||
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s must include at least one style field", path)
|
||
}
|
||
return workbookCreateCellStyleOp{Range: rangeStr, Style: style}, nil
|
||
}
|
||
|
||
func parseWorkbookCreateMergeOps(v interface{}, path string) ([]workbookCreateMergeOp, []error) {
|
||
arr, ok := v.([]interface{})
|
||
if !ok {
|
||
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
|
||
}
|
||
ops := make([]workbookCreateMergeOp, 0, len(arr))
|
||
var probs []error
|
||
for i, raw := range arr {
|
||
op, err := parseWorkbookCreateMergeOp(raw, fmt.Sprintf("%s[%d]", path, i))
|
||
if err != nil {
|
||
probs = append(probs, err)
|
||
continue
|
||
}
|
||
ops = append(ops, op)
|
||
}
|
||
return ops, probs
|
||
}
|
||
|
||
func parseWorkbookCreateMergeOp(raw interface{}, path string) (workbookCreateMergeOp, error) {
|
||
// A bare range string means {range: s, merge_type: all} — the only
|
||
// possible reading (07-20 eval hit).
|
||
if s, ok := raw.(string); ok && strings.TrimSpace(s) != "" {
|
||
raw = map[string]interface{}{"range": strings.TrimSpace(s)}
|
||
}
|
||
op, ok := raw.(map[string]interface{})
|
||
if !ok {
|
||
return workbookCreateMergeOp{}, common.ValidationErrorf("%s must be an object", path)
|
||
}
|
||
rangeStr, err := requireWorkbookCreateRange(op, path)
|
||
if err != nil {
|
||
return workbookCreateMergeOp{}, err
|
||
}
|
||
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
|
||
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.range %q: %v", path, rangeStr, err)
|
||
}
|
||
mergeType := "all"
|
||
if raw, ok := op["merge_type"]; ok {
|
||
v, ok := raw.(string)
|
||
if !ok || strings.TrimSpace(v) == "" {
|
||
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.merge_type must be a non-empty string", path)
|
||
}
|
||
mergeType = normalizeMergeType(strings.TrimSpace(v))
|
||
}
|
||
switch mergeType {
|
||
case "all", "rows", "columns":
|
||
default:
|
||
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.merge_type %q is invalid (want all/rows/columns)", path, mergeType)
|
||
}
|
||
if err := rejectUnexpectedWorkbookStyleFields(op, path, "range", "merge_type"); err != nil {
|
||
return workbookCreateMergeOp{}, err
|
||
}
|
||
return workbookCreateMergeOp{Range: rangeStr, MergeType: mergeType}, nil
|
||
}
|
||
|
||
// normalizeMergeType maps the raw OpenAPI merge vocabulary (MERGE_ALL /
|
||
// MERGE_ROWS / MERGE_COLUMNS — which agents reproduce from the Lark API
|
||
// docs) onto the CLI's all/rows/columns. Unknown values pass through for
|
||
// the caller's enum check to reject.
|
||
func normalizeMergeType(v string) string {
|
||
lower := strings.ToLower(v)
|
||
lower = strings.TrimPrefix(lower, "merge_")
|
||
switch lower {
|
||
case "all", "rows", "columns":
|
||
return lower
|
||
}
|
||
return v
|
||
}
|
||
|
||
func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]workbookCreateResizeOp, []error) {
|
||
arr, ok := v.([]interface{})
|
||
if !ok {
|
||
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
|
||
}
|
||
ops := make([]workbookCreateResizeOp, 0, len(arr))
|
||
var probs []error
|
||
for i, raw := range arr {
|
||
op, err := parseWorkbookCreateResizeOp(raw, fmt.Sprintf("%s[%d]", path, i), dimension)
|
||
if err != nil {
|
||
probs = append(probs, err)
|
||
continue
|
||
}
|
||
ops = append(ops, op)
|
||
}
|
||
return ops, probs
|
||
}
|
||
|
||
// resizeOpExample renders a complete valid op for the dimension, inlined on
|
||
// every type/size error: eval traces show the field errors chaining (type
|
||
// "custom" → fixed to pixel → "pixel requires size"), each costing a round
|
||
// trip, because no error ever showed a whole valid op at once.
|
||
func resizeOpExample(dimension string) string {
|
||
if dimension == "column" {
|
||
return `{"range":"A:C","type":"pixel","size":120} (or {"range":"A:C","type":"standard"} to reset)`
|
||
}
|
||
return `{"range":"2:10","type":"pixel","size":32} (or "type":"auto" to fit content)`
|
||
}
|
||
|
||
func parseWorkbookCreateResizeOp(raw interface{}, path, dimension string) (workbookCreateResizeOp, error) {
|
||
op, ok := raw.(map[string]interface{})
|
||
if !ok {
|
||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s must be an object", path)
|
||
}
|
||
rangeStr, err := requireWorkbookCreateRange(op, path)
|
||
if err != nil {
|
||
return workbookCreateResizeOp{}, err
|
||
}
|
||
parsedDim, _, _, err := parseA1Range(rangeStr)
|
||
if err != nil {
|
||
want := "row numbers like 2:10"
|
||
if dimension == "column" {
|
||
want = "column letters like A:E"
|
||
}
|
||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.range %q must use %s: %v", path, rangeStr, want, err)
|
||
}
|
||
if parsedDim != dimension {
|
||
want := "row numbers like 2:10"
|
||
if dimension == "column" {
|
||
want = "column letters like A:E"
|
||
}
|
||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.range %q must use %s", path, rangeStr, want)
|
||
}
|
||
typeHint := "pixel/standard"
|
||
if dimension == "row" {
|
||
typeHint = "pixel/standard/auto"
|
||
}
|
||
resizeType, _ := op["type"].(string)
|
||
resizeType = strings.TrimSpace(resizeType)
|
||
if resizeType != "" {
|
||
if dimension == "column" && resizeType == "auto" {
|
||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type auto is rows-only", path)
|
||
}
|
||
switch resizeType {
|
||
case "pixel", "standard", "auto":
|
||
default:
|
||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type %q is invalid (want %s), e.g. %s", path, resizeType, typeHint, resizeOpExample(dimension))
|
||
}
|
||
}
|
||
// size is the canonical dimension key (uniform across row_sizes and
|
||
// col_sizes — the array name already carries the dimension). The Excel-
|
||
// vocabulary alias (height on rows, width on columns) is accepted
|
||
// silently; the WRONG dimension's word is a targeted error, never a
|
||
// silent rewrite.
|
||
alias, wrongDim := "height", "width"
|
||
if dimension == "column" {
|
||
alias, wrongDim = "width", "height"
|
||
}
|
||
if _, has := op[wrongDim]; has {
|
||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.%s does not apply to this array (the array name carries the dimension); use size, e.g. %s", path, wrongDim, resizeOpExample(dimension))
|
||
}
|
||
sizeRaw, hasSize := op["size"]
|
||
if aliasRaw, hasAlias := op[alias]; hasAlias {
|
||
if hasSize {
|
||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s: give either size or %s, not both", path, alias)
|
||
}
|
||
sizeRaw, hasSize = aliasRaw, true
|
||
}
|
||
size := 0
|
||
if hasSize {
|
||
n, ok := util.ToFloat64(sizeRaw)
|
||
if !ok || n <= 0 {
|
||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.size must be a positive number", path)
|
||
}
|
||
size = int(n)
|
||
}
|
||
// type is optional ceremony when a pixel size is given: {range, size}
|
||
// means a pixel resize, exactly as --width/--height without --type does
|
||
// on the flag path. Explicit standard/auto still needs type.
|
||
if resizeType == "" {
|
||
if size <= 0 {
|
||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s needs size (px) or type (%s), e.g. %s", path, typeHint, resizeOpExample(dimension))
|
||
}
|
||
resizeType = "pixel"
|
||
}
|
||
if resizeType == "pixel" && size <= 0 {
|
||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type pixel requires size, e.g. %s", path, resizeOpExample(dimension))
|
||
}
|
||
if resizeType != "pixel" && size > 0 {
|
||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.size is only valid with type pixel", path)
|
||
}
|
||
if err := rejectUnexpectedWorkbookStyleFields(op, path, "range", "type", "size", alias); err != nil {
|
||
return workbookCreateResizeOp{}, err
|
||
}
|
||
return workbookCreateResizeOp{Range: normalizeWorkbookResizeRange(rangeStr), ResizeType: resizeType, Size: size}, nil
|
||
}
|
||
|
||
func requireWorkbookCreateRange(op map[string]interface{}, path string) (string, error) {
|
||
rangeRaw, ok := op["range"]
|
||
if !ok {
|
||
return "", common.ValidationErrorf("%s.range is required", path)
|
||
}
|
||
rangeStr, ok := rangeRaw.(string)
|
||
if !ok || strings.TrimSpace(rangeStr) == "" {
|
||
return "", common.ValidationErrorf("%s.range must be a non-empty string", path)
|
||
}
|
||
return strings.TrimSpace(rangeStr), nil
|
||
}
|
||
|
||
func rejectUnexpectedWorkbookStyleFields(op map[string]interface{}, path string, allowed ...string) error {
|
||
allow := map[string]struct{}{}
|
||
for _, k := range allowed {
|
||
allow[k] = struct{}{}
|
||
}
|
||
for k := range op {
|
||
if _, ok := allow[k]; !ok {
|
||
return common.ValidationErrorf("%s.%s is not valid here", path, k)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func normalizeWorkbookResizeRange(rangeStr string) string {
|
||
rangeStr = strings.TrimSpace(rangeStr)
|
||
if !strings.Contains(rangeStr, ":") {
|
||
return rangeStr + ":" + rangeStr
|
||
}
|
||
return rangeStr
|
||
}
|
||
|
||
func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string) (map[string]interface{}, error) {
|
||
if len(in) == 0 {
|
||
return nil, nil
|
||
}
|
||
if err := foldBorderFamilyAliases(in, path); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := normalizeCellStyleAliases(in, path); err != nil {
|
||
return nil, err
|
||
}
|
||
out := map[string]interface{}{}
|
||
cellStyle := map[string]interface{}{}
|
||
for k, v := range in {
|
||
switch k {
|
||
case "cell_styles":
|
||
return nil, common.ValidationErrorf("%s.cell_styles is not supported inside cell_styles[]; put style fields directly on the item", path)
|
||
case "border_styles":
|
||
m, ok := v.(map[string]interface{})
|
||
if !ok {
|
||
return nil, common.ValidationErrorf("%s.border_styles must be a JSON object", path)
|
||
}
|
||
expandBorderAllShorthand(m)
|
||
if err := validateWorkbookBorderStyles(m, path); err != nil {
|
||
return nil, err
|
||
}
|
||
out["border_styles"] = m
|
||
case "value", "formula", "rich_text", "multiple_values", "note", "data_validation":
|
||
return nil, common.ValidationErrorf("%s.%s is a content field — a styles spec carries no cell content; write values/formulas via +cells-set or +table-put", path, k)
|
||
default:
|
||
if !workbookCreateCellStyleField(k) {
|
||
// Universal rejection with the full field list: this is the
|
||
// mechanism that absorbs the infinite tail of spelling
|
||
// permutations at a fixed one-retry cost — silent aliases are
|
||
// reserved for high-frequency words from real external
|
||
// vocabularies (see the style_vocab.go contract). A curated
|
||
// prescription wins over did-you-mean; without one, the
|
||
// distance match must be a near-typo (≤2 edits) — a
|
||
// concept-swap neighbor (font_bold → font_color, distance 3)
|
||
// misleads worse than silence.
|
||
msg := fmt.Sprintf("%s.%s is not a supported style field", path, k)
|
||
lower := strings.ToLower(k)
|
||
if rx, ok := styleFieldPrescriptions[lower]; ok {
|
||
msg += " — " + rx
|
||
} else if match := suggest.Closest(lower, workbookCreateCellStyleFieldList, 1); len(match) > 0 && suggest.Levenshtein(lower, match[0]) <= 2 {
|
||
msg += fmt.Sprintf(" — did you mean %q?", match[0])
|
||
}
|
||
msg += "; supported: " + strings.Join(workbookCreateCellStyleFieldList, ", ")
|
||
return nil, common.ValidationErrorf("%s", msg)
|
||
}
|
||
cellStyle[k] = v
|
||
}
|
||
}
|
||
if len(cellStyle) > 0 {
|
||
out["cell_styles"] = cellStyle
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// workbookCreateCellStyleFieldList is what a caller may WRITE in a cell_styles
|
||
// item, in display order for the unknown-field hint — the canonical scalar
|
||
// vocabulary (workbookCreateCellStyleField) plus the two border carriers.
|
||
// "border" is the documented four-sides shorthand rather than a field the
|
||
// switch above ever sees: foldBorderFamilyAliases folds it into border_styles
|
||
// first. It belongs in this list because the list answers "what may I write",
|
||
// not "what survives normalization".
|
||
var workbookCreateCellStyleFieldList = []string{
|
||
"font_color", "font_family", "font_size", "font_weight", "font_style", "font_line",
|
||
"background_color", "horizontal_alignment", "vertical_alignment",
|
||
"number_format", "word_wrap", "border", "border_styles",
|
||
}
|
||
|
||
func workbookCreateCellStyleField(name string) bool {
|
||
switch name {
|
||
case "font_color", "font_family", "font_size", "font_weight", "font_style", "font_line",
|
||
"background_color", "horizontal_alignment", "vertical_alignment",
|
||
"number_format", "word_wrap":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// validateWorkbookBorderStyles checks a border_styles object's internal shape
|
||
// (per-side style/weight enums + color) at parse time. --styles is on
|
||
// parseJSONFlagSkip so it bypasses the generic schema validator; this keeps
|
||
// border errors caught in the CLI (mirroring +cells-set-style) rather than being
|
||
// passed straight through to the backend.
|
||
func validateWorkbookBorderStyles(m map[string]interface{}, path string) error {
|
||
for side, raw := range m {
|
||
switch side {
|
||
case "top", "bottom", "left", "right":
|
||
default:
|
||
return common.ValidationErrorf("%s.border_styles.%s is not a valid side (want top/bottom/left/right; a horizontal line is the top/bottom side of its range, a vertical line is left/right)", path, side)
|
||
}
|
||
spec, ok := raw.(map[string]interface{})
|
||
if !ok {
|
||
return common.ValidationErrorf("%s.border_styles.%s must be a JSON object", path, side)
|
||
}
|
||
for k, v := range spec {
|
||
switch k {
|
||
case "style":
|
||
if s, _ := v.(string); !workbookBorderStyleEnum(s) {
|
||
return common.ValidationErrorf("%s.border_styles.%s.style %q is invalid (want solid/dashed/dotted/double/none)", path, side, s)
|
||
}
|
||
case "weight":
|
||
if w, _ := v.(string); w != "thin" && w != "medium" && w != "thick" {
|
||
return common.ValidationErrorf("%s.border_styles.%s.weight %q is invalid (want thin/medium/thick)", path, side, w)
|
||
}
|
||
case "color":
|
||
if _, ok := v.(string); !ok {
|
||
return common.ValidationErrorf("%s.border_styles.%s.color must be a string", path, side)
|
||
}
|
||
default:
|
||
return common.ValidationErrorf("%s.border_styles.%s.%s is not valid (want style/weight/color)", path, side, k)
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func workbookBorderStyleEnum(s string) bool {
|
||
switch s {
|
||
case "solid", "dashed", "dotted", "double", "none":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func workbookCreateStyleDimensions(styles *workbookCreateStylePayload, baseCol, baseRow int) (rows, cols int) {
|
||
if styles == nil {
|
||
return 0, 0
|
||
}
|
||
expandCellRange := func(rng string) {
|
||
startCol, startRow, endCol, endRow, err := workbookCreateStyleRangeBounds(rng)
|
||
if err != nil {
|
||
return
|
||
}
|
||
if startCol < baseCol || startRow < baseRow {
|
||
return
|
||
}
|
||
if endCol-baseCol+1 > cols {
|
||
cols = endCol - baseCol + 1
|
||
}
|
||
if endRow-baseRow+1 > rows {
|
||
rows = endRow - baseRow + 1
|
||
}
|
||
}
|
||
expandRowRange := func(rng string) {
|
||
dim, _, endIdx, err := parseA1Range(rng)
|
||
if err != nil || dim != "row" || endIdx < baseRow {
|
||
return
|
||
}
|
||
if endIdx-baseRow+1 > rows {
|
||
rows = endIdx - baseRow + 1
|
||
}
|
||
}
|
||
expandColRange := func(rng string) {
|
||
dim, _, endIdx, err := parseA1Range(rng)
|
||
if err != nil || dim != "column" || endIdx < baseCol {
|
||
return
|
||
}
|
||
if endIdx-baseCol+1 > cols {
|
||
cols = endIdx - baseCol + 1
|
||
}
|
||
}
|
||
for _, op := range styles.CellStyles {
|
||
expandCellRange(op.Range)
|
||
}
|
||
// cell_merges / row_sizes / col_sizes also contribute to the write extent —
|
||
// without this, a style-only payload (e.g. just cell_merges) would compute
|
||
// extent 0 and the Execute path would skip writeTypedSheets entirely,
|
||
// silently dropping the visual ops.
|
||
for _, op := range styles.CellMerges {
|
||
expandCellRange(op.Range)
|
||
}
|
||
for _, op := range styles.RowSizes {
|
||
expandRowRange(op.Range)
|
||
}
|
||
for _, op := range styles.ColSizes {
|
||
expandColRange(op.Range)
|
||
}
|
||
return rows, cols
|
||
}
|
||
|
||
// matrixDimensionsForStyles projects the padded matrix size without allocating
|
||
// it. Only cell_styles contribute; merges and row/column sizes use separate API
|
||
// calls. Ranges up/left of the write anchor are left for the caller to reject.
|
||
func matrixDimensionsForStyles(rows, cols int, styles *workbookCreateStylePayload, baseCol, baseRow int) (int, int) {
|
||
if styles == nil {
|
||
return rows, cols
|
||
}
|
||
for _, op := range styles.CellStyles {
|
||
startCol, startRow, endCol, endRow, err := workbookCreateStyleRangeBounds(op.Range)
|
||
if err != nil || startCol < baseCol || startRow < baseRow {
|
||
continue // unparsable, or up/left of the anchor: not paddable
|
||
}
|
||
if endCol-baseCol+1 > cols {
|
||
cols = endCol - baseCol + 1
|
||
}
|
||
if endRow-baseRow+1 > rows {
|
||
rows = endRow - baseRow + 1
|
||
}
|
||
}
|
||
return rows, cols
|
||
}
|
||
|
||
// padMatrixForStyles grows the matrix down and right to the projected style
|
||
// extent, appending empty cells that cell_styles can mutate in place.
|
||
func padMatrixForStyles(rows [][]interface{}, styles *workbookCreateStylePayload, baseCol, baseRow int) [][]interface{} {
|
||
needCols := 0
|
||
if len(rows) > 0 {
|
||
needCols = len(rows[0])
|
||
}
|
||
needRows, needCols := matrixDimensionsForStyles(len(rows), needCols, styles, baseCol, baseRow)
|
||
// Widen existing rows to needCols.
|
||
for r := range rows {
|
||
for len(rows[r]) < needCols {
|
||
rows[r] = append(rows[r], map[string]interface{}{})
|
||
}
|
||
}
|
||
// Append full empty rows to reach needRows.
|
||
for len(rows) < needRows {
|
||
row := make([]interface{}, needCols)
|
||
for c := range row {
|
||
row[c] = map[string]interface{}{}
|
||
}
|
||
rows = append(rows, row)
|
||
}
|
||
return rows
|
||
}
|
||
|
||
// checkStylesAnchors rejects cell_styles ranges whose top-left falls left of /
|
||
// above the sheet's write anchor — matrix padding cannot reach them, so the
|
||
// write phase would fail. Validate-time twin of the bounds check inside
|
||
// applyWorkbookCreateStylesToMatrix: running it BEFORE the workbook-create API
|
||
// call means the failure cannot strand an orphan workbook (live-verified: a
|
||
// start_cell B2 payload with a cell_styles range A1 used to create the
|
||
// workbook and then fail the fill). Anchor / range parse errors are skipped —
|
||
// the payload and styles parsers already report those with richer context.
|
||
//
|
||
// For append mode against a sheet that may already hold data, only the COLUMN
|
||
// is checked at Validate time: the contract ignores start_cell's row (the base
|
||
// row is resolved from the sheet's existing data at execute time), so
|
||
// comparing style rows against the ignored static row misfires — data ending
|
||
// at row 5 with start_cell B10 appends at row 6, making a B6 style legal even
|
||
// though it sits "above B10". newSheets says every payload sheet writes into a
|
||
// KNOWN-EMPTY sheet (+workbook-create: the workbook is being created), where
|
||
// append resolves its base row to the static anchor — the row check then
|
||
// applies to append too. For +table-put (newSheets false) the missing-target
|
||
// case is covered pre-creation by checkSheetStyleAnchors in writeTypedSheets;
|
||
// existing sheets keep the write-phase check with the real base row.
|
||
func checkStylesAnchors(payload *tablePayload, styles *workbookCreateSheetStyles, newSheets bool) error {
|
||
if payload == nil || styles == nil {
|
||
return nil
|
||
}
|
||
for i := range payload.Sheets {
|
||
s := &payload.Sheets[i]
|
||
checkRow := newSheets || s.Mode != "append"
|
||
if err := checkSheetStyleAnchors(s, styles.styleFor(i), checkRow); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// checkSheetStyleAnchors is the per-sheet core of checkStylesAnchors. It is
|
||
// also called by writeTypedSheets (with checkRow true) right before CREATING a
|
||
// missing target sheet: the fresh sheet is empty, so even append resolves its
|
||
// base row to the static anchor — checking first keeps a bad style range from
|
||
// stranding a newly created empty sheet behind a "no sheets were written"
|
||
// failure.
|
||
func checkSheetStyleAnchors(s *tableSheetSpec, sp *workbookCreateStylePayload, checkRow bool) error {
|
||
if sp == nil {
|
||
return nil
|
||
}
|
||
_, col0, row0, err := sheetAnchor(s)
|
||
if err != nil {
|
||
return nil //nolint:nilerr // a malformed start_cell is the payload parser's to report with row/column context; this check only compares bounds
|
||
}
|
||
for j, op := range sp.CellStyles {
|
||
startCol, startRow, _, _, err := workbookCreateStyleRangeBounds(op.Range)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if startCol < col0 {
|
||
return common.ValidationErrorf("--styles for sheet %q[%d].range %q starts left of the write range (its column must be at or after %s)",
|
||
s.Name, j, op.Range, columnIndexToLetter(col0))
|
||
}
|
||
if checkRow && startRow < row0 {
|
||
return common.ValidationErrorf("--styles for sheet %q[%d].range %q starts outside the write range (its top-left must be at or after %s%d)",
|
||
s.Name, j, op.Range, columnIndexToLetter(col0), row0+1)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// applyWorkbookCreateStylesToMatrix pads the matrix to cover the cell_styles
|
||
// ranges (see padMatrixForStyles), merges each op's style into the covered
|
||
// cells, and returns the padded matrix. A range that starts left of / above the
|
||
// write anchor can't be padded to and is rejected.
|
||
func applyWorkbookCreateStylesToMatrix(rows [][]interface{}, styles *workbookCreateStylePayload, baseCol, baseRow int, label string) ([][]interface{}, error) {
|
||
if styles == nil {
|
||
return rows, nil
|
||
}
|
||
rows = padMatrixForStyles(rows, styles, baseCol, baseRow)
|
||
for i, op := range styles.CellStyles {
|
||
startCol, startRow, endCol, endRow, err := workbookCreateStyleRangeBounds(op.Range)
|
||
if err != nil {
|
||
return rows, common.ValidationErrorf("%s[%d].range %q: %v", label, i, op.Range, err)
|
||
}
|
||
// After padding, the matrix reaches every range that starts at or after
|
||
// the anchor; a start left of / above it can't be covered. The endRow /
|
||
// endCol checks stay as a defensive backstop (padding should have made
|
||
// them unreachable).
|
||
if startCol < baseCol || startRow < baseRow || len(rows) == 0 ||
|
||
endRow-baseRow >= len(rows) || endCol-baseCol >= len(rows[0]) {
|
||
return rows, common.ValidationErrorf("%s[%d].range %q starts outside the write range (its top-left must be at or after %s%d)",
|
||
label, i, op.Range,
|
||
columnIndexToLetter(baseCol), baseRow+1)
|
||
}
|
||
for r := startRow - baseRow; r <= endRow-baseRow; r++ {
|
||
for c := startCol - baseCol; c <= endCol-baseCol; c++ {
|
||
mergeWorkbookCreateStyle(rows[r][c], op.Style)
|
||
}
|
||
}
|
||
}
|
||
return rows, nil
|
||
}
|
||
|
||
func appendWorkbookCreateVisualOpsDryRun(dry *common.DryRunAPI, token, sheetID, sheetName string, styles *workbookCreateStylePayload) {
|
||
if dry == nil || styles == nil {
|
||
return
|
||
}
|
||
for _, op := range workbookCreateVisualOps(styles) {
|
||
input, toolName := workbookCreateVisualOpInput(token, sheetID, sheetName, op)
|
||
if toolName == "" {
|
||
continue
|
||
}
|
||
wireBody, _ := buildToolBody(toolName, input)
|
||
dry.POST(toolInvokePath(token, ToolKindWrite)).
|
||
Desc(fmt.Sprintf("apply %s", op.describe())).
|
||
Body(wireBody)
|
||
}
|
||
}
|
||
|
||
func applyWorkbookCreateVisualOps(ctx context.Context, runtime *common.RuntimeContext, token, sheetID string, styles *workbookCreateStylePayload) error {
|
||
if styles == nil {
|
||
return nil
|
||
}
|
||
for _, op := range workbookCreateVisualOps(styles) {
|
||
input, toolName := workbookCreateVisualOpInput(token, sheetID, "", op)
|
||
if toolName == "" {
|
||
continue
|
||
}
|
||
if _, err := callTool(ctx, runtime, token, ToolKindWrite, toolName, input); err != nil {
|
||
// callTool already returns a typed error; pass it through unchanged
|
||
// (re-wrapping would downgrade its classification) and attach the
|
||
// failing op as a recovery hint when one isn't already set.
|
||
if p, ok := errs.ProblemOf(err); ok {
|
||
if p.Hint == "" {
|
||
p.Hint = fmt.Sprintf("failed while applying %s", op.describe())
|
||
}
|
||
return err
|
||
}
|
||
return errs.NewInternalError(errs.SubtypeUnknown, "%s failed", op.describe()).WithCause(err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func workbookCreateVisualOps(styles *workbookCreateStylePayload) []workbookCreateStyleOp {
|
||
if styles == nil {
|
||
return nil
|
||
}
|
||
ops := make([]workbookCreateStyleOp, 0, len(styles.CellMerges)+len(styles.RowSizes)+len(styles.ColSizes)+2)
|
||
for _, op := range styles.CellMerges {
|
||
ops = append(ops, workbookCreateStyleOp{Kind: "cell_merge", Range: op.Range, MergeType: op.MergeType})
|
||
}
|
||
for _, op := range styles.RowSizes {
|
||
ops = append(ops, workbookCreateStyleOp{Kind: "row_size", Range: op.Range, ResizeType: op.ResizeType, Size: op.Size})
|
||
}
|
||
for _, op := range styles.ColSizes {
|
||
ops = append(ops, workbookCreateStyleOp{Kind: "col_size", Range: op.Range, ResizeType: op.ResizeType, Size: op.Size})
|
||
}
|
||
if styles.Freeze != nil {
|
||
ops = append(ops, workbookCreateStyleOp{Kind: "freeze", FreezeRows: styles.Freeze.Rows, FreezeCols: styles.Freeze.Cols})
|
||
}
|
||
return ops
|
||
}
|
||
|
||
type workbookCreateStyleOp struct {
|
||
Kind string
|
||
Range string
|
||
MergeType string
|
||
ResizeType string
|
||
Size int
|
||
FreezeRows int
|
||
FreezeCols int
|
||
}
|
||
|
||
// describe renders the op for dry-run text and failure hints. freeze carries
|
||
// counts instead of a range, so "%s %s" of kind and range would trail a blank.
|
||
func (op workbookCreateStyleOp) describe() string {
|
||
if op.Kind != "freeze" {
|
||
return op.Kind + " " + op.Range
|
||
}
|
||
if op.FreezeRows == 0 && op.FreezeCols == 0 {
|
||
return "unfreeze"
|
||
}
|
||
parts := make([]string, 0, 2)
|
||
if op.FreezeRows > 0 {
|
||
parts = append(parts, fmt.Sprintf("rows=%d", op.FreezeRows))
|
||
}
|
||
if op.FreezeCols > 0 {
|
||
parts = append(parts, fmt.Sprintf("cols=%d", op.FreezeCols))
|
||
}
|
||
return "freeze " + strings.Join(parts, " ")
|
||
}
|
||
|
||
func workbookCreateVisualOpInput(token, sheetID, sheetName string, op workbookCreateStyleOp) (map[string]interface{}, string) {
|
||
// Every caller names the sheet through the selector, so a "Sheet!" prefix
|
||
// left on the range would be a duplicate the backend range parser rejects.
|
||
op.Range = stripSheetPrefix(op.Range)
|
||
switch op.Kind {
|
||
case "cell_merge":
|
||
input := map[string]interface{}{
|
||
"excel_id": token,
|
||
"range": op.Range,
|
||
"operation": "merge",
|
||
"merge_type": op.MergeType,
|
||
}
|
||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||
return input, "merge_cells"
|
||
case "row_size", "col_size":
|
||
input := map[string]interface{}{
|
||
"excel_id": token,
|
||
"range": op.Range,
|
||
}
|
||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||
block := map[string]interface{}{"type": op.ResizeType}
|
||
if op.ResizeType == "pixel" {
|
||
block["value"] = op.Size
|
||
}
|
||
if op.Kind == "row_size" {
|
||
input["resize_height"] = block
|
||
} else {
|
||
input["resize_width"] = block
|
||
}
|
||
return input, "resize_range"
|
||
case "freeze":
|
||
// Both axes travel in ONE operation because the backend treats freeze as
|
||
// full-state replacement, not a per-axis patch: verified 07-31 on a live
|
||
// sheet — freezing 1 row then 2 columns in two calls ends at
|
||
// frozen_row_count 0 / frozen_column_count 2, the second call having
|
||
// silently dropped the first axis. One call carrying both lands 1/2.
|
||
// By the same rule an omitted axis is unfrozen, which is what a
|
||
// declarative --styles spec should mean. An all-zero target is the bare
|
||
// "unfreeze" operation, which carries no counts and clears everything —
|
||
// the same request +dim-freeze --rows 0 --cols 0 sends.
|
||
input := map[string]interface{}{
|
||
"excel_id": token,
|
||
"operation": "unfreeze",
|
||
}
|
||
if op.FreezeRows > 0 || op.FreezeCols > 0 {
|
||
input["operation"] = "freeze"
|
||
}
|
||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||
if op.FreezeRows > 0 {
|
||
input["freeze_rows"] = op.FreezeRows
|
||
}
|
||
if op.FreezeCols > 0 {
|
||
input["freeze_columns"] = op.FreezeCols
|
||
}
|
||
return input, "modify_sheet_structure"
|
||
default:
|
||
return nil, ""
|
||
}
|
||
}
|
||
|
||
func workbookCreateStyleRangeBounds(rangeStr string) (startCol, startRow, endCol, endRow int, err error) {
|
||
if idx := strings.Index(rangeStr, "!"); idx >= 0 {
|
||
rangeStr = rangeStr[idx+1:]
|
||
}
|
||
rangeStr = strings.TrimSpace(rangeStr)
|
||
if rangeStr == "" {
|
||
return 0, 0, 0, 0, fmt.Errorf("empty range") //nolint:forbidigo // intermediate error; callers wrap it into a typed validation error with flag/param context
|
||
}
|
||
parts := strings.SplitN(rangeStr, ":", 2)
|
||
if len(parts) == 1 {
|
||
col, row, ok := splitCellRef(parts[0])
|
||
if !ok {
|
||
return 0, 0, 0, 0, fmt.Errorf("invalid cell ref %q", parts[0]) //nolint:forbidigo // intermediate error; callers wrap it into a typed validation error with flag/param context
|
||
}
|
||
return col, row, col, row, nil
|
||
}
|
||
startCol, startRow, ok1 := splitCellRef(parts[0])
|
||
endCol, endRow, ok2 := splitCellRef(parts[1])
|
||
if !ok1 || !ok2 {
|
||
return 0, 0, 0, 0, fmt.Errorf("unsupported range form %q (need rectangular A1:B2)", rangeStr) //nolint:forbidigo // intermediate error; callers wrap it into a typed validation error with flag/param context
|
||
}
|
||
if endRow < startRow || endCol < startCol {
|
||
return 0, 0, 0, 0, fmt.Errorf("end %q must be at or after start %q", parts[1], parts[0]) //nolint:forbidigo // intermediate error; callers wrap it into a typed validation error with flag/param context
|
||
}
|
||
return startCol, startRow, endCol, endRow, nil
|
||
}
|
||
|
||
// mergeWorkbookCreateStyle merges one cell_styles op's style map into a cell.
|
||
// cell_styles / border_styles are nested submaps: they are deep-merged one level
|
||
// (field-wise, last write wins) so overlapping cell_styles ops accumulate fields
|
||
// rather than the later op's submap wholesale-replacing the earlier one. A fresh
|
||
// submap is allocated each merge so the op.Style shared across the range's cells
|
||
// is never mutated.
|
||
func mergeWorkbookCreateStyle(cell interface{}, style map[string]interface{}) {
|
||
if len(style) == 0 {
|
||
return
|
||
}
|
||
m, ok := cell.(map[string]interface{})
|
||
if !ok {
|
||
return
|
||
}
|
||
for k, v := range style {
|
||
if k == "cell_styles" || k == "border_styles" {
|
||
if incoming, ok := v.(map[string]interface{}); ok {
|
||
merged := map[string]interface{}{}
|
||
if existing, ok := m[k].(map[string]interface{}); ok {
|
||
for sk, sv := range existing {
|
||
merged[sk] = sv
|
||
}
|
||
}
|
||
for sk, sv := range incoming {
|
||
merged[sk] = sv
|
||
}
|
||
m[k] = merged
|
||
continue
|
||
}
|
||
}
|
||
m[k] = v
|
||
}
|
||
}
|
||
|
||
// ─── +workbook-export (legacy OAPI, cli_status: cli-only) ────────────
|
||
//
|
||
// Drives the three-step export flow against the classic drive endpoints:
|
||
// create export task → poll task status → optional binary download.
|
||
// Not exposed as an MCP tool.
|
||
|
||
// WorkbookExport drives the three-step export flow: create task → poll →
|
||
// optionally download. CSV mode requires --sheet-id (the API exports one
|
||
// sheet at a time as csv).
|
||
var WorkbookExport = common.Shortcut{
|
||
Service: "sheets",
|
||
Command: "+workbook-export",
|
||
Description: "Export a spreadsheet to xlsx or a single sheet to csv (async + poll + optional download).",
|
||
Risk: "read",
|
||
Scopes: []string{"sheets:spreadsheet:read", "docs:document:export", "drive:drive.metadata:readonly"},
|
||
AuthTypes: []string{"user", "bot"},
|
||
HasFormat: true,
|
||
Flags: flagsFor("+workbook-export"),
|
||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||
return err
|
||
}
|
||
ext := runtime.Str("file-extension")
|
||
if ext == "" {
|
||
ext = "xlsx"
|
||
}
|
||
if ext == "csv" && strings.TrimSpace(runtime.Str("sheet-id")) == "" {
|
||
return common.ValidationErrorf("--sheet-id is required when --file-extension=csv")
|
||
}
|
||
return nil
|
||
},
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
p, _ := workbookExportParams(runtime)
|
||
p.OutputDir = strings.TrimSpace(runtime.Str("output-path"))
|
||
return drive.PlanExportDryRun(runtime, p)
|
||
},
|
||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
p, err := workbookExportParams(runtime)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// workbookExportParams resolves --url network-free (DryRun shares it); a
|
||
// /wiki/ URL carries a node_token that needs the get_node step only
|
||
// Execute may take, so re-resolve the token here.
|
||
if p.Token, err = resolveSpreadsheetTokenExec(runtime); err != nil {
|
||
return err
|
||
}
|
||
applyWorkbookOutputPath(&p, runtime.FileIO(), runtime.Str("output-path"))
|
||
return drive.RunExport(ctx, runtime, p)
|
||
},
|
||
Tips: []string{
|
||
"Polls for a bounded window; if the export is still running it returns a resume reference instead of blocking. Pass --output-path to download the file once ready (omit it to only create the export task and get the file token back).",
|
||
},
|
||
}
|
||
|
||
// workbookExportParams builds the shared drive export request for
|
||
// +workbook-export: spreadsheet token + sheet locator, pinned to type=sheet.
|
||
// workbook-export has always overwritten the target, so Overwrite is set. The
|
||
// --output-path → OutputDir/FileName split (which needs a Stat) is applied
|
||
// separately by applyWorkbookOutputPath so Validate/DryRun stay I/O-free.
|
||
func workbookExportParams(runtime *common.RuntimeContext) (drive.ExportParams, error) {
|
||
token, err := resolveSpreadsheetToken(runtime)
|
||
if err != nil {
|
||
return drive.ExportParams{}, err
|
||
}
|
||
ext := runtime.Str("file-extension")
|
||
if ext == "" {
|
||
ext = "xlsx"
|
||
}
|
||
return drive.ExportParams{
|
||
Token: token,
|
||
DocType: "sheet",
|
||
FileExtension: ext,
|
||
SubID: strings.TrimSpace(runtime.Str("sheet-id")),
|
||
Overwrite: true,
|
||
}, nil
|
||
}
|
||
|
||
// applyWorkbookOutputPath maps the single --output-path flag onto the drive
|
||
// export OutputDir/FileName pair, preserving the legacy behavior: empty = no
|
||
// download (return the ready file token only); an existing directory = download
|
||
// into it under the server-provided name; otherwise treat it as a file path and
|
||
// split into dir + base name.
|
||
func applyWorkbookOutputPath(p *drive.ExportParams, fio fileio.FileIO, outputPath string) {
|
||
outputPath = strings.TrimSpace(outputPath)
|
||
if outputPath == "" {
|
||
return
|
||
}
|
||
if info, err := fio.Stat(outputPath); err == nil && info.IsDir() {
|
||
p.OutputDir = outputPath
|
||
return
|
||
}
|
||
p.OutputDir = filepath.Dir(outputPath)
|
||
p.FileName = filepath.Base(outputPath)
|
||
}
|
||
|
||
// lookupSheetIndex finds a sub-sheet by id or name and returns its canonical
|
||
// id + current 0-based index. Caller is responsible for ensuring at least one
|
||
// of sheetID/sheetName is non-empty.
|
||
func lookupSheetIndex(ctx context.Context, runtime *common.RuntimeContext, token, sheetID, sheetName string) (resolvedID string, index int, err error) {
|
||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_workbook_structure", map[string]interface{}{
|
||
"excel_id": token,
|
||
})
|
||
if err != nil {
|
||
return "", 0, err
|
||
}
|
||
m, ok := out.(map[string]interface{})
|
||
if !ok {
|
||
return "", 0, errs.NewInternalError(errs.SubtypeInvalidResponse, "get_workbook_structure returned non-object output")
|
||
}
|
||
sheets, _ := m["sheets"].([]interface{})
|
||
for _, raw := range sheets {
|
||
sm, ok := raw.(map[string]interface{})
|
||
if !ok {
|
||
continue
|
||
}
|
||
id, _ := sm["sheet_id"].(string)
|
||
// get_workbook_structure surfaces the sub-sheet's display name as
|
||
// "title"; older/alt payloads use "sheet_name". Match either so a
|
||
// --sheet-name lookup resolves regardless of the field name.
|
||
name, _ := sm["sheet_name"].(string)
|
||
if name == "" {
|
||
name, _ = sm["title"].(string)
|
||
}
|
||
if (sheetID != "" && id == sheetID) || (sheetName != "" && name == sheetName) {
|
||
idx, ok := util.ToFloat64(sm["index"])
|
||
if !ok {
|
||
return "", 0, errs.NewInternalError(errs.SubtypeInvalidResponse, "sheet entry missing index field")
|
||
}
|
||
return id, int(idx), nil
|
||
}
|
||
}
|
||
target := sheetID
|
||
if target == "" {
|
||
target = sheetName
|
||
}
|
||
return "", 0, errs.NewValidationError(errs.SubtypeFailedPrecondition, "sheet %q not found in workbook", target)
|
||
}
|
||
|
||
// lookupFirstSheetID returns the sheet_id of the sub-sheet at index 0 (the
|
||
// default sheet of a freshly created workbook). Used by +workbook-create to
|
||
// target the initial-fill set_cell_range write — set_cell_range rejects an
|
||
// empty sheet selector ("sheet_id or sheet_name is required"), and the v3
|
||
// create-spreadsheet response does not echo the default sheet's id.
|
||
func lookupFirstSheetID(ctx context.Context, runtime *common.RuntimeContext, token string) (string, error) {
|
||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_workbook_structure", map[string]interface{}{
|
||
"excel_id": token,
|
||
})
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
m, ok := out.(map[string]interface{})
|
||
if !ok {
|
||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "get_workbook_structure returned non-object output")
|
||
}
|
||
sheets, _ := m["sheets"].([]interface{})
|
||
bestID := ""
|
||
bestIdx := -1
|
||
for _, raw := range sheets {
|
||
sm, ok := raw.(map[string]interface{})
|
||
if !ok {
|
||
continue
|
||
}
|
||
id, _ := sm["sheet_id"].(string)
|
||
if id == "" {
|
||
continue
|
||
}
|
||
idx, ok := util.ToFloat64(sm["index"])
|
||
if !ok {
|
||
// No index field — fall back to first encountered sheet.
|
||
if bestID == "" {
|
||
bestID = id
|
||
}
|
||
continue
|
||
}
|
||
if bestIdx < 0 || int(idx) < bestIdx {
|
||
bestIdx = int(idx)
|
||
bestID = id
|
||
}
|
||
}
|
||
if bestID == "" {
|
||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "get_workbook_structure returned no sheets")
|
||
}
|
||
return bestID, nil
|
||
}
|
||
|
||
// ─── +workbook-import (reuses drive import core, cli_status: cli-only) ──
|
||
//
|
||
// Imports a local xlsx/xls/csv file as a brand-new spreadsheet. The full
|
||
// upload → create-task → poll flow is the shared drive import core
|
||
// (drive.RunImport); this shortcut only pins the target type to "sheet",
|
||
// omits the bitable-only --target-token, and — because spreadsheet source
|
||
// files are routinely misnamed (an .xlsx exported/renamed to .xls, etc.) —
|
||
// sniffs the file's real container so the drive import backend receives the
|
||
// true file_extension instead of failing with a cryptic
|
||
// "xml_version_not_support". Symmetric with +workbook-export. Not exposed as
|
||
// an MCP tool.
|
||
|
||
// WorkbookImport imports a local spreadsheet file as a new Feishu spreadsheet
|
||
// by delegating to the shared drive import core with type fixed to "sheet".
|
||
var WorkbookImport = common.Shortcut{
|
||
Service: "sheets",
|
||
Command: "+workbook-import",
|
||
Description: "Import a local xlsx/xls/csv file as a new spreadsheet (async + poll). Reuses the drive import core with type fixed to sheet.",
|
||
Risk: "write",
|
||
Scopes: []string{"docs:document.media:upload", "docs:document:import"},
|
||
AuthTypes: []string{"user", "bot"},
|
||
HasFormat: true,
|
||
Flags: flagsFor("+workbook-import"),
|
||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
params, err := workbookImportParams(runtime)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return drive.ValidateImport(params)
|
||
},
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
params, err := workbookImportParams(runtime)
|
||
if err != nil {
|
||
return common.NewDryRunAPI().Set("error", err.Error())
|
||
}
|
||
dry := drive.PlanImportDryRun(runtime, params)
|
||
if note := workbookImportMislabelNote(params); note != "" {
|
||
dry.Desc(note)
|
||
}
|
||
return dry
|
||
},
|
||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
params, err := workbookImportParams(runtime)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if note := workbookImportMislabelNote(params); note != "" {
|
||
fmt.Fprintln(runtime.IO().ErrOut, note)
|
||
}
|
||
return drive.RunImport(ctx, runtime, params)
|
||
},
|
||
}
|
||
|
||
// workbookImportParams builds the drive import request for +workbook-import,
|
||
// pinning DocType to "sheet". The bitable-only --target-token is intentionally
|
||
// not exposed here — use drive +import for non-sheet import targets. It also
|
||
// resolves a corrected file extension via content sniffing (see
|
||
// correctedWorkbookExtension) and surfaces it through ImportParams.FileExtension.
|
||
func workbookImportParams(runtime *common.RuntimeContext) (drive.ImportParams, error) {
|
||
file := runtime.Str("file")
|
||
params := drive.ImportParams{
|
||
File: file,
|
||
DocType: "sheet",
|
||
FolderToken: runtime.Str("folder-token"),
|
||
Name: runtime.Str("name"),
|
||
}
|
||
ext, err := correctedWorkbookExtension(runtime.FileIO(), file)
|
||
if err != nil {
|
||
return params, err
|
||
}
|
||
params.FileExtension = ext
|
||
return params, nil
|
||
}
|
||
|
||
// correctedWorkbookExtension returns an override extension when the file's
|
||
// declared .xls/.xlsx suffix disagrees with its real container, "" when the
|
||
// declared suffix is correct (or the extension is not in the Excel family, or
|
||
// the file cannot yet be read). A declared Excel file whose bytes match neither
|
||
// container yields a prescriptive validation error rather than deferring to the
|
||
// backend's opaque "xml_version_not_support".
|
||
func correctedWorkbookExtension(fio fileio.FileIO, filePath string) (string, error) {
|
||
declared := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".")
|
||
if declared != "xls" && declared != "xlsx" {
|
||
return "", nil
|
||
}
|
||
|
||
sniffed, ok := sniffWorkbookContainer(fio, filePath)
|
||
if !ok {
|
||
// Not readable here; let the drive core's stat/upload surface any error.
|
||
return "", nil
|
||
}
|
||
switch sniffed {
|
||
case declared:
|
||
return "", nil
|
||
case "xls", "xlsx":
|
||
return sniffed, nil
|
||
default:
|
||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||
"file %s has a .%s extension but its content is neither an OOXML (.xlsx) nor a legacy Excel (.xls) workbook; re-save it as a real .xlsx/.xls (or export to .csv) before importing",
|
||
filePath, declared).WithParam("--file")
|
||
}
|
||
}
|
||
|
||
// sniffWorkbookContainer inspects a file's leading magic bytes to tell an OOXML
|
||
// workbook (zip container -> .xlsx) apart from a legacy OLE2/BIFF workbook
|
||
// (compound document -> .xls). The second return value is false when the file
|
||
// cannot be read far enough to judge (open error or fewer than the four
|
||
// discriminating bytes). When true, the format is "xlsx", "xls", or "" (bytes
|
||
// matching neither container).
|
||
func sniffWorkbookContainer(fio fileio.FileIO, filePath string) (string, bool) {
|
||
f, err := fio.Open(filePath)
|
||
if err != nil {
|
||
return "", false
|
||
}
|
||
defer f.Close()
|
||
|
||
var head [8]byte
|
||
n, _ := io.ReadFull(f, head[:])
|
||
if n < 4 {
|
||
return "", false
|
||
}
|
||
switch {
|
||
case head[0] == 0x50 && head[1] == 0x4B: // "PK" -> ZIP, i.e. OOXML .xlsx
|
||
return "xlsx", true
|
||
case head[0] == 0xD0 && head[1] == 0xCF && head[2] == 0x11 && head[3] == 0xE0: // OLE2 compound doc -> legacy .xls
|
||
return "xls", true
|
||
}
|
||
return "", true
|
||
}
|
||
|
||
// workbookImportMislabelNote returns a user-facing note when content sniffing
|
||
// overrode the declared extension, or "" when no correction was applied.
|
||
func workbookImportMislabelNote(params drive.ImportParams) string {
|
||
declared := strings.TrimPrefix(strings.ToLower(filepath.Ext(params.File)), ".")
|
||
if params.FileExtension == "" || params.FileExtension == declared {
|
||
return ""
|
||
}
|
||
return fmt.Sprintf("Note: %s has a mislabeled .%s extension but is actually a .%s workbook; importing it as .%s.",
|
||
filepath.Base(params.File), declared, params.FileExtension, params.FileExtension)
|
||
}
|