From 3dd041660789014e8d4c3b3d654fd65035f15962 Mon Sep 17 00:00:00 2001 From: "caichengjie.viper" Date: Thu, 23 Apr 2026 16:45:35 +0800 Subject: [PATCH] feat(slides): add +replace-slide shortcut for block-level XML edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces `lark-cli slides +replace-slide`, a shortcut over the native `xml_presentation.slide.replace` API for element-level editing of existing Lark Slides pages. Callers pass a JSON array of parts and the CLI handles URL resolution, XML hygiene, client-side validation, and 3350001 hint enrichment. Why a dedicated shortcut The native API has three sharp edges every caller hits: 1. URL formats. Users have /slides/ or /wiki/ URLs, not bare xml_presentation_id. 2. Undocumented XML hygiene. `block_replace` requires id= on the replacement root; requires . Missing either returns a catch-all 3350001 with no guidance. 3. 3350001 is a catch-all on the backend with no actionable message. Code shortcuts/slides/slides_replace_slide.go (new) - Flags: --presentation (bare token | /slides/ URL | /wiki/ URL), --slide-id, --parts (JSON array, max 200), --revision-id (-1 for current, specific number for optimistic locking), --tid, --as user|bot. - Validation (pre-API): [1,200] item cap; action restricted to block_replace / block_insert (str_replace rejected); per-action required fields (block_id for block_replace, insertion for block_insert); per-field string type-assertion guards on the decoded JSON so a numeric/bool payload fails fast with a targeted error. - XML hygiene: * injects id="" on block_replace replacement roots; * auto-expands self-closing and injects on shapes for SML 2.0 compliance. Dry-run surfaces injection errors and renders the same path-encoded presentationID that Execute sends. - On backend 3350001 attaches a generic common-causes checklist (missing block_id / invalid XML / coords out of 960×540). shortcuts/slides/helpers.go - ensureXMLRootID: regex tightened to `(?:^|\s)id` so data-id and xml:id are not matched as root id. - ensureShapeHasContent: regex `)` avoids false positives like ; self-closing branch preserves trailing siblings. shortcuts/slides/shortcuts.go: register SlidesReplaceSlide. Tests (package coverage 89.4%; parseReplaceParts and injectBlockReplaceIDs both reach 100%) - helpers_test.go: regex edge cases, id override semantics, content auto-inject across self-closing and open-tag shapes. - slides_replace_slide_test.go: parameter validation table, URL resolution (slides / wiki), mixed block_replace + block_insert, size boundaries, auto-inject behavior, 3350001 hint enrichment, per-field type-assertion guards, whitespace-only --parts guard (distinct from the `[]` "at least 1 item" path), replacement without root element surfaces pre-flight instead of reaching the backend, and a tight negative assertion that non-3350001 errors get no slides-specific hint. Docs (skills/lark-slides) - SKILL.md: add +replace-slide to the Shortcuts table, register the new xml_presentation.slide.get / .replace native endpoints, update core rule 7 to prefer block-level replace over full-page rebuild now that element-level editing exists, extend the error table with 3350001 / 3350002 pointing at the replace-slide doc, add "add image to existing slide via block_insert" as an explicit Workflow step and symptom-table entry, and refresh the reference index to include the three new docs below. The old "整页替换" 4-rule checklist is retired — its one still-relevant guard (new avoiding overlap) is preserved in the symptom table. - New references: * lark-slides-replace-slide.md — flags, parts schema, auto-inject notes, mixed-action support, 200-item cap, revision_id semantics, error table, and a "合法根元素速查" cheatsheet for the eight supported root elements (shape / line / polyline / img / icon / table / td / chart) with minimal verified XML snippets. Explicit unsupported list: video / audio / whiteboard (these appear only as export placeholders in SML 2.0). * lark-slides-edit-workflows.md — recipe-style edit flows covering the read → modify → write loop and the block_replace vs block_insert decision tree. * lark-slides-xml-presentation-slide-get.md — native read API with block_id extraction examples. - Fixes across existing references: * replace / create / delete / presentations.get: add the .data wrapper in return-value examples, correct jq paths. * media-upload: fix jq path .file_token → .data.file_token. * examples.md: annotate auto-inject behavior, replace the incorrect failed_part_index example with the actual 3350001 error shape. Empirical corrections (BOE-verified) - revision_id: stale-but-existing values are accepted; only values greater than current return 3350002. - Wrong block_id returns 3350001, not a 200 with failed_part_index. - Mixed block_replace + block_insert in one call is supported. - Type-mismatched block_replace (e.g. shape id with a replacement) is silently accepted by the backend and may destroy content; 3350001 specifically signals a missing block_id. --- shortcuts/slides/helpers.go | 128 ++++ shortcuts/slides/helpers_test.go | 224 ++++++ shortcuts/slides/shortcuts.go | 1 + shortcuts/slides/slides_replace_slide.go | 344 +++++++++ shortcuts/slides/slides_replace_slide_test.go | 686 ++++++++++++++++++ skills/lark-slides/SKILL.md | 60 +- skills/lark-slides/references/examples.md | 102 ++- .../references/lark-slides-edit-workflows.md | 142 ++++ .../references/lark-slides-media-upload.md | 39 +- .../references/lark-slides-replace-slide.md | 239 ++++++ ...rk-slides-xml-presentation-slide-create.md | 12 +- ...rk-slides-xml-presentation-slide-delete.md | 10 +- .../lark-slides-xml-presentation-slide-get.md | 110 +++ ...k-slides-xml-presentation-slide-replace.md | 186 +++++ .../lark-slides-xml-presentations-get.md | 24 +- 15 files changed, 2226 insertions(+), 81 deletions(-) create mode 100644 shortcuts/slides/slides_replace_slide.go create mode 100644 shortcuts/slides/slides_replace_slide_test.go create mode 100644 skills/lark-slides/references/lark-slides-edit-workflows.md create mode 100644 skills/lark-slides/references/lark-slides-replace-slide.md create mode 100644 skills/lark-slides/references/lark-slides-xml-presentation-slide-get.md create mode 100644 skills/lark-slides/references/lark-slides-xml-presentation-slide-replace.md diff --git a/shortcuts/slides/helpers.go b/shortcuts/slides/helpers.go index 8bdecbda5..c8722b209 100644 --- a/shortcuts/slides/helpers.go +++ b/shortcuts/slides/helpers.go @@ -148,6 +148,134 @@ func extractImagePlaceholderPaths(slideXMLs []string) []string { return paths } +// xmlRootOpenTagRegex matches the first opening tag of an XML fragment: +// skipping leading whitespace, XML declaration (), and comments +// (). +// +// Match groups: +// +// 1: leading prefix (whitespace / decl / comments) — preserved on rewrite +// 2: tag name +// 3: attributes span (may be empty; leading whitespace included) +// 4: closing marker — "/>" (self-closing) or ">" (open tag) +// +// Regex is (?s) so "." crosses newlines; we anchor with \A so the opener +// really is the fragment's root, not any nested later in the string. +var xmlRootOpenTagRegex = regexp.MustCompile(`(?s)\A(\s*(?:<\?[^?]*(?:\?[^>][^?]*)*\?>\s*)?(?:\s*)*)<([A-Za-z_][\w.-]*)((?:\s[^>]*?)?)(/?>)`) + +// xmlIdAttrRegex matches a standalone `id="..."` or `id='...'` attribute +// (with optional whitespace around `=`). Group 1 is the quote char, group 2 +// the value. Case-sensitive: XML attribute names are case-sensitive and the +// SML 2.0 schema uses lowercase `id`. +// +// Uses (?:^|\s) instead of \b so that attributes whose names merely contain +// "id" as a suffix (e.g. data-id, xml:id) are not accidentally matched — +// \b treats the '-' / ':' before "id" as a word boundary and would fire. +var xmlIdAttrRegex = regexp.MustCompile(`(?s)(?:^|\s)id\s*=\s*(["'])(.*?)(["'])`) + +// ensureXMLRootID parses xmlFragment as XML, locates the root element's +// opening tag, and ensures it carries id="want". Behavior: +// +// - root has no id → inject ` id="want"` into the attributes span +// - root has id and value == want → returned unchanged +// - root has id but value != want → value overridden with want +// +// Whitespace, surrounding attributes, and self-closing form are preserved. +// Nested elements are never touched. Returns an error when no root element +// can be found (empty/malformed fragment). +// +// The regex approach matches the pattern used by imgSrcPlaceholderRegex +// elsewhere in this package: preserve caller formatting instead of round- +// tripping through encoding/xml (which reformats whitespace and loses +// attribute order). +func ensureXMLRootID(xmlFragment, want string) (string, error) { + m := xmlRootOpenTagRegex.FindStringSubmatchIndex(xmlFragment) + if m == nil { + return "", fmt.Errorf("no root element found in XML fragment") + } + prefix := xmlFragment[m[2]:m[3]] + tagName := xmlFragment[m[4]:m[5]] + attrs := xmlFragment[m[6]:m[7]] + closer := xmlFragment[m[8]:m[9]] + rest := xmlFragment[m[1]:] + + // Check for existing id in the attrs span. + if sub := xmlIdAttrRegex.FindStringSubmatchIndex(attrs); sub != nil { + if attrs[sub[4]:sub[5]] == want { + return xmlFragment, nil + } + // Override: replace only the value between the existing quotes; + // the original quote style is preserved because we only touch [sub[4]:sub[5]]. + newAttrs := attrs[:sub[4]] + want + attrs[sub[5]:] + return prefix + "<" + tagName + newAttrs + closer + rest, nil + } + + // No id → inject ` id="want"` at the end of the attrs span, preserving + // any pre-closer whitespace (e.g. the " " in `` + // before `/>`). We split the span into (content, trailing-ws), append + // our attr to the content side, then put the trailing whitespace back. + trimmed := strings.TrimRight(attrs, " \t\n\r") + trailing := attrs[len(trimmed):] + injected := trimmed + fmt.Sprintf(` id="%s"`, want) + trailing + return prefix + "<" + tagName + injected + closer + rest, nil +} + +// xmlContentTagRegex matches a opening tag in its various valid +// forms (open tag, self-closing, or with attributes). The character after +// "content" must be whitespace, '/', or '>' — this ensures that tags whose +// names merely start with "content" (e.g. ) are not matched. +var xmlContentTagRegex = regexp.MustCompile(`)`) + +// ensureShapeHasContent ensures that a root element has a +// child. The SML 2.0 schema requires every to carry ; a +// self-closing or an open without causes the +// backend to return 3350001 (invalid param). Auto-injecting here mirrors the +// id-injection done by ensureXMLRootID — users write natural XML and the CLI +// patches in the required boilerplate. +// +// Only elements are affected; , , etc. are left +// untouched because they have different child-element schemas. +func ensureShapeHasContent(xmlFragment string) string { + m := xmlRootOpenTagRegex.FindStringSubmatchIndex(xmlFragment) + if m == nil { + return xmlFragment + } + tagName := xmlFragment[m[4]:m[5]] + if tagName != "shape" { + return xmlFragment + } + closer := xmlFragment[m[8]:m[9]] + + if closer == "/>" { + prefix := xmlFragment[m[2]:m[3]] + attrs := xmlFragment[m[6]:m[7]] + trimmed := strings.TrimRight(attrs, " \t\n\r") + rest := xmlFragment[m[1]:] + return prefix + "<" + tagName + trimmed + ">" + rest + } + + afterOpen := xmlFragment[m[1]:] + if xmlContentTagRegex.MatchString(afterOpen) { + return xmlFragment + } + + closeTag := "" + closeIdx := strings.Index(afterOpen, closeTag) + if closeIdx < 0 { + return xmlFragment + } + // Only inject when the shape body is empty. If the user already wrote + // non-content children (e.g. `

