Files
larksuite__cli/shortcuts/sheets/flag_view.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

427 lines
13 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"fmt"
"math"
"slices"
"strconv"
"strings"
)
// flagView is the read-only flag-accessor surface that every CLI-shape →
// MCP-tool-body translator (the *Input builders) depends on. It is satisfied
// as-is by *common.RuntimeContext (cobra-backed, used by standalone shortcut
// execution) and by mapFlagView (map-backed, used by +batch-update sub-ops).
//
// Routing both paths through the same interface lets a sub-op inside
// +batch-update reuse the exact same translator the standalone shortcut runs,
// so the generated MCP body is identical either way (enforced by the
// batch-vs-standalone contract test).
type flagView interface {
Str(name string) string
Int(name string) int
Float64(name string) float64
Bool(name string) bool
StrArray(name string) []string
StrSlice(name string) []string
Changed(name string) bool
InputResolvedFromSource(name string) bool
// Command returns the shortcut command this view feeds (e.g.
// "+pivot-create"). Used to look up the schema entry for
// schema-driven flag validation; both standalone and batch sub-op
// paths populate it so a sub-op gets validated against the same
// schema as the standalone shortcut.
Command() string
}
// mapFlagView adapts a +batch-update sub-op input object (decoded JSON) to the
// flagView interface so the standalone *Input translators can consume it.
//
// Keys are matched leniently against the CLI flag name: a translator asking for
// "source-range" finds either "source-range" or "source_range" in the map (the
// reference docs use CLI flag names; users frequently send the underscore
// form). Composite values (arrays / objects for flags like cells / properties /
// sort-keys) are re-encoded to a JSON string on Str() so the downstream
// parseJSONFlag round-trips them exactly as it would a CLI string argument.
//
// To mirror the standalone cobra layer exactly, value reads fall back to the
// flag's declared default (seeded from flag-defs.json), while Changed() reflects
// only what the user actually provided. This split matters because some
// translators branch on Changed() (e.g. omit target_index unless --index was
// set) and others read defaulted values (e.g. row-count defaults to 200).
type mapFlagView struct {
raw map[string]interface{} // user-supplied sub-op input (drives Changed)
defaults map[string]interface{} // flag defaults (value fallback only)
command string // shortcut command (e.g. "+chart-create"); used by schema validator
}
func (m mapFlagView) Command() string { return m.command }
// newMapFlagViewForCommand wraps a sub-op input and seeds the value-fallback
// defaults declared for `command` in flag-defs.json, so an absent flag resolves
// to the same value the standalone cobra command would carry.
func newMapFlagViewForCommand(command string, input map[string]interface{}) mapFlagView {
fv := mapFlagView{raw: input, defaults: map[string]interface{}{}, command: command}
defs, err := loadFlagDefs()
if err != nil {
return fv
}
spec, ok := defs[command]
if !ok {
return fv
}
for _, df := range spec.Flags {
if df.Kind == "system" || df.Default == "" {
continue
}
fv.defaults[df.Name] = typedDefault(df)
}
return fv
}
// typedDefault converts a flag's string default to the Go type matching its
// declared kind, so Int()/Bool()/Float64() see the right type.
func typedDefault(df flagDef) interface{} {
switch df.Type {
case "bool":
return df.Default == "true"
case "int":
var n int
fmt.Sscanf(df.Default, "%d", &n)
return n
case "float64":
var f float64
fmt.Sscanf(df.Default, "%g", &f)
return f
default:
return df.Default
}
}
// lookup resolves a flag name for a VALUE read: user input first (hyphen↔
// underscore tolerant), then the seeded default. Returns the value and whether
// it was found in either source.
func (m mapFlagView) lookup(name string) (interface{}, bool) {
if v, ok := m.lookupRaw(name); ok {
return v, true
}
if m.defaults != nil {
if v, ok := m.defaults[name]; ok {
return v, true
}
}
return nil, false
}
// lookupRaw resolves a flag name against the user-supplied input only, trying
// the exact key then the hyphen↔underscore variants.
func (m mapFlagView) lookupRaw(name string) (interface{}, bool) {
_, v, ok := m.lookupRawWithKey(name)
return v, ok
}
func (m mapFlagView) lookupRawWithKey(name string) (string, interface{}, bool) {
for _, key := range []string{
name,
strings.ReplaceAll(name, "-", "_"),
strings.ReplaceAll(name, "_", "-"),
} {
if v, ok := m.raw[key]; ok {
return key, v, true
}
}
canonicalName := strings.ReplaceAll(name, "_", "-")
aliases := make([]string, 0, len(commandFlagAliases[m.command]))
for alias, target := range commandFlagAliases[m.command] {
if strings.ReplaceAll(target, "_", "-") == canonicalName {
aliases = append(aliases, alias)
}
}
slices.Sort(aliases)
for _, alias := range aliases {
for _, key := range []string{alias, strings.ReplaceAll(alias, "-", "_")} {
if v, ok := m.raw[key]; ok {
return key, v, true
}
}
}
return "", nil, false
}
func (m mapFlagView) Str(name string) string {
v, ok := m.lookup(name)
if !ok || v == nil {
return ""
}
switch t := v.(type) {
case string:
return t
case bool, float64, int, int64:
b, _ := json.Marshal(t)
return string(b)
default:
// Arrays / objects (cells, properties, sort-keys, options, ...) are
// re-encoded so the translator's parseJSONFlag re-parses them.
b, err := json.Marshal(t)
if err != nil {
return ""
}
return string(b)
}
}
func (m mapFlagView) Int(name string) int {
v, ok := m.lookup(name)
if !ok {
return 0
}
switch t := v.(type) {
case float64:
return int(t)
case int:
return t
case int64:
return int(t)
}
return 0
}
func (m mapFlagView) Float64(name string) float64 {
v, ok := m.lookup(name)
if !ok {
return 0
}
switch t := v.(type) {
case float64:
return t
case int:
return float64(t)
case int64:
return float64(t)
}
return 0
}
func (m mapFlagView) Bool(name string) bool {
v, ok := m.lookup(name)
if !ok {
return false
}
b, _ := v.(bool)
return b
}
func (m mapFlagView) StrArray(name string) []string {
return m.strSliceLike(name)
}
func (m mapFlagView) StrSlice(name string) []string {
return m.strSliceLike(name)
}
func (m mapFlagView) strSliceLike(name string) []string {
v, ok := m.lookup(name)
if !ok || v == nil {
return nil
}
switch t := v.(type) {
case []string:
return t
case []interface{}:
out := make([]string, 0, len(t))
for _, e := range t {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
case string:
// CSV / comma-separated (matches cobra StringSlice behavior).
if t == "" {
return nil
}
return strings.Split(t, ",")
}
return nil
}
func (m mapFlagView) Changed(name string) bool {
_, ok := m.lookupRaw(name)
return ok
}
func (m mapFlagView) InputResolvedFromSource(name string) bool { return false }
// validateRawTypes rejects sub-op input fields whose JSON type contradicts the
// flag's declared type in flag-defs. +batch-update skips parse-time schema
// validation for `operations`, and Int/Float64/Bool silently fall back to
// the zero value on a type mismatch — so without this guard a wrong-typed scalar
// (e.g. "index":"abc" or "multiple":"true") would land as 0 / false instead of
// erroring, writing to the wrong place. Only numeric and boolean flags are
// checked; string and composite (array/object) flags stay permissive because
// Str() intentionally coerces them and the translator/schema validates shape.
//
// Returns a bare error; the +batch-update translator wraps it with the
// operations[i] (<shortcut>) context.
func (m mapFlagView) validateRawTypes() error {
if len(m.raw) == 0 {
return nil
}
defs, err := loadFlagDefs()
if err != nil {
return nil //nolint:nilerr // fail-open: if flag-defs can't load, skip type validation rather than block the batch
}
spec, ok := defs[m.command]
if !ok {
return nil
}
declaredType := make(map[string]string, len(spec.Flags))
for _, df := range spec.Flags {
declaredType[df.Name] = df.Type
}
for rawKey, val := range m.raw {
name := rawKey
typ, ok := declaredType[name]
if !ok {
// flag-defs use hyphen names; tolerate the underscore form users send.
name = strings.ReplaceAll(rawKey, "_", "-")
typ, ok = declaredType[name]
}
if !ok {
continue // unknown key — leave it for the translator / schema layer
}
switch typ {
case "int":
// Int(): float64 → int(t) truncates, so a non-integer number would
// be silently floored (1.9 → 1). Standalone cobra rejects it at
// parse time; reject here too to keep batch/standalone parity.
f, isNum := val.(float64)
if !isNum {
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
}
if math.Trunc(f) != f {
return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
}
case "float64":
if _, isNum := val.(float64); !isNum {
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
}
case "bool":
if _, isBool := val.(bool); !isBool {
return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
}
}
}
return nil
}
// normalizeAndValidateEnums applies the same flat string-enum contract as the
// standalone cobra path. Canonical casing and known aliases are rewritten in
// place; unknown values are rejected before a translator can silently fall
// back to a different operation.
func (m *mapFlagView) normalizeAndValidateEnums() error {
defs, err := loadFlagDefs()
if err != nil {
return nil //nolint:nilerr // match validateRawTypes: missing embedded metadata must not block the batch
}
spec, ok := defs[m.command]
if !ok {
return nil
}
for _, df := range spec.Flags {
if df.Kind == "system" || df.Type != "string" || len(df.Enum) == 0 {
continue
}
rawKey, raw, changed := m.lookupRawWithKey(df.Name)
if !changed {
continue
}
value, ok := raw.(string)
if !ok {
return fmt.Errorf("--%s must be a string, got %s", df.Name, jsonTypeName(raw)) //nolint:forbidigo // intermediate error; batch dispatcher adds typed operations context
}
if value == "" || slices.Contains(df.Enum, value) {
continue
}
if canonical := canonicalEnumValue(value, df.Enum); canonical != "" {
m.raw[rawKey] = canonical
continue
}
// A retired value means "as if omitted" — delete the key so Changed()
// also reports it as absent, matching the standalone path.
if isRetiredEnumValue(m.command, df.Name, value) {
delete(m.raw, rawKey)
continue
}
message := fmt.Sprintf("invalid value %q for --%s, allowed: %s", value, df.Name, strings.Join(df.Enum, ", "))
if match := closestEnumValue(value, df.Enum); match != "" {
message += fmt.Sprintf("; did you mean %q?", match)
}
return fmt.Errorf("%s", message) //nolint:forbidigo // intermediate error; batch dispatcher adds typed operations context
}
return nil
}
// normalizeRangeSheetPrefix mirrors the standalone chainRangeSheetPrefix
// rewrite (range_sheet_prefix.go) for the map-backed paths, +batch-update
// sub-ops and +cells-set --writes items: one that named its sheet only inside
// "range" ("Sheet1!A1:D20") gets sheet_name filled in and the bare range left
// behind, so every form accepts the same input.
//
// The derived selector is written under the underscore key the sub-op input
// vocabulary uses, and left as the only spelling of it; lookupRaw is
// separator-tolerant, so a translator asking for "sheet-name" finds it anyway.
func (m *mapFlagView) normalizeRangeSheetPrefix() {
if !rangeSheetPrefixApplies(m.command) {
return
}
if strings.TrimSpace(m.Str("sheet-id")) != "" || strings.TrimSpace(m.Str("sheet-name")) != "" {
return
}
rangeKey, raw, ok := m.lookupRawWithKey(rangeSheetPrefixFlag)
if !ok {
return
}
value, isString := raw.(string)
if !isString {
return
}
sheet, rest, ok := splitRangeSheetPrefix(value)
if !ok {
return
}
m.raw[rangeKey] = rest
// Drop the hyphen spelling before writing the underscore one. An item may
// carry both when their values agree (normalizeSubOpInputKeys keeps a
// harmless duplicate rather than erroring), and lookupRaw answers with the
// first spelling it finds — so an empty "sheet-name" left in place would
// shadow the selector just derived and fail as "no sheet selector".
delete(m.raw, "sheet-name")
m.raw["sheet_name"] = sheet
}
// jsonTypeName names the JSON kind of a value decoded by encoding/json, for
// type-mismatch error messages.
func jsonTypeName(v interface{}) string {
switch v.(type) {
case nil:
return "null"
case bool:
return "boolean"
case float64:
return "number"
case string:
return "string"
case []interface{}:
return "array"
case map[string]interface{}:
return "object"
default:
return fmt.Sprintf("%T", v)
}
}