diff --git a/shortcuts/slides/helpers_shortcuts.go b/shortcuts/slides/helpers_shortcuts.go new file mode 100644 index 000000000..3027add36 --- /dev/null +++ b/shortcuts/slides/helpers_shortcuts.go @@ -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" + "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") +} diff --git a/shortcuts/slides/shortcuts.go b/shortcuts/slides/shortcuts.go index 3de3fdf8d..65170e8b2 100644 --- a/shortcuts/slides/shortcuts.go +++ b/shortcuts/slides/shortcuts.go @@ -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, } } diff --git a/shortcuts/slides/slides_bulk_media_upload.go b/shortcuts/slides/slides_bulk_media_upload.go new file mode 100644 index 000000000..f27a8ee63 --- /dev/null +++ b/shortcuts/slides/slides_bulk_media_upload.go @@ -0,0 +1,218 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/vfs" + "github.com/larksuite/cli/shortcuts/common" +) + +var supportedBulkUploadExtensions = map[string]bool{ + ".png": true, + ".jpg": true, + ".jpeg": true, + ".webp": true, + ".gif": true, + ".bmp": true, + ".svg": true, +} + +// SlidesBulkMediaUpload uploads multiple local images to a slides +// presentation and returns a stable filename -> file_token map. +var SlidesBulkMediaUpload = common.Shortcut{ + Service: "slides", + Command: "+bulk-media-upload", + Description: "Upload multiple local images to a slides presentation and return a filename->file_token map", + Risk: "write", + Scopes: []string{"docs:document.media:upload", "wiki:node:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true}, + {Name: "files", Type: "string_array", Desc: "local image path; repeatable"}, + {Name: "dir", Desc: "local directory to scan for image files (non-recursive)"}, + {Name: "asset-root", Desc: "optional base directory for --files; paths are resolved relative to this directory"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := parsePresentationRef(runtime.Str("presentation")); err != nil { + return err + } + if len(runtime.StrArray("files")) == 0 && runtime.Str("dir") == "" { + return common.FlagErrorf("specify --files or --dir") + } + assetRoot, err := validateAssetRoot(runtime, runtime.Str("asset-root")) + if err != nil { + return err + } + _, _, err = resolveBulkUploadSpecs(runtime, runtime.StrArray("files"), runtime.Str("dir"), assetRoot) + return err + }, + 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()) + } + assetRoot, err := validateAssetRoot(runtime, runtime.Str("asset-root")) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + uploads, filenames, err := resolveBulkUploadSpecs(runtime, runtime.StrArray("files"), runtime.Str("dir"), assetRoot) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + + dry := common.NewDryRunAPI() + parentNode := ref.Token + step := 1 + if ref.Kind == "wiki" { + parentNode = "" + dry.Desc("2-step orchestration: resolve wiki → upload media"). + GET("/open-apis/wiki/v2/spaces/get_node"). + Desc("[1] Resolve wiki node to slides presentation"). + Params(map[string]any{"token": ref.Token}) + step = 2 + } else { + dry.Desc(fmt.Sprintf("Upload %d local file(s) to slides presentation", len(uploads))) + } + + total := len(uploads) + step - 1 + for i, upload := range uploads { + appendSlidesUploadDryRun(dry, upload.InputPath, parentNode, step+i) + dry.Desc(fmt.Sprintf("[%d/%d] Upload %s", step+i, total, upload.FileName)) + } + return dry.Set("presentation_id", ref.Token).Set("file_names", filenames) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { + return err + } + assetRoot, err := validateAssetRoot(runtime, runtime.Str("asset-root")) + if err != nil { + return err + } + uploads, filenames, err := resolveBulkUploadSpecs(runtime, runtime.StrArray("files"), runtime.Str("dir"), assetRoot) + if err != nil { + return err + } + presentationID, err := resolvePresentationID(runtime, ref) + if err != nil { + return err + } + + fileTokens := make(map[string]string, len(uploads)) + uploaded := make([]map[string]any, 0, len(uploads)) + for i, upload := range uploads { + fmt.Fprintf(runtime.IO().ErrOut, "Uploading %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 output.Errorf(output.ExitAPI, "api_error", + "bulk media upload failed on %s after %d/%d file(s): %v", + upload.FileName, i, len(uploads), err) + } + fileTokens[upload.FileName] = token + uploaded = append(uploaded, map[string]any{ + "file_name": upload.FileName, + "file_path": upload.InputPath, + "file_token": token, + "size": upload.Size, + }) + } + + runtime.Out(map[string]any{ + "presentation_id": presentationID, + "uploaded_count": len(uploaded), + "file_names": filenames, + "file_tokens": fileTokens, + "files": uploaded, + }, nil) + return nil + }, +} + +func resolveBulkUploadSpecs(runtime *common.RuntimeContext, rawFiles []string, dir string, assetRoot string) ([]localUploadSpec, []string, error) { + var uploads []localUploadSpec + seenPaths := map[string]bool{} + + addUpload := func(path string) error { + spec, err := validateLocalUpload(runtime, path, "file not found") + if err != nil { + return err + } + if seenPaths[spec.InputPath] { + return nil + } + seenPaths[spec.InputPath] = true + uploads = append(uploads, spec) + return nil + } + + for _, rawFile := range rawFiles { + path := maybeResolveAgainstRoot(assetRoot, rawFile) + if err := addUpload(path); err != nil { + return nil, nil, err + } + } + + if strings.TrimSpace(dir) != "" { + dirUploads, err := collectUploadsFromDir(runtime, dir) + if err != nil { + return nil, nil, err + } + for _, upload := range dirUploads { + if err := addUpload(upload.InputPath); err != nil { + return nil, nil, err + } + } + } + + if len(uploads) == 0 { + return nil, nil, output.ErrValidation("no uploadable files found") + } + + byName := make(map[string]string, len(uploads)) + fileNames := make([]string, 0, len(uploads)) + for _, upload := range uploads { + if prev, exists := byName[upload.FileName]; exists && prev != upload.InputPath { + return nil, nil, output.ErrValidation("duplicate file name %q from %s and %s; rename one file before bulk upload", + upload.FileName, prev, upload.InputPath) + } + byName[upload.FileName] = upload.InputPath + fileNames = append(fileNames, upload.FileName) + } + return uploads, fileNames, nil +} + +func collectUploadsFromDir(runtime *common.RuntimeContext, dir string) ([]localUploadSpec, 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 upload directory %s: %v", dir, err) + } + + var uploads []localUploadSpec + for _, entry := range entries { + if entry.IsDir() || !supportedBulkUploadExtensions[strings.ToLower(filepath.Ext(entry.Name()))] { + continue + } + spec, err := validateLocalUpload(runtime, filepath.Join(dir, entry.Name()), "file not found") + if err != nil { + return nil, err + } + uploads = append(uploads, spec) + } + if len(uploads) == 0 { + return nil, output.ErrValidation("--dir %s contains no supported image files", dir) + } + return uploads, nil +} diff --git a/shortcuts/slides/slides_bulk_media_upload_test.go b/shortcuts/slides/slides_bulk_media_upload_test.go new file mode 100644 index 000000000..d74434e69 --- /dev/null +++ b/shortcuts/slides/slides_bulk_media_upload_test.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "os" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestSlidesBulkMediaUploadWithAssetRoot(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + if err := os.Mkdir("assets", 0o755); err != nil { + t.Fatalf("mkdir assets: %v", err) + } + if err := os.WriteFile("assets/cover.png", []byte("cover"), 0o644); err != nil { + t.Fatalf("write cover: %v", err) + } + if err := os.WriteFile("assets/chart.png", []byte("chart"), 0o644); err != nil { + t.Fatalf("write chart: %v", err) + } + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + uploadStub1 := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok_cover"}}, + } + uploadStub2 := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok_chart"}}, + } + reg.Register(uploadStub1) + reg.Register(uploadStub2) + + err := runSlidesShortcut(t, f, stdout, SlidesBulkMediaUpload, []string{ + "+bulk-media-upload", + "--presentation", "pres_bulk", + "--asset-root", "assets", + "--files", "cover.png", + "--files", "chart.png", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeShortcutData(t, stdout) + if data["presentation_id"] != "pres_bulk" { + t.Fatalf("presentation_id = %v, want pres_bulk", data["presentation_id"]) + } + if data["uploaded_count"] != float64(2) { + t.Fatalf("uploaded_count = %v, want 2", data["uploaded_count"]) + } + + fileTokens, ok := data["file_tokens"].(map[string]interface{}) + if !ok { + t.Fatalf("file_tokens = %#v, want map", data["file_tokens"]) + } + if fileTokens["cover.png"] != "tok_cover" || fileTokens["chart.png"] != "tok_chart" { + t.Fatalf("file_tokens = %#v, want cover/chart tokens", fileTokens) + } + + for _, stub := range []*httpmock.Stub{uploadStub1, uploadStub2} { + body := decodeMultipartBody(t, stub) + if got := body.Fields["parent_node"]; got != "pres_bulk" { + t.Fatalf("parent_node = %q, want pres_bulk", got) + } + } +} + +func TestSlidesBulkMediaUploadRejectsDuplicateFileNames(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + if err := os.MkdirAll("a", 0o755); err != nil { + t.Fatalf("mkdir a: %v", err) + } + if err := os.MkdirAll("b", 0o755); err != nil { + t.Fatalf("mkdir b: %v", err) + } + if err := os.WriteFile("a/shared.png", []byte("a"), 0o644); err != nil { + t.Fatalf("write a/shared.png: %v", err) + } + if err := os.WriteFile("b/shared.png", []byte("b"), 0o644); err != nil { + t.Fatalf("write b/shared.png: %v", err) + } + + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesShortcut(t, f, stdout, SlidesBulkMediaUpload, []string{ + "+bulk-media-upload", + "--presentation", "pres_bulk", + "--files", "a/shared.png", + "--files", "b/shared.png", + "--as", "user", + }) + if err == nil { + t.Fatal("expected duplicate file name validation error") + } + if !strings.Contains(err.Error(), "duplicate file name") { + t.Fatalf("err = %v, want duplicate file name", err) + } +} diff --git a/shortcuts/slides/slides_compose.go b/shortcuts/slides/slides_compose.go new file mode 100644 index 000000000..af49d08d4 --- /dev/null +++ b/shortcuts/slides/slides_compose.go @@ -0,0 +1,177 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "context" + "fmt" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// SlidesCompose creates a new presentation from a directory or manifest of +// slide XML files and auto-uploads local @path image placeholders. +var SlidesCompose = common.Shortcut{ + Service: "slides", + Command: "+compose", + Description: "Create a slides presentation from slide XML files with automatic local image upload", + Risk: "write", + AuthTypes: []string{"user", "bot"}, + Scopes: []string{"slides:presentation:create", "slides:presentation:write_only", "docs:document.media:upload"}, + Flags: []common.Flag{ + {Name: "title", Desc: "presentation title (defaults to manifest title or Untitled)"}, + {Name: "slides-dir", Desc: "directory containing .xml slide files, applied in lexical order"}, + {Name: "manifest", Desc: "compose manifest JSON", Input: []string{common.File, common.Stdin}}, + {Name: "asset-root", Desc: "optional base directory for @path image placeholders"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + manifest, err := parseComposeManifest(runtime.Str("manifest")) + if err != nil { + return err + } + assetRoot, err := validateAssetRoot(runtime, runtime.Str("asset-root")) + if err != nil { + return err + } + slides, err := resolveComposeSlides(runtime, runtime.Str("slides-dir"), manifest) + if err != nil { + return err + } + _, _, err = collectSlidePlaceholderUploads(runtime, slideContents(slides), assetRoot) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + manifest, err := parseComposeManifest(runtime.Str("manifest")) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + assetRoot, err := validateAssetRoot(runtime, runtime.Str("asset-root")) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + slides, err := resolveComposeSlides(runtime, runtime.Str("slides-dir"), manifest) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + uploads, _, err := collectSlidePlaceholderUploads(runtime, slideContents(slides), assetRoot) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + + title := effectiveTitle(runtime.Str("title")) + if title == "Untitled" && manifest.Title != "" { + title = effectiveTitle(manifest.Title) + } + + total := 1 + len(uploads) + len(slides) + dry := common.NewDryRunAPI(). + Desc(fmt.Sprintf("Create presentation from %d slide(s)", len(slides))). + POST("/open-apis/slides_ai/v1/xml_presentations"). + Desc(fmt.Sprintf("[1/%d] Create presentation", total)). + Body(map[string]any{ + "xml_presentation": map[string]any{"content": buildPresentationXML(title)}, + }) + + for i, upload := range uploads { + appendSlidesUploadDryRun(dry, upload.InputPath, "", i+2) + } + + stepStart := 2 + len(uploads) + for i, slide := range slides { + dry.POST("/open-apis/slides_ai/v1/xml_presentations//slide"). + Desc(fmt.Sprintf("[%d/%d] Add slide %d from %s", stepStart+i, total, i+1, slide.Name)). + Body(map[string]any{ + "slide": map[string]any{"content": slide.Content}, + }) + } + + 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.Set("title", title).Set("slide_count", len(slides)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + manifest, err := parseComposeManifest(runtime.Str("manifest")) + if err != nil { + return err + } + assetRoot, err := validateAssetRoot(runtime, runtime.Str("asset-root")) + if err != nil { + return err + } + slides, err := resolveComposeSlides(runtime, runtime.Str("slides-dir"), manifest) + if err != nil { + return err + } + uploads, placeholderToUpload, err := collectSlidePlaceholderUploads(runtime, slideContents(slides), assetRoot) + if err != nil { + return err + } + + title := effectiveTitle(runtime.Str("title")) + if title == "Untitled" && manifest.Title != "" { + title = effectiveTitle(manifest.Title) + } + presentationID, revisionID, err := createPresentation(runtime, title) + if err != nil { + return err + } + + result := map[string]any{ + "xml_presentation_id": presentationID, + "title": title, + } + if revisionID > 0 { + result["revision_id"] = revisionID + } + + if len(uploads) > 0 { + uploadTokens, uploaded, err := uploadLocalFiles(runtime, presentationID, uploads) + if err != nil { + return output.Errorf(output.ExitAPI, "api_error", + "image upload failed: %v (presentation %s was created; %d image(s) uploaded before failure)", + err, presentationID, uploaded) + } + placeholderTokens := mapPlaceholderTokens(placeholderToUpload, uploadTokens) + for i := range slides { + slides[i].Content = replaceImagePlaceholders(slides[i].Content, placeholderTokens) + } + result["images_uploaded"] = uploaded + } + + slideIDs := make([]string, 0, len(slides)) + for i, slide := range slides { + slideID, _, err := createSlide(runtime, presentationID, slide.Content, "") + if err != nil { + return output.Errorf(output.ExitAPI, "api_error", + "slide %d/%d (%s) failed: %v (presentation %s was created; %d slide(s) added before failure)", + i+1, len(slides), slide.Name, err, presentationID, i) + } + if slideID != "" { + slideIDs = append(slideIDs, slideID) + } + } + result["slide_ids"] = slideIDs + result["slides_added"] = len(slideIDs) + + if url := fetchPresentationURL(runtime, presentationID); url != "" { + result["url"] = url + } + if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil { + result["permission_grant"] = grant + } + + runtime.Out(result, nil) + return nil + }, +} + +func slideContents(slides []slideSource) []string { + out := make([]string, 0, len(slides)) + for _, slide := range slides { + out = append(out, slide.Content) + } + return out +} diff --git a/shortcuts/slides/slides_compose_test.go b/shortcuts/slides/slides_compose_test.go new file mode 100644 index 000000000..cd32909be --- /dev/null +++ b/shortcuts/slides/slides_compose_test.go @@ -0,0 +1,154 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestSlidesComposeFromDirWithAssetRoot(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + if err := os.Mkdir("slides", 0o755); err != nil { + t.Fatalf("mkdir slides: %v", err) + } + if err := os.Mkdir("assets", 0o755); err != nil { + t.Fatalf("mkdir assets: %v", err) + } + if err := os.WriteFile("assets/hero.png", []byte("hero"), 0o644); err != nil { + t.Fatalf("write hero.png: %v", err) + } + if err := os.WriteFile("slides/01-cover.xml", []byte(``), 0o644); err != nil { + t.Fatalf("write slide 1: %v", err) + } + if err := os.WriteFile("slides/02-body.xml", []byte(`