hi

`), + // prepending `` would make

a sibling of — per + // SML 2.0

must live inside , so the result would be schema- + // legal but semantically wrong (empty content + stray

). Leave that + // case to the backend's 3350001 rather than silently rewrap children. + if strings.TrimSpace(afterOpen[:closeIdx]) != "" { + return xmlFragment + } + return xmlFragment[:m[1]] + "" + afterOpen +} + // replaceImagePlaceholders rewrites occurrences in the input // XML by looking up each path in tokens. Paths missing from the map are left // untouched (callers should ensure the map is complete). diff --git a/shortcuts/slides/helpers_test.go b/shortcuts/slides/helpers_test.go index 83db445be..bd1a740b7 100644 --- a/shortcuts/slides/helpers_test.go +++ b/shortcuts/slides/helpers_test.go @@ -57,6 +57,109 @@ func TestParsePresentationRef(t *testing.T) { } } +func TestEnsureShapeHasContent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + { + name: "self-closing shape gets content injected", + in: ``, + want: ``, + }, + { + name: "self-closing shape with id already injected", + in: ``, + want: ``, + }, + { + // If the user already wrote non-content children, injecting + // as a sibling would make

a sibling of + // (schema-legal but semantically wrong per SML 2.0, which + // requires

to live inside ). Leave that case to + // the backend's 3350001 rather than silently rewrap. + name: "open shape with non-content children is left untouched", + in: `

hello

`, + want: `

hello

`, + }, + { + name: "empty open shape gets content injected", + in: ``, + want: ``, + }, + { + name: "shape with content already present is unchanged", + in: `

hi

`, + want: `

hi

`, + }, + { + name: "shape with self-closing content is unchanged", + in: ``, + want: ``, + }, + { + name: "img self-closing is not touched", + in: ``, + want: ``, + }, + { + name: "img open tag is not touched", + in: ``, + want: ``, + }, + { + name: "table is not touched", + in: `
`, + want: `
`, + }, + { + name: "bare self-closing shape", + in: ``, + want: ``, + }, + { + name: "shape with trailing space before self-close", + in: ``, + want: ``, + }, + { + // Regression: strings.Contains(" that merely start with "content". The regex + // now requires the char after "content" to be \s, / or >, so the + // shape is correctly classified as having no child. + // Even so, we don't inject — counts as an existing + // non-content child (same rule as the

case above), so the + // shape is left untouched for the backend to reject. + name: "shape with contention child is left untouched", + in: ``, + want: ``, + }, + { + name: "malformed input returned as-is", + in: `not xml at all`, + want: `not xml at all`, + }, + { + name: "empty string returned as-is", + in: ``, + want: ``, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := ensureShapeHasContent(tt.in) + if got != tt.want { + t.Fatalf("got %q\nwant %q", got, tt.want) + } + }) + } +} + func TestExtractImagePlaceholderPaths(t *testing.T) { t.Parallel() @@ -189,3 +292,124 @@ func TestReplaceImagePlaceholders(t *testing.T) { }) } } + +func TestEnsureXMLRootID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + wantOut string + wantErr string + }{ + { + name: "injects id when absent on self-closing tag", + in: ``, + want: "bUn", + wantOut: ``, + }, + { + name: "injects id when absent on open tag", + in: `

hi

`, + want: "bUn", + wantOut: `

hi

