mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
1 Commits
main
...
feat/slide
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
307e753b3f |
@@ -15,4 +15,5 @@ registry.npmjs.org
|
||||
registry.npmmirror.com
|
||||
sf16-sg.tiktokcdn.com
|
||||
www.feishu.cn
|
||||
www.larkoffice.com
|
||||
www.larksuite.com
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
390
shortcuts/slides/slides_update_slide.go
Normal file
390
shortcuts/slides/slides_update_slide.go
Normal 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),
|
||||
)
|
||||
}
|
||||
598
shortcuts/slides/slides_update_slide_diff.go
Normal file
598
shortcuts/slides/slides_update_slide_diff.go
Normal 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   decodes to the
|
||||
// very same token, so dropping "insignificant" whitespace here would also drop
|
||||
// a real   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 ""
|
||||
}
|
||||
898
shortcuts/slides/slides_update_slide_test.go
Normal file
898
shortcuts/slides/slides_update_slide_test.go
Normal 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   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></p><p></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
|
||||
}
|
||||
@@ -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)(交整页 XML,CLI 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>` XML,CLI 读回当前页做 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`(交整页 XML,CLI 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>` 输出不一致,以后两者为准。
|
||||
|
||||
177
skills/lark-slides/references/lark-slides-update-slide.md
Normal file
177
skills/lark-slides/references/lark-slides-update-slide.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# slides +update-slide(按整页 XML 更新一页)
|
||||
|
||||
交一份完整的 `<slide>` XML,CLI 读回这一页当前的样子、和你给的做 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>` URL(wiki 自动解析) |
|
||||
| `--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 里 ` ` 是"保留空格",解码后和普通空格是同一个字符,宁可把一个语义等价的空白变化多发一次替换,也不能把真实的 ` ` 编辑误判成"没变化"。
|
||||
|
||||
## 改不了的东西(会报错,不会静默)
|
||||
|
||||
| 你想做的 | 结果 | 为什么 |
|
||||
|---|---|---|
|
||||
| 改页面背景 / `<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) — 读-改-写闭环 + 决策树
|
||||
@@ -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 |
|
||||
|
||||
175
tests/cli_e2e/slides/slides_update_slide_dryrun_test.go
Normal file
175
tests/cli_e2e/slides/slides_update_slide_dryrun_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
262
tests/cli_e2e/slides/slides_update_slide_workflow_test.go
Normal file
262
tests/cli_e2e/slides/slides_update_slide_workflow_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user