Compare commits

...

1 Commits

Author SHA1 Message Date
niezhiwei
307e753b3f feat(slides): add +update-slide to apply a page of XML by diffing it
Editing most of a page previously meant one block_replace part per
element, each carrying that element's full XML (coordinates, size, font
size included). Restyling every text block on a page was impractical as a
result.

+update-slide takes the page the caller wants, reads the page that is
there, and sends one element-level part per difference. The indirection is
forced: ReplacePart.block_id is validated as a short ELEMENT id (it must
start with "b"), so a single part covering the page is impossible — the
page's own id is "p"-prefixed and the background fill's id is
"f"-prefixed, and both are rejected with 3350001 while element-level parts
on the same page succeed. Element ids are the only handles this endpoint
offers.

--content is the page's target state. An element carrying its original id
is replaced when it differs, an element without an id is inserted at the
position it was written, an element missing from --content is deleted, and
a missing <note> clears the speaker notes. Nothing differing means no
request is sent at all.

Comparison is canonical because the server returns pretty-printed XML with
reordered attributes and injected style defaults the caller never wrote:
attributes are namespace-qualified, quoted and sorted, and indentation
between structural elements is dropped. Text inside a <p> subtree is
compared verbatim and escaped — SML collapses a literal space between
inline tags, but a preserved space written as &#32; decodes to the very
same token, so trimming would drop a real edit as unchanged; and without
escaping, a paragraph holding the literal text "</p><p>" reads identically
to two empty paragraphs. What is sent is the caller's exact bytes, so
their formatting survives into the page.

Anything the diff cannot express fails loudly instead of being silently
dropped: a background change (<style> has no id of its own and its <fill>
id is "f"-prefixed), a reorder of existing elements (there is no move
operation), an id that does not exist on the page (omit the id to create
an element), a root id that differs from --slide-id (the classic
read-page-A-write-page-B mistake), any slide-level structure other than
one <style>, one <data> and one <note>, and container attributes — the
root additionally accepts the SML namespace in the three forms the
repository's SXSD validator recognizes (the official identifier and the
two server read-back spellings), since the primary workflow feeds
+xml-get output straight back in. Unrepresentable structure in --content
is a validation error; a page that comes back with it is a failed
precondition.

Pages carrying an <undefined> placeholder — the server's stand-in for an
object it could not export, such as a whiteboard read without its export
option — are refused outright. Whether the whole-page rewrite behind
slide.replace preserves an untouched placeholder is a server-owned
behavior that no self-contained test can pin down (boards cannot be
created programmatically: the CLI has no whiteboard-create and SML has no
whiteboard element), and editing on an unverifiable assumption risks
silently destroying the one object the caller cannot see. +replace-slide
remains the element-level path for such pages.

--revision-id is documented as what it is. Passing a stale revision is not
rejected by the backend: it means "apply against this snapshot", so
pinning an older one discards every later edit to the page. -1, the
default, is the safe value.

slides:presentation:read sits in the enforced pre-flight scopes, not in
ConditionalScopes: every execution reads the page before writing it, and
ConditionalScopes is metadata only, so a write-only token would reach the
GET before failing.

Also:
- +update as a hidden alias, derived from the canonical shortcut so scopes,
  identities and flags cannot drift. Agents reach for "slide update"
  before reading --help and used to burn turns on the error plus a help
  dump.
- --xml / --slide-xml / --slide-content / --content-xml as hidden spellings
  of --content, scoped to the whole-page commands: --content exists on
  other slides shortcuts, and resolving these aliases there would rewrite
  a mistyped flag into one the caller never meant to use.
- A backend failure reason becomes a typed API error rather than a field on
  a success envelope.
- www.larkoffice.com joins the public-domain allowlist: it is the host of
  the SML namespace identifier, the same vendor domain family as the
  already-listed www.feishu.cn / www.larksuite.com, and the quality gate
  matches exact hostnames.
- Live and dry-run E2E coverage per AGENTS.md. The live workflow walks
  replace, insert, delete and the no-op against the real API, checks a
  background change is refused with the page left untouched and the deck
  order holds, and is self-contained where that is knowable: for stored
  credentials it probes the cleanup scopes with a dry-run delete and
  refuses to create a deck it cannot delete (unexpected probe failures are
  fatal); environment tokens carry no scope metadata, so those runs rest
  on the documented requirement that the CI identity is provisioned with
  the cleanup scopes, and a cleanup failure stays fatal and visible. The
  dry-run suite pins the validation process contract (launch error, exit
  code 2, empty stdout, typed envelope on stderr). An earlier design
  passed every HTTP stub and was rejected outright by the real endpoint;
  coverage.md records that the live test skips without a user token, which
  is the blind spot that allowed it.
- Skill docs: what the command does, what it refuses, why the background
  cannot be changed, and a portable (BSD/GNU) sed spelling in the primary
  example.
2026-07-31 18:20:43 +08:00
13 changed files with 2740 additions and 71 deletions

View File

@@ -15,4 +15,5 @@ registry.npmjs.org
registry.npmmirror.com
sf16-sg.tiktokcdn.com
www.feishu.cn
www.larkoffice.com
www.larksuite.com

View File

@@ -10,6 +10,7 @@ import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -110,6 +111,16 @@ func resolvePresentationID(runtime *common.RuntimeContext, ref presentationRef)
}
}
// slideReplaceAPIPath builds the xml_presentation.slide.replace endpoint for a
// presentation. Shared by +replace-slide (element-level parts) and
// +update-slide (a single whole-page part) so the two cannot drift apart.
func slideReplaceAPIPath(presentationID string) string {
return fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide/replace",
validate.EncodePathSegment(presentationID),
)
}
// 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".

View File

