diff --git a/shortcuts/slides/shortcuts.go b/shortcuts/slides/shortcuts.go index 011e27e0b..3a15cdffd 100644 --- a/shortcuts/slides/shortcuts.go +++ b/shortcuts/slides/shortcuts.go @@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common" func Shortcuts() []common.Shortcut { return []common.Shortcut{ SlidesCreate, + SlidesCreateSVGlide, SlidesMediaUpload, SlidesReplaceSlide, SlidesReplacePages, diff --git a/shortcuts/slides/slides_create_svglide.go b/shortcuts/slides/slides_create_svglide.go new file mode 100644 index 000000000..cd4dd21bc --- /dev/null +++ b/shortcuts/slides/slides_create_svglide.go @@ -0,0 +1,114 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "context" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/svglide" + "github.com/larksuite/cli/shortcuts/common" +) + +// SlidesCreateSVGlide manages a local Codex-mediated SVGlide SVG run directory. +var SlidesCreateSVGlide = common.Shortcut{ + Service: "slides", + Command: "+create-svglide", + Description: "Create and manage a local SVGlide SVG run directory", + Risk: "write", + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "action", Desc: "runtime action: init, status, next, validate, preview", Required: true, Enum: []string{"init", "status", "next", "validate", "preview"}}, + {Name: "run", Desc: "existing run directory for status/next/validate/preview"}, + {Name: "title", Desc: "deck title for init"}, + {Name: "input", Desc: "local source markdown/text path for init"}, + {Name: "audience", Desc: "final audience for the deck"}, + {Name: "delivery-mode", Desc: "delivery mode: presented, self_read, dual_mode", Enum: []string{"presented", "self_read", "dual_mode"}}, + {Name: "pages", Type: "int", Desc: "target page count"}, + {Name: "out", Desc: "output run directory for init"}, + {Name: "overwrite", Type: "bool", Desc: "allow init to overwrite an existing run directory"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + action := runtime.Str("action") + if action == "init" { + if strings.TrimSpace(runtime.Str("title")) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--title is required for init").WithParam("--title") + } + if strings.TrimSpace(runtime.Str("input")) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--input is required for init").WithParam("--input") + } + if strings.TrimSpace(runtime.Str("out")) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--out is required for init").WithParam("--out") + } + if stat, err := runtime.FileIO().Stat(runtime.Str("input")); err != nil { + return common.WrapInputStatErrorTyped(err, "cannot read --input") + } else if !stat.Mode().IsRegular() { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--input must be a regular file").WithParam("--input") + } + return nil + } + if strings.TrimSpace(runtime.Str("run")) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--run is required for %s", action).WithParam("--run") + } + return nil + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + action := runtime.Str("action") + switch action { + case "init": + out := runtime.Str("out") + if err := svglide.InitRun(out, svglide.InitOptions{ + Title: runtime.Str("title"), + Input: runtime.Str("input"), + Audience: runtime.Str("audience"), + DeliveryMode: runtime.Str("delivery-mode"), + Pages: runtime.Int("pages"), + Overwrite: runtime.Bool("overwrite"), + }); err != nil { + return err + } + status, err := svglide.InspectStatus(out) + if err != nil { + return err + } + runtime.Out(map[string]any{ + "action": action, + "run": out, + "next_command": status.NextCommand, + }, nil) + return nil + case "status": + report, err := svglide.InspectStatus(runtime.Str("run")) + if err != nil { + return err + } + runtime.Out(report, nil) + return nil + case "next": + report, err := svglide.NextTask(runtime.Str("run")) + if err != nil { + return err + } + runtime.Out(report, nil) + return nil + case "validate": + report, err := svglide.ValidateRun(runtime.Str("run")) + if err != nil { + return err + } + runtime.Out(report, nil) + return nil + case "preview": + report, err := svglide.WritePreview(runtime.Str("run")) + if err != nil { + return err + } + runtime.Out(report, nil) + return nil + default: + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --action %q", action).WithParam("--action") + } + }, +} diff --git a/shortcuts/slides/slides_create_svglide_test.go b/shortcuts/slides/slides_create_svglide_test.go new file mode 100644 index 000000000..7ee14ac90 --- /dev/null +++ b/shortcuts/slides/slides_create_svglide_test.go @@ -0,0 +1,254 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" +) + +func TestSlidesCreateSVGlideInitShortcut(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil { + t.Fatal(err) + } + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + + err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{ + "+create-svglide", + "--action", "init", + "--title", "Demo", + "--input", "source.md", + "--audience", "产品负责人", + "--delivery-mode", "self_read", + "--pages", "8", + "--out", "run-demo", + "--as", "user", + }) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, "run-demo", "run.json")); err != nil { + t.Fatalf("missing run.json: %v", err) + } + data := decodeShortcutData(t, stdout) + if data["action"] != "init" { + t.Fatalf("action = %v, want init", data["action"]) + } + if data["run"] != "run-demo" { + t.Fatalf("run = %v, want run-demo", data["run"]) + } + if !strings.Contains(stringValue(data["next_command"]), "--action next --run run-demo") { + t.Fatalf("next_command = %v, want next action", data["next_command"]) + } +} + +func TestSlidesCreateSVGlideRejectsPositionalAction(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + + err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{ + "+create-svglide", + "init", + "--as", "user", + }) + if err == nil { + t.Fatal("expected positional argument rejection") + } + if !strings.Contains(err.Error(), "positional arguments are not supported") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSlidesCreateSVGlideStatusAndNextActions(t *testing.T) { + dir := initSVGlideShortcutRun(t) + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + + err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{ + "+create-svglide", + "--action", "status", + "--run", "run-demo", + "--as", "user", + }) + if err != nil { + t.Fatal(err) + } + statusData := decodeShortcutData(t, stdout) + if statusData["current_stage"] != "request" { + t.Fatalf("current_stage = %v, want request", statusData["current_stage"]) + } + if !strings.Contains(stringValue(statusData["next_command"]), "--action next --run run-demo") { + t.Fatalf("next_command = %v, want next action", statusData["next_command"]) + } + + err = runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{ + "+create-svglide", + "--action", "next", + "--run", "run-demo", + "--as", "user", + }) + if err != nil { + t.Fatal(err) + } + nextData := decodeShortcutData(t, stdout) + if nextData["stage"] != "request" || nextData["prompt_path"] != "prompts/01_request.task.md" { + t.Fatalf("next data = %+v, want request prompt", nextData) + } + if _, err := os.Stat(filepath.Join(dir, "run-demo", "prompts", "01_request.task.md")); err != nil { + t.Fatalf("missing prompt: %v", err) + } +} + +func TestSlidesCreateSVGlideValidateActionOutputsReport(t *testing.T) { + initSVGlideShortcutRunWithDeck(t) + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + + err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{ + "+create-svglide", + "--action", "validate", + "--run", "run-demo", + "--as", "user", + }) + if err != nil { + t.Fatal(err) + } + data := decodeShortcutData(t, stdout) + if data["ok"] != true { + t.Fatalf("ok = %v, want true; data=%+v", data["ok"], data) + } + if _, err := os.Stat(filepath.Join("run-demo", "receipts", "lint.json")); err != nil { + t.Fatalf("missing lint receipt: %v", err) + } +} + +func TestSlidesCreateSVGlidePreviewActionOutputsReport(t *testing.T) { + initSVGlideShortcutRunWithDeck(t) + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + + err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{ + "+create-svglide", + "--action", "preview", + "--run", "run-demo", + "--as", "user", + }) + if err != nil { + t.Fatal(err) + } + data := decodeShortcutData(t, stdout) + if data["status"] != "passed" { + t.Fatalf("status = %v, want passed; data=%+v", data["status"], data) + } + if _, err := os.Stat(filepath.Join("run-demo", "preview.html")); err != nil { + t.Fatalf("missing preview.html: %v", err) + } + if _, err := os.Stat(filepath.Join("run-demo", "receipts", "preview.json")); err != nil { + t.Fatalf("missing preview receipt: %v", err) + } +} + +func TestSlidesCreateSVGlideValidateActionDoesNotErrorOnValidationFailure(t *testing.T) { + initSVGlideShortcutRun(t) + writeSVGlideShortcutDeck(t, "slides/missing.svg") + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + + err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{ + "+create-svglide", + "--action", "validate", + "--run", "run-demo", + "--as", "user", + }) + if err != nil { + t.Fatal(err) + } + data := decodeShortcutData(t, stdout) + if data["ok"] != false { + t.Fatalf("ok = %v, want false; data=%+v", data["ok"], data) + } +} + +func TestSlidesCreateSVGlideRegistered(t *testing.T) { + for _, shortcut := range Shortcuts() { + if shortcut.Command == "+create-svglide" { + return + } + } + t.Fatal("slides +create-svglide shortcut is not registered") +} + +func initSVGlideShortcutRunWithDeck(t *testing.T) { + initSVGlideShortcutRun(t) + writeSVGlideShortcutDeck(t, "slides/01.svg") + writeSVGlideShortcutFile(t, filepath.Join("run-demo", "slides", "01.svg"), svglideShortcutVisibleTextSVG()) +} + +func initSVGlideShortcutRun(t *testing.T) string { + t.Helper() + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil { + t.Fatal(err) + } + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + if err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{ + "+create-svglide", + "--action", "init", + "--title", "Demo", + "--input", "source.md", + "--out", "run-demo", + "--as", "user", + }); err != nil { + t.Fatal(err) + } + return dir +} + +func writeSVGlideShortcutDeck(t *testing.T, slidePath string) { + t.Helper() + deck := map[string]any{ + "title": "Demo", + "slides": []map[string]string{{ + "id": "cover", + "title": "Slide", + "summary": "Summary", + "role": "cover", + "key_message": "Message", + "path": slidePath, + }}, + } + raw, err := json.MarshalIndent(deck, "", " ") + if err != nil { + t.Fatal(err) + } + raw = append(raw, '\n') + writeSVGlideShortcutFile(t, filepath.Join("run-demo", "outline", "deck.json"), string(raw)) +} + +func writeSVGlideShortcutFile(t *testing.T, path string, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func svglideShortcutVisibleTextSVG() string { + return `Hello` +} + +func stringValue(value any) string { + if text, ok := value.(string); ok { + return text + } + var b bytes.Buffer + _ = json.NewEncoder(&b).Encode(value) + return strings.TrimSpace(b.String()) +}