`, + }, + { + name: "leaves id alone when already matching", + in: ``, + want: "bUn", + wantOut: ``, + }, + { + name: "overrides mismatched id value preserving quotes and attrs", + in: ``, + want: "bUn", + wantOut: ``, + }, + { + name: "overrides single-quoted id", + in: ``, + want: "bUn", + wantOut: ``, + }, + { + name: "tolerates whitespace around equals", + in: ``, + want: "bUn", + wantOut: ``, + }, + { + name: "tolerates leading whitespace and XML declaration", + in: ``, + want: "bUn", + wantOut: ``, + }, + { + name: "does not touch nested element id", + in: ``, + want: "bUn", + wantOut: ``, + }, + { + name: "no duplicate space before injected attr", + in: ``, + want: "bUn", + wantOut: ``, + }, + { + name: "bare tag gets id injected", + in: ``, + want: "bUn", + wantOut: ``, + }, + { + name: "empty string errors", + in: ``, + want: "bUn", + wantErr: "no root element", + }, + { + name: "whitespace-only errors", + in: " \n\t ", + want: "bUn", + wantErr: "no root element", + }, + { + name: "malformed no closing angle errors", + in: ``, + want: "bUn", + wantOut: ``, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := ensureXMLRootID(tt.in, tt.want) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("want error %q, got nil; out=%q", tt.wantErr, got) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("want error containing %q, got %q", tt.wantErr, err.Error()) + } + return + } + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got != tt.wantOut { + t.Fatalf("got %q\nwant %q", got, tt.wantOut) + } + }) + } +} diff --git a/shortcuts/slides/shortcuts.go b/shortcuts/slides/shortcuts.go index 3de3fdf8d..2ef3650ec 100644 --- a/shortcuts/slides/shortcuts.go +++ b/shortcuts/slides/shortcuts.go @@ -10,5 +10,6 @@ func Shortcuts() []common.Shortcut { return []common.Shortcut{ SlidesCreate, SlidesMediaUpload, + SlidesReplaceSlide, } } diff --git a/shortcuts/slides/slides_replace_slide.go b/shortcuts/slides/slides_replace_slide.go new file mode 100644 index 000000000..c576ce987 --- /dev/null +++ b/shortcuts/slides/slides_replace_slide.go @@ -0,0 +1,344 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/larksuite/cli/internal/output" + "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"}, + 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 { + if _, err := parsePresentationRef(runtime.Str("presentation")); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("slide-id")) == "" { + return common.FlagErrorf("--slide-id cannot be empty") + } + 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.CallAPI("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, common.FlagErrorf("--parts cannot be empty") + } + var decoded []map[string]interface{} + if err := json.Unmarshal([]byte(s), &decoded); err != nil { + return nil, common.FlagErrorf("--parts invalid JSON, must be an array of objects: %v", 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, common.FlagErrorf("--parts[%d].action must be a string", i) + } + p.Action = s + } + if v, ok := m["replacement"]; ok { + s, ok := v.(string) + if !ok { + return nil, common.FlagErrorf("--parts[%d].replacement must be a string", i) + } + p.Replacement = &s + } + if v, ok := m["block_id"]; ok { + s, ok := v.(string) + if !ok { + return nil, common.FlagErrorf("--parts[%d].block_id must be a string", i) + } + p.BlockID = &s + } + if v, ok := m["insertion"]; ok { + s, ok := v.(string) + if !ok { + return nil, common.FlagErrorf("--parts[%d].insertion must be a string", i) + } + p.Insertion = &s + } + if v, ok := m["insert_before_block_id"]; ok { + s, ok := v.(string) + if !ok { + return nil, common.FlagErrorf("--parts[%d].insert_before_block_id must be a string", i) + } + 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 { + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.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. + if exitErr.Detail.Hint == "" { + exitErr.Detail.Hint = slides3350001Hint + } + return exitErr +} + +// 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 common.FlagErrorf("--parts must contain at least 1 item") + } + if len(parts) > maxReplaceParts { + return common.FlagErrorf("--parts contains %d items, exceeds maximum of %d", len(parts), maxReplaceParts) + } + for i, p := range parts { + switch p.Action { + case "block_replace": + if p.BlockID == nil || strings.TrimSpace(*p.BlockID) == "" { + return common.FlagErrorf("--parts[%d] (block_replace) requires non-empty block_id", i) + } + if p.Replacement == nil || strings.TrimSpace(*p.Replacement) == "" { + return common.FlagErrorf("--parts[%d] (block_replace) requires non-empty replacement", i) + } + case "block_insert": + if p.Insertion == nil || strings.TrimSpace(*p.Insertion) == "" { + return common.FlagErrorf("--parts[%d] (block_insert) requires non-empty insertion", i) + } + 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 common.FlagErrorf("--parts[%d] action %q is not supported by this shortcut; use block_replace or block_insert", i, p.Action) + case "": + return common.FlagErrorf("--parts[%d].action is required", i) + default: + return common.FlagErrorf("--parts[%d] unknown action %q, supported: block_replace, block_insert", i, p.Action) + } + } + 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, output.ErrValidation("--parts[%d].replacement: %v", i, 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 +} diff --git a/shortcuts/slides/slides_replace_slide_test.go b/shortcuts/slides/slides_replace_slide_test.go new file mode 100644 index 000000000..7ed4d9155 --- /dev/null +++ b/shortcuts/slides/slides_replace_slide_test.go @@ -0,0 +1,686 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" +) + +// TestReplaceSlideBlockReplaceInjectsID is the core regression: users write +// as replacement and the CLI must stitch id="" +// onto the root before sending. The backend returns 3350001 otherwise. +func TestReplaceSlideBlockReplaceInjectsID(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide/replace", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"revision_id": 42}, + }, + } + reg.Register(stub) + + parts := `[{"action":"block_replace","block_id":"bUn","replacement":""}]` + err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ + "+replace-slide", + "--presentation", "pres_abc", + "--slide-id", "slide_xyz", + "--parts", parts, + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var body struct { + Parts []struct { + Action string `json:"action"` + BlockID string `json:"block_id"` + Replacement string `json:"replacement"` + } `json:"parts"` + } + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("decode body: %v\nraw=%s", err, stub.CapturedBody) + } + if len(body.Parts) != 1 { + t.Fatalf("parts = %d, want 1", len(body.Parts)) + } + got := body.Parts[0] + if got.Action != "block_replace" || got.BlockID != "bUn" { + t.Fatalf("part = %+v", got) + } + // The replacement must have id="bUn" injected into the root. + if !strings.Contains(got.Replacement, `id="bUn"`) { + t.Fatalf("replacement missing id=\"bUn\": %q", got.Replacement) + } + if !strings.Contains(got.Replacement, `type="rect"`) { + t.Fatalf("replacement dropped existing attr: %q", got.Replacement) + } + // Input was self-closing ; the content-injection pass should + // have expanded it to . Asserting both + // branches here guards against a future reorder between ensureXMLRootID + // and ensureShapeHasContent silently regressing the combined path. + if !strings.Contains(got.Replacement, "") || !strings.Contains(got.Replacement, "") { + t.Fatalf("self-closing shape should have been expanded with : %q", got.Replacement) + } + + data := decodeShortcutData(t, stdout) + if data["xml_presentation_id"] != "pres_abc" { + t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"]) + } + if data["slide_id"] != "slide_xyz" { + t.Fatalf("slide_id = %v", data["slide_id"]) + } + if data["revision_id"] != float64(42) { + t.Fatalf("revision_id = %v, want 42", data["revision_id"]) + } +} + +// TestReplaceSlideBlockReplacePreservesMatchingID verifies that if the user +// already wrote id="" in their XML, the CLI leaves the value alone. +func TestReplaceSlideBlockReplacePreservesMatchingID(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/slide/replace", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"revision_id": 7}}, + } + reg.Register(stub) + + parts := `[{"action":"block_replace","block_id":"bab","replacement":""}]` + err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ + "+replace-slide", + "--presentation", "pres_abc", + "--slide-id", "slide_xyz", + "--parts", parts, + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var body struct { + Parts []struct { + Replacement string `json:"replacement"` + } `json:"parts"` + } + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Parts[0].Replacement != `` { + t.Fatalf("replacement = %q, want auto-injected", body.Parts[0].Replacement) + } +} + +// TestReplaceSlideBlockReplaceOverridesMismatchedID verifies that if the user +// wrote the wrong id in their XML, the CLI rewrites it to match block_id. +func TestReplaceSlideBlockReplaceOverridesMismatchedID(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/slide/replace", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"revision_id": 7}}, + } + reg.Register(stub) + + parts := `[{"action":"block_replace","block_id":"bUn","replacement":""}]` + err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ + "+replace-slide", + "--presentation", "pres_abc", + "--slide-id", "slide_xyz", + "--parts", parts, + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var body struct { + Parts []struct { + Replacement string `json:"replacement"` + } `json:"parts"` + } + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if !strings.Contains(body.Parts[0].Replacement, `id="bUn"`) || + strings.Contains(body.Parts[0].Replacement, `id="wrong"`) { + t.Fatalf("replacement = %q, want id=\"bUn\" override", body.Parts[0].Replacement) + } +} + +// TestReplaceSlideBlockInsertPassthrough verifies block_insert parts are sent +// as-is (no id injection, since there is no block_id to inject). +func TestReplaceSlideBlockInsertPassthrough(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/slide/replace", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"revision_id": 5}}, + } + reg.Register(stub) + + parts := `[{"action":"block_insert","insertion":"","insert_before_block_id":"baa"}]` + err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ + "+replace-slide", + "--presentation", "pres_abc", + "--slide-id", "slide_xyz", + "--parts", parts, + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var body struct { + Parts []map[string]interface{} `json:"parts"` + } + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("decode body: %v", err) + } + got := body.Parts[0] + if got["action"] != "block_insert" { + t.Fatalf("action = %v", got["action"]) + } + if got["insertion"] != `` { + t.Fatalf("insertion mutated: %v", got["insertion"]) + } + if got["insert_before_block_id"] != "baa" { + t.Fatalf("insert_before_block_id = %v", got["insert_before_block_id"]) + } + if _, hasID := got["block_id"]; hasID { + t.Fatalf("block_insert should not carry block_id, got %v", got) + } +} + +// TestReplaceSlideRejectsStrReplace verifies str_replace is blocked at the +// CLI even though the backend supports it (product decision). +func TestReplaceSlideRejectsStrReplace(t *testing.T) { + t.Parallel() + + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + parts := `[{"action":"str_replace","pattern":"old","replacement":"new"}]` + err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ + "+replace-slide", + "--presentation", "pres_abc", + "--slide-id", "s", + "--parts", parts, + "--as", "user", + }) + if err == nil { + t.Fatal("expected error for str_replace action") + } + if !strings.Contains(err.Error(), "str_replace") || !strings.Contains(err.Error(), "block_replace") { + t.Fatalf("err = %v, want mention of both str_replace and block_replace", err) + } +} + +// TestReplaceSlideRejectsUnknownAction verifies unknown actions are rejected +// with a helpful error listing supported actions. +func TestReplaceSlideRejectsUnknownAction(t *testing.T) { + t.Parallel() + + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + parts := `[{"action":"nuke","block_id":"bUn"}]` + err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ + "+replace-slide", + "--presentation", "pres_abc", + "--slide-id", "s", + "--parts", parts, + "--as", "user", + }) + if err == nil { + t.Fatal("expected error for unknown action") + } + if !strings.Contains(err.Error(), "unknown action") { + t.Fatalf("err = %v, want 'unknown action'", err) + } +} + +// TestReplaceSlideMissingRequiredField checks per-action required fields. +func TestReplaceSlideMissingRequiredField(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + parts string + wantErr string + }{ + {"block_replace missing block_id", `[{"action":"block_replace","replacement":""}]`, "block_id"}, + {"block_replace missing replacement", `[{"action":"block_replace","block_id":"bUn"}]`, "replacement"}, + {"block_insert missing insertion", `[{"action":"block_insert"}]`, "insertion"}, + {"empty action", `[{"block_id":"bUn"}]`, "action is required"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ + "+replace-slide", + "--presentation", "pres_abc", + "--slide-id", "s", + "--parts", tt.parts, + "--as", "user", + }) + if err == nil { + t.Fatalf("expected error for %s", tt.name) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("err = %v, want substring %q", err, tt.wantErr) + } + }) + } +} + +// TestReplaceSlidePartsNonStringField covers the type-assertion guards in +// parseReplaceParts — each string field must reject non-string JSON values +// rather than silently coercing or panicking. +func TestReplaceSlidePartsNonStringField(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + parts string + wantErr string + }{ + { + "action is not a string", + `[{"action":123,"block_id":"bUn","replacement":""}]`, + "action must be a string", + }, + { + "replacement is not a string", + `[{"action":"block_replace","block_id":"bUn","replacement":123}]`, + "replacement must be a string", + }, + { + "block_id is not a string", + `[{"action":"block_replace","block_id":123,"replacement":""}]`, + "block_id must be a string", + }, + { + "insertion is not a string", + `[{"action":"block_insert","insertion":{"foo":"bar"}}]`, + "insertion must be a string", + }, + { + "insert_before_block_id is not a string", + `[{"action":"block_insert","insertion":"","insert_before_block_id":true}]`, + "insert_before_block_id must be a string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ + "+replace-slide", + "--presentation", "pres_abc", + "--slide-id", "s", + "--parts", tt.parts, + "--as", "user", + }) + if err == nil { + t.Fatalf("expected error for %s", tt.name) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("err = %v, want substring %q", err, tt.wantErr) + } + }) + } +} + +// TestReplaceSlideWhitespaceOnlyParts hits parseReplaceParts' pre-decode +// guard for a raw value that trims to empty. Distinct from `[]` which +// falls through to validateReplaceParts' "at least 1 item" error. +func TestReplaceSlideWhitespaceOnlyParts(t *testing.T) { + t.Parallel() + + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ + "+replace-slide", + "--presentation", "pres_abc", + "--slide-id", "s", + "--parts", " ", + "--as", "user", + }) + if err == nil { + t.Fatal("expected error for whitespace-only --parts") + } + if !strings.Contains(err.Error(), "cannot be empty") { + t.Fatalf("err = %v, want 'cannot be empty'", err) + } +} + +// TestReplaceSlideReplacementWithoutRootElement covers the ensureXMLRootID +// error branch inside injectBlockReplaceIDs: validateReplaceParts accepts +// any non-empty string for replacement, but a payload with no XML root +// (plain text / comment-only) fails at id-injection time and must surface +// as a clean validation error instead of reaching the backend. +func TestReplaceSlideReplacementWithoutRootElement(t *testing.T) { + t.Parallel() + + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ + "+replace-slide", + "--presentation", "pres_abc", + "--slide-id", "s", + "--parts", `[{"action":"block_replace","block_id":"bUn","replacement":"plain text, no root element"}]`, + "--as", "user", + }) + if err == nil { + t.Fatal("expected error for replacement without root element") + } + if !strings.Contains(err.Error(), "no root element") { + t.Fatalf("err = %v, want 'no root element'", err) + } +} + +// TestReplaceSlideEmptyParts verifies the 1..200 size bounds. +func TestReplaceSlideEmptyParts(t *testing.T) { + t.Parallel() + + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ + "+replace-slide", + "--presentation", "pres_abc", + "--slide-id", "s", + "--parts", `[]`, + "--as", "user", + }) + if err == nil { + t.Fatal("expected error for empty parts") + } + if !strings.Contains(err.Error(), "at least 1") { + t.Fatalf("err = %v, want 'at least 1'", err) + } +} + +func TestReplaceSlideTooManyParts(t *testing.T) { + t.Parallel() + + // Build 201 valid block_insert parts. + var b strings.Builder + b.WriteString("[") + for i := 0; i < 201; i++ { + if i > 0 { + b.WriteString(",") + } + fmt.Fprintf(&b, `{"action":"block_insert","insertion":""}`) + } + b.WriteString("]") + + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ + "+replace-slide", + "--presentation", "pres_abc", + "--slide-id", "s", + "--parts", b.String(), + "--as", "user", + }) + if err == nil { + t.Fatal("expected error for >200 parts") + } + if !strings.Contains(err.Error(), "200") { + t.Fatalf("err = %v, want mention of 200", err) + } +} + +// TestReplaceSlideInvalidJSON verifies a clear error for malformed --parts. +func TestReplaceSlideInvalidJSON(t *testing.T) { + t.Parallel() + + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ + "+replace-slide", + "--presentation", "pres_abc", + "--slide-id", "s", + "--parts", `not-json`, + "--as", "user", + }) + if err == nil { + t.Fatal("expected error for invalid JSON") + } + if !strings.Contains(err.Error(), "invalid JSON") { + t.Fatalf("err = %v, want 'invalid JSON'", err) + } +} + +// TestReplaceSlideWikiResolution verifies a wiki URL is resolved before the +// replace call, and the resolved token appears in the replace URL. +func TestReplaceSlideWikiResolution(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/get_node", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "obj_type": "slides", + "obj_token": "real_pres", + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/real_pres/slide/replace", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"revision_id": 1}}, + }) + + parts := `[{"action":"block_insert","insertion":""}]` + err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ + "+replace-slide", + "--presentation", "https://x.feishu.cn/wiki/wikcn_abc", + "--slide-id", "sid", + "--parts", parts, + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeShortcutData(t, stdout) + if data["xml_presentation_id"] != "real_pres" { + t.Fatalf("xml_presentation_id = %v, want real_pres", data["xml_presentation_id"]) + } +} + +// TestReplaceSlideDryRun verifies dry-run prints the URL with the slide_id +// query param and shows the id-injection result in the body. +func TestReplaceSlideDryRun(t *testing.T) { + t.Parallel() + + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + parts := `[{"action":"block_replace","block_id":"bUn","replacement":""}]` + err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ + "+replace-slide", + "--presentation", "pres_abc", + "--slide-id", "slide_xyz", + "--parts", parts, + "--dry-run", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "/slide/replace") { + t.Fatalf("dry-run missing endpoint: %s", out) + } + if !strings.Contains(out, "slide_xyz") { + t.Fatalf("dry-run missing slide_id: %s", out) + } + if !strings.Contains(out, `id=\"bUn\"`) && !strings.Contains(out, `id="bUn"`) { + t.Fatalf("dry-run body should show injected id=\"bUn\": %s", out) + } +} + +// TestReplaceSlidePassThroughFailureFields verifies failed_part_index / +// failed_reason are returned when the server reports partial failure. +func TestReplaceSlidePassThroughFailureFields(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/slide/replace", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "revision_id": 3, + "failed_part_index": 0, + "failed_reason": "block not found", + }, + }, + }) + + parts := `[{"action":"block_replace","block_id":"bxx","replacement":""}]` + err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ + "+replace-slide", + "--presentation", "pres_abc", + "--slide-id", "s", + "--parts", parts, + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeShortcutData(t, stdout) + if data["failed_part_index"] != float64(0) { + t.Fatalf("failed_part_index = %v", data["failed_part_index"]) + } + if data["failed_reason"] != "block not found" { + t.Fatalf("failed_reason = %v", data["failed_reason"]) + } +} + +func TestReplaceSlide3350001ErrorEnrichment(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + parts string + wantHint string + }{ + { + name: "block_replace with non-existent block_id gets generic hint", + parts: `[{"action":"block_replace","block_id":"bUn","replacement":""}]`, + wantHint: "common causes", + }, + { + // Mixed block_replace+block_insert is supported by the backend + // (empirically verified). A 3350001 in a mixed batch means something + // else went wrong (bad block_id, invalid XML, etc.) — use generic hint. + name: "mixed actions gets generic hint", + parts: `[{"action":"block_replace","block_id":"bUn","replacement":""},{"action":"block_insert","insertion":""}]`, + wantHint: "common causes", + }, + { + name: "block_insert only gets generic hint", + parts: `[{"action":"block_insert","insertion":""}]`, + wantHint: "common causes", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/slide/replace", + Body: map[string]interface{}{ + "code": 3350001, + "msg": "invalid param", + "data": map[string]interface{}{}, + }, + }) + + err := runSlidesShortcut(t, f, nil, SlidesReplaceSlide, []string{ + "+replace-slide", + "--presentation", "pres_abc", + "--slide-id", "s", + "--parts", tt.parts, + "--as", "user", + }) + if err == nil { + t.Fatal("expected error for 3350001") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected ExitError with Detail, got %v", err) + } + if exitErr.Detail.Code != 3350001 { + t.Fatalf("expected code 3350001, got %d", exitErr.Detail.Code) + } + if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) { + t.Fatalf("hint = %q, want substring %q", exitErr.Detail.Hint, tt.wantHint) + } + }) + } +} + +func TestReplaceSlideNon3350001ErrorNotEnriched(t *testing.T) { + t.Parallel() + + f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/slide/replace", + Body: map[string]interface{}{ + "code": 99991672, + "msg": "scope not enabled", + "data": map[string]interface{}{}, + }, + }) + + parts := `[{"action":"block_replace","block_id":"bUn","replacement":""}]` + err := runSlidesShortcut(t, f, nil, SlidesReplaceSlide, []string{ + "+replace-slide", + "--presentation", "pres_abc", + "--slide-id", "s", + "--parts", parts, + "--as", "user", + }) + if err == nil { + t.Fatal("expected error") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected ExitError, got %v", err) + } + if exitErr.Detail.Code != 99991672 { + t.Fatalf("expected code 99991672, got %d", exitErr.Detail.Code) + } + // Non-3350001 errors must not have the slides-specific hint attached. + // Assert the actual hint is not our 3350001 checklist, rather than a + // string the hint never emits. + if strings.Contains(exitErr.Detail.Hint, "common causes") { + t.Fatalf("non-3350001 error should not get slides-specific hint, got %q", exitErr.Detail.Hint) + } +} diff --git a/skills/lark-slides/SKILL.md b/skills/lark-slides/SKILL.md index 98ef935a8..005878e9c 100644 --- a/skills/lark-slides/SKILL.md +++ b/skills/lark-slides/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-slides version: 1.0.0 -description: "飞书幻灯片:以 XML 格式读取和管理 PPT 页面。创建演示文稿优先用 `+create`;XML API 主要用于读取 PPT 全文信息、创建和删除幻灯片页面。当用户需要创建 PPT、读取 PPT 内容、管理幻灯片页面时使用。" +description: "飞书幻灯片:创建和编辑幻灯片,接口通过 XML 协议通信。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。" metadata: requires: bins: ["lark-cli"] @@ -14,6 +14,8 @@ metadata: **CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。** +**编辑已有幻灯片页面**:优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。 + ## 身份选择 飞书幻灯片通常是用户自己的内容资源。**默认应优先显式使用 `--as user`(用户身份)执行 slides 相关操作**,始终显式指定身份。 @@ -30,7 +32,7 @@ lark-cli auth login --domain slides 1. 创建、读取、增删 slide、按用户给出的链接继续编辑已有 PPT,默认都先用 `--as user`。 2. 如果出现权限不足,先检查当前是否误用了 bot 身份;不要默认回退到 bot。 -3. 只有在用户明确要求“用应用身份 / bot 身份操作”,或当前工作流就是 bot 创建资源后再做协作授权时,才切换到 `--as bot`。 +3. 只有在用户明确要求"用应用身份 / bot 身份操作",或当前工作流就是 bot 创建资源后再做协作授权时,才切换到 `--as bot`。 ## 快速开始 @@ -64,6 +66,7 @@ lark-cli slides +create --title "演示文稿标题" --slides '[ | 需要 CLI 调用示例 | [examples.md](references/examples.md) | | 需要参考真实 PPT 的 XML | [slides_demo.xml](references/slides_demo.xml) | | 需要用 table/chart 等复杂元素 | [slides_xml_schema_definition.xml](references/slides_xml_schema_definition.xml)(完整 Schema) | +| 需要编辑已有 PPT 的单个页面 | [lark-slides-edit-workflows.md](references/lark-slides-edit-workflows.md) | | 需要了解某个命令的详细参数 | 对应命令的 reference 文档(见下方参考文档章节) | ## Workflow @@ -88,7 +91,9 @@ Step 2: 生成大纲 → 用户确认 → 创建 +create 会自动上传并替换为 file_token(详见 lark-slides-create.md) · 给已有 PPT 加带图新页 —— 先 `slides +media-upload --file ./pic.png --presentation $PID` 拿到 file_token,再用它写进 slide XML 调 xml_presentation.slide.create - · 给已有页加图 —— XML API 无元素级编辑,需要整页替换;必守规则和流程见下方「给已有 PPT 的已有页加图」章节 + · 给已有页加图 —— 两步:① `slides +media-upload` 拿 file_token + ② `slides +replace-slide --parts '[{"action":"block_insert","insertion":"\" .../>"}]'` + 不动其他元素,不要再整页重建(完整示例见 lark-slides-edit-workflows.md 的 block_insert 章节) · 路径必须是 CWD 内的相对路径(如 ./pic.png 或 ./assets/x.png); 绝对路径会被 CLI 拒绝,先 cd 到素材所在目录再执行 - 每页 slide 需要完整的 XML:背景、文本、图形、配色 @@ -98,7 +103,7 @@ Step 3: 审查 & 交付 - 创建完成后,用 xml_presentations.get 读取全文 XML,确认: · 页数是否正确?每页内容是否完整? · 配色是否统一?字号层级是否合理? - - 有问题 → 用 xml_presentation.slide.delete 删除问题页,重新创建 + - 局部问题 → 用 `+replace-slide` 块级修正;整页结构要改 → `slide.delete` 旧页 + `slide.create` 新页 - 没问题 → 交付:告知用户演示文稿 ID 和访问方式 ``` @@ -127,19 +132,6 @@ lark-cli slides xml_presentation.slide create \ '{slide:{content:$content}, before_slide_id:$before}')" ``` -### 给已有 PPT 的已有页加图(整页替换) - -XML API 没有元素级编辑接口(见核心规则 7)。想给某一页加图,只能**整页替换**:读原 slide → 加 `` → 原位 create 新页 → 删除旧页。 - -**必守 4 条**: - -1. **先 create 后 delete** —— 顺序反了且 create 失败会丢页 -2. **原 slide 的所有元素必须原样搬到新 XML**(标题、正文、形状、原有 img)—— 只写新 `` 会把原页其他内容全删掉 -3. **`before_slide_id=<旧 slide_id>` 必传,且必须放在 `--data` body 里**(与 `slide` 同级),**不能放在 `--params`** —— `--params` 只接 path/query 参数,body 字段塞进去会被 CLI 当未知 query 参数静默下发,服务端忽略,结果是新页追加到末尾、打乱页序。正确形状:`--data '{"slide":{"content":"..."},"before_slide_id":"<旧 slide_id>"}'` -4. **新 `` 坐标避开现有元素** —— 读原 `` 里元素的 `topLeftX/Y/width/height` 挑空白区;空间不够就先缩小/挪动现有元素再放图 - -完整 bash 模板和 `+media-upload` 参数见 [+media-upload 文档](references/lark-slides-media-upload.md)。 - ### 风格快速判断表 > **注意**:渐变色必须使用 `rgba()` 格式并带百分比停靠点,如 `linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)`。使用 `rgb()` 或省略停靠点会导致服务端回退为白色。 @@ -196,6 +188,8 @@ N. 结尾页:[结尾文案] | `/slides/` | `https://example.larkoffice.com/slides/xxxxxxxxxxxxx` | `xml_presentation_id` | URL 路径中的 token 直接作为 `xml_presentation_id` 使用 | | `/wiki/` | `https://example.larkoffice.com/wiki/wikcnxxxxxxxxx` | `wiki_token` | ⚠️ **不能直接使用**,需要先查询获取真实的 `obj_token` | +> `+replace-slide` 和 `+media-upload` shortcut 会自动解析以上两种 URL;直接调用原生 API 时仍需手动解析 wiki 链接。 + ### Wiki 链接特殊处理(关键!) 知识库链接(`/wiki/TOKEN`)背后可能是云文档、电子表格、幻灯片等不同类型的文档。**不能直接假设 URL 中的 token 就是 `xml_presentation_id`**,必须先查询实际类型和真实 token。 @@ -261,24 +255,27 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides + [flags]` |----------|------| | [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `` 占位符自动上传),bot 模式自动授权 | | [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 ``),最大 20 MB | +| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 ``,不改变页序 | ## API Resources ```bash -lark-cli schema slides.. # 调用 API 前必须先查看参数结构 -lark-cli slides [flags] # 调用 API +lark-cli schema slides.. # 调用 API 前必须先查看参数结构 +lark-cli slides [flags] # 调用 API ``` > **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。 ### xml_presentations - - `get` — 读取ppt全文信息,xml格式返回 + - `get` — 读取演示文稿全文信息,XML 格式返回 ### xml_presentation.slide - - `create` — 在指定 xml 演示文稿下创建页面 - - `delete` — 删除指定 xml 演示文稿下的页面 + - `create` — 在指定 XML 演示文稿下创建页面 + - `delete` — 在指定 XML 演示文稿下删除页面 + - `get` — 获取指定 XML 演示文稿的单个页面 XML 内容 + - `replace` — 对指定 XML 演示文稿页面进行元素级别的局部替换 ## 核心规则 @@ -288,7 +285,7 @@ lark-cli slides [flags] # 调用 API 4. **文本通过 `` 表达**:必须用 `