@@ -18,50 +18,102 @@ var presentationFlagAliases = []string{
"url",
}
// Shortcuts returns all slides shortcuts.
func Shortcuts() []common.Shortcut {
all := []common.Shortcut{
SlidesCreate,
SlidesMediaUpload,
SlidesReplaceSlide,
SlidesReplacePages,
SlidesScreenshot,
SlidesXMLGet,
SlidesHistoryList,
SlidesHistoryRevert,
SlidesHistoryRevertStatus,
}
for i := range all {
if hasPresentationFlag(all[i].Flags) {
all[i].PostMount = withPresentationFlagAliases(all[i].PostMount)
}
}
return all
// contentFlagAliases are the spellings agents reach for instead of --content
// when handing a whole page of XML to +update-slide.
//
// Deliberately not "slide": several slides commands take a --slide-id, so
// `--slide <id>` is a likely typo for that, and resolving it to --content
// would turn the typo into a request carrying an id where page XML belongs.
var contentFlagAliases = []string{
"xml",
"slide-xml",
"slide-content",
"content-xml",
}
func hasPresentationFlag(flags []common.Flag) bool {
// presentationAliasMap resolves every --presentation spelling and is attached
// to every shortcut that declares that flag.
var presentationAliasMap = aliasMap(map[string][]string{"presentation": presentationFlagAliases})
// wholePageAliasMap additionally resolves the --content spellings. It is
// attached only to the whole-page overwrite commands: --content exists on
// other slides shortcuts too, and letting these aliases resolve there would
// rewrite a mistyped flag into one the caller never meant to use.
var wholePageAliasMap = aliasMap(map[string][]string{
"presentation": presentationFlagAliases,
"content": contentFlagAliases,
})
// aliasMap inverts canonical→aliases into alias→canonical.
func aliasMap(byCanonical map[string][]string) map[string]string {
out := make(map[string]string)
for canonical, aliases := range byCanonical {
for _, alias := range aliases {
out[alias] = canonical
}
}
return out
}
// Shortcuts returns all slides shortcuts.
func Shortcuts() []common.Shortcut {
all := []struct {
shortcut common.Shortcut
aliases map[string]string
}{
{shortcut: SlidesCreate, aliases: presentationAliasMap},
{shortcut: SlidesMediaUpload, aliases: presentationAliasMap},
{shortcut: SlidesReplaceSlide, aliases: presentationAliasMap},
{shortcut: SlidesReplacePages, aliases: presentationAliasMap},
{shortcut: SlidesUpdateSlide, aliases: wholePageAliasMap},
{shortcut: SlidesUpdate, aliases: wholePageAliasMap},
{shortcut: SlidesScreenshot, aliases: presentationAliasMap},
{shortcut: SlidesXMLGet, aliases: presentationAliasMap},
{shortcut: SlidesHistoryList, aliases: presentationAliasMap},
{shortcut: SlidesHistoryRevert, aliases: presentationAliasMap},
{shortcut: SlidesHistoryRevertStatus, aliases: presentationAliasMap},
}
out := make([]common.Shortcut, 0, len(all))
for _, entry := range all {
if hasAliasableFlag(entry.shortcut.Flags, entry.aliases) {
entry.shortcut.PostMount = withFlagAliases(entry.aliases, entry.shortcut.PostMount)
}
out = append(out, entry.shortcut)
}
return out
}
// hasAliasableFlag reports whether the shortcut declares a flag that one of
// the aliases resolves to, i.e. whether attaching the normalizer can do
// anything.
func hasAliasableFlag(flags []common.Flag, aliases map[string]string) bool {
for _, flag := range flags {
if flag.Name == "presentation" {
return true
for _, canonical := range aliases {
if flag.Name == canonical {
return true
}
}
}
return false
}
// withPresentationFlagAliases accepts common agent-generated spellings for
// --presentation without registering extra flags. The aliases therefore stay
// out of help and completion while resolving to the canonical flag at parse
// time, matching the zero-round-trip compatibility used by Sheets.
func withPresentationFlagAliases(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
// withFlagAliases accepts common agent-generated spellings for canonical flags
// without registering extra flags. The aliases therefore stay out of help and
// completion while resolving to the canonical flag at parse time, matching the
// zero-round-trip compatibility used by Sheets.
func withFlagAliases(aliases map[string]string, prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
return func(cmd *cobra.Command) {
if prev != nil {
prev(cmd)
}
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
for _, alias := range presentationFlagAliases {
if name == alias {
return pflag.NormalizedName("presentation")
}
cmd.Flags().SetNormalizeFunc(func(fs *pflag.FlagSet, name string) pflag.NormalizedName {
// fs.Lookup re-enters this func with the canonical name; that
// terminates because no canonical name is itself an alias key
// (asserted by TestFlagAliasesAreNotCanonicalNames). Looking the
// canonical name up keeps a mistyped alias reported as the flag
// the caller actually typed on commands that lack the target.
if canonical, ok := aliases[name]; ok && fs.Lookup(canonical) != nil {
return pflag.NormalizedName(canonical)
}
return pflag.NormalizedName(name)
})

View File

@@ -7,37 +7,128 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestWithPresentationFlagAliases(t *testing.T) {
for _, alias := range presentationFlagAliases {
t.Run(alias, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("presentation", "", "presentation reference")
withPresentationFlagAliases(nil)(cmd)
func TestWithFlagAliases(t *testing.T) {
cases := []struct {
canonical string
aliases []string
}{
{canonical: "presentation", aliases: presentationFlagAliases},
{canonical: "content", aliases: contentFlagAliases},
}
for _, tc := range cases {
for _, alias := range tc.aliases {
t.Run(tc.canonical+"/"+alias, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String(tc.canonical, "", tc.canonical+" value")
withFlagAliases(wholePageAliasMap, nil)(cmd)
if err := cmd.Flags().Parse([]string{"--" + alias, "presABC"}); err != nil {
t.Fatalf("--%s should resolve to --presentation: %v", alias, err)
}
got, err := cmd.Flags().GetString("presentation")
if err != nil {
t.Fatalf("read --presentation: %v", err)
}
if got != "presABC" {
t.Fatalf("--%s set --presentation to %q, want presABC", alias, got)
}
if usage := cmd.Flags().FlagUsages(); strings.Contains(usage, "--"+alias) {
t.Fatalf("hidden compatibility alias --%s leaked into help:\n%s", alias, usage)
}
})
if err := cmd.Flags().Parse([]string{"--" + alias, "valABC"}); err != nil {
t.Fatalf("--%s should resolve to --%s: %v", alias, tc.canonical, err)
}
got, err := cmd.Flags().GetString(tc.canonical)
if err != nil {
t.Fatalf("read --%s: %v", tc.canonical, err)
}
if got != "valABC" {
t.Fatalf("--%s set --%s to %q, want valABC", alias, tc.canonical, got)
}
if usage := cmd.Flags().FlagUsages(); strings.Contains(usage, "--"+alias) {
t.Fatalf("hidden compatibility alias --%s leaked into help:\n%s", alias, usage)
}
})
}
}
}
func TestShortcutsAttachPresentationFlagAliases(t *testing.T) {
// TestFlagAliasesOnlyResolveDeclaredFlags pins the guard that keeps a mistyped
// alias reported as the flag the caller actually typed: when the command does
// not declare the canonical target, the alias must be left alone.
func TestFlagAliasesOnlyResolveDeclaredFlags(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("presentation", "", "presentation reference")
withFlagAliases(wholePageAliasMap, nil)(cmd)
err := cmd.Flags().Parse([]string{"--xml", "<slide/>"})
if err == nil {
t.Fatal("--xml resolved on a command without --content, want unknown-flag error")
}
if !strings.Contains(err.Error(), "xml") {
t.Fatalf("error should name the flag the user typed, got: %v", err)
}
}
// TestContentAliasesStayOffOtherShortcuts is the regression guard for a
// package-wide alias table: --content exists on other slides shortcuts, so a
// shared table silently turned `--xml` / `--slide-xml` there into a --content
// value the caller never meant to pass. Only the whole-page commands may
// resolve them.
func TestContentAliasesStayOffOtherShortcuts(t *testing.T) {
wholePage := map[string]bool{"+update-slide": true, "+update": true}
for _, shortcut := range Shortcuts() {
if wholePage[shortcut.Command] || !declaresFlag(shortcut.Flags, "content") {
continue
}
if shortcut.PostMount == nil {
continue
}
cmd := &cobra.Command{Use: shortcut.Command}
cmd.Flags().String("content", "", "content")
cmd.Flags().String("presentation", "", "presentation reference")
shortcut.PostMount(cmd)
for _, alias := range contentFlagAliases {
if err := cmd.Flags().Parse([]string{"--" + alias, "x"}); err == nil {
t.Errorf("%s resolved --%s to --content; content aliases must be scoped to the whole-page commands", shortcut.Command, alias)
}
}
}
}
// TestFlagAliasesAreNotCanonicalNames guards the termination argument in
// withFlagAliases: fs.Lookup re-enters the normalizer with the canonical name,
// which must not itself be an alias key.
func TestFlagAliasesAreNotCanonicalNames(t *testing.T) {
for _, aliases := range []map[string]string{presentationAliasMap, wholePageAliasMap} {
canonical := map[string]bool{}
for _, name := range aliases {
canonical[name] = true
}
for alias, target := range aliases {
if canonical[alias] {
t.Errorf("alias %q is also a canonical flag name; normalization would recurse", alias)
}
if alias == target {
t.Errorf("alias %q maps to itself", alias)
}
}
}
}
// TestFlagAliasesDoNotShadowRealFlags catches the dangerous direction of
// pflag.SetNormalizeFunc: it re-normalizes flags that are already registered,
// so if a shortcut ever declares a flag whose name is an alias key, that flag
// collapses into the canonical one — no panic, no error, just a missing flag.
func TestFlagAliasesDoNotShadowRealFlags(t *testing.T) {
for _, shortcut := range Shortcuts() {
if shortcut.PostMount == nil {
continue
}
for _, flag := range shortcut.Flags {
if canonical, ok := wholePageAliasMap[flag.Name]; ok {
t.Errorf("%s declares --%s, which is an alias of --%s; the normalizer would erase it", shortcut.Command, flag.Name, canonical)
}
}
}
}
func TestShortcutsAttachFlagAliases(t *testing.T) {
count := 0
for _, shortcut := range Shortcuts() {
if !hasPresentationFlag(shortcut.Flags) {
if !declaresFlag(shortcut.Flags, "presentation") {
continue
}
count++
@@ -66,3 +157,12 @@ func TestShortcutsAttachPresentationFlagAliases(t *testing.T) {
t.Fatal("expected at least one slides shortcut with --presentation")
}
}
func declaresFlag(flags []common.Flag, name string) bool {
for _, flag := range flags {
if flag.Name == name {
return true
}
}
return false
}

View File

@@ -10,7 +10,6 @@ import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -116,10 +115,7 @@ var SlidesReplaceSlide = common.Shortcut{
} 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),
)).
dry.POST(slideReplaceAPIPath(presentationID)).
Params(query).
Body(body)
return dry.Set("parts_count", len(parts))
@@ -156,11 +152,7 @@ var SlidesReplaceSlide = common.Shortcut{
}
body := map[string]interface{}{"parts": injected}
url := fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide/replace",
validate.EncodePathSegment(presentationID),
)
data, err := runtime.CallAPITyped("POST", url, query, body)
data, err := runtime.CallAPITyped("POST", slideReplaceAPIPath(presentationID), query, body)
if err != nil {
return enrichSlidesReplaceError(err)
}

View File

@@ -0,0 +1,390 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// SlidesUpdateSlide applies a whole page of XML to an existing slide, keeping
// its slide_id and its position in the deck.
//
// The caller hands over the page they want; the CLI reads the page that is
// there, diffs the two, and sends one element-level part per difference. That
// indirection is not a stylistic choice — a single part covering the whole page
// is impossible. ReplacePart.block_id is validated as a short ELEMENT id (it
// must start with "b"), so the page's own id ("p"-prefixed) and the background
// fill's id ("f"-prefixed) are both rejected with 3350001. Element ids are the
// only handles this endpoint offers.
//
// What that buys the caller is the part they actually found painful: they no
// longer enumerate parts or hand-write each element's full XML (coordinates,
// size, font size included) to restyle a page. What it costs is one capability
// the endpoint cannot express at all:
//
// - The page background lives in <style>, which has no id of its own and
// whose <fill> id starts with "f". A changed <style> is therefore an error,
// not a silent no-op.
//
// Two more limits fall out of having no move operation and no way to invent
// ids: reordering existing elements is rejected, and an id in --content that
// does not exist on the page is rejected rather than created.
var SlidesUpdateSlide = common.Shortcut{
Service: "slides",
Command: "+update-slide",
Description: "Apply a full <slide> XML to an existing slide by diffing it against the current page (keeps slide_id and page order; background changes are not supported)",
Risk: "write",
// slides:presentation:read is unconditional: every execution reads the
// page before writing it, so it belongs in the enforced pre-flight set —
// ConditionalScopes is metadata only and would let a write-only token
// reach the GET before failing.
Scopes: []string{"slides:presentation:read", "slides:presentation:update", "slides:presentation:write_only"},
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Tips: []string{
"Read-modify-write: `slides +xml-get --presentation <id> --slide-id <sid> --output page.xml` → edit page.xml → `slides +update-slide --content @page.xml`",
"--content is the page's target state: an element you drop is deleted, an element without an id is created",
"Keep the <style> block from the read unchanged — the page background cannot be changed through this command",
"Elements cannot be reordered and an unknown id cannot be created; both are rejected up front",
"Editing one shape / image is cheaper with `slides +replace-slide`",
},
Flags: updateSlideFlags,
Validate: updateSlideValidate,
DryRun: updateSlideDryRun,
Execute: updateSlideExecute,
}
// SlidesUpdate registers `slides +update` as a hidden alias of +update-slide.
//
// Agents reach for "slide update" before reading --help (the command did not
// exist, so they burned turns on the error plus a help dump). Accepting the
// shorter spelling costs nothing and removes those round trips; it stays out
// of --help so the canonical name is the only one advertised.
//
// Derived from the canonical shortcut rather than re-declared, so scopes,
// identities and flags cannot drift between the two spellings.
var SlidesUpdate = func() common.Shortcut {
sc := SlidesUpdateSlide
sc.Command = "+update"
sc.Hidden = true
return sc
}()
var updateSlideFlags = []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) of the page to update", Required: true},
{Name: "content", Desc: "full page XML with a single <slide> root; it is diffed against the current page", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "revision-id", Type: "int", Default: "-1", Desc: "revision to read and apply against; -1 (default) means latest. Pinning an older revision rebuilds the page from that snapshot and discards newer edits to it"},
{Name: "tid", Desc: "transaction id for concurrent-edit locking (usually empty)"},
}
func updateSlideValidate(_ context.Context, runtime *common.RuntimeContext) error {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
if ref.Kind == "wiki" {
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
return err
}
}
slideID, err := updateSlideID(runtime)
if err != nil {
return err
}
// Only the shape of --content can be checked without a network call; the
// diff itself needs the current page.
_, err = parseWantedPageFor(runtime.Str("content"), slideID)
return err
}
// updateSlideDryRun reports what would be read and how, without calling the
// API. The parts cannot be shown: they are derived from the page's current
// state, which is exactly what dry-run must not fetch.
func updateSlideDryRun(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
slideID, err := updateSlideID(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
wanted, err := parseWantedPageFor(runtime.Str("content"), slideID)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
dry := common.NewDryRunAPI()
presentationID := ref.Token
if ref.Kind == "wiki" {
presentationID = "<resolved_slides_token>"
dry.Desc("3-step orchestration: resolve wiki → read page → replace changed elements").
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("2-step orchestration: read slide %s, then replace the elements that differ", slideID))
}
dry.GET(slideReadAPIPath(presentationID)).
Desc("[1] Read the current page to diff against").
Params(updateSlideQuery(runtime, slideID))
dry.POST(slideReplaceAPIPath(presentationID)).
Desc("[2] One element-level part per difference; the parts depend on the page's current state").
Params(updateSlideQuery(runtime, slideID))
return dry.Set("wanted_element_count", len(wanted.Elements))
}
func updateSlideExecute(_ 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, err := updateSlideID(runtime)
if err != nil {
return err
}
wanted, err := parseWantedPageFor(runtime.Str("content"), slideID)
if err != nil {
return err
}
current, err := readCurrentPage(runtime, presentationID, slideID)
if err != nil {
return err
}
// diffPage already reports the edits it cannot express as typed validation
// errors against --content.
diff, err := diffPage(current, wanted)
if err != nil {
return err
}
result := map[string]interface{}{
"xml_presentation_id": presentationID,
"slide_id": slideID,
"parts_count": len(diff.Parts),
"replaced": diff.Replaced,
"inserted": diff.Inserted,
"deleted": diff.Deleted,
}
if diff.NoteCleared {
result["note_cleared"] = true
}
if diff.NoteReplaced {
result["note_replaced"] = true
}
// Nothing differs: report it instead of sending an empty batch, which the
// backend rejects, and instead of claiming a write that never happened.
if len(diff.Parts) == 0 {
result["unchanged"] = true
runtime.Out(result, nil)
return nil
}
if len(diff.Parts) > maxReplaceParts {
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"the page differs in %d elements, which needs %d parts and exceeds the maximum of %d; split the edit across several calls",
len(diff.Parts), len(diff.Parts), maxReplaceParts,
).WithParam("--content")
}
parts := make([]map[string]interface{}, 0, len(diff.Parts))
for _, part := range diff.Parts {
parts = append(parts, part.toMap())
}
data, err := runtime.CallAPITyped(
"POST",
slideReplaceAPIPath(presentationID),
updateSlideQuery(runtime, slideID),
map[string]interface{}{"parts": parts},
)
if err != nil {
return enrichSlidesReplaceError(err)
}
// A failure reason means the batch was rejected and nothing was written, so
// it cannot be reported inside a success envelope. The backend currently
// pairs one with a non-zero code, which CallAPITyped already turns into an
// error, so this guards an inconsistent response rather than a reachable
// path.
if reason := strings.TrimSpace(common.GetString(data, "failed_reason")); reason != "" {
return errs.NewAPIError(
errs.SubtypeInvalidParameters,
"slide %s was not updated: %s", slideID, reason,
).WithHint(slides3350001Hint)
}
if _, ok := data["revision_id"]; ok {
result["revision_id"] = int(common.GetFloat(data, "revision_id"))
}
runtime.Out(result, nil)
return nil
}
// readCurrentPage fetches the page the diff is computed against.
func readCurrentPage(runtime *common.RuntimeContext, presentationID, slideID string) (pageDoc, error) {
data, err := runtime.CallAPITyped("GET", slideReadAPIPath(presentationID), updateSlideQuery(runtime, slideID), nil)
if err != nil {
return pageDoc{}, err
}
content := common.GetString(common.GetMap(data, "slide"), "content")
if strings.TrimSpace(content) == "" {
return pageDoc{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "reading slide %s returned empty content", slideID)
}
current, err := parsePageDoc(content)
if err != nil {
return pageDoc{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "slide %s returned XML the CLI cannot parse: %v", slideID, err).WithCause(err)
}
// A page whose structure the diff cannot see cannot be safely edited: the
// unrecognized part would be invisible to the comparison, so the command
// could neither preserve it deliberately nor notice the caller changing it.
if current.Unsupported != "" {
return pageDoc{}, errs.NewValidationError(
errs.SubtypeFailedPrecondition,
"slide %s contains %s, which this command cannot represent; use `slides +replace-slide` for element-level edits on this page",
slideID, current.Unsupported,
)
}
// A page carrying an <undefined> placeholder is refused outright. The
// placeholder stands for an object the server could not export (a
// whiteboard, unexported media); whether the whole-page rewrite behind
// slide.replace preserves an untouched one is a server-owned behavior that
// no self-contained test can pin down — boards cannot be created
// programmatically. Editing on top of an unverifiable assumption risks
// silently destroying the one object the caller cannot see, so the page is
// off-limits to this command until preservation is provable.
for _, el := range current.Elements {
if el.Tag == placeholderTag {
return pageDoc{}, errs.NewValidationError(
errs.SubtypeFailedPrecondition,
"slide %s contains an <undefined> placeholder (element %s) for an object the server could not export, such as a whiteboard; this command refuses to edit the page because it cannot prove a rewrite would preserve that object — use `slides +replace-slide` for element-level edits here",
slideID, el.ID,
)
}
}
return current, nil
}
// parseWantedPage validates --content and decomposes it.
//
// The root-tag check is a safety gate, not politeness: --content describes the
// whole page, so an element-level fragment would be read as "the page should
// contain only this", and every other element on it would be deleted.
func parseWantedPage(content string) (pageDoc, error) {
trimmed := strings.TrimSpace(content)
if trimmed == "" {
return pageDoc{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content cannot be empty").WithParam("--content")
}
doc, err := parsePageDoc(trimmed)
if err != nil {
return pageDoc{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not well-formed XML: %v", err).WithParam("--content").WithCause(err)
}
if doc.RootTag == "" {
return pageDoc{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content has no root element; pass one full <slide>…</slide> fragment").WithParam("--content")
}
if doc.RootTag != "slide" {
return pageDoc{}, updateSlideRootTagError(doc.RootTag)
}
if doc.TrailingTag != "" {
return pageDoc{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--content has a <%s> element after the </slide> root; pass exactly one page per call",
doc.TrailingTag,
).WithParam("--content")
}
if doc.TrailingText {
return pageDoc{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--content has text after the </slide> root; pass exactly one <slide>…</slide> fragment",
).WithParam("--content")
}
// Anything the decomposition could not place has no part it could become.
// Accepting it and diffing only the recognized parts would drop the edit —
// and could even answer `unchanged` for a change the caller asked for.
if doc.Unsupported != "" {
return pageDoc{}, contentError(
"--content contains %s, which this command cannot represent; a <slide> carries exactly one <style>, one <data> and one <note>",
doc.Unsupported,
)
}
return doc, nil
}
// parseWantedPageFor additionally pins the root id, when present, to the page
// being updated. XML fetched for page A and posted against --slide-id for page
// B is the classic wrong-target mistake; the element-id checks catch it only
// incidentally (an empty page, or one whose element ids were stripped, would
// sail through and rebuild B with A's content).
func parseWantedPageFor(content, slideID string) (pageDoc, error) {
doc, err := parseWantedPage(content)
if err != nil {
return doc, err
}
if doc.RootID != "" && doc.RootID != slideID {
return doc, contentError(
"--content root carries id %q but --slide-id is %q; this XML looks like it was read from a different page — re-run `slides +xml-get` for this page, or drop the root id to apply the content here",
doc.RootID, slideID,
)
}
return doc, nil
}
// updateSlideRootTagError explains what to use instead, picked by what the
// caller actually passed: a whole presentation is a multi-page job, anything
// else is an element-level edit.
func updateSlideRootTagError(rootTag string) error {
remedy := "use `slides +replace-slide` to edit individual elements"
if rootTag == "presentation" {
remedy = "pass a single page's <slide> XML, or use `slides +replace-pages` to rebuild several pages at once"
}
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--content root element is <%s>, but +update-slide takes a whole page and requires a single <slide> root; %s",
rootTag, remedy,
).WithParam("--content")
}
// updateSlideID reads and validates --slide-id.
func updateSlideID(runtime *common.RuntimeContext) (string, error) {
slideID := strings.TrimSpace(runtime.Str("slide-id"))
if slideID == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide-id cannot be empty").WithParam("--slide-id")
}
return slideID, nil
}
// updateSlideQuery builds the query params shared by the read, the write and
// dry-run. The same revision is used for both calls so the parts are applied to
// the snapshot they were computed from.
func updateSlideQuery(runtime *common.RuntimeContext, slideID string) map[string]interface{} {
query := map[string]interface{}{
"slide_id": slideID,
"revision_id": runtime.Int("revision-id"),
}
if tid := strings.TrimSpace(runtime.Str("tid")); tid != "" {
query["tid"] = tid
}
return query
}
// slideReadAPIPath is the single-slide read endpoint.
func slideReadAPIPath(presentationID string) string {
return fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide",
validate.EncodePathSegment(presentationID),
)
}

View File

@@ -0,0 +1,598 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"encoding/xml"
"errors"
"fmt"
"io"
"sort"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
)
// This file turns "here is the page I want" into the element-level parts the
// slide.replace endpoint accepts.
//
// A whole-page part is not an option: ReplacePart.block_id is validated as a
// short ELEMENT id (it must start with "b"), so neither the page's own id nor
// the background fill's id (which starts with "f") can be addressed. Verified
// against the live API — a slide-rooted replacement and a fill-targeted
// replacement are both rejected with 3350001, while element-level parts on the
// same page succeed. So the CLI diffs the caller's page against the current one
// and emits one part per changed element.
//
// Comparison is canonical (attributes sorted, insignificant whitespace
// dropped) because the server returns pretty-printed XML with normalized
// attribute order and injected style defaults; a raw string compare would call
// every element changed. What gets SENT is the caller's exact bytes, so their
// formatting and attribute order survive into the page.
// contentError reports an edit --content asks for that element-level parts
// cannot express. Every one of these is a refusal to guess: the alternative is
// applying part of the edit, or returning success with it silently dropped.
func contentError(format string, args ...any) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...).WithParam("--content")
}
// smlNamespaces are the namespace forms a page may declare on its root — the
// official identifier plus the two read-back spellings the server emits.
// Mirrors ACCEPTED_SML_NAMESPACES in
// skills/lark-slides/scripts/sxsd_validator.py; keep the two lists in sync.
var smlNamespaces = map[string]bool{
"http://www.larkoffice.com/sml/2.0": true,
"https://www.larkoffice.com/sml/2.0": true,
"/sml/2.0": true,
}
// placeholderTag is the element the server substitutes for objects it cannot
// export as SML — a whiteboard read without its export option, video and audio
// embeds. The caller cannot see what is behind one, and no self-contained
// endpoint test can prove that a page rewrite preserves it (boards cannot be
// created programmatically — the CLI has no whiteboard-create and SML has no
// whiteboard element), so pages carrying one are refused outright rather than
// edited on an unverifiable assumption. The element-level escape hatch is
// +replace-slide.
const placeholderTag = "undefined"
// pageNode is one addressable node of a page.
type pageNode struct {
Tag string
// ID is the short id carried by the node, empty when it has none. Only
// "b"-prefixed ids are addressable as a part's block_id.
ID string
// Raw is the caller's (or server's) exact bytes for this node.
Raw string
// Canon is the comparison form: attributes sorted, whitespace collapsed.
Canon string
}
// pageDoc is a decomposed <slide> document.
type pageDoc struct {
// RootTag is the local name of the first element, "" when there is none.
RootTag string
RootID string
// Style and Note are the <style> / <note> children of <slide>, nil when
// absent.
Style *pageNode
Note *pageNode
// Elements are the <data> children in document order.
Elements []pageNode
// TrailingTag and TrailingText report content after the root's close tag.
TrailingTag string
TrailingText bool
// Unsupported names the first slide-level structure the diff cannot
// represent: an unknown direct child of <slide>, a duplicate <style> /
// <data> / <note>, or stray text. Empty means the page decomposed cleanly.
//
// This must be an error, not a shrug: a diff computed from only the
// recognized parts would drop the unrecognized edit and could even report
// `unchanged` — a success claim for a change that never happened.
Unsupported string
}
// elementByID indexes Elements by id, skipping nodes without one.
func (d pageDoc) elementByID() map[string]pageNode {
out := make(map[string]pageNode, len(d.Elements))
for _, el := range d.Elements {
if el.ID != "" {
out[el.ID] = el
}
}
return out
}
// orderedIDs returns the ids of Elements that carry one, in document order.
func (d pageDoc) orderedIDs() []string {
out := make([]string, 0, len(d.Elements))
for _, el := range d.Elements {
if el.ID != "" {
out = append(out, el.ID)
}
}
return out
}
// parsePageDoc walks a <slide> document once and records every addressable
// node together with the exact bytes it came from.
//
// Raw slices are cut from the input using the decoder's byte offsets rather
// than re-serialized, so a replacement carries the caller's own formatting
// instead of whatever encoding/xml would emit.
func parsePageDoc(pageXML string) (pageDoc, error) {
var doc pageDoc
decoder := xml.NewDecoder(strings.NewReader(pageXML))
var (
stack []string
rootClosed bool
dataSeen int
// capture is the node currently being sliced out: its start offset and
// the depth at which it ends.
capturing bool
captureAt int64
captureTag string
captureID string
captureIn string // "root" for <slide> children, "data" for <data> children
)
unsupported := func(format string, args ...any) {
if doc.Unsupported == "" {
doc.Unsupported = fmt.Sprintf(format, args...)
}
}
for {
before := decoder.InputOffset()
token, err := decoder.Token()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return doc, err
}
switch t := token.(type) {
case xml.StartElement:
switch {
case len(stack) == 0 && rootClosed:
if doc.TrailingTag == "" {
doc.TrailingTag = t.Name.Local
}
case len(stack) == 0:
doc.RootTag, doc.RootID = t.Name.Local, attrValue(t, "id")
// The diff carries the root's id and nothing else, so any
// other attribute would be accepted and then neither compared
// nor sent — an edit to it would vanish into `unchanged`.
// Namespace declarations are no exception: a binding inherited
// from the root changes what every descendant name means, and
// the canonical comparison reads local names precisely because
// legitimate pages differ only in carrying the one official
// declaration or not. Anything else must not be waved through.
for _, attr := range t.Attr {
switch {
case attr.Name.Space == "" && attr.Name.Local == "id":
case attr.Name.Space == "" && attr.Name.Local == "xmlns":
if !smlNamespaces[attr.Value] {
unsupported("an unsupported xmlns %q on <%s>", attr.Value, t.Name.Local)
}
case attr.Name.Space == "xmlns":
unsupported("a prefixed namespace declaration %q on <%s>", "xmlns:"+attr.Name.Local, t.Name.Local)
default:
unsupported("an unsupported attribute %q on <%s>", attrDisplayName(attr), t.Name.Local)
}
}
case !capturing && len(stack) == 1:
// Direct children of <slide>: exactly one <style>, one <data>
// and one <note> are representable; anything else has no
// element-level part it could become.
switch t.Name.Local {
case "style":
if doc.Style != nil {
unsupported("a second <style> element")
break
}
capturing, captureAt = true, elementStart(pageXML, before)
captureTag, captureID, captureIn = t.Name.Local, attrValue(t, "id"), "root"
case "note":
if doc.Note != nil {
unsupported("a second <note> element")
break
}
capturing, captureAt = true, elementStart(pageXML, before)
captureTag, captureID, captureIn = t.Name.Local, attrValue(t, "id"), "root"
case "data":
dataSeen++
if dataSeen > 1 {
unsupported("a second <data> element")
}
// <data> is pure structure to the diff; an attribute on it
// — namespace declarations included, since captured Raw
// slices would not carry an inherited binding — has no
// element-level part it could travel in.
for _, attr := range t.Attr {
unsupported("an unsupported attribute %q on <data>", attrDisplayName(attr))
}
default:
unsupported("an unknown <%s> element directly under <slide>", t.Name.Local)
}
case !capturing && len(stack) == 2 && stack[1] == "data":
capturing, captureAt = true, elementStart(pageXML, before)
captureTag, captureID, captureIn = t.Name.Local, attrValue(t, "id"), "data"
}
stack = append(stack, t.Name.Local)
case xml.EndElement:
closingDepth := len(stack)
if closingDepth > 0 {
stack = stack[:closingDepth-1]
}
if len(stack) == 0 {
rootClosed = true
}
// A captured node ends when the stack returns to the depth it
// started at: "root" children start at depth 1, "data" children at
// depth 2.
startDepth := 1
if captureIn == "data" {
startDepth = 2
}
if capturing && len(stack) == startDepth {
raw := strings.TrimSpace(pageXML[captureAt:decoder.InputOffset()])
canon, err := canonicalizeElement(raw)
if err != nil {
return doc, err
}
node := pageNode{Tag: captureTag, ID: captureID, Raw: raw, Canon: canon}
switch {
case captureIn == "root" && captureTag == "style":
doc.Style = &node
case captureIn == "root" && captureTag == "note":
doc.Note = &node
case captureIn == "data":
doc.Elements = append(doc.Elements, node)
}
capturing = false
}
case xml.CharData:
if strings.TrimSpace(string(t)) == "" {
break // pretty-printed indentation, never meaningful
}
switch {
case rootClosed && len(stack) == 0:
doc.TrailingText = true
case !capturing && len(stack) == 1:
unsupported("text directly inside <slide>")
case !capturing && len(stack) == 2 && stack[1] == "data":
unsupported("text directly inside <data>")
}
}
}
return doc, nil
}
// elementStart returns the offset of the '<' that opens the token beginning at
// or after off, so a captured slice starts at the tag rather than at the
// whitespace preceding it.
func elementStart(s string, off int64) int64 {
for i := int(off); i < len(s); i++ {
if s[i] == '<' {
return int64(i)
}
}
return off
}
func attrValue(el xml.StartElement, name string) string {
for _, attr := range el.Attr {
if attr.Name.Local == name {
return attr.Value
}
}
return ""
}
// attrDisplayName renders an attribute name for error messages.
func attrDisplayName(attr xml.Attr) string {
if attr.Name.Space != "" {
return attr.Name.Space + ":" + attr.Name.Local
}
return attr.Name.Local
}
// canonicalizeElement renders an element fragment in a stable form: attributes
// sorted by name, indentation between structural elements dropped, paragraph
// text preserved verbatim and escaped.
//
// This exists so "unchanged" survives the round trip through the server, which
// re-orders attributes, indents the XML and injects style defaults that the
// caller never wrote.
//
// The whitespace rule is asymmetric by design. Outside <p>, whitespace-only
// character data is the pretty-printer's indentation and never content. Inside
// a <p> subtree it is kept verbatim: SML itself collapses a literal space
// between inline tags, but a preserved space written as &#32; decodes to the
// very same token, so dropping "insignificant" whitespace here would also drop
// a real &#32; edit as `unchanged`. Keeping both costs at most a spurious
// rewrite of identical content; dropping either loses an edit.
func canonicalizeElement(fragment string) (string, error) {
decoder := xml.NewDecoder(strings.NewReader(fragment))
var out strings.Builder
pDepth := 0
for {
token, err := decoder.Token()
if errors.Is(err, io.EOF) {
return out.String(), nil
}
if err != nil {
return "", err
}
switch t := token.(type) {
case xml.StartElement:
// Element names stay namespace-free on purpose: a fragment cut
// from a document with a default xmlns resolves its elements into
// that namespace, while the same fragment from the server's plain
// output does not — including it would make every round-trip look
// changed. SML is a single vocabulary, so local names cannot clash.
out.WriteString("<" + t.Name.Local)
attrs := make([]string, 0, len(t.Attr))
for _, attr := range t.Attr {
name := attr.Name.Local
// Attributes do not inherit the default namespace, so a
// non-empty Space is explicit (xmlns declarations, prefixed
// attributes) and part of the attribute's identity.
if attr.Name.Space != "" {
name = attr.Name.Space + ":" + name
}
// Quote the value: with bare name=value concatenation,
// alt="foo rotateWithShape=true" and the two attributes
// alt="foo" rotateWithShape="true" canonicalize identically,
// and the diff would drop a real change as unchanged.
attrs = append(attrs, name+"="+strconv.Quote(attr.Value))
}
sort.Strings(attrs)
for _, attr := range attrs {
out.WriteString(" " + attr)
}
out.WriteString(">")
if t.Name.Local == "p" {
pDepth++
}
case xml.EndElement:
out.WriteString("</" + t.Name.Local + ">")
if t.Name.Local == "p" && pDepth > 0 {
pDepth--
}
case xml.CharData:
if pDepth == 0 && strings.TrimSpace(string(t)) == "" {
break // indentation between structural elements, never content
}
// Escaped, so text can never imitate markup in the comparison
// stream: a paragraph holding the literal text "</p><p>" must not
// compare equal to two empty paragraphs.
if err := xml.EscapeText(&out, t); err != nil {
return "", err
}
}
}
}
// replacePart is one entry of the request body.
type replacePartOut struct {
Action string
BlockID string
Replacement string
Insertion string
InsertBeforeBlockID string
}
func (p replacePartOut) toMap() map[string]interface{} {
m := map[string]interface{}{"action": p.Action}
switch p.Action {
case "block_replace":
m["block_id"] = p.BlockID
m["replacement"] = p.Replacement
case "block_insert":
m["insertion"] = p.Insertion
if p.InsertBeforeBlockID != "" {
m["insert_before_block_id"] = p.InsertBeforeBlockID
}
}
return m
}
// pageDiff is the outcome of comparing the wanted page against the current one.
type pageDiff struct {
Parts []replacePartOut
// Counters describe what the parts do, for the result envelope.
Replaced, Inserted, Deleted int
NoteCleared, NoteReplaced bool
}
// diffPage produces the parts that turn current into wanted.
//
// Semantics: --content is the page's target state. An element present in
// current but absent from wanted is deleted; an element without an id is
// created. The background is the one thing that cannot be expressed, so a
// changed <style> is an error rather than a silent no-op.
func diffPage(current, wanted pageDoc) (pageDiff, error) {
var diff pageDiff
if err := diffStyle(current, wanted); err != nil {
return diff, err
}
currentByID := current.elementByID()
// Pages carrying a placeholder are rejected on read (see readCurrentPage),
// so one here can only be hand-authored content — and there is nothing it
// could correctly mean: the object it stands for cannot be created, and a
// page that really had one would never have reached the diff.
for _, el := range wanted.Elements {
if el.Tag == placeholderTag {
return diff, contentError(
"--content contains an <undefined> element; it is only the server's stand-in for an object it could not export, and pages carrying one cannot be edited by this command — use `slides +replace-slide`",
)
}
}
wantedIDs := map[string]bool{}
for _, el := range wanted.Elements {
if el.ID == "" {
continue
}
if _, ok := currentByID[el.ID]; !ok {
return diff, contentError(
"element id %q in --content does not exist on slide %s; drop the id to create it as a new element, or re-read the page",
el.ID, current.RootID,
)
}
if wantedIDs[el.ID] {
return diff, contentError("element id %q appears twice in --content", el.ID)
}
wantedIDs[el.ID] = true
}
// Surviving elements must keep their relative order: there is no move
// operation, so a reorder cannot be expressed and must not be silently
// dropped.
if err := checkOrderPreserved(current.orderedIDs(), wanted.orderedIDs(), wantedIDs); err != nil {
return diff, err
}
// Deletions first so later inserts land at the positions the caller meant.
for _, el := range current.Elements {
if el.ID != "" && !wantedIDs[el.ID] {
diff.Parts = append(diff.Parts, replacePartOut{
Action: "block_replace",
BlockID: el.ID,
// An empty replacement deletes the block.
Replacement: "",
})
diff.Deleted++
}
}
for i, el := range wanted.Elements {
switch {
case el.ID == "":
diff.Parts = append(diff.Parts, replacePartOut{
Action: "block_insert",
Insertion: el.Raw,
InsertBeforeBlockID: nextSurvivingID(wanted.Elements, i, wantedIDs),
})
diff.Inserted++
case el.Canon != currentByID[el.ID].Canon:
diff.Parts = append(diff.Parts, replacePartOut{
Action: "block_replace",
BlockID: el.ID,
Replacement: el.Raw,
})
diff.Replaced++
}
}
notePart, err := diffNote(current, wanted)
if err != nil {
return diff, err
}
if notePart != nil {
diff.Parts = append(diff.Parts, *notePart)
if wanted.Note == nil {
diff.NoteCleared = true
} else {
diff.NoteReplaced = true
}
}
return diff, nil
}
// diffStyle rejects a background change instead of dropping it.
//
// <style> has no id of its own and the <fill> inside it carries an "f"-prefixed
// id, which the endpoint's block_id validation rejects. There is therefore no
// way to change a page's background through this path at all — saying so beats
// returning success with the background untouched.
func diffStyle(current, wanted pageDoc) error {
currentStyle, wantedStyle := "", ""
if current.Style != nil {
currentStyle = current.Style.Canon
}
if wanted.Style != nil {
wantedStyle = wanted.Style.Canon
}
if currentStyle == wantedStyle {
return nil
}
if wanted.Style == nil {
return contentError(
"--content has no <style> but the slide has one; the page background cannot be changed through this command — copy the existing <style> over from `slides +xml-get` output",
)
}
return contentError(
"--content changes <style> (the page background), which this command cannot express: the background has no addressable element id — keep the <style> from `slides +xml-get` output unchanged",
)
}
// diffNote turns a note change into a part, or reports that it cannot be made.
func diffNote(current, wanted pageDoc) (*replacePartOut, error) {
currentNote, wantedNote := "", ""
if current.Note != nil {
currentNote = current.Note.Canon
}
if wanted.Note != nil {
wantedNote = wanted.Note.Canon
}
if currentNote == wantedNote {
return nil, nil
}
// Editing or clearing the note both go through the existing note block, so
// the current page has to have one to address.
if current.Note == nil || current.Note.ID == "" {
return nil, contentError("slide %s has no addressable <note>; speaker notes cannot be changed through this command", current.RootID)
}
replacement := fmt.Sprintf("<note id=%q><content/></note>", current.Note.ID)
if wanted.Note != nil {
replacement = wanted.Note.Raw
}
return &replacePartOut{
Action: "block_replace",
BlockID: current.Note.ID,
Replacement: replacement,
}, nil
}
// checkOrderPreserved verifies the surviving ids appear in the same relative
// order on both sides.
func checkOrderPreserved(currentIDs, wantedIDs []string, surviving map[string]bool) error {
kept := make([]string, 0, len(currentIDs))
for _, id := range currentIDs {
if surviving[id] {
kept = append(kept, id)
}
}
if len(kept) != len(wantedIDs) {
// Length mismatch is already covered by the unknown-id and duplicate
// checks in diffPage; nothing to add here.
return nil
}
for i := range kept {
if kept[i] != wantedIDs[i] {
return contentError(
"--content reorders existing elements (expected %s at position %d, got %s); this command cannot move elements — delete and re-insert them, or keep the original order",
kept[i], i+1, wantedIDs[i],
)
}
}
return nil
}
// nextSurvivingID finds the id-bearing element that follows position i, so a
// new element is inserted at the position the caller wrote it in. An empty
// result means "append to the end of the page".
func nextSurvivingID(elements []pageNode, i int, surviving map[string]bool) string {
for _, el := range elements[i+1:] {
if el.ID != "" && surviving[el.ID] {
return el.ID
}
}
return ""
}

