Skip to content
Open
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
364 changes: 364 additions & 0 deletions shortcuts/slides/helpers_shortcuts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,364 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package slides

import (
"encoding/json"
"fmt"
"io"
"path/filepath"
"sort"
"strings"

"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"

Check failure on line 16 in shortcuts/slides/helpers_shortcuts.go

View workflow job for this annotation

GitHub Actions / lint

import 'github.com/larksuite/cli/internal/vfs' is not allowed from list 'shortcuts-no-vfs': shortcuts must not import internal/vfs directly. Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation. (depguard)
"github.com/larksuite/cli/shortcuts/common"
)

type localUploadSpec struct {
InputPath string
FileName string
Size int64
}

type composeManifest struct {
Title string `json:"title"`
Slides []composeManifestSlide `json:"slides"`
}

type composeManifestSlide struct {
File string `json:"file"`
Content string `json:"content"`
}

type slideSource struct {
Name string
Content string
}

func validateAssetRoot(runtime *common.RuntimeContext, assetRoot string) (string, error) {
assetRoot = strings.TrimSpace(assetRoot)
if assetRoot == "" {
return "", nil
}

stat, err := runtime.FileIO().Stat(assetRoot)
if err != nil {
return "", common.WrapInputStatError(err, "--asset-root not found")
}
if !stat.IsDir() {
return "", output.ErrValidation("--asset-root must be a directory: %s", assetRoot)
}
return filepath.Clean(assetRoot), nil
}

func maybeResolveAgainstRoot(root, raw string) string {
cleaned := filepath.Clean(strings.TrimSpace(raw))
if root == "" {
return cleaned
}

root = filepath.Clean(root)
if cleaned == root || strings.HasPrefix(cleaned, root+string(filepath.Separator)) {
return cleaned
}
return filepath.Join(root, cleaned)
}

func validateLocalUpload(runtime *common.RuntimeContext, inputPath string, sizeLabel string) (localUploadSpec, error) {
stat, err := runtime.FileIO().Stat(inputPath)
if err != nil {
return localUploadSpec{}, common.WrapInputStatError(err, sizeLabel)
}
if !stat.Mode().IsRegular() {
return localUploadSpec{}, output.ErrValidation("%s: must be a regular file", inputPath)
}
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
return localUploadSpec{}, output.ErrValidation("%s: file size %s exceeds 20 MB limit for slides image upload",
inputPath, common.FormatSize(stat.Size()))
}
return localUploadSpec{
InputPath: inputPath,
FileName: filepath.Base(inputPath),
Size: stat.Size(),
}, nil
}

func collectSlidePlaceholderUploads(runtime *common.RuntimeContext, slideXMLs []string, assetRoot string) ([]localUploadSpec, map[string]string, error) {
rawPaths := extractImagePlaceholderPaths(slideXMLs)
if len(rawPaths) == 0 {
return nil, nil, nil
}

placeholderToUpload := make(map[string]string, len(rawPaths))
seenUploads := make(map[string]bool, len(rawPaths))
uploads := make([]localUploadSpec, 0, len(rawPaths))

for _, rawPath := range rawPaths {
uploadPath := maybeResolveAgainstRoot(assetRoot, rawPath)
spec, err := validateLocalUpload(runtime, uploadPath, fmt.Sprintf("@%s: file not found", rawPath))
if err != nil {
return nil, nil, err
}
placeholderToUpload[rawPath] = uploadPath
if seenUploads[uploadPath] {
continue
}
seenUploads[uploadPath] = true
uploads = append(uploads, spec)
}

return uploads, placeholderToUpload, nil
}

