mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Compare commits
2 Commits
v1.0.63
...
feat/slide
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40a828cb5e | ||
|
|
c0a961dbc3 |
@@ -14,5 +14,8 @@ func Shortcuts() []common.Shortcut {
|
||||
SlidesReplacePages,
|
||||
SlidesScreenshot,
|
||||
SlidesXMLGet,
|
||||
SlidesHistoryList,
|
||||
SlidesHistoryRevert,
|
||||
SlidesHistoryRevertStatus,
|
||||
}
|
||||
}
|
||||
|
||||
299
shortcuts/slides/slides_history.go
Normal file
299
shortcuts/slides/slides_history.go
Normal file
@@ -0,0 +1,299 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type slidesHistoryListSpec struct {
|
||||
PageSize int
|
||||
PageToken string
|
||||
}
|
||||
|
||||
type slidesHistoryRevertSpec struct {
|
||||
HistoryVersionID string
|
||||
WaitTimeoutMs int
|
||||
}
|
||||
|
||||
type slidesHistoryRevertStatusSpec struct {
|
||||
TaskID string
|
||||
}
|
||||
|
||||
func parseSlidesHistoryPresentation(runtime *common.RuntimeContext) (presentationRef, error) {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return presentationRef{}, err
|
||||
}
|
||||
if ref.Kind == "wiki" {
|
||||
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
|
||||
return presentationRef{}, err
|
||||
}
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
func validateSlidesHistoryPageSize(pageSize int) error {
|
||||
if pageSize < 1 || pageSize > 20 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --page-size %d: must be between 1 and 20", pageSize).WithParam("--page-size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSlidesHistoryVersionID(historyVersionID string) error {
|
||||
version, err := strconv.ParseInt(strings.TrimSpace(historyVersionID), 10, 64)
|
||||
if err != nil || version <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by slides +history-list").WithParam("--history-version-id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSlidesHistoryWaitTimeout(timeoutMs int) error {
|
||||
if timeoutMs < 0 || timeoutMs > 30000 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --wait-timeout-ms %d: must be between 0 and 30000", timeoutMs).WithParam("--wait-timeout-ms")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func slidesHistoryListParams(spec slidesHistoryListSpec) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"page_size": spec.PageSize,
|
||||
}
|
||||
if spec.PageToken != "" {
|
||||
params["page_token"] = spec.PageToken
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func slidesHistoryRevertBody(spec slidesHistoryRevertSpec) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"history_version_id": spec.HistoryVersionID,
|
||||
"wait_timeout_ms": spec.WaitTimeoutMs,
|
||||
}
|
||||
}
|
||||
|
||||
func slidesHistoryStatusParams(spec slidesHistoryRevertStatusSpec) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"task_id": spec.TaskID,
|
||||
}
|
||||
}
|
||||
|
||||
func slidesHistoryAPIPath(presentationID, suffix string) string {
|
||||
return fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/%s", validate.EncodePathSegment(presentationID), suffix)
|
||||
}
|
||||
|
||||
func newSlidesHistoryDryRun(ref presentationRef, desc string) (*common.DryRunAPI, string) {
|
||||
dry := common.NewDryRunAPI()
|
||||
presentationID := ref.Token
|
||||
if ref.Kind == "wiki" {
|
||||
presentationID = "<resolved_slides_token>"
|
||||
dry.Desc("2-step orchestration: resolve wiki then " + desc).
|
||||
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("OpenAPI: " + desc)
|
||||
}
|
||||
return dry, presentationID
|
||||
}
|
||||
|
||||
// SlidesHistoryList lists history versions of a Slides XML presentation.
|
||||
var SlidesHistoryList = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+history-list",
|
||||
Description: "List Slides presentation history versions",
|
||||
Risk: "read",
|
||||
Scopes: []string{"slides:presentation:read"},
|
||||
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: "page-size", Type: "int", Default: "20", Desc: "history entries to return, range 1-20"},
|
||||
{Name: "page-token", Desc: "pagination token from the previous page's page_token"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseSlidesHistoryPresentation(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateSlidesHistoryPageSize(runtime.Int("page-size"))
|
||||
},
|
||||
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())
|
||||
}
|
||||
spec := slidesHistoryListSpec{
|
||||
PageSize: runtime.Int("page-size"),
|
||||
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||
}
|
||||
dry, presentationID := newSlidesHistoryDryRun(ref, "list Slides history versions")
|
||||
return dry.
|
||||
GET(slidesHistoryAPIPath(presentationID, "histories")).
|
||||
Params(slidesHistoryListParams(spec)).
|
||||
Set("xml_presentation_id", presentationID)
|
||||
},
|
||||
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
|
||||
}
|
||||
spec := slidesHistoryListSpec{
|
||||
PageSize: runtime.Int("page-size"),
|
||||
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodGet,
|
||||
slidesHistoryAPIPath(presentationID, "histories"),
|
||||
slidesHistoryListParams(spec),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SlidesHistoryRevert reverts a Slides XML presentation to a history version.
|
||||
var SlidesHistoryRevert = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+history-revert",
|
||||
Description: "Revert a Slides presentation to a historical version",
|
||||
Risk: "write",
|
||||
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"},
|
||||
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: "history-version-id", Desc: "history_version_id from slides +history-list to revert to", Required: true},
|
||||
{Name: "wait-timeout-ms", Type: "int", Default: "30000", Desc: "milliseconds to wait for revert completion before returning, range 0-30000"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseSlidesHistoryPresentation(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSlidesHistoryVersionID(runtime.Str("history-version-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateSlidesHistoryWaitTimeout(runtime.Int("wait-timeout-ms"))
|
||||
},
|
||||
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())
|
||||
}
|
||||
spec := slidesHistoryRevertSpec{
|
||||
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
|
||||
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
|
||||
}
|
||||
dry, presentationID := newSlidesHistoryDryRun(ref, "revert Slides history")
|
||||
return dry.
|
||||
POST(slidesHistoryAPIPath(presentationID, "history/revert")).
|
||||
Body(slidesHistoryRevertBody(spec)).
|
||||
Set("xml_presentation_id", presentationID)
|
||||
},
|
||||
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
|
||||
}
|
||||
spec := slidesHistoryRevertSpec{
|
||||
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
|
||||
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodPost,
|
||||
slidesHistoryAPIPath(presentationID, "history/revert"),
|
||||
nil,
|
||||
slidesHistoryRevertBody(spec),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SlidesHistoryRevertStatus gets the status of a Slides history revert task.
|
||||
var SlidesHistoryRevertStatus = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+history-revert-status",
|
||||
Description: "Get Slides history revert task status",
|
||||
Risk: "read",
|
||||
Scopes: []string{"slides:presentation:read"},
|
||||
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: "task-id", Desc: "task_id returned by slides +history-revert", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseSlidesHistoryPresentation(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("task-id")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required").WithParam("--task-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())
|
||||
}
|
||||
spec := slidesHistoryRevertStatusSpec{
|
||||
TaskID: strings.TrimSpace(runtime.Str("task-id")),
|
||||
}
|
||||
dry, presentationID := newSlidesHistoryDryRun(ref, "get Slides history revert status")
|
||||
return dry.
|
||||
GET(slidesHistoryAPIPath(presentationID, "history/revert_status")).
|
||||
Params(slidesHistoryStatusParams(spec)).
|
||||
Set("xml_presentation_id", presentationID)
|
||||
},
|
||||
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
|
||||
}
|
||||
spec := slidesHistoryRevertStatusSpec{
|
||||
TaskID: strings.TrimSpace(runtime.Str("task-id")),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodGet,
|
||||
slidesHistoryAPIPath(presentationID, "history/revert_status"),
|
||||
slidesHistoryStatusParams(spec),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
440
shortcuts/slides/slides_history_test.go
Normal file
440
shortcuts/slides/slides_history_test.go
Normal file
@@ -0,0 +1,440 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestSlidesHistoryDeclaredScopes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantBase []string
|
||||
wantFull []string
|
||||
}{
|
||||
{
|
||||
name: "list",
|
||||
shortcut: SlidesHistoryList,
|
||||
wantBase: []string{"slides:presentation:read"},
|
||||
wantFull: []string{"slides:presentation:read", "wiki:node:read"},
|
||||
},
|
||||
{
|
||||
name: "revert",
|
||||
shortcut: SlidesHistoryRevert,
|
||||
wantBase: []string{"slides:presentation:update", "slides:presentation:write_only"},
|
||||
wantFull: []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
shortcut: SlidesHistoryRevertStatus,
|
||||
wantBase: []string{"slides:presentation:read"},
|
||||
wantFull: []string{"slides:presentation:read", "wiki:node:read"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.shortcut.ScopesForIdentity("user"); !reflect.DeepEqual(got, tt.wantBase) {
|
||||
t.Fatalf("user preflight scopes = %#v, want %#v", got, tt.wantBase)
|
||||
}
|
||||
if got := tt.shortcut.ScopesForIdentity("bot"); !reflect.DeepEqual(got, tt.wantBase) {
|
||||
t.Fatalf("bot preflight scopes = %#v, want %#v", got, tt.wantBase)
|
||||
}
|
||||
if got := tt.shortcut.DeclaredScopesForIdentity("user"); !reflect.DeepEqual(got, tt.wantFull) {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, tt.wantFull)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesHistoryValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
param string
|
||||
}{
|
||||
{
|
||||
name: "list rejects unsupported presentation input",
|
||||
shortcut: SlidesHistoryList,
|
||||
args: []string{"+history-list", "--presentation", "tmp/wiki/wikcn123", "--as", "bot"},
|
||||
param: "--presentation",
|
||||
},
|
||||
{
|
||||
name: "list rejects invalid page size",
|
||||
shortcut: SlidesHistoryList,
|
||||
args: []string{"+history-list", "--presentation", "presHistory", "--page-size", "0", "--as", "bot"},
|
||||
param: "--page-size",
|
||||
},
|
||||
{
|
||||
name: "revert rejects invalid history version id",
|
||||
shortcut: SlidesHistoryRevert,
|
||||
args: []string{"+history-revert", "--presentation", "presHistory", "--history-version-id", "0", "--as", "bot"},
|
||||
param: "--history-version-id",
|
||||
},
|
||||
{
|
||||
name: "revert rejects invalid wait timeout",
|
||||
shortcut: SlidesHistoryRevert,
|
||||
args: []string{"+history-revert", "--presentation", "presHistory", "--history-version-id", "10", "--wait-timeout-ms", "30001", "--as", "bot"},
|
||||
param: "--wait-timeout-ms",
|
||||
},
|
||||
{
|
||||
name: "status rejects empty task id",
|
||||
shortcut: SlidesHistoryRevertStatus,
|
||||
args: []string{"+history-revert-status", "--presentation", "presHistory", "--task-id", "", "--as", "bot"},
|
||||
param: "--task-id",
|
||||
},
|
||||
}
|
||||
|
||||
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, tt.shortcut, tt.args)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != tt.param {
|
||||
t.Fatalf("param = %q, want %q (err: %v)", validationErr.Param, tt.param, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesHistoryDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
listCmd := newSlidesHistoryRuntimeCmd(t, SlidesHistoryList, map[string]string{
|
||||
"presentation": "presHistoryDryRun",
|
||||
"page-size": "5",
|
||||
"page-token": "page_token_1",
|
||||
})
|
||||
listDry := decodeSlidesHistoryDryRun(t, SlidesHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(listCmd, nil)))
|
||||
if got, want := listDry.API[0].URL, "/open-apis/slides_ai/v1/xml_presentations/presHistoryDryRun/histories"; got != want {
|
||||
t.Fatalf("list dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := int(listDry.API[0].Params["page_size"].(float64)); got != 5 {
|
||||
t.Fatalf("list page_size = %d, want 5", got)
|
||||
}
|
||||
if got := listDry.API[0].Params["page_token"]; got != "page_token_1" {
|
||||
t.Fatalf("list page_token = %#v, want page_token_1", got)
|
||||
}
|
||||
|
||||
revertCmd := newSlidesHistoryRuntimeCmd(t, SlidesHistoryRevert, map[string]string{
|
||||
"presentation": "presHistoryDryRun",
|
||||
"history-version-id": "42",
|
||||
"wait-timeout-ms": "30000",
|
||||
})
|
||||
revertDry := decodeSlidesHistoryDryRun(t, SlidesHistoryRevert.DryRun(context.Background(), common.TestNewRuntimeContext(revertCmd, nil)))
|
||||
if got, want := revertDry.API[0].URL, "/open-apis/slides_ai/v1/xml_presentations/presHistoryDryRun/history/revert"; got != want {
|
||||
t.Fatalf("revert dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := revertDry.API[0].Body["history_version_id"]; got != "42" {
|
||||
t.Fatalf("revert history_version_id = %#v, want 42", got)
|
||||
}
|
||||
if got := int(revertDry.API[0].Body["wait_timeout_ms"].(float64)); got != 30000 {
|
||||
t.Fatalf("revert wait_timeout_ms = %d, want 30000", got)
|
||||
}
|
||||
|
||||
statusCmd := newSlidesHistoryRuntimeCmd(t, SlidesHistoryRevertStatus, map[string]string{
|
||||
"presentation": "presHistoryDryRun",
|
||||
"task-id": "task_1",
|
||||
})
|
||||
statusDry := decodeSlidesHistoryDryRun(t, SlidesHistoryRevertStatus.DryRun(context.Background(), common.TestNewRuntimeContext(statusCmd, nil)))
|
||||
if got, want := statusDry.API[0].URL, "/open-apis/slides_ai/v1/xml_presentations/presHistoryDryRun/history/revert_status"; got != want {
|
||||
t.Fatalf("status dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := statusDry.API[0].Params["task_id"]; got != "task_1" {
|
||||
t.Fatalf("status task_id = %#v, want task_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesHistoryDryRunWithWikiPresentation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newSlidesHistoryRuntimeCmd(t, SlidesHistoryList, map[string]string{
|
||||
"presentation": "https://example.feishu.cn/wiki/wikcn123",
|
||||
"page-size": "20",
|
||||
})
|
||||
dry := decodeSlidesHistoryDryRun(t, SlidesHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(cmd, nil)))
|
||||
if len(dry.API) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2: %#v", len(dry.API), dry.API)
|
||||
}
|
||||
if got, want := dry.API[0].URL, "/open-apis/wiki/v2/spaces/get_node"; got != want {
|
||||
t.Fatalf("wiki dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := dry.API[0].Params["token"]; got != "wikcn123" {
|
||||
t.Fatalf("wiki node parameter mismatch: got %#v, want placeholder node id", got)
|
||||
}
|
||||
if got, want := dry.API[1].URL, "/open-apis/slides_ai/v1/xml_presentations/%3Cresolved_slides_token%3E/histories"; got != want {
|
||||
t.Fatalf("history dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesHistoryExecuteList(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
var capturedQuery url.Values
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/presHistory/histories",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"entries": []interface{}{
|
||||
map[string]interface{}{
|
||||
"revision_id": float64(42),
|
||||
"history_version_id": "11",
|
||||
"edit_time": "1780000000",
|
||||
"type": float64(1),
|
||||
"editor_ids": []interface{}{"ou_1"},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "page_token_2",
|
||||
},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.Query()
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesHistoryList, []string{
|
||||
"+history-list",
|
||||
"--presentation", "presHistory",
|
||||
"--page-size", "5",
|
||||
"--page-token", "page_token_1",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got := capturedQuery.Get("page_size"); got != "5" {
|
||||
t.Fatalf("page_size query = %q, want 5", got)
|
||||
}
|
||||
if got := capturedQuery.Get("page_token"); got != "page_token_1" {
|
||||
t.Fatalf("page_token query = %q, want page_token_1", got)
|
||||
}
|
||||
|
||||
data := decodeSlidesHistoryEnvelope(t, stdout)
|
||||
if got := data["page_token"]; got != "page_token_2" {
|
||||
t.Fatalf("page_token = %#v, want page_token_2", got)
|
||||
}
|
||||
entries, _ := data["entries"].([]interface{})
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("entries = %#v, want one entry", data["entries"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesHistoryExecuteRevert(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/presHistory/history/revert",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"task_id": "task_1",
|
||||
"status": "running",
|
||||
"history_version_id": "42",
|
||||
"poll_after_ms": float64(10000),
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesHistoryRevert, []string{
|
||||
"+history-revert",
|
||||
"--presentation", "presHistory",
|
||||
"--history-version-id", "42",
|
||||
"--wait-timeout-ms", "0",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode revert body: %v\nraw=%s", err, stub.CapturedBody)
|
||||
}
|
||||
if got := body["history_version_id"]; got != "42" {
|
||||
t.Fatalf("history_version_id = %#v, want 42", got)
|
||||
}
|
||||
if got := int(body["wait_timeout_ms"].(float64)); got != 0 {
|
||||
t.Fatalf("wait_timeout_ms = %d, want 0", got)
|
||||
}
|
||||
|
||||
data := decodeSlidesHistoryEnvelope(t, stdout)
|
||||
if got := data["task_id"]; got != "task_1" {
|
||||
t.Fatalf("task_id = %#v, want task_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesHistoryExecuteRevertStatus(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
var capturedQuery url.Values
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/presHistory/history/revert_status",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"status": "done",
|
||||
"history_version_id": "11",
|
||||
},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.Query()
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesHistoryRevertStatus, []string{
|
||||
"+history-revert-status",
|
||||
"--presentation", "presHistory",
|
||||
"--task-id", "task_1",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got := capturedQuery.Get("task_id"); got != "task_1" {
|
||||
t.Fatalf("task_id query = %q, want task_1", got)
|
||||
}
|
||||
data := decodeSlidesHistoryEnvelope(t, stdout)
|
||||
if got := data["status"]; got != "done" {
|
||||
t.Fatalf("status = %#v, want done", got)
|
||||
}
|
||||
if got := data["history_version_id"]; got != "11" {
|
||||
t.Fatalf("history_version_id = %#v, want 11", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesHistoryExecuteResolvesWikiPresentation(t *testing.T) {
|
||||
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": "presReal",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/presReal/histories",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"entries": []interface{}{},
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesHistoryList, []string{
|
||||
"+history-list",
|
||||
"--presentation", "https://example.feishu.cn/wiki/wikcn123",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeSlidesHistoryEnvelope(t, stdout)
|
||||
if got := data["has_more"]; got != false {
|
||||
t.Fatalf("has_more = %#v, want false", got)
|
||||
}
|
||||
}
|
||||
|
||||
type slidesHistoryDryRunOutput struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
|
||||
func newSlidesHistoryRuntimeCmd(t *testing.T, shortcut common.Shortcut, values map[string]string) *cobra.Command {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: shortcut.Command}
|
||||
for _, flag := range shortcut.Flags {
|
||||
switch flag.Type {
|
||||
case "int":
|
||||
cmd.Flags().Int(flag.Name, 0, flag.Desc)
|
||||
default:
|
||||
cmd.Flags().String(flag.Name, flag.Default, flag.Desc)
|
||||
}
|
||||
}
|
||||
for name, value := range values {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func decodeSlidesHistoryDryRun(t *testing.T, dry *common.DryRunAPI) slidesHistoryDryRunOutput {
|
||||
t.Helper()
|
||||
|
||||
raw, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run: %v", err)
|
||||
}
|
||||
var out slidesHistoryDryRunOutput
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\nraw=%s", err, raw)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decodeSlidesHistoryEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode envelope: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data == nil {
|
||||
t.Fatalf("missing data in envelope: %#v", envelope)
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -5,7 +5,7 @@ description: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli slides --help"
|
||||
cliHelp: "lark-cli slides --help; lark-cli slides +create --help; lark-cli slides +xml-get --help; lark-cli slides +replace-slide --help; lark-cli slides +replace-pages --help; lark-cli slides +history-list --help; lark-cli slides +history-revert --help; lark-cli slides +history-revert-status --help"
|
||||
---
|
||||
|
||||
# slides (v1)
|
||||
@@ -18,6 +18,7 @@ metadata:
|
||||
| 已有 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` |
|
||||
| 查看或回滚历史版本 | 先用 `+history-list` 找 `history_version_id`,再 `+history-revert`,必要时 `+history-revert-status` 轮询 | [`lark-slides-history.md`](references/lark-slides-history.md) |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides` 的 `@./path` 占位符 |
|
||||
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
|
||||
@@ -28,6 +29,8 @@ metadata:
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),认证、权限和全局参数均以 lark-shared 为准。**
|
||||
|
||||
**CRITICAL — 查看或回滚历史版本前,MUST 先读取 [`lark-slides-history.md`](references/lark-slides-history.md)。回滚接口只接受 `history_version_id`,不要把 `revision_id` 直接传给 `+history-revert`。**
|
||||
|
||||
**CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
|
||||
@@ -83,6 +86,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-replace-pages.md`](references/lark-slides-replace-pages.md)
|
||||
- 历史版本:[`lark-slides-history.md`](references/lark-slides-history.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)
|
||||
|
||||
122
skills/lark-slides/references/lark-slides-history.md
Normal file
122
skills/lark-slides/references/lark-slides-history.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# slides history(历史版本与回滚)
|
||||
|
||||
用于查看 Slides XML presentation 历史版本、按 `history_version_id` 回滚,以及查询回滚任务状态。
|
||||
|
||||
## 安全流程
|
||||
|
||||
1. 先用分页接口 `+history-list` 找到目标版本的 `history_version_id`。
|
||||
2. 如果用户指定的是 `revision_id`,不要假设它唯一,也不要把 `revision_id` 直接传给 `+history-revert`。先拉一页并在 `entries[]` 中筛选 `revision_id` 相同的候选;如果未匹配到且 `has_more=true`,继续用 `page_token` 翻页;如果已匹配到候选,最多额外再拉一页补齐可能跨页的相邻候选。最终优先根据用户目标时间与 `edit_time` 的接近程度选择最合适的一条,取同一条的 `history_version_id`;如果没有目标时间,或多个候选无法可靠区分,再向用户展示候选版本(`history_version_id`、`revision_id`、`edit_time`、`name/description`)并确认后回滚。
|
||||
3. 如果用户指定的是某一时刻但没有指定 `revision_id`,按 `entries[].edit_time` 匹配;优先选择不晚于目标时刻的最近一条历史记录,无法明确匹配时先向用户确认候选版本。
|
||||
4. 再用 `+history-revert --history-version-id <history_version_id>` 发起回滚。默认最多等待 30 秒;如果返回 `status: running`,记录 `task_id`。
|
||||
5. 用 `+history-revert-status` 轮询 `task_id`,直到状态不再是 `running`。
|
||||
6. 回滚完成后,用 `slides +xml-get` 或 `slides xml_presentations get` 读取演示文稿确认内容。
|
||||
|
||||
## 按 revision_id 或时间点回滚
|
||||
|
||||
当用户说“回滚到 revision_id=42”“恢复到昨天下午 3 点的版本”这类需求时,流程是:
|
||||
|
||||
1. 执行 `slides +history-list --presentation <presentation>` 获取第一页历史记录;`+history-list` 是分页接口,只有 `has_more=true` 且还需要更多候选时才继续传 `--page-token` 翻页。
|
||||
2. 如果用户给出 `revision_id`:先筛选当前页中 `entries[].revision_id == 用户给出的 revision_id`。如果未命中且 `has_more=true`,继续拉下一页;如果已经命中候选,最多额外再拉一页,补齐同一个 `revision_id` 可能跨页出现的相邻 `history_version_id`。若用户同时给出目标时间,在候选里选择 `edit_time` 与目标时间最接近的一条;若未给目标时间但候选只有一条,可直接使用;若多个候选无法可靠区分,不要自行取第一条,向用户展示候选并确认。
|
||||
3. 如果用户只给出时间:用 `entries[].edit_time` 匹配,选择目标时刻之前最近的一条;如果用户表达的是“最接近某时刻”,则选择绝对时间差最小的一条。
|
||||
4. 从最终匹配条目读取 `history_version_id`。`history_version_id` 对应服务端 `minor_history.version`,这是回滚接口需要的 ID。
|
||||
5. 执行 `slides +history-revert --presentation <presentation> --history-version-id <history_version_id>`。
|
||||
|
||||
候选确认时使用类似格式:
|
||||
|
||||
```text
|
||||
同一个 revision_id 命中多个历史版本,请确认要回滚哪一条:
|
||||
- history_version_id=11 revision_id=42 edit_time=2026-06-22T12:24:45Z name=...
|
||||
- history_version_id=12 revision_id=42 edit_time=2026-06-22T12:25:14Z name=...
|
||||
```
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 列出历史版本
|
||||
lark-cli slides +history-list --presentation "<slides_url_or_token>" --page-size 20
|
||||
|
||||
# 翻页
|
||||
lark-cli slides +history-list --presentation "<slides_url_or_token>" --page-size 20 --page-token "<page_token>"
|
||||
|
||||
# 回滚到指定 history_version_id(默认等待 30000ms)
|
||||
lark-cli slides +history-revert --presentation "<slides_url_or_token>" --history-version-id 42
|
||||
|
||||
# 只发起任务,不等待
|
||||
lark-cli slides +history-revert --presentation "<slides_url_or_token>" --history-version-id 42 --wait-timeout-ms 0
|
||||
|
||||
# 查询回滚任务状态
|
||||
lark-cli slides +history-revert-status --presentation "<slides_url_or_token>" --task-id "<task_id>"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 命令 | 参数 | 必填 | 说明 |
|
||||
|-|-|-|-|
|
||||
| `+history-list` | `--presentation` | 是 | `xml_presentation_id`、Slides URL,或可解析为 Slides 的 wiki URL |
|
||||
| `+history-list` | `--page-size` | 否 | 返回条数,范围 `1-20`,默认 `20` |
|
||||
| `+history-list` | `--page-token` | 否 | 上一页返回的 `page_token` |
|
||||
| `+history-revert` | `--presentation` | 是 | 同一个演示文稿 |
|
||||
| `+history-revert` | `--history-version-id` | 是 | `+history-list` 返回的 `history_version_id`,必须大于 0 |
|
||||
| `+history-revert` | `--wait-timeout-ms` | 否 | 等待回滚完成的毫秒数,范围 `0-30000`,默认 `30000` |
|
||||
| `+history-revert-status` | `--presentation` | 是 | 同一个演示文稿 |
|
||||
| `+history-revert-status` | `--task-id` | 是 | `+history-revert` 返回的 `task_id` |
|
||||
|
||||
## 返回值要点
|
||||
|
||||
`+history-list` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"revision_id": 42,
|
||||
"history_version_id": "11",
|
||||
"edit_time": "1780000000",
|
||||
"type": 1,
|
||||
"name": "版本名",
|
||||
"description": "版本说明",
|
||||
"editor_ids": ["ou_xxx"]
|
||||
}
|
||||
],
|
||||
"has_more": true,
|
||||
"page_token": "page_token"
|
||||
}
|
||||
```
|
||||
|
||||
`+history-revert` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "task_xxx",
|
||||
"status": "running",
|
||||
"history_version_id": "11",
|
||||
"poll_after_ms": 10000
|
||||
}
|
||||
```
|
||||
|
||||
`+history-revert-status` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "partial_failed",
|
||||
"history_version_id": "11",
|
||||
"failed_block_tokens": ["blk_xxx"]
|
||||
}
|
||||
```
|
||||
|
||||
`status` 可能是 `running`、`done`、`partial_failed`、`failed`。当状态是 `partial_failed` 或 `failed` 时,优先检查 `failed_block_tokens`。
|
||||
|
||||
## 回滚后验证
|
||||
|
||||
回滚成功后必须读取一次当前内容确认:
|
||||
|
||||
```bash
|
||||
lark-cli slides +xml-get --presentation "<slides_url_or_token>" --output ./presentation.xml
|
||||
```
|
||||
|
||||
如果只需要快速检查返回结构,也可以走 raw OpenAPI:
|
||||
|
||||
```bash
|
||||
lark-cli api get "/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>" \
|
||||
--params '{"revision_id":-1}'
|
||||
```
|
||||
99
tests/cli_e2e/slides/slides_history_dryrun_test.go
Normal file
99
tests/cli_e2e/slides/slides_history_dryrun_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestSlidesHistoryDryRunE2E(t *testing.T) {
|
||||
setSlidesDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantURL string
|
||||
wantVerb string
|
||||
assertion func(t *testing.T, stdout string)
|
||||
}{
|
||||
{
|
||||
name: "list",
|
||||
args: []string{
|
||||
"slides", "+history-list",
|
||||
"--presentation", "presHistoryDryRun",
|
||||
"--page-size", "5",
|
||||
"--page-token", "page_token_1",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/slides_ai/v1/xml_presentations/presHistoryDryRun/histories",
|
||||
wantVerb: "GET",
|
||||
assertion: func(t *testing.T, stdout string) {
|
||||
require.Equal(t, int64(5), gjson.Get(stdout, "api.0.params.page_size").Int(), stdout)
|
||||
require.Equal(t, "page_token_1", gjson.Get(stdout, "api.0.params.page_token").String(), stdout)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "revert",
|
||||
args: []string{
|
||||
"slides", "+history-revert",
|
||||
"--presentation", "presHistoryDryRun",
|
||||
"--history-version-id", "42",
|
||||
"--wait-timeout-ms", "0",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/slides_ai/v1/xml_presentations/presHistoryDryRun/history/revert",
|
||||
wantVerb: "POST",
|
||||
assertion: func(t *testing.T, stdout string) {
|
||||
require.Equal(t, "42", gjson.Get(stdout, "api.0.body.history_version_id").String(), stdout)
|
||||
require.Equal(t, int64(0), gjson.Get(stdout, "api.0.body.wait_timeout_ms").Int(), stdout)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
args: []string{
|
||||
"slides", "+history-revert-status",
|
||||
"--presentation", "presHistoryDryRun",
|
||||
"--task-id", "task_1",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/slides_ai/v1/xml_presentations/presHistoryDryRun/history/revert_status",
|
||||
wantVerb: "GET",
|
||||
assertion: func(t *testing.T, stdout string) {
|
||||
require.Equal(t, "task_1", gjson.Get(stdout, "api.0.params.task_id").String(), stdout)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
require.Equal(t, tt.wantVerb, gjson.Get(result.Stdout, "api.0.method").String(), "stdout:\n%s", result.Stdout)
|
||||
require.Equal(t, tt.wantURL, gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout)
|
||||
tt.assertion(t, result.Stdout)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setSlidesDryRunEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "slides_dryrun_test")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "slides_dryrun_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
}
|
||||
228
tests/cli_e2e/slides/slides_history_workflow_test.go
Normal file
228
tests/cli_e2e/slides/slides_history_workflow_test.go
Normal file
@@ -0,0 +1,228 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestSlides_HistoryWorkflow(t *testing.T) {
|
||||
if os.Getenv("LARK_SLIDES_HISTORY_E2E") != "1" {
|
||||
t.Skip("set LARK_SLIDES_HISTORY_E2E=1 to run slides history live workflow")
|
||||
}
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
title := "lark-cli-e2e-slides-history-" + suffix
|
||||
originalMarker := "original history marker " + suffix
|
||||
updatedMarker := "updated history marker " + suffix
|
||||
const defaultAs = "user"
|
||||
|
||||
originalSlideXML := slidesHistoryWorkflowSlideXML(title, originalMarker)
|
||||
updatedSlideXML := slidesHistoryWorkflowSlideXML(title, updatedMarker)
|
||||
slidesJSON := mustMarshalSlidesJSON(t, []string{originalSlideXML})
|
||||
|
||||
var presentationID string
|
||||
var slideID string
|
||||
createResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+create",
|
||||
"--title", title,
|
||||
"--slides", slidesJSON,
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
createResult.AssertExitCode(t, 0)
|
||||
createResult.AssertStdoutStatus(t, true)
|
||||
|
||||
presentationID = gjson.Get(createResult.Stdout, "data.xml_presentation_id").String()
|
||||
require.NotEmpty(t, presentationID, "stdout:\n%s", createResult.Stdout)
|
||||
slideID = gjson.Get(createResult.Stdout, "data.slide_ids.0").String()
|
||||
require.NotEmpty(t, slideID, "stdout:\n%s", createResult.Stdout)
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
|
||||
defer cleanupCancel()
|
||||
|
||||
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+delete",
|
||||
"--file-token", presentationID,
|
||||
"--type", "slides",
|
||||
"--yes",
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
clie2e.ReportCleanupFailure(parentT, "delete presentation "+presentationID, deleteResult, deleteErr)
|
||||
})
|
||||
|
||||
fetchOriginal, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"api", "get", "/open-apis/slides_ai/v1/xml_presentations/" + presentationID},
|
||||
DefaultAs: defaultAs,
|
||||
Params: map[string]any{"revision_id": -1},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
fetchOriginal.AssertExitCode(t, 0)
|
||||
fetchOriginal.AssertStdoutStatus(t, true)
|
||||
originalContent := gjson.Get(fetchOriginal.Stdout, "data.xml_presentation.content").String()
|
||||
assert.Contains(t, originalContent, originalMarker, "stdout:\n%s", fetchOriginal.Stdout)
|
||||
originalRevision := gjson.Get(fetchOriginal.Stdout, "data.xml_presentation.revision_id").Int()
|
||||
require.Greater(t, originalRevision, int64(0), "stdout:\n%s", fetchOriginal.Stdout)
|
||||
|
||||
pagesJSON := mustMarshalPagesJSON(t, []slidesHistoryWorkflowPage{
|
||||
{SlideID: slideID, Content: updatedSlideXML},
|
||||
})
|
||||
updateResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+replace-pages",
|
||||
"--presentation", presentationID,
|
||||
"--pages", pagesJSON,
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
updateResult.AssertExitCode(t, 0)
|
||||
updateResult.AssertStdoutStatus(t, true)
|
||||
|
||||
fetchUpdated, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"api", "get", "/open-apis/slides_ai/v1/xml_presentations/" + presentationID},
|
||||
DefaultAs: defaultAs,
|
||||
Params: map[string]any{"revision_id": -1},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
fetchUpdated.AssertExitCode(t, 0)
|
||||
fetchUpdated.AssertStdoutStatus(t, true)
|
||||
updatedContent := gjson.Get(fetchUpdated.Stdout, "data.xml_presentation.content").String()
|
||||
assert.Contains(t, updatedContent, updatedMarker, "stdout:\n%s", fetchUpdated.Stdout)
|
||||
assert.NotContains(t, updatedContent, originalMarker, "stdout:\n%s", fetchUpdated.Stdout)
|
||||
currentRevision := gjson.Get(fetchUpdated.Stdout, "data.xml_presentation.revision_id").Int()
|
||||
require.Greater(t, currentRevision, originalRevision, "stdout:\n%s", fetchUpdated.Stdout)
|
||||
|
||||
var revertHistoryVersionID string
|
||||
require.Eventually(t, func() bool {
|
||||
listResult, listErr := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+history-list",
|
||||
"--presentation", presentationID,
|
||||
"--page-size", "20",
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
if listErr != nil || listResult.ExitCode != 0 {
|
||||
return false
|
||||
}
|
||||
for _, entry := range gjson.Get(listResult.Stdout, "data.entries").Array() {
|
||||
revisionID := entry.Get("revision_id").Int()
|
||||
historyVersionID := entry.Get("history_version_id").String()
|
||||
if revisionID == originalRevision && historyVersionID != "" {
|
||||
revertHistoryVersionID = historyVersionID
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 45*time.Second, 3*time.Second, "history list did not expose original revision %d", originalRevision)
|
||||
require.NotEmpty(t, revertHistoryVersionID)
|
||||
|
||||
revertResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+history-revert",
|
||||
"--presentation", presentationID,
|
||||
"--history-version-id", revertHistoryVersionID,
|
||||
"--wait-timeout-ms", "30000",
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
revertResult.AssertExitCode(t, 0)
|
||||
revertResult.AssertStdoutStatus(t, true)
|
||||
|
||||
status := gjson.Get(revertResult.Stdout, "data.status").String()
|
||||
taskID := gjson.Get(revertResult.Stdout, "data.task_id").String()
|
||||
if status == "running" {
|
||||
require.NotEmpty(t, taskID, "stdout:\n%s", revertResult.Stdout)
|
||||
require.Eventually(t, func() bool {
|
||||
statusResult, statusErr := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+history-revert-status",
|
||||
"--presentation", presentationID,
|
||||
"--task-id", taskID,
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
if statusErr != nil || statusResult.ExitCode != 0 {
|
||||
return false
|
||||
}
|
||||
status = gjson.Get(statusResult.Stdout, "data.status").String()
|
||||
return status != "" && status != "running"
|
||||
}, 60*time.Second, 5*time.Second, "history revert task did not finish")
|
||||
}
|
||||
require.Equal(t, "done", status, "revert stdout:\n%s", revertResult.Stdout)
|
||||
|
||||
fetchReverted, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"api", "get", "/open-apis/slides_ai/v1/xml_presentations/" + presentationID},
|
||||
DefaultAs: defaultAs,
|
||||
Params: map[string]any{"revision_id": -1},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
fetchReverted.AssertExitCode(t, 0)
|
||||
fetchReverted.AssertStdoutStatus(t, true)
|
||||
revertedContent := gjson.Get(fetchReverted.Stdout, "data.xml_presentation.content").String()
|
||||
assert.Contains(t, revertedContent, originalMarker, "stdout:\n%s", fetchReverted.Stdout)
|
||||
assert.NotContains(t, revertedContent, updatedMarker, "stdout:\n%s", fetchReverted.Stdout)
|
||||
}
|
||||
|
||||
type slidesHistoryWorkflowPage struct {
|
||||
SlideID string `json:"slide_id"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func slidesHistoryWorkflowSlideXML(title, marker string) string {
|
||||
return `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>` +
|
||||
`<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120"><content textType="title"><p>` + slidesHistoryWorkflowXMLEscape(title) + `</p></content></shape>` +
|
||||
`<shape type="text" topLeftX="80" topLeftY="220" width="800" height="180"><content textType="body"><p>` + slidesHistoryWorkflowXMLEscape(marker) + `</p></content></shape>` +
|
||||
`</data></slide>`
|
||||
}
|
||||
|
||||
func slidesHistoryWorkflowXMLEscape(s string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
`"`, """,
|
||||
"'", "'",
|
||||
)
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
|
||||
func mustMarshalSlidesJSON(t *testing.T, slides []string) string {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(slides)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal slides JSON: %v", err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func mustMarshalPagesJSON(t *testing.T, pages []slidesHistoryWorkflowPage) string {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(pages)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pages JSON: %v", err)
|
||||
}
|
||||
return strings.TrimSpace(string(raw))
|
||||
}
|
||||
Reference in New Issue
Block a user