From 39d60cb7061d0771fc88ec0da276e9bf25b48077 Mon Sep 17 00:00:00 2001 From: ethan-zhx Date: Fri, 26 Jun 2026 15:56:55 +0800 Subject: [PATCH] feat: add slides replace-pages and xml-get shortcuts (#1585) * feat: add slides replace-pages shortcut * feat: add slides xml get shortcut * fix: stop advertising slides screenshot scope * feat: expose slides presentation url --- shortcuts/slides/shortcuts.go | 2 + shortcuts/slides/slides_create.go | 12 +- shortcuts/slides/slides_create_test.go | 20 +- shortcuts/slides/slides_replace_pages.go | 426 ++++++++++++++++++ shortcuts/slides/slides_replace_pages_test.go | 341 ++++++++++++++ shortcuts/slides/slides_replace_slide.go | 14 +- shortcuts/slides/slides_replace_slide_test.go | 16 + shortcuts/slides/slides_screenshot.go | 4 +- shortcuts/slides/slides_screenshot_test.go | 16 +- shortcuts/slides/slides_xml_get.go | 144 ++++++ shortcuts/slides/slides_xml_get_test.go | 165 +++++++ skills/lark-slides/SKILL.md | 9 +- .../references/lark-slides-edit-workflows.md | 4 +- .../references/lark-slides-replace-pages.md | 95 ++++ .../references/lark-slides-screenshot.md | 2 +- 15 files changed, 1241 insertions(+), 29 deletions(-) create mode 100644 shortcuts/slides/slides_replace_pages.go create mode 100644 shortcuts/slides/slides_replace_pages_test.go create mode 100644 shortcuts/slides/slides_xml_get.go create mode 100644 shortcuts/slides/slides_xml_get_test.go create mode 100644 skills/lark-slides/references/lark-slides-replace-pages.md diff --git a/shortcuts/slides/shortcuts.go b/shortcuts/slides/shortcuts.go index f665810cb..011e27e0b 100644 --- a/shortcuts/slides/shortcuts.go +++ b/shortcuts/slides/shortcuts.go @@ -11,6 +11,8 @@ func Shortcuts() []common.Shortcut { SlidesCreate, SlidesMediaUpload, SlidesReplaceSlide, + SlidesReplacePages, SlidesScreenshot, + SlidesXMLGet, } } diff --git a/shortcuts/slides/slides_create.go b/shortcuts/slides/slides_create.go index 115f04cd1..b03fea491 100644 --- a/shortcuts/slides/slides_create.go +++ b/shortcuts/slides/slides_create.go @@ -204,13 +204,11 @@ var SlidesCreate = common.Shortcut{ } } - // Build the presentation URL locally from the token. The brand-standard - // host transparently redirects to the tenant domain (same fallback used by - // drive +upload / wiki +node-create). This avoids the prior best-effort - // drive metas/batch_query call, which needed an extra drive scope and 403'd - // for users who only authorized slides scopes — without ever blocking an - // otherwise-successful creation. - if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" { + // Prefer the URL returned by presentation.create. Fall back to a local + // brand-standard URL only when the API omits it. + if url := common.GetString(data, "url"); url != "" { + result["url"] = url + } else if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" { result["url"] = url } diff --git a/shortcuts/slides/slides_create_test.go b/shortcuts/slides/slides_create_test.go index 36b964a07..a5ba64cdf 100644 --- a/shortcuts/slides/slides_create_test.go +++ b/shortcuts/slides/slides_create_test.go @@ -34,6 +34,7 @@ func TestSlidesCreateBasic(t *testing.T) { "data": map[string]interface{}{ "xml_presentation_id": "pres_abc123", "revision_id": 1, + "url": "https://tenant.example.com/slides/pres_abc123", }, }, }) @@ -54,10 +55,8 @@ func TestSlidesCreateBasic(t *testing.T) { if data["title"] != "项目汇报" { t.Fatalf("title = %v, want 项目汇报", data["title"]) } - // URL is built locally from the token (brand-standard host), not fetched from - // drive metas, so it is deterministic and needs no drive scope. - if data["url"] != "https://www.feishu.cn/slides/pres_abc123" { - t.Fatalf("url = %v, want https://www.feishu.cn/slides/pres_abc123", data["url"]) + if data["url"] != "https://tenant.example.com/slides/pres_abc123" { + t.Fatalf("url = %v, want https://tenant.example.com/slides/pres_abc123", data["url"]) } if _, ok := data["permission_grant"]; ok { t.Fatalf("did not expect permission_grant in user mode") @@ -647,12 +646,12 @@ func TestSlidesCreateWithoutSlidesUnchanged(t *testing.T) { } } -// TestSlidesCreateURLBuiltLocally verifies the presentation URL is constructed -// locally from the token — no drive metas/batch_query call is made, so creation -// works for users who only authorized slides scopes. The httpmock registry has no -// batch_query stub registered; if the shortcut tried to call it, the request would -// fail the test (unregistered stub), proving the URL is built without a drive call. -func TestSlidesCreateURLBuiltLocally(t *testing.T) { +// TestSlidesCreateURLFallsBackToLocalBuild verifies the presentation URL is +// constructed locally from the token when presentation.create omits url — no +// drive metas/batch_query call is made, so creation works for users who only +// authorized slides scopes. The httpmock registry has no batch_query stub +// registered; if the shortcut tried to call it, the request would fail the test. +func TestSlidesCreateURLFallsBackToLocalBuild(t *testing.T) { t.Parallel() f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) @@ -665,6 +664,7 @@ func TestSlidesCreateURLBuiltLocally(t *testing.T) { "data": map[string]interface{}{ "xml_presentation_id": "pres_local_url", "revision_id": 1, + "url": "", }, }, }) diff --git a/shortcuts/slides/slides_replace_pages.go b/shortcuts/slides/slides_replace_pages.go new file mode 100644 index 000000000..a2ee77163 --- /dev/null +++ b/shortcuts/slides/slides_replace_pages.go @@ -0,0 +1,426 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "context" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// SlidesReplacePages rebuilds multiple pages inside an existing presentation. +// It deliberately creates the new page before deleting the old one so a create +// failure cannot remove existing user content. The operation is not atomic. +const replacePagesInitialRevisionID = -1 + +var SlidesReplacePages = common.Shortcut{ + Service: "slides", + Command: "+replace-pages", + Description: "Batch rebuild pages inside an existing Slides presentation (create before old page, then delete old page; not atomic)", + Risk: "write", + Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"}, + // wiki:node:read is required only when --presentation is a wiki URL. + ConditionalScopes: []string{"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: "pages", Desc: "JSON array of page replacements (each: {slide_id, content}); supports @file or -", Required: true, Input: []string{common.File, common.Stdin}}, + {Name: "continue-on-error", Type: "bool", Desc: "continue with later pages after a create/delete failure; default false"}, + {Name: "validate-only", Type: "bool", Desc: "validate input and build the create/delete plan without write calls"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { + return err + } + if ref.Kind == "wiki" { + if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil { + return err + } + } + pages, err := parseReplacePages(runtime.Str("pages")) + if err != nil { + return err + } + return validateReplacePagesInput(pages) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + dry := common.NewDryRunAPI() + resolved, err := prepareReplacePages(runtime) + if err != nil { + return dry.Set("error", err.Error()) + } + appendReplacePagesDryRunCalls(dry, resolved) + return dry. + Set("xml_presentation_id", resolved.PresentationID). + Set("pages_count", len(resolved.Plan)). + Set("plan", replacePagesPlanOutput(resolved.Plan)). + Set("note", "dry-run built a create/delete plan from slide_id inputs; no Slides presentation get/create/delete calls were executed") + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + resolved, err := prepareReplacePages(runtime) + if err != nil { + return err + } + if runtime.Bool("validate-only") { + runtime.Out(map[string]interface{}{ + "xml_presentation_id": resolved.PresentationID, + "pages_count": len(resolved.Plan), + "plan": replacePagesPlanOutput(resolved.Plan), + "status": "validated", + "note": "validate-only checked input and built the create/delete plan; no Slides presentation get/create/delete calls were executed", + }, nil) + return nil + } + + revisionID := replacePagesInitialRevisionID + results := make([]replacePageResult, 0, len(resolved.Plan)) + for i, item := range resolved.Plan { + result, err := replaceOnePage(runtime, resolved.PresentationID, item, revisionID) + results = append(results, result) + if result.RevisionID != nil { + revisionID = *result.RevisionID + } + if err != nil { + if runtime.Bool("continue-on-error") { + continue + } + return appendSlidesProgressHint(err, fmt.Sprintf("slides +replace-pages stopped at item %d/%d; %d page(s) completed before failure; old page is kept when create failed", i+1, len(resolved.Plan), countReplacedPages(results))) + } + } + + out := map[string]interface{}{ + "xml_presentation_id": resolved.PresentationID, + "pages_count": len(resolved.Plan), + "results": replacePageResultsOutput(results), + "status": "completed", + "summary": replacePagesSummaryOutput(results), + "note": "batch replace is not atomic; each page was created before its old page was deleted", + } + if revisionID != replacePagesInitialRevisionID { + out["revision_id"] = revisionID + } + if hasReplacePageFailures(results) { + out["status"] = "partial_failure" + return runtime.OutPartialFailure(out, nil) + } + runtime.Out(out, nil) + return nil + }, +} + +type replacePageInput struct { + SlideID string + Content string +} + +type replacePagePlanItem struct { + OldSlideID string + Content string + Locator string +} + +type replacePagesPrepared struct { + PresentationID string + Plan []replacePagePlanItem +} + +type replacePageResult struct { + OldSlideID string + NewSlideID string + Status string + Error string + RevisionID *int +} + +func prepareReplacePages(runtime *common.RuntimeContext) (*replacePagesPrepared, error) { + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { + return nil, err + } + presentationID, err := resolvePresentationID(runtime, ref) + if err != nil { + return nil, err + } + pages, err := parseReplacePages(runtime.Str("pages")) + if err != nil { + return nil, err + } + if err := validateReplacePagesInput(pages); err != nil { + return nil, err + } + + plan, err := buildReplacePagesPlan(pages) + if err != nil { + return nil, err + } + return &replacePagesPrepared{PresentationID: presentationID, Plan: plan}, nil +} + +func parseReplacePages(raw string) ([]replacePageInput, error) { + s := strings.TrimSpace(raw) + if s == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages cannot be empty").WithParam("--pages") + } + var decoded []map[string]interface{} + if err := json.Unmarshal([]byte(s), &decoded); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages invalid JSON, must be an array of objects: %v", err).WithParam("--pages").WithCause(err) + } + out := make([]replacePageInput, 0, len(decoded)) + for i, m := range decoded { + p := replacePageInput{} + if v, ok := m["slide_number"]; ok { + _ = v + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_number is no longer supported; use slide_id", i).WithParam("--pages").WithHint("read current slide IDs first, then pass slide_id for each page replacement") + } + if v, ok := m["slide_id"]; ok { + s, ok := v.(string) + if !ok { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id must be a string", i).WithParam("--pages") + } + p.SlideID = s + } + if v, ok := m["content"]; ok { + s, ok := v.(string) + if !ok { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a string", i).WithParam("--pages") + } + p.Content = s + } + out = append(out, p) + } + return out, nil +} + +func validateReplacePagesInput(pages []replacePageInput) error { + if len(pages) == 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages must contain at least 1 item").WithParam("--pages") + } + seenIDs := map[string]bool{} + for i, p := range pages { + id := strings.TrimSpace(p.SlideID) + if id == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id is required", i).WithParam("--pages") + } + if seenIDs[id] { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages contains duplicate slide_id %q", id).WithParam("--pages") + } + seenIDs[id] = true + if strings.TrimSpace(p.Content) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content cannot be empty", i).WithParam("--pages") + } + if err := validateCompleteSlideXML(p.Content); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a complete XML element: %v", i, err).WithParam("--pages").WithCause(err) + } + } + return nil +} + +func validateCompleteSlideXML(content string) error { + dec := xml.NewDecoder(strings.NewReader(content)) + depth := 0 + seenRoot := false + for { + tok, err := dec.Token() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return err + } + switch t := tok.(type) { + case xml.StartElement: + if depth == 0 { + if seenRoot { + return invalidSlideXMLStructureError("multiple root elements") + } + if t.Name.Local != "slide" { + return invalidSlideXMLStructureError("root element is <%s>, want ", t.Name.Local) + } + seenRoot = true + } + depth++ + case xml.EndElement: + depth-- + case xml.CharData: + if depth == 0 && strings.TrimSpace(string(t)) != "" { + return invalidSlideXMLStructureError("non-whitespace text outside root element") + } + } + } + if !seenRoot { + return invalidSlideXMLStructureError("missing root element") + } + if depth != 0 { + return invalidSlideXMLStructureError("unclosed XML element") + } + return nil +} + +func invalidSlideXMLStructureError(format string, args ...interface{}) error { + return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...) +} + +func buildReplacePagesPlan(pages []replacePageInput) ([]replacePagePlanItem, error) { + plan := make([]replacePagePlanItem, 0, len(pages)) + for _, page := range pages { + id := strings.TrimSpace(page.SlideID) + plan = append(plan, replacePagePlanItem{ + OldSlideID: id, + Content: page.Content, + Locator: "slide_id", + }) + } + return plan, nil +} + +func appendReplacePagesDryRunCalls(dry *common.DryRunAPI, resolved *replacePagesPrepared) { + dry.Desc("Batch replace pages in-place: create each new page before old page, then delete old page (not atomic)") + for i, item := range resolved.Plan { + dry.POST(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))). + Desc(fmt.Sprintf("[%d/%d] Create replacement before old slide %s", i*2+1, len(resolved.Plan)*2, item.OldSlideID)). + Params(map[string]interface{}{"revision_id": ""}). + Body(map[string]interface{}{ + "slide": map[string]interface{}{"content": item.Content}, + "before_slide_id": item.OldSlideID, + }) + dry.DELETE(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))). + Desc(fmt.Sprintf("[%d/%d] Delete old slide %s after create succeeds", i*2+2, len(resolved.Plan)*2, item.OldSlideID)). + Params(map[string]interface{}{ + "slide_id": item.OldSlideID, + "revision_id": "", + }) + } +} + +func replaceOnePage(runtime *common.RuntimeContext, presentationID string, item replacePagePlanItem, revisionID int) (replacePageResult, error) { + result := replacePageResult{ + OldSlideID: item.OldSlideID, + Status: "pending", + } + slideURL := fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(presentationID)) + createData, err := runtime.CallAPITyped( + "POST", + slideURL, + map[string]interface{}{"revision_id": revisionID}, + map[string]interface{}{ + "slide": map[string]interface{}{"content": item.Content}, + "before_slide_id": item.OldSlideID, + }, + ) + if err != nil { + result.Status = "create_failed" + result.Error = err.Error() + return result, err + } + newSlideID := common.GetString(createData, "slide_id") + if newSlideID == "" { + err := errs.NewInternalError(errs.SubtypeInvalidResponse, "slide.create returned no slide_id for replacement of slide_id %q", item.OldSlideID) + result.Status = "create_failed" + result.Error = err.Error() + return result, err + } + result.NewSlideID = newSlideID + if rev, ok := revisionFromData(createData); ok { + revisionID = rev + result.RevisionID = &rev + } + + deleteData, err := runtime.CallAPITyped( + "DELETE", + slideURL, + map[string]interface{}{ + "slide_id": item.OldSlideID, + "revision_id": revisionID, + }, + nil, + ) + if err != nil { + result.Status = "delete_failed" + result.Error = err.Error() + return result, err + } + if rev, ok := revisionFromData(deleteData); ok { + result.RevisionID = &rev + } + result.Status = "replaced" + return result, nil +} + +func revisionFromData(data map[string]interface{}) (int, bool) { + if _, ok := data["revision_id"]; !ok { + return 0, false + } + return int(common.GetFloat(data, "revision_id")), true +} + +func replacePagesPlanOutput(plan []replacePagePlanItem) []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(plan)) + for _, item := range plan { + out = append(out, map[string]interface{}{ + "old_slide_id": item.OldSlideID, + "insert_before_slide_id": item.OldSlideID, + "locator": item.Locator, + "action": "create_before_then_delete_old", + }) + } + return out +} + +func replacePageResultsOutput(results []replacePageResult) []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(results)) + for _, result := range results { + m := map[string]interface{}{ + "old_slide_id": result.OldSlideID, + "status": result.Status, + } + if result.NewSlideID != "" { + m["new_slide_id"] = result.NewSlideID + } + if result.Error != "" { + m["error"] = result.Error + } + if result.RevisionID != nil { + m["revision_id"] = *result.RevisionID + } + out = append(out, m) + } + return out +} + +func replacePagesSummaryOutput(results []replacePageResult) map[string]interface{} { + replaced := countReplacedPages(results) + return map[string]interface{}{ + "replaced": replaced, + "failed": len(results) - replaced, + "total": len(results), + } +} + +func countReplacedPages(results []replacePageResult) int { + n := 0 + for _, result := range results { + if result.Status == "replaced" { + n++ + } + } + return n +} + +func hasReplacePageFailures(results []replacePageResult) bool { + for _, result := range results { + if result.Status == "create_failed" || result.Status == "delete_failed" { + return true + } + } + return false +} diff --git a/shortcuts/slides/slides_replace_pages_test.go b/shortcuts/slides/slides_replace_pages_test.go new file mode 100644 index 000000000..fd1c51584 --- /dev/null +++ b/shortcuts/slides/slides_replace_pages_test.go @@ -0,0 +1,341 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "encoding/json" + "errors" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" +) + +func TestReplacePagesDeclaredScopes(t *testing.T) { + if got := SlidesReplacePages.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) { + t.Fatalf("user preflight scopes = %#v, want slides update/write_only only", got) + } + if got := SlidesReplacePages.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) { + t.Fatalf("bot preflight scopes = %#v, want slides update/write_only only", got) + } + + got := SlidesReplacePages.DeclaredScopesForIdentity("user") + want := []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("declared scopes = %#v, want %#v", got, want) + } +} + +func TestReplacePagesCreatesBeforeThenDeletesOld(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + var requestOrder []string + createStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"slide_id": "new2", "revision_id": 11}, + }, + OnMatch: func(req *http.Request) { + requestOrder = append(requestOrder, req.Method) + }, + } + reg.Register(createStub) + var deleteQuery map[string][]string + deleteStub := &httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"revision_id": 12}, + }, + OnMatch: func(req *http.Request) { + requestOrder = append(requestOrder, req.Method) + deleteQuery = req.URL.Query() + }, + } + reg.Register(deleteStub) + + pages := `[{"slide_id":"old2","content":""}]` + err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{ + "+replace-pages", + "--presentation", "pres_abc", + "--pages", pages, + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var createBody struct { + Slide struct { + Content string `json:"content"` + } `json:"slide"` + BeforeSlideID string `json:"before_slide_id"` + } + if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil { + t.Fatalf("decode create body: %v\nraw=%s", err, createStub.CapturedBody) + } + if createBody.BeforeSlideID != "old2" { + t.Fatalf("before_slide_id = %q, want old2", createBody.BeforeSlideID) + } + if !strings.Contains(createBody.Slide.Content, ""}, + {"slide_id":"old2","content":""} + ]` + err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{ + "+replace-pages", + "--presentation", "pres_abc", + "--pages", pages, + "--continue-on-error", + "--as", "user", + }) + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err) + } + + env := decodeReplacePagesEnvelope(t, stdout) + if env.OK { + t.Fatalf("stdout ok = true, want false for partial failure") + } + data := env.Data + if data["status"] != "partial_failure" { + t.Fatalf("status = %v, want partial_failure", data["status"]) + } + summary, _ := data["summary"].(map[string]interface{}) + if summary["replaced"] != float64(1) || summary["failed"] != float64(1) || summary["total"] != float64(2) { + t.Fatalf("summary = %#v, want replaced=1 failed=1 total=2", summary) + } + results, _ := data["results"].([]interface{}) + if len(results) != 2 { + t.Fatalf("results len = %d, want 2", len(results)) + } + first, _ := results[0].(map[string]interface{}) + second, _ := results[1].(map[string]interface{}) + if first["status"] != "create_failed" { + t.Fatalf("first status = %v, want create_failed", first["status"]) + } + if second["status"] != "replaced" || second["new_slide_id"] != "new2" { + t.Fatalf("second result = %#v, want replaced with new2", second) + } +} + +func TestReplacePagesContinueOnErrorDeleteFailureIncludesNewSlideID(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_abc/slide", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"slide_id": "new1", "revision_id": 11}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide", + Body: map[string]interface{}{ + "code": 3350001, + "msg": "invalid param", + "data": map[string]interface{}{}, + }, + }) + + pages := `[{"slide_id":"old1","content":""}]` + err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{ + "+replace-pages", + "--presentation", "pres_abc", + "--pages", pages, + "--continue-on-error", + "--as", "user", + }) + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err) + } + + env := decodeReplacePagesEnvelope(t, stdout) + if env.OK { + t.Fatalf("stdout ok = true, want false for partial failure") + } + results, _ := env.Data["results"].([]interface{}) + if len(results) != 1 { + t.Fatalf("results len = %d, want 1", len(results)) + } + first, _ := results[0].(map[string]interface{}) + if first["status"] != "delete_failed" { + t.Fatalf("status = %v, want delete_failed", first["status"]) + } + if first["new_slide_id"] != "new1" { + t.Fatalf("new_slide_id = %v, want new1", first["new_slide_id"]) + } +} + +func TestReplacePagesDryRunPlansOnly(t *testing.T) { + t.Parallel() + + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + + pages := `[{"slide_id":"old2","content":""}]` + err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{ + "+replace-pages", + "--presentation", "pres_abc", + "--pages", pages, + "--dry-run", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var out map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &out); err != nil { + t.Fatalf("decode dry-run: %v\nraw=%s", err, stdout.String()) + } + if out["xml_presentation_id"] != "pres_abc" { + t.Fatalf("xml_presentation_id = %v", out["xml_presentation_id"]) + } + plan, _ := out["plan"].([]interface{}) + if len(plan) != 1 { + t.Fatalf("plan len = %d, want 1", len(plan)) + } + item, _ := plan[0].(map[string]interface{}) + if item["old_slide_id"] != "old2" || item["action"] != "create_before_then_delete_old" { + t.Fatalf("plan item = %#v", item) + } + api, _ := out["api"].([]interface{}) + if len(api) != 2 { + t.Fatalf("api len = %d, want create/delete plan", len(api)) + } +} + +func TestReplacePagesValidationParam(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pages string + }{ + {"empty pages", `[]`}, + {"slide number no longer supported", `[{"slide_number":1,"content":""}]`}, + {"no locator", `[{"content":""}]`}, + {"empty content", `[{"slide_id":"s1","content":" "}]`}, + {"not slide XML", `[{"slide_id":"s1","content":""}]`}, + {"duplicate id", `[{"slide_id":"s1","content":""},{"slide_id":"s1","content":""}]`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{ + "+replace-pages", + "--presentation", "pres_abc", + "--pages", tt.pages, + "--as", "user", + }) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %v, want *errs.ValidationError", err) + } + if ve.Param != "--pages" { + t.Fatalf("Param = %q, want --pages", ve.Param) + } + }) + } +} + +type replacePagesEnvelope struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` +} + +func decodeReplacePagesEnvelope(t *testing.T, stdout interface{ Bytes() []byte }) replacePagesEnvelope { + t.Helper() + var env replacePagesEnvelope + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\nraw=%s", err, string(stdout.Bytes())) + } + if env.Data == nil { + t.Fatalf("missing data: %#v", env) + } + return env +} diff --git a/shortcuts/slides/slides_replace_slide.go b/shortcuts/slides/slides_replace_slide.go index a69107288..b518b2c81 100644 --- a/shortcuts/slides/slides_replace_slide.go +++ b/shortcuts/slides/slides_replace_slide.go @@ -43,8 +43,10 @@ var SlidesReplaceSlide = common.Shortcut{ Command: "+replace-slide", Description: "Replace elements on a slide via block_replace / block_insert parts (auto-injects id + on shape elements)", Risk: "write", - Scopes: []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"}, - AuthTypes: []string{"user", "bot"}, + Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"}, + // wiki:node:read is required only when --presentation is a wiki URL. + ConditionalScopes: []string{"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: "slide-id", Desc: "slide page identifier (slide_id)", Required: true}, @@ -53,9 +55,15 @@ var SlidesReplaceSlide = common.Shortcut{ {Name: "tid", Desc: "transaction id for concurrent-edit locking (usually empty)"}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := parsePresentationRef(runtime.Str("presentation")); err != nil { + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { return err } + if ref.Kind == "wiki" { + if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil { + return err + } + } if strings.TrimSpace(runtime.Str("slide-id")) == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide-id cannot be empty").WithParam("--slide-id") } diff --git a/shortcuts/slides/slides_replace_slide_test.go b/shortcuts/slides/slides_replace_slide_test.go index a4c37db39..97a531b2a 100644 --- a/shortcuts/slides/slides_replace_slide_test.go +++ b/shortcuts/slides/slides_replace_slide_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "reflect" "strings" "testing" @@ -15,6 +16,21 @@ import ( "github.com/larksuite/cli/internal/httpmock" ) +func TestReplaceSlideDeclaredScopes(t *testing.T) { + if got := SlidesReplaceSlide.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) { + t.Fatalf("user preflight scopes = %#v, want slides update/write_only only", got) + } + if got := SlidesReplaceSlide.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) { + t.Fatalf("bot preflight scopes = %#v, want slides update/write_only only", got) + } + + got := SlidesReplaceSlide.DeclaredScopesForIdentity("user") + want := []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("declared scopes = %#v, want %#v", got, want) + } +} + // TestReplaceSlideBlockReplaceInjectsID is the core regression: users write // as replacement and the CLI must stitch id="" // onto the root before sending. The backend returns 3350001 otherwise. diff --git a/shortcuts/slides/slides_screenshot.go b/shortcuts/slides/slides_screenshot.go index a265facde..ba09549b0 100644 --- a/shortcuts/slides/slides_screenshot.go +++ b/shortcuts/slides/slides_screenshot.go @@ -34,7 +34,9 @@ var SlidesScreenshot = common.Shortcut{ Command: "+screenshot", Description: "Save slide screenshots to local files without printing Base64 image data", Risk: "read", - Scopes: []string{"slides:presentation:screenshot"}, + Scopes: []string{}, + // The screenshot API is allowlist-gated for only a few apps, so do not + // advertise/preflight its scope. Let the API fail and let callers degrade. // wiki:node:read is required only when --presentation is a wiki URL. ConditionalScopes: []string{"wiki:node:read"}, AuthTypes: []string{"user", "bot"}, diff --git a/shortcuts/slides/slides_screenshot_test.go b/shortcuts/slides/slides_screenshot_test.go index 8c477b68f..3efae3729 100644 --- a/shortcuts/slides/slides_screenshot_test.go +++ b/shortcuts/slides/slides_screenshot_test.go @@ -17,11 +17,23 @@ import ( ) func TestSlidesScreenshotDeclaredScopes(t *testing.T) { + if got := SlidesScreenshot.ScopesForIdentity("user"); len(got) != 0 { + t.Fatalf("user preflight scopes = %#v, want empty", got) + } + if got := SlidesScreenshot.ScopesForIdentity("bot"); len(got) != 0 { + t.Fatalf("bot preflight scopes = %#v, want empty", got) + } + got := SlidesScreenshot.DeclaredScopesForIdentity("user") - want := []string{"slides:presentation:screenshot", "wiki:node:read"} - if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + want := []string{"wiki:node:read"} + if len(got) != len(want) || got[0] != want[0] { t.Fatalf("declared scopes = %#v, want %#v", got, want) } + for _, scope := range got { + if scope == "slides:presentation:screenshot" { + t.Fatalf("declared scopes must not advertise screenshot scope: %#v", got) + } + } } func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) { diff --git a/shortcuts/slides/slides_xml_get.go b/shortcuts/slides/slides_xml_get.go new file mode 100644 index 000000000..cc2101484 --- /dev/null +++ b/shortcuts/slides/slides_xml_get.go @@ -0,0 +1,144 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "bytes" + "context" + "fmt" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// SlidesXMLGet fetches the full XML presentation content and writes it to a +// local file, keeping the terminal output small for large decks. +var SlidesXMLGet = common.Shortcut{ + Service: "slides", + Command: "+xml-get", + Description: "Fetch full presentation XML and save it to a local file", + Risk: "read", + Scopes: []string{"slides:presentation:read"}, + // wiki:node:read is required only when --presentation is a wiki URL. + ConditionalScopes: []string{"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: "output", Desc: "local XML output path; existing file is overwritten", Required: true}, + {Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision_id; -1 means latest"}, + {Name: "remove-attr-id", Type: "bool", Desc: "remove XML id attributes in the returned content; useful for read-only inspection, not precise block editing"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { + return err + } + if ref.Kind == "wiki" { + if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil { + return err + } + } + if strings.TrimSpace(runtime.Str("output")) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be empty").WithParam("--output") + } + if _, err := runtime.ResolveSavePath(runtime.Str("output")); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output invalid: %v", err).WithParam("--output").WithCause(err) + } + if runtime.Int("revision-id") < -1 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--revision-id must be -1 or a non-negative integer").WithParam("--revision-id") + } + return nil + }, + 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()) + } + presentationID := ref.Token + dry := common.NewDryRunAPI() + if ref.Kind == "wiki" { + presentationID = "" + dry.Desc("2-step orchestration: resolve wiki → fetch full presentation XML"). + GET("/open-apis/wiki/v2/spaces/get_node"). + Desc("[1] Resolve wiki node to slides presentation"). + Params(map[string]interface{}{"token": ref.Token}) + } else { + dry.Desc("Fetch full presentation XML and save it to a local file") + } + params := map[string]interface{}{ + "revision_id": runtime.Int("revision-id"), + } + if runtime.Bool("remove-attr-id") { + params["remove_attr_id"] = true + } + dry.GET(fmt.Sprintf( + "/open-apis/slides_ai/v1/xml_presentations/%s", + validate.EncodePathSegment(presentationID), + )). + Params(params) + return dry.Set("output", runtime.Str("output")).Set("stdout_content", "suppressed; XML content is saved to --output during execution") + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { + return err + } + presentationID, err := resolvePresentationID(runtime, ref) + if err != nil { + return err + } + + params := map[string]interface{}{ + "revision_id": runtime.Int("revision-id"), + } + if runtime.Bool("remove-attr-id") { + params["remove_attr_id"] = true + } + data, err := runtime.CallAPITyped( + "GET", + fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s", validate.EncodePathSegment(presentationID)), + params, + nil, + ) + if err != nil { + return err + } + + presentation := common.GetMap(data, "xml_presentation") + content := common.GetString(presentation, "content") + if content == "" { + return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides xml get returned empty xml_presentation.content") + } + outputPath := runtime.Str("output") + result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{ + ContentType: "application/xml", + ContentLength: int64(len(content)), + }, bytes.NewReader([]byte(content))) + if err != nil { + return common.WrapSaveErrorTyped(err) + } + resolvedPath, err := runtime.ResolveSavePath(outputPath) + if err != nil { + return errs.NewInternalError(errs.SubtypeFileIO, "resolve saved XML path %s: %v", outputPath, err).WithCause(err) + } + + out := map[string]interface{}{ + "xml_presentation_id": presentationID, + "path": resolvedPath, + "size": result.Size(), + "content_saved": true, + } + if revisionID := common.GetFloat(presentation, "revision_id"); revisionID > 0 { + out["revision_id"] = int(revisionID) + } + if runtime.Bool("remove-attr-id") { + out["remove_attr_id"] = true + } + runtime.Out(out, nil) + return nil + }, +} diff --git a/shortcuts/slides/slides_xml_get_test.go b/shortcuts/slides/slides_xml_get_test.go new file mode 100644 index 000000000..f0f3a2ab1 --- /dev/null +++ b/shortcuts/slides/slides_xml_get_test.go @@ -0,0 +1,165 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "errors" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + + xml := `hello` + var capturedQuery url.Values + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "xml_presentation": map[string]interface{}{ + "presentation_id": "pres_abc", + "revision_id": 7, + "content": xml, + }, + }, + }, + OnMatch: func(req *http.Request) { + capturedQuery = req.URL.Query() + }, + }) + + err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{ + "+xml-get", + "--presentation", "pres_abc", + "--output", "readback.xml", + "--revision-id", "7", + "--remove-attr-id", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + path := filepath.Join(dir, "readback.xml") + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read saved XML: %v", err) + } + if string(got) != xml { + t.Fatalf("saved XML = %q, want %q", got, xml) + } + if strings.Contains(stdout.String(), xml) { + t.Fatalf("stdout leaked full XML content: %s", stdout.String()) + } + if got := capturedQuery.Get("revision_id"); got != "7" { + t.Fatalf("revision_id query = %q, want 7", got) + } + if got := capturedQuery.Get("remove_attr_id"); got != "true" { + t.Fatalf("remove_attr_id query = %q, want true", got) + } + + data := decodeShortcutData(t, stdout) + if data["xml_presentation_id"] != "pres_abc" { + t.Fatalf("xml_presentation_id = %v, want pres_abc", data["xml_presentation_id"]) + } + if data["revision_id"] != float64(7) { + t.Fatalf("revision_id = %v, want 7", data["revision_id"]) + } + if data["size"] != float64(len(xml)) { + t.Fatalf("size = %v, want %d", data["size"], len(xml)) + } + gotPath, _ := data["path"].(string) + if !filepath.IsAbs(gotPath) { + t.Fatalf("path = %v, want absolute path", gotPath) + } + if !strings.HasSuffix(gotPath, "readback.xml") { + t.Fatalf("path = %v, want readback.xml suffix", gotPath) + } +} + +func TestSlidesXMLGetResolvesWikiPresentation(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/get_node", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "obj_type": "slides", + "obj_token": "pres_real", + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_real", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "xml_presentation": map[string]interface{}{ + "content": ``, + }, + }, + }, + }) + + err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{ + "+xml-get", + "--presentation", "https://example.feishu.cn/wiki/wikcn123", + "--output", "wiki.xml", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeShortcutData(t, stdout) + if data["xml_presentation_id"] != "pres_real" { + t.Fatalf("xml_presentation_id = %v, want pres_real", data["xml_presentation_id"]) + } +} + +func TestSlidesXMLGetRejectsUnsafeOutputPath(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{ + "+xml-get", + "--presentation", "pres_abc", + "--output", "../readback.xml", + "--as", "user", + }) + if err == nil { + t.Fatal("expected unsafe output path error, got nil") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T %v", err, err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got %T %v", err, err) + } + if validationErr.Param != "--output" { + t.Fatalf("param = %q, want --output", validationErr.Param) + } +} diff --git a/skills/lark-slides/SKILL.md b/skills/lark-slides/SKILL.md index c0b6dac26..4630b663c 100644 --- a/skills/lark-slides/SKILL.md +++ b/skills/lark-slides/SKILL.md @@ -15,7 +15,7 @@ metadata: | 用户需求 | 优先动作 | 关键文档 / 命令 | |----------|----------|-----------------| | 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` | -| 大幅改写页面 | 先回读现有 XML,写入新 plan,再替换或重建相关页面 | `xml_presentations.get`、`+replace-slide`、`lark-slides-edit-workflows.md` | +| 已有 PPT 大幅改写 | 多页整页重建用 `+replace-pages`,单页局部编辑用 `+replace-slide` | `xml_presentations.get`、`lark-slides-replace-pages.md`、`lark-slides-edit-workflows.md` | | 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` | | 读取或分析已有 PPT | 解析 slides/wiki token,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` | | 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面 | `slides +screenshot`、`lark-slides-screenshot.md` | @@ -47,7 +47,7 @@ metadata: **CRITICAL — 使用模板生成或改写页面时,MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。** -**编辑已有幻灯片页面**:优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。 +**编辑已有幻灯片页面**:单个标题、文本块、图片或局部元素优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);已有 Slides 的多页大改优先用 [`+replace-pages`](references/lark-slides-replace-pages.md) 在原 presentation 内批量重建页面,避免 `slides +create` 生成新链接。选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。 ## 身份选择 @@ -82,7 +82,7 @@ lark-cli auth login --domain slides 按需再读: - 创建:[`lark-slides-create.md`](references/lark-slides-create.md) -- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md) +- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md) - 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md) - 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md) - 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) @@ -268,6 +268,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides + [flags]` | [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `` 占位符自动上传) | | [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 ``),最大 20 MB | | [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 ``,不改变页序 | +| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 | 没有 Shortcut 覆盖时使用原生 API。高频资源:`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。 @@ -286,7 +287,7 @@ lark-cli slides [flags] # 调用 API 4. **文本通过 `` 表达**:必须用 `

...

`,不能把文字直接写在 shape 内 5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id` 6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片 -7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create` +7. **编辑已有页面优先原链接更新**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;已有 Slides 的多页整页重建用 `+replace-pages`,不要用 `slides +create` 新建整份 PPT;只有没有 shortcut 覆盖的特殊单页整页操作才手动 `slide.create` + `slide.delete` 8. **`` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 ``」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。 > **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides..` 输出不一致,以后两者为准。 diff --git a/skills/lark-slides/references/lark-slides-edit-workflows.md b/skills/lark-slides/references/lark-slides-edit-workflows.md index 4b7fa3c75..63f6ce5ec 100644 --- a/skills/lark-slides/references/lark-slides-edit-workflows.md +++ b/skills/lark-slides/references/lark-slides-edit-workflows.md @@ -1,6 +1,6 @@ # 编辑已有 PPT:读-改-写闭环 -编辑走 **shortcut [`+replace-slide`](lark-slides-replace-slide.md)**(块级替换 / 插入),配合 `xml_presentation.slide.get` 读原页拿 `block_id`。 +局部编辑走 **shortcut [`+replace-slide`](lark-slides-replace-slide.md)**(块级替换 / 插入),配合 `xml_presentation.slide.get` 读原页拿 `block_id`。已有 Slides 的多页整页重建走 **[`+replace-pages`](lark-slides-replace-pages.md)**,保持原 presentation 链接不变。 > 生成 XML 前**必读** [xml-schema-quick-ref.md](xml-schema-quick-ref.md)。 @@ -11,6 +11,7 @@ | 已知某块的 `block_id`,要换这块内容(改标题、换图、挪坐标) | `block_replace` | 精准替换,原子性好;`replacement` 根 `id` 由 CLI 自动注入为 `block_id` | | 只加 1~N 个元素、不动现有布局 | `block_insert` | 新增不覆盖,可选 `insert_before_block_id` 指定位置 | | 一次动多个元素(如:换标题 + 加图) | 单次 `--parts` 里拼多条 | 整批作为原子事务,任一失败整批不生效;`block_replace` 和 `block_insert` 可混用 | +| 多页版式重建、整页坐标重排 | `+replace-pages` | 原 presentation 内批量 create-before/delete-old,不生成新 Slides 链接 | > **没有字段级 patch**:即便只想改一个 `shape` 的 `topLeftX`,也得把整个块的新 XML 写出来用 `block_replace`。这不是"微调",是块级重写。 @@ -136,6 +137,7 @@ cat parts.json | lark-cli slides +replace-slide --as user --presentation "$PID" ## 相关文档 - [lark-slides-replace-slide.md](lark-slides-replace-slide.md) — +replace-slide shortcut 参数详情 +- [lark-slides-replace-pages.md](lark-slides-replace-pages.md) — 多页整页重建 shortcut - [lark-slides-xml-presentation-slide-get.md](lark-slides-xml-presentation-slide-get.md) — slide.get 参考(拿 `block_id` / `revision_id`) - [lark-slides-xml-presentation-slide-replace.md](lark-slides-xml-presentation-slide-replace.md) — 底层 replace API 参考(一般直接用 shortcut 即可) - [lark-slides-media-upload.md](lark-slides-media-upload.md) — 上传图片拿 file_token diff --git a/skills/lark-slides/references/lark-slides-replace-pages.md b/skills/lark-slides/references/lark-slides-replace-pages.md new file mode 100644 index 000000000..df503dcfd --- /dev/null +++ b/skills/lark-slides/references/lark-slides-replace-pages.md @@ -0,0 +1,95 @@ +# slides +replace-pages(多页整页重建) + +批量替换已有演示文稿里的多个页面,保持原 `xml_presentation_id` 和原 Slides 链接不变。适合多页版式大改、坐标重排、整页视觉重建;单个文本框、图片或 shape 的局部编辑仍优先用 [`+replace-slide`](lark-slides-replace-slide.md)。 + +> 重要:这是多步编排,不是后端原子事务。CLI 对每页执行“先创建新页到旧页前,再删除旧页”;创建失败时旧页会保留。删除失败时可能出现新旧页同时存在,需要按返回结果继续处理。 + +## 命令 + +```bash +lark-cli slides +replace-pages \ + --as user \ + --presentation \ + --pages @pages.json +``` + +## 参数 + +| 参数 | 必需 | 说明 | +|------|------|------| +| `--presentation` | 是 | `xml_presentation_id`、`/slides/` URL 或 `/wiki/` URL | +| `--pages` | 是 | JSON 数组,每项包含 `slide_id` 和 `content`;支持 literal、`@file`、stdin `-` | +| `--dry-run` | 否 | 基于 `slide_id` 输入输出替换计划,不执行 create/delete | +| `--continue-on-error` | 否 | 默认失败即停;开启后继续处理后续页,并在结果中标记失败项 | +| `--validate-only` | 否 | 只校验输入并生成替换计划,不执行 Slides get/create/delete | + +## pages.json + +```json +[ + { + "slide_id": "slide_short_id_1", + "content": "" + }, + { + "slide_id": "slide_short_id_2", + "content": "" + } +] +``` + +规则: + +- 每项必须提供 `slide_id`;不支持 `slide_number`。 +- `content` 必须是完整 `...` XML。 +- 同一批次不能重复 `slide_id`。 +- CLI 不会回读整份 presentation;如果 `slide_id` 已失效,create/delete 阶段会返回对应错误。 + +## Dry Run + +```bash +lark-cli slides +replace-pages --as user \ + --presentation "$PID" \ + --pages @pages.json \ + --dry-run +``` + +输出包含 `xml_presentation_id`、`pages_count`、`plan`,以及每页的 `old_slide_id`、`insert_before_slide_id` 和动作 `create_before_then_delete_old`。Dry-run 只基于输入的 `slide_id` 构造计划,不会调用 `xml_presentations.get`,也不会执行 create/delete。 + +## 成功输出 + +```json +{ + "xml_presentation_id": "xxx", + "pages_count": 2, + "status": "completed", + "summary": { + "replaced": 2, + "failed": 0, + "total": 2 + }, + "results": [ + { + "old_slide_id": "old3", + "new_slide_id": "new3", + "status": "replaced" + } + ], + "revision_id": 123 +} +``` + +如果使用 `--continue-on-error` 且任一页面失败,CLI 会继续处理后续页,但最终以 partial failure 非零退出;stdout 仍保留完整 `results`,顶层 `ok` 为 `false`,`status` 为 `partial_failure`。 + +`status` 可能为: + +- `replaced`:新页创建成功,旧页删除成功。 +- `create_failed`:新页创建失败,旧页保留。 +- `delete_failed`:新页已创建,但旧页删除失败。 + +## 使用建议 + +1. 大幅改写前先 `xml_presentations.get` 保存当前 XML,并记录要替换页面的 `slide_id`。 +2. 生成只含 `slide_id` 的 `pages.json` 后先跑 `--dry-run` 或 `--validate-only`。 +3. 默认不要开 `--continue-on-error`,除非能接受部分页面已替换。 +4. 替换后再回读全文 XML 并截图检查,确认页序、视觉和文本没有破损。 diff --git a/skills/lark-slides/references/lark-slides-screenshot.md b/skills/lark-slides/references/lark-slides-screenshot.md index ec4f29c4d..23557fcb9 100644 --- a/skills/lark-slides/references/lark-slides-screenshot.md +++ b/skills/lark-slides/references/lark-slides-screenshot.md @@ -4,7 +4,7 @@ 获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图;传入 `--content` 时用于直接渲染单个 `` XML 片段预览。本 shortcut 会在 CLI 进程内解码并写入文件,stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。 -注意:该截图能力对应的权限受白名单控制。只有在白名单内的应用才能申请该权限;不在白名单内的应用即使命令和参数正确,服务端仍可能返回权限或能力不可用相关错误。 +注意:该截图能力受应用白名单限制,绝大多数应用不可用。截图失败时不要引导用户申请 `slides:presentation:screenshot` 权限;记录错误后降级到 XML 读回、结构 lint、文本重叠检查等非截图检查路径。 ## 命令