Files
larksuite__cli/shortcuts/slides/slides_replace_slide.go
R0bynZhu 7690ba4446 feat(slides): lint slide writes server-side, add --no-lint to opt out (#2607)
+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.
2026-09-03 14:04:05 +08:00

640 lines
25 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}