Files
larksuite__cli/shortcuts/slides/slides_replace_slide.go
tianyouskrrr 7cfbea68d9 feat(slides): normalize replace-slide part aliases (#2225)
Normalize deterministic +replace-slide part aliases (replace→block_replace,
target_id→block_id, and payload field folding), and reject semantically
different actions up front.

Point whole-page actions at +update-slide now that it is GA:
- page_replace / slide_replace recovery guidance and the reference error
  table now direct callers to `slides +update-slide` (in-place whole-page
  rewrite) instead of the deprecated +replace-pages.
- drop the +replace-pages reference doc, completing the #2227 deprecation
  (the command stays; its deprecation signal is carried by --help and the
  output JSON `deprecated` field).

Generalize whole-page validation gating to the behavior rather than a
command name (SKILL.md, workflow/validation-xml.md): "整页写回后" instead of
"每次通过 +update-slide 整页写回后". Folds in PR #2255.
2026-08-10 19:28:03 +08:00

619 lines
24 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.
//
// `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)"},
},
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 := map[string]interface{}{"parts": injected}
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 := map[string]interface{}{"parts": injected}
data, err := runtime.CallAPITyped("POST", slideReplaceAPIPath(presentationID), query, body)
if err != nil {
return enrichSlidesReplaceError(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
}
runtime.Out(result, nil)
return nil
},
}
// 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
}