mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Image uploads to a spreadsheet hard-coded parent_type=sheet_image at every entry point. Imported "office" spreadsheets carry a token prefixed with "fake_office_", for which the drive backend requires parent_type=office_sheet_file. Funnel the parent_type selection through a single sheets-domain helper so the rule lives in one place and every image-upload path (float-image, +cells-set-image, backward +media-upload, and every dry-run preview) stays consistent. - Add sheetMediaParentType(token) in the sheets domain: returns office_sheet_file for fake_office_-prefixed tokens, otherwise sheet_image. - Add an uploadSheetImage(...) collector that builds the DriveMediaUploadAllConfig (including parent_type) once, replacing the per-call-site hand-rolled configs. - Route both main-domain image entries through the collector — float-image local upload and +cells-set-image — covering Execute and the dry-run preview body/desc. - Cover the backward +media-upload entry: single-part, multipart (>20MB), and both dry-run bodies. backward is a separate package and an intentional verbatim mirror of shortcuts/sheets/, so it keeps its own copy of the helper rather than importing the main domain. - Leave the shared common.UploadDriveMediaAllTyped upload layer untouched — the fake_office_ rule is sheets-specific and must not leak into mail/slides/doc/drive/base. Tests: - Pure-function TestSheetMediaParentType (5 cases incl. prefix-only and mid-string non-match). - Main-domain dry-run TestCellsSetImage_DryRunOfficeParentType and TestUploadSheetImage_ParentType / _FileOpenError that exercise the Execute path on the wire, asserting parent_type via the captured multipart body and typed validation metadata (errs.ProblemOf category/subtype, fs.ErrNotExist cause preserved) on file open errors. decodeSheetMediaMultipartBody fails fast on NextPart / ReadFrom errors rather than silently producing a partial body. - backward TestSheetMediaUploadExecuteOfficeParentType (real multipart wire) and TestSheetMediaUploadDryRunSmallFileOfficeParentType (small-file dry-run preview for fake_office_). - cli_e2e tests/cli_e2e/sheets/sheets_image_upload_dryrun_test.go: --dry-run end-to-end across +media-upload and +cells-set-image, native and fake_office_ tokens, asserting api.0 is POST upload_all with parent_type=sheet_image / office_sheet_file and parent_node = token.
884 lines
33 KiB
Go
884 lines
33 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package sheets
|
||
|
||
import (
|
||
"context"
|
||
"encoding/csv"
|
||
"fmt"
|
||
"image"
|
||
_ "image/gif"
|
||
_ "image/jpeg"
|
||
_ "image/png"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/larksuite/cli/errs"
|
||
"github.com/larksuite/cli/internal/validate"
|
||
"github.com/larksuite/cli/shortcuts/common"
|
||
"github.com/spf13/cobra"
|
||
)
|
||
|
||
// ─── lark_sheet_write_cells ───────────────────────────────────────────
|
||
//
|
||
// Wraps:
|
||
// - set_cell_range (powers +cells-set / +cells-set-style /
|
||
// +dropdown-set / +dropdown-update / +dropdown-delete)
|
||
// - set_range_from_csv (powers +csv-put)
|
||
//
|
||
// +cells-set-image is a `cli_only_derivative` shortcut (needs a local file
|
||
// upload before calling set_cell_range); it lives in the cli-only batch
|
||
// where the upload helper is shared with +workbook-create / +dim-move /
|
||
// +workbook-export.
|
||
//
|
||
// All set_cell_range-backed shortcuts construct a cells matrix whose
|
||
// dimensions exactly match the target range — the tool errors on mismatch.
|
||
|
||
// CellsSet wraps set_cell_range: caller provides the cells matrix via --cells
|
||
// (JSON), with an optional --copy-to-range to replicate the written block
|
||
// across a larger area (formula refs auto-shift).
|
||
var CellsSet = common.Shortcut{
|
||
Service: "sheets",
|
||
Command: "+cells-set",
|
||
Description: "Write values / formulas / styles / comments / data validation / embed-image to a cell range.",
|
||
Risk: "write",
|
||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||
AuthTypes: []string{"user", "bot"},
|
||
HasFormat: true,
|
||
Flags: flagsFor("+cells-set"),
|
||
Validate: validateViaInput(cellsSetInput),
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
token, _ := resolveSpreadsheetToken(runtime)
|
||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||
input, _ := cellsSetInput(runtime, token, sheetID, sheetName)
|
||
return invokeToolDryRun(token, ToolKindWrite, "set_cell_range", 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 := cellsSetInput(runtime, token, sheetID, sheetName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", input)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
runtime.Out(out, nil)
|
||
return nil
|
||
},
|
||
}
|
||
|
||
func cellsSetInput(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("range")) == "" {
|
||
return nil, sheetsValidationForFlag("range", "--range is required")
|
||
}
|
||
cells, err := requireJSONArray(runtime, "cells")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := normalizeTypedCellsStyleAliases(cells, "--cells"); err != nil {
|
||
return nil, err
|
||
}
|
||
input := map[string]interface{}{
|
||
"excel_id": token,
|
||
"range": strings.TrimSpace(runtime.Str("range")),
|
||
"cells": cells,
|
||
}
|
||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||
if !runtime.Bool("allow-overwrite") {
|
||
input["allow_overwrite"] = false
|
||
}
|
||
if copyTo := strings.TrimSpace(runtime.Str("copy-to-range")); copyTo != "" {
|
||
input["copy_to_range"] = copyTo
|
||
}
|
||
if err := validateInputAgainstSchema(runtime, input); err != nil {
|
||
return nil, err
|
||
}
|
||
return input, nil
|
||
}
|
||
|
||
// CellsSetStyle stamps a single style block across every cell in --range.
|
||
// Style is composed from a dozen flat flags (background-color, font-color,
|
||
// font-size, font-style, font-weight, font-line, horizontal-alignment,
|
||
// vertical-alignment, word-wrap, number-format) plus --border-styles for
|
||
// the only field that still needs a nested object. At least one flag must
|
||
// be set.
|
||
var CellsSetStyle = common.Shortcut{
|
||
Service: "sheets",
|
||
Command: "+cells-set-style",
|
||
Description: "Apply style flags to every cell in a range (values / formulas untouched).",
|
||
Risk: "write",
|
||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||
AuthTypes: []string{"user", "bot"},
|
||
HasFormat: true,
|
||
Flags: flagsFor("+cells-set-style"),
|
||
Validate: validateViaInput(cellsSetStyleInput),
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
token, _ := resolveSpreadsheetToken(runtime)
|
||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||
input, _ := cellsSetStyleInput(runtime, token, sheetID, sheetName)
|
||
return invokeToolDryRun(token, ToolKindWrite, "set_cell_range", 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 := cellsSetStyleInput(runtime, token, sheetID, sheetName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", input)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
runtime.Out(out, nil)
|
||
return nil
|
||
},
|
||
}
|
||
|
||
func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||
return nil, err
|
||
}
|
||
rangeStr := strings.TrimSpace(runtime.Str("range"))
|
||
if rangeStr == "" {
|
||
return nil, sheetsValidationForFlag("range", "--range is required")
|
||
}
|
||
rows, cols, err := rangeDimensions(rangeStr)
|
||
if err != nil {
|
||
return nil, sheetsValidationForFlag("range", "--range %q: %v", rangeStr, err)
|
||
}
|
||
if err := requireAnyStyleFlag(runtime); err != nil {
|
||
return nil, err
|
||
}
|
||
cellStyle := buildCellStyleFromFlags(runtime)
|
||
borderStyles, err := borderStylesFromFlag(runtime)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
cells := make([][]interface{}, rows)
|
||
for r := range cells {
|
||
row := make([]interface{}, cols)
|
||
for c := range row {
|
||
cell := map[string]interface{}{}
|
||
if len(cellStyle) > 0 {
|
||
cell["cell_styles"] = cellStyle
|
||
}
|
||
if borderStyles != nil {
|
||
cell["border_styles"] = borderStyles
|
||
}
|
||
row[c] = cell
|
||
}
|
||
cells[r] = row
|
||
}
|
||
input := map[string]interface{}{
|
||
"excel_id": token,
|
||
"range": rangeStr,
|
||
"cells": cells,
|
||
}
|
||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||
if err := validateInputAgainstSchema(runtime, input); err != nil {
|
||
return nil, err
|
||
}
|
||
return input, nil
|
||
}
|
||
|
||
// CsvPut wraps set_range_from_csv: dump a CSV blob into a sheet. A cell whose
|
||
// text starts with = is evaluated as a formula; use +cells-set for styles / notes / images.
|
||
var CsvPut = common.Shortcut{
|
||
Service: "sheets",
|
||
Command: "+csv-put",
|
||
Description: "Paste RFC-4180 CSV into a sheet at --start-cell (values or formulas: a leading = is evaluated as a formula; no styles / comments; auto-expands sheet if needed).",
|
||
Risk: "write",
|
||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||
AuthTypes: []string{"user", "bot"},
|
||
HasFormat: true,
|
||
Flags: flagsFor("+csv-put"), // includes the hidden --range alias (defined in the base flags table)
|
||
PostMount: func(cmd *cobra.Command) {
|
||
// --range is an accepted alias for --start-cell (see csvPutInput).
|
||
// Neither is individually required; exactly one must be set. flag-defs
|
||
// marks --start-cell required, so clear that annotation and switch to a
|
||
// one-required group — otherwise cobra rejects `--range A1` for a
|
||
// missing --start-cell before the handler ever runs.
|
||
if fl := cmd.Flags().Lookup("start-cell"); fl != nil {
|
||
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
|
||
}
|
||
cmd.MarkFlagsOneRequired("start-cell", "range")
|
||
cmd.MarkFlagsMutuallyExclusive("start-cell", "range")
|
||
},
|
||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
if err := guardCSVValueIsNotFilePath(runtime); err != nil {
|
||
return err
|
||
}
|
||
return validateViaInput(csvPutInput)(ctx, runtime)
|
||
},
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
token, _ := resolveSpreadsheetToken(runtime)
|
||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||
input, _ := csvPutInput(runtime, token, sheetID, sheetName)
|
||
dr := invokeToolDryRun(token, ToolKindWrite, "set_range_from_csv", input)
|
||
if rng, ok := csvPutWriteRangeFromInput(input); ok {
|
||
dr = dr.Set("writes_range", rng)
|
||
}
|
||
return dr
|
||
},
|
||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
input, err := csvPutInput(runtime, token, sheetID, sheetName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_range_from_csv", input)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if rng, ok := csvPutWriteRangeFromInput(input); ok {
|
||
if m, isMap := out.(map[string]interface{}); isMap {
|
||
m["writes_range"] = rng
|
||
}
|
||
}
|
||
runtime.Out(out, nil)
|
||
return nil
|
||
},
|
||
}
|
||
|
||
// csvPutWriteRangeFromInput computes the rectangle +csv-put will actually write,
|
||
// from the built tool input (start_cell + csv). +csv-put pastes from the anchor
|
||
// and auto-expands to the CSV's own row/column count — the footprint is the
|
||
// result, not a user-set boundary. Surfacing it (e.g. "B2:D4") in dry-run and in
|
||
// the success envelope lets agents see how far a paste reaches before it
|
||
// silently overwrites neighbouring cells (use --allow-overwrite=false to block
|
||
// that). Returns ok=false when the anchor is not a single cell or the CSV has no
|
||
// parseable fields.
|
||
func csvPutWriteRangeFromInput(input map[string]interface{}) (string, bool) {
|
||
anchor, _ := input["start_cell"].(string)
|
||
csvText, _ := input["csv"].(string)
|
||
if anchor == "" || csvText == "" {
|
||
return "", false
|
||
}
|
||
col0, row0, ok := splitCellRef(anchor)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
r := csv.NewReader(strings.NewReader(csvText))
|
||
r.FieldsPerRecord = -1 // tolerate ragged rows; we only need the max width
|
||
records, err := r.ReadAll()
|
||
if err != nil || len(records) == 0 {
|
||
return "", false
|
||
}
|
||
cols := 0
|
||
for _, rec := range records {
|
||
if len(rec) > cols {
|
||
cols = len(rec)
|
||
}
|
||
}
|
||
if cols == 0 {
|
||
return "", false
|
||
}
|
||
endCol := columnIndexToLetter(col0 + cols - 1)
|
||
endRow := row0 + len(records) // row0 is 0-based; +len(records) is the 1-based bottom row
|
||
return fmt.Sprintf("%s:%s%d", anchor, endCol, endRow), true
|
||
}
|
||
|
||
// guardCSVValueIsNotFilePath catches the common slip of passing a CSV file path
|
||
// to --csv without the "@" that reads it (e.g. `--csv data.csv` instead of
|
||
// `--csv @data.csv`). Because any string is a valid one-cell CSV, the mistake
|
||
// would otherwise be written silently as the literal text "data.csv". It runs
|
||
// in +csv-put's Validate, after resolveInputFlags — so an @file / stdin value is
|
||
// already its contents (a real CSV blob, never a path) and only a bare value
|
||
// reaches here unchanged. It flags the value only when it actually names an
|
||
// existing file in the cwd subtree; checking real existence (not name shape)
|
||
// means inline content that merely ends in a filename ("see config.json") is
|
||
// never misjudged. Fails open: any Stat error or a directory leaves the value
|
||
// untouched. Scoped to --csv only — no other flag is affected.
|
||
func guardCSVValueIsNotFilePath(runtime *common.RuntimeContext) error {
|
||
raw := strings.TrimSpace(runtime.Str("csv"))
|
||
if raw == "" {
|
||
return nil
|
||
}
|
||
fio := runtime.FileIO()
|
||
if fio == nil {
|
||
return nil
|
||
}
|
||
info, err := fio.Stat(raw)
|
||
if err != nil || info == nil || info.IsDir() {
|
||
return nil //nolint:nilerr // fail-open: a missing/unreadable path is treated as inline content, not a forgotten @
|
||
}
|
||
return sheetsValidationForFlag("csv",
|
||
"--csv value %q is an existing file, not inline CSV; to read it use --csv @%s, or pass the literal text via stdin (--csv -)",
|
||
raw, raw,
|
||
)
|
||
}
|
||
|
||
func csvPutInput(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("csv")) == "" {
|
||
return nil, sheetsValidationForFlag("csv", "--csv is required")
|
||
}
|
||
if runtime.Changed("start-cell") && runtime.Changed("range") {
|
||
return nil, common.ValidationErrorf("--start-cell and --range are mutually exclusive").WithParams(sheetsInvalidParam("start-cell", "mutually exclusive"), sheetsInvalidParam("range", "mutually exclusive"))
|
||
}
|
||
anchor := strings.TrimSpace(runtime.Str("start-cell"))
|
||
// --range is accepted as an alias for --start-cell. +csv-get and +cells-set
|
||
// locate with --range, so agents routinely carry --range over to +csv-put and
|
||
// hit a guaranteed first-try failure. Honor it when --start-cell was not
|
||
// explicitly set — guard on Changed, not emptiness, because --start-cell
|
||
// defaults to "A1" and is therefore never empty. A range like "A1:H17"
|
||
// collapses to its top-left cell; +csv-put pastes from the anchor and
|
||
// auto-expands, so the range's lower-right bound is irrelevant.
|
||
//
|
||
// Standalone enforces exactly one of --start-cell / --range via cobra's
|
||
// flag groups (see PostMount). A +batch-update sub-op never runs cobra, so
|
||
// without explicit checks the default "A1" silently wins and the paste lands
|
||
// at A1 instead of failing like the standalone command. Mirror the
|
||
// standalone contract: double-set is invalid, and when --start-cell is
|
||
// absent, --range is mandatory.
|
||
if !runtime.Changed("start-cell") {
|
||
rng := strings.TrimSpace(runtime.Str("range"))
|
||
if rng == "" {
|
||
return nil, common.ValidationErrorf("--start-cell or --range is required").WithParams(sheetsInvalidParam("start-cell", "required; specify exactly one"), sheetsInvalidParam("range", "required; specify exactly one"))
|
||
}
|
||
anchor = strings.TrimSpace(strings.SplitN(rng, ":", 2)[0])
|
||
if idx := strings.Index(anchor, "!"); idx >= 0 {
|
||
anchor = anchor[idx+1:]
|
||
}
|
||
}
|
||
if anchor == "" {
|
||
return nil, sheetsValidationForFlag("start-cell", "--start-cell is required")
|
||
}
|
||
if _, _, ok := splitCellRef(anchor); !ok {
|
||
return nil, sheetsValidationForFlag("start-cell", "--start-cell %q must be a single cell ref (e.g. A1)", anchor)
|
||
}
|
||
input := map[string]interface{}{
|
||
"excel_id": token,
|
||
"csv": runtime.Str("csv"),
|
||
"start_cell": anchor,
|
||
}
|
||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||
if !runtime.Bool("allow-overwrite") {
|
||
input["allow_overwrite"] = false
|
||
}
|
||
return input, nil
|
||
}
|
||
|
||
// ─── +dropdown-* (set_cell_range via data_validation) ─────────────────
|
||
//
|
||
// All three dropdown shortcuts stamp a `data_validation` block on every cell
|
||
// of the target range(s). set / update / delete differ in (a) how many
|
||
// ranges they accept and (b) whether the block is populated or null.
|
||
|
||
// DropdownSet places a single dropdown on one range.
|
||
var DropdownSet = common.Shortcut{
|
||
Service: "sheets",
|
||
Command: "+dropdown-set",
|
||
Description: "Attach a dropdown / data-validation list to every cell in --range.",
|
||
Risk: "write",
|
||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||
AuthTypes: []string{"user", "bot"},
|
||
HasFormat: true,
|
||
Flags: flagsFor("+dropdown-set"),
|
||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
if err := validateViaInput(dropdownSetInput)(ctx, runtime); err != nil {
|
||
return err
|
||
}
|
||
warnDropdownSourceRangeHighlight(runtime)
|
||
return nil
|
||
},
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
token, _ := resolveSpreadsheetToken(runtime)
|
||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||
input, _ := dropdownSetInput(runtime, token, sheetID, sheetName)
|
||
return invokeToolDryRun(token, ToolKindWrite, "set_cell_range", 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 := dropdownSetInput(runtime, token, sheetID, sheetName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", input)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
runtime.Out(out, nil)
|
||
return nil
|
||
},
|
||
}
|
||
|
||
func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||
return nil, err
|
||
}
|
||
rangeStr := strings.TrimSpace(runtime.Str("range"))
|
||
if rangeStr == "" {
|
||
return nil, sheetsValidationForFlag("range", "--range is required")
|
||
}
|
||
rows, cols, err := rangeDimensions(rangeStr)
|
||
if err != nil {
|
||
return nil, sheetsValidationForFlag("range", "--range %q: %v", rangeStr, err)
|
||
}
|
||
validation, err := buildDropdownValidation(runtime)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
cells := fillCellsMatrix(rows, cols, map[string]interface{}{"data_validation": validation})
|
||
input := map[string]interface{}{
|
||
"excel_id": token,
|
||
"range": rangeStr,
|
||
"cells": cells,
|
||
}
|
||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||
if err := validateInputAgainstSchema(runtime, input); err != nil {
|
||
return nil, err
|
||
}
|
||
return input, nil
|
||
}
|
||
|
||
// NOTE: +dropdown-update and +dropdown-delete were originally drafted here
|
||
// but moved to lark_sheet_batch_update (B7) per the spec: multi-range
|
||
// dropdown CRUD now goes through batch_update for atomicity. They'll land in
|
||
// the batch_update file alongside +cells-batch-set-style.
|
||
|
||
// ─── shared dropdown helpers ──────────────────────────────────────────
|
||
|
||
// buildDropdownValidation packs --options or --source-range plus --colors /
|
||
// --multiple / --highlight into the data_validation block expected by
|
||
// set_cell_range. Field names follow the canonical
|
||
// set_cell_range.data_validation schema:
|
||
//
|
||
// --options -> {type: "list", items: <strings>}
|
||
// --source-range -> {type: "listFromRange", range: <A1+sheet prefix>}
|
||
// --multiple -> support_multiple_values (bool)
|
||
// --colors -> highlight_colors (string array, hex)
|
||
// --highlight -> enable_highlight (bool, tri-state via Changed)
|
||
//
|
||
// --options and --source-range are XOR (caller must pass exactly one).
|
||
// --colors length may be shorter than the source size (options length or
|
||
// source-range cell count) — server cycles remaining slots through a
|
||
// built-in 10-color palette — but must not exceed it.
|
||
//
|
||
// --highlight is tri-state: omitted leaves enable_highlight off the body so the
|
||
// server's new default (true) applies; --highlight=true stamps an explicit true;
|
||
// --highlight=false stamps false to turn the highlight off. Using Changed() lets
|
||
// us distinguish "not passed" from "explicit false" — required because the
|
||
// server-side default flipped from false to true and a plain cobra Bool can no
|
||
// longer carry the opt-out signal.
|
||
func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) {
|
||
sourceSize, dv, err := dropdownTypeAndItems(runtime)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if runtime.Str("colors") != "" {
|
||
colors, err := requireJSONArray(runtime, "colors")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(colors) > sourceSize {
|
||
return nil, sheetsValidationForFlag("colors", "--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
|
||
}
|
||
dv["highlight_colors"] = colors
|
||
}
|
||
if runtime.Bool("multiple") {
|
||
dv["support_multiple_values"] = true
|
||
}
|
||
if runtime.Changed("highlight") {
|
||
dv["enable_highlight"] = runtime.Bool("highlight")
|
||
}
|
||
return dv, nil
|
||
}
|
||
|
||
// dropdownTypeAndItems resolves the XOR between --options and --source-range
|
||
// and returns (sourceSize, partial dv with type+items|range set). sourceSize
|
||
// is the option count for `list` mode or the source-range cell count for
|
||
// `listFromRange` mode — used to validate --colors length.
|
||
func dropdownTypeAndItems(runtime flagView) (int, map[string]interface{}, error) {
|
||
optsRaw := runtime.Str("options")
|
||
sourceRange := strings.TrimSpace(runtime.Str("source-range"))
|
||
switch {
|
||
case optsRaw != "" && sourceRange != "":
|
||
return 0, nil, common.ValidationErrorf("--options and --source-range are mutually exclusive; pass exactly one").WithParams(sheetsInvalidParam("options", "mutually exclusive"), sheetsInvalidParam("source-range", "mutually exclusive"))
|
||
case optsRaw == "" && sourceRange == "":
|
||
return 0, nil, common.ValidationErrorf("one of --options (inline list) or --source-range (listFromRange) is required").WithParams(sheetsInvalidParam("options", "required; specify exactly one"), sheetsInvalidParam("source-range", "required; specify exactly one"))
|
||
case optsRaw != "":
|
||
options, err := requireJSONArray(runtime, "options")
|
||
if err != nil {
|
||
return 0, nil, err
|
||
}
|
||
return len(options), map[string]interface{}{
|
||
"type": "list",
|
||
"items": options,
|
||
}, nil
|
||
default: // sourceRange != ""
|
||
rows, cols, err := rangeDimensions(sourceRange)
|
||
if err != nil {
|
||
return 0, nil, sheetsValidationForFlag("source-range", "--source-range %q: %v", sourceRange, err)
|
||
}
|
||
return rows * cols, map[string]interface{}{
|
||
"type": "listFromRange",
|
||
"range": sourceRange,
|
||
}, nil
|
||
}
|
||
}
|
||
|
||
// validateDropdownSourceOrOptions runs the XOR + --colors length check at
|
||
// Validate time so +dropdown-update / +dropdown-delete can fail fast without
|
||
// reaching the body-build step. Returns the dropdown source size (options
|
||
// length for list mode, source-range cell count for listFromRange) so
|
||
// callers can size their cells matrix.
|
||
func validateDropdownSourceOrOptions(runtime flagView) (int, error) {
|
||
sourceSize, _, err := dropdownTypeAndItems(runtime)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if runtime.Str("colors") != "" {
|
||
colors, err := requireJSONArray(runtime, "colors")
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if len(colors) > sourceSize {
|
||
return 0, sheetsValidationForFlag("colors", "--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
|
||
}
|
||
}
|
||
return sourceSize, nil
|
||
}
|
||
|
||
// dropdownSourceRangeHighlightLimit is the cell-count cap above which the
|
||
// server marks the dropdown's options as invalid when highlight is on.
|
||
// Source: byted-sheet core LIST_WITH_COLOR_MAX_COUNT
|
||
// (sheet-packages/.../dataValidation/list/ListFromRangeValidation.ts:49).
|
||
// Beyond this, ListFromRangeValidation.checkOptionsValid() sets
|
||
// isOptionError=true (highlight + range > 2000 is an unsupported combo).
|
||
const dropdownSourceRangeHighlightLimit = 2000
|
||
|
||
// warnDropdownSourceRangeHighlight emits a soft stderr warning when the user
|
||
// targets a --source-range larger than dropdownSourceRangeHighlightLimit while
|
||
// highlight is on (the server-side default and the most common path).
|
||
// Inline --options is not subject to this limit (server has no inline count
|
||
// or per-item length cap; only the listFromRange + highlight combo is).
|
||
// Validate phase only — never blocks the request. Caller must already have
|
||
// confirmed the source-or-options validation passed.
|
||
func warnDropdownSourceRangeHighlight(runtime *common.RuntimeContext) {
|
||
sourceRange := strings.TrimSpace(runtime.Str("source-range"))
|
||
if sourceRange == "" {
|
||
return // inline --options mode — no server-side size cap applies
|
||
}
|
||
// highlight is tri-state: omitted = ON (server default), --highlight=true
|
||
// = ON, --highlight=false = OFF. Only the OFF case avoids the warning.
|
||
if runtime.Changed("highlight") && !runtime.Bool("highlight") {
|
||
return
|
||
}
|
||
rows, cols, err := rangeDimensions(sourceRange)
|
||
if err != nil {
|
||
return // already errored upstream; don't double-report
|
||
}
|
||
cellCount := rows * cols
|
||
if cellCount <= dropdownSourceRangeHighlightLimit {
|
||
return
|
||
}
|
||
fmt.Fprintf(runtime.IO().ErrOut,
|
||
"warning: --source-range covers %d cells; server marks the dropdown as option-error when highlight is on and the source exceeds %d cells. Pass --highlight=false to suppress this.\n",
|
||
cellCount, dropdownSourceRangeHighlightLimit)
|
||
}
|
||
|
||
// ─── range parsing helpers ────────────────────────────────────────────
|
||
|
||
// rangeDimensions parses an A1 range like "A1:C5" / "A1" / "sheet1!B2:D10"
|
||
// and returns its row / column counts. Errors on non-rectangular forms like
|
||
// "A:C" (whole-column) or "3:6" (whole-row) — those need a row/col total
|
||
// from get_sheet_structure, outside the scope of pure local parsing.
|
||
func rangeDimensions(rangeStr string) (rows, cols int, err error) {
|
||
if idx := strings.Index(rangeStr, "!"); idx >= 0 {
|
||
rangeStr = rangeStr[idx+1:]
|
||
}
|
||
rangeStr = strings.TrimSpace(rangeStr)
|
||
if rangeStr == "" {
|
||
return 0, 0, fmt.Errorf("empty range")
|
||
}
|
||
parts := strings.SplitN(rangeStr, ":", 2)
|
||
if len(parts) == 1 {
|
||
// single cell, e.g. "A1"
|
||
if _, _, ok := splitCellRef(parts[0]); !ok {
|
||
return 0, 0, fmt.Errorf("invalid cell ref %q", parts[0])
|
||
}
|
||
return 1, 1, nil
|
||
}
|
||
startCol, startRow, ok1 := splitCellRef(parts[0])
|
||
endCol, endRow, ok2 := splitCellRef(parts[1])
|
||
if !ok1 || !ok2 {
|
||
return 0, 0, fmt.Errorf("unsupported range form %q (need rectangular A1:B2)", rangeStr)
|
||
}
|
||
if endRow < startRow || endCol < startCol {
|
||
return 0, 0, fmt.Errorf("end %q must be at or after start %q", parts[1], parts[0])
|
||
}
|
||
return endRow - startRow + 1, endCol - startCol + 1, nil
|
||
}
|
||
|
||
// splitCellRef parses "A1" → (col=0, row=0, true). Returns false for any
|
||
// non-rectangular form (pure column "A", pure row "1", invalid chars).
|
||
func splitCellRef(s string) (col, row int, ok bool) {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return 0, 0, false
|
||
}
|
||
var colEnd int
|
||
for i, r := range s {
|
||
if r >= '0' && r <= '9' {
|
||
colEnd = i
|
||
break
|
||
}
|
||
colEnd = i + 1
|
||
}
|
||
if colEnd == 0 || colEnd == len(s) {
|
||
return 0, 0, false
|
||
}
|
||
col = letterToColumnIndex(s[:colEnd])
|
||
if col < 0 {
|
||
return 0, 0, false
|
||
}
|
||
n, err := strconv.Atoi(s[colEnd:])
|
||
if err != nil || n < 1 {
|
||
return 0, 0, false
|
||
}
|
||
return col, n - 1, true
|
||
}
|
||
|
||
// letterToColumnIndex converts spreadsheet letter notation to a 0-based
|
||
// column index ("A" → 0, "Z" → 25, "AA" → 26). Returns -1 on bad input.
|
||
func letterToColumnIndex(letters string) int {
|
||
letters = strings.ToUpper(strings.TrimSpace(letters))
|
||
if letters == "" {
|
||
return -1
|
||
}
|
||
n := 0
|
||
for _, c := range letters {
|
||
if c < 'A' || c > 'Z' {
|
||
return -1
|
||
}
|
||
n = n*26 + int(c-'A'+1)
|
||
}
|
||
return n - 1
|
||
}
|
||
|
||
// fillCellsMatrix returns a rows×cols matrix where every cell is the same
|
||
// (shallow-copied) prototype map. Use for fan-out shortcuts that stamp a
|
||
// single attribute (style / data_validation) across an entire range.
|
||
func fillCellsMatrix(rows, cols int, prototype map[string]interface{}) [][]interface{} {
|
||
cells := make([][]interface{}, rows)
|
||
for r := range cells {
|
||
row := make([]interface{}, cols)
|
||
for c := range row {
|
||
cell := make(map[string]interface{}, len(prototype))
|
||
for k, v := range prototype {
|
||
cell[k] = v
|
||
}
|
||
row[c] = cell
|
||
}
|
||
cells[r] = row
|
||
}
|
||
return cells
|
||
}
|
||
|
||
// ─── +cells-set-image (cli_only_derivative) ──────────────────────────
|
||
//
|
||
// The backing tool (set_cell_range) is in mcp-tools.json, but the CLI
|
||
// shortcut also needs a local-file upload before it can call the tool.
|
||
// That extra step doesn't fit the One-OpenAPI dispatcher, so the spec
|
||
// marks this shortcut cli_only_derivative — the CLI uploads the image
|
||
// to drive (parent_type=sheet_image) and then writes the returned
|
||
// file_token into the target cell via callTool(set_cell_range) with a
|
||
// rich_text embed-image entry.
|
||
|
||
// CellsSetImage uploads a local image to drive (parent_type=sheet_image,
|
||
// parent_node=spreadsheet token) and then writes a rich_text embed-image
|
||
// into the target single-cell range via the set_cell_range tool.
|
||
var CellsSetImage = common.Shortcut{
|
||
Service: "sheets",
|
||
Command: "+cells-set-image",
|
||
Description: "Embed a local image into a single cell (uploads via drive, then set_cell_range with rich_text embed-image).",
|
||
Risk: "write",
|
||
Scopes: []string{"sheets:spreadsheet:write_only", "drive:file:upload"},
|
||
AuthTypes: []string{"user", "bot"},
|
||
HasFormat: true,
|
||
Flags: flagsFor("+cells-set-image"),
|
||
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
|
||
}
|
||
r := strings.TrimSpace(runtime.Str("range"))
|
||
if r == "" {
|
||
return sheetsValidationForFlag("range", "--range is required")
|
||
}
|
||
rows, cols, err := rangeDimensions(r)
|
||
if err != nil {
|
||
return sheetsValidationForFlag("range", "--range %q: %v", r, err)
|
||
}
|
||
if rows != 1 || cols != 1 {
|
||
return sheetsValidationForFlag("range", "--range %q must be exactly one cell (got %d×%d)", r, rows, cols)
|
||
}
|
||
imgPath := strings.TrimSpace(runtime.Str("image"))
|
||
if imgPath == "" {
|
||
return sheetsValidationForFlag("image", "--image is required")
|
||
}
|
||
// Validate path safety here (not just at Execute) so --dry-run also
|
||
// rejects unsafe paths instead of giving a false-positive preview.
|
||
// SafeLocalFlagPath checks path safety only (abs/traversal/outside-cwd),
|
||
// not existence, so legitimate relative paths still dry-run cleanly;
|
||
// the Execute-time Stat below still reports a missing/unreadable file.
|
||
if _, err := validate.SafeLocalFlagPath("--image", imgPath); err != nil {
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
|
||
WithParam("--image").
|
||
WithCause(err)
|
||
}
|
||
return nil
|
||
},
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
token, _ := resolveSpreadsheetToken(runtime)
|
||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||
imgPath := strings.TrimSpace(runtime.Str("image"))
|
||
fileName := strings.TrimSpace(runtime.Str("name"))
|
||
if fileName == "" {
|
||
fileName = filepath.Base(imgPath)
|
||
}
|
||
setCellBody, _ := buildToolBody("set_cell_range", map[string]interface{}{
|
||
"excel_id": token,
|
||
"range": strings.TrimSpace(runtime.Str("range")),
|
||
"sheet_id": sheetSelectorPlaceholder(sheetID, sheetName),
|
||
"cells": [][]interface{}{{map[string]interface{}{
|
||
"rich_text": []map[string]interface{}{{
|
||
"type": "embed-image",
|
||
"text": "",
|
||
"image_token": "<file_token>",
|
||
"image_width": "<image_width>",
|
||
"image_height": "<image_height>",
|
||
}},
|
||
}}},
|
||
})
|
||
return common.NewDryRunAPI().
|
||
POST("/open-apis/drive/v1/medias/upload_all").
|
||
Desc("upload local image to drive (parent_type=" + sheetMediaParentType(token) + ")").
|
||
Body(map[string]interface{}{
|
||
"file_name": fileName,
|
||
"parent_type": sheetMediaParentType(token),
|
||
"parent_node": token,
|
||
"size": "<file_size>",
|
||
"file": "@" + imgPath,
|
||
}).
|
||
POST(toolInvokePath(token, ToolKindWrite)).
|
||
Desc("embed file_token into the cell via set_cell_range").
|
||
Body(setCellBody)
|
||
},
|
||
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
|
||
}
|
||
imgPath := strings.TrimSpace(runtime.Str("image"))
|
||
fileName := strings.TrimSpace(runtime.Str("name"))
|
||
if fileName == "" {
|
||
fileName = filepath.Base(imgPath)
|
||
}
|
||
info, err := runtime.FileIO().Stat(imgPath)
|
||
if err != nil {
|
||
return sheetsInputStatError("image", err)
|
||
}
|
||
imgFile, err := runtime.FileIO().Open(imgPath)
|
||
if err != nil {
|
||
return sheetsInputStatError("image", err)
|
||
}
|
||
imgCfg, _, err := image.DecodeConfig(imgFile)
|
||
imgFile.Close()
|
||
if err != nil {
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "decode image dimensions: %s", err).
|
||
WithParam("--image").
|
||
WithCause(err)
|
||
}
|
||
fileToken, err := uploadSheetImage(runtime, token, imgPath, fileName, info.Size())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
setCellInput := map[string]interface{}{
|
||
"excel_id": token,
|
||
"range": strings.TrimSpace(runtime.Str("range")),
|
||
"cells": [][]interface{}{{map[string]interface{}{
|
||
"rich_text": []map[string]interface{}{{
|
||
"type": "embed-image",
|
||
"text": "",
|
||
"image_token": fileToken,
|
||
"image_width": imgCfg.Width,
|
||
"image_height": imgCfg.Height,
|
||
}},
|
||
}}},
|
||
}
|
||
sheetSelectorForToolInput(setCellInput, sheetID, sheetName)
|
||
setCellOut, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", setCellInput)
|
||
if err != nil {
|
||
return wrapCellsSetImageWriteError(err, fileToken)
|
||
}
|
||
runtime.Out(map[string]interface{}{
|
||
"file_token": fileToken,
|
||
"file_name": fileName,
|
||
"set_cell_range": setCellOut,
|
||
}, nil)
|
||
return nil
|
||
},
|
||
Tips: []string{
|
||
"--range must be a single cell. The uploaded image becomes a cell-internal embed; use +float-image-create for floating images.",
|
||
},
|
||
}
|
||
|
||
func wrapCellsSetImageWriteError(err error, fileToken string) error {
|
||
hint := fmt.Sprintf("image was uploaded as file_token=%s; retry only the cell write with that token or remove the uploaded media", fileToken)
|
||
if p, ok := errs.ProblemOf(err); ok {
|
||
if strings.TrimSpace(p.Hint) != "" {
|
||
p.Hint += "\n" + hint
|
||
} else {
|
||
p.Hint = hint
|
||
}
|
||
return err
|
||
}
|
||
return errs.NewInternalError(errs.SubtypeSDKError, "image uploaded (file_token=%s) but cell write failed: %s", fileToken, err).
|
||
WithHint(hint).
|
||
WithCause(err)
|
||
}
|