Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions shortcuts/slides/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,35 @@
return paths
}

// validateImagePlaceholderFiles checks every @-placeholder path up front:
// exists, is a regular file, and fits the 20 MB single-part upload ceiling.
// Callers run this during Validate so a bad path fails before any API call,
// rather than half-way through an upload sequence.
//
// param names the flag the paths came from (e.g. "--slides", "--slide") so the
// typed Param points at what the caller actually typed. The message quotes the
// <img> element instead of writing "--slide @path", which reads as if the flag
// argument itself were the missing image; the paths come from placeholders
// nested inside the XML, and they resolve against the process CWD rather than
// the directory of an @file passed to the flag.
func validateImagePlaceholderFiles(runtime *common.RuntimeContext, param string, paths []string) error {
for _, path := range paths {
placeholder := fmt.Sprintf(`%s: <img src="@%s"> resolved from the current directory`, param, path)
stat, err := runtime.FileIO().Stat(path)
if err != nil {
return slidesInputStatError(err, param, placeholder)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if !stat.Mode().IsRegular() {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: must be a regular file", placeholder).WithParam(param)
}
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: file size %s exceeds 20 MB limit for slides image upload",
placeholder, common.FormatSize(stat.Size())).WithParam(param)

Check warning on line 178 in shortcuts/slides/helpers.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/slides/helpers.go#L177-L178

Added lines #L177 - L178 were not covered by tests
}
}
return nil
}

// xmlRootOpenTagRegex matches the first opening tag of an XML fragment:
// skipping leading whitespace, XML declaration (<?...?>), and comments
// (<!-- ... -->).
Expand Down
92 changes: 92 additions & 0 deletions shortcuts/slides/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
package slides

import (
"bytes"
"errors"
"reflect"
"strings"
"testing"

"github.com/larksuite/cli/errs"
)

func TestParsePresentationRef(t *testing.T) {
Expand Down Expand Up @@ -413,3 +417,91 @@ func TestEnsureXMLRootID(t *testing.T) {
})
}
}

// errAnyCause asks assertValidationProblem for "a cause was preserved" without
// naming it. Used where the wrapped error is an ad-hoc fmt.Errorf from a shared
// validator with no sentinel to match on; prefer a real sentinel wherever one
// exists, because that is what proves the *right* error survived.
var errAnyCause = errors.New("any preserved cause")

// assertValidationProblem asserts the typed metadata every error path in this
// package owes its callers: the validation Category/Subtype pair agents route
// on, the flag that produced it, and a preserved cause. Param is read through
// errors.As because ProblemOf returns the shared Problem, which does not carry
// it. wantCause is nil for the checks that reject an input outright and have no
// underlying error to wrap — those must not invent one.
func assertValidationProblem(t *testing.T, err error, wantParam string, wantCause error) *errs.ValidationError {
t.Helper()

var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %v (%T), want *errs.ValidationError", err, err)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %v, want typed problem metadata", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("Category = %q, want %q", problem.Category, errs.CategoryValidation)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != wantParam {
t.Fatalf("Param = %q, want %q", ve.Param, wantParam)
}
switch {
case wantCause == nil:
if ve.Cause != nil {
t.Fatalf("Cause = %v, want none for an outright-rejected input", ve.Cause)
}
case errors.Is(wantCause, errAnyCause):
if ve.Cause == nil {
t.Fatal("Cause = nil, want the underlying error preserved")
}
default:
if !errors.Is(ve.Cause, wantCause) {
t.Fatalf("Cause = %v, want it to wrap %v", ve.Cause, wantCause)
}
}
return ve
}

// decodeShortcutDryRunAPI returns the API steps a --dry-run run planned, in
// order. Dry-run output is the only place the orchestration shape (how many
// calls, and in which order) is observable without making real requests.
func decodeShortcutDryRunAPI(t *testing.T, stdout *bytes.Buffer) []map[string]interface{} {
t.Helper()

data := decodeShortcutData(t, stdout)
raw, _ := data["api"].([]interface{})
if len(raw) == 0 {
t.Fatalf("dry-run planned no API calls: %#v", data)
}
steps := make([]map[string]interface{}, 0, len(raw))
for i, item := range raw {
step, ok := item.(map[string]interface{})
if !ok {
t.Fatalf("api[%d] = %#v, want an object", i, item)
}
steps = append(steps, step)
}
return steps
}

