// Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT package slides import ( "context" "encoding/json" "fmt" "strings" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "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=""` 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 `` elements it auto-injects `` 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 + 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{ {Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true}, {Name: "slide-id", Desc: "slide page identifier (slide_id)", Required: true}, {Name: "parts", Desc: "JSON array of replace parts (each: {action: block_replace|block_insert, ...}); 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, err := parseReplaceParts(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 = "" 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(fmt.Sprintf( "/open-apis/slides_ai/v1/xml_presentations/%s/slide/replace", validate.EncodePathSegment(presentationID), )). Params(query). Body(body) return dry.Set("parts_count", len(parts)) }, 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, err := parseReplaceParts(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} url := fmt.Sprintf( "/open-apis/slides_ai/v1/xml_presentations/%s/slide/replace", validate.EncodePathSegment(presentationID), ) data, err := runtime.CallAPITyped("POST", url, query, body) if err != nil { return enrichSlidesReplaceError(err) } result := map[string]interface{}{ "xml_presentation_id": presentationID, "slide_id": slideID, "parts_count": len(injected), } // 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 } // parseReplaceParts decodes the --parts JSON into typed structs. // // Accepts JSON with extra keys (pattern / is_multiple) so that a user who // copy-pasted a doc example doesn't get a decoder error; those keys are // ignored because str_replace isn't exposed. validateReplaceParts enforces // that nothing from the str_replace family actually gets used. func parseReplaceParts(raw string) ([]replacePart, error) { s := strings.TrimSpace(raw) if s == "" { return 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, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts invalid JSON, must be an array of objects: %v", err).WithParam("--parts").WithCause(err) } out := make([]replacePart, 0, len(decoded)) for i, m := range decoded { p := replacePart{} if v, ok := m["action"]; ok { s, ok := v.(string) if !ok { return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].action must be a string", i).WithParam("--parts") } p.Action = s } if v, ok := m["replacement"]; ok { s, ok := v.(string) if !ok { return 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, 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, 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, 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, nil } 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 "": 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="". Backend (3350001) requires // this; doing it in the CLI means users write natural-looking XML (e.g. // ``) 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 }