Files
larksuite__cli/shortcuts/slides/slides_create.go
2026-08-24 18:26:23 +08:00

402 lines
16 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.
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."},
},
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)},
}
dry := common.NewDryRunAPI()
if len(slides) == 0 {
dry.Desc("Create empty presentation").
POST("/open-apis/slides_ai/v1/xml_presentations").
Body(createBody)
} else {
n := len(slides)
placeholders := extractImagePlaceholderPaths(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", total)).
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)).
Body(map[string]interface{}{
"slide": map[string]interface{}{"content": slideXML},
})
}
}
if runtime.IsBot() {
dry.Desc("After creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new presentation.")
}
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
}
// Step 1: Create presentation
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.
placeholders := extractImagePlaceholderPaths(slides)
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
}
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,
map[string]interface{}{"revision_id": -1},
map[string]interface{}{
"slide": map[string]interface{}{"content": slideXML},
},
)
if err != nil {
return appendSlidesProgressHint(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
}
// 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
}