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
360 lines
12 KiB
Go
360 lines
12 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package sheets
|
|
|
|
import (
|
|
_ "embed"
|
|
"encoding/json"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
)
|
|
|
|
// ─── --print-schema runtime introspection ─────────────────────────────
|
|
//
|
|
// Composite JSON flags (--cells, --properties, --operations, --border-styles,
|
|
// --sort-keys) carry non-trivial structured payloads. Reference docs cover
|
|
// the top-level fields but agents often need the full JSON Schema to
|
|
// generate valid input.
|
|
//
|
|
// To serve that need without forcing every caller to fetch external docs,
|
|
// the spec repo ships a compact `flag-schemas.json` that extracts just the
|
|
// schema subtree corresponding to each (shortcut, flag) pair. We embed
|
|
// that artifact at compile time so `lark-cli sheets <shortcut>
|
|
// --print-schema --flag-name <name>` runs entirely locally.
|
|
//
|
|
// The artifact is generated by sheet-skill-spec's
|
|
// scripts/sync_to_consumers.mjs from canonical-spec/cli-flag-schema-map.json
|
|
// + tool-schemas/mcp-tools.json. Do not hand-edit data/flag-schemas.json;
|
|
// regenerate via the sync script.
|
|
|
|
//go:embed data/flag-schemas.json
|
|
var flagSchemasJSON []byte
|
|
|
|
// flagSchemaIndex parses lazily on first access; failures are surfaced
|
|
// as errors from the lookup helper rather than panicking at init time.
|
|
type flagSchemaIndex struct {
|
|
SchemaVersion string `json:"schema_version"`
|
|
Flags map[string]map[string]json.RawMessage `json:"flags"`
|
|
}
|
|
|
|
// loadFlagSchemas is sync.Once-guarded so concurrent first access from
|
|
// parallel goroutines (e.g. parallel unit tests, parallel shortcut
|
|
// invocations) doesn't race on the lazy parse.
|
|
var (
|
|
flagSchemasOnce sync.Once
|
|
parsedFlagSchemas *flagSchemaIndex
|
|
parseFlagErr error
|
|
)
|
|
|
|
func loadFlagSchemas() (*flagSchemaIndex, error) {
|
|
flagSchemasOnce.Do(func() {
|
|
var idx flagSchemaIndex
|
|
if err := json.Unmarshal(flagSchemasJSON, &idx); err != nil {
|
|
parseFlagErr = errs.NewInternalError(errs.SubtypeUnknown, "flag-schemas.json: %v", err).WithCause(err)
|
|
return
|
|
}
|
|
if idx.Flags == nil {
|
|
idx.Flags = map[string]map[string]json.RawMessage{}
|
|
}
|
|
for _, command := range []string{"+chart-create", "+chart-update"} {
|
|
entry := idx.Flags[command]
|
|
if entry == nil {
|
|
continue
|
|
}
|
|
if raw := entry["properties"]; raw != nil {
|
|
materialized, err := materializeChartSnapshotSchema(raw)
|
|
if err != nil {
|
|
parseFlagErr = errs.NewInternalError(errs.SubtypeUnknown, "materialize %s --properties schema: %v", command, err).WithCause(err)
|
|
return
|
|
}
|
|
if command == "+chart-update" {
|
|
materialized, err = recursivePartialJSONSchema(materialized)
|
|
if err != nil {
|
|
parseFlagErr = errs.NewInternalError(errs.SubtypeUnknown, "derive +chart-update --properties schema: %v", err).WithCause(err)
|
|
return
|
|
}
|
|
}
|
|
entry["properties"] = materialized
|
|
}
|
|
}
|
|
parsedFlagSchemas = &idx
|
|
})
|
|
return parsedFlagSchemas, parseFlagErr
|
|
}
|
|
|
|
// materializeChartSnapshotSchema unwraps the canonical snapshot union into
|
|
// its structured branch. The other union branch documents that chart-update
|
|
// accepts a partial snapshot, but leaving it in the runtime schema would also
|
|
// permit invalid values. The snapshot root remains optional-field compatible
|
|
// with the previous generated schema, while nested create constraints remain
|
|
// strict; chart-update becomes recursively partial below.
|
|
func materializeChartSnapshotSchema(raw json.RawMessage) (json.RawMessage, error) {
|
|
var schema map[string]interface{}
|
|
if err := json.Unmarshal(raw, &schema); err != nil {
|
|
return nil, err
|
|
}
|
|
properties, _ := schema["properties"].(map[string]interface{})
|
|
snapshot, _ := properties["snapshot"].(map[string]interface{})
|
|
branches, _ := snapshot["anyOf"].([]interface{})
|
|
for _, branch := range branches {
|
|
full, ok := branch.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
fullProperties, ok := full["properties"].(map[string]interface{})
|
|
if !ok || len(fullProperties) == 0 {
|
|
continue
|
|
}
|
|
if description, ok := snapshot["description"]; ok {
|
|
full["description"] = description
|
|
}
|
|
delete(full, "required")
|
|
properties["snapshot"] = full
|
|
break
|
|
}
|
|
return json.Marshal(schema)
|
|
}
|
|
|
|
// recursivePartialJSONSchema derives an update schema from a full object
|
|
// schema by removing required constraints from patchable objects. Arrays are
|
|
// replaced as a whole, so their item schemas remain strict. The remaining
|
|
// type, enum, bounds, and additionalProperties constraints still reject
|
|
// malformed fields that are present in the patch.
|
|
func recursivePartialJSONSchema(raw json.RawMessage) (json.RawMessage, error) {
|
|
var schema map[string]interface{}
|
|
if err := json.Unmarshal(raw, &schema); err != nil {
|
|
return nil, err
|
|
}
|
|
makeJSONSchemaRecursivePartial(schema)
|
|
return json.Marshal(schema)
|
|
}
|
|
|
|
func makeJSONSchemaRecursivePartial(schema map[string]interface{}) {
|
|
delete(schema, "required")
|
|
|
|
for _, key := range []string{"properties", "patternProperties", "dependentSchemas", "definitions", "$defs"} {
|
|
children, _ := schema[key].(map[string]interface{})
|
|
for _, child := range children {
|
|
makeJSONSchemaValueRecursivePartial(child)
|
|
}
|
|
}
|
|
for _, key := range []string{"additionalProperties", "not", "if", "then", "else", "propertyNames"} {
|
|
makeJSONSchemaValueRecursivePartial(schema[key])
|
|
}
|
|
for _, key := range []string{"allOf", "anyOf", "oneOf"} {
|
|
makeJSONSchemaValueRecursivePartial(schema[key])
|
|
}
|
|
}
|
|
|
|
func makeJSONSchemaValueRecursivePartial(value interface{}) {
|
|
switch typed := value.(type) {
|
|
case map[string]interface{}:
|
|
makeJSONSchemaRecursivePartial(typed)
|
|
case []interface{}:
|
|
for _, item := range typed {
|
|
makeJSONSchemaValueRecursivePartial(item)
|
|
}
|
|
}
|
|
}
|
|
|
|
// commandsWithFlagSchema returns the set of shortcut commands that have
|
|
// at least one introspectable flag. Used by Shortcuts() to decide which
|
|
// shortcuts to wire PrintFlagSchema into.
|
|
func commandsWithFlagSchema() map[string]struct{} {
|
|
idx, err := loadFlagSchemas()
|
|
if err != nil || idx == nil {
|
|
return nil
|
|
}
|
|
out := make(map[string]struct{}, len(idx.Flags))
|
|
for cmd := range idx.Flags {
|
|
out[cmd] = struct{}{}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// printFlagSchemaFor returns a PrintFlagSchema closure bound to the given
|
|
// shortcut command. When flagName == "" the closure returns a JSON
|
|
// listing of introspectable flags; otherwise it returns the schema
|
|
// subtree JSON for the named flag, or an error if the flag is not
|
|
// registered.
|
|
//
|
|
// flagName also accepts a dotted path (properties.plotArea.axes): the
|
|
// first segment names the flag, the rest walk the schema's properties
|
|
// (descending through array items implicitly), returning just that
|
|
// subtree. Large schemas — chart-create's properties is ~1,750 pretty
|
|
// lines — otherwise force agents to page through the full dump for one
|
|
// nested field; eval traces show 25 such round trips in one batch.
|
|
func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
|
return func(flagName string) ([]byte, error) {
|
|
idx, err := loadFlagSchemas()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entry, ok := idx.Flags[command]
|
|
if !ok || len(entry) == 0 {
|
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no JSON Schema registered for %s", command)
|
|
}
|
|
if flagName == "" {
|
|
flags := make([]string, 0, len(entry))
|
|
for f := range entry {
|
|
flags = append(flags, f)
|
|
}
|
|
sort.Strings(flags)
|
|
return json.MarshalIndent(map[string]interface{}{
|
|
"shortcut": command,
|
|
"introspectable_flags": flags,
|
|
"hint": "run again with --flag-name <name> to dump that flag's JSON Schema, or a dotted path like <name>.plotArea.axes to dump just one subtree",
|
|
}, "", " ")
|
|
}
|
|
name, path := splitSchemaPath(flagName)
|
|
schema, ok := entry[name]
|
|
if !ok {
|
|
// Tolerate the wire-vocabulary underscore form (--flag-name
|
|
// border_styles for border-styles) — agents copy field names out
|
|
// of JSON payloads where underscores are canonical.
|
|
if alt := strings.ReplaceAll(name, "_", "-"); alt != name {
|
|
schema, ok = entry[alt]
|
|
}
|
|
}
|
|
if !ok {
|
|
flags := make([]string, 0, len(entry))
|
|
for f := range entry {
|
|
flags = append(flags, f)
|
|
}
|
|
sort.Strings(flags)
|
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
"no JSON Schema registered for %s --%s; available: %v", command, name, flags).
|
|
WithParam("--flag-name")
|
|
}
|
|
var pretty interface{}
|
|
if err := json.Unmarshal(schema, &pretty); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(path) > 0 {
|
|
pretty, err = sliceSchemaByPath(pretty, name, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
// Reformat for readability — schema files store compact JSON.
|
|
return json.MarshalIndent(pretty, "", " ")
|
|
}
|
|
}
|
|
|
|
// splitSchemaPath splits a --flag-name value into the flag name and the
|
|
// optional dotted schema path after it.
|
|
func splitSchemaPath(flagName string) (string, []string) {
|
|
parts := strings.Split(flagName, ".")
|
|
return parts[0], parts[1:]
|
|
}
|
|
|
|
// sliceSchemaByPath walks a decoded JSON Schema along dotted path segments.
|
|
// Each segment matches a key under "properties"; array levels are descended
|
|
// implicitly through "items" (an explicit "items" segment also works), and
|
|
// oneOf / anyOf branches are searched for the first one carrying the key. A miss
|
|
// errors with the keys actually available at that level so the caller can
|
|
// re-issue the path without a full dump.
|
|
func sliceSchemaByPath(schema interface{}, flagName string, path []string) (interface{}, error) {
|
|
node := schema
|
|
walked := flagName
|
|
for _, seg := range path {
|
|
next, ok := schemaChild(node, seg)
|
|
if !ok {
|
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
"no %q under %s; available keys: %v", seg, walked, schemaChildKeys(node)).
|
|
WithParam("--flag-name")
|
|
}
|
|
node = next
|
|
walked += "." + seg
|
|
}
|
|
return node, nil
|
|
}
|
|
|
|
// schemaChild resolves one path segment against a schema node, descending
|
|
// through items / oneOf / anyOf wrappers as needed.
|
|
func schemaChild(node interface{}, seg string) (interface{}, bool) {
|
|
for depth := 0; depth < 8; depth++ {
|
|
m, ok := node.(map[string]interface{})
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
if seg == "items" {
|
|
if items, ok := m["items"]; ok {
|
|
return items, true
|
|
}
|
|
}
|
|
if props, ok := m["properties"].(map[string]interface{}); ok {
|
|
if child, ok := props[seg]; ok {
|
|
return child, true
|
|
}
|
|
}
|
|
if items, ok := m["items"]; ok {
|
|
node = items
|
|
continue
|
|
}
|
|
if branches, ok := m["oneOf"].([]interface{}); ok {
|
|
for _, b := range branches {
|
|
if child, ok := schemaChild(b, seg); ok {
|
|
return child, true
|
|
}
|
|
}
|
|
}
|
|
if branches, ok := m["anyOf"].([]interface{}); ok {
|
|
for _, b := range branches {
|
|
if child, ok := schemaChild(b, seg); ok {
|
|
return child, true
|
|
}
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// schemaChildKeys lists the property keys reachable at a schema node (through
|
|
// items / oneOf / anyOf wrappers), for the path-miss error.
|
|
func schemaChildKeys(node interface{}) []string {
|
|
seen := map[string]struct{}{}
|
|
var collect func(n interface{}, depth int)
|
|
collect = func(n interface{}, depth int) {
|
|
if depth > 8 {
|
|
return
|
|
}
|
|
m, ok := n.(map[string]interface{})
|
|
if !ok {
|
|
return
|
|
}
|
|
if props, ok := m["properties"].(map[string]interface{}); ok {
|
|
for k := range props {
|
|
seen[k] = struct{}{}
|
|
}
|
|
return
|
|
}
|
|
if items, ok := m["items"]; ok {
|
|
collect(items, depth+1)
|
|
return
|
|
}
|
|
if branches, ok := m["oneOf"].([]interface{}); ok {
|
|
for _, b := range branches {
|
|
collect(b, depth+1)
|
|
}
|
|
}
|
|
if branches, ok := m["anyOf"].([]interface{}); ok {
|
|
for _, b := range branches {
|
|
collect(b, depth+1)
|
|
}
|
|
}
|
|
}
|
|
collect(node, 0)
|
|
keys := make([]string, 0, len(seen))
|
|
for k := range seen {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|