Compare commits

...

3 Commits

Author SHA1 Message Date
liuxin.0319
40a828cb5e test(slides): avoid credential-like history test text 2026-07-02 11:53:37 +08:00
liuxin.0319
c0a961dbc3 feat(slides): add history rollback shortcuts 2026-07-02 11:21:48 +08:00
liangshuo-1
462358a746 install: warn instead of failing when checksums.txt is missing (#1712) 2026-07-01 22:50:56 +08:00
10 changed files with 1204 additions and 31 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.62",
"version": "1.0.63",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -265,9 +265,10 @@ function getExpectedChecksum(archiveName, checksumsDir) {
const checksumsPath = path.join(dir, "checksums.txt");
if (!fs.existsSync(checksumsPath)) {
throw new Error(
"[SECURITY] checksums.txt not found; refusing to install an unverified binary."
console.error(
"[WARN] checksums.txt not found, skipping checksum verification"
);
return null;
}
const content = fs.readFileSync(checksumsPath, "utf8");
@@ -285,11 +286,7 @@ function getExpectedChecksum(archiveName, checksumsDir) {
}
function verifyChecksum(archivePath, expectedHash) {
if (typeof expectedHash !== "string" || expectedHash.length === 0) {
throw new Error(
"[SECURITY] missing expected checksum; refusing to install an unverified binary."
);
}
if (expectedHash === null) return;
// Stream the file to avoid loading the entire archive into memory.
// Archives can be 10-100MB; streaming keeps RSS constant.

View File

@@ -52,17 +52,11 @@ describe("getExpectedChecksum", () => {
);
});
it("throws [SECURITY] when checksums.txt does not exist (fail-closed)", () => {
it("returns null when checksums.txt does not exist", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
// No checksums.txt in dir
assert.throws(
() => getExpectedChecksum("anything.tar.gz", dir),
(err) => {
assert.match(err.message, /^\[SECURITY\]/);
assert.match(err.message, /checksums\.txt not found/);
return true;
}
);
const result = getExpectedChecksum("anything.tar.gz", dir);
assert.equal(result, null);
});
it("skips malformed lines and still finds valid entry", () => {
@@ -131,19 +125,6 @@ describe("verifyChecksum", () => {
}
);
});
it("verifyChecksum throws [SECURITY] on null/empty expectedHash (fail-closed)", () => {
const filePath = makeTmpFile("content");
for (const expectedHash of [null, ""]) {
assert.throws(
() => verifyChecksum(filePath, expectedHash),
(err) => {
assert.match(err.message, /^\[SECURITY\]/);
return true;
}
);
}
});
});
describe("assertAllowedHost", () => {

View File

@@ -14,5 +14,8 @@ func Shortcuts() []common.Shortcut {
SlidesReplacePages,
SlidesScreenshot,
SlidesXMLGet,
SlidesHistoryList,
SlidesHistoryRevert,
SlidesHistoryRevertStatus,
}
}

View 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
},
}

View 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
}

View File

@@ -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)

View 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}'
```

View 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")
}

View 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(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&apos;",
)
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))
}