Files
larksuite__cli/shortcuts/sheets/lark_sheet_chart.go
zhengzhijiej-tech 5f35c72bd3 feat(sheets): combine chart workflows and special chart types (#2374)
* feat(sheets): support partial chart snapshot schemas

* feat(sheets): add semantic chart shortcuts

* feat(sheets): improve semantic chart workflows

* fix(sheets): prefer semantic chart shortcuts

* fix(sheets): normalize chart range and flag inputs

* fix(sheets): normalize irregular chart ranges

* feat(sheets): add chart data update shortcut

* feat(sheets): harden chart update workflows

* feat(sheets): improve chart creation dimension handling

* feat(sheets): add dedicated chart batch shortcuts

* fix(sheets): allow chart color theme patches

* fix(sheets): persist chart color theme updates

* feat(sheets): simplify batch chart operations

* fix(sheets): preserve batch scope and cross-sheet chart ranges

* feat(sheets): support bubble waterfall and pareto charts

* fix(sheets): sync special chart tool schema

* feat(sheets): add semantic bubble chart indexes

* docs(sheets): sync combined chart workflow guidance

* fix(sheets): align combined chart artifacts

* feat(sheets): add x-axis number interpretation flag

* docs(sheets): validate chart axis semantics

* fix(sheets): allow recursive chart update patches

* test(sheets): isolate chart create schema check

* feat(sheets): refine semantic chart creation

* docs(sheets): sync semantic chart guidance

* docs(sheets): remove unrelated label position guidance

* fix(sheets): support all chart data label combinations

* fix(sheets): support numeric x-axis bounds

* feat(sheets): support chart y-axis bounds

* feat(sheets): add last-point chart label flag

* fix(sheets): preserve disabled waterfall stacking

* fix(sheets): nest last-point chart label property

* fix(sheets): resolve lint and dead-code CI failures

- lark_sheet_chart.go: drop redundant chartConfigUpdateInput /
  chartDataUpdateInput calls in Execute whose result is immediately
  overwritten by the *FromSnapshot variant (ineffassign); the snapshot
  variants already re-run the same validation internally.
- lark_sheet_chart_test.go: remove Go 1.22+ redundant loop-variable
  copies (copyloopvar).
- batch_op_dispatch.go / lark_sheet_batch_update.go: remove unreachable
  allowedBatchShortcuts and batchUpdateInput; callers use the lower-level
  allowedShortcuts and buildBatchUpdatePlan directly.

* fix(sheets): validate chart config updates

* fix(sheets): sync skill specs and chart schema validation

* fix(sheets): resolve chart review follow-ups

* fix(sheets): restore the two-color contract

* fix(sheets): require at least two chart colors

* docs(sheets): expose advanced chart shortcut flags

* fix(sheets): address chart review feedback

* fix(sheets): surface ignored batch locators

* chore(sheets): bump skill version to 3.1.6

* fix(sheets): surface batch warnings consistently

* test(sheets): satisfy copyloopvar lint

* docs(sheets): sync skill from spec

* fix(sheets): harden chart batch updates

* fix(sheets): tighten chart update validation

* fix(sheets): canonicalize chart ranges and batch targets
2026-08-25 18:53:47 +08:00

1748 lines
55 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"math"
"regexp"
"strconv"
"strings"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
var chartHexColorPattern = regexp.MustCompile(`^#?[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$`)
// Sheet currently accepts at most 50 value series in one chart. Keep the
// client-side guard aligned with the tool-layer contract so an obviously wide
// source fails before a write call and tells the agent how to recover.
const chartSeriesMaxCount = 50
type chartRoleIndex struct {
flag string
role string
index int
}
var bubbleRoleIndexFlags = []struct {
flag string
role string
}{
{flag: "x-index", role: "x"},
{flag: "y-index", role: "y"},
{flag: "group-index", role: "group"},
{flag: "size-index", role: "size"},
}
func resolveBubbleRoleIndexes(rt flagView, chartType string, dimensionCount int) (int, []chartRoleIndex, bool, error) {
semantic := rt.Changed("key-index")
for _, item := range bubbleRoleIndexFlags {
semantic = semantic || rt.Changed(item.flag)
}
if !semantic {
return 0, nil, false, nil
}
if chartType != "" && chartType != "bubble" {
return 0, nil, false, sheetsValidationForFlag("key-index", "bubble role indexes are only valid with --chart-type bubble")
}
if rt.Changed("dim1-index") || rt.Changed("dim2-indexes") {
return 0, nil, false, sheetsValidationForFlag("key-index", "bubble role indexes must not be combined with --dim1-index or --dim2-indexes")
}
if !rt.Changed("x-index") || !rt.Changed("y-index") {
return 0, nil, false, sheetsValidationForFlag("x-index", "--x-index and --y-index must be provided together for a bubble chart")
}
keyIndex := 1
if rt.Changed("key-index") {
keyIndex = rt.Int("key-index")
}
if keyIndex < 1 || (dimensionCount > 0 && keyIndex > dimensionCount) {
return 0, nil, false, sheetsValidationForFlag("key-index", "--key-index must be between 1 and %d for the selected --data-range", dimensionCount)
}
seen := map[int]string{keyIndex: "key"}
series := make([]chartRoleIndex, 0, len(bubbleRoleIndexFlags))
for _, item := range bubbleRoleIndexFlags {
if !rt.Changed(item.flag) {
continue
}
index := rt.Int(item.flag)
if index < 1 || (dimensionCount > 0 && index > dimensionCount) {
return 0, nil, false, sheetsValidationForFlag(item.flag, "--%s must be between 1 and %d for the selected --data-range", item.flag, dimensionCount)
}
if existingRole, exists := seen[index]; exists {
return 0, nil, false, sheetsValidationForFlag(item.flag, "--%s must not reuse index %d already assigned to %s", item.flag, index, existingRole)
}
seen[index] = item.role
series = append(series, chartRoleIndex{flag: item.flag, role: item.role, index: index})
}
return keyIndex, series, true, nil
}
var chartSemanticConfigFlags = []string{
"title",
"subtitle",
"legend-position",
"x-axis-title",
"y-axis-title",
"secondary-y-axis-title",
"x-axis-label-angle",
"y-axis-label-angle",
"x-axis-min",
"x-axis-max",
"y-axis-min",
"y-axis-max",
"data-labels",
"data-label-position",
"stack",
"color-palette",
}
// ChartCreateBasic creates a complete server-side chart snapshot from a chart
// type and a rectangular source range. The CLI only forwards semantic input;
// it deliberately does not own or duplicate the full chart snapshot template.
var ChartCreateBasic = common.Shortcut{
Service: "sheets",
Command: "+chart-create-basic",
Description: "Create a basic chart from a chart type and data range; the server builds and validates the full snapshot.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+chart-create-basic"),
PostMount: configureChartSemanticCommand,
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
_, err = chartCreateBasicInput(runtime, token, sheetID, sheetName)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := chartCreateBasicInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "manage_chart_object", 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 := chartCreateBasicInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_chart_object", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
// ChartConfigUpdate updates the common chart settings that repeatedly caused
// full-snapshot retries in eval traces. Advanced per-series and marker styling
// remains on +chart-update --properties.
var ChartConfigUpdate = common.Shortcut{
Service: "sheets",
Command: "+chart-config-update",
Description: "Update common chart titles, axes, legend, labels, stacking, smoothing, or chart-level colors without sending a full snapshot.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:read", "sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+chart-config-update"),
Tips: []string{
"--dry-run validates the request shape only; execution reads the current chart snapshot, so X-axis bounds can still be rejected unless the existing bottom X axis is continuous (valueType=linear).",
},
PostMount: configureChartSemanticCommand,
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
_, err = chartConfigUpdateInput(runtime, token, sheetID, sheetName)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := chartConfigUpdateInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "manage_chart_object", 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
}
snapshot, err := fetchChartSnapshot(ctx, runtime, token, sheetID, sheetName, runtime.Str("chart-id"))
if err != nil {
return err
}
input, viewModel, err := chartConfigUpdateInputFromSnapshot(runtime, token, sheetID, sheetName, snapshot)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_chart_object", input)
if err != nil {
return err
}
if runtime.Changed("last-point-label") {
updatedSnapshot, readErr := fetchChartSnapshot(
ctx, runtime, token, sheetID, sheetName, runtime.Str("chart-id"),
)
if readErr != nil {
return readErr
}
viewModel = chartViewModel(updatedSnapshot)
}
runtime.Out(withChartShortcutResult(out, "viewModel", viewModel), nil)
return nil
},
}
// ChartDataUpdate rebinds an existing chart to a new source range. The CLI
// reads the current snapshot, rebuilds its data mapping, and preserves the
// chart's layout and visual configuration.
var ChartDataUpdate = common.Shortcut{
Service: "sheets",
Command: "+chart-data-update",
Description: "Update an existing chart's data range or direction while preserving its layout and visual configuration.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:read", "sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+chart-data-update"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
_, err = chartDataUpdateInput(runtime, token, sheetID, sheetName)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := chartDataUpdateInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "manage_chart_object", 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
}
snapshot, err := fetchChartSnapshot(ctx, runtime, token, sheetID, sheetName, runtime.Str("chart-id"))
if err != nil {
return err
}
input, data, ranges, notice, err := chartDataUpdateInputFromSnapshot(runtime, token, sheetID, sheetName, snapshot)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_chart_object", input)
if err != nil {
return err
}
result := withChartShortcutResult(out, "data", data)
result["normalized_data_ranges"] = ranges
if notice != "" {
result["normalization_notice"] = notice
}
runtime.Out(result, nil)
return nil
},
}
func chartCreateBasicInput(rt flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
chartType := strings.TrimSpace(rt.Str("chart-type"))
if chartType == "" {
return nil, sheetsValidationForFlag("chart-type", "--chart-type is required")
}
dataRange := strings.TrimSpace(rt.Str("data-range"))
if dataRange == "" {
return nil, sheetsValidationForFlag("data-range", "--data-range is required")
}
direction := rt.Str("data-direction")
if direction == "" {
direction = "column"
}
normalizedDataRange, dimensionCount, dataPointCount, _, err := normalizeBasicChartDataRanges(dataRange, direction)
if err != nil {
return nil, err
}
if dimensionCount < 2 || dataPointCount < 2 {
if direction == "column" && dataPointCount < 2 && dimensionCount >= 2 {
return nil, sheetsValidationForFlag(
"data-range",
"--data-range has one row and multiple columns; if categories run horizontally, include the category and value rows in --data-range and use --data-direction row",
)
}
return nil, sheetsValidationForFlag("data-range", "--data-range must provide at least 2 data points and 2 dimensions")
}
bubbleKeyIndex, bubbleSeries, useBubbleRoles, err := resolveBubbleRoleIndexes(rt, chartType, dimensionCount)
if err != nil {
return nil, err
}
dim1Index := 1
if useBubbleRoles {
dim1Index = bubbleKeyIndex
} else if rt.Changed("dim1-index") {
dim1Index = rt.Int("dim1-index")
}
if dim1Index < 1 || dim1Index > dimensionCount {
return nil, sheetsValidationForFlag(
"dim1-index",
"--dim1-index must be between 1 and %d for the selected --data-range",
dimensionCount,
)
}
dim2Indexes := make([]int, 0, dimensionCount-1)
if useBubbleRoles {
for _, item := range bubbleSeries {
dim2Indexes = append(dim2Indexes, item.index)
}
} else {
for index := 1; index <= dimensionCount; index++ {
if index != dim1Index {
dim2Indexes = append(dim2Indexes, index)
}
}
}
if chartType == "pie" || chartType == "pareto" {
dim2Indexes = dim2Indexes[:1]
}
if rt.Changed("dim2-indexes") {
dim2Indexes, err = parseChartDim2Indexes(rt.Str("dim2-indexes"))
if err != nil {
return nil, sheetsValidationForFlag("dim2-indexes", "%v", err)
}
}
for _, index := range dim2Indexes {
if index > dimensionCount {
return nil, sheetsValidationForFlag(
"dim2-indexes",
"--dim2-indexes must contain only indexes between 1 and %d for the selected --data-range",
dimensionCount,
)
}
if index == dim1Index {
return nil, sheetsValidationForFlag(
"dim2-indexes",
"--dim2-indexes must not contain the dim1 index %d",
dim1Index,
)
}
}
if chartType == "pie" && len(dim2Indexes) != 1 {
return nil, sheetsValidationForFlag("dim2-indexes", "pie charts require exactly one --dim2-indexes value")
}
if chartType == "combo" && len(dim2Indexes) < 2 {
return nil, sheetsValidationForFlag("dim2-indexes", "combo charts require at least two --dim2-indexes values")
}
if chartType != "combo" && (rt.Changed("series-types") || rt.Changed("series-y-axes")) {
return nil, sheetsValidationForFlag("series-types", "--series-types and --series-y-axes are only valid for combo charts")
}
var seriesTypes []string
if rt.Changed("series-types") {
seriesTypes, err = parseChartEnumList(rt.Str("series-types"), "series-types", []string{"column", "line", "area"})
if err != nil {
return nil, err
}
if len(seriesTypes) != len(dim2Indexes) {
return nil, sheetsValidationForFlag("series-types", "--series-types must contain one value per selected value series")
}
}
var seriesYAxes []string
if rt.Changed("series-y-axes") {
seriesYAxes, err = parseChartEnumList(rt.Str("series-y-axes"), "series-y-axes", []string{"left", "right"})
if err != nil {
return nil, err
}
if len(seriesYAxes) != len(dim2Indexes) {
return nil, sheetsValidationForFlag("series-y-axes", "--series-y-axes must contain one value per selected value series")
}
}
if chartType == "bubble" && (len(dim2Indexes) < 2 || len(dim2Indexes) > 4) {
return nil, sheetsValidationForFlag("dim2-indexes", "bubble charts require x and y plus optional group and size indexes")
}
if chartType == "pareto" && len(dim2Indexes) != 1 {
return nil, sheetsValidationForFlag("dim2-indexes", "pareto charts require exactly one --dim2-indexes value")
}
if len(dim2Indexes) > chartSeriesMaxCount {
flagName := "data-range"
if rt.Changed("dim2-indexes") {
flagName = "dim2-indexes"
}
return nil, sheetsValidationForFlag(
flagName,
"the selected dimensions create %d series, over the current limit of %d; provide at most %d --dim2-indexes values or build a compact summary table",
len(dim2Indexes),
chartSeriesMaxCount,
chartSeriesMaxCount,
)
}
xAxisNumbersAs := strings.TrimSpace(rt.Str("x-axis-numbers-as"))
if xAxisNumbersAs == "" {
xAxisNumbersAs = "text"
}
if xAxisNumbersAs != "text" && xAxisNumbersAs != "values" {
return nil, sheetsValidationForFlag(
"x-axis-numbers-as",
"--x-axis-numbers-as must be text or values",
)
}
if err := validateChartAxisBounds(rt, "x", xAxisNumbersAs == "values"); err != nil {
return nil, err
}
if err := validateChartAxisBounds(rt, "y", true); err != nil {
return nil, err
}
basic := map[string]interface{}{
"chart_type": chartType,
"data_range": normalizedDataRange,
"x_axis_numbers_as": xAxisNumbersAs,
}
if rt.Changed("header-range") {
headerRange := strings.TrimSpace(rt.Str("header-range"))
if err := validateChartRangeListFlag("header-range", headerRange); err != nil {
return nil, err
}
if err := validateChartHeaderRangeDirection(headerRange, direction); err != nil {
return nil, err
}
if _, err := buildChartHeaderRefs(headerRange, direction, dimensionCount); err != nil {
return nil, err
}
basic["header_range"] = headerRange
}
if rt.Changed("data-direction") {
basic["data_direction"] = rt.Str("data-direction")
}
if useBubbleRoles {
basic["key_index"] = dim1Index
for _, item := range bubbleSeries {
basic[strings.ReplaceAll(item.flag, "-", "_")] = item.index
}
} else if rt.Changed("dim1-index") {
basic["dim1_index"] = dim1Index
}
if !useBubbleRoles && rt.Changed("dim2-indexes") {
basic["dim2_indexes"] = dim2Indexes
}
if rt.Changed("series-types") {
basic["series_types"] = seriesTypes
}
if rt.Changed("series-y-axes") {
basic["series_y_axes"] = seriesYAxes
}
if err := validateChartColorFlags(rt); err != nil {
return nil, err
}
if err := validateChartSemanticEnums(rt); err != nil {
return nil, err
}
addChartSemanticConfig(rt, basic)
if rt.Changed("anchor-cell") {
anchor := strings.TrimSpace(rt.Str("anchor-cell"))
_, row, ok := splitCellRef(anchor)
if !ok {
return nil, sheetsValidationForFlag("anchor-cell", "--anchor-cell must be a single A1 cell such as F2")
}
colEnd := 0
for colEnd < len(anchor) && ((anchor[colEnd] >= 'A' && anchor[colEnd] <= 'Z') || (anchor[colEnd] >= 'a' && anchor[colEnd] <= 'z')) {
colEnd++
}
basic["position"] = map[string]interface{}{"row": row, "col": strings.ToUpper(anchor[:colEnd])}
}
widthChanged := rt.Changed("width")
heightChanged := rt.Changed("height")
if widthChanged != heightChanged {
return nil, common.ValidationErrorf("--width and --height must be provided together").WithParams(
sheetsInvalidParam("width", "must be paired with --height"),
sheetsInvalidParam("height", "must be paired with --width"),
)
}
if widthChanged {
if rt.Int("width") < 10 || rt.Int("height") < 10 {
return nil, common.ValidationErrorf("--width and --height must be at least 10")
}
basic["size"] = map[string]interface{}{"width": rt.Int("width"), "height": rt.Int("height")}
}
input := map[string]interface{}{"excel_id": token, "operation": "create", "basic_chart": basic}
sheetSelectorForToolInput(input, sheetID, sheetName)
if err := validateInputAgainstSchema(rt, input); err != nil {
return nil, err
}
return input, nil
}
func chartConfigUpdateInput(rt flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
chartID := strings.TrimSpace(rt.Str("chart-id"))
if chartID == "" {
return nil, sheetsValidationForFlag("chart-id", "--chart-id is required")
}
updates := map[string]interface{}{}
if err := validateChartColorFlags(rt); err != nil {
return nil, err
}
if err := validateChartSemanticEnums(rt); err != nil {
return nil, err
}
if err := validateChartAxisBounds(rt, "x", true); err != nil {
return nil, err
}
if err := validateChartAxisBounds(rt, "y", true); err != nil {
return nil, err
}
addChartSemanticConfig(rt, updates)
if len(updates) == 0 && !rt.Changed("last-point-label") {
return nil, common.ValidationErrorf("at least one chart configuration flag is required")
}
patch, _ := applyChartConfigPatch(map[string]interface{}{}, updates)
input := map[string]interface{}{
"excel_id": token,
"operation": "update",
"chart_id": chartID,
"properties": map[string]interface{}{
"snapshot": patch,
},
}
if rt.Changed("last-point-label") {
input["properties"].(map[string]interface{})["last_point_label"] = rt.Bool("last-point-label")
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if err := validateInputAgainstSchema(rt, input); err != nil {
return nil, err
}
return input, nil
}
func chartDataUpdateInput(rt flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
chartID := strings.TrimSpace(rt.Str("chart-id"))
if chartID == "" {
return nil, sheetsValidationForFlag("chart-id", "--chart-id is required")
}
dataRange := strings.TrimSpace(rt.Str("data-range"))
if dataRange == "" {
return nil, sheetsValidationForFlag("data-range", "--data-range is required")
}
ranges, err := splitChartDataRanges(dataRange)
if err != nil {
return nil, sheetsValidationForFlag("data-range", "invalid --data-range %q: %v", dataRange, err)
}
for _, value := range ranges {
_, parseErr := parseChartDataRange(value)
if parseErr != nil {
return nil, sheetsValidationForFlag("data-range", "invalid --data-range item %q: %v", value, parseErr)
}
}
updates := map[string]interface{}{"data_range": dataRange}
if rt.Changed("header-range") {
headerRange := strings.TrimSpace(rt.Str("header-range"))
if err := validateChartRangeListFlag("header-range", headerRange); err != nil {
return nil, err
}
updates["header_range"] = headerRange
}
if rt.Changed("data-direction") {
updates["data_direction"] = rt.Str("data-direction")
}
dim1Index := 1
if rt.Changed("dim1-index") {
dim1Index = rt.Int("dim1-index")
if dim1Index < 1 {
return nil, sheetsValidationForFlag("dim1-index", "--dim1-index must be a positive 1-based index")
}
updates["dim1_index"] = dim1Index
}
if rt.Changed("dim2-indexes") {
dim2Indexes, parseErr := parseChartDim2Indexes(rt.Str("dim2-indexes"))
if parseErr != nil {
return nil, sheetsValidationForFlag("dim2-indexes", "%v", parseErr)
}
for _, index := range dim2Indexes {
if index == dim1Index {
return nil, sheetsValidationForFlag(
"dim2-indexes",
"--dim2-indexes must not contain the dim1 index %d",
dim1Index,
)
}
}
if len(dim2Indexes) > chartSeriesMaxCount {
return nil, sheetsValidationForFlag(
"dim2-indexes",
"--dim2-indexes selects %d series, over the current limit of %d; select fewer series",
len(dim2Indexes),
chartSeriesMaxCount,
)
}
updates["dim2_indexes"] = dim2Indexes
}
bubbleKeyIndex, bubbleSeries, useBubbleRoles, err := resolveBubbleRoleIndexes(rt, "", 0)
if err != nil {
return nil, err
}
if useBubbleRoles {
updates["key_index"] = bubbleKeyIndex
for _, item := range bubbleSeries {
updates[strings.ReplaceAll(item.flag, "-", "_")] = item.index
}
}
dryRunData, err := chartDataDryRunPatch(updates)
if err != nil {
return nil, err
}
input := map[string]interface{}{
"excel_id": token,
"operation": "update",
"chart_id": chartID,
"properties": map[string]interface{}{
"snapshot": map[string]interface{}{
"data": dryRunData,
},
},
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if err := validateInputAgainstSchema(rt, input); err != nil {
return nil, err
}
return input, nil
}
func chartConfigUpdateInputFromSnapshot(
rt flagView,
token, sheetID, sheetName string,
snapshot map[string]interface{},
) (map[string]interface{}, map[string]interface{}, error) {
if _, err := chartConfigUpdateInput(rt, token, sheetID, sheetName); err != nil {
return nil, nil, err
}
if err := validateChartConfigSnapshot(rt, snapshot); err != nil {
return nil, nil, err
}
updates := map[string]interface{}{}
addChartSemanticConfig(rt, updates)
patch, viewModel := applyChartConfigPatch(snapshot, updates)
input := map[string]interface{}{
"excel_id": token,
"operation": "update",
"chart_id": strings.TrimSpace(rt.Str("chart-id")),
"properties": map[string]interface{}{
"snapshot": patch,
},
}
if rt.Changed("last-point-label") {
input["properties"].(map[string]interface{})["last_point_label"] = rt.Bool("last-point-label")
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if err := validateInputAgainstSchema(rt, input); err != nil {
return nil, nil, err
}
return input, viewModel, nil
}
func chartDataUpdateInputFromSnapshot(
rt flagView,
token, sheetID, sheetName string,
snapshot map[string]interface{},
) (map[string]interface{}, map[string]interface{}, []string, string, error) {
if _, err := chartDataUpdateInput(rt, token, sheetID, sheetName); err != nil {
return nil, nil, nil, "", err
}
currentData, _ := snapshot["data"].(map[string]interface{})
if static, _ := currentData["isStaticData"].(bool); static {
return nil, nil, nil, "", sheetsValidationForFlag("chart-id", "+chart-data-update does not support static-data charts")
}
chartType := chartTypeFromSnapshot(snapshot)
if !isBasicChartType(chartType) {
return nil, nil, nil, "", sheetsValidationForFlag(
"chart-id",
"+chart-data-update does not support chart type %q",
chartType,
)
}
direction := "column"
if value, ok := currentData["direction"].(string); ok && value != "" {
direction = value
}
if rt.Changed("data-direction") {
direction = rt.Str("data-direction")
}
dataRange := strings.TrimSpace(rt.Str("data-range"))
normalized, dimensionCount, _, merged, err := normalizeBasicChartDataRanges(dataRange, direction)
if err != nil {
return nil, nil, nil, "", err
}
bubbleKeyIndex, bubbleSeries, useBubbleRoles, err := resolveBubbleRoleIndexes(rt, chartType, dimensionCount)
if err != nil {
return nil, nil, nil, "", err
}
dim1Index := 1
if useBubbleRoles {
dim1Index = bubbleKeyIndex
} else if rt.Changed("dim1-index") {
dim1Index = rt.Int("dim1-index")
}
if dim1Index < 1 || dim1Index > dimensionCount {
return nil, nil, nil, "", sheetsValidationForFlag(
"dim1-index",
"--dim1-index must be between 1 and %d for the selected --data-range",
dimensionCount,
)
}
dim2Indexes := make([]int, 0, dimensionCount-1)
if useBubbleRoles {
for _, item := range bubbleSeries {
dim2Indexes = append(dim2Indexes, item.index)
}
} else {
for index := 1; index <= dimensionCount; index++ {
if index != dim1Index {
dim2Indexes = append(dim2Indexes, index)
}
}
}
if (chartType == "pie" || chartType == "pareto") && len(dim2Indexes) > 1 {
dim2Indexes = dim2Indexes[:1]
}
if rt.Changed("dim2-indexes") {
dim2Indexes, err = parseChartDim2Indexes(rt.Str("dim2-indexes"))
if err != nil {
return nil, nil, nil, "", sheetsValidationForFlag("dim2-indexes", "%v", err)
}
}
for _, index := range dim2Indexes {
if index > dimensionCount {
return nil, nil, nil, "", sheetsValidationForFlag(
"dim2-indexes",
"--dim2-indexes must contain only indexes between 1 and %d for the selected --data-range",
dimensionCount,
)
}
if index == dim1Index {
return nil, nil, nil, "", sheetsValidationForFlag(
"dim2-indexes",
"--dim2-indexes must not contain the dim1 index %d",
dim1Index,
)
}
}
if chartType == "pie" && len(dim2Indexes) != 1 {
return nil, nil, nil, "", sheetsValidationForFlag("dim2-indexes", "pie charts require exactly one --dim2-indexes value")
}
if chartType == "combo" && len(dim2Indexes) < 2 {
return nil, nil, nil, "", sheetsValidationForFlag("dim2-indexes", "combo charts require at least two --dim2-indexes values")
}
if chartType == "bubble" && (len(dim2Indexes) < 2 || len(dim2Indexes) > 4) {
return nil, nil, nil, "", sheetsValidationForFlag("dim2-indexes", "bubble charts require x and y plus optional group and size indexes")
}
if chartType == "pareto" && len(dim2Indexes) != 1 {
return nil, nil, nil, "", sheetsValidationForFlag("dim2-indexes", "pareto charts require exactly one --dim2-indexes value")
}
if len(dim2Indexes) > chartSeriesMaxCount {
return nil, nil, nil, "", sheetsValidationForFlag(
"dim2-indexes",
"--dim2-indexes selects %d series, over the current limit of %d; select fewer series",
len(dim2Indexes),
chartSeriesMaxCount,
)
}
headerRefs := existingChartHeaderRefs(currentData)
detached := currentData["headerMode"] == "detached"
if rt.Changed("header-range") {
headerRefs, err = buildChartHeaderRefs(strings.TrimSpace(rt.Str("header-range")), direction, dimensionCount)
if err != nil {
return nil, nil, nil, "", err
}
detached = true
}
if detached {
for _, index := range append([]int{dim1Index}, dim2Indexes...) {
if headerRefs[index] == "" {
return nil, nil, nil, "", sheetsValidationForFlag(
"header-range",
"--header-range is required because the detached header for dimension %d is missing",
index,
)
}
}
}
rangeValues, _ := splitChartDataRanges(normalized)
refs := make([]interface{}, 0, len(rangeValues))
for _, value := range rangeValues {
refs = append(refs, map[string]interface{}{"value": value})
}
dim1 := map[string]interface{}{"index": dim1Index}
if detached {
dim1["nameRef"] = headerRefs[dim1Index]
}
series := make([]interface{}, 0, len(dim2Indexes))
for seriesIndex, index := range dim2Indexes {
item := map[string]interface{}{"index": index}
if chartType == "bubble" {
role := []string{"x", "y", "group", "size"}[seriesIndex]
if useBubbleRoles {
role = bubbleSeries[seriesIndex].role
}
item["role"] = role
}
if detached {
item["nameRef"] = headerRefs[index]
}
series = append(series, item)
}
data := map[string]interface{}{
"isStaticData": false,
"direction": direction,
"refs": refs,
"dim1": map[string]interface{}{"serie": dim1},
"dim2": map[string]interface{}{"series": series},
}
if detached {
data["headerMode"] = "detached"
}
patch := map[string]interface{}{"data": data}
if chartType == "combo" {
patch["plotArea"] = comboPlotAreaForIndexes(snapshot, dim2Indexes)
}
input := map[string]interface{}{
"excel_id": token,
"operation": "update",
"chart_id": strings.TrimSpace(rt.Str("chart-id")),
"properties": map[string]interface{}{
"snapshot": patch,
},
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if err := validateInputAgainstSchema(rt, input); err != nil {
return nil, nil, nil, "", err
}
notice := ""
if merged {
notice = "Multiple data ranges were not aligned and were merged into the smallest enclosing rectangular range; review normalized_data_ranges before continuing."
}
return input, data, rangeValues, notice, nil
}
func chartDataDryRunPatch(updates map[string]interface{}) (map[string]interface{}, error) {
data := map[string]interface{}{}
dataRange, _ := updates["data_range"].(string)
direction, _ := updates["data_direction"].(string)
if direction == "" {
direction = "column"
}
normalized, dimensionCount, _, _, err := normalizeBasicChartDataRanges(dataRange, direction)
if err != nil {
return nil, err
}
if dataRange != "" {
ranges, err := splitChartDataRanges(normalized)
if err != nil {
return nil, sheetsValidationForFlag("data-range", "invalid --data-range %q: %v", normalized, err)
}
refs := make([]interface{}, 0, len(ranges))
for _, item := range ranges {
refs = append(refs, map[string]interface{}{"value": item})
}
data["refs"] = refs
}
if value, ok := updates["data_direction"]; ok {
data["direction"] = value
}
dim1Index := 1
if value, ok := chartInt(updates["key_index"]); ok {
dim1Index = value
} else if value, ok := chartInt(updates["dim1_index"]); ok {
dim1Index = value
}
dim2Indexes := make([]int, 0, max(0, dimensionCount-1))
for index := 1; index <= dimensionCount; index++ {
if index != dim1Index {
dim2Indexes = append(dim2Indexes, index)
}
}
if values, ok := updates["dim2_indexes"].([]int); ok {
dim2Indexes = values
}
headerRefs := map[int]string{}
if headerRange, ok := updates["header_range"].(string); ok {
headerRefs, err = buildChartHeaderRefs(headerRange, direction, dimensionCount)
if err != nil {
return nil, err
}
data["headerMode"] = "detached"
}
dim1 := map[string]interface{}{"index": dim1Index}
if headerRefs[dim1Index] != "" {
dim1["nameRef"] = headerRefs[dim1Index]
}
data["dim1"] = map[string]interface{}{"serie": dim1}
semanticRoles := make([]chartRoleIndex, 0, len(bubbleRoleIndexFlags))
for _, item := range bubbleRoleIndexFlags {
if value, ok := chartInt(updates[strings.ReplaceAll(item.flag, "-", "_")]); ok {
semanticRoles = append(semanticRoles, chartRoleIndex{flag: item.flag, role: item.role, index: value})
}
}
if len(semanticRoles) > 0 {
dim2Indexes = dim2Indexes[:0]
for _, item := range semanticRoles {
dim2Indexes = append(dim2Indexes, item.index)
}
}
series := make([]interface{}, 0, len(dim2Indexes))
for seriesIndex, value := range dim2Indexes {
item := map[string]interface{}{"index": value}
if len(semanticRoles) > 0 {
item["role"] = semanticRoles[seriesIndex].role
}
if headerRefs[value] != "" {
item["nameRef"] = headerRefs[value]
}
series = append(series, item)
}
data["dim2"] = map[string]interface{}{"series": series}
return data, nil
}
func fetchChartSnapshot(
ctx context.Context,
runtime *common.RuntimeContext,
token, sheetID, sheetName, chartID string,
) (map[string]interface{}, error) {
input := map[string]interface{}{
"excel_id": token,
"chart_id": strings.TrimSpace(chartID),
}
sheetSelectorForToolInput(input, sheetID, sheetName)
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_chart_objects", input)
if err != nil {
return nil, err
}
root, _ := out.(map[string]interface{})
if data, ok := root["data"].(map[string]interface{}); ok {
root = data
}
sheets, _ := root["sheets"].([]interface{})
for _, rawSheet := range sheets {
sheet, _ := rawSheet.(map[string]interface{})
charts, _ := sheet["charts"].([]interface{})
for _, rawChart := range charts {
chart, _ := rawChart.(map[string]interface{})
if id, _ := chart["chart_id"].(string); id != strings.TrimSpace(chartID) {
continue
}
details, _ := chart["details"].(map[string]interface{})
snapshot, _ := details["snapshot"].(map[string]interface{})
if snapshot == nil {
return nil, sheetsValidationForFlag("chart-id", "chart %q has no editable snapshot", chartID)
}
return snapshot, nil
}
}
return nil, sheetsValidationForFlag("chart-id", "chart %q was not found on the selected sheet", chartID)
}
func withChartShortcutResult(out interface{}, key string, value interface{}) map[string]interface{} {
result := map[string]interface{}{}
if object, ok := out.(map[string]interface{}); ok {
for name, item := range object {
result[name] = item
}
} else if out != nil {
result["tool_output"] = out
}
result[key] = value
return result
}
func applyChartConfigPatch(
current map[string]interface{},
updates map[string]interface{},
) (map[string]interface{}, map[string]interface{}) {
next := cloneChartMap(current)
patch := map[string]interface{}{}
plotChanged := false
if value, ok := updates["title"].(string); ok {
title := chartMap(next["title"])
title["text"] = value
next["title"] = title
patch["title"] = title
}
if value, ok := updates["subtitle"].(string); ok {
subtitle := chartMap(next["subTitle"])
subtitle["text"] = value
next["subTitle"] = subtitle
patch["subTitle"] = subtitle
}
if value, ok := updates["legend_position"].(string); ok {
if value == "hidden" {
next["legend"] = false
patch["legend"] = false
} else {
legend := chartMap(next["legend"])
legend["position"] = value
next["legend"] = legend
patch["legend"] = legend
}
}
plotArea := chartMap(next["plotArea"])
plot := chartMap(plotArea["plot"])
plotArea["plot"] = plot
next["plotArea"] = plotArea
for _, item := range []struct {
key string
axisType string
position string
title bool
}{
{"x_axis_title", "x", "bottom", true},
{"y_axis_title", "y", "left", true},
{"secondary_y_axis_title", "y", "right", true},
{"x_axis_label_angle", "x", "bottom", false},
{"y_axis_label_angle", "y", "left", false},
} {
value, ok := updates[item.key]
if !ok {
continue
}
axis := ensureChartAxisMap(plotArea, item.axisType, item.position)
if item.title {
axis["title"] = map[string]interface{}{"text": value}
} else {
label := chartMap(axis["label"])
label["angle"] = value
axis["label"] = label
}
plotChanged = true
}
for _, item := range []struct {
key string
axisType string
position string
axisProp string
}{
{"x_axis_min", "x", "bottom", "min"},
{"x_axis_max", "x", "bottom", "max"},
{"y_axis_min", "y", "left", "min"},
{"y_axis_max", "y", "left", "max"},
} {
value, ok := updates[item.key]
if !ok {
continue
}
axis := ensureChartAxisMap(plotArea, item.axisType, item.position)
axis[item.axisProp] = value
plotChanged = true
}
if value, ok := updates["data_labels"].(string); ok {
if value == "none" {
delete(plot, "labels")
} else {
labels := map[string]interface{}{
"series": value == "series",
"category": strings.Contains(value, "category"),
"value": strings.Contains(value, "value"),
"percentage": strings.Contains(value, "percentage"),
}
if position, ok := updates["data_label_position"]; ok {
labels["position"] = position
}
plot["labels"] = labels
}
plotChanged = true
} else if position, ok := updates["data_label_position"]; ok {
labels, exists := plot["labels"].(map[string]interface{})
if exists {
labels["position"] = position
plot["labels"] = labels
plotChanged = true
}
}
if value, ok := updates["stack"].(string); ok {
extra := chartMap(plot["extra"])
if value == "none" {
chartType, _ := plot["type"].(string)
if chartType == "" || chartType == "waterfall" {
extra["stack"] = map[string]interface{}{"enabled": false}
} else {
delete(extra, "stack")
}
} else {
extra["stack"] = map[string]interface{}{"percentage": value == "percent"}
}
plot["extra"] = extra
plotChanged = true
}
if value, ok := updates["smooth"].(bool); ok {
extra := chartMap(plot["extra"])
extra["smooth"] = value
plot["extra"] = extra
plotChanged = true
}
var colorTheme []interface{}
if value, ok := updates["color_palette"].(string); ok {
colorTheme = []interface{}{value}
}
if values, ok := updates["colors"].([]string); ok {
colorTheme = make([]interface{}, 0, len(values))
for _, value := range values {
colorTheme = append(colorTheme, value)
}
}
if colorTheme != nil {
style := chartMap(next["style"])
style["colorTheme"] = colorTheme
next["style"] = style
patch["style"] = map[string]interface{}{"colorTheme": colorTheme}
}
if plotChanged {
patch["plotArea"] = plotArea
}
return patch, chartViewModel(next)
}
func chartViewModel(snapshot map[string]interface{}) map[string]interface{} {
viewModel := cloneChartMap(snapshot)
delete(viewModel, "data")
return viewModel
}
func cloneChartMap(value map[string]interface{}) map[string]interface{} {
if value == nil {
return map[string]interface{}{}
}
raw, _ := json.Marshal(value)
out := map[string]interface{}{}
_ = json.Unmarshal(raw, &out)
return out
}
func chartMap(value interface{}) map[string]interface{} {
if object, ok := value.(map[string]interface{}); ok {
return object
}
return map[string]interface{}{}
}
func ensureChartAxisMap(plotArea map[string]interface{}, axisType, position string) map[string]interface{} {
if axis := findChartAxisMap(plotArea, axisType, position); axis != nil {
return axis
}
axes, _ := plotArea["axes"].([]interface{})
axis := map[string]interface{}{"type": axisType, "position": position}
plotArea["axes"] = append(axes, axis)
return axis
}
func findChartAxisMap(plotArea map[string]interface{}, axisType, position string) map[string]interface{} {
axes, _ := plotArea["axes"].([]interface{})
for _, raw := range axes {
axis, _ := raw.(map[string]interface{})
if axis["type"] == axisType && axis["position"] == position {
return axis
}
}
return nil
}
func chartTypeFromSnapshot(snapshot map[string]interface{}) string {
plotArea := chartMap(snapshot["plotArea"])
plot := chartMap(plotArea["plot"])
value, _ := plot["type"].(string)
return value
}
func isBasicChartType(value string) bool {
switch value {
case "column", "bar", "line", "area", "pie", "scatter", "combo", "radar", "bubble", "waterfall", "pareto":
return true
default:
return false
}
}
func existingChartHeaderRefs(data map[string]interface{}) map[int]string {
out := map[int]string{}
if dim1 := chartMap(chartMap(data["dim1"])["serie"]); dim1 != nil {
index, _ := chartInt(dim1["index"])
name, _ := dim1["nameRef"].(string)
if index > 0 && name != "" {
out[index] = name
}
}
series, _ := chartMap(data["dim2"])["series"].([]interface{})
for _, raw := range series {
item := chartMap(raw)
index, _ := chartInt(item["index"])
name, _ := item["nameRef"].(string)
if index > 0 && name != "" {
out[index] = name
}
}
return out
}
func buildChartHeaderRefs(value, direction string, dimensionCount int) (map[int]string, error) {
ranges, err := splitChartDataRanges(value)
if err != nil {
return nil, sheetsValidationForFlag("header-range", "invalid --header-range %q: %v", value, err)
}
var refs []string
for _, rangeValue := range ranges {
item, parseErr := parseChartHeaderRange(rangeValue)
if parseErr != nil {
return nil, sheetsValidationForFlag("header-range", "invalid --header-range item %q: %v", rangeValue, parseErr)
}
prefix := ""
if item.sheet != "" {
prefix = item.sheet + "!"
}
if direction == "row" {
if item.colCount != 1 {
return nil, sheetsValidationForFlag("header-range", "row-oriented --header-range must be one column")
}
for row := item.row; row < item.row+item.rowCount; row++ {
refs = append(refs, prefix+columnIndexToLetter(item.col)+strconv.Itoa(row+1))
}
} else {
if item.rowCount != 1 {
return nil, sheetsValidationForFlag("header-range", "column-oriented --header-range must be one row")
}
for col := item.col; col < item.col+item.colCount; col++ {
refs = append(refs, prefix+columnIndexToLetter(col)+strconv.Itoa(item.row+1))
}
}
}
if len(refs) != dimensionCount {
return nil, sheetsValidationForFlag(
"header-range",
"--header-range provides %d headers but --data-range has %d dimensions",
len(refs),
dimensionCount,
)
}
out := make(map[int]string, len(refs))
for index, value := range refs {
out[index+1] = value
}
return out, nil
}
func comboPlotAreaForIndexes(snapshot map[string]interface{}, indexes []int) map[string]interface{} {
plotArea := chartMap(cloneChartMap(snapshot)["plotArea"])
plot := chartMap(plotArea["plot"])
currentSeries, _ := plot["series"].([]interface{})
byIndex := map[int]map[string]interface{}{}
for _, raw := range currentSeries {
item := chartMap(raw)
if index, ok := chartInt(item["index"]); ok {
byIndex[index] = item
}
}
nextSeries := make([]interface{}, 0, len(indexes))
for position, index := range indexes {
item := cloneChartMap(byIndex[index])
if len(item) == 0 {
item["comboType"] = "line"
item["yAxisPosition"] = "right"
if position == 0 {
item["comboType"] = "column"
item["yAxisPosition"] = "left"
}
}
item["index"] = index
nextSeries = append(nextSeries, item)
}
plot["series"] = nextSeries
plotArea["plot"] = plot
return plotArea
}
func chartInt(value interface{}) (int, bool) {
switch typed := value.(type) {
case int:
return typed, true
case int64:
return int(typed), true
case float64:
return int(typed), typed == float64(int(typed))
default:
return 0, false
}
}
func validateChartRangeListFlag(flagName, value string) error {
if value == "" {
return sheetsValidationForFlag(flagName, "--%s must not be empty", flagName)
}
ranges, err := splitChartDataRanges(value)
if err != nil {
return sheetsValidationForFlag(flagName, "invalid --%s %q: %v", flagName, value, err)
}
for _, rangeValue := range ranges {
_, parseErr := parseChartHeaderRange(rangeValue)
if parseErr != nil {
return sheetsValidationForFlag(flagName, "invalid --%s item %q: %v", flagName, rangeValue, parseErr)
}
}
return nil
}
func validateChartHeaderRangeDirection(value, direction string) error {
ranges, err := splitChartDataRanges(value)
if err != nil {
return sheetsValidationForFlag("header-range", "invalid --header-range %q: %v", value, err)
}
for _, rangeValue := range ranges {
item, parseErr := parseChartHeaderRange(rangeValue)
if parseErr != nil {
return sheetsValidationForFlag("header-range", "invalid --header-range item %q: %v", rangeValue, parseErr)
}
if direction == "row" && item.colCount != 1 {
return sheetsValidationForFlag(
"header-range",
"row-oriented --header-range must be one column of dimension/series names; this horizontal range looks like a category row, so include it in --data-range and omit --header-range",
)
}
if direction == "column" && item.rowCount != 1 {
return sheetsValidationForFlag(
"header-range",
"column-oriented --header-range must be one row of dimension/series names",
)
}
}
return nil
}
func parseChartHeaderRange(value string) (chartDataRange, error) {
item := chartDataRange{}
ref := strings.TrimSpace(value)
if bang := strings.LastIndex(ref, "!"); bang >= 0 {
item.sheet = strings.TrimSpace(ref[:bang])
ref = strings.TrimSpace(ref[bang+1:])
}
if strings.Contains(ref, ":") {
return parseChartDataRange(value)
}
col, row, ok := splitCellRef(ref)
if !ok {
return item, common.ValidationErrorf("expected an A1 cell or rectangular range such as A1 or A1:C1")
}
item.row, item.col = row, col
item.rowCount, item.colCount = 1, 1
return item, nil
}
func parseChartDim2Indexes(raw string) ([]int, error) {
raw = strings.TrimSpace(raw)
if strings.HasPrefix(raw, "[") {
var indexes []int
if err := json.Unmarshal([]byte(raw), &indexes); err != nil {
return nil, common.ValidationErrorf("--dim2-indexes must be a comma-separated list or an array of positive 1-based indexes")
}
return validateChartDim2Indexes(indexes)
}
parts := strings.Split(raw, ",")
indexes := make([]int, 0, len(parts))
for _, part := range parts {
value := strings.TrimSpace(part)
if value == "" {
return nil, common.ValidationErrorf("--dim2-indexes must be a comma-separated list of positive 1-based indexes")
}
index, err := strconv.Atoi(value)
if err != nil || index < 1 {
return nil, common.ValidationErrorf("--dim2-indexes must contain only positive 1-based indexes")
}
indexes = append(indexes, index)
}
return validateChartDim2Indexes(indexes)
}
func parseChartEnumList(raw, flagName string, allowed []string) ([]string, error) {
raw = strings.TrimSpace(raw)
values := []string{}
if strings.HasPrefix(raw, "[") {
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil, sheetsValidationForFlag(flagName, "--%s must be a comma-separated list or a JSON string array", flagName)
}
} else {
values = strings.Split(raw, ",")
}
if len(values) == 0 {
return nil, sheetsValidationForFlag(flagName, "--%s must not be empty", flagName)
}
allowedSet := make(map[string]struct{}, len(allowed))
for _, value := range allowed {
allowedSet[value] = struct{}{}
}
for index := range values {
values[index] = strings.TrimSpace(values[index])
if _, ok := allowedSet[values[index]]; !ok {
return nil, sheetsValidationForFlag(
flagName,
"--%s value %q is invalid; expected one of %s",
flagName,
values[index],
strings.Join(allowed, ", "),
)
}
}
return values, nil
}
func validateChartDim2Indexes(indexes []int) ([]int, error) {
if len(indexes) == 0 {
return nil, common.ValidationErrorf("--dim2-indexes must contain at least one positive 1-based index")
}
seen := make(map[int]struct{}, len(indexes))
for _, index := range indexes {
if index < 1 {
return nil, common.ValidationErrorf("--dim2-indexes must contain only positive 1-based indexes")
}
if _, exists := seen[index]; exists {
return nil, common.ValidationErrorf("--dim2-indexes must not contain duplicate index %d", index)
}
seen[index] = struct{}{}
}
return indexes, nil
}
type chartDataRange struct {
sheet string
qualifier string
row, col int
rowCount, colCount int
}
func normalizeBasicChartDataRanges(dataRange, direction string) (normalized string, dimensionCount, dataPointCount int, merged bool, err error) {
ranges, err := splitChartDataRanges(dataRange)
if err != nil {
return "", 0, 0, false, sheetsValidationForFlag("data-range", "invalid --data-range %q: %v", dataRange, err)
}
parsed := make([]chartDataRange, 0, len(ranges))
for _, value := range ranges {
item, parseErr := parseChartDataRange(value)
if parseErr != nil {
return "", 0, 0, false, sheetsValidationForFlag("data-range", "invalid --data-range item %q: %v", value, parseErr)
}
parsed = append(parsed, item)
}
first := parsed[0]
spansBySheet := make(map[string][][2]int)
aligned := true
minRow, minCol := first.row, first.col
maxRow, maxCol := first.row+first.rowCount, first.col+first.colCount
for _, item := range parsed {
if direction == "row" {
if item.col != first.col || item.colCount != first.colCount {
aligned = false
}
dimensionCount += item.rowCount
spansBySheet[item.sheet] = append(spansBySheet[item.sheet], [2]int{item.row, item.row + item.rowCount})
} else {
if item.row != first.row || item.rowCount != first.rowCount {
aligned = false
}
dimensionCount += item.colCount
spansBySheet[item.sheet] = append(spansBySheet[item.sheet], [2]int{item.col, item.col + item.colCount})
}
minRow = min(minRow, item.row)
minCol = min(minCol, item.col)
maxRow = max(maxRow, item.row+item.rowCount)
maxCol = max(maxCol, item.col+item.colCount)
}
overlapping := false
for _, spans := range spansBySheet {
for i, current := range spans {
for j := 0; j < i; j++ {
if current[0] < spans[j][1] && spans[j][0] < current[1] {
overlapping = true
}
}
}
}
normalized = strings.Join(ranges, ",")
if len(ranges) > 1 && (!aligned || overlapping) {
if len(spansBySheet) > 1 {
return "", 0, 0, false, sheetsValidationForFlag(
"data-range",
"cross-sheet --data-range items must align along the data-point axis and must not overlap within the same sheet",
)
}
prefix := ""
if first.qualifier != "" {
prefix = first.qualifier
}
normalized = prefix + columnIndexToLetter(minCol) + strconv.Itoa(minRow+1) + ":" + columnIndexToLetter(maxCol-1) + strconv.Itoa(maxRow)
dimensionCount = maxCol - minCol
dataPointCount = maxRow - minRow
if direction == "row" {
dimensionCount, dataPointCount = dataPointCount, dimensionCount
}
return normalized, dimensionCount, dataPointCount, true, nil
}
if direction == "row" {
dataPointCount = first.colCount
} else {
dataPointCount = first.rowCount
}
return normalized, dimensionCount, dataPointCount, false, nil
}
func splitChartDataRanges(value string) ([]string, error) {
var ranges []string
start := 0
inQuote := false
for i := 0; i <= len(value); i++ {
if i < len(value) && value[i] == '\'' {
if inQuote && i+1 < len(value) && value[i+1] == '\'' {
i++
} else {
inQuote = !inQuote
}
}
if i == len(value) || (value[i] == ',' && !inQuote) {
part := strings.TrimSpace(value[start:i])
if part == "" {
return nil, common.ValidationErrorf("range list contains an empty item")
}
ranges = append(ranges, part)
start = i + 1
}
}
if inQuote {
return nil, common.ValidationErrorf("unterminated quoted sheet name")
}
return ranges, nil
}
func parseChartDataRange(value string) (chartDataRange, error) {
item := chartDataRange{}
ref := strings.TrimSpace(value)
if sheet, end, ok := scanSheetQualifier(ref); ok {
item.sheet = sheet
item.qualifier = strings.TrimSpace(ref[:end])
ref = strings.TrimSpace(ref[end:])
}
parts := strings.SplitN(ref, ":", 2)
if len(parts) != 2 {
return item, common.ValidationErrorf("expected a rectangular A1 range such as A1:C10")
}
startCol, startRow, startOK := splitCellRef(parts[0])
endCol, endRow, endOK := splitCellRef(parts[1])
if !startOK || !endOK || endCol < startCol || endRow < startRow {
return item, common.ValidationErrorf("expected a rectangular A1 range such as A1:C10")
}
item.row, item.col = startRow, startCol
item.rowCount, item.colCount = endRow-startRow+1, endCol-startCol+1
return item, nil
}
func configureChartSemanticCommand(cmd *cobra.Command) {
if cmd.Flags().Lookup("stacked") == nil {
cmd.Flags().Bool("stacked", false, "compatibility alias for --stack normal")
_ = cmd.Flags().MarkHidden("stacked")
}
originalArgs := cmd.Args
cmd.Args = func(cmd *cobra.Command, args []string) error {
if len(args) == 1 && cmd.Flags().Changed("smooth") && (args[0] == "true" || args[0] == "false") {
return cmd.Flags().Set("smooth", args[0])
}
return originalArgs(cmd, args)
}
cmd.SetFlagErrorFunc(func(_ *cobra.Command, err error) error {
message := err.Error()
if strings.Contains(message, "unknown flag: --stacked") {
return sheetsValidationForFlag("stacked", "--stacked is not supported; use --stack normal (or --stack percent for 100%% stacking)")
}
return err
})
}
func addChartSemanticConfig(rt flagView, out map[string]interface{}) {
for _, flag := range chartSemanticConfigFlags {
if !rt.Changed(flag) {
continue
}
key := strings.ReplaceAll(flag, "-", "_")
if flag == "x-axis-label-angle" || flag == "y-axis-label-angle" {
out[key] = rt.Int(flag)
} else if flag == "x-axis-min" || flag == "x-axis-max" || flag == "y-axis-min" || flag == "y-axis-max" {
out[key] = rt.Float64(flag)
} else {
out[key] = rt.Str(flag)
}
}
if rt.Changed("stacked") {
if rt.Bool("stacked") {
out["stack"] = "normal"
} else {
out["stack"] = "none"
}
}
if rt.Changed("smooth") {
out["smooth"] = rt.Bool("smooth")
}
if rt.Changed("colors") {
out["colors"] = normalizedChartColors(rt)
}
}
func validateChartSemanticEnums(rt flagView) error {
if rt.Changed("stack") && rt.Changed("stacked") {
return common.ValidationErrorf("--stack and --stacked are mutually exclusive").WithParams(
sheetsInvalidParam("stack", "cannot be used with --stacked"),
sheetsInvalidParam("stacked", "cannot be used with --stack"),
)
}
return nil
}
func validateChartConfigSnapshot(rt flagView, snapshot map[string]interface{}) error {
if rt.Changed("data-label-position") && !rt.Changed("data-labels") {
plotArea := chartMap(snapshot["plotArea"])
plot := chartMap(plotArea["plot"])
if _, exists := plot["labels"].(map[string]interface{}); !exists {
return sheetsValidationForFlag(
"data-label-position",
"--data-label-position requires existing data labels or --data-labels in the same update",
)
}
}
if !rt.Changed("x-axis-min") && !rt.Changed("x-axis-max") {
return nil
}
plotArea := chartMap(snapshot["plotArea"])
xAxis := findChartAxisMap(plotArea, "x", "bottom")
if valueType, _ := xAxis["valueType"].(string); valueType == "linear" {
return nil
}
flag := "x-axis-min"
if !rt.Changed(flag) {
flag = "x-axis-max"
}
return sheetsValidationForFlag(
flag,
"--%s requires the existing bottom X axis to be a continuous numeric X axis (valueType=linear)",
flag,
)
}
func validateChartAxisBounds(rt flagView, axis string, numericAxis bool) error {
minFlag := axis + "-axis-min"
maxFlag := axis + "-axis-max"
hasMin := rt.Changed(minFlag)
hasMax := rt.Changed(maxFlag)
if !hasMin && !hasMax {
return nil
}
if !numericAxis {
return sheetsValidationForFlag(
"x-axis-numbers-as",
"--%s and --%s require --x-axis-numbers-as values",
minFlag,
maxFlag,
)
}
if hasMin {
value := rt.Float64(minFlag)
if math.IsNaN(value) || math.IsInf(value, 0) {
return sheetsValidationForFlag(minFlag, "--%s must be a finite number", minFlag)
}
}
if hasMax {
value := rt.Float64(maxFlag)
if math.IsNaN(value) || math.IsInf(value, 0) {
return sheetsValidationForFlag(maxFlag, "--%s must be a finite number", maxFlag)
}
}
if hasMin && hasMax && rt.Float64(minFlag) >= rt.Float64(maxFlag) {
return sheetsValidationForFlag(minFlag, "--%s must be less than --%s", minFlag, maxFlag)
}
return nil
}
func validateChartColorFlags(rt flagView) error {
if rt.Changed("color-palette") && rt.Changed("colors") {
return common.ValidationErrorf("--color-palette and --colors are mutually exclusive").WithParams(
sheetsInvalidParam("color-palette", "cannot be used with --colors"),
sheetsInvalidParam("colors", "cannot be used with --color-palette"),
)
}
if rt.Changed("colors") {
colors := normalizedChartColors(rt)
if len(colors) < 2 {
return sheetsValidationForFlag("colors", "--colors must contain at least two hex colors")
}
for _, color := range colors {
if !chartHexColorPattern.MatchString(color) {
return sheetsValidationForFlag("colors", "--colors contains invalid hex color %q", color)
}
}
}
return nil
}
func normalizedChartColors(rt flagView) []string {
raw := rt.StrSlice("colors")
colors := make([]string, len(raw))
for i := range raw {
colors[i] = strings.TrimSpace(raw[i])
}
return colors
}