View File

@@ -0,0 +1,898 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"encoding/json"
"errors"
"net/http"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// currentPageXML is shaped like what the server actually returns: pretty
// printed, attributes in its own order, style defaults injected that the caller
// never wrote, and ids on the style fill ("f"-prefixed) and the note
// ("b"-prefixed).
const currentPageXML = `<slide id="piy">
<style>
<fill id="fiy">
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</style>
<data>
<shape width="800" height="120" topLeftX="80" topLeftY="80" type="text" id="bbD">
<content textType="title" fontSize="54" fontFamily="思源黑体">
<p>BEFORE</p>
</content>
</shape>
<shape width="400" height="80" topLeftX="80" topLeftY="260" type="text" id="bbv">
<content fontSize="16" fontFamily="思源黑体">
<p>SECOND</p>
</content>
</shape>
</data>
<note id="bbb">
<content/>
</note>
</slide>`
const currentStyleXML = `<style>
<fill id="fiy">
<fillColor color="rgba(255, 255, 255, 1)"/>
</fill>
</style>`
// wantPage assembles a page in the caller's own style, so the tests exercise
// the canonical comparison rather than string equality.
func wantPage(style, data, note string) string {
return `<slide id="piy">` + style + `<data>` + data + `</data>` + note + `</slide>`
}
const (
elemOne = `<shape id="bbD" type="text" topLeftX="80" topLeftY="80" width="800" height="120"><content textType="title" fontSize="54" fontFamily="思源黑体"><p>BEFORE</p></content></shape>`
elemOneNew = `<shape id="bbD" type="text" topLeftX="80" topLeftY="80" width="800" height="120"><content textType="title" fontSize="54" fontFamily="楷体"><p>BEFORE</p></content></shape>`
elemTwo = `<shape id="bbv" type="text" topLeftX="80" topLeftY="260" width="400" height="80"><content fontSize="16" fontFamily="思源黑体"><p>SECOND</p></content></shape>`
noteKept = `<note id="bbb"><content/></note>`
)
// registerPageRead stubs the read half. The write stub must be registered with
// Method POST, since httpmock matches the URL by substring and "/slide" is a
// prefix of "/slide/replace".
func registerPageRead(t *testing.T, reg *httpmock.Registry, pageXML string) {
t.Helper()
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"slide": map[string]interface{}{"slide_id": "piy", "content": pageXML},
"revision_id": 7,
},
},
})
}
func registerWriteStub(t *testing.T, reg *httpmock.Registry, revision int) *httpmock.Stub {
t.Helper()
stub := &httpmock.Stub{
Method: "POST",
URL: "/slide/replace",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"revision_id": revision}},
}
reg.Register(stub)
return stub
}
// forbidWrite registers a POST stub that fails the test if it is ever hit.
func forbidWrite(t *testing.T, reg *httpmock.Registry, why string) {
t.Helper()
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/slide/replace",
Optional: true,
OnMatch: func(*http.Request) { t.Error(why) },
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
})
}
func runUpdateSlide(t *testing.T, f *cmdutil.Factory, content string, extra ...string) error {
t.Helper()
args := append([]string{
"+update-slide",
"--presentation", "pres_abc",
"--slide-id", "piy",
"--content", content,
}, extra...)
return runSlidesShortcut(t, f, nil, SlidesUpdateSlide, append(args, "--as", "user"))
}
func TestUpdateSlideDeclaredScopes(t *testing.T) {
// The read scope is ENFORCED, not merely declared: every execution reads
// the page before writing it, and ConditionalScopes would let a write-only
// token reach the GET before failing.
want := []string{"slides:presentation:read", "slides:presentation:update", "slides:presentation:write_only"}
for _, sc := range []common.Shortcut{SlidesUpdateSlide, SlidesUpdate} {
if got := sc.ScopesForIdentity("user"); !reflect.DeepEqual(got, want) {
t.Errorf("%s user preflight scopes = %#v, want %#v", sc.Command, got, want)
}
declared := sc.DeclaredScopesForIdentity("user")
found := false
for _, got := range declared {
if got == "wiki:node:read" {
found = true
}
}
if !found {
t.Errorf("%s declared scopes %#v missing wiki:node:read", sc.Command, declared)
}
}
}
func TestUpdateSlideIsRegisteredWithAlias(t *testing.T) {
canonical := findSlidesShortcut(t, "+update-slide")
alias := findSlidesShortcut(t, "+update")
if canonical.Hidden {
t.Error("+update-slide must be visible in --help")
}
if !alias.Hidden {
t.Error("+update alias must stay hidden so only the canonical name is advertised")
}
if alias.Service != canonical.Service || alias.Risk != canonical.Risk ||
!reflect.DeepEqual(alias.AuthTypes, canonical.AuthTypes) ||
!reflect.DeepEqual(alias.Scopes, canonical.Scopes) ||
!reflect.DeepEqual(alias.ConditionalScopes, canonical.ConditionalScopes) ||
!reflect.DeepEqual(alias.Flags, canonical.Flags) ||
!reflect.DeepEqual(alias.Tips, canonical.Tips) ||
alias.Description != canonical.Description {
t.Error("alias metadata drifted from +update-slide; it must be derived, not re-declared")
}
}
// TestUpdateSlideSendsOnePartPerChangedElement is the core contract: only what
// differs is touched, the part addresses the element by its own id, and the
// replacement carries the caller's bytes rather than a re-serialized form.
func TestUpdateSlideSendsOnePartPerChangedElement(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
registerPageRead(t, reg, currentPageXML)
stub := registerWriteStub(t, reg, 8)
err := runUpdateSlide(t, f, wantPage(currentStyleXML, elemOneNew+elemTwo, noteKept))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
parts := decodeUpdateSlideParts(t, stub.CapturedBody)
if len(parts) != 1 {
t.Fatalf("parts = %d, want 1 (only the restyled element differs): %#v", len(parts), parts)
}
if parts[0].Action != "block_replace" || parts[0].BlockID != "bbD" {
t.Errorf("part = %+v, want block_replace on bbD", parts[0])
}
if !strings.Contains(parts[0].Replacement, `fontFamily="楷体"`) {
t.Errorf("replacement lost the edit: %q", parts[0].Replacement)
}
if parts[0].Replacement != elemOneNew {
t.Errorf("replacement should be the caller's exact bytes:\n got %q\nwant %q", parts[0].Replacement, elemOneNew)
}
data := decodeShortcutData(t, stdout)
if data["parts_count"] != float64(1) || data["replaced"] != float64(1) ||
data["inserted"] != float64(0) || data["deleted"] != float64(0) {
t.Errorf("counters = %v", data)
}
if data["revision_id"] != float64(8) {
t.Errorf("revision_id = %v, want 8", data["revision_id"])
}
}
// TestUpdateSlideCanonicalComparison pins that the server's own formatting does
// not read as a change. The caller here reorders attributes, collapses the
// indentation and self-closes differently, but means the same page.
func TestUpdateSlideCanonicalComparison(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
registerPageRead(t, reg, currentPageXML)
forbidWrite(t, reg, "an unchanged page must not be written")
err := runUpdateSlide(t, f, wantPage(currentStyleXML, elemOne+elemTwo, noteKept))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutData(t, stdout)
if data["unchanged"] != true || data["parts_count"] != float64(0) {
t.Fatalf("an identical page should report unchanged, got %v", data)
}
}
func TestUpdateSlideDeletesDroppedElements(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
registerPageRead(t, reg, currentPageXML)
stub := registerWriteStub(t, reg, 9)
// bbv is dropped from the target page.
err := runUpdateSlide(t, f, wantPage(currentStyleXML, elemOne, noteKept))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
parts := decodeUpdateSlideParts(t, stub.CapturedBody)
if len(parts) != 1 {
t.Fatalf("parts = %#v, want a single delete", parts)
}
if parts[0].BlockID != "bbv" || parts[0].Replacement != "" {
t.Errorf("part = %+v, want an empty replacement on bbv (delete)", parts[0])
}
if data := decodeShortcutData(t, stdout); data["deleted"] != float64(1) {
t.Errorf("deleted = %v, want 1", data["deleted"])
}
}
func TestUpdateSlideInsertsNewElementsInPlace(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
registerPageRead(t, reg, currentPageXML)
stub := registerWriteStub(t, reg, 10)
// A new element without an id, written between the two existing ones.
fresh := `<img src="tok" topLeftX="500" topLeftY="100" width="200" height="150"/>`
err := runUpdateSlide(t, f, wantPage(currentStyleXML, elemOne+fresh+elemTwo, noteKept))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
parts := decodeUpdateSlideParts(t, stub.CapturedBody)
if len(parts) != 1 || parts[0].Action != "block_insert" {
t.Fatalf("parts = %#v, want a single block_insert", parts)
}
if parts[0].Insertion != fresh {
t.Errorf("insertion = %q, want the caller's bytes", parts[0].Insertion)
}
// Position is expressed by naming the element it precedes; without it the
// new element would land at the end of the page.
if parts[0].InsertBeforeBlockID != "bbv" {
t.Errorf("insert_before_block_id = %q, want bbv", parts[0].InsertBeforeBlockID)
}
if data := decodeShortcutData(t, stdout); data["inserted"] != float64(1) {
t.Errorf("inserted = %v, want 1", data["inserted"])
}
}
func TestUpdateSlideAppendsWhenNewElementIsLast(t *testing.T) {
t.Parallel()
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
registerPageRead(t, reg, currentPageXML)
stub := registerWriteStub(t, reg, 11)
fresh := `<img src="tok" width="100" height="100"/>`
err := runUpdateSlide(t, f, wantPage(currentStyleXML, elemOne+elemTwo+fresh, noteKept))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
parts := decodeUpdateSlideParts(t, stub.CapturedBody)
if len(parts) != 1 || parts[0].InsertBeforeBlockID != "" {
t.Fatalf("a trailing new element must be appended, got %#v", parts)
}
}
// TestUpdateSlideRejectsBackgroundChange is the honest-failure case. The
// background has no addressable element id, so the only alternative to an error
// is returning success with the background untouched.
func TestUpdateSlideRejectsBackgroundChange(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
name string
style string
wantWord string
}{
{
name: "changed_fill",
style: `<style><fill id="fiy"><fillColor color="rgba(255, 0, 0, 1)"/></fill></style>`,
wantWord: "background",
},
{
name: "style_dropped",
style: ``,
wantWord: "background",
},
} {
t.Run(tt.name, func(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
registerPageRead(t, reg, currentPageXML)
forbidWrite(t, reg, "a background change must be rejected before any write")
err := runUpdateSlide(t, f, wantPage(tt.style, elemOne+elemTwo, noteKept))
if err == nil {
t.Fatal("expected a validation error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Param != "--content" || !strings.Contains(ve.Message, tt.wantWord) {
t.Fatalf("error should name --content and mention the background, got %q (param %q)", ve.Message, ve.Param)
}
})
}
}
func TestUpdateSlideHandlesNote(t *testing.T) {
t.Parallel()
t.Run("replaced", func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
registerPageRead(t, reg, currentPageXML)
stub := registerWriteStub(t, reg, 12)
newNote := `<note id="bbb"><content><p>talk track</p></content></note>`
err := runUpdateSlide(t, f, wantPage(currentStyleXML, elemOne+elemTwo, newNote))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
parts := decodeUpdateSlideParts(t, stub.CapturedBody)
if len(parts) != 1 || parts[0].BlockID != "bbb" || parts[0].Replacement != newNote {
t.Fatalf("parts = %#v, want a block_replace on the note id", parts)
}
if data := decodeShortcutData(t, stdout); data["note_replaced"] != true {
t.Errorf("note_replaced = %v, want true", data["note_replaced"])
}
})
t.Run("cleared_when_omitted", func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
// A page whose note has content, so omitting <note> is a real change.
withNote := strings.Replace(currentPageXML, `<note id="bbb">
<content/>
</note>`, `<note id="bbb"><content><p>old note</p></content></note>`, 1)
registerPageRead(t, reg, withNote)
stub := registerWriteStub(t, reg, 13)
err := runUpdateSlide(t, f, wantPage(currentStyleXML, elemOne+elemTwo, ""))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
parts := decodeUpdateSlideParts(t, stub.CapturedBody)
if len(parts) != 1 || parts[0].BlockID != "bbb" {
t.Fatalf("parts = %#v, want the note cleared", parts)
}
if !strings.Contains(parts[0].Replacement, "<content/>") {
t.Errorf("replacement = %q, want an empty note", parts[0].Replacement)
}
if data := decodeShortcutData(t, stdout); data["note_cleared"] != true {
t.Errorf("note_cleared = %v, want true", data["note_cleared"])
}
})
}
// TestUpdateSlideRejectsUnexpressibleEdits covers the edits that element-level
// parts cannot describe. Each one must fail before any write rather than land
// partially.
func TestUpdateSlideRejectsUnexpressibleEdits(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
name string
content string
wantWord string
}{
{
name: "reordered_elements",
content: wantPage(currentStyleXML, elemTwo+elemOne, noteKept),
wantWord: "reorders",
},
{
name: "unknown_element_id",
content: wantPage(currentStyleXML, elemOne+elemTwo+`<shape id="bZZ" type="text"><content/></shape>`, noteKept),
wantWord: "does not exist",
},
{
name: "duplicate_element_id",
content: wantPage(currentStyleXML, elemOne+elemOne+elemTwo, noteKept),
wantWord: "twice",
},
} {
t.Run(tt.name, func(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
registerPageRead(t, reg, currentPageXML)
forbidWrite(t, reg, "an unexpressible edit must be rejected before any write")
err := runUpdateSlide(t, f, tt.content)
if err == nil {
t.Fatal("expected a validation error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
}
if !strings.Contains(p.Message, tt.wantWord) {
t.Fatalf("error message = %q, want it to mention %q", p.Message, tt.wantWord)
}
})
}
}
// TestUpdateSlideRejectsBadContentBeforeReading pins that malformed input fails
// without spending an API call.
func TestUpdateSlideRejectsBadContentBeforeReading(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
name string
content string
wantWord string
}{
{name: "element_root", content: `<shape type="text"><content/></shape>`, wantWord: "+replace-slide"},
{name: "presentation_root", content: `<presentation><slide id="piy"><data/></slide></presentation>`, wantWord: "+replace-pages"},
{name: "second_page", content: `<slide id="p1"><data/></slide><slide id="p2"><data/></slide>`, wantWord: "one page per call"},
{name: "trailing_text", content: `<slide id="p1"><data/></slide>oops`, wantWord: "text after"},
{name: "not_xml", content: `not xml at all`, wantWord: "no root element"},
{name: "unclosed", content: `<slide id="p1"><data>`, wantWord: "well-formed"},
{name: "blank", content: ` `, wantWord: "cannot be empty"},
// Slide-level structure the diff cannot represent: accepting any of
// these would silently drop the edit — and could even answer
// `unchanged` for a change the caller asked for.
{name: "unknown_slide_child", content: `<slide id="piy"><data/><foo requestedChange="true"/></slide>`, wantWord: "unknown <foo>"},
{name: "duplicate_data", content: `<slide id="piy"><data/><data><shape type="text"><content/></shape></data></slide>`, wantWord: "second <data>"},
{name: "duplicate_style", content: `<slide id="piy"><style/><style/><data/></slide>`, wantWord: "second <style>"},
{name: "duplicate_note", content: `<slide id="piy"><data/><note><content/></note><note><content/></note></slide>`, wantWord: "second <note>"},
{name: "text_in_slide", content: `<slide id="piy"><data/>stray</slide>`, wantWord: "text directly inside <slide>"},
{name: "text_in_data", content: `<slide id="piy"><data><shape type="text"><content/></shape>stray</data></slide>`, wantWord: "text directly inside <data>"},
// XML fetched for page A posted against page B: the root id is the
// only reliable cross-check — element-id checks catch it merely
// incidentally, and an empty page would sail through them.
{name: "root_id_mismatch", content: `<slide id="pother"><data/></slide>`, wantWord: "read from a different page"},
// Container attributes have no element-level part to travel in, so
// accepting them means an edit that silently vanishes into unchanged.
{name: "root_attr", content: `<slide id="piy" requestedChange="true"><data/></slide>`, wantWord: `unsupported attribute "requestedChange" on <slide>`},
{name: "data_attr", content: `<slide id="piy"><data requestedChange="true"/></slide>`, wantWord: `unsupported attribute "requestedChange" on <data>`},
// Namespace declarations are not a loophole: an inherited binding
// changes what every descendant name means, and the canonicalizer
// compares local names, so a binding-only edit would vanish into
// unchanged. Only the official SML default namespace may appear, and
// only on the root.
{name: "wrong_default_xmlns", content: `<slide xmlns="urn:not-sml" id="piy"><data/></slide>`, wantWord: `unsupported xmlns "urn:not-sml"`},
{name: "prefixed_xmlns_on_slide", content: `<slide xmlns:x="urn:a" id="piy"><data/></slide>`, wantWord: `prefixed namespace declaration "xmlns:x"`},
{name: "xmlns_on_data", content: `<slide id="piy"><data xmlns="http://www.larkoffice.com/sml/2.0"/></slide>`, wantWord: `on <data>`},
} {
t.Run(tt.name, func(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/slide",
Optional: true,
OnMatch: func(*http.Request) { t.Error("malformed --content must fail before reading the page") },
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
})
err := runUpdateSlide(t, f, tt.content)
if err == nil {
t.Fatal("expected a validation error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
}
if !strings.Contains(p.Message, tt.wantWord) {
t.Fatalf("error message = %q, want it to mention %q", p.Message, tt.wantWord)
}
})
}
}
// TestUpdateSlideRejectsUnsupportedCurrentPage covers the read side of the
// structure guard: a page whose slide-level structure the diff cannot see
// cannot be safely edited, because the comparison could neither preserve the
// unrecognized part nor notice the caller changing it.
func TestUpdateSlideRejectsUnsupportedCurrentPage(t *testing.T) {
t.Parallel()
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
registerPageRead(t, reg, `<slide id="piy"><style/><data><shape id="bbD" type="text"><content/></shape></data><transition type="fade"/><note id="bbb"><content/></note></slide>`)
forbidWrite(t, reg, "a page the diff cannot represent must never be written")
err := runUpdateSlide(t, f, wantPage("<style/>", elemOne, noteKept))
if err == nil {
t.Fatal("expected an error for a page with unrepresentable structure")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition (the page state, not the flag, is the problem)", ve.Subtype)
}
if !strings.Contains(ve.Message, "<transition>") || !strings.Contains(ve.Message, "cannot represent") {
t.Errorf("message should name the structure: %q", ve.Message)
}
}
// TestUpdateSlideRootIDMayBeOmitted pins the other half of the root-id rule:
// only a non-empty mismatching id is rejected; hand-written pages without one
// apply normally.
func TestUpdateSlideRootIDMayBeOmitted(t *testing.T) {
t.Parallel()
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
registerPageRead(t, reg, currentPageXML)
stub := registerWriteStub(t, reg, 23)
err := runUpdateSlide(t, f, `<slide>`+currentStyleXML+`<data>`+elemOneNew+elemTwo+`</data>`+noteKept+`</slide>`)
if err != nil {
t.Fatalf("a missing root id must be allowed: %v", err)
}
if parts := decodeUpdateSlideParts(t, stub.CapturedBody); len(parts) != 1 || parts[0].BlockID != "bbD" {
t.Fatalf("parts = %#v, want the single font change", parts)
}
}
// TestUpdateSlideCanonicalAttrCollision is the regression for ambiguous
// canonical encoding: with bare name=value concatenation these two elements
// canonicalize identically, and the edit would be dropped as unchanged.
func TestUpdateSlideCanonicalAttrCollision(t *testing.T) {
t.Parallel()
a, err := canonicalizeElement(`<img id="bbD" alt="foo rotateWithShape=true" src="x"/>`)
if err != nil {
t.Fatalf("canonicalize a: %v", err)
}
b, err := canonicalizeElement(`<img id="bbD" alt="foo" rotateWithShape="true" src="x"/>`)
if err != nil {
t.Fatalf("canonicalize b: %v", err)
}
if a == b {
t.Fatalf("distinct attribute sets must not canonicalize identically:\n%s", a)
}
// And through the whole command: the change must become a replacement part.
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
registerPageRead(t, reg, `<slide id="piy"><style/><data><img id="bbD" alt="foo rotateWithShape=true" src="x"/></data><note id="bbb"><content/></note></slide>`)
stub := registerWriteStub(t, reg, 24)
err = runUpdateSlide(t, f, `<slide id="piy"><style/><data><img id="bbD" alt="foo" rotateWithShape="true" src="x"/></data><note id="bbb"><content/></note></slide>`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
parts := decodeUpdateSlideParts(t, stub.CapturedBody)
if len(parts) != 1 || parts[0].BlockID != "bbD" {
t.Fatalf("the attribute change must produce a replacement, got %#v", parts)
}
}
// TestUpdateSlidePlaceholderRefusal pins the conservative contract for
// <undefined> placeholders — the server's stand-ins for objects it cannot
// export (a whiteboard read without its export option, video/audio embeds).
// Whether the whole-page rewrite preserves an untouched one is a server-owned
// behavior no self-contained test can pin down (boards cannot be created
// programmatically), so pages carrying one are refused outright rather than
// edited on an unverifiable assumption.
func TestUpdateSlidePlaceholderRefusal(t *testing.T) {
t.Parallel()
currentWithBoard := `<slide id="piy"><style/><data>` + elemOne + `<undefined id="bbW" type="whiteboard"/></data><note id="bbb"><content/></note></slide>`
t.Run("page_with_placeholder_is_refused", func(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
registerPageRead(t, reg, currentWithBoard)
forbidWrite(t, reg, "a page carrying a placeholder must never be written")
// Even an edit that does not touch the placeholder is refused: the
// rewrite behind the endpoint is whole-page, and preservation of the
// placeholder is exactly what cannot be proven.
err := runUpdateSlide(t, f, `<slide id="piy"><style/><data>`+elemOneNew+`<undefined id="bbW" type="whiteboard"/></data><note id="bbb"><content/></note></slide>`)
if err == nil {
t.Fatal("expected a refusal")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition (the page state, not the flag, is the problem)", ve.Subtype)
}
if !strings.Contains(ve.Message, "<undefined>") || !strings.Contains(ve.Message, "bbW") || !strings.Contains(ve.Message, "+replace-slide") {
t.Errorf("message should name the placeholder and the escape hatch: %q", ve.Message)
}
})
t.Run("placeholder_in_content_is_refused", func(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
registerPageRead(t, reg, currentPageXML) // a clean page
forbidWrite(t, reg, "hand-authored placeholders must be rejected before any write")
err := runUpdateSlide(t, f, wantPage(currentStyleXML, elemOne+`<undefined type="whiteboard"/>`, noteKept))
if err == nil {
t.Fatal("expected a validation error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Param != "--content" || !strings.Contains(ve.Message, "<undefined>") {
t.Fatalf("error = %q (param %q)", ve.Message, ve.Param)
}
})
}
// TestUpdateSlideAcceptsSMLNamespaces pins that every namespace form the
// repository's own SXSD validator accepts — the official identifier plus the
// two server read-back spellings — round-trips on both sides of the diff. The
// primary workflow copies `+xml-get` output back in, so rejecting a read-back
// form would break the feature's core contract.
func TestUpdateSlideAcceptsSMLNamespaces(t *testing.T) {
t.Parallel()
for _, ns := range []string{
"http://www.larkoffice.com/sml/2.0",
"https://www.larkoffice.com/sml/2.0",
"/sml/2.0",
} {
t.Run(ns, func(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
// The server itself may return the namespace on the read side.
currentNS := `<slide xmlns="` + ns + `" id="piy">` + currentStyleXML + `<data>` + elemOne + elemTwo + `</data>` + noteKept + `</slide>`
registerPageRead(t, reg, currentNS)
stub := registerWriteStub(t, reg, 25)
err := runUpdateSlide(t, f, `<slide xmlns="`+ns+`" id="piy">`+currentStyleXML+`<data>`+elemOneNew+elemTwo+`</data>`+noteKept+`</slide>`)
if err != nil {
t.Fatalf("namespace %q must be accepted on both sides: %v", ns, err)
}
if parts := decodeUpdateSlideParts(t, stub.CapturedBody); len(parts) != 1 || parts[0].BlockID != "bbD" {
t.Fatalf("parts = %#v, want the single font change", parts)
}
})
}
}
// TestUpdateSlideDetectsTextEdits is the regression for lossy text
// canonicalization. Both edits were previously reported as `unchanged`: the
// whitespace-only node between inline runs was trimmed away (dropping a
// preserved &#32; space, which decodes to the same token as a literal one),
// and unescaped text let literal markup collide with real elements.
func TestUpdateSlideDetectsTextEdits(t *testing.T) {
t.Parallel()
page := func(paragraph string) string {
return `<slide id="piy"><style/><data><shape id="bbD" type="text"><content>` + paragraph + `</content></shape></data><note id="bbb"><content/></note></slide>`
}
for _, tt := range []struct {
name string
current string
wanted string
}{
{
name: "space_between_inline_runs",
current: page(`<p><strong>Hello</strong> <em>world</em></p>`),
wanted: page(`<p><strong>Hello</strong><em>world</em></p>`),
},
{
name: "escaped_literal_markup_vs_elements",
current: page(`<p>&lt;/p&gt;&lt;p&gt;</p>`),
wanted: page(`<p></p><p></p>`),
},
} {
t.Run(tt.name, func(t *testing.T) {
// The pair must not canonicalize identically…
a, err := canonicalizeElement(tt.current)
if err != nil {
t.Fatalf("canonicalize current: %v", err)
}
b, err := canonicalizeElement(tt.wanted)
if err != nil {
t.Fatalf("canonicalize wanted: %v", err)
}
if a == b {
t.Fatalf("distinct content must not canonicalize identically:\n%s", a)
}
// …and the edit must become a replacement, never `unchanged`.
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
registerPageRead(t, reg, tt.current)
stub := registerWriteStub(t, reg, 26)
if err := runUpdateSlide(t, f, tt.wanted); err != nil {
t.Fatalf("unexpected error: %v", err)
}
parts := decodeUpdateSlideParts(t, stub.CapturedBody)
if len(parts) != 1 || parts[0].Action != "block_replace" || parts[0].BlockID != "bbD" {
t.Fatalf("the text edit must produce a replacement, got %#v", parts)
}
if data := decodeShortcutData(t, stdout); data["unchanged"] == true {
t.Fatal("a real text edit was reported as unchanged")
}
})
}
}
// TestUpdateSlideFailedReasonIsAFailure pins that a rejected batch is reported
// as a command failure rather than as a field on a success envelope.
func TestUpdateSlideFailedReasonIsAFailure(t *testing.T) {
t.Parallel()
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
registerPageRead(t, reg, currentPageXML)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/slide/replace",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"failed_reason": "block with id 'bbD' not found"},
},
})
err := runUpdateSlide(t, f, wantPage(currentStyleXML, elemOneNew+elemTwo, noteKept))
if err == nil {
t.Fatal("a rejected batch must fail the command")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
}
if p.Category != errs.CategoryAPI || !strings.Contains(p.Message, "not found") {
t.Fatalf("error = %+v, want an API error carrying the backend reason", p)
}
}
func TestUpdateSlideDryRunShowsBothCalls(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/slide",
Optional: true,
OnMatch: func(*http.Request) { t.Error("--dry-run must not call the API") },
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
})
err := runSlidesShortcut(t, f, stdout, SlidesUpdateSlide, []string{
"+update-slide",
"--presentation", "pres_abc",
"--slide-id", "piy",
"--content", wantPage(currentStyleXML, elemOne, noteKept),
"--dry-run",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// Both halves must be visible: the parts cannot be, because they depend on
// the page's current state and dry-run must not fetch it.
if !strings.Contains(out, `"GET"`) || !strings.Contains(out, `"POST"`) {
t.Errorf("dry-run should show the read and the write: %s", out)
}
if !strings.Contains(out, "/slide/replace") {
t.Errorf("dry-run missing the write endpoint: %s", out)
}
}
func TestUpdateSlideForwardsRevisionAndTID(t *testing.T) {
t.Parallel()
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
var readQuery, writeQuery string
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
OnMatch: func(req *http.Request) { readQuery = req.URL.RawQuery },
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"slide": map[string]interface{}{"content": currentPageXML}},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/slide/replace",
OnMatch: func(req *http.Request) { writeQuery = req.URL.RawQuery },
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"revision_id": 20}},
})
err := runUpdateSlide(t, f, wantPage(currentStyleXML, elemOneNew+elemTwo, noteKept),
"--revision-id", "19", "--tid", "tid_9")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// The same revision is used for both calls so the parts apply to the
// snapshot they were computed from.
for name, query := range map[string]string{"read": readQuery, "write": writeQuery} {
if !strings.Contains(query, "revision_id=19") {
t.Errorf("%s query = %q, want revision_id=19", name, query)
}
if !strings.Contains(query, "tid=tid_9") {
t.Errorf("%s query = %q, want tid=tid_9", name, query)
}
}
}
func TestUpdateSlideAliasSharesBehavior(t *testing.T) {
t.Parallel()
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
registerPageRead(t, reg, currentPageXML)
stub := registerWriteStub(t, reg, 21)
err := runSlidesShortcut(t, f, nil, SlidesUpdate, []string{
"+update",
"--presentation", "pres_abc",
"--slide-id", "piy",
"--content", wantPage(currentStyleXML, elemOneNew+elemTwo, noteKept),
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if parts := decodeUpdateSlideParts(t, stub.CapturedBody); len(parts) != 1 || parts[0].BlockID != "bbD" {
t.Fatalf("alias sent %#v, want the same single part", parts)
}
}
func TestUpdateSlideAcceptsContentFlagAlias(t *testing.T) {
t.Parallel()
sc := findSlidesShortcut(t, "+update-slide")
f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
registerPageRead(t, reg, currentPageXML)
stub := registerWriteStub(t, reg, 22)
err := runSlidesShortcut(t, f, nil, sc, []string{
"+update-slide",
"--token", "pres_abc",
"--slide-id", "piy",
"--xml", wantPage(currentStyleXML, elemOneNew+elemTwo, noteKept),
"--as", "user",
})
if err != nil {
t.Fatalf("--token / --xml aliases should resolve: %v", err)
}
if parts := decodeUpdateSlideParts(t, stub.CapturedBody); len(parts) != 1 {
t.Fatalf("parts = %d, want 1", len(parts))
}
}
// findSlidesShortcut returns the registered shortcut for command, failing the
// test when it is not wired into Shortcuts().
func findSlidesShortcut(t *testing.T, command string) common.Shortcut {
t.Helper()
for _, sc := range Shortcuts() {
if sc.Command == command {
return sc
}
}
t.Fatalf("shortcut %s is not registered in Shortcuts()", command)
return common.Shortcut{}
}
type updateSlidePart struct {
Action string `json:"action"`
BlockID string `json:"block_id"`
Replacement string `json:"replacement"`
Insertion string `json:"insertion"`
InsertBeforeBlockID string `json:"insert_before_block_id"`
}
func decodeUpdateSlideParts(t *testing.T, raw []byte) []updateSlidePart {
t.Helper()
var body struct {
Parts []updateSlidePart `json:"parts"`
}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("decode body: %v\nraw=%s", err, raw)
}
return body.Parts
}

View File

@@ -82,6 +82,7 @@ metadata:
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md``visual-planning.md``asset-planning.md``lark-slides-create.md``slides +create` |
| 用户要求使用模板,或提供 PPTX 文件要求修改、美化 | 将模板导入为 Slides 再编辑 | `lark-slides-pptx-template-workflows.md` |
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide``lark-slides-replace-slide.md` |
| 一页里大部分元素都要改(批量换字体 / 换配色 / 重排版式) | 先读回该页 XML本地改完整交回去CLI 做 diff 只发有差异的元素 | `slides +xml-get``slides +update-slide`、[`lark-slides-update-slide.md`](references/lark-slides-update-slide.md) |
| 读取或分析已有 PPT | 解析 slides/wiki token用 shortcut 回读全文 XML 或读取单页 XML保存 `xml_presentation_id``slide_id``revision_id` | `slides +xml-get``xml_presentation.slide.get``lark-slides-xml-presentations-get.md` |
| 查看或回滚历史版本 | 先用 `+history-list``history_version_id`,再 `+history-revert`,必要时 `+history-revert-status` 轮询 | [`lark-slides-history.md`](references/lark-slides-history.md) |
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面,一次不超过 10 页 | `slides +screenshot``lark-slides-screenshot.md` |
@@ -103,13 +104,19 @@ metadata:
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
**CRITICAL — 将完整 `<slide>` XML 提交给 `slides +create --slides`、`xml_presentation.slide create``slides +replace-pages` 之前MUST 先把待提交 XML 保存到本地文件并运行唯一版式准出入口 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)`summary.error_count` 必须为 0 才能调用接口,`summary.warning_count > 0` 时必须先做对应页面的截图复核。**
**CRITICAL — 将完整 `<slide>` XML 提交给 `slides +create --slides`、`xml_presentation.slide create``slides +replace-pages` 或 `slides +update-slide` 之前MUST 先把待提交 XML 保存到本地文件并运行唯一版式准出入口 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)`summary.error_count` 必须为 0 才能调用接口,`summary.warning_count > 0` 时必须先做对应页面的截图复核。改字体、字号、宽高同样会改变文本度量和换行,属于必须过闸的编辑。**
**注意 `--revision-id` 不是乐观锁。** 实测传过期版本号服务端不会拒绝——它的含义是「在这个快照上应用改动」,钉住旧版本会丢弃该版本之后对这一页的所有编辑。**默认 `-1`(最新)就是推荐值。**
**CRITICAL — 创建或大幅改写后MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素并使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py) 统一检查 XML、越界、重叠、空白页和内容稀疏风险。**
**CRITICAL — 创建前自检或失败排障时MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
**编辑已有幻灯片页面**:单个标题、文本块、图片或局部元素优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);已有 Slides 的多页大改优先用 [`+replace-pages`](references/lark-slides-replace-pages.md) 在原 presentation 内批量重建页面,避免 `slides +create` 生成新链接。选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
**编辑已有幻灯片页面**:单个标题、文本块、图片或局部元素优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);一页里大部分元素都要改(批量换字体、换配色、重排版式)用 [`+update-slide`](references/lark-slides-update-slide.md)(交整页 XMLCLI diff 后只改有差异的元素;`slide_id` 和页序不变,但改不了背景);已有 Slides 的多页大改优先用 [`+replace-pages`](references/lark-slides-replace-pages.md) 在原 presentation 内批量重建页面,避免 `slides +create` 生成新链接。选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
**CRITICAL — 用 `+update-slide` 时MUST 以 `slides +xml-get --slide-id <sid>` 读回的 XML 为基准修改,不要凭记忆手写整页**`--content` 是这一页的目标状态——带原 `id` 的元素被更新、**不带 `id` 的当新元素插入**、原有但没出现在 `--content` 里的元素**被删除**、`<note>` 没出现则**备注被清空**。页面上有 `<undefined>` 占位符(画板、未导出的音视频)时 **`+update-slide` 会整页拒绝**(无法证明重写会保留它),该页改用 `+replace-slide` 做元素级编辑。手写整页即使渲染一致,也会变成大规模删建。
**CRITICAL — `+update-slide` 改不了页面背景,会直接报错而不是静默忽略。** 底层端点的 `block_id` 只收 `b` 开头的元素 id`<style>` 没有自己的 id、`<fill>` 的 id 是 `f` 开头。把 `+xml-get` 读回的 `<style>` 原样保留即可;确实要改背景只能重建该页。同理**现有元素的顺序也不能调换**(没有 move 操作),会报错。整页更新还会**打散页面上所有组合且不可恢复**、把挂在非空 master / layout 的页面重新挂到空白 layout与主题脱钩——这些是端点行为`+replace-slide` 同样触发,不是 `+update-slide` 独有。详见 [`lark-slides-update-slide.md`](references/lark-slides-update-slide.md)。
**用户要求使用模板**:按 [lark-slides-pptx-template-workflows.md](references/lark-slides-pptx-template-workflows.md) 处理。
@@ -147,7 +154,7 @@ lark-cli auth login --domain slides
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)、[`lark-slides-xml-presentation-slide-create.md`](references/lark-slides-xml-presentation-slide-create.md)(逐页添加)
- 阅读:[`lark-slides-xml-presentations-get.md`](references/lark-slides-xml-presentations-get.md)
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md)
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-update-slide.md`](references/lark-slides-update-slide.md)(按整页 XML 更新一页)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md)
- 历史版本:[`lark-slides-history.md`](references/lark-slides-history.md)
- 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md)
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
@@ -307,6 +314,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
| [`+screenshot`](references/lark-slides-screenshot.md) | 把幻灯片页面截图保存为本地图片,用 `--slide-number` 指定页号(从 1 开始,多页重复传入,一次最多 10 页),用 `--output-dir` 指定保存目录(必须是 CWD 内的相对路径,默认 `.lark-slides/screenshots`),失败时降级到 XML 回读等非截图检查 |
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
| [`+update-slide`](references/lark-slides-update-slide.md) | 交一份完整 `<slide>` XMLCLI 读回当前页做 diff只对有差异的元素发替换 / 新增 / 删除;`slide_id` 和页序不变。适合一页里多个元素都要改(批量换字体 / 配色 / 版式)。**改不了页面背景**,必须以 `+xml-get` 读回的 XML 为基准 |
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |
没有 Shortcut 覆盖时使用原生 API。高频资源`slides +xml-get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
@@ -326,7 +334,7 @@ lark-cli slides <resource> <method> [flags] # 调用 API
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
5. **保存关键 ID**:后续操作需要 `xml_presentation_id``slide_id``revision_id`
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
7. **编辑已有页面优先原链接更新**:修改单个 shape/img 用 `+replace-slide``block_replace` / `block_insert`),不要整页重建;已有 Slides 的多页整页重建用 `+replace-pages`,不要用 `slides +create` 新建整份 PPT只有没有 shortcut 覆盖的特殊单页整页操作才手动 `slide.create` + `slide.delete`
7. **编辑已有页面优先原链接更新**:修改单个 shape/img 用 `+replace-slide``block_replace` / `block_insert`),不要整页重建;单页里多个元素都要改用 `+update-slide`(交整页 XMLCLI diff 后只改差异项,`slide_id` 不变,必须基于 `+xml-get` 读回的 XML且改不了背景已有 Slides 的多页整页重建用 `+replace-pages`,不要用 `slides +create` 新建整份 PPT只有没有 shortcut 覆盖的特殊操作才手动 `slide.create` + `slide.delete`
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传,或在 `+create --slides` 的 XML 里写 `<img src="@./path">` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**slides upload API 不支持分片上传)。
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。