// assertDryRunStep checks one planned call's method and URL, and returns it for
// the caller's params/body assertions.
func assertDryRunStep(t *testing.T, steps []map[string]interface{}, i int, wantMethod, wantURL string) map[string]interface{} {
t.Helper()

if i >= len(steps) {
t.Fatalf("want at least %d planned call(s), got %d: %#v", i+1, len(steps), steps)
}
if steps[i]["method"] != wantMethod {
t.Fatalf("api[%d].method = %v, want %s", i, steps[i]["method"], wantMethod)
}
if steps[i]["url"] != wantURL {
t.Fatalf("api[%d].url = %v, want %s", i, steps[i]["url"], wantURL)
}
return steps[i]
}
2 changes: 2 additions & 0 deletions shortcuts/slides/shortcuts.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ var presentationFlagAliases = []string{
func Shortcuts() []common.Shortcut {
all := []common.Shortcut{
SlidesCreate,
SlidesAddSlide,
SlidesDeleteSlide,
SlidesMediaUpload,
SlidesReplaceSlide,
SlidesReplacePages,
Expand Down
245 changes: 245 additions & 0 deletions shortcuts/slides/slides_add_slide.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
// 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"
)

// SlidesAddSlide appends (or inserts) a single page into an existing
// presentation. It is the second half of the two-step creation flow: create a
// blank deck with +create, then add pages one at a time.
//
// Value-adds over the raw xml_presentation.slide.create command:
//
// 1. --presentation accepts a token / slides URL / wiki URL, like every other
// slides shortcut, instead of a hand-built --params JSON blob.
// 2. --slide takes the XML directly (and via @file / stdin), so callers stop
// nesting a fully escaped XML document inside a JSON string inside a shell
// argument — the escaping layer that produces most 3350001 reports.
// 3. <img src="@./local.png"> placeholders are uploaded and rewritten to
// file_tokens, the same as +create --slides. Previously this combination
// had no CLI support at all: adding an image-bearing page to an existing
// deck meant calling +media-upload and splicing the token in by hand.
//
// Deliberately single-page: the backend endpoint creates one page per call, so
// a batch flag here would just be a client-side loop with partial-failure
// semantics to explain. Callers who want many pages loop the command, or use
// +create --slides when the deck does not exist yet.
var SlidesAddSlide = common.Shortcut{
Service: "slides",
Command: "+add-slide",
Description: "Add one page to an existing presentation (<img src=\"@./local.png\"> placeholders are auto-uploaded and replaced with file_token)",
Risk: "write",
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"},
// Both extras are path-dependent, so they stay conditional rather than
// gating every call: wiki:node:read only when --presentation is a wiki URL,
// docs:document.media:upload only when the XML carries @-placeholders.
// Unlike +create there is no orphan risk to pre-empt here — the
// presentation already exists, so a late upload 403 leaves nothing behind.
ConditionalScopes: []string{"wiki:node:read", "docs:document.media:upload"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
// The "(supports @file, - reads stdin ...)" suffix is appended from Input
// below, so spelling it out here too produced a doubled parenthetical.
{Name: "slide", Desc: "one complete <slide> XML document", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "before-slide-id", Desc: "insert before this slide_id (default: append after the last page)"},
{Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision (-1 = latest; pass a specific number for optimistic locking)"},
},
Tips: []string{
"<img src=\"@path\"> placeholders resolve against the current directory, not the directory of the --slide file, and are deduplicated per call: a page-by-page loop re-uploads a shared image once per page, so upload it once with slides +media-upload and reuse the file_token instead.",
},
Validate: func(ctx 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

Check warning on line 67 in shortcuts/slides/slides_add_slide.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/slides/slides_add_slide.go#L67

Added line #L67 was not covered by tests
}
}
slideXML, err := addSlideXML(runtime)
if err != nil {
return err
}
// validateCompleteSlideXML is shared with +replace-pages and reports the
// structural problem alone ("root element is <presentation>, want
// <slide>"). Re-tag it with the flag it came from so the caller sees
// which input to fix, and so agents can route on the typed Param.
if err := validateCompleteSlideXML(slideXML); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide is not a single complete <slide> document: %v", err).WithParam("--slide").WithCause(err)
}
// Check placeholder files before any API call so a typo in a path fails
// locally instead of after the page is half-built.
placeholders := extractImagePlaceholderPaths([]string{slideXML})
if len(placeholders) > 0 {
if err := runtime.EnsureScopes([]string{"docs:document.media:upload"}); err != nil {
return err

Check warning on line 86 in shortcuts/slides/slides_add_slide.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/slides/slides_add_slide.go#L86

Added line #L86 was not covered by tests
}
if err := validateImagePlaceholderFiles(runtime, "--slide", placeholders); err != nil {
return err
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())

Check warning on line 97 in shortcuts/slides/slides_add_slide.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/slides/slides_add_slide.go#L97

Added line #L97 was not covered by tests
}
slideXML, err := addSlideXML(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())

Check warning on line 101 in shortcuts/slides/slides_add_slide.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/slides/slides_add_slide.go#L101

Added line #L101 was not covered by tests
}

placeholders := extractImagePlaceholderPaths([]string{slideXML})
dry := common.NewDryRunAPI()

presentationID := ref.Token
step := 1
total := 1 + len(placeholders)
if ref.Kind == "wiki" {
total++
}

if ref.Kind == "wiki" {
presentationID = "<resolved_slides_token>"
dry.Desc(fmt.Sprintf("%d-step orchestration: resolve wiki → add page", total)).
GET("/open-apis/wiki/v2/spaces/get_node").
Desc(fmt.Sprintf("[%d/%d] Resolve wiki node to slides presentation", step, total)).
Params(map[string]interface{}{"token": ref.Token})
step++
} else if len(placeholders) > 0 {
dry.Desc(fmt.Sprintf("Upload %d image(s) + add 1 page", len(placeholders)))
} else {
dry.Desc("Add 1 page")
}

for _, path := range placeholders {
appendSlidesUploadDryRun(dry, path, presentationID, step)
step++
}

descSuffix := ""
if len(placeholders) > 0 {
descSuffix = " (img placeholders auto-replaced)"
}
dry.POST(fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide",
validate.EncodePathSegment(presentationID),
)).
Desc(fmt.Sprintf("[%d/%d] Add page%s", step, total, descSuffix)).
Params(addSlideQuery(runtime)).
Body(addSlideBody(slideXML, runtime.Str("before-slide-id")))

