Files
larksuite__cli/shortcuts/slides/slides_create.go
R0bynZhu 7690ba4446 feat(slides): lint slide writes server-side, add --no-lint to opt out (#2607)
+create, +add-slide, +update-slide and +replace-slide are the four
shortcuts that change slide content, so they are the four that can ask
the backend to check the page before accepting the write. All four now
send lint_xml=true by default and lint_xml=false when --no-lint is
passed. The subject of the check is the page the write produces, not the
payload it was handed: +replace-slide submits fragments, and a fragment
that is correct on its own can still push a neighbour off the canvas.

The switch travels in the request body rather than the query string. A
query parameter has to be declared in the gateway's own api meta before
it is bound to a field, and the published definition of these endpoints
does not list one — so an undeclared parameter is dropped, the field
arrives unset, and the server reads it as "not requested". Verified
against a live backend: pages that asked to be linted were written
unlinted, with nothing anywhere to say so. Body fields ride along with
the JSON already being sent and need no registration.

The value is sent explicitly in both directions rather than omitted when
on. The parameter is newer than the registry, so the server-side default
is not something this CLI can read anywhere, and a request that states
the value keeps meaning the same thing if that default ever moves.

A refusal is passed through verbatim. The message field carries the lint
report itself — the same document the lint tool writes when it is run by
hand — and the same refusal reaches callers through `lark-cli api` as
well, where nothing rewrites it. Rendering it to prose here would give
one refusal two formats depending on which command produced it. Each
finding carries the numbers behind its own rule, and which numbers those
are differs per rule, so nothing is decoded that is not used: the report
is what the caller reads.

The refusal is recognised by its error code, 4000153, which the engine
raises for nothing else and which reaches the CLI unchanged. Matching on
the shape of the message instead would mean claiming any JSON that
resembles a report, and a false positive there rewrites the hint of an
error this code does not understand.

What the backend cannot say goes in the hint instead: how many findings
refused the write, that the page did not land, and --no-lint, which is a
CLI flag the server has never heard of. The count is summary.error_count
rather than the number of findings, because errors are what refuse a
page — the same line the lint tool draws when it is run by hand, exiting
non-zero on error_count alone. A report can arrive with warnings beside
its errors, and counting those too would send the caller hunting for
blockers that are not there. A message that does not parse still gets
the hint: the escape hatch is the half of it they cannot get anywhere
else, and withholding it over a missing number helps nobody.

The hint names no page. Every write path submits exactly one page, so a
finding's slide_number is its position inside that submission and is
always 1 — which is not the page the caller is looking for. On +create
it is actively wrong: it would read "on slide 1" next to a progress line
saying "adding slide 2/3 failed". The page number has one source, and it
is that line.

Findings that did not refuse the write come back the other way. The
backend returns them in an issues field on a response that succeeded, and
all four shortcuts now pass that field through untouched rather than
dropping it. It only ever arrives on a page that was written: anything
serious enough to refuse the write left as the error above, carrying the
same report. Dropping it would leave the caller believing the deck says
exactly what they wrote, with no way to learn otherwise short of looking
at the rendered page. It is passed through rather than reformatted so
that one field reads the same however the page was written.

+create keeps adding its pages one at a time, so a refusal there can
arrive with the presentation and some of its pages already written. It
is reported as such: the error carries the lint report and, next to it,
which page was refused and how many landed before it, so the retry adds
the rest instead of building a second deck. +replace-pages does the same
for the items in its plan.

A batch that was told to keep going reports its failures only through the
per-item records, so those carry the report, the code and the flag hint
as well; a record built from the error's message alone would have named
neither the refusal nor the way past it. The position stays on the
returning path, where it is the only thing that says how far the batch
got — beside a per-item record it would describe a batch that did not
stop.

Tests assert on the wire — the body the stub actually received — rather
than on the builder's return value, so a command that stops calling its
own builder still fails.
2026-09-03 14:04:05 +08:00

436 lines
18 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
defaultPresentationWidth = 960
defaultPresentationHeight = 540
maxSlidesPerCreate = 10
)
// SlidesCreate creates a new Lark Slides presentation with bot auto-grant.
//
// The presentation is created first as an empty shell, then the pages are added
// one at a time. Each page is linted on its own way in, so a bad page is refused
// with the findings for that page — and the run stops there, leaving the
// presentation and the pages added before it. The error says so, so the caller
// knows what exists and where the run stopped.
var SlidesCreate = common.Shortcut{
Service: "slides",
Command: "+create",
Description: "Create a Lark Slides presentation",
Risk: "write",
AuthTypes: []string{"user", "bot"},
// docs:document.media:upload is required by the @-placeholder upload path.
// Declared up-front (matching the convention used by other multi-API shortcuts
// like wiki_move) so the pre-flight check fails fast and lark-cli's
// auth login --scope hint guides the user, instead of leaving an orphaned
// empty presentation when the in-flight upload 403s.
// NB: no drive scope here on purpose — slides creation never touches drive;
// the presentation URL is built locally (see Execute), so we don't gate a
// drive-free operation behind a drive scope.
Scopes: []string{"slides:presentation:create", "slides:presentation:write_only", "docs:document.media:upload"},
Flags: []common.Flag{
{Name: "title", Desc: "presentation title"},
// The "(supports @file, - reads stdin ...)" suffix is appended from Input
// by the framework, so it is not spelled out in Desc.
{Name: "slides", Desc: "slide content JSON array (each element is a <slide> XML string, max 10; for more pages, create first then add them one at a time with slides +add-slide). <img src=\"@./local.png\"> placeholders are auto-uploaded and replaced with file_token.", Input: []string{common.File, common.Stdin}},
{Name: "slide", Type: "string_array", Desc: "one complete <slide> XML document, or @path to read one from a file; repeat once per page (max 10) and the CLI assembles the array for you, so no JSON escaping is needed. <img src=\"@./local.png\"> placeholders are handled as with --slides. Mutually exclusive with --slides."},
noLintFlag(),
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
slides, param, err := createSlideContents(runtime)
if err != nil {
return err
}
if len(slides) == 0 {
return nil
}
// Validate placeholder paths up front so we don't create a presentation
// only to fail mid-way on a missing local file.
return validateImagePlaceholderFiles(runtime, param, extractImagePlaceholderPaths(slides))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
title := effectiveTitle(runtime.Str("title"))
slides, _, err := createSlideContents(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
createBody := map[string]interface{}{
"xml_presentation": map[string]interface{}{"content": buildPresentationXML(title)},
}
placeholders := extractImagePlaceholderPaths(slides)
// The note belongs to the create step, which is what the grant follows.
// Adding it at the end instead would land on whichever step happened to
// be last and overwrite that step's own description.
botNote := ""
if runtime.IsBot() {
botNote = " After creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new presentation."
}
dry := common.NewDryRunAPI()
if len(slides) == 0 {
dry.Desc("Create empty presentation").
POST("/open-apis/slides_ai/v1/xml_presentations").
Desc(strings.TrimSpace(botNote)).
Body(createBody)
} else {
n := len(slides)
total := n + 1 + len(placeholders)
descSuffix := ""
if len(placeholders) > 0 {
descSuffix = fmt.Sprintf(" + upload %d image(s)", len(placeholders))
}
dry.Desc(fmt.Sprintf("Create presentation%s + add %d slide(s)", descSuffix, n)).
POST("/open-apis/slides_ai/v1/xml_presentations").
Desc(fmt.Sprintf("[1/%d] Create presentation.%s", total, botNote)).
Body(createBody)
// Upload steps come right after creation so they can use the new
// presentation_id as parent_node.
for i, path := range placeholders {
appendSlidesUploadDryRun(dry, path, "<xml_presentation_id>", slideFileParentType, i+2)
}
slideStepStart := 2 + len(placeholders)
slideDescSuffix := ""
if len(placeholders) > 0 {
slideDescSuffix = " (img placeholders auto-replaced)"
}
for i, slideXML := range slides {
dry.POST("/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide").
Desc(fmt.Sprintf("[%d/%d] Add slide %d%s", slideStepStart+i, total, i+1, slideDescSuffix)).
Params(createSlideQuery()).
Body(createSlideBody(slideXML, runtime))
}
}
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
title := effectiveTitle(runtime.Str("title"))
content := buildPresentationXML(title)
// Resolve @path inputs before the create call: a bad path must not leave
// an orphaned empty presentation behind.
slides, param, err := createSlideContents(runtime)
if err != nil {
return err
}
placeholders := extractImagePlaceholderPaths(slides)
// Step 1: Create presentation. The shell carries no page, so there is
// nothing here for the server to lint and no lint switch to send.
data, err := runtime.CallAPITyped(
"POST",
"/open-apis/slides_ai/v1/xml_presentations",
nil,
map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": content,
},
},
)
if err != nil {
return err
}
presentationID := common.GetString(data, "xml_presentation_id")
if presentationID == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides create returned no xml_presentation_id")
}
result := map[string]interface{}{
"xml_presentation_id": presentationID,
"title": title,
}
if revisionID := common.GetFloat(data, "revision_id"); revisionID > 0 {
result["revision_id"] = int(revisionID)
}
if issues, ok := data["issues"]; ok {
result["issues"] = issues
}
// Step 2: Add slides if provided
if len(slides) > 0 {
// Step 1.5: Upload any @path placeholders, then rewrite slide XML
// with the resulting file_tokens. Uploads run after creation so
// they can use the new presentation_id as parent_node.
if len(placeholders) > 0 {
tokens, uploaded, err := uploadSlidesPlaceholders(runtime, presentationID, placeholders, param)
if err != nil {
return appendSlidesProgressHint(err, fmt.Sprintf("presentation %s was created; %d image(s) uploaded before failure", presentationID, uploaded))
}
for i := range slides {
slides[i] = replaceImagePlaceholders(slides[i], tokens)
}
result["images_uploaded"] = uploaded
}
// Each page is linted on its way in, so the first refusal stops the
// run with the pages before it already on the server — which is what
// the progress hint on the error spells out.
slideURL := fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide",
validate.EncodePathSegment(presentationID),
)
var slideIDs []string
var slideIssues []map[string]interface{}
for i, slideXML := range slides {
slideData, err := runtime.CallAPITyped(
"POST",
slideURL,
createSlideQuery(),
createSlideBody(slideXML, runtime),
)
if err != nil {
return appendSlidesProgressHint(enrichSlidesLintError(err), fmt.Sprintf("adding slide %d/%d failed; presentation %s was created, %d slide(s) added before failure", i+1, len(slides), presentationID, i))
}
sid := common.GetString(slideData, "slide_id")
if sid != "" {
slideIDs = append(slideIDs, sid)
}
if issues, ok := slideData["issues"]; ok {
slideIssues = append(slideIssues, map[string]interface{}{
"slide_index": i + 1,
"slide_id": sid,
"issues": issues,
})
}
}
result["slide_ids"] = slideIDs
result["slides_added"] = len(slideIDs)
if len(slideIssues) > 0 {
result["slide_issues"] = slideIssues
}
}
// Prefer the URL returned by presentation.create. Fall back to a local
// brand-standard URL only when the API omits it.
presentationURL := common.GetString(data, "url")
if presentationURL == "" {
presentationURL = common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID)
}
if presentationURL != "" {
result["url"] = presentationURL
if len(slides) == 0 {
result["message"] = fmt.Sprintf("成功创建空白幻灯片,url:%s,请给用户推送开工通知。", presentationURL)
}
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil {
result["permission_grant"] = grant
}
runtime.Out(result, nil)
return nil
},
}
// createSlideContents resolves the page XML for +create from whichever input
// form the caller used, and returns it alongside the flag name to blame in
// errors. An empty result means "create an empty presentation".
//
// Two forms, because assembling the JSON array by hand is what callers keep
// getting wrong: --slides takes the finished array (now also from @file/stdin),
// --slide is repeated once per page and the CLI builds the array. The forms are
// mutually exclusive — merging them would make page order depend on flag
// ordering rules nobody wants to reason about.
func createSlideContents(runtime *common.RuntimeContext) ([]string, string, error) {
// Whether --slides was typed, not whether its value is non-empty: an empty
// value is the shape a failed command substitution takes
// (--slides "$(...)"), and silently reading it as "no pages given" is how a
// caller ends up with a blank deck reported as success. Empty is not valid
// JSON, so routing it into the parse below rejects it by itself.
slidesJSON := runtime.Str("slides")
slidesGiven := runtime.Cmd.Flags().Changed("slides")
slideArgs := runtime.StrArray("slide")
if slidesGiven && len(slideArgs) > 0 {
return nil, "--slide", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--slide and --slides cannot be combined; pass the whole array with --slides, or one page per --slide").
WithParam("--slide")
}
var (
slides []string
param string
)
switch {
case slidesGiven:
param = "--slides"
// A null literal is valid JSON for any slice, so it parses without error
// and leaves slides nil. Reading that as "no pages given" is the same
// blank-deck-reported-as-success an empty value would cause, so it is
// rejected the same way. `[]` stays valid: that array is explicitly empty.
if err := json.Unmarshal([]byte(slidesJSON), &slides); err != nil || slides == nil {
return nil, param, errs.NewValidationError(errs.SubtypeInvalidArgument, "--slides invalid JSON, must be an array of XML strings").
WithParam("--slides").
WithHint("to pass pages as XML files instead of building the array, repeat --slide @page.xml once per page")
}
case len(slideArgs) > 0:
param = "--slide"
var err error
if slides, err = readSlideArgs(runtime, slideArgs); err != nil {
return nil, param, err
}
default:
return nil, "--slides", nil
}
if len(slides) > maxSlidesPerCreate {
return nil, param, errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s exceeds maximum of %d slides per create; create the presentation first, then add the remaining pages one at a time with slides +add-slide",
param, maxSlidesPerCreate).WithParam(param)
}
// Structural checks run on the assembled array, so both input forms fail the
// same way. The backend rejects a non-<slide> payload with an opaque 3350001
// after the presentation already exists; catching it here keeps the deck from
// being created at all.
for i, slideXML := range slides {
slides[i] = strings.TrimSpace(slideXML)
if slides[i] == "" {
return nil, param, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: page %d is empty", param, i+1).WithParam(param)
}
if err := validateCompleteSlideXML(slides[i]); err != nil {
return nil, param, errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s: page %d is not a single complete <slide> document: %v", param, i+1, err).WithParam(param).WithCause(err)
}
}
return slides, param, nil
}
// readSlideArgs turns each --slide value into page XML, reading @path values
// from disk. The framework only resolves Flag.Input for single-valued string
// flags, so a repeatable flag has to do it here — deliberately through the same
// reader, which enforces the "relative path under the current directory" rule.
func readSlideArgs(runtime *common.RuntimeContext, args []string) ([]string, error) {
slides := make([]string, 0, len(args))
for i, raw := range args {
raw = strings.TrimSpace(raw)
switch {
case raw == "-":
// A process has one stdin, so "-" cannot mean "this occurrence" on a
// repeatable flag. --slides reads stdin as the whole array.
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide does not support stdin (-)").
WithParam("--slide").
WithHint("pass each page as --slide @page.xml, or pipe the whole JSON array into --slides -")
case strings.HasPrefix(raw, "@@"):
// Same escape the framework applies: @@ is a literal leading @.
slides = append(slides, raw[1:])
case strings.HasPrefix(raw, "@"):
path := strings.TrimSpace(raw[1:])
if path == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide: file path cannot be empty after @ (page %d)", i+1).WithParam("--slide")
}
data, err := cmdutil.ReadInputFile(runtime.FileIO(), path)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide: %v", err).WithParam("--slide").WithCause(err)
}
// Same normalization the framework applies to Input flags: a
// repeatable flag resolves @path itself, so it has to strip the BOM
// itself too, otherwise --slide @page.xml rejects a file that
// --slides @deck.json accepts.
slides = append(slides, common.StripUTF8BOM(string(data)))
default:
slides = append(slides, raw)
}
}
return slides, nil
}
// effectiveTitle returns the title to use, falling back to "Untitled".
func effectiveTitle(title string) string {
if title == "" {
return "Untitled"
}
return title
}
// createSlideQuery builds the query for the per-page calls +create makes after
// the presentation exists. revision_id is pinned to -1 (latest) rather than
// exposed: the deck was created by this same command a moment ago, so there is
// no earlier revision a caller could sensibly target.
func createSlideQuery() map[string]interface{} {
return map[string]interface{}{"revision_id": -1}
}
// createSlideBody builds the per-page body shared by dry-run and execute, so
// the two cannot drift on the lint switch the way two literals would.
//
// The presentation-create call has no body of its own to stamp: it sends the
// title-only <presentation> shell from buildPresentationXML, and there is no
// page in it for the server to lint.
func createSlideBody(slideXML string, runtime *common.RuntimeContext) map[string]interface{} {
return withLintXML(map[string]interface{}{
"slide": map[string]interface{}{"content": slideXML},
}, runtime)
}
// buildPresentationXML builds the minimal XML for a new empty presentation.
func buildPresentationXML(title string) string {
escapedTitle := xmlEscape(title)
if escapedTitle == "" {
escapedTitle = "Untitled"
}
return fmt.Sprintf(
`<presentation xmlns="https://www.larkoffice.com/sml/2.0" width="%d" height="%d"><title>%s</title></presentation>`,
defaultPresentationWidth, defaultPresentationHeight, escapedTitle,
)
}
// uploadSlidesPlaceholders uploads each unique placeholder path against the
// presentation and returns the path→file_token map. The second return value is
// the number of files successfully uploaded before any error, so callers can
// surface progress in the failure message. param names the flag the XML came
// from, so an error points at the flag the caller actually typed.
func uploadSlidesPlaceholders(runtime *common.RuntimeContext, presentationID string, paths []string, param string) (map[string]string, int, error) {
tokens := make(map[string]string, len(paths))
for i, path := range paths {
stat, err := runtime.FileIO().Stat(path)
if err != nil {
return tokens, i, slidesInputStatError(err, param, fmt.Sprintf("@%s", path))
}
if !stat.Mode().IsRegular() {
return tokens, i, errs.NewValidationError(errs.SubtypeInvalidArgument, "@%s: must be a regular file", path).WithParam(param)
}
fileName := filepath.Base(path)
token, err := uploadSlidesMedia(runtime, path, fileName, stat.Size(), presentationID)
if err != nil {
return tokens, i, fmt.Errorf("@%s: %w", path, err) //nolint:forbidigo // intermediate; preserves typed cause via %w, reclassified by appendSlidesProgressHint at the call site
}
tokens[path] = token
}
return tokens, len(paths), nil
}
// xmlEscape escapes special XML characters in text content.
func xmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "'", "&apos;")
return s
}