...

`,不能把文字直接写在 shape 内 5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id` 6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片 -7. **没有元素级编辑能力**:飞书 slides XML API 只有 slide 级 `create` / `delete`,**没有更新单个 shape/img 坐标或尺寸的接口**。不要向用户承诺"微调坐标/尺寸"、"挪一下这个图"。要改只能整页重建(`xml_presentations.get` 读回 → 改 XML → `slide.delete` 旧页 + `slide.create` 新页),且 `slide_id` 会变、默认追加到末尾(要回原位需 `before_slide_id`)。整页重建前必须先告知用户代价并确认 +7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create` 8. **`` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 ``」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。 ## 权限表 @@ -297,16 +294,18 @@ lark-cli slides [flags] # 调用 API |------|-----------| | `slides +create` | `slides:presentation:create`, `slides:presentation:write_only`(含 `@` 占位符时还需 `docs:document.media:upload`) | | `slides +media-upload` | `docs:document.media:upload`(wiki URL 解析还需 `wiki:node:read`) | +| `slides +replace-slide` | `slides:presentation:update`(wiki URL 解析还需 `wiki:node:read`) | | `xml_presentations.get` | `slides:presentation:read` | | `xml_presentation.slide.create` | `slides:presentation:update` 或 `slides:presentation:write_only` | | `xml_presentation.slide.delete` | `slides:presentation:update` 或 `slides:presentation:write_only` | +| `xml_presentation.slide.get` | `slides:presentation:read` | +| `xml_presentation.slide.replace` | `slides:presentation:update` | ## 常见错误速查 | 错误码 | 含义 | 解决方案 | |--------|------|----------| | 400 | XML 格式错误 | 检查 XML 语法,确保标签闭合 | -| 400 | create 内容超出支持范围 | `xml_presentations.create` 仅用于创建空白 PPT,不要在这里传完整 slide 内容 | | 400 | 请求包装错误 | 检查 `--data` 是否按 schema 传入 `xml_presentation.content` 或 `slide.content` | | 404 | 演示文稿不存在 | 检查 `xml_presentation_id` 是否正确 | | 404 | 幻灯片不存在 | 检查 `slide_id` 是否正确 | @@ -314,6 +313,8 @@ lark-cli slides [flags] # 调用 API | 400 | 无法删除唯一幻灯片 | 演示文稿至少保留一页幻灯片 | | 1061002 | params error(媒体上传时) | 用 `slides +media-upload`,不要手拼原生 `medias/upload_all`;slides 唯一可用 `parent_type` 是 `slide_file` | | 1061004 | forbidden:当前身份对演示文稿无编辑权限 | 确认 user/bot 对目标 PPT 有编辑权限;bot 常见于 PPT 非该 bot 创建,需先授权或用 `+create --as bot` 新建 | +| 3350001 | `xml_presentation.slide.replace` 失败(catch-all) | 检查 `block_replace` 替换根是否带 `id=`;`` 是否含 ``;坐标是否在 960×540 内。详见 [lark-slides-replace-slide.md](references/lark-slides-replace-slide.md) | +| 3350002 | `revision_id` 大于当前版本 | 用 `-1` 取当前版本,或重新读 `xml_presentations.get` 取最新 `revision_id` | | validation: unsafe file path | `--file` 给了绝对路径或上层路径 | `--file` 必须是 CWD 内相对路径;先 `cd` 到素材目录再执行 | ## 创建前自查 @@ -339,12 +340,14 @@ lark-cli slides [flags] # 调用 API | 表格列宽不合理 | 调整 `colgroup` 中 `col` 的 `width` 值 | | 图表没有显示 | 检查 `chartPlotArea` 和 `chartData` 是否都包含,`dim1`/`dim2` 数据数量是否匹配 | | 图片被裁掉一部分 | `` 的 `width`/`height` 是裁剪后尺寸,比例和原图不一致时会自动裁剪;要整图显示就让 `width:height` 对齐原图比例 | -| 给已有页加图后,原页文字/形状消失了 | 替换整页时必须把原 slide 的 `` 所有元素原样搬过来,不能只写新 `` | +| 只想改某页的单个元素(文字/图片/形状) | 用 `+replace-slide` 块级替换,不要整页重建 | +| 想给已有页加一张图(不动原有元素) | ① `+media-upload` 拿 `file_token` ② `+replace-slide` 用 `block_insert` 插入 ``;不要再用 "整页 create + delete" 的老流程 | +| 新插入的 `` 挡住/重叠原有元素 | `slide.get` 读原页,对照已有块的 `topLeftX/Y/width/height` 挑空白位置;空间不够就在同一批 `--parts` 里先 `block_replace` 缩小/挪动现有块再 `block_insert` 图片 | | 渐变背景变成白色 | 渐变必须用 `rgba()` 格式 + 百分比停靠点,如 `linear-gradient(135deg,rgba(30,60,114,1) 0%,rgba(59,130,246,1) 100%)`;用 `rgb()` 或省略停靠点会被回退为白色 | | 渐变方向不对 | 调整 `linear-gradient` 的角度(`90deg` 水平、`180deg` 垂直、`135deg` 对角线) | | 整体风格不统一 | 封面页和结尾页用同一背景,内容页保持一致的配色和字号体系 | | API 返回 400 | 检查 XML 语法:标签闭合、属性引号、特殊字符转义 | -| API 返回 3350001 | `xml_presentation_id` 不是通过 `xml_presentations.create` 创建的,或 token 不正确 | +| API 返回 3350001 | `block_replace` 根元素缺 `id=` 或 `` 缺 ``,详见 replace-slide 文档 | | 图片不显示 / `` 仍是 `@path` | `@` 占位符**只在 `+create --slides` 中替换**;直接调 `xml_presentation.slide.create` 必须先用 `+media-upload` 拿 `file_token` 写进 src | | 上传图片报 1061002 params error | `parent_type` 必须是 `slide_file`(slides 唯一接受值);不要手拼,用 `slides +media-upload` | @@ -354,15 +357,18 @@ lark-cli slides [flags] # 调用 API |------|------| | [lark-slides-create.md](references/lark-slides-create.md) | **+create Shortcut:创建 PPT(支持 `--slides` 一步添加页面,含 `@` 占位符自动上传图片)** | | [lark-slides-media-upload.md](references/lark-slides-media-upload.md) | **+media-upload Shortcut:上传本地图片,返回 `file_token`** | +| [lark-slides-replace-slide.md](references/lark-slides-replace-slide.md) | **+replace-slide Shortcut:块级替换/插入,含合法根元素速查与 3350001 排错** | +| [lark-slides-edit-workflows.md](references/lark-slides-edit-workflows.md) | 编辑已有页面的读-改-写流程与 action 决策树 | | [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md) | **XML Schema 精简速查(必读)** | | [slide-templates.md](references/slide-templates.md) | 可复制的 Slide XML 模板 | | [xml-format-guide.md](references/xml-format-guide.md) | XML 详细结构与示例 | | [examples.md](references/examples.md) | CLI 调用示例 | | [slides_demo.xml](references/slides_demo.xml) | 真实 PPT 的完整 XML | | [slides_xml_schema_definition.xml](references/slides_xml_schema_definition.xml) | **完整 Schema 定义**(唯一协议依据) | -| [lark-slides-xml-presentations-create.md](references/lark-slides-xml-presentations-create.md) | 创建空白 PPT 命令详情 | | [lark-slides-xml-presentations-get.md](references/lark-slides-xml-presentations-get.md) | 读取 PPT 命令详情 | | [lark-slides-xml-presentation-slide-create.md](references/lark-slides-xml-presentation-slide-create.md) | 添加幻灯片命令详情 | | [lark-slides-xml-presentation-slide-delete.md](references/lark-slides-xml-presentation-slide-delete.md) | 删除幻灯片命令详情 | +| [lark-slides-xml-presentation-slide-get.md](references/lark-slides-xml-presentation-slide-get.md) | 读取单个幻灯片命令详情 | +| [lark-slides-xml-presentation-slide-replace.md](references/lark-slides-xml-presentation-slide-replace.md) | 原生 slide.replace API 命令详情 | > **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides..` 输出不一致,以后两者为准。 diff --git a/skills/lark-slides/references/examples.md b/skills/lark-slides/references/examples.md index 2d4d88ca6..5a0c2d0d4 100644 --- a/skills/lark-slides/references/examples.md +++ b/skills/lark-slides/references/examples.md @@ -12,6 +12,8 @@ - [示例 4: 在指定页面前插入新幻灯片](#示例-4-在指定页面前插入新幻灯片) - [示例 5: 删除幻灯片](#示例-5-删除幻灯片) - [示例 6: 从文件读取 XML 后添加页面](#示例-6-从文件读取-xml-后添加页面) +- [示例 7: +replace-slide + block_insert 给已有页加图](#示例-7-replace-slide--block_insert-给已有页加图) +- [示例 8: +replace-slide + block_replace 替换一个块](#示例-8-replace-slide--block_replace-替换一个块) ## 示例 1: 使用 Shortcut 创建空白演示文稿 @@ -56,18 +58,22 @@ lark-cli slides xml_presentations get --as user --params '{ ```bash lark-cli slides xml_presentations get --as user --params '{ "xml_presentation_id": "slides_example_presentation_id" -}' | jq -r '.xml_presentation.content' +}' | jq -r '.data.xml_presentation.content' ``` 预期返回结构: ```json { - "xml_presentation": { - "presentation_id": "slides_example_presentation_id", - "revision_id": 3, - "content": "..." - } + "code": 0, + "data": { + "xml_presentation": { + "presentation_id": "slides_example_presentation_id", + "revision_id": 3, + "content": "..." + } + }, + "msg": "success" } ``` @@ -88,8 +94,12 @@ lark-cli slides xml_presentation.slide create --as user --params '{ ```json { - "slide_id": "slide_example_id", - "revision_id": 100 + "code": 0, + "data": { + "slide_id": "slide_example_id", + "revision_id": 100 + }, + "msg": "success" } ``` @@ -106,7 +116,11 @@ lark-cli slides xml_presentation.slide delete --as user --params '{ ```json { - "revision_id": 101 + "code": 0, + "data": { + "revision_id": 101 + }, + "msg": "success" } ``` @@ -140,6 +154,74 @@ lark-cli slides xml_presentation.slide create --as user \ --data "$(jq -n --arg content "$(cat slide.xml)" '{slide:{content:$content}}')" ``` +## 示例 7: +replace-slide + block_insert 给已有页加图 + +只想在已有页上加一张图、不动其他元素——走 shortcut `+replace-slide`,`block_insert` 追加到页末(或用 `insert_before_block_id` 指定位置)。 + +```bash +PID="slides_example_presentation_id" +SID="slide_example_id" + +# 1. 上传图片拿 file_token +TOKEN=$(lark-cli slides +media-upload --file ./pic.png --presentation "$PID" --as user \ + | jq -r '.data.file_token') + +# 2. block_insert 到页面末尾(省略 insert_before_block_id) +# 注: 是自闭合标签,CLI 不会展开(只有 会被补 ) +lark-cli slides +replace-slide --as user \ + --presentation "$PID" --slide-id "$SID" \ + --parts "$(jq -n --arg token "$TOKEN" \ + '[{action:"block_insert",insertion:("")}]')" +``` + +预期返回: + +```json +{ + "ok": true, + "data": { + "xml_presentation_id": "slides_example_presentation_id", + "slide_id": "slide_example_id", + "parts_count": 1, + "revision_id": 102 + } +} +``` + +## 示例 8: +replace-slide + block_replace 替换一个块 + +已知某块的 3 位 short element ID(从 `slide.get` 返回 XML 里读),整块换掉。`replacement` 根元素的 `id` 会由 CLI 自动注入为 `block_id`,无需手写;若写了 `` 自闭合形式,CLI 也会自动补 ``。 + +```bash +lark-cli slides +replace-slide --as user \ + --presentation slides_example_presentation_id \ + --slide-id slide_example_id \ + --parts '[ + { + "action": "block_replace", + "block_id": "bab", + "replacement": "

新标题

" + } + ]' +# CLI 实际发送的 replacement 根元素会带 id="bab",即使手写时省略了 +``` + +失败时(3350001 错误,CLI 在 error 字段中给出 hint): + +```json +{ + "ok": false, + "error": { + "type": "api_error", + "code": 3350001, + "message": "API error: [3350001] invalid param", + "hint": "common causes: (1) block_id not found in current slide ..." + } +} +``` + +整批作为原子事务,任一 part 失败则整批不生效;按 `failed_part_index` 定位修正后重发。 + ## 常见处理技巧 ### 获取最新 revision_id @@ -147,7 +229,7 @@ lark-cli slides xml_presentation.slide create --as user \ ```bash lark-cli slides xml_presentations get --as user --params '{ "xml_presentation_id": "slides_example_presentation_id" -}' | jq -r '.xml_presentation.revision_id' +}' | jq '.data.xml_presentation.revision_id' ``` ### 批量插入多页 diff --git a/skills/lark-slides/references/lark-slides-edit-workflows.md b/skills/lark-slides/references/lark-slides-edit-workflows.md new file mode 100644 index 000000000..4b7fa3c75 --- /dev/null +++ b/skills/lark-slides/references/lark-slides-edit-workflows.md @@ -0,0 +1,142 @@ +# 编辑已有 PPT:读-改-写闭环 + +编辑走 **shortcut [`+replace-slide`](lark-slides-replace-slide.md)**(块级替换 / 插入),配合 `xml_presentation.slide.get` 读原页拿 `block_id`。 + +> 生成 XML 前**必读** [xml-schema-quick-ref.md](xml-schema-quick-ref.md)。 + +## 决策树:block_replace vs block_insert + +| 需求 | 推荐 action | 理由 | +|------|------------|------| +| 已知某块的 `block_id`,要换这块内容(改标题、换图、挪坐标) | `block_replace` | 精准替换,原子性好;`replacement` 根 `id` 由 CLI 自动注入为 `block_id` | +| 只加 1~N 个元素、不动现有布局 | `block_insert` | 新增不覆盖,可选 `insert_before_block_id` 指定位置 | +| 一次动多个元素(如:换标题 + 加图) | 单次 `--parts` 里拼多条 | 整批作为原子事务,任一失败整批不生效;`block_replace` 和 `block_insert` 可混用 | + +> **没有字段级 patch**:即便只想改一个 `shape` 的 `topLeftX`,也得把整个块的新 XML 写出来用 `block_replace`。这不是"微调",是块级重写。 + +## 最小读-改-写闭环 + +```bash +PID="xml_presentation_id_here" +SID="slide_id_here" + +# 1. 读原页,从 XML 里挑出要改的块的 3 位 short id(如 bUn / bab) +lark-cli slides xml_presentation.slide get --as user \ + --params "{\"xml_presentation_id\":\"$PID\",\"slide_id\":\"$SID\"}" + +# 2. 用 +replace-slide 直接改那个块(不需要搬原 XML) +lark-cli slides +replace-slide --as user \ + --presentation "$PID" --slide-id "$SID" \ + --parts '[{"action":"block_replace","block_id":"bUn","replacement":"

新标题

"}]' +``` + +`slide_id` / 页序不会变。`block_replace` 的 `replacement` 根元素 `id` 会自动注入为 `block_id`,用户手写 XML 时不需要自己加。 + +## `revision_id` 参数 + +`--revision-id` 默认 `-1`,表示基于当前最新版执行。传具体版本号时,服务端以该版本为 base 应用变更: + +```bash +# 读时拿当前 revision_id +REV=$(lark-cli slides xml_presentation.slide get --as user \ + --params "{\"xml_presentation_id\":\"$PID\",\"slide_id\":\"$SID\"}" \ + | jq '.data.revision_id') + +# 写时传该版本号,服务端以此为 base +lark-cli slides +replace-slide --as user \ + --presentation "$PID" --slide-id "$SID" --revision-id "$REV" \ + --parts '[{"action":"block_replace","block_id":"bUn","replacement":""}]' +``` + +注意:传不存在的版本号(超过当前 revision)会返回 3350002 not found;不确定时用 `-1` 即可。 + +## `--tid` 事务锁 + +跨请求的并发事务 ID,多人协作长事务才用得上。**单人单次调用留空**即可。 + +## 两种 action 详解 + +### block_replace — 整块替换 + +适合"已知块 ID,要换这块整体内容"的场景。`replacement` 根元素的 `id=""` 由 CLI 自动注入(用户手写的 XML 如果没带 `id` 直接省略即可;如果带了错的会被覆盖为正确值)。 + +```bash +lark-cli slides +replace-slide --as user \ + --presentation "$PID" --slide-id "$SID" \ + --parts '[{"action":"block_replace","block_id":"bab","replacement":"

新标题

"}]' +``` + +字段说明: + +| 字段 | 必填 | 说明 | +|------|------|------| +| `action` | 是 | 固定为 `block_replace` | +| `block_id` | 是 | 目标块的 3 位 short element ID(从 `slide.get` 返回的 XML 里读)| +| `replacement` | 是 | 新 XML 片段;根元素 `id` 会被 CLI 自动注入为 `block_id` | + +### block_insert — 整块插入 + +适合"只想加一个元素,不动现有元素"的场景(典型:给已有页加图)。 + +```bash +lark-cli slides +replace-slide --as user \ + --presentation "$PID" --slide-id "$SID" \ + --parts "$(jq -n --arg token "$FILE_TOKEN" \ + '[{action:"block_insert",insertion:(""),insert_before_block_id:"baa"}]')" +``` + +字段说明: + +| 字段 | 必填 | 说明 | +|------|------|------| +| `action` | 是 | 固定为 `block_insert` | +| `insertion` | 是 | 要插入的完整 XML 片段 | +| `insert_before_block_id` | 否 | 插到这个块之前;省略(不提供此字段)则追加到页面末尾 | + +> **`` 必须用 `file_token`**,不能用外链 URL——先 `slides +media-upload --file ./pic.png --presentation $PID` 拿 token。 + +### 批量 parts + +一次 `--parts` 最多 200 条,按数组顺序串行执行。`block_replace` 和 `block_insert` 可以在同一批次混用。举例:一次性把标题块替换、然后在末尾追加一个装饰图。 + +```bash +lark-cli slides +replace-slide --as user \ + --presentation "$PID" --slide-id "$SID" \ + --parts '[ + {"action":"block_replace","block_id":"bab","replacement":"

新标题

"}, + {"action":"block_insert","insertion":"\" topLeftX=\"700\" topLeftY=\"400\" width=\"180\" height=\"100\"/>"} + ]' +``` + +整批作为原子事务:任一条失败整批不生效。失败时后端通常返回 3350001;若响应中带 `failed_part_index` / `failed_reason` 字段,shortcut 会原样透传。 + +## 大 --parts 用 jq 或 stdin 组装 + +`--parts` 支持 `@file`(读文件)和 `-`(stdin)作为值来源,适合批量 XML 场景: + +```bash +# 从文件读 +lark-cli slides +replace-slide --as user --presentation "$PID" --slide-id "$SID" \ + --parts @parts.json + +# 从 stdin 读 +cat parts.json | lark-cli slides +replace-slide --as user --presentation "$PID" --slide-id "$SID" \ + --parts - +``` + +## 错误排查 + +| 现象 | 原因 | 对策 | +|------|------|------| +| 3350001,hint 含 "block_id not found" | `parts[i].block_id` 在当前页不存在 | 重新 `slide.get` 拿最新 XML,按里面的 short ID 再填 | +| 3350002 not found | `--revision-id` 传了不存在的版本号 | 用 `-1` 或实际存在的 `revision_id` | +| `` 不显示 / 显示破图 | `src` 写了外链 URL | 换成通过 `+media-upload` 拿到的 `file_token` | +| 3350001(block_replace 返回) | 正常情况下 CLI 已自动注入 `id` 和 ``;如果仍报错,确认 `block_id` 在当前页存在(重新 `slide.get`),检查 XML 结构是否合法;坐标是否超出 960×540 范围 | — | + +## 相关文档 + +- [lark-slides-replace-slide.md](lark-slides-replace-slide.md) — +replace-slide shortcut 参数详情 +- [lark-slides-xml-presentation-slide-get.md](lark-slides-xml-presentation-slide-get.md) — slide.get 参考(拿 `block_id` / `revision_id`) +- [lark-slides-xml-presentation-slide-replace.md](lark-slides-xml-presentation-slide-replace.md) — 底层 replace API 参考(一般直接用 shortcut 即可) +- [lark-slides-media-upload.md](lark-slides-media-upload.md) — 上传图片拿 file_token +- [xml-schema-quick-ref.md](xml-schema-quick-ref.md) — XML 元素和属性速查 diff --git a/skills/lark-slides/references/lark-slides-media-upload.md b/skills/lark-slides/references/lark-slides-media-upload.md index c29f33979..82336ff48 100644 --- a/skills/lark-slides/references/lark-slides-media-upload.md +++ b/skills/lark-slides/references/lark-slides-media-upload.md @@ -79,45 +79,29 @@ lark-cli slides +create --as user --title "图测试" --slides '[ 详见 [+create 文档](lark-slides-create.md#本地图片path-占位符)。 -### 给已有 PPT 的已有页加图(整页替换) +### 给已有 PPT 的已有页加图 -> ⚠️ slides XML API 没有元素级编辑接口(见 SKILL.md 核心规则 7)—— 没法"往现有 slide 上贴一张图",只能**把整页替换**:读原 slide → 加 `` → 原位插入新页 → 删除旧页。 +拿到 `file_token` 后走 [`+replace-slide`](lark-slides-replace-slide.md) 的 `block_insert`,不用搬原 XML、不改 `slide_id`、不打乱页序: ```bash PRES_ID=xxx -TARGET_SLIDE_ID=yyy # 要加图的那一页 +SID=yyy # 要加图的那一页 # 1) 上传图片拿 file_token TOKEN=$(lark-cli slides +media-upload --as user \ --file ./pic.png --presentation $PRES_ID | jq -r '.data.file_token') -# 2) 读整份 PPT,摘出目标 slide 的完整 XML(保留所有 shape/line/img 原样) -lark-cli slides xml_presentations get --as user \ - --params "{\"xml_presentation_id\":\"$PRES_ID\"}" \ - | jq -r '.data.xml_presentation.content' - -# 3) 在 agent 侧拼新 slide XML:原有所有元素原样保留 + 新增一个 -# 关键:先看原 里现有元素的 topLeftX/Y/width/height,把 放到空白区 - -# 4) 原位 create(before_slide_id = 旧 slide_id) -lark-cli slides xml_presentation.slide create --as user \ - --params "{\"xml_presentation_id\":\"$PRES_ID\"}" \ - --data "$(jq -n --arg content "$NEW_XML" --arg before "$TARGET_SLIDE_ID" \ - '{slide:{content:$content}, before_slide_id:$before}')" - -# 5) create 成功后删旧页 -lark-cli slides xml_presentation.slide delete --as user \ - --params "{\"xml_presentation_id\":\"$PRES_ID\",\"slide_id\":\"$TARGET_SLIDE_ID\"}" +# 2) block_insert 到页末(或用 insert_before_block_id 指定插入位置) +lark-cli slides +replace-slide --as user \ + --presentation "$PRES_ID" --slide-id "$SID" \ + --parts "$(jq -n --arg token "$TOKEN" \ + '[{action:"block_insert",insertion:("")}]')" ``` -**必须遵守**: +注意事项: -1. **先 create 后 delete** —— 顺序反了且 create 失败会丢页 -2. **原 slide 的所有元素必须原样搬过来** —— 只写新 `` 会把原页标题/正文/形状全删掉 -3. **`before_slide_id=<旧 slide_id>` 必传** —— 不传新页追加到末尾,打乱页序 -4. **`` 坐标避开现有元素** —— 先读现有元素 bbox 挑空白区;空间不够就缩小/挪动现有元素后再放图 -5. **`` 的 `width:height` 仍需对齐原图比例** —— 比例不一致会被裁剪,参见 [xml-schema-quick-ref.md](xml-schema-quick-ref.md) `` 说明 -6. **`slide_id` 会变** —— 新页有新 ID,外部有深链依赖的要告知用户 +1. **`` 坐标避开现有元素** —— 先读现有元素 bbox 挑空白区;空间不够就先用 `block_replace` 挪动/缩小现有元素后再放图 +2. **`` 的 `width:height` 对齐原图比例** —— 比例不一致会被裁剪,参见 [xml-schema-quick-ref.md](xml-schema-quick-ref.md) `` 说明 ## 工作原理 @@ -140,4 +124,5 @@ lark-cli slides xml_presentation.slide delete --as user \ ## 相关命令 - [+create](lark-slides-create.md) — 新建 PPT(支持 `@` 占位符自动上传图片) +- [+replace-slide](lark-slides-replace-slide.md) — 给已有页加图 / 换图(`block_insert` / `block_replace`) - [xml_presentation.slide create](lark-slides-xml-presentation-slide-create.md) — 创建 slide 页面(拿到 file_token 后塞进 XML) diff --git a/skills/lark-slides/references/lark-slides-replace-slide.md b/skills/lark-slides/references/lark-slides-replace-slide.md new file mode 100644 index 000000000..c5220e7e7 --- /dev/null +++ b/skills/lark-slides/references/lark-slides-replace-slide.md @@ -0,0 +1,239 @@ +# slides +replace-slide(块级替换 / 插入) + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +对指定 slide 做块级替换或插入。编辑已有 PPT 的主路径——`slide_id` 不变、页序不动、只影响被指定的块。 + +相比直接调 `xml_presentation.slide.replace`,这个 shortcut 的四个额外价值: + +1. `--presentation` 接受 `xml_presentation_id` / `/slides/` URL / `/wiki/` URL(wiki 自动解析); +2. `block_replace` 的 `replacement` 根元素 `id=""` 由 CLI 自动注入——底层 API 的硬约束(不注入返回 3350001);直接调原生 API 需自己加,用 Shortcut 则自动注入; +3. `` 元素缺少 `` 子元素时由 CLI 自动注入——SML 2.0 schema 要求每个 `` 必须有 `` 子元素,缺失同样触发 3350001;自闭合的 `` 也会被自动展开为 ``; +4. 3350001 错误时提供上下文感知的 hint,帮助 AI agent 和用户快速定位原因。 + +## 命令 + +```bash +# block_insert:在页末追加一个新元素 +lark-cli slides +replace-slide --as user \ + --presentation slidesXXXXXXXXXXXXXXXXXXXXXX \ + --slide-id pfG \ + --parts '[{"action":"block_insert","insertion":""}]' + +# block_replace:已知某块 id,整块替换(replacement 根 id 自动注入为 bUn) +lark-cli slides +replace-slide --as user \ + --presentation slidesXXXXXXXXXXXXXXXXXXXXXX \ + --slide-id pfG \ + --parts '[{"action":"block_replace","block_id":"bUn","replacement":"

新标题

"}]' + +# 大 --parts 走文件或 stdin(auto-gen 命令不支持 @file,但 shortcut 支持) +lark-cli slides +replace-slide --as user \ + --presentation $PID --slide-id $SID --parts @parts.json +cat parts.json | lark-cli slides +replace-slide --as user \ + --presentation $PID --slide-id $SID --parts - + +# wiki URL 直接传(CLI 自动 get_node → 拿真实 xml_presentation_id) +lark-cli slides +replace-slide --as user \ + --presentation "https://xxx.feishu.cn/wiki/wikcnXXXXXX" --slide-id pfG \ + --parts '[{"action":"block_insert","insertion":""}]' + +# 预览(不实际调用) +lark-cli slides +replace-slide --as user \ + --presentation $PID --slide-id $SID --parts "$PARTS" --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--presentation` | 是 | `xml_presentation_id`、`/slides/` URL,或 `/wiki/` URL | +| `--slide-id` | 是 | 页面 ID(`xml_presentation.slide.get` / `xml_presentations.get` 都能拿到) | +| `--parts` | 是 | JSON 数组(`[{...}, ...]`),单次最多 200 条。支持 `@` 和 `-`(stdin)读取 | +| `--revision-id` | 否 | 基础版本号;默认 `-1` 表示基于最新版执行;传具体版本号时,服务端以该版本为 base 执行;**传不存在的版本号(超过当前 revision)返回 3350002** | +| `--tid` | 否 | 并发事务 ID;多人协作长事务才用,单次单人调用留空 | + +## parts 元素结构 + +> **限制**:最多 200 条;`block_replace` 和 `block_insert` 可以在同一批次混用。**其他 action(含 `str_replace`)CLI 会直接报错拒绝**。 + +每条 part 按 `action` 取不同字段: + +### action = `block_replace` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `action` | 是 | `"block_replace"` | +| `block_id` | 是 | 目标块的 3 位 short element ID(从 `slide.get` 返回 XML 里读) | +| `replacement` | 是 | 新 XML 片段;**根元素 `id` 会被 CLI 自动注入为 `block_id`**,用户不用自己加(如果已经加了且不一致会被覆盖为正确值) | + +### action = `block_insert` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `action` | 是 | `"block_insert"` | +| `insertion` | 是 | 要插入的 XML 片段 | +| `insert_before_block_id` | 否 | 插到这个块之前;省略(不提供此字段)则追加到页末 | + +## 合法根元素速查 + +`block_replace.replacement` 和 `block_insert.insertion` 必须以 SML 2.0 定义的合法元素为根。完整权威定义看 [`slides_xml_schema_definition.xml`](slides_xml_schema_definition.xml);这里只列能作为**根**的类型 + 每种类型的最小可工作片段。 + +| 元素 | 用途 | 关键点 | +|---|---|---| +| `` | 矩形/椭圆/三角/文本框等所有形状 | `type` 必填;`` 缺失时 CLI 会自动注入 | +| `` | 直线 | 需 `startX/startY/endX/endY` | +| `` | 折线 | `points` 读回时被服务端规整丢弃(几何已入库) | +| `` | 图片 | `src` 必须是 [`+media-upload`](lark-slides-media-upload.md) 返回的 `file_token`,不能是 URL | +| `` | 图标 | `iconType` 取自 iconpark 资源 | +| `
` | 表格 | 整表替换会**重建内部 td id**,旧 td block_id 立即失效 | +| ` +``` + +``(`type` 改成 `bar`/`column`/`pie`/`area`/`radar`/`combo` 切换图型): +```xml + + + + Q1,Q2,Q3,Q4 + 10,20,15,30 + + +``` + +## 返回值 + +```json +{ + "xml_presentation_id": "slidesXXXXXXXXXXXXXXXXXXXXXX", + "slide_id": "pfG", + "parts_count": 1, + "revision_id": 102 +} +``` + +| 字段 | 说明 | +|------|------| +| `xml_presentation_id` | 解析后的真实 token(wiki URL 解析后会变化) | +| `slide_id` | 与入参一致 | +| `parts_count` | 本次提交的 parts 条数 | +| `revision_id` | 成功后的新版本号,下次做乐观锁时用 | +| `failed_part_index` | 有部分失败时存在,指向第几条 part 失败 | +| `failed_reason` | 失败原因文字描述 | + +整批作为原子事务:任一 part 失败则整批不生效,服务端通过 `failed_part_index` / `failed_reason` 告诉你是哪条;按此定位修正后重发。 + +## 使用流程 + +### 给已有页加图(典型场景) + +```bash +PID=xxx +SID=yyy + +# 1) 上传图片 +TOKEN=$(lark-cli slides +media-upload --as user \ + --file ./pic.png --presentation "$PID" | jq -r '.data.file_token') + +# 2) block_insert 到页末 +lark-cli slides +replace-slide --as user \ + --presentation "$PID" --slide-id "$SID" \ + --parts "$(jq -n --arg token "$TOKEN" \ + '[{action:"block_insert",insertion:("")}]')" +``` + +### 改标题(block_replace) + +```bash +# 先拿原页 XML,从里面找到标题块的 3 位 short id(如 bUn) +lark-cli slides xml_presentation.slide get --as user \ + --params "{\"xml_presentation_id\":\"$PID\",\"slide_id\":\"$SID\"}" + +# block_replace 换掉整个标题块(id 自动注入) +lark-cli slides +replace-slide --as user \ + --presentation "$PID" --slide-id "$SID" \ + --parts '[{"action":"block_replace","block_id":"bUn","replacement":"

新标题

"}]' +``` + +### 批量:一次换标题 + 追加装饰图 + +`block_replace` 和 `block_insert` 可以在同一个 `--parts` 里混用,整批原子执行。 + +```bash +lark-cli slides +replace-slide --as user \ + --presentation "$PID" --slide-id "$SID" \ + --parts '[ + {"action":"block_replace","block_id":"bab","replacement":"

新标题

"}, + {"action":"block_insert","insertion":"\" topLeftX=\"700\" topLeftY=\"400\" width=\"180\" height=\"100\"/>"} + ]' +``` + +### 乐观锁 + +```bash +# 读时记录 revision_id +REV=$(lark-cli slides xml_presentation.slide get --as user \ + --params "{\"xml_presentation_id\":\"$PID\",\"slide_id\":\"$SID\"}" \ + | jq '.data.revision_id') + +# 写时传 --revision-id;传不存在的版本号(超过当前 revision)返回 3350002 +lark-cli slides +replace-slide --as user \ + --presentation "$PID" --slide-id "$SID" --revision-id "$REV" \ + --parts "$PARTS" +``` + +## 常见错误 + +| 现象 | 原因 | 对策 | +|------|------|------| +| 3350001 + hint "block_id not found" | `parts[i].block_id` 在当前页不存在 | 重新 `slide.get` 拿最新 XML,按里面的 short ID 再填 | +| 3350002 not found | `--revision-id` 传了不存在的版本号(超过当前 revision) | 用 `-1` 或用 `slide.get` 拿到的有效 `revision_id` | +| `--parts[i] action "str_replace" is not supported` | CLI 不暴露 `str_replace` | 把替换需求改写成 `block_replace` / `block_insert` | +| `--parts contains N items, exceeds maximum of 200` | 一次提交 parts 太多 | 拆多次调用 | +| `--parts[i] (block_replace) requires non-empty block_id` / `replacement` | 字段缺失 | 按 parts 元素结构补齐 | +| `` 不显示 / 显示破图 | `src` 写了外链 URL | 换成通过 [`+media-upload`](lark-slides-media-upload.md) 拿到的 `file_token` | +| 3350001 | `replacement` 不是合法单根 XML 片段,或 `block_id` 不存在 | CLI 已自动注入 `id` 和 ``;如果仍报错,重新 `slide.get` 拿最新 XML 确认 `block_id` 存在;检查 XML 结构是否合法;坐标是否超出 960×540 | +| 403 | 权限不足 | 需要 `slides:presentation:update` 或 `slides:presentation:write_only`;wiki URL 还需要 `wiki:node:read` | + +## 相关命令 + +- [xml_presentation.slide get](lark-slides-xml-presentation-slide-get.md) — 读原页拿 `block_id` / `revision_id` +- [xml_presentation.slide replace](lark-slides-xml-presentation-slide-replace.md) — 底层 replace API 参考 +- [+media-upload](lark-slides-media-upload.md) — 上传图片拿 `file_token` +- [lark-slides-edit-workflows.md](lark-slides-edit-workflows.md) — 读-改-写闭环 + 决策树 diff --git a/skills/lark-slides/references/lark-slides-xml-presentation-slide-create.md b/skills/lark-slides/references/lark-slides-xml-presentation-slide-create.md index 5cb1ca819..5815f40b6 100644 --- a/skills/lark-slides/references/lark-slides-xml-presentation-slide-create.md +++ b/skills/lark-slides/references/lark-slides-xml-presentation-slide-create.md @@ -141,8 +141,12 @@ lark-cli slides xml_presentation.slide create --as user \ ```json { - "slide_id": "slide_example_id", - "revision_id": 100 + "code": 0, + "data": { + "slide_id": "slide_example_id", + "revision_id": 100 + }, + "msg": "success" } ``` @@ -150,8 +154,8 @@ lark-cli slides xml_presentation.slide create --as user \ | 字段 | 类型 | 说明 | |------|------|------| -| `slide_id` | string | 新幻灯片的唯一标识 | -| `revision_id` | integer | 演示文稿最新版本号 | +| `data.slide_id` | string | 新幻灯片的唯一标识 | +| `data.revision_id` | integer | 演示文稿最新版本号 | ## slide 元素可用子元素 diff --git a/skills/lark-slides/references/lark-slides-xml-presentation-slide-delete.md b/skills/lark-slides/references/lark-slides-xml-presentation-slide-delete.md index d39ac4047..da2e7f3e3 100644 --- a/skills/lark-slides/references/lark-slides-xml-presentation-slide-delete.md +++ b/skills/lark-slides/references/lark-slides-xml-presentation-slide-delete.md @@ -49,7 +49,7 @@ lark-cli slides xml_presentation.slide delete --as user --params '{ ```bash # 先读取 XML 内容,确认待删除页面 -lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"slides_example_presentation_id"}' | jq -r '.xml_presentation.content' +lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"slides_example_presentation_id"}' | jq -r '.data.xml_presentation.content' # 然后按已知 slide_id 删除 lark-cli slides xml_presentation.slide delete --as user --params '{"xml_presentation_id":"slides_example_presentation_id","slide_id":"slide_example_id"}' @@ -61,7 +61,11 @@ lark-cli slides xml_presentation.slide delete --as user --params '{"xml_presenta ```json { - "revision_id": 100 + "code": 0, + "data": { + "revision_id": 100 + }, + "msg": "success" } ``` @@ -69,7 +73,7 @@ lark-cli slides xml_presentation.slide delete --as user --params '{"xml_presenta | 字段 | 类型 | 说明 | |------|------|------| -| `revision_id` | integer | 删除后的最新版本号 | +| `data.revision_id` | integer | 删除后的最新版本号 | ## 常见错误 diff --git a/skills/lark-slides/references/lark-slides-xml-presentation-slide-get.md b/skills/lark-slides/references/lark-slides-xml-presentation-slide-get.md new file mode 100644 index 000000000..3a75ff471 --- /dev/null +++ b/skills/lark-slides/references/lark-slides-xml-presentation-slide-get.md @@ -0,0 +1,110 @@ +# lark-slides xml_presentation.slide get + +## 用途 + +按 `slide_id` 拉取指定演示文稿单页的 XML 内容(可指定历史版本)。常用于"读-改-写"编辑闭环的第一步。 + +## 命令 + +```bash +lark-cli slides xml_presentation.slide get --as user --params '' +``` + +## 参数说明 + +| 参数 | 类型 | 必需 | 说明 | +|------|------|------|------| +| `--params` | JSON string | 是 | 路径参数与查询参数 | + +### params JSON 结构 + +```json +{ + "xml_presentation_id": "slides_example_presentation_id", + "slide_id": "slide_example_id", + "revision_id": -1 +} +``` + +| 字段 | 类型 | 必需 | 说明 | +|------|------|------|------| +| `xml_presentation_id` | string | 是 | 目标演示文稿唯一标识 | +| `slide_id` | string | 是 | 目标页面唯一标识 | +| `revision_id` | integer | 否 | 版本号,`-1` 表示最新版(默认)| + +## 使用示例 + +### 读最新版本 + +```bash +lark-cli slides xml_presentation.slide get --as user --params '{ + "xml_presentation_id": "slides_example_presentation_id", + "slide_id": "slide_example_id" +}' +``` + +### 只提取 XML 内容 + +```bash +lark-cli slides xml_presentation.slide get --as user \ + --params '{"xml_presentation_id":"slides_example_presentation_id","slide_id":"slide_example_id"}' \ + | jq -r '.data.slide.content' +``` + +### 读指定历史版本 + +```bash +lark-cli slides xml_presentation.slide get --as user --params '{ + "xml_presentation_id": "slides_example_presentation_id", + "slide_id": "slide_example_id", + "revision_id": 42 +}' +``` + +## 返回值 + +```json +{ + "code": 0, + "data": { + "slide": { + "slide_id": "slide_example_id", + "content": "
` | 单元格局部替换 | 只能 `block_replace`,不能 `block_insert`;`block_id` 必须是最新 `slide.get` 拿到的 td id | +| `` | 图表(line/bar/column/pie/area/radar/combo) | 必须嵌 `` + `` + `//` | + +**不可作为根元素**: + +- ``(`block_replace` 单元格;`block_id` 必须是最新 `slide.get` 拿到的 td id): +```xml +

新内容