return dry.Set("images_to_upload", len(placeholders))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err

Check warning on line 149 in shortcuts/slides/slides_add_slide.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/slides/slides_add_slide.go#L149

Added line #L149 was not covered by tests
}
presentationID, err := resolvePresentationID(runtime, ref)
if err != nil {
return err

Check warning on line 153 in shortcuts/slides/slides_add_slide.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/slides/slides_add_slide.go#L153

Added line #L153 was not covered by tests
}
slideXML, err := addSlideXML(runtime)
if err != nil {
return err

Check warning on line 157 in shortcuts/slides/slides_add_slide.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/slides/slides_add_slide.go#L157

Added line #L157 was not covered by tests
}

result := map[string]interface{}{
"xml_presentation_id": presentationID,
}

// Uploads run against the target presentation, so they can only happen
// after a wiki ref has been resolved to a real presentation id.
placeholders := extractImagePlaceholderPaths([]string{slideXML})
if len(placeholders) > 0 {
tokens, uploaded, err := uploadSlidesPlaceholders(runtime, presentationID, placeholders)
if err != nil {
return appendSlidesProgressHint(err, fmt.Sprintf("no page was added; %d of %d image(s) uploaded before failure", uploaded, len(placeholders)))
}
slideXML = replaceImagePlaceholders(slideXML, tokens)
result["images_uploaded"] = uploaded
}

beforeSlideID := strings.TrimSpace(runtime.Str("before-slide-id"))
data, err := runtime.CallAPITyped(
"POST",
fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide",
validate.EncodePathSegment(presentationID),
),
addSlideQuery(runtime),
addSlideBody(slideXML, beforeSlideID),
)
if err != nil {
if len(placeholders) > 0 {
// The images are already in the deck's media store; say so, or
// a retry silently uploads a second copy of every file.
err = appendSlidesProgressHint(err, fmt.Sprintf("%d image(s) were uploaded before the page failed; re-running will upload them again", len(placeholders)))
}
return enrichSlidesReplaceError(err)
}

slideID := common.GetString(data, "slide_id")
if slideID == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "slide.create returned no slide_id")
}
result["slide_id"] = slideID
if beforeSlideID != "" {
result["before_slide_id"] = beforeSlideID
}
if rev, ok := revisionFromData(data); ok {
result["revision_id"] = rev
}
// issues carries backend-side schema warnings for content that was
// accepted but altered; pass it through untouched so the caller can
// decide whether the page still says what they meant.
if issues, ok := data["issues"]; ok {
result["issues"] = issues
}

runtime.Out(result, nil)
return nil
},
}

// addSlideXML returns the trimmed --slide value, rejecting an empty one.
// --slide is Required, so cobra already blocks a missing flag; this catches
// `--slide ""` and an @file / stdin source that turned out to be blank.
func addSlideXML(runtime *common.RuntimeContext) (string, error) {
xml := strings.TrimSpace(runtime.Str("slide"))
if xml == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide cannot be empty").WithParam("--slide")
}
return xml, nil
}

// addSlideQuery builds the query params shared by dry-run and execute.
func addSlideQuery(runtime *common.RuntimeContext) map[string]interface{} {
return map[string]interface{}{"revision_id": runtime.Int("revision-id")}
}

// addSlideBody builds the request body shared by dry-run and execute.
// before_slide_id is omitted when empty: the backend appends to the end only
// if the key is absent, and an empty string is rejected as an unknown slide.
func addSlideBody(slideXML, beforeSlideID string) map[string]interface{} {
body := map[string]interface{}{
"slide": map[string]interface{}{"content": slideXML},
}
if id := strings.TrimSpace(beforeSlideID); id != "" {
body["before_slide_id"] = id
}
return body
}
Loading
Loading