mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
5f35c72bd3
* 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
427 lines
13 KiB
Go
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)
|
|
}
|
|
}
|