mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
56fd29e611
Add an in-place whole-page slide update shortcut with validation, aliases, docs, unit tests, and dry-run E2E coverage. Deprecate the superseded +replace-pages: the binary keeps the command working for a deprecation window, with the replacement named in its --help description and in a `deprecated` field on every output (dry-run, validate-only and real runs), while the skill no longer routes to it. Multi-page updates now call +update-slide once per page. The XML/revision helpers it shared with +add-slide / +delete-slide move to slides_shared.go so its eventual removal cannot break them. +add-slide and +delete-slide now declare --presentation through the shared presentation-ref flag, so they accept the same alias spellings (--token, --url, ...) as every other slides shortcut.
403 lines
14 KiB
Go
403 lines
14 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package slides
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/validate"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
const replacePagesInitialRevisionID = -1
|
|
|
|
// replacePagesDeprecationNote is surfaced in every +replace-pages output —
|
|
// dry-run, validate-only and real runs — so callers that never read --help
|
|
// still see the deprecation and the replacement.
|
|
const replacePagesDeprecationNote = "+replace-pages is deprecated: it recreates each page, changing slide_id and every element id, and the old-page delete is irreversible. Use `slides +update-slide` once per page instead — it rewrites the page in place, keeping slide_id and page order; elements written back in --content with their original ids keep those ids"
|
|
|
|
// 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.
|
|
//
|
|
// Deprecated: use `slides +update-slide` once per page instead — it rewrites
|
|
// the page in place, keeping slide_id and page order; elements carried over in
|
|
// --content with their original ids keep them (omitted elements are deleted,
|
|
// id-less ones are inserted fresh). Going through create+delete here changes
|
|
// slide_id and regenerates all element ids, which breaks comments and deep
|
|
// links anchored to them, and the delete is not reversible. The lark-slides skill no longer routes here; the command
|
|
// itself stays for a deprecation window so existing callers keep working —
|
|
// every run says so in --help, in its description and in the output envelope.
|
|
// Delete the file once the window closes; the shared XML/revision helpers
|
|
// already live in slides_shared.go so +add-slide / +delete-slide survive that
|
|
// removal.
|
|
var SlidesReplacePages = common.Shortcut{
|
|
Service: "slides",
|
|
Command: "+replace-pages",
|
|
Description: "Deprecated — use +update-slide once per page (in place: keeps slide_id and page order; elements written back with their original ids keep them). This rebuild changes slide_id and every element id; 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{
|
|
requiredPresentationRefFlag(),
|
|
{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").
|
|
Set("deprecated", replacePagesDeprecationNote)
|
|
},
|
|
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",
|
|
"deprecated": replacePagesDeprecationNote,
|
|
}, 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",
|
|
"deprecated": replacePagesDeprecationNote,
|
|
}
|
|
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
|
|
Issues interface{}
|
|
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 <slide> XML element: %v", i, err).WithParam("--pages").WithCause(err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateCompleteSlideXML, invalidSlideXMLStructureError and revisionFromData
|
|
// live in slides_shared.go: +add-slide and +delete-slide use them too, and they
|
|
// must survive this file's eventual removal.
|
|
|
|
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": "<latest_or_revision_returned_by_previous_step>"}).
|
|
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": "<revision_returned_by_create>",
|
|
})
|
|
}
|
|
}
|
|
|
|
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 issues, ok := createData["issues"]; ok {
|
|
result.Issues = issues
|
|
}
|
|
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 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.Issues != nil {
|
|
m["issues"] = result.Issues
|
|
}
|
|
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
|
|
}
|