func uploadLocalFiles(runtime *common.RuntimeContext, presentationID string, uploads []localUploadSpec) (map[string]string, int, error) {
tokens := make(map[string]string, len(uploads))
for i, upload := range uploads {
fmt.Fprintf(runtime.IO().ErrOut, "Uploading image %d/%d: %s (%s)\n",
i+1, len(uploads), upload.FileName, common.FormatSize(upload.Size))

token, err := uploadSlidesMedia(runtime, upload.InputPath, upload.FileName, upload.Size, presentationID)
if err != nil {
return tokens, i, fmt.Errorf("%s: %w", upload.InputPath, err)
}
tokens[upload.InputPath] = token
}
return tokens, len(uploads), nil
}

func mapPlaceholderTokens(placeholderToUpload map[string]string, uploadTokens map[string]string) map[string]string {
if len(placeholderToUpload) == 0 || len(uploadTokens) == 0 {
return nil
}
placeholderTokens := make(map[string]string, len(placeholderToUpload))
for rawPath, uploadPath := range placeholderToUpload {
if token := uploadTokens[uploadPath]; token != "" {
placeholderTokens[rawPath] = token
}
}
return placeholderTokens
}

func validateSlidesDir(runtime *common.RuntimeContext, dir string) (string, error) {
if strings.TrimSpace(dir) == "" {
return "", output.ErrValidation("--slides-dir cannot be empty")
}
stat, err := runtime.FileIO().Stat(dir)
if err != nil {
return "", common.WrapInputStatError(err, "--slides-dir not found")
}
if !stat.IsDir() {
return "", output.ErrValidation("--slides-dir must be a directory: %s", dir)
}
resolved, err := validate.SafeInputPath(dir)
if err != nil {
return "", output.ErrValidation("--slides-dir: %v", err)
}
return resolved, nil
}

func readTextFile(runtime *common.RuntimeContext, path string, label string) (string, error) {
f, err := runtime.FileIO().Open(path)
if err != nil {
return "", common.WrapInputStatError(err, label)
}
defer f.Close()

data, err := io.ReadAll(f)
if err != nil {
return "", output.ErrValidation("%s: %v", label, err)
}
if len(data) == 0 {
return "", output.ErrValidation("%s is empty: %s", label, path)
}
return string(data), nil
}

func loadSlidesFromDir(runtime *common.RuntimeContext, dir string) ([]slideSource, error) {
resolvedDir, err := validateSlidesDir(runtime, dir)
if err != nil {
return nil, err
}

entries, err := vfs.ReadDir(resolvedDir)
if err != nil {
return nil, output.Errorf(output.ExitInternal, "io", "cannot read slides directory %s: %v", dir, err)
}

var fileNames []string
for _, entry := range entries {
if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".xml") {
continue
}
fileNames = append(fileNames, entry.Name())
}
sort.Strings(fileNames)
if len(fileNames) == 0 {
return nil, output.ErrValidation("--slides-dir %s contains no .xml slide files", dir)
}

slides := make([]slideSource, 0, len(fileNames))
for _, name := range fileNames {
path := filepath.Join(dir, name)
content, err := readTextFile(runtime, path, "--slides-dir slide file")
if err != nil {
return nil, err
}
slides = append(slides, slideSource{Name: name, Content: content})
}
return slides, nil
}

func parseComposeManifest(raw string) (composeManifest, error) {
if strings.TrimSpace(raw) == "" {
return composeManifest{}, nil
}
var manifest composeManifest
if err := json.Unmarshal([]byte(raw), &manifest); err != nil {
return composeManifest{}, output.ErrValidation("--manifest invalid JSON: %v", err)
}
return manifest, nil
}

func manifestHasSlides(manifest composeManifest) bool {
return len(manifest.Slides) > 0
}

func joinBaseDir(baseDir, path string) string {
path = filepath.Clean(strings.TrimSpace(path))
if baseDir == "" {
return path
}
baseDir = filepath.Clean(baseDir)
if path == baseDir || strings.HasPrefix(path, baseDir+string(filepath.Separator)) {
return path
}
return filepath.Join(baseDir, path)
}

