项目文件夹

文件
wehub-resource-sync bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

1770 行
64 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"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")
}
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"),
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
}
_, err = parseWorkbookCreateSheetStyles(runtime, payload)
return err
}
// 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
}
if _, err := buildValuesPayload(runtime, sheetStyles); err != nil {
return err
}
return nil
},
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)
_ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
input := map[string]interface{}{
"excel_id": "<new-token>",
"sheet_name": s.Name,
"range": tablePutFullRange(s, len(matrix)),
"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), len(s.Columns))).
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)
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)
}
// 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
return &tablePayload{Sheets: []tableSheetSpec{{
Name: valuesSheetName,
Mode: "overwrite",
Header: &noHeader,
Columns: cols,
Rows: rows,
}}}, 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
}
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")
}
return parseWorkbookCreateStyleItem(items[0], "--styles.styles[0]")
}
// 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.
func parseWorkbookCreateSheetStyles(runtime flagView, payload *tablePayload) (*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))
for i, item := range items {
name, _ := item["name"].(string)
if strings.TrimSpace(name) == "" {
return nil, common.ValidationErrorf("--styles.styles[%d].name is required", i)
}
if name != payload.Sheets[i].Name {
return nil, common.ValidationErrorf("--styles.styles[%d].name %q must match --sheets.sheets[%d].name %q", i, name, i, payload.Sheets[i].Name)
}
style, err := parseWorkbookCreateStyleItem(item, fmt.Sprintf("--styles.styles[%d]", i))
if err != nil {
return nil, err
}
out.ByIndex[i] = style
out.ByName[name] = style
}
return out, nil
}
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")
}
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
}
func parseWorkbookCreateStyleItem(item map[string]interface{}, path string) (*workbookCreateStylePayload, error) {
payload := &workbookCreateStylePayload{}
var err error
if raw, ok := item["cell_styles"]; ok {
payload.CellStyles, err = parseWorkbookCreateCellStyleOps(raw, path+".cell_styles")
if err != nil {
return nil, err
}
}
if raw, ok := item["row_sizes"]; ok {
payload.RowSizes, err = parseWorkbookCreateResizeOps(raw, path+".row_sizes", "row")
if err != nil {
return nil, err
}
}
if raw, ok := item["col_sizes"]; ok {
payload.ColSizes, err = parseWorkbookCreateResizeOps(raw, path+".col_sizes", "column")
if err != nil {
return nil, err
}
}
if raw, ok := item["cell_merges"]; ok {
payload.CellMerges, err = parseWorkbookCreateMergeOps(raw, path+".cell_merges")
if err != nil {
return nil, err
}
}
if len(payload.CellStyles) == 0 && len(payload.RowSizes) == 0 && len(payload.ColSizes) == 0 && len(payload.CellMerges) == 0 {
return nil, common.ValidationErrorf("%s must include at least one of cell_styles/row_sizes/col_sizes/cell_merges", path)
}
return payload, nil
}
func parseWorkbookCreateCellStyleOps(v interface{}, path string) ([]workbookCreateCellStyleOp, error) {
arr, ok := v.([]interface{})
if !ok {
return nil, common.ValidationErrorf("%s must be an array", path)
}
ops := make([]workbookCreateCellStyleOp, 0, len(arr))
for i, raw := range arr {
op, ok := raw.(map[string]interface{})
if !ok {
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
}
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
if err != nil {
return nil, err
}
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
return nil, common.ValidationErrorf("%s[%d].range %q: %v", path, i, 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, fmt.Sprintf("%s[%d]", path, i))
if err != nil {
return nil, err
}
if len(style) == 0 {
return nil, common.ValidationErrorf("%s[%d] must include at least one style field", path, i)
}
ops = append(ops, workbookCreateCellStyleOp{Range: rangeStr, Style: style})
}
return ops, nil
}
func parseWorkbookCreateMergeOps(v interface{}, path string) ([]workbookCreateMergeOp, error) {
arr, ok := v.([]interface{})
if !ok {
return nil, common.ValidationErrorf("%s must be an array", path)
}
ops := make([]workbookCreateMergeOp, 0, len(arr))
for i, raw := range arr {
op, ok := raw.(map[string]interface{})
if !ok {
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
}
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
if err != nil {
return nil, err
}
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
return nil, common.ValidationErrorf("%s[%d].range %q: %v", path, i, rangeStr, err)
}
mergeType := "all"
if raw, ok := op["merge_type"]; ok {
v, ok := raw.(string)
if !ok || strings.TrimSpace(v) == "" {
return nil, common.ValidationErrorf("%s[%d].merge_type must be a non-empty string", path, i)
}
mergeType = strings.TrimSpace(v)
}
switch mergeType {
case "all", "rows", "columns":
default:
return nil, common.ValidationErrorf("%s[%d].merge_type %q is invalid (want all/rows/columns)", path, i, mergeType)
}
if err := rejectUnexpectedWorkbookStyleFields(op, fmt.Sprintf("%s[%d]", path, i), "range", "merge_type"); err != nil {
return nil, err
}
ops = append(ops, workbookCreateMergeOp{Range: rangeStr, MergeType: mergeType})
}
return ops, nil
}
func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]workbookCreateResizeOp, error) {
arr, ok := v.([]interface{})
if !ok {
return nil, common.ValidationErrorf("%s must be an array", path)
}
ops := make([]workbookCreateResizeOp, 0, len(arr))
for i, raw := range arr {
op, ok := raw.(map[string]interface{})
if !ok {
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
}
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
if err != nil {
return nil, err
}
parsedDim, _, _, err := parseA1Range(rangeStr)
if err != nil {
want := "row numbers like 2:10"
if dimension == "column" {
want = "column letters like A:E"
}
return nil, common.ValidationErrorf("%s[%d].range %q must use %s: %v", path, i, rangeStr, want, err)
}
if parsedDim != dimension {
want := "row numbers like 2:10"
if dimension == "column" {
want = "column letters like A:E"
}
return nil, common.ValidationErrorf("%s[%d].range %q must use %s", path, i, rangeStr, want)
}
resizeType, _ := op["type"].(string)
resizeType = strings.TrimSpace(resizeType)
if resizeType == "" {
return nil, common.ValidationErrorf("%s[%d].type is required (pixel/standard%s)", path, i, autoSuffix(dimension))
}
if dimension == "column" && resizeType == "auto" {
return nil, common.ValidationErrorf("%s[%d].type auto is rows-only", path, i)
}
switch resizeType {
case "pixel", "standard", "auto":
default:
return nil, common.ValidationErrorf("%s[%d].type %q is invalid (want pixel/standard%s)", path, i, resizeType, autoSuffix(dimension))
}
size := 0
if raw, ok := op["size"]; ok {
n, ok := util.ToFloat64(raw)
if !ok || n <= 0 {
return nil, common.ValidationErrorf("%s[%d].size must be a positive number", path, i)
}
size = int(n)
}
if resizeType == "pixel" && size <= 0 {
return nil, common.ValidationErrorf("%s[%d].type pixel requires size", path, i)
}
if resizeType != "pixel" && size > 0 {
return nil, common.ValidationErrorf("%s[%d].size is only valid with type pixel", path, i)
}
if err := rejectUnexpectedWorkbookStyleFields(op, fmt.Sprintf("%s[%d]", path, i), "range", "type", "size"); err != nil {
return nil, err
}
ops = append(ops, workbookCreateResizeOp{Range: normalizeWorkbookResizeRange(rangeStr), ResizeType: resizeType, Size: size})
}
return ops, 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 := 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)
}
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 is for styles only; put content in --values or use --sheets for typed cell objects", path)
default:
if !workbookCreateCellStyleField(k) {
return nil, common.ValidationErrorf("%s.%s is not a supported style field", path, k)
}
cellStyle[k] = v
}
}
if len(cellStyle) > 0 {
out["cell_styles"] = cellStyle
}
return out, nil
}
func workbookCreateCellStyleField(name string) bool {
switch name {
case "font_color", "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)", 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
}
func applyWorkbookCreateStylesToMatrix(rows [][]interface{}, styles *workbookCreateStylePayload, baseCol, baseRow int, label string) error {
if styles == nil {
return nil
}
for i, op := range styles.CellStyles {
startCol, startRow, endCol, endRow, err := workbookCreateStyleRangeBounds(op.Range)
if err != nil {
return common.ValidationErrorf("%s[%d].range %q: %v", label, i, op.Range, err)
}
if startCol < baseCol || startRow < baseRow || endRow-baseRow >= len(rows) || len(rows) == 0 || endCol-baseCol >= len(rows[0]) {
return common.ValidationErrorf("%s[%d].range %q is outside the write range %s%d:%s%d",
label, i, op.Range,
columnIndexToLetter(baseCol), baseRow+1,
columnIndexToLetter(baseCol+len(rows[0])-1), baseRow+len(rows))
}
for r := startRow - baseRow; r <= endRow-baseRow; r++ {
for c := startCol - baseCol; c <= endCol-baseCol; c++ {
mergeWorkbookCreateStyle(rows[r][c], op.Style)
}
}
}
return 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 %s", op.Kind, op.Range)).
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 on %s", op.Kind, op.Range)
}
return err
}
return errs.NewInternalError(errs.SubtypeUnknown, "%s %s failed", op.Kind, op.Range).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))
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})
}
return ops
}
type workbookCreateStyleOp struct {
Kind string
Range string
MergeType string
ResizeType string
Size int
}
func workbookCreateVisualOpInput(token, sheetID, sheetName string, op workbookCreateStyleOp) (map[string]interface{}, string) {
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"
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" and
// omits the bitable-only --target-token. 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 {
return drive.ValidateImport(workbookImportParams(runtime))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return drive.PlanImportDryRun(runtime, workbookImportParams(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return drive.RunImport(ctx, runtime, workbookImportParams(runtime))
},
}
// 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.
func workbookImportParams(runtime *common.RuntimeContext) drive.ImportParams {
return drive.ImportParams{
File: runtime.Str("file"),
DocType: "sheet",
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
}
}