Body

`), 0o644); err != nil { + t.Fatalf("write slide 2: %v", err) + } + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "xml_presentation_id": "pres_compose", + "revision_id": 1, + }, + }, + }) + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok_hero"}}, + } + reg.Register(uploadStub) + slideStub1 := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_compose/slide", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_1", "revision_id": 2}}, + } + slideStub2 := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_compose/slide", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_2", "revision_id": 3}}, + } + reg.Register(slideStub1) + reg.Register(slideStub2) + registerBatchQueryStub(reg, "pres_compose", "https://x.feishu.cn/slides/pres_compose") + + err := runSlidesShortcut(t, f, stdout, SlidesCompose, []string{ + "+compose", + "--title", "Compose Demo", + "--slides-dir", "slides", + "--asset-root", "assets", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeShortcutData(t, stdout) + if data["xml_presentation_id"] != "pres_compose" { + t.Fatalf("xml_presentation_id = %v, want pres_compose", data["xml_presentation_id"]) + } + if data["images_uploaded"] != float64(1) { + t.Fatalf("images_uploaded = %v, want 1", data["images_uploaded"]) + } + if data["slides_added"] != float64(2) { + t.Fatalf("slides_added = %v, want 2", data["slides_added"]) + } + + var body map[string]interface{} + if err := json.Unmarshal(slideStub1.CapturedBody, &body); err != nil { + t.Fatalf("decode slide body: %v", err) + } + slide, _ := body["slide"].(map[string]interface{}) + content, _ := slide["content"].(string) + if !strings.Contains(content, "tok_hero") || strings.Contains(content, "@hero.png") { + t.Fatalf("slide content = %q, want tokenized image", content) + } +} + +func TestSlidesComposeManifestTitleFallback(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + manifest := `{"title":"Manifest Title","slides":[{"content":""}]}` + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "xml_presentation_id": "pres_manifest", + "revision_id": 1, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_manifest/slide", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_1", "revision_id": 2}}, + }) + registerBatchQueryStub(reg, "pres_manifest", "https://x.feishu.cn/slides/pres_manifest") + + err := runSlidesShortcut(t, f, stdout, SlidesCompose, []string{ + "+compose", + "--manifest", manifest, + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeShortcutData(t, stdout) + if data["title"] != "Manifest Title" { + t.Fatalf("title = %v, want Manifest Title", data["title"]) + } +} + +func TestSlidesComposeRejectsMissingSlideSources(t *testing.T) { + t.Parallel() + + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesShortcut(t, f, stdout, SlidesCompose, []string{ + "+compose", + "--title", "No Slides", + "--as", "user", + }) + if err == nil { + t.Fatal("expected validation error when no slides provided") + } + if !strings.Contains(err.Error(), "--slides-dir") && !strings.Contains(err.Error(), "--manifest") { + t.Fatalf("err = %v, want mention of slide source flags", err) + } +} diff --git a/shortcuts/slides/slides_replace_slide_xml.go b/shortcuts/slides/slides_replace_slide_xml.go new file mode 100644 index 000000000..beee3bfe7 --- /dev/null +++ b/shortcuts/slides/slides_replace_slide_xml.go @@ -0,0 +1,143 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "context" + "fmt" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// SlidesReplaceSlideXML rebuilds a slide in place by creating a replacement +// immediately before the old slide and then deleting the old slide. +var SlidesReplaceSlideXML = common.Shortcut{ + Service: "slides", + Command: "+replace-slide-xml", + Description: "Replace an existing slide by creating a new slide before it and deleting the old one", + Risk: "write", + AuthTypes: []string{"user", "bot"}, + Scopes: []string{"slides:presentation:update", "slides:presentation:write_only", "docs:document.media:upload", "wiki:node:read"}, + Flags: []common.Flag{ + {Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true}, + {Name: "slide-id", Desc: "existing slide_id to replace", Required: true}, + {Name: "content", Desc: "replacement slide XML", Required: true, Input: []string{common.File, common.Stdin}}, + {Name: "asset-root", Desc: "optional base directory for @path image placeholders"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := parsePresentationRef(runtime.Str("presentation")); err != nil { + return err + } + assetRoot, err := validateAssetRoot(runtime, runtime.Str("asset-root")) + if err != nil { + return err + } + _, _, err = collectSlidePlaceholderUploads(runtime, []string{runtime.Str("content")}, assetRoot) + return err + }, + 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()) + } + assetRoot, err := validateAssetRoot(runtime, runtime.Str("asset-root")) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + uploads, _, err := collectSlidePlaceholderUploads(runtime, []string{runtime.Str("content")}, assetRoot) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + + dry := common.NewDryRunAPI() + parentNode := ref.Token + step := 1 + if ref.Kind == "wiki" { + parentNode = "" + dry.Desc("Resolve wiki node, upload local assets, create replacement slide, then delete old slide"). + GET("/open-apis/wiki/v2/spaces/get_node"). + Desc("[1] Resolve wiki node to slides presentation"). + Params(map[string]any{"token": ref.Token}) + step = 2 + } else { + dry.Desc("Upload local assets, create replacement slide, then delete old slide") + } + + total := len(uploads) + step + 1 + for i, upload := range uploads { + appendSlidesUploadDryRun(dry, upload.InputPath, parentNode, step+i) + } + createStep := step + len(uploads) + deleteStep := createStep + 1 + dry.POST("/open-apis/slides_ai/v1/xml_presentations//slide"). + Desc(fmt.Sprintf("[%d/%d] Create replacement slide before old slide", createStep, total)). + Body(map[string]any{ + "slide": map[string]any{"content": runtime.Str("content")}, + "before_slide_id": runtime.Str("slide-id"), + }) + dry.DELETE("/open-apis/slides_ai/v1/xml_presentations//slide"). + Desc(fmt.Sprintf("[%d/%d] Delete old slide", deleteStep, total)). + Params(map[string]any{"slide_id": runtime.Str("slide-id"), "revision_id": -1}) + return dry.Set("presentation_id", ref.Token).Set("slide_id", runtime.Str("slide-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { + return err + } + assetRoot, err := validateAssetRoot(runtime, runtime.Str("asset-root")) + if err != nil { + return err + } + content := runtime.Str("content") + uploads, placeholderToUpload, err := collectSlidePlaceholderUploads(runtime, []string{content}, assetRoot) + if err != nil { + return err + } + presentationID, err := resolvePresentationID(runtime, ref) + if err != nil { + return err + } + + if len(uploads) > 0 { + uploadTokens, uploaded, err := uploadLocalFiles(runtime, presentationID, uploads) + if err != nil { + return output.Errorf(output.ExitAPI, "api_error", + "image upload failed before slide replacement: %v (presentation %s unchanged; %d image(s) uploaded before failure)", + err, presentationID, uploaded) + } + content = replaceImagePlaceholders(content, mapPlaceholderTokens(placeholderToUpload, uploadTokens)) + } + + oldSlideID := runtime.Str("slide-id") + newSlideID, createRevisionID, err := createSlide(runtime, presentationID, content, oldSlideID) + if err != nil { + return output.Errorf(output.ExitAPI, "api_error", "create replacement slide failed for %s: %v", oldSlideID, err) + } + + deleteRevisionID, err := deleteSlide(runtime, presentationID, oldSlideID) + if err != nil { + return output.Errorf(output.ExitAPI, "api_error", + "replacement slide %s was created before deleting old slide %s failed: %v", + newSlideID, oldSlideID, err) + } + + result := map[string]any{ + "presentation_id": presentationID, + "replaced_slide_id": oldSlideID, + "replacement_slide_id": newSlideID, + } + if createRevisionID > 0 { + result["create_revision_id"] = createRevisionID + } + if deleteRevisionID > 0 { + result["delete_revision_id"] = deleteRevisionID + } + result["images_uploaded"] = len(uploads) + + runtime.Out(result, nil) + return nil + }, +} diff --git a/shortcuts/slides/slides_replace_slide_xml_test.go b/shortcuts/slides/slides_replace_slide_xml_test.go new file mode 100644 index 000000000..2b8a7ffb9 --- /dev/null +++ b/shortcuts/slides/slides_replace_slide_xml_test.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestSlidesReplaceSlideXMLWithAssetRoot(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + if err := os.Mkdir("assets", 0o755); err != nil { + t.Fatalf("mkdir assets: %v", err) + } + if err := os.WriteFile("assets/replacement.png", []byte("img"), 0o644); err != nil { + t.Fatalf("write replacement.png: %v", err) + } + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok_img"}}, + } + reg.Register(uploadStub) + createStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_replace/slide", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_new", "revision_id": 10}}, + } + deleteStub := &httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_replace/slide", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"revision_id": 11}}, + } + reg.Register(createStub) + reg.Register(deleteStub) + + err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlideXML, []string{ + "+replace-slide-xml", + "--presentation", "pres_replace", + "--slide-id", "slide_old", + "--content", ``, + "--asset-root", "assets", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeShortcutData(t, stdout) + if data["replaced_slide_id"] != "slide_old" { + t.Fatalf("replaced_slide_id = %v, want slide_old", data["replaced_slide_id"]) + } + if data["replacement_slide_id"] != "slide_new" { + t.Fatalf("replacement_slide_id = %v, want slide_new", data["replacement_slide_id"]) + } + + var createBody map[string]interface{} + if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil { + t.Fatalf("decode create body: %v", err) + } + if createBody["before_slide_id"] != "slide_old" { + t.Fatalf("before_slide_id = %v, want slide_old", createBody["before_slide_id"]) + } + slide, _ := createBody["slide"].(map[string]interface{}) + content, _ := slide["content"].(string) + if !strings.Contains(content, "tok_img") || strings.Contains(content, "@replacement.png") { + t.Fatalf("replacement content = %q, want tokenized image", content) + } +} + +func TestSlidesReplaceSlideXMLDeleteFailureIncludesNewSlideID(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_replace/slide", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_new", "revision_id": 10}}, + }) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_replace/slide", + Body: map[string]interface{}{"code": 400, "msg": "cannot delete"}, + }) + + err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlideXML, []string{ + "+replace-slide-xml", + "--presentation", "pres_replace", + "--slide-id", "slide_old", + "--content", ``, + "--as", "user", + }) + if err == nil { + t.Fatal("expected delete failure") + } + if !strings.Contains(err.Error(), "slide_new") { + t.Fatalf("err = %v, want new slide id for recovery", err) + } +} diff --git a/skills/lark-slides/SKILL.md b/skills/lark-slides/SKILL.md index 98ef935a8..9785537d4 100644 --- a/skills/lark-slides/SKILL.md +++ b/skills/lark-slides/SKILL.md @@ -14,6 +14,11 @@ metadata: **CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。** +**CRITICAL — 如果用户提到“模板”“套用模板”“参考某种主题/风格/版式”,或用户需求明显落在已有场景模板内(如工作汇报、产品介绍、商业计划书、培训、晋升汇报等),MUST 先读取 [template-index.json](references/template-index.json) 或运行 [`scripts/template-tool.py`](scripts/template-tool.py) 做模板检索;默认给出 2-3 个最匹配模板候选供用户选择。只有确定要复用某类版式时,才进一步读取 [template-catalog.md](references/template-catalog.md) 或按页型裁切 `references/templates/*.xml` 片段;不要默认阅读全文模板 XML。** + +> [!NOTE] +> `scripts/template-tool.py` / `scripts/layout-lint.py` 需要 Python 3,但它们是可选辅助路径,不是强制依赖。没有 Python 时,退回 “`template-index.json` → `template-catalog.md` → 按范围读取 XML 片段” 的纯文档路径。 + ## 身份选择 飞书幻灯片通常是用户自己的内容资源。**默认应优先显式使用 `--as user`(用户身份)执行 slides 相关操作**,始终显式指定身份。 @@ -44,6 +49,9 @@ lark-cli slides +create --title "演示文稿标题" --slides '[ 也可以分两步(先创建空白 PPT,再逐页添加),详见 [+create 参考文档](references/lark-slides-create.md)。 +> [!WARNING] +> `--slides '[...]'` 适合简单页面批量创建,但并不等同于“10 页以内都安全”。如果 slide XML 含中文、大段文本、复杂布局、嵌套引号或较多特殊字符,shell 传参时可能出现转义或截断问题,导致内容丢失、页面空白或布局异常。遇到复杂页面时,优先改用“两步创建法”。 + > 以上是最小可用示例。更丰富的页面效果(渐变背景、卡片、图表、表格等),参考下方 Workflow 和 XML 模板。 ## 执行前必做 @@ -61,6 +69,10 @@ lark-cli slides +create --title "演示文稿标题" --slides '[ | 场景 | 文档 | |------|------| | 需要了解详细 XML 结构 | [xml-format-guide.md](references/xml-format-guide.md) | +| 需要快速筛模板、做低成本路由 | [template-index.json](references/template-index.json) | +| 需要匹配 PPT 模板/主题风格 | [template-catalog.md](references/template-catalog.md) | +| 需要按页型抽摘要或裁切 XML 片段 | [`scripts/template-tool.py`](scripts/template-tool.py) | +| 需要做本地布局风险检查 | [`scripts/layout-lint.py`](scripts/layout-lint.py) | | 需要 CLI 调用示例 | [examples.md](references/examples.md) | | 需要参考真实 PPT 的 XML | [slides_demo.xml](references/slides_demo.xml) | | 需要用 table/chart 等复杂元素 | [slides_xml_schema_definition.xml](references/slides_xml_schema_definition.xml)(完整 Schema) | @@ -70,38 +82,98 @@ lark-cli slides +create --title "演示文稿标题" --slides '[ > **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。 +### 创建方式选择 + +| 场景 | 推荐方式 | +|------|----------| +| 简单 XML(1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 | +| 复杂 XML(多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多) | **两步创建**:先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide create` 逐页添加 | +| 已有 PPT 继续追加或插入页面 | 使用 `xml_presentation.slide create`,必要时配合 `before_slide_id` | + +> [!WARNING] +> `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议使用两步创建法。 + ```text Step 1: 需求澄清 & 读取知识 - 澄清用户需求:主题、受众、页数、风格偏好 + - 如果需求明显落在已有模板场景内,主动提示用户“可以直接基于现成模板生成”,并给出 2-3 个最匹配模板候选(模板名 + 适用场景 + 风格/色调 + 简短推荐理由) + - 默认不要把完整模板目录直接贴给用户;除非用户明确要求看更多,否则只展示 2-3 个候选 + - 候选优先选场景强相关模板;只有没有明显场景模板时,才用 `light_general.xml` / `dark_general.xml` 这类通用模板兜底 - 如果用户没有明确风格,根据主题推荐(见下方风格判断表) + - 如果用户要求“模板/主题/风格参考”,或主题属于常见模板场景: + · 先读 template-index.json,或运行 `python3 skills/lark-slides/scripts/template-tool.py search --query "<主题>" --limit 3` 做低成本模板匹配 + · 需要人类可读说明时,再读 template-catalog.md 组织候选文案 + · 锁定模板后,优先运行 `template-tool.py summarize` 看 `` / 页型摘要;需要具体布局时,再用 `template-tool.py extract` 或按 range 读取 XML 片段 + · 复用模板的 theme、配色、页面流、布局骨架,不要照搬占位文案 + · 除非用户明确要求查看整份模板,否则不要默认读取 `references/templates/*.xml` 全文 - 读取 XML Schema 参考: · xml-schema-quick-ref.md — 元素和属性速查 · xml-format-guide.md — 详细结构与示例 · slides_demo.xml — 真实 XML 示例 Step 2: 生成大纲 → 用户确认 → 创建 + - 生成大纲前,先确认用户是否采用推荐模板;如果用户没有明确反对且候选中有明显最佳匹配,可默认按最匹配模板继续 - 生成结构化大纲(每页标题 + 要点 + 布局描述),交给用户确认 - - 10 页以内:用 slides +create --slides '[...]' 一步创建 PPT 并添加所有页面 - - 超过 10 页:先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加 + - 如果已选模板,大纲和页面布局要明确标注“基于哪个模板/哪些模板改写” + - 如果用户明确不要模板,直接按自定义风格继续,不要重复推动模板选择 + - 先判断创建方式: + · 简单 XML:可用 `slides +create --slides '[...]'` 一步创建 + · 多页复杂 XML:优先用 `slides +compose --slides-dir ./slides --asset-root ./assets` + 或 `--manifest @compose.json`,由 CLI 统一完成建空白 PPT、上传图片、逐页创建 + · 复杂 XML:优先先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加 + · 超过 10 页:默认使用两步创建,避免单次输入过长 - 含本地图片: · 新建带图 PPT —— 在 slide XML 里写 , +create 会自动上传并替换为 file_token(详见 lark-slides-create.md) + · 批量把一组图片预上传到已有 PPT —— 用 `slides +bulk-media-upload --presentation $PID --dir ./assets` + 或重复 `--files`,直接拿到 `file_name -> file_token` map · 给已有 PPT 加带图新页 —— 先 `slides +media-upload --file ./pic.png --presentation $PID` 拿到 file_token,再用它写进 slide XML 调 xml_presentation.slide.create - · 给已有页加图 —— XML API 无元素级编辑,需要整页替换;必守规则和流程见下方「给已有 PPT 的已有页加图」章节 + · 给已有页整体替换 —— 优先用 `slides +replace-slide-xml --presentation $PID --slide-id $SID --content @slide.xml` + CLI 会按“先创建新页,再删除旧页”的顺序执行,且支持 `@./pic.png` 占位符 · 路径必须是 CWD 内的相对路径(如 ./pic.png 或 ./assets/x.png); - 绝对路径会被 CLI 拒绝,先 cd 到素材所在目录再执行 + `+compose` / `+replace-slide-xml` / `+bulk-media-upload` 可额外用 `--asset-root ./assets` + 降低素材目录切换成本,但本质上仍要求路径落在当前工作目录白名单内 - 每页 slide 需要完整的 XML:背景、文本、图形、配色 - 复杂元素(table、chart)需参考 XSD 原文 Step 3: 审查 & 交付 - - 创建完成后,用 xml_presentations.get 读取全文 XML,确认: - · 页数是否正确?每页内容是否完整? + - 创建完成后,必须用 xml_presentations.get 读取全文 XML 做创建后验证,确认: + · 页数是否正确? + · 每页 `` 是否包含预期的 `` / `` / 其他元素? + · 文本内容是否完整,是否有被截断、丢失、空白区域? + · 关键布局坐标和尺寸是否合理,是否出现明显重叠? · 配色是否统一?字号层级是否合理? + - 如果本地有 Python 3,建议额外运行 + `python3 skills/lark-slides/scripts/layout-lint.py --input presentation.xml` + 做重叠、越界、文本高度风险检查 - 有问题 → 用 xml_presentation.slide.delete 删除问题页,重新创建 - 没问题 → 交付:告知用户演示文稿 ID 和访问方式 ``` +### 创建后验证 + +创建成功不等于内容正确。创建完 PPT 后,**必须**读取全文 XML 校验结果: + +```bash +lark-cli slides xml_presentations get --as user \ + --params '{"xml_presentation_id":"YOUR_ID"}' +``` + +重点检查: + +- [ ] 页数是否与预期一致 +- [ ] 每页 `` 中是否包含所有预期元素 +- [ ] 文本内容是否完整,没有被 shell 截断或转义损坏 +- [ ] 白底内容区、卡片区、图文区等关键布局是否实际生成 +- [ ] 坐标、宽高是否合理,是否出现堆叠或越界 + +发现问题时: + +1. 不要假设“创建成功就代表渲染正确” +2. 先读取问题页的 XML,确认是生成问题还是传参损坏 +3. 删除问题页后重新添加;复杂页面优先改用两步创建法 + ### jq 命令模板(编辑已有 PPT 时使用) 新建 PPT 推荐用 `+create --slides`。以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号: @@ -171,6 +243,8 @@ XML API 没有元素级编辑接口(见核心规则 7)。想给某一页加 ```text [PPT 标题] — [定位描述],面向 [目标受众] +模板:[未使用模板 / /