func loadSlidesFromManifest(runtime *common.RuntimeContext, manifest composeManifest, slidesDir string) ([]slideSource, error) {
slides := make([]slideSource, 0, len(manifest.Slides))
for i, entry := range manifest.Slides {
switch {
case strings.TrimSpace(entry.File) != "" && strings.TrimSpace(entry.Content) != "":
return nil, output.ErrValidation("--manifest slides[%d] must specify exactly one of file or content", i)
case strings.TrimSpace(entry.File) != "":
path := joinBaseDir(slidesDir, entry.File)
content, err := readTextFile(runtime, path, fmt.Sprintf("--manifest slides[%d].file", i))
if err != nil {
return nil, err
}
slides = append(slides, slideSource{Name: entry.File, Content: content})
case strings.TrimSpace(entry.Content) != "":
slides = append(slides, slideSource{
Name: fmt.Sprintf("manifest[%d]", i),
Content: entry.Content,
})
default:
return nil, output.ErrValidation("--manifest slides[%d] must include file or content", i)
}
}
if len(slides) == 0 {
return nil, output.ErrValidation("--manifest contains no slides")
}
return slides, nil
}

func resolveComposeSlides(runtime *common.RuntimeContext, slidesDir string, manifest composeManifest) ([]slideSource, error) {
if manifestHasSlides(manifest) {
return loadSlidesFromManifest(runtime, manifest, slidesDir)
}
if strings.TrimSpace(slidesDir) == "" {
return nil, output.ErrValidation("specify --slides-dir or provide slides in --manifest")
}
return loadSlidesFromDir(runtime, slidesDir)
}

func createPresentation(runtime *common.RuntimeContext, title string) (string, int, error) {
data, err := runtime.CallAPI(
"POST",
"/open-apis/slides_ai/v1/xml_presentations",
nil,
map[string]any{
"xml_presentation": map[string]any{
"content": buildPresentationXML(title),
},
},
)
if err != nil {
return "", 0, err
}

presentationID := common.GetString(data, "xml_presentation_id")
if presentationID == "" {
return "", 0, output.Errorf(output.ExitAPI, "api_error", "slides create returned no xml_presentation_id")
}
return presentationID, int(common.GetFloat(data, "revision_id")), nil
}

func createSlide(runtime *common.RuntimeContext, presentationID string, slideXML string, beforeSlideID string) (string, int, error) {
body := map[string]any{
"slide": map[string]any{"content": slideXML},
}
if beforeSlideID != "" {
body["before_slide_id"] = beforeSlideID
}

data, err := runtime.CallAPI(
"POST",
fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(presentationID)),
map[string]any{"revision_id": -1},
body,
)
if err != nil {
return "", 0, err
}
return common.GetString(data, "slide_id"), int(common.GetFloat(data, "revision_id")), nil
}

func deleteSlide(runtime *common.RuntimeContext, presentationID string, slideID string) (int, error) {
data, err := runtime.CallAPI(
"DELETE",
fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(presentationID)),
map[string]any{
"slide_id": slideID,
"revision_id": -1,
},
nil,
)
if err != nil {
return 0, err
}
return int(common.GetFloat(data, "revision_id")), nil
}

func fetchPresentationURL(runtime *common.RuntimeContext, presentationID string) string {
metaData, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,
map[string]any{
"request_docs": []map[string]any{
{
"doc_token": presentationID,
"doc_type": "slides",
},
},
"with_url": true,
},
)
if err != nil {
return ""
}
metas := common.GetSlice(metaData, "metas")
if len(metas) == 0 {
return ""
}
meta, ok := metas[0].(map[string]any)
if !ok {
return ""
}
return common.GetString(meta, "url")
}
3 changes: 3 additions & 0 deletions shortcuts/slides/shortcuts.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all slides shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
SlidesBulkMediaUpload,
SlidesCompose,
SlidesCreate,
SlidesMediaUpload,
SlidesReplaceSlideXML,
}
}
Loading
Loading