mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 00:06:28 +08:00
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/<token> or /wiki/<token> URLs, not
bare xml_presentation_id.
2. Undocumented XML hygiene. `block_replace` requires id=<block_id> on
the replacement root; <shape> requires <content/>. 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="<block_id>" on block_replace replacement roots;
* auto-expands self-closing <shape/> and injects <content/> 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 `<content(?:\s|/|>)` avoids false
positives like <contention/>; 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 <img>
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 <undefined> 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 <td>
replacement) is silently accepted by the backend and may destroy
content; 3350001 specifically signals a missing block_id.
306 lines
12 KiB
Go
306 lines
12 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package slides
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
// presentationRef holds a parsed --presentation input.
|
|
//
|
|
// Slides shortcuts accept three input shapes:
|
|
// - a raw xml_presentation_id token
|
|
// - a slides URL like https://<host>/slides/<token>
|
|
// - a wiki URL like https://<host>/wiki/<token> (must resolve to obj_type=slides)
|
|
type presentationRef struct {
|
|
Kind string // "slides" | "wiki"
|
|
Token string
|
|
}
|
|
|
|
// parsePresentationRef extracts a presentation token from a token, slides URL, or wiki URL.
|
|
// Wiki tokens are returned unresolved; callers must run resolveWikiToSlidesToken to
|
|
// obtain the real xml_presentation_id and verify obj_type=slides.
|
|
func parsePresentationRef(input string) (presentationRef, error) {
|
|
raw := strings.TrimSpace(input)
|
|
if raw == "" {
|
|
return presentationRef{}, output.ErrValidation("--presentation cannot be empty")
|
|
}
|
|
// URL inputs: parse properly and only honor /slides/ or /wiki/ when they
|
|
// appear as a prefix of the URL path. Substring matching previously let
|
|
// e.g. `https://x/docx/foo?next=/slides/abc` resolve to token "abc".
|
|
if strings.Contains(raw, "://") {
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Path == "" {
|
|
return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
|
|
}
|
|
if token, ok := tokenAfterPathPrefix(u.Path, "/slides/"); ok {
|
|
return presentationRef{Kind: "slides", Token: token}, nil
|
|
}
|
|
if token, ok := tokenAfterPathPrefix(u.Path, "/wiki/"); ok {
|
|
return presentationRef{Kind: "wiki", Token: token}, nil
|
|
}
|
|
return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
|
|
}
|
|
// Non-URL input must be a bare token — anything with path/query/fragment
|
|
// chars is rejected so partial-path inputs like `tmp/wiki/wikcn123` don't
|
|
// get silently accepted.
|
|
if strings.ContainsAny(raw, "/?#") {
|
|
return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
|
|
}
|
|
return presentationRef{Kind: "slides", Token: raw}, nil
|
|
}
|
|
|
|
// tokenAfterPathPrefix extracts the first path segment after prefix from path.
|
|
// Returns ("", false) if path doesn't start with prefix or the segment is empty.
|
|
func tokenAfterPathPrefix(path, prefix string) (string, bool) {
|
|
if !strings.HasPrefix(path, prefix) {
|
|
return "", false
|
|
}
|
|
rest := path[len(prefix):]
|
|
if i := strings.IndexByte(rest, '/'); i >= 0 {
|
|
rest = rest[:i]
|
|
}
|
|
rest = strings.TrimSpace(rest)
|
|
if rest == "" {
|
|
return "", false
|
|
}
|
|
return rest, true
|
|
}
|
|
|
|
// resolvePresentationID resolves a parsed ref into an xml_presentation_id.
|
|
// Slides refs pass through; wiki refs are looked up via wiki.spaces.get_node and
|
|
// must resolve to obj_type=slides.
|
|
func resolvePresentationID(runtime *common.RuntimeContext, ref presentationRef) (string, error) {
|
|
switch ref.Kind {
|
|
case "slides":
|
|
return ref.Token, nil
|
|
case "wiki":
|
|
data, err := runtime.CallAPI(
|
|
"GET",
|
|
"/open-apis/wiki/v2/spaces/get_node",
|
|
map[string]interface{}{"token": ref.Token},
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
node := common.GetMap(data, "node")
|
|
objType := common.GetString(node, "obj_type")
|
|
objToken := common.GetString(node, "obj_token")
|
|
if objType == "" || objToken == "" {
|
|
return "", output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data")
|
|
}
|
|
if objType != "slides" {
|
|
return "", output.ErrValidation("wiki resolved to %q, but slides shortcuts require a slides presentation", objType)
|
|
}
|
|
return objToken, nil
|
|
default:
|
|
return "", output.ErrValidation("unsupported presentation ref kind %q", ref.Kind)
|
|
}
|
|
}
|
|
|
|
// imgSrcPlaceholderRegex matches `src="@<path>"` or `src='@<path>'` inside <img> tags.
|
|
// The "@" prefix is the magic marker for "this is a local file path; upload it and
|
|
// replace with file_token".
|
|
//
|
|
// Match groups:
|
|
//
|
|
// 1: opening quote character (so we can replace symmetrically)
|
|
// 2: the path string (everything inside the quotes after the leading @)
|
|
//
|
|
// We deliberately scope to <img ... src="@..."> rather than any src= so other
|
|
// schema elements (like icon/iconType) aren't accidentally rewritten.
|
|
// `\s*=\s*` tolerates `src = "..."` style attributes (XML allows whitespace
|
|
// around `=`); without it we'd silently leave such placeholders unrewritten.
|
|
var imgSrcPlaceholderRegex = regexp.MustCompile(`(?s)<img\b[^>]*?\bsrc\s*=\s*(["'])@([^"']+)(["'])`)
|
|
|
|
// extractImagePlaceholderPaths returns the de-duplicated list of local paths
|
|
// referenced via <img src="@path"> in the given slide XML strings.
|
|
//
|
|
// Order is preserved (first occurrence wins) so dry-run / progress messages are
|
|
// stable across runs.
|
|
func extractImagePlaceholderPaths(slideXMLs []string) []string {
|
|
var paths []string
|
|
seen := map[string]bool{}
|
|
for _, xml := range slideXMLs {
|
|
matches := imgSrcPlaceholderRegex.FindAllStringSubmatch(xml, -1)
|
|
for _, m := range matches {
|
|
if m[1] != m[3] {
|
|
// Mismatched opening/closing quotes — Go's RE2 has no backreferences,
|
|
// so we filter it here. Treat as malformed XML and skip.
|
|
continue
|
|
}
|
|
path := strings.TrimSpace(m[2])
|
|
if path == "" || seen[path] {
|
|
continue
|
|
}
|
|
seen[path] = true
|
|
paths = append(paths, path)
|
|
}
|
|
}
|
|
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 <el> 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 `<shape type="rect" />`
|
|
// 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 <content> 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. <contention/>) are not matched.
|
|
var xmlContentTagRegex = regexp.MustCompile(`<content(?:\s|/|>)`)
|
|
|
|
// ensureShapeHasContent ensures that a <shape> root element has a <content/>
|
|
// child. The SML 2.0 schema requires every <shape> to carry <content/>; a
|
|
// self-closing <shape .../> or an open <shape> without <content> 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 <shape> elements are affected; <img>, <table>, <chart> 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 + "><content/></" + tagName + ">" + rest
|
|
}
|
|
|
|
afterOpen := xmlFragment[m[1]:]
|
|
if xmlContentTagRegex.MatchString(afterOpen) {
|
|
return xmlFragment
|
|
}
|
|
|
|
closeTag := "</" + tagName + ">"
|
|
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. `<shape type="text"><p>hi</p></shape>`),
|
|
// prepending `<content/>` would make <p> a sibling of <content> — per
|
|
// SML 2.0 <p> must live inside <content>, so the result would be schema-
|
|
// legal but semantically wrong (empty content + stray <p>). Leave that
|
|
// case to the backend's 3350001 rather than silently rewrap children.
|
|
if strings.TrimSpace(afterOpen[:closeIdx]) != "" {
|
|
return xmlFragment
|
|
}
|
|
return xmlFragment[:m[1]] + "<content/>" + afterOpen
|
|
}
|
|
|
|
// replaceImagePlaceholders rewrites <img src="@path"> 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).
|
|
func replaceImagePlaceholders(slideXML string, tokens map[string]string) string {
|
|
return imgSrcPlaceholderRegex.ReplaceAllStringFunc(slideXML, func(match string) string {
|
|
sub := imgSrcPlaceholderRegex.FindStringSubmatch(match)
|
|
if len(sub) < 4 {
|
|
return match
|
|
}
|
|
quote, path, closeQuote := sub[1], sub[2], sub[3]
|
|
if quote != closeQuote {
|
|
// Mismatched quotes — see extractImagePlaceholderPaths.
|
|
return match
|
|
}
|
|
token, ok := tokens[strings.TrimSpace(path)]
|
|
if !ok {
|
|
return match
|
|
}
|
|
// Replace only the `"@<path>"` segment (quotes inclusive) so any
|
|
// surrounding attrs and whitespace around `=` stay intact. Looking up
|
|
// by the literal `@<path>"` (with closing quote) avoids accidentally
|
|
// matching the same path elsewhere in the tag.
|
|
oldQuoted := fmt.Sprintf("%s@%s%s", quote, path, closeQuote)
|
|
newQuoted := fmt.Sprintf("%s%s%s", quote, token, closeQuote)
|
|
return strings.Replace(match, oldQuoted, newQuoted, 1)
|
|
})
|
|
}
|