View File

@@ -0,0 +1,177 @@
# slides +update-slide按整页 XML 更新一页)
交一份完整的 `<slide>` XMLCLI 读回这一页当前的样子、和你给的做 diff然后**只对有差异的元素**发替换 / 新增 / 删除。`slide_id` 和页序不变。
`slides +update` 是等价别名(不出现在 `--help` 里)。
## 为什么是 diff 而不是整页覆盖
底层端点 `xml_presentation.slide.replace` 的 part 里,`block_id` 被校验成**短元素 id必须 `b` 开头)**。所以:
- 页面自己的 id 是 `p` 开头 → **不能**用一个 part 覆盖整页
- 背景 fill 的 id 是 `f` 开头 → **不能**改背景
这两条都实测确认过(各自返回 3350001而同一页上 `b` 开头的元素级 part 成功)。元素 id 是这个端点唯一的抓手,所以整页语义只能由 CLI 在客户端拆成元素级操作来表达。
**这带来一个硬限制:背景改不了。** 见下方「改不了的东西」。
## 什么时候用它,什么时候用 +replace-slide
| 场景 | 用哪个 |
|------|--------|
| 改一个标题、换一张图、动一个形状 | [`+replace-slide`](lark-slides-replace-slide.md),你已经知道要改哪个块,不需要 diff |
| 一页里多个元素都要改(批量换字体 / 换配色 / 重排版式) | `+update-slide`,交整页 XML不用逐块枚举 parts 和手写元素 XML |
| 多页整页重建 | [`+replace-pages`](lark-slides-replace-pages.md) |
| 新增一页 | `xml_presentation.slide create` |
| 只改页面背景 | **本命令做不到**,见下 |
## 命令
```bash
# 典型用法:读-改-写(--content 走文件,避免 shell 转义和长度问题)
PID=slidesXXXXXXXXXXXXXXXXXXXXXX
SID=piy
# 1) 读回这一页
lark-cli slides +xml-get --as user \
--presentation "$PID" --slide-id "$SID" --output .lark-slides/page.xml
# 2) 本地改(这里:把整页字体统一成思源黑体;-i.bak 写法 macOS / Linux 通用)
sed -i.bak 's/fontFamily="[^"]*"/fontFamily="思源黑体"/g' .lark-slides/page.xml && rm .lark-slides/page.xml.bak
# 3) 版式准出检查(改字体会改变文本度量,必须过这一关)
python3 skills/lark-slides/scripts/xml_text_overlap_lint.py .lark-slides/page.xml
# 4) 写回CLI 自己再读一次做 diff只发有差异的元素
lark-cli slides +update-slide --as user \
--presentation "$PID" --slide-id "$SID" --content @.lark-slides/page.xml
# stdin 也可以
cat .lark-slides/page.xml | lark-cli slides +update-slide --as user \
--presentation "$PID" --slide-id "$SID" --content -
# 预览只显示会发哪两个请求parts 取决于页面当前状态dry-run 不读页面所以显示不了)
lark-cli slides +update-slide --as user \
--presentation "$PID" --slide-id "$SID" --content @.lark-slides/page.xml --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--presentation` | 是 | `xml_presentation_id``/slides/<token>` URL`/wiki/<token>` URLwiki 自动解析) |
| `--slide-id` | 是 | 要更新的页面 ID |
| `--content` | 是 | 整页 XML**单个 `<slide>` 根元素**;支持 `@<file>``-`stdin |
| `--revision-id` | 否 | 读取和应用所基于的版本;默认 `-1` = 最新。**不是乐观锁**见下方「revision 不是乐观锁」 |
| `--tid` | 否 | 并发事务 ID多人协作长事务才用单次单人调用留空 |
`--content` 也接受 `--xml` / `--slide-xml` / `--slide-content` / `--content-xml``--presentation` 也接受 `--token` / `--url` 等;不出现在 `--help` 里但传了能识别。**`--slide` 不是别名**——太容易和 `--slide-id` 混淆,故意没收录。
## 语义:`--content` 是这一页的目标状态
| 你写的 | 结果 |
|---|---|
| 元素带原 `id`、内容有变 | 替换该元素(`replaced`|
| 元素带原 `id`、内容没变 | 不动它,不产生 part |
| 元素**不带 `id`** | 当新元素插入到你写的位置(`inserted`|
| 原有元素**没出现**在 `--content` 里 | 删除(`deleted`|
| `<note>` 有变 | 替换备注(`note_replaced`|
| `<note>` 没出现 | **清空备注**`note_cleared`|
| 完全没有差异 | 不发写请求,返回 `unchanged: true` |
比较是**规范化**的:服务端返回的 XML 是 pretty-print、属性顺序被重排、还会注入你没写的样式默认值。CLI 比较时会把属性排序、忽略**结构元素之间**的排版空白,所以这些都不算变化——**原样读回、原样写回是幂等的**(已实测)。而发出去的替换内容是**你的原始字节**,你的格式和属性顺序会保留到页面里。
注意 **`<p>` 段落内的文本按原样比较**包括空白SML 里 `&#32;` 是"保留空格",解码后和普通空格是同一个字符,宁可把一个语义等价的空白变化多发一次替换,也不能把真实的 `&#32;` 编辑误判成"没变化"。
## 改不了的东西(会报错,不会静默)
| 你想做的 | 结果 | 为什么 |
|---|---|---|
| 改页面背景 / `<style>` | **报错** | `<style>` 自身没有 id`<fill>` 的 id 是 `f` 开头,端点只收 `b` 开头 |
| 调换现有元素的顺序 | **报错** | 没有 move 操作,元素级 part 表达不出来 |
| `--content` 里写一个页面上不存在的 `id` | **报错** | CLI 不会替你造 id想新建就**别写 id** |
| 同一个 `id` 出现两次 | **报错** | — |
| 根元素不是 `<slide>` | **报错** | `--content` 描述整页,传元素级片段会被理解成"这一页只剩这个",其余全删 |
| 根元素 `id``--slide-id` 不一致 | **报错** | 大概率是拿错了页的 XML读的 A 页、写的 B 页)。确实要跨页套用内容,就把根 `id` 去掉 |
| `<slide>` 下出现 `style` / `data` / `note` 之外的子元素、或它们重复出现、或夹带文本 | **报错** | diff 表达不了这类结构;如果放过去,这部分改动会被静默丢弃,甚至误报 `unchanged` |
| 给 `<slide>``<data>` 加属性 | **报错** | 容器属性没有可承载它的元素级 part。仅有的例外`<slide>` 上可以带 SML namespace——接受与 `sxsd_validator.py` 相同的三种写法:`http://www.larkoffice.com/sml/2.0``https://www.larkoffice.com/sml/2.0``/sml/2.0`(或不带);其它 xmlns、前缀 `xmlns:x``<data>` 上的任何属性都会被拒绝 |
| 编辑**任何**含 `<undefined>` 占位符的页面 | **报错** | 占位符是服务端对"导不出来的对象"(画板、未导出的音视频)的替身。整页重写是否会保留一个没被触碰的占位符,是服务端行为,**没有可编程复现的端点测试能证明它**(画板无法程序化创建),所以本命令直接拒绝编辑这类页面,而不是在无法验证的假设上动手。该页要改,用 [`+replace-slide`](lark-slides-replace-slide.md) 做元素级编辑 |
| 一次传多页 | **报错** | 一页一次调用 |
背景确实要改的话,目前只能重建这一页(`slide create` + `slide delete`),或在客户端手动改。
## revision 不是乐观锁
**实测确认**:传一个已经过期的 `--revision-id` 服务端**不会拒绝**。它的含义是"在这个版本的快照上应用改动",然后把结果提交为新版本——所以钉住旧版本会把**该版本之后对这一页的所有编辑全部丢弃**。
所以:
- **默认 `-1`(最新)就是推荐值**,别去钉住你读到的那个 revision。`-1` 下你的 parts 应用在最新快照上,你没碰的元素保持别人的最新状态。
- 只有在明确想"回到某个快照 + 我的改动"时才传具体版本号,并且清楚这会丢掉之后的编辑。
- 想避免和别人抢同一页,靠的是 `--tid` 事务或流程约定,不是 `--revision-id`
## 返回值
```json
{
"xml_presentation_id": "slidesXXXXXXXXXXXXXXXXXXXXXX",
"slide_id": "piy",
"parts_count": 3,
"replaced": 2,
"inserted": 1,
"deleted": 0,
"revision_id": 103
}
```
| 字段 | 说明 |
|------|------|
| `parts_count` | 本次发出的元素级操作条数;`0` 表示没有差异 |
| `replaced` / `inserted` / `deleted` | 分别替换、新增、删除了几个元素 |
| `note_replaced` / `note_cleared` | 仅在备注被改 / 被清空时出现且为 `true` |
| `unchanged` | 仅在完全没有差异时出现且为 `true`,此时没有发生写入 |
| `revision_id` | 写入成功后的新版本号 |
| `failed_reason` | 不会出现在成功返回里——批次被拒时整条命令失败 |
单次最多 200 个 part服务端上限。差异超过 200 个元素会在本地报错,让你拆开调用。
## 整页更新会连带影响的东西
下面这些是**这个端点**的行为,不是本命令引入的——[`+replace-slide`](lark-slides-replace-slide.md) 改一个元素也一样会触发(两者最终都走同一个整页 rewrite
| 影响 | 说明 |
|---|---|
| **组合被打散** | 页面上所有 group 会被解除组合,且无法恢复——`<group>` 在读写两侧都不可表达 |
| **主题挂载被改** | 页面原本挂在非空 master / layout 上时,会被重新挂到空白 layout从此与主题脱钩占位符会被清理 |
| **动画丢失** | 被删除或被重建的元素上的动画会一并删掉(保住 `id` 的元素不受影响);`<smartLayout>` 每次都重建所以动画必丢。翻页转场不受影响 |
| **静态图表换数据源** | 可编辑图表保留原有数据源;静态图表会拿到新 token旧数据被弃用 |
| **评论锚点** | `id` 匹配的元素上的评论保留;元素被删则锚点随之消失 |
| **含 `<undefined>` 占位符的页面整页拒绝** | 画板、未导出的音视频读回来是 `<undefined>` 占位符;整页重写是否保留它无法用可复现的测试证明,所以本命令直接拒绝编辑这类页面(见上表)。[`+replace-slide`](lark-slides-replace-slide.md) 仍可对该页做元素级编辑 |
最后两条再次指向同一条建议:**以 `+xml-get` 的输出为基准做最小改动**,别手写整页。
## 常见错误
| 现象 | 原因 | 对策 |
|------|------|------|
| `--content changes <style> (the page background)` | 改了背景 | 把 `+xml-get` 读回的 `<style>` 原样保留;确实要改背景只能重建该页 |
| `--content reorders existing elements` | 调换了现有元素顺序 | 保持原顺序;要挪位置就删掉再以新元素插入 |
| `element id "bZZ" ... does not exist` | 写了页面上不存在的 id | 想新建元素就**不要写 id**;或重新 `+xml-get` 确认 id |
| `--content root element is <shape>` | 传了元素级片段 | 单个元素改动用 [`+replace-slide`](lark-slides-replace-slide.md);整页更新要补全 `<slide>` 外层 |
| `--content root carries id "pold" but --slide-id is "pnew"` | 拿 A 页的 XML 写 B 页 | 重新对目标页 `+xml-get`;确实要跨页套用就去掉根 `id` |
| `--content contains an unknown <foo> element` / `a second <data>` | `<slide>` 下有 diff 表达不了的结构 | 一页只有一个 `<style>`、一个 `<data>`、一个 `<note>`;把多余结构去掉 |
| `slide piy contains an <undefined> placeholder` | 这一页上有画板或未导出的媒体对象 | 本命令拒绝编辑该页;用 [`+replace-slide`](lark-slides-replace-slide.md) 做元素级编辑 |
| `an unsupported xmlns "…" on <slide>` | xmlns 写错或用了前缀声明 | 根元素接受 `sxsd_validator.py` 认可的三种 SML namespace可不带`<data>` 不收任何属性 |
| `slide piy contains ... which this command cannot represent` | **当前页**(不是你的输入)带有本命令无法处理的结构 | 这一页改用 [`+replace-slide`](lark-slides-replace-slide.md) 做元素级编辑 |
| `--content is not well-formed XML` | 括号没闭合、引号没配对、实体没转义 | 报错里带解析位置 |
| 返回 `unchanged: true` 但你以为改了 | 你的改动被规范化比较判定为无差异(例如只动了缩进或属性顺序) | 检查是不是真的改了内容 |
| 3350001 | 元素 XML 结构不合法,或嵌套 `<shape>``<content/>` | 对照 [`xml-schema-quick-ref.md`](xml-schema-quick-ref.md)。注意 `+replace-slide` 会自动补 `<content/>`,本命令不会——元素 XML 原样发出 |
| 大页面偶发失败,报错与排队 / 超时相关 | 服务端处理背压,不是尺寸超限 | 先重试;持续出现说明该页确实偏大,拆成几次 `+replace-slide` |
| 403 | 权限不足 | 需要 `slides:presentation:update``write_only`**以及 `slides:presentation:read`**要先读页面wiki URL 还需要 `wiki:node:read` |
## 相关命令
- [+xml-get](lark-slides-xml-presentations-get.md) — 读回整页 XML本命令的输入来源
- [+replace-slide](lark-slides-replace-slide.md) — 元素级替换 / 插入,已知目标块时用它
- [+replace-pages](lark-slides-replace-pages.md) — 多页整页重建
- [lark-slides-edit-workflows.md](lark-slides-edit-workflows.md) — 读-改-写闭环 + 决策树

View File

@@ -1,12 +1,16 @@
# Slides CLI E2E Coverage
## Metrics
- Denominator: 2 leaf commands
- Covered: 1
- Coverage: 50.0%
- Denominator: 3 leaf commands
- Covered: 2
- Coverage: 66.7%
## Summary
- TestSlides_CreateWorkflowAsUser: proves the user slides workflow through `create presentation with slide as user` and `get created presentation xml as user`; creates a fresh presentation, asserts returned IDs, then reads back the XML content to prove the title and slide body persisted.
- TestSlides_UpdateSlideWorkflowAsUser: proves `+update-slide` end to end on a two-page deck — restyle an element (one replace part), rewrite the same page (no-op, no request), add an element without an id (insert), drop an element (delete), and confirm a background change is refused with the page left untouched; then checks the control page and the deck order did not move. **This test is why the command works**: the first version of the command sent a single part covering the whole page, which HTTP stubs accepted and the real API rejects outright — `ReplacePart.block_id` is validated as a short ELEMENT id, so neither the page id (`p`-prefixed) nor the background fill id (`f`-prefixed) can be addressed. Stubs prove the request shape; only a live round trip proves the request is legal.
- TestSlidesUpdateSlideDryRunE2E / TestSlidesUpdateAliasDryRunE2E / TestSlidesUpdateSlideRejectsElementRootDryRunE2E: dry-run coverage for the read-then-write orchestration, the shared revision and slide_id on both calls, the hidden `slides +update` alias with the hidden `--token` / `--xml` spellings, and the refusal of a non-`<slide>` root before any request is built.
- **Known gap**: the live workflow test skips without a user token, so a CI run configured only with bot credentials leaves the load-bearing backend behavior unverified — exactly the blind spot that let the original design reach review. A CI user token, or a bot-identity variant of this workflow, would close it.
- Cleanup deletes the deck through `drive +delete`, which needs `space:document:delete` and `drive:drive.metadata:readonly`. For **stored credentials** the workflow probes that capability up front with a `--dry-run` delete (whose scope pre-flight reads the stored grants) and skips before creating anything when they are missing; an unexpected probe failure is fatal. For **environment tokens** (`TEST_USER_ACCESS_TOKEN`) no scope metadata exists and no API exposes a token's grants without exercising them, so the probe proves nothing there — the CI identity must be provisioned with the cleanup scopes, and a cleanup failure stays fatal and visible. A fully-scoped run creates, edits and deletes its own deck (verified green end to end).
- Blocked area: `slides +media-upload` is still uncovered because it needs a deterministic local image fixture plus XML follow-up proof that is separate from the base create/read workflow.
## Command Table
@@ -14,4 +18,5 @@
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | slides +create | shortcut | slides_create_workflow_test.go::TestSlides_CreateWorkflowAsUser/create presentation with slide as user | `--title`; `--slides ["<slide ...>"]` | read back through raw slides API to prove persisted XML |
| ✓ | slides +update-slide | shortcut | slides_update_slide_workflow_test.go::TestSlides_UpdateSlideWorkflowAsUser | `--presentation`; `--slide-id`; `--content "<slide ...>"`; `--revision-id` | live run needs a user token carrying `slides:presentation:create` / `read` / `update` / `write_only`, plus `space:document:delete` for cleanup; the dry-run half needs no secrets |
| ✕ | slides +media-upload | shortcut | | none | needs a stable local image fixture plus follow-up slide XML proof |

View File

@@ -0,0 +1,175 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
const updateSlideDryRunPageXML = `<slide id="piy"><style><fill id="fiy"><fillColor color="rgba(255, 255, 255, 1)"/></fill></style><data><shape id="bRU" type="text" topLeftX="46" topLeftY="34" width="400" height="36"><content textType="headline" fontSize="28"><p>Overview</p></content></shape></data><note id="bno"><content/></note></slide>`
// TestSlidesUpdateSlideDryRunE2E pins the shape of the orchestration through the
// built CLI: the command reads the page before writing it, because the parts it
// sends are derived from the page's current state rather than from --content
// alone. Dry-run deliberately cannot show the parts — computing them would
// require the read it is not allowed to perform.
func TestSlidesUpdateSlideDryRunE2E(t *testing.T) {
setSlidesDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+update-slide",
"--presentation", "presUpdateSlideDryRun",
"--slide-id", "piy",
"--content", updateSlideDryRunPageXML,
"--revision-id", "17",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
api := gjson.Get(result.Stdout, "data.api").Array()
require.Len(t, api, 2, "the command reads then writes\n%s", result.Stdout)
require.Equal(t, "GET", api[0].Get("method").String(), result.Stdout)
require.Equal(t,
"/open-apis/slides_ai/v1/xml_presentations/presUpdateSlideDryRun/slide",
api[0].Get("url").String(), result.Stdout,
)
require.Equal(t, "POST", api[1].Get("method").String(), result.Stdout)
require.Equal(t,
"/open-apis/slides_ai/v1/xml_presentations/presUpdateSlideDryRun/slide/replace",
api[1].Get("url").String(), result.Stdout,
)
// The same revision is used for both calls so the parts apply to the
// snapshot they were diffed against.
for i := range api {
require.Equal(t, "piy", api[i].Get("params.slide_id").String(), result.Stdout)
require.Equal(t, int64(17), api[i].Get("params.revision_id").Int(), result.Stdout)
require.False(t, api[i].Get("params.tid").Exists(), "tid must be omitted when empty\n%s", result.Stdout)
}
require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.wanted_element_count").Int(), result.Stdout)
}
// TestSlidesUpdateAliasDryRunE2E proves the hidden `+update` spelling reaches
// the same logic through the real CLI, along with the hidden --token / --xml
// flag spellings.
func TestSlidesUpdateAliasDryRunE2E(t *testing.T) {
setSlidesDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+update",
"--token", "presUpdateSlideDryRun",
"--slide-id", "piy",
"--xml", updateSlideDryRunPageXML,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t,
"/open-apis/slides_ai/v1/xml_presentations/presUpdateSlideDryRun/slide/replace",
gjson.Get(result.Stdout, "data.api.1.url").String(),
result.Stdout,
)
require.Equal(t, int64(-1), gjson.Get(result.Stdout, "data.api.1.params.revision_id").Int(),
"-1 is the default: apply against the latest revision\n%s", result.Stdout)
}
// TestSlidesUpdateSlideRejectsBadContentDryRunE2E is the guardrail check
// through the built CLI: inputs whose failure mode is data loss must be
// refused before any request is built, with the full typed error contract —
// agents branch on type/subtype/param, not on prose.
func TestSlidesUpdateSlideRejectsBadContentDryRunE2E(t *testing.T) {
setSlidesDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
t.Cleanup(cancel)
for _, tt := range []struct {
name string
content string
wantMessage string
}{
{
// An element-level fragment would mean "the page should contain
// only this" and delete everything else.
name: "element_root",
content: `<shape type="text"><content><p>oops</p></content></shape>`,
wantMessage: "+replace-slide",
},
{
// XML fetched for page A posted against page B.
name: "root_id_mismatch",
content: `<slide id="pother"><data/></slide>`,
wantMessage: "read from a different page",
},
{
// Slide-level structure the diff cannot represent would be
// silently dropped — possibly reported as `unchanged`.
name: "unknown_slide_child",
content: `<slide id="piy"><data/><foo requestedChange="true"/></slide>`,
wantMessage: "unknown <foo>",
},
{
// Container attributes have no element-level part to travel in.
name: "root_attribute",
content: `<slide id="piy" requestedChange="true"><data/></slide>`,
wantMessage: "unsupported attribute",
},
{
// A namespace binding inherited from the root changes what every
// descendant name means; only the official SML declaration passes.
name: "wrong_default_xmlns",
content: `<slide xmlns="urn:not-sml" id="piy"><data/></slide>`,
wantMessage: "unsupported xmlns",
},
} {
t.Run(tt.name, func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+update-slide",
"--presentation", "presUpdateSlideDryRun",
"--slide-id", "piy",
"--content", tt.content,
"--dry-run",
},
DefaultAs: "bot",
})
// RunCmd errors only when the CLI could not be launched; a normal
// non-zero exit lands in result.ExitCode. Discarding this error
// would turn a broken harness into a nil-pointer panic.
require.NoError(t, err, "the CLI must launch")
// Validation errors have a fixed process contract: exit code 2,
// nothing on stdout, the typed envelope on stderr.
require.Equal(t, 2, result.ExitCode,
"stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
require.Empty(t, result.Stdout, "a refused command must not emit a result")
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), result.Stderr)
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
require.Equal(t, "--content", gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), tt.wantMessage, result.Stderr)
})
}
}

View File

@@ -0,0 +1,262 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"os"
"regexp"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// skipWithoutCleanupScopes refuses to create a deck this run cannot delete,
// when that is knowable. AGENTS.md wants live workflows self-contained
// (create → use → cleanup); a cleanup that fails on missing scopes both fails
// the package after the workflow already passed and leaks one presentation per
// run. The capability is probed up front with --dry-run, which runs the same
// scope pre-flight as the real cleanup without touching anything remote.
//
// The probe is authoritative only for stored credentials, whose scope grants
// the pre-flight can read. A token injected through the environment
// (TEST_USER_ACCESS_TOKEN / LARKSUITE_CLI_USER_ACCESS_TOKEN) carries no scope
// metadata, and the pre-flight deliberately skips when scopes are unknown —
// exit 0 from the probe proves nothing there. No API exposes a token's grants
// without exercising them, so for that path the run proceeds on the documented
// requirement that the CI identity is provisioned with the cleanup scopes
// (coverage.md), and a cleanup failure stays fatal and visible.
func skipWithoutCleanupScopes(ctx context.Context, t *testing.T) {
t.Helper()
if os.Getenv("TEST_USER_ACCESS_TOKEN") != "" || os.Getenv("LARKSUITE_CLI_USER_ACCESS_TOKEN") != "" {
t.Log("cleanup-scope probe skipped: environment tokens carry no scope metadata, so a dry-run pre-flight cannot prove anything; the CI identity must be provisioned with space:document:delete and drive:drive.metadata:readonly (see coverage.md)")
return
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", "cleanup_scope_probe", "--type", "slides", "--yes", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err, "the CLI must launch for the scope probe")
switch {
case result.ExitCode == 0:
// Stored credential with the scopes present.
case strings.Contains(result.Stderr, "missing_scope"):
t.Skipf("user token lacks the cleanup scopes (space:document:delete, drive:drive.metadata:readonly); refusing to create a deck the run cannot delete\nstderr:\n%s", result.Stderr)
default:
// Anything else is a broken probe, not a known-good capability;
// proceeding would risk creating a deck under unknown conditions.
t.Fatalf("cleanup-scope probe failed unexpectedly (exit %d)\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr)
}
}
// TestSlides_UpdateSlideWorkflowAsUser is the only test that can prove this
// command works, and it exists because an earlier version of it did not: the
// whole design once rested on sending a single part covering the page, which
// unit stubs happily accepted and the real API rejects outright (block_id is
// validated as a short ELEMENT id, so a page id cannot be addressed). Stubs
// prove the request shape; only a live round trip proves the request is legal
// and does what it claims.
//
// It walks every operation the diff can emit — replace, insert, delete, and the
// no-op — then checks the two things element-level parts cannot express are
// refused rather than silently dropped.
func TestSlides_UpdateSlideWorkflowAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
// Without a user token the load-bearing backend behavior goes unverified in
// this run; say so rather than skipping quietly.
clie2e.SkipWithoutUserToken(t)
skipWithoutCleanupScopes(ctx, t)
parentT := t
suffix := clie2e.GenerateSuffix()
title := "slides-update-e2e-" + suffix
controlText := "Control " + suffix
originalText := "Original " + suffix
page := func(body string) string {
return `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>` +
`<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">` +
`<content textType="title"><p>` + body + `</p></content></shape></data></slide>`
}
jsonArray := func(xmls ...string) string {
quoted := make([]string, 0, len(xmls))
for _, xml := range xmls {
quoted = append(quoted, `"`+strings.ReplaceAll(xml, `"`, `\"`)+`"`)
}
return "[" + strings.Join(quoted, ",") + "]"
}
readPage := func(t *testing.T, presentationID, slideID string) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"slides", "+xml-get", "--presentation", presentationID, "--slide-id", slideID},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
content := gjson.Get(result.Stdout, "data.slide.content").String()
require.NotEmpty(t, content, "stdout:\n%s", result.Stdout)
return content
}
update := func(t *testing.T, presentationID, slideID, content string) *clie2e.Result {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+update-slide",
"--presentation", presentationID,
"--slide-id", slideID,
"--content", content,
},
DefaultAs: "user",
})
require.NoError(t, err)
return result
}
var presentationID, targetSlideID string
t.Run("create a two-page presentation as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+create",
"--title", title,
"--slides", jsonArray(page(controlText), page(originalText)),
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
presentationID = gjson.Get(result.Stdout, "data.xml_presentation_id").String()
require.NotEmpty(t, presentationID, "stdout:\n%s", result.Stdout)
slideIDs := gjson.Get(result.Stdout, "data.slide_ids").Array()
require.Len(t, slideIDs, 2, "stdout:\n%s", result.Stdout)
targetSlideID = slideIDs[1].String()
parentT.Cleanup(func() {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{
"drive", "+delete",
"--file-token", presentationID,
"--type", "slides",
"--yes",
},
DefaultAs: "user",
})
// Deleting needs space:document:delete, which a token scoped only
// for slides does not carry; report rather than fail so a scope gap
// does not mask the workflow result. The deck is named with the
// run suffix so a leftover is identifiable.
clie2e.ReportCleanupFailure(parentT, "delete presentation "+presentationID, deleteResult, deleteErr)
})
})
t.Run("restyle an element: one replace part", func(t *testing.T) {
require.NotEmpty(t, targetSlideID, "presentation should be created first")
// Change the font on every element, which is the edit the command
// exists for. Only <content> is touched, so exactly one element differs.
current := readPage(t, presentationID, targetSlideID)
wanted := regexp.MustCompile(`fontFamily="[^"]*"`).ReplaceAllString(current, `fontFamily="楷体"`)
require.NotEqual(t, current, wanted, "the page should have had a fontFamily to change:\n%s", current)
result := update(t, presentationID, targetSlideID, wanted)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.replaced").Int(), result.Stdout)
require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.parts_count").Int(), result.Stdout)
require.Equal(t, targetSlideID, gjson.Get(result.Stdout, "data.slide_id").String(),
"the page keeps its slide_id\n%s", result.Stdout)
after := readPage(t, presentationID, targetSlideID)
require.Contains(t, after, `fontFamily="楷体"`, "the restyle must be live:\n%s", after)
require.Contains(t, after, originalText, "restyling must not change the text:\n%s", after)
})
t.Run("writing the same page again is a no-op", func(t *testing.T) {
current := readPage(t, presentationID, targetSlideID)
result := update(t, presentationID, targetSlideID, current)
result.AssertExitCode(t, 0)
require.True(t, gjson.Get(result.Stdout, "data.unchanged").Bool(),
"an identical page must not be written\n%s", result.Stdout)
require.Equal(t, int64(0), gjson.Get(result.Stdout, "data.parts_count").Int(), result.Stdout)
})
t.Run("add an element without an id: one insert part", func(t *testing.T) {
current := readPage(t, presentationID, targetSlideID)
added := `<shape type="text" topLeftX="80" topLeftY="300" width="400" height="80"><content><p>Added ` + suffix + `</p></content></shape>`
wanted := strings.Replace(current, "</data>", added+"</data>", 1)
result := update(t, presentationID, targetSlideID, wanted)
result.AssertExitCode(t, 0)
require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.inserted").Int(), result.Stdout)
after := readPage(t, presentationID, targetSlideID)
require.Contains(t, after, "Added "+suffix, "the new element must be live:\n%s", after)
require.Contains(t, after, originalText, "the existing element must survive:\n%s", after)
})
t.Run("drop an element: one delete part", func(t *testing.T) {
current := readPage(t, presentationID, targetSlideID)
// Remove the original title shape, keeping the one added above.
wanted := regexp.MustCompile(`(?s)\s*<shape[^>]*>\s*<content[^>]*>\s*<p>`+regexp.QuoteMeta(originalText)+`</p>.*?</shape>`).
ReplaceAllString(current, "")
require.NotEqual(t, current, wanted, "the title shape should have been removable:\n%s", current)
result := update(t, presentationID, targetSlideID, wanted)
result.AssertExitCode(t, 0)
require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.deleted").Int(), result.Stdout)
after := readPage(t, presentationID, targetSlideID)
require.NotContains(t, after, originalText, "the dropped element must be gone:\n%s", after)
require.Contains(t, after, "Added "+suffix, "the kept element must remain:\n%s", after)
})
t.Run("a background change is refused, not dropped", func(t *testing.T) {
current := readPage(t, presentationID, targetSlideID)
// Matches both the self-closing <style/> a plain page comes back with
// and a populated <style>…</style>.
styleBlock := regexp.MustCompile(`(?s)<style\s*/>|<style[^>]*>.*?</style>`)
require.True(t, styleBlock.MatchString(current), "page should carry a <style> block:\n%s", current)
wanted := styleBlock.ReplaceAllString(current,
`<style><fill><fillColor color="rgba(255, 0, 0, 1)"/></fill></style>`)
result := update(t, presentationID, targetSlideID, wanted)
require.NotEqual(t, 0, result.ExitCode,
"the background cannot be expressed, so it must fail loudly\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
require.Contains(t, result.Stderr, "background", "stderr:\n%s", result.Stderr)
// And nothing may have been written: the page is still what it was.
require.Equal(t, current, readPage(t, presentationID, targetSlideID),
"a refused background change must leave the page untouched")
})
t.Run("the other page and the page order are untouched", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"api", "get", "/open-apis/slides_ai/v1/xml_presentations/" + presentationID},
DefaultAs: "user",
Params: map[string]any{"revision_id": -1},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
content := gjson.Get(result.Stdout, "data.xml_presentation.content").String()
require.Contains(t, content, controlText, "the control page must be untouched\n%s", content)
controlAt := strings.Index(content, controlText)
editedAt := strings.Index(content, "Added "+suffix)
require.GreaterOrEqual(t, controlAt, 0, content)
require.GreaterOrEqual(t, editedAt, 0, content)
require.Less(t, controlAt, editedAt, "the deck order must not change\n%s", content)
})
}