mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 08:12:36 +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.
416 lines
12 KiB
Go
416 lines
12 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package slides
|
|
|
|
import (
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestParsePresentationRef(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantKind string
|
|
wantToken string
|
|
wantErr string
|
|
}{
|
|
{name: "raw token", input: "slidesXXXXXXXXXXXXXXXXXXXXXX", wantKind: "slides", wantToken: "slidesXXXXXXXXXXXXXXXXXXXXXX"},
|
|
{name: "slides URL", input: "https://x.feishu.cn/slides/abc123", wantKind: "slides", wantToken: "abc123"},
|
|
{name: "slides URL with query", input: "https://x.feishu.cn/slides/abc123?from=share", wantKind: "slides", wantToken: "abc123"},
|
|
{name: "slides URL with anchor", input: "https://x.feishu.cn/slides/abc123#p1", wantKind: "slides", wantToken: "abc123"},
|
|
{name: "wiki URL", input: "https://x.feishu.cn/wiki/wikcn123", wantKind: "wiki", wantToken: "wikcn123"},
|
|
{name: "trims whitespace", input: " abc123 ", wantKind: "slides", wantToken: "abc123"},
|
|
{name: "empty", input: "", wantErr: "cannot be empty"},
|
|
{name: "blank", input: " ", wantErr: "cannot be empty"},
|
|
{name: "unsupported url", input: "https://x.feishu.cn/docx/foo", wantErr: "unsupported"},
|
|
{name: "unsupported path", input: "foo/bar", wantErr: "unsupported"},
|
|
// Regression: /slides/ inside a query string must NOT be treated as a slides marker.
|
|
{name: "slides marker inside query", input: "https://x.feishu.cn/docx/foo?next=/slides/abc", wantErr: "unsupported"},
|
|
// Regression: /wiki/ as a path segment but not a prefix must not match.
|
|
{name: "wiki marker mid-path", input: "https://x.feishu.cn/docx/wiki/wikcn123", wantErr: "unsupported"},
|
|
// Regression: bare relative path containing wiki/ is not a wiki ref.
|
|
{name: "non-url wiki segment", input: "tmp/wiki/wikcn123", wantErr: "unsupported"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got, err := parsePresentationRef(tt.input)
|
|
if tt.wantErr != "" {
|
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Fatalf("err = %v, want substring %q", err, tt.wantErr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got.Kind != tt.wantKind || got.Token != tt.wantToken {
|
|
t.Fatalf("got = %+v, want kind=%s token=%s", got, tt.wantKind, tt.wantToken)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEnsureShapeHasContent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
{
|
|
name: "self-closing shape gets content injected",
|
|
in: `<shape type="rect" width="100" height="50"/>`,
|
|
want: `<shape type="rect" width="100" height="50"><content/></shape>`,
|
|
},
|
|
{
|
|
name: "self-closing shape with id already injected",
|
|
in: `<shape type="rect" width="100" height="50" id="bUn"/>`,
|
|
want: `<shape type="rect" width="100" height="50" id="bUn"><content/></shape>`,
|
|
},
|
|
{
|
|
// If the user already wrote non-content children, injecting
|
|
// <content/> as a sibling would make <p> a sibling of <content>
|
|
// (schema-legal but semantically wrong per SML 2.0, which
|
|
// requires <p> to live inside <content>). Leave that case to
|
|
// the backend's 3350001 rather than silently rewrap.
|
|
name: "open shape with non-content children is left untouched",
|
|
in: `<shape type="text"><p>hello</p></shape>`,
|
|
want: `<shape type="text"><p>hello</p></shape>`,
|
|
},
|
|
{
|
|
name: "empty open shape gets content injected",
|
|
in: `<shape type="text"></shape>`,
|
|
want: `<shape type="text"><content/></shape>`,
|
|
},
|
|
{
|
|
name: "shape with content already present is unchanged",
|
|
in: `<shape type="text"><content><p>hi</p></content></shape>`,
|
|
want: `<shape type="text"><content><p>hi</p></content></shape>`,
|
|
},
|
|
{
|
|
name: "shape with self-closing content is unchanged",
|
|
in: `<shape type="rect"><content/></shape>`,
|
|
want: `<shape type="rect"><content/></shape>`,
|
|
},
|
|
{
|
|
name: "img self-closing is not touched",
|
|
in: `<img src="tok_abc" width="100" height="80"/>`,
|
|
want: `<img src="tok_abc" width="100" height="80"/>`,
|
|
},
|
|
{
|
|
name: "img open tag is not touched",
|
|
in: `<img src="tok_abc" width="100" height="80"><crop/></img>`,
|
|
want: `<img src="tok_abc" width="100" height="80"><crop/></img>`,
|
|
},
|
|
{
|
|
name: "table is not touched",
|
|
in: `<table rows="3" cols="3"/>`,
|
|
want: `<table rows="3" cols="3"/>`,
|
|
},
|
|
{
|
|
name: "bare self-closing shape",
|
|
in: `<shape/>`,
|
|
want: `<shape><content/></shape>`,
|
|
},
|
|
{
|
|
name: "shape with trailing space before self-close",
|
|
in: `<shape type="rect" />`,
|
|
want: `<shape type="rect"><content/></shape>`,
|
|
},
|
|
{
|
|
// Regression: strings.Contains("<content") used to false-match tags
|
|
// like <contention/> 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 <content> child.
|
|
// Even so, we don't inject — <contention/> counts as an existing
|
|
// non-content child (same rule as the <p> case above), so the
|
|
// shape is left untouched for the backend to reject.
|
|
name: "shape with contention child is left untouched",
|
|
in: `<shape type="text"><contention/></shape>`,
|
|
want: `<shape type="text"><contention/></shape>`,
|
|
},
|
|
{
|
|
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()
|
|
|
|
tests := []struct {
|
|
name string
|
|
in []string
|
|
want []string
|
|
}{
|
|
{
|
|
name: "no placeholders",
|
|
in: []string{`<slide><data><img src="https://x.com/a.png"/></data></slide>`},
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "single placeholder",
|
|
in: []string{`<slide><data><img src="@./pic.png" topLeftX="10"/></data></slide>`},
|
|
want: []string{"./pic.png"},
|
|
},
|
|
{
|
|
name: "single quotes",
|
|
in: []string{`<img src='@./a.png'/>`},
|
|
want: []string{"./a.png"},
|
|
},
|
|
{
|
|
name: "dedup across slides",
|
|
in: []string{
|
|
`<slide><data><img src="@./shared.png"/></data></slide>`,
|
|
`<slide><data><img src="@./shared.png" topLeftX="100"/><img src="@./other.png"/></data></slide>`,
|
|
},
|
|
want: []string{"./shared.png", "./other.png"},
|
|
},
|
|
{
|
|
name: "ignores non-img src",
|
|
in: []string{`<icon src="@./fake.png"/><img src="@./real.png"/>`},
|
|
want: []string{"./real.png"},
|
|
},
|
|
{
|
|
name: "preserves order of first occurrence",
|
|
in: []string{`<img src="@b.png"/><img src="@a.png"/><img src="@b.png"/>`},
|
|
want: []string{"b.png", "a.png"},
|
|
},
|
|
{
|
|
// Regression: Go RE2 has no backreferences, so the regex captures
|
|
// opening and closing quotes independently. Mismatched pairs must
|
|
// be filtered out post-match instead of producing bogus paths.
|
|
name: "rejects mismatched quotes",
|
|
in: []string{`<img src="@./oops.png'/>`},
|
|
want: nil,
|
|
},
|
|
{
|
|
// Regression: XML allows whitespace around `=`; placeholders in
|
|
// `src = "@..."` form must still be detected.
|
|
name: "tolerates whitespace around equals",
|
|
in: []string{`<img src = "@./spaced.png" />`},
|
|
want: []string{"./spaced.png"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := extractImagePlaceholderPaths(tt.in)
|
|
if !reflect.DeepEqual(got, tt.want) {
|
|
t.Fatalf("got %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReplaceImagePlaceholders(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tokens := map[string]string{
|
|
"./pic.png": "tok_abc",
|
|
"./b.png": "tok_b",
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
{
|
|
name: "single replacement preserves siblings",
|
|
in: `<img src="@./pic.png" topLeftX="10" width="100"/>`,
|
|
want: `<img src="tok_abc" topLeftX="10" width="100"/>`,
|
|
},
|
|
{
|
|
name: "multiple replacements",
|
|
in: `<img src="@./pic.png"/><img src="@./b.png"/>`,
|
|
want: `<img src="tok_abc"/><img src="tok_b"/>`,
|
|
},
|
|
{
|
|
name: "single quotes",
|
|
in: `<img src='@./pic.png'/>`,
|
|
want: `<img src='tok_abc'/>`,
|
|
},
|
|
{
|
|
name: "leaves unknown placeholder untouched",
|
|
in: `<img src="@./missing.png"/>`,
|
|
want: `<img src="@./missing.png"/>`,
|
|
},
|
|
{
|
|
name: "leaves http url alone",
|
|
in: `<img src="https://x.com/a.png"/>`,
|
|
want: `<img src="https://x.com/a.png"/>`,
|
|
},
|
|
{
|
|
name: "leaves bare token alone",
|
|
in: `<img src="existing_token"/>`,
|
|
want: `<img src="existing_token"/>`,
|
|
},
|
|
{
|
|
// Regression: placeholders with whitespace around `=` must be
|
|
// rewritten too (XML permits the form). Surrounding whitespace
|
|
// is preserved so the rewritten attribute reads naturally.
|
|
name: "tolerates whitespace around equals",
|
|
in: `<img src = "@./pic.png" topLeftX="10"/>`,
|
|
want: `<img src = "tok_abc" topLeftX="10"/>`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := replaceImagePlaceholders(tt.in, tokens)
|
|
if got != tt.want {
|
|
t.Fatalf("got %q\nwant %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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: `<shape type="rect" width="100" height="50"/>`,
|
|
want: "bUn",
|
|
wantOut: `<shape type="rect" width="100" height="50" id="bUn"/>`,
|
|
},
|
|
{
|
|
name: "injects id when absent on open tag",
|
|
in: `<shape type="text"><content><p>hi</p></content></shape>`,
|
|
want: "bUn",
|
|
wantOut: `<shape type="text" id="bUn"><content><p>hi</p></content></shape>`,
|
|
},
|
|
{
|
|
name: "leaves id alone when already matching",
|
|
in: `<shape id="bUn" type="rect"/>`,
|
|
want: "bUn",
|
|
wantOut: `<shape id="bUn" type="rect"/>`,
|
|
},
|
|
{
|
|
name: "overrides mismatched id value preserving quotes and attrs",
|
|
in: `<shape id="xxx" type="rect"/>`,
|
|
want: "bUn",
|
|
wantOut: `<shape id="bUn" type="rect"/>`,
|
|
},
|
|
{
|
|
name: "overrides single-quoted id",
|
|
in: `<shape id='xxx' type='rect'/>`,
|
|
want: "bUn",
|
|
wantOut: `<shape id='bUn' type='rect'/>`,
|
|
},
|
|
{
|
|
name: "tolerates whitespace around equals",
|
|
in: `<shape id = "xxx" type="rect"/>`,
|
|
want: "bUn",
|
|
wantOut: `<shape id = "bUn" type="rect"/>`,
|
|
},
|
|
{
|
|
name: "tolerates leading whitespace and XML declaration",
|
|
in: `<?xml version="1.0"?><shape type="rect"/>`,
|
|
want: "bUn",
|
|
wantOut: `<?xml version="1.0"?><shape type="rect" id="bUn"/>`,
|
|
},
|
|
{
|
|
name: "does not touch nested element id",
|
|
in: `<shape type="rect"><inner id="keepme"/></shape>`,
|
|
want: "bUn",
|
|
wantOut: `<shape type="rect" id="bUn"><inner id="keepme"/></shape>`,
|
|
},
|
|
{
|
|
name: "no duplicate space before injected attr",
|
|
in: `<shape type="rect" />`,
|
|
want: "bUn",
|
|
wantOut: `<shape type="rect" id="bUn" />`,
|
|
},
|
|
{
|
|
name: "bare tag gets id injected",
|
|
in: `<shape/>`,
|
|
want: "bUn",
|
|
wantOut: `<shape id="bUn"/>`,
|
|
},
|
|
{
|
|
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: `<shape type="rect"`,
|
|
want: "bUn",
|
|
wantErr: "no root element",
|
|
},
|
|
{
|
|
// Regression: \bid matches the "id" suffix in data-id / xml:id.
|
|
// The regex now uses (?:^|\s) so only a standalone id attribute fires.
|
|
name: "does not confuse data-id with id — injects fresh id",
|
|
in: `<shape data-id="old" type="rect"/>`,
|
|
want: "bUn",
|
|
wantOut: `<shape data-id="old" type="rect" id="bUn"/>`,
|
|
},
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|