mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
7690ba4446
+create, +add-slide, +update-slide and +replace-slide are the four shortcuts that change slide content, so they are the four that can ask the backend to check the page before accepting the write. All four now send lint_xml=true by default and lint_xml=false when --no-lint is passed. The subject of the check is the page the write produces, not the payload it was handed: +replace-slide submits fragments, and a fragment that is correct on its own can still push a neighbour off the canvas. The switch travels in the request body rather than the query string. A query parameter has to be declared in the gateway's own api meta before it is bound to a field, and the published definition of these endpoints does not list one — so an undeclared parameter is dropped, the field arrives unset, and the server reads it as "not requested". Verified against a live backend: pages that asked to be linted were written unlinted, with nothing anywhere to say so. Body fields ride along with the JSON already being sent and need no registration. The value is sent explicitly in both directions rather than omitted when on. The parameter is newer than the registry, so the server-side default is not something this CLI can read anywhere, and a request that states the value keeps meaning the same thing if that default ever moves. A refusal is passed through verbatim. The message field carries the lint report itself — the same document the lint tool writes when it is run by hand — and the same refusal reaches callers through `lark-cli api` as well, where nothing rewrites it. Rendering it to prose here would give one refusal two formats depending on which command produced it. Each finding carries the numbers behind its own rule, and which numbers those are differs per rule, so nothing is decoded that is not used: the report is what the caller reads. The refusal is recognised by its error code, 4000153, which the engine raises for nothing else and which reaches the CLI unchanged. Matching on the shape of the message instead would mean claiming any JSON that resembles a report, and a false positive there rewrites the hint of an error this code does not understand. What the backend cannot say goes in the hint instead: how many findings refused the write, that the page did not land, and --no-lint, which is a CLI flag the server has never heard of. The count is summary.error_count rather than the number of findings, because errors are what refuse a page — the same line the lint tool draws when it is run by hand, exiting non-zero on error_count alone. A report can arrive with warnings beside its errors, and counting those too would send the caller hunting for blockers that are not there. A message that does not parse still gets the hint: the escape hatch is the half of it they cannot get anywhere else, and withholding it over a missing number helps nobody. The hint names no page. Every write path submits exactly one page, so a finding's slide_number is its position inside that submission and is always 1 — which is not the page the caller is looking for. On +create it is actively wrong: it would read "on slide 1" next to a progress line saying "adding slide 2/3 failed". The page number has one source, and it is that line. Findings that did not refuse the write come back the other way. The backend returns them in an issues field on a response that succeeded, and all four shortcuts now pass that field through untouched rather than dropping it. It only ever arrives on a page that was written: anything serious enough to refuse the write left as the error above, carrying the same report. Dropping it would leave the caller believing the deck says exactly what they wrote, with no way to learn otherwise short of looking at the rendered page. It is passed through rather than reformatted so that one field reads the same however the page was written. +create keeps adding its pages one at a time, so a refusal there can arrive with the presentation and some of its pages already written. It is reported as such: the error carries the lint report and, next to it, which page was refused and how many landed before it, so the retry adds the rest instead of building a second deck. +replace-pages does the same for the items in its plan. A batch that was told to keep going reports its failures only through the per-item records, so those carry the report, the code and the flag hint as well; a record built from the error's message alone would have named neither the refusal nor the way past it. The position stays on the returning path, where it is the only thing that says how far the batch got — beside a per-item record it would describe a batch that did not stop. Tests assert on the wire — the body the stub actually received — rather than on the builder's return value, so a command that stops calling its own builder still fails.
640 lines
25 KiB
Go
640 lines
25 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package slides
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"slices"
|
||
"strings"
|
||
|
||
"github.com/larksuite/cli/errs"
|
||
"github.com/larksuite/cli/shortcuts/common"
|
||
)
|
||
|
||
// maxReplaceParts matches the server-side cap declared in meta_data.json
|
||
// ("最少1条,最多200条"). Enforced client-side so a too-large batch fails fast
|
||
// with a clear message instead of a 400 from the backend.
|
||
const maxReplaceParts = 200
|
||
|
||
// SlidesReplaceSlide wraps slides.xml_presentation.slide.replace with specific
|
||
// value-adds over the raw auto-generated command:
|
||
//
|
||
// 1. It accepts --presentation as token / slides URL / wiki URL (and resolves
|
||
// wiki tokens), same as other slides shortcuts.
|
||
// 2. For every `block_replace` part it auto-injects `id="<block_id>"` into the
|
||
// root element of `replacement`. The backend requires the replacement
|
||
// fragment's root carry that id and returns 3350001 otherwise; the
|
||
// requirement is undocumented and catches callers repeatedly, so we fix it
|
||
// at the CLI layer.
|
||
// 3. For `<shape>` elements it auto-injects `<content/>` when missing. The
|
||
// SML 2.0 schema requires every shape to carry a content child; omitting
|
||
// it triggers 3350001.
|
||
// 4. On 3350001 errors it enriches the hint with context-specific guidance
|
||
// so AI agents can self-correct.
|
||
// 5. It asks the backend to lint the page these parts produce, and renders the
|
||
// refusal when the lint blocks the write. --no-lint opts out.
|
||
//
|
||
// `str_replace` is intentionally NOT exposed: product direction is that
|
||
// slide edits go through structural (block-level) operations only. The backend
|
||
// still accepts str_replace, but the CLI rejects it up front.
|
||
var SlidesReplaceSlide = common.Shortcut{
|
||
Service: "slides",
|
||
Command: "+replace-slide",
|
||
Description: "Replace elements on a slide via block_replace / block_insert parts (auto-injects id + <content/> on shape elements)",
|
||
Risk: "write",
|
||
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"},
|
||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||
ConditionalScopes: []string{"wiki:node:read"},
|
||
AuthTypes: []string{"user", "bot"},
|
||
Flags: []common.Flag{
|
||
requiredPresentationRefFlag(),
|
||
{Name: "slide-id", Desc: "slide page identifier (slide_id)", Required: true},
|
||
{Name: "parts", Desc: "JSON array of replace parts; accepts replace/insert action aliases, target_id for block_id, and block/content/shape/element for the action's XML payload; max 200", Required: true, Input: []string{common.File, common.Stdin}},
|
||
{Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision (-1 = latest; pass a specific number for optimistic locking)"},
|
||
{Name: "tid", Desc: "transaction id for concurrent-edit locking (usually empty)"},
|
||
noLintFlag(),
|
||
},
|
||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if ref.Kind == "wiki" {
|
||
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if strings.TrimSpace(runtime.Str("slide-id")) == "" {
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide-id cannot be empty").WithParam("--slide-id")
|
||
}
|
||
parts, err := parseReplaceParts(runtime.Str("parts"))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := validateReplaceParts(parts); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
},
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||
if err != nil {
|
||
return common.NewDryRunAPI().Set("error", err.Error())
|
||
}
|
||
parts, normalizations, err := parseReplacePartsWithNormalization(runtime.Str("parts"))
|
||
if err != nil {
|
||
return common.NewDryRunAPI().Set("error", err.Error())
|
||
}
|
||
if err := validateReplaceParts(parts); err != nil {
|
||
return common.NewDryRunAPI().Set("error", err.Error())
|
||
}
|
||
// Apply the same id-injection the real Execute does, so dry-run body
|
||
// shows what will actually be sent.
|
||
injected, err := injectBlockReplaceIDs(parts)
|
||
if err != nil {
|
||
return common.NewDryRunAPI().Set("error", err.Error())
|
||
}
|
||
|
||
slideID := runtime.Str("slide-id")
|
||
query := map[string]interface{}{
|
||
"slide_id": slideID,
|
||
"revision_id": runtime.Int("revision-id"),
|
||
}
|
||
if tid := runtime.Str("tid"); tid != "" {
|
||
query["tid"] = tid
|
||
}
|
||
body := replaceSlideBody(injected, runtime)
|
||
|
||
dry := common.NewDryRunAPI()
|
||
presentationID := ref.Token
|
||
if ref.Kind == "wiki" {
|
||
presentationID = "<resolved_slides_token>"
|
||
dry.Desc("2-step orchestration: resolve wiki → replace slide parts").
|
||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||
Desc("[1] Resolve wiki node to slides presentation").
|
||
Params(map[string]interface{}{"token": ref.Token})
|
||
} else {
|
||
dry.Desc(fmt.Sprintf("Replace %d part(s) on slide %s", len(parts), slideID))
|
||
}
|
||
dry.POST(slideReplaceAPIPath(presentationID)).
|
||
Params(query).
|
||
Body(body)
|
||
dry.Set("parts_count", len(parts))
|
||
if len(normalizations) > 0 {
|
||
dry.Set("normalizations", normalizations)
|
||
}
|
||
return dry
|
||
},
|
||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
presentationID, err := resolvePresentationID(runtime, ref)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
slideID := strings.TrimSpace(runtime.Str("slide-id"))
|
||
|
||
parts, normalizations, err := parseReplacePartsWithNormalization(runtime.Str("parts"))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := validateReplaceParts(parts); err != nil {
|
||
return err
|
||
}
|
||
injected, err := injectBlockReplaceIDs(parts)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
query := map[string]interface{}{
|
||
"slide_id": slideID,
|
||
"revision_id": runtime.Int("revision-id"),
|
||
}
|
||
if tid := strings.TrimSpace(runtime.Str("tid")); tid != "" {
|
||
query["tid"] = tid
|
||
}
|
||
body := replaceSlideBody(injected, runtime)
|
||
|
||
data, err := runtime.CallAPITyped("POST", slideReplaceAPIPath(presentationID), query, body)
|
||
if err != nil {
|
||
// Lint first: enrichSlidesReplaceError only fills an empty hint, so
|
||
// running it second leaves the specific lint finding in place and
|
||
// the generic 3350001 checklist for everything else.
|
||
return enrichSlidesReplaceError(enrichSlidesLintError(err))
|
||
}
|
||
|
||
result := map[string]interface{}{
|
||
"xml_presentation_id": presentationID,
|
||
"slide_id": slideID,
|
||
"parts_count": len(injected),
|
||
}
|
||
if len(normalizations) > 0 {
|
||
result["normalizations"] = normalizations
|
||
}
|
||
// Presence check (not `v > 0`) mirrors the failed_part_index / failed_reason
|
||
// branches below, so behavior stays consistent across the three fields.
|
||
if _, ok := data["revision_id"]; ok {
|
||
result["revision_id"] = int(common.GetFloat(data, "revision_id"))
|
||
}
|
||
// Backend reports partial failures via failed_part_index / failed_reason.
|
||
// Surface them untouched so the caller can react.
|
||
if raw, ok := data["failed_part_index"]; ok {
|
||
result["failed_part_index"] = raw
|
||
}
|
||
if raw, ok := data["failed_reason"]; ok {
|
||
result["failed_reason"] = raw
|
||
}
|
||
// issues points the other way from failed_reason: the parts were applied
|
||
// and committed, and the backend still had something to say about the page
|
||
// they produced. A finding serious enough to refuse the write leaves as an
|
||
// error carrying the same report, so whatever arrives here describes a page
|
||
// that is already stored.
|
||
if raw, ok := data["issues"]; ok {
|
||
result["issues"] = raw
|
||
}
|
||
|
||
runtime.Out(result, nil)
|
||
return nil
|
||
},
|
||
}
|
||
|
||
// replaceSlideBody builds the request body shared by dry-run and execute, so the
|
||
// two cannot disagree about the lint switch — the failure mode being a --dry-run
|
||
// that shows a linted request and an execute that sends an unlinted one.
|
||
func replaceSlideBody(parts []map[string]interface{}, runtime *common.RuntimeContext) map[string]interface{} {
|
||
return withLintXML(map[string]interface{}{"parts": parts}, runtime)
|
||
}
|
||
|
||
// replacePart is the normalized (post-JSON) representation of one entry in the
|
||
// parts array. Fields are nullable so we can tell "not provided" from "empty".
|
||
type replacePart struct {
|
||
Action string
|
||
Replacement *string
|
||
BlockID *string
|
||
Insertion *string
|
||
InsertBeforeBlockID *string
|
||
}
|
||
|
||
// replacePartNormalization records each compatibility conversion so callers can
|
||
// see the exact canonical request shape the CLI chose.
|
||
type replacePartNormalization struct {
|
||
PartIndex int `json:"part_index"`
|
||
Kind string `json:"kind"`
|
||
From string `json:"from"`
|
||
To string `json:"to"`
|
||
}
|
||
|
||
// parseReplaceParts decodes the --parts JSON into typed structs.
|
||
func parseReplaceParts(raw string) ([]replacePart, error) {
|
||
parts, _, err := parseReplacePartsWithNormalization(raw)
|
||
return parts, err
|
||
}
|
||
|
||
// parseReplacePartsWithNormalization applies only aliases whose semantics are
|
||
// determined by the action, then runs the existing strict field and type checks.
|
||
// Ambiguous actions remain errors; they are never guessed into block operations.
|
||
func parseReplacePartsWithNormalization(raw string) ([]replacePart, []replacePartNormalization, error) {
|
||
s := strings.TrimSpace(raw)
|
||
if s == "" {
|
||
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts cannot be empty").WithParam("--parts")
|
||
}
|
||
var decoded []map[string]interface{}
|
||
if err := json.Unmarshal([]byte(s), &decoded); err != nil {
|
||
return nil, nil, errs.NewValidationError(
|
||
errs.SubtypeInvalidArgument,
|
||
"--parts invalid JSON, must be an array of objects: %v",
|
||
err,
|
||
).WithParam("--parts").
|
||
WithHint("avoid shell-escaping errors by passing `--parts @parts.json` or piping JSON to `--parts -`").
|
||
WithCause(err)
|
||
}
|
||
out := make([]replacePart, 0, len(decoded))
|
||
normalizations := make([]replacePartNormalization, 0)
|
||
for i, m := range decoded {
|
||
itemNormalizations, err := normalizeReplacePartAliases(i, m)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
normalizations = append(normalizations, itemNormalizations...)
|
||
|
||
p := replacePart{}
|
||
if v, ok := m["action"]; ok {
|
||
s, ok := v.(string)
|
||
if !ok {
|
||
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].action must be a string", i).WithParam("--parts")
|
||
}
|
||
p.Action = s
|
||
} else if err := checkMisspelledAction(i, m); err != nil {
|
||
// "Action" selects no schema, so the per-action check below would skip
|
||
// the part entirely and validateReplaceParts would only say action is
|
||
// required. Name the misspelling instead.
|
||
return nil, nil, err
|
||
}
|
||
if err := checkReplacePartFields(i, m, p.Action); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
if v, ok := m["replacement"]; ok {
|
||
s, ok := v.(string)
|
||
if !ok {
|
||
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].replacement must be a string", i).WithParam("--parts")
|
||
}
|
||
p.Replacement = &s
|
||
}
|
||
if v, ok := m["block_id"]; ok {
|
||
s, ok := v.(string)
|
||
if !ok {
|
||
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].block_id must be a string", i).WithParam("--parts")
|
||
}
|
||
p.BlockID = &s
|
||
}
|
||
if v, ok := m["insertion"]; ok {
|
||
s, ok := v.(string)
|
||
if !ok {
|
||
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].insertion must be a string", i).WithParam("--parts")
|
||
}
|
||
p.Insertion = &s
|
||
}
|
||
if v, ok := m["insert_before_block_id"]; ok {
|
||
s, ok := v.(string)
|
||
if !ok {
|
||
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].insert_before_block_id must be a string", i).WithParam("--parts")
|
||
}
|
||
p.InsertBeforeBlockID = &s
|
||
}
|
||
out = append(out, p)
|
||
}
|
||
return out, normalizations, nil
|
||
}
|
||
|
||
var replaceActionAliases = map[string]string{
|
||
"replace": "block_replace",
|
||
"insert": "block_insert",
|
||
}
|
||
|
||
var compatibleXMLPayloadAliases = []string{"block", "content", "element", "shape"}
|
||
|
||
func normalizeReplacePartAliases(i int, part map[string]interface{}) ([]replacePartNormalization, error) {
|
||
normalizations := make([]replacePartNormalization, 0)
|
||
rawAction, hasAction := part["action"]
|
||
if !hasAction {
|
||
return normalizations, nil
|
||
}
|
||
action, ok := rawAction.(string)
|
||
if !ok {
|
||
return normalizations, nil
|
||
}
|
||
if canonical, ok := replaceActionAliases[action]; ok {
|
||
part["action"] = canonical
|
||
normalizations = append(normalizations, replacePartNormalization{
|
||
PartIndex: i, Kind: "action", From: action, To: canonical,
|
||
})
|
||
action = canonical
|
||
}
|
||
|
||
schema, ok := replacePartSchemas[action]
|
||
if !ok {
|
||
return normalizations, nil
|
||
}
|
||
if action == "block_replace" {
|
||
changed, err := normalizeReplacePartField(i, part, "target_id", "block_id")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if changed {
|
||
normalizations = append(normalizations, replacePartNormalization{
|
||
PartIndex: i, Kind: "field", From: "target_id", To: "block_id",
|
||
})
|
||
}
|
||
}
|
||
for _, alias := range compatibleXMLPayloadAliases {
|
||
changed, err := normalizeReplacePartField(i, part, alias, schema.payload)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if changed {
|
||
normalizations = append(normalizations, replacePartNormalization{
|
||
PartIndex: i, Kind: "field", From: alias, To: schema.payload,
|
||
})
|
||
}
|
||
}
|
||
return normalizations, nil
|
||
}
|
||
|
||
func normalizeReplacePartField(i int, part map[string]interface{}, alias, canonical string) (bool, error) {
|
||
aliasValue, hasAlias := part[alias]
|
||
if !hasAlias {
|
||
return false, nil
|
||
}
|
||
if canonicalValue, hasCanonical := part[canonical]; hasCanonical && !valuesEqual(canonicalValue, aliasValue) {
|
||
return false, errs.NewValidationError(
|
||
errs.SubtypeInvalidArgument,
|
||
"--parts[%d] fields %q and %q conflict; pass only %q, or give both the same value",
|
||
i, canonical, alias, canonical,
|
||
).WithParam("--parts")
|
||
}
|
||
if _, hasCanonical := part[canonical]; !hasCanonical {
|
||
part[canonical] = aliasValue
|
||
}
|
||
delete(part, alias)
|
||
return true, nil
|
||
}
|
||
|
||
func valuesEqual(a, b interface{}) bool {
|
||
aJSON, aErr := json.Marshal(a)
|
||
bJSON, bErr := json.Marshal(b)
|
||
return aErr == nil && bErr == nil && string(aJSON) == string(bJSON)
|
||
}
|
||
|
||
// replacePartSchema describes one exposed action: the fields it accepts, the
|
||
// field carrying its XML fragment, and a correct one-liner to echo back.
|
||
type replacePartSchema struct {
|
||
fields []string
|
||
payload string
|
||
hint string
|
||
}
|
||
|
||
// replacePartSchemas is the field contract of the two exposed actions. Holding
|
||
// it in one place lets the error name the exact set the caller may use, instead
|
||
// of a generic "unknown field".
|
||
var replacePartSchemas = map[string]replacePartSchema{
|
||
"block_replace": {
|
||
fields: []string{"action", "block_id", "replacement"},
|
||
payload: "replacement",
|
||
hint: `correct shape: {"action":"block_replace","block_id":"bUn","replacement":"<shape type=\"text\"><content><p>text</p></content></shape>"}`,
|
||
},
|
||
"block_insert": {
|
||
fields: []string{"action", "insertion", "insert_before_block_id"},
|
||
payload: "insertion",
|
||
hint: `correct shape: {"action":"block_insert","insertion":"<shape type=\"rect\" width=\"100\" height=\"100\"/>"}`,
|
||
},
|
||
}
|
||
|
||
// xmlPayloadAliases are field names callers reach for when they mean the XML
|
||
// payload. Exact deterministic aliases are normalized earlier; this broader
|
||
// list remains for unsupported spellings and casing variants so their errors
|
||
// can still suggest the action's canonical payload field. internal/suggest
|
||
// cannot route these reliably: edit distance misses most of them outright
|
||
// (content → replacement), and prefix ranking sends block_xml to block_id.
|
||
//
|
||
// Entries are limited to names actually observed carrying a fragment. A
|
||
// shape attribute like "fill" is deliberately absent: whoever writes it means
|
||
// "recolor this block", not "here is my XML", so pointing them at replacement
|
||
// would be guessing. The unknown-field error already names the valid set.
|
||
var xmlPayloadAliases = []string{"block", "block_xml", "content", "data", "element", "new_xml", "xml"}
|
||
|
||
// normalizeFieldKey folds case and the _/- separators so "Content", "newXml",
|
||
// "new-xml" and "new_xml" collapse onto one key. Callers pick the casing of
|
||
// whatever language they happen to be thinking in (the CLI's own flags are
|
||
// kebab-case), and the suggestion should survive that; the whitelist itself
|
||
// stays exact, because the API only accepts snake_case.
|
||
func normalizeFieldKey(k string) string {
|
||
k = strings.ReplaceAll(k, "_", "")
|
||
k = strings.ReplaceAll(k, "-", "")
|
||
return strings.ToLower(k)
|
||
}
|
||
|
||
// matchNormalized returns the candidate that matches k once both are
|
||
// normalized, so "blockId" resolves to "block_id".
|
||
func matchNormalized(k string, candidates []string) (string, bool) {
|
||
nk := normalizeFieldKey(k)
|
||
for _, c := range candidates {
|
||
if normalizeFieldKey(c) == nk {
|
||
return c, true
|
||
}
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// checkMisspelledAction runs when a part has no exact "action" key: if some key
|
||
// normalizes to it, that spelling is the real problem, and saying so beats the
|
||
// bare "action is required" the caller would otherwise get.
|
||
func checkMisspelledAction(i int, m map[string]interface{}) error {
|
||
keys := make([]string, 0, len(m))
|
||
for k := range m {
|
||
keys = append(keys, k)
|
||
}
|
||
slices.Sort(keys)
|
||
for _, k := range keys {
|
||
if normalizeFieldKey(k) != "action" {
|
||
continue
|
||
}
|
||
err := errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||
"--parts[%d] unknown field %q; did you mean %q?", i, k, "action",
|
||
).WithParam("--parts")
|
||
// The misspelled key still holds the action name, so the example can be
|
||
// the one the caller was actually reaching for.
|
||
if name, ok := m[k].(string); ok {
|
||
if schema, known := replacePartSchemas[name]; known {
|
||
return err.WithHint("%s", schema.hint)
|
||
}
|
||
}
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// checkReplacePartFields rejects fields that don't belong to the part's action,
|
||
// naming the field the caller most likely meant. Actions this shortcut doesn't
|
||
// expose ("", str_replace, unknown) are skipped so their own errors from
|
||
// validateReplaceParts still win.
|
||
func checkReplacePartFields(i int, m map[string]interface{}, action string) error {
|
||
schema, ok := replacePartSchemas[action]
|
||
if !ok {
|
||
return nil
|
||
}
|
||
keys := make([]string, 0, len(m))
|
||
for k := range m {
|
||
keys = append(keys, k)
|
||
}
|
||
// Sorted so a part carrying several bad fields always reports the same one.
|
||
slices.Sort(keys)
|
||
for _, k := range keys {
|
||
if slices.Contains(schema.fields, k) {
|
||
continue
|
||
}
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||
"%s", unknownPartFieldMessage(i, k, action, schema),
|
||
).WithParam("--parts").WithHint("%s", schema.hint)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// unknownPartFieldMessage builds the error text for one rejected field,
|
||
// naming the field the caller most likely meant.
|
||
func unknownPartFieldMessage(i int, key, action string, schema replacePartSchema) string {
|
||
valid := fmt.Sprintf("Valid fields for %s: %s", action, strings.Join(schema.fields, ", "))
|
||
// Right field, wrong casing or separator (e.g. "blockId", "block-id").
|
||
if field, ok := matchNormalized(key, schema.fields); ok {
|
||
return fmt.Sprintf("--parts[%d] unknown field %q; did you mean %q? %s", i, key, field, valid)
|
||
}
|
||
if _, ok := matchNormalized(key, xmlPayloadAliases); ok {
|
||
return fmt.Sprintf("--parts[%d] unknown field %q; did you mean %q? %s", i, key, schema.payload, valid)
|
||
}
|
||
// Other actions are checked in sorted-name order so the one named in the
|
||
// error stays deterministic even if a future field matches several.
|
||
others := make([]string, 0, len(replacePartSchemas))
|
||
for name := range replacePartSchemas {
|
||
if name != action {
|
||
others = append(others, name)
|
||
}
|
||
}
|
||
slices.Sort(others)
|
||
for _, other := range others {
|
||
if _, ok := matchNormalized(key, replacePartSchemas[other].fields); ok {
|
||
return fmt.Sprintf("--parts[%d] unknown field %q; it belongs to %s. %s", i, key, other, valid)
|
||
}
|
||
}
|
||
return fmt.Sprintf("--parts[%d] unknown field %q. %s", i, key, valid)
|
||
}
|
||
|
||
const larkCodeSlidesInvalidParam = 3350001
|
||
|
||
// slides3350001Hint is the generic checklist attached to 3350001 errors.
|
||
// 3350001 is a catch-all on the backend; listing the common root causes gives
|
||
// AI agents and humans a concrete starting point. Mixed block_replace+block_insert
|
||
// batches are supported, so splitting them is deliberately NOT suggested.
|
||
const slides3350001Hint = "common causes: (1) block_id not found in current slide — re-run slide.get for latest XML; (2) invalid XML structure or unsupported element; (3) element coordinates exceed slide bounds (960×540)"
|
||
|
||
// enrichSlidesReplaceError attaches slides3350001Hint when the API returns
|
||
// 3350001 (invalid param). Other error codes pass through untouched.
|
||
func enrichSlidesReplaceError(err error) error {
|
||
p, ok := errs.ProblemOf(err)
|
||
if !ok || p.Code != larkCodeSlidesInvalidParam {
|
||
return err
|
||
}
|
||
// Only fall back to the generic checklist when no upstream hint is
|
||
// already attached — don't clobber a more specific hint set by the
|
||
// backend or an earlier wrapper. p points at the embedded Problem, so
|
||
// the mutation is reflected in the returned err.
|
||
if p.Hint == "" {
|
||
p.Hint = slides3350001Hint
|
||
}
|
||
return err
|
||
}
|
||
|
||
// validateReplaceParts enforces CLI-level invariants:
|
||
// - size is within [1, 200]
|
||
// - action is one of the exposed actions (block_replace / block_insert)
|
||
// - per-action required fields are present
|
||
func validateReplaceParts(parts []replacePart) error {
|
||
if len(parts) == 0 {
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts must contain at least 1 item").WithParam("--parts")
|
||
}
|
||
if len(parts) > maxReplaceParts {
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts contains %d items, exceeds maximum of %d", len(parts), maxReplaceParts).WithParam("--parts")
|
||
}
|
||
for i, p := range parts {
|
||
switch p.Action {
|
||
case "block_replace":
|
||
if p.BlockID == nil || strings.TrimSpace(*p.BlockID) == "" {
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] (block_replace) requires non-empty block_id", i).WithParam("--parts")
|
||
}
|
||
if p.Replacement == nil || strings.TrimSpace(*p.Replacement) == "" {
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] (block_replace) requires non-empty replacement", i).WithParam("--parts")
|
||
}
|
||
case "block_insert":
|
||
if p.Insertion == nil || strings.TrimSpace(*p.Insertion) == "" {
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] (block_insert) requires non-empty insertion", i).WithParam("--parts")
|
||
}
|
||
case "str_replace":
|
||
// Backend still accepts str_replace, but product decision is to
|
||
// force structural edits through the CLI. Block it up-front so
|
||
// users don't build tooling around an option we won't keep.
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] action %q is not supported by this shortcut; use block_replace or block_insert", i, p.Action).WithParam("--parts")
|
||
case "replace_all":
|
||
return errs.NewValidationError(
|
||
errs.SubtypeInvalidArgument,
|
||
"--parts[%d] action %q is not equivalent to a block operation and cannot be normalized safely; use block_replace for each known block or block_insert for new elements",
|
||
i, p.Action,
|
||
).WithParam("--parts")
|
||
case "page_replace", "slide_replace":
|
||
return errs.NewValidationError(
|
||
errs.SubtypeInvalidArgument,
|
||
"--parts[%d] action %q means whole-page replacement and cannot be normalized to block_replace; use `slides +update-slide` to rewrite the whole page in place",
|
||
i, p.Action,
|
||
).WithParam("--parts")
|
||
case "":
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].action is required", i).WithParam("--parts")
|
||
default:
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] unknown action %q, supported: block_replace, block_insert", i, p.Action).WithParam("--parts")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// injectBlockReplaceIDs rewrites each block_replace part's `replacement` so
|
||
// that the root element carries id="<block_id>". Backend (3350001) requires
|
||
// this; doing it in the CLI means users write natural-looking XML (e.g.
|
||
// `<shape type="rect">…</shape>`) and get the id stitched in automatically.
|
||
//
|
||
// Returns a slice of `map[string]interface{}` ready to be encoded as the
|
||
// request body, preserving field order handed to the JSON encoder.
|
||
func injectBlockReplaceIDs(parts []replacePart) ([]map[string]interface{}, error) {
|
||
out := make([]map[string]interface{}, 0, len(parts))
|
||
for i, p := range parts {
|
||
m := map[string]interface{}{"action": p.Action}
|
||
switch p.Action {
|
||
case "block_replace":
|
||
fixed, err := ensureXMLRootID(*p.Replacement, *p.BlockID)
|
||
if err != nil {
|
||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].replacement: %v", i, err).WithParam("--parts").WithCause(err)
|
||
}
|
||
fixed = ensureShapeHasContent(fixed)
|
||
m["block_id"] = *p.BlockID
|
||
m["replacement"] = fixed
|
||
case "block_insert":
|
||
m["insertion"] = ensureShapeHasContent(*p.Insertion)
|
||
if p.InsertBeforeBlockID != nil {
|
||
m["insert_before_block_id"] = *p.InsertBeforeBlockID
|
||
}
|
||
}
|
||
out = append(out, m)
|
||
}
|
||
return out, nil
|
||
}
|