diff --git a/shortcuts/slides/shortcuts.go b/shortcuts/slides/shortcuts.go index 745a52b38..011e27e0b 100644 --- a/shortcuts/slides/shortcuts.go +++ b/shortcuts/slides/shortcuts.go @@ -13,5 +13,6 @@ func Shortcuts() []common.Shortcut { SlidesReplaceSlide, SlidesReplacePages, SlidesScreenshot, + SlidesXMLGet, } } diff --git a/shortcuts/slides/slides_xml_get.go b/shortcuts/slides/slides_xml_get.go new file mode 100644 index 000000000..cc2101484 --- /dev/null +++ b/shortcuts/slides/slides_xml_get.go @@ -0,0 +1,144 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "bytes" + "context" + "fmt" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// SlidesXMLGet fetches the full XML presentation content and writes it to a +// local file, keeping the terminal output small for large decks. +var SlidesXMLGet = common.Shortcut{ + Service: "slides", + Command: "+xml-get", + Description: "Fetch full presentation XML and save it to a local file", + Risk: "read", + Scopes: []string{"slides:presentation:read"}, + // wiki:node:read is required only when --presentation is a wiki URL. + ConditionalScopes: []string{"wiki:node:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true}, + {Name: "output", Desc: "local XML output path; existing file is overwritten", Required: true}, + {Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision_id; -1 means latest"}, + {Name: "remove-attr-id", Type: "bool", Desc: "remove XML id attributes in the returned content; useful for read-only inspection, not precise block editing"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { + return err + } + if ref.Kind == "wiki" { + if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil { + return err + } + } + if strings.TrimSpace(runtime.Str("output")) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be empty").WithParam("--output") + } + if _, err := runtime.ResolveSavePath(runtime.Str("output")); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output invalid: %v", err).WithParam("--output").WithCause(err) + } + if runtime.Int("revision-id") < -1 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--revision-id must be -1 or a non-negative integer").WithParam("--revision-id") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + presentationID := ref.Token + dry := common.NewDryRunAPI() + if ref.Kind == "wiki" { + presentationID = "" + dry.Desc("2-step orchestration: resolve wiki → fetch full presentation XML"). + GET("/open-apis/wiki/v2/spaces/get_node"). + Desc("[1] Resolve wiki node to slides presentation"). + Params(map[string]interface{}{"token": ref.Token}) + } else { + dry.Desc("Fetch full presentation XML and save it to a local file") + } + params := map[string]interface{}{ + "revision_id": runtime.Int("revision-id"), + } + if runtime.Bool("remove-attr-id") { + params["remove_attr_id"] = true + } + dry.GET(fmt.Sprintf( + "/open-apis/slides_ai/v1/xml_presentations/%s", + validate.EncodePathSegment(presentationID), + )). + Params(params) + return dry.Set("output", runtime.Str("output")).Set("stdout_content", "suppressed; XML content is saved to --output during execution") + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { + return err + } + presentationID, err := resolvePresentationID(runtime, ref) + if err != nil { + return err + } + + params := map[string]interface{}{ + "revision_id": runtime.Int("revision-id"), + } + if runtime.Bool("remove-attr-id") { + params["remove_attr_id"] = true + } + data, err := runtime.CallAPITyped( + "GET", + fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s", validate.EncodePathSegment(presentationID)), + params, + nil, + ) + if err != nil { + return err + } + + presentation := common.GetMap(data, "xml_presentation") + content := common.GetString(presentation, "content") + if content == "" { + return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides xml get returned empty xml_presentation.content") + } + outputPath := runtime.Str("output") + result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{ + ContentType: "application/xml", + ContentLength: int64(len(content)), + }, bytes.NewReader([]byte(content))) + if err != nil { + return common.WrapSaveErrorTyped(err) + } + resolvedPath, err := runtime.ResolveSavePath(outputPath) + if err != nil { + return errs.NewInternalError(errs.SubtypeFileIO, "resolve saved XML path %s: %v", outputPath, err).WithCause(err) + } + + out := map[string]interface{}{ + "xml_presentation_id": presentationID, + "path": resolvedPath, + "size": result.Size(), + "content_saved": true, + } + if revisionID := common.GetFloat(presentation, "revision_id"); revisionID > 0 { + out["revision_id"] = int(revisionID) + } + if runtime.Bool("remove-attr-id") { + out["remove_attr_id"] = true + } + runtime.Out(out, nil) + return nil + }, +} diff --git a/shortcuts/slides/slides_xml_get_test.go b/shortcuts/slides/slides_xml_get_test.go new file mode 100644 index 000000000..fdaf37262 --- /dev/null +++ b/shortcuts/slides/slides_xml_get_test.go @@ -0,0 +1,157 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + + xml := `hello` + var capturedQuery url.Values + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "xml_presentation": map[string]interface{}{ + "presentation_id": "pres_abc", + "revision_id": 7, + "content": xml, + }, + }, + }, + OnMatch: func(req *http.Request) { + capturedQuery = req.URL.Query() + }, + }) + + err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{ + "+xml-get", + "--presentation", "pres_abc", + "--output", "readback.xml", + "--revision-id", "7", + "--remove-attr-id", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + path := filepath.Join(dir, "readback.xml") + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read saved XML: %v", err) + } + if string(got) != xml { + t.Fatalf("saved XML = %q, want %q", got, xml) + } + if strings.Contains(stdout.String(), xml) { + t.Fatalf("stdout leaked full XML content: %s", stdout.String()) + } + if got := capturedQuery.Get("revision_id"); got != "7" { + t.Fatalf("revision_id query = %q, want 7", got) + } + if got := capturedQuery.Get("remove_attr_id"); got != "true" { + t.Fatalf("remove_attr_id query = %q, want true", got) + } + + data := decodeShortcutData(t, stdout) + if data["xml_presentation_id"] != "pres_abc" { + t.Fatalf("xml_presentation_id = %v, want pres_abc", data["xml_presentation_id"]) + } + if data["revision_id"] != float64(7) { + t.Fatalf("revision_id = %v, want 7", data["revision_id"]) + } + if data["size"] != float64(len(xml)) { + t.Fatalf("size = %v, want %d", data["size"], len(xml)) + } + gotPath, _ := data["path"].(string) + if !filepath.IsAbs(gotPath) { + t.Fatalf("path = %v, want absolute path", gotPath) + } + if !strings.HasSuffix(gotPath, "readback.xml") { + t.Fatalf("path = %v, want readback.xml suffix", gotPath) + } +} + +func TestSlidesXMLGetResolvesWikiPresentation(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/get_node", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "obj_type": "slides", + "obj_token": "pres_real", + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_real", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "xml_presentation": map[string]interface{}{ + "content": ``, + }, + }, + }, + }) + + err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{ + "+xml-get", + "--presentation", "https://example.feishu.cn/wiki/wikcn123", + "--output", "wiki.xml", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeShortcutData(t, stdout) + if data["xml_presentation_id"] != "pres_real" { + t.Fatalf("xml_presentation_id = %v, want pres_real", data["xml_presentation_id"]) + } +} + +func TestSlidesXMLGetRejectsUnsafeOutputPath(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{ + "+xml-get", + "--presentation", "pres_abc", + "--output", "../readback.xml", + "--as", "user", + }) + if err == nil { + t.Fatal("expected unsafe output path error, got nil") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T %v", err, err) + } + if problem.Param != "--output" { + t.Fatalf("param = %q, want --output", problem.Param) + } +}