feat: add slides create-svglide shortcut

This commit is contained in:
songtianyi.theo
2026-07-09 02:42:29 +08:00
parent 3f3bf05c26
commit ce0ba1f0fe
3 changed files with 265 additions and 0 deletions

View File

@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
SlidesCreate,
SlidesCreateSVGlide,
SlidesMediaUpload,
SlidesReplaceSlide,
SlidesReplacePages,

View File

@@ -0,0 +1,164 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// SlidesCreateSVGlide publishes a validator-ready SVGlide manifest as raw SVG pages.
var SlidesCreateSVGlide = common.Shortcut{
Service: "slides",
Command: "+create-svglide",
Description: "Create a Lark Slides presentation from an existing SVGlide manifest",
Risk: "write",
AuthTypes: []string{"user", "bot"},
Scopes: []string{"slides:presentation:create", "slides:presentation:write_only", "docs:document.media:upload"},
Flags: []common.Flag{
{Name: "manifest", Desc: "SVGlide manifest JSON file; version=svglide.manifest.v1, max 10 pages", Required: true},
{Name: "title", Desc: "presentation title override; defaults to manifest.title, then Untitled"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := prepareSVGlidePublishSpec(runtime.Str("manifest"), runtime.Str("title"))
if err != nil {
return err
}
_, err = planSVGlideImageRewrites(runtime, spec)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := prepareSVGlidePublishSpec(runtime.Str("manifest"), runtime.Str("title"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
imagePlan, err := planSVGlideImageRewrites(runtime, spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
total := 1 + len(imagePlan.UniqueFiles) + len(spec.Pages)
dry := common.NewDryRunAPI()
dry.Desc(fmt.Sprintf("Create SVGlide presentation + add %d page(s)", len(spec.Pages))).
POST("/open-apis/slides_ai/v1/xml_presentations").
Desc(fmt.Sprintf("[1/%d] Create presentation", total)).
Body(map[string]interface{}{
"xml_presentation": map[string]interface{}{"content": buildPresentationXML(spec.Title)},
})
for i, file := range imagePlan.UniqueFiles {
appendSlidesUploadDryRun(dry, file.FilePath, "<xml_presentation_id>", i+2)
}
pageStepStart := 2 + len(imagePlan.UniqueFiles)
for i, page := range spec.Pages {
dry.POST("/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide").
Desc(fmt.Sprintf("[%d/%d] Add SVGlide page %d (%s)", pageStepStart+i, total, i+1, page.ID)).
Params(map[string]interface{}{"revision_id": -1}).
Body(map[string]interface{}{
"slide": map[string]interface{}{
"content": fmt.Sprintf("<raw SVG omitted: source_sha256=%s source_bytes=%d>", page.SHA256, page.SourceBytes),
"content_type": "svg",
"source_bytes": page.SourceBytes,
"source_sha256": page.SHA256,
"submitted_bytes": "<computed after image upload>",
"submitted_sha256": "<computed after image upload>",
"content_omitted": true,
},
})
}
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("manifest", runtime.Str("manifest")).Set("svglide_manifest_version", svglideManifestVersion)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := prepareSVGlidePublishSpec(runtime.Str("manifest"), runtime.Str("title"))
if err != nil {
return err
}
imagePlan, err := planSVGlideImageRewrites(runtime, spec)
if err != nil {
return err
}
data, err := runtime.CallAPITyped(
"POST",
"/open-apis/slides_ai/v1/xml_presentations",
nil,
map[string]interface{}{
"xml_presentation": map[string]interface{}{"content": buildPresentationXML(spec.Title)},
},
)
if err != nil {
return err
}
presentationID := common.GetString(data, "xml_presentation_id")
if presentationID == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides create-svglide returned no xml_presentation_id")
}
submitPages, imageRewrite, err := uploadAndRewriteSVGlideImages(runtime, spec, imagePlan, presentationID)
if err != nil {
return appendSlidesProgressHint(err, fmt.Sprintf("uploading SVGlide image assets failed; presentation %s was created, no pages were added", presentationID))
}
result := map[string]interface{}{
"xml_presentation_id": presentationID,
"title": spec.Title,
"manifest": spec.ManifestPath,
"svglide_manifest_version": svglideManifestVersion,
"images_uploaded": imageRewrite.UploadedCount,
"image_rewrites": imageRewrite.Entries,
}
if revisionID := common.GetFloat(data, "revision_id"); revisionID > 0 {
result["revision_id"] = int(revisionID)
}
slideURL := fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(presentationID))
slideIDs := make([]string, 0, len(submitPages))
pageResults := make([]map[string]interface{}, 0, len(submitPages))
for i, page := range submitPages {
slideData, err := runtime.CallAPITyped(
"POST",
slideURL,
map[string]interface{}{"revision_id": -1},
map[string]interface{}{
"slide": map[string]interface{}{"content": page.Content},
},
)
if err != nil {
return appendSlidesProgressHint(err, fmt.Sprintf("adding SVGlide page %d/%d (%s) failed; presentation %s was created, %d page(s) added before failure", i+1, len(submitPages), page.ID, presentationID, i))
}
slideID := common.GetString(slideData, "slide_id")
if slideID != "" {
slideIDs = append(slideIDs, slideID)
}
pageResults = append(pageResults, map[string]interface{}{
"id": page.ID,
"index": page.Index,
"file": page.File,
"source_sha256": page.SHA256,
"source_bytes": page.SourceBytes,
"submitted_sha256": sha256Hex([]byte(page.Content)),
"submitted_bytes": page.ContentBytes,
"slide_id": slideID,
})
}
result["slide_ids"] = slideIDs
result["slides_added"] = len(slideIDs)
result["pages"] = pageResults
if url := common.GetString(data, "url"); url != "" {
result["url"] = url
} else if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
result["url"] = url
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil {
result["permission_grant"] = grant
}
runtime.Out(result, nil)
return nil
},
}

View File

@@ -5,6 +5,7 @@ package slides
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
@@ -324,6 +325,105 @@ func TestUploadAndRewriteSVGlideImagesUploadsOnceAndRewrites(t *testing.T) {
reg.Verify(t)
}
func TestSlidesCreateSVGlideExecute(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
page := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="page-001" viewBox="0 0 960 540"><rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/></svg>`
mustWriteSVGlideBundle(t, "SVGlide Deck", page)
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_sg",
"revision_id": 1,
"url": "https://tenant.example.com/slides/pres_sg",
},
},
})
slideStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_sg/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_1", "revision_id": 2}},
}
reg.Register(slideStub)
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
"+create-svglide",
"--manifest", "manifest.json",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["xml_presentation_id"] != "pres_sg" {
t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"])
}
if data["slides_added"] != float64(1) {
t.Fatalf("slides_added = %v, want 1", data["slides_added"])
}
if data["svglide_manifest_version"] != svglideManifestVersion {
t.Fatalf("svglide_manifest_version = %v", data["svglide_manifest_version"])
}
var body map[string]interface{}
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v", err)
}
slide := body["slide"].(map[string]interface{})
if !strings.Contains(slide["content"].(string), "<svg") {
t.Fatalf("slide content did not contain SVGlide payload: %#v", slide["content"])
}
}
func TestSlidesCreateSVGlideDryRun(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
page := `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"><rect/></svg>`
mustWriteSVGlideBundle(t, "SVGlide Deck", page)
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
"+create-svglide",
"--manifest", "manifest.json",
"--dry-run",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"Create SVGlide presentation", "Add SVGlide page 1", "source_sha256", "submitted_sha256", "/open-apis/slides_ai/v1/xml_presentations", "/slide"} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run missing %q: %s", want, out)
}
}
if strings.Contains(out, "<svg") {
t.Fatalf("dry-run must summarize SVG content instead of printing full payload: %s", out)
}
}
func mustWriteSVGlideBundle(t *testing.T, title string, page string) {
t.Helper()
mustWriteSVGlideTestFile(t, "pages/page-001.svg", page)
mustWriteSVGlideTestFile(t, "receipts/validate_svg_deck.json", `{"totalErrors":0}`)
mustWriteSVGlideTestFile(t, "manifest.json", fmt.Sprintf(`{
"version": "svglide.manifest.v1",
"protocol": "svg-slides.v1",
"title": %q,
"size": {"width": 960, "height": 540},
"publish_ready": true,
"published": false,
"pages": [{"id": "page-001", "index": 1, "file": "pages/page-001.svg", "sha256": %q}],
"receipts": {"validate_svg_deck": "receipts/validate_svg_deck.json"}
}`, title, sha256Hex([]byte(page))))
}
func newSVGlideTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())