Files
larksuite__cli/shortcuts/slides/slides_screenshot.go
BD-ZERO 7363eb5448 feat(slides): support explicit screenshot output paths (#2180)
Adds AI-friendly output path handling to `slides +screenshot`.

- Supports `--output` for a single screenshot in both existing-slide and XML render modes.
- Validates selector count, conflicting output flags, unsafe paths, directories, whitespace, and unsupported extensions with structured errors.
- Reconciles the requested filename with the server’s actual PNG/JPEG format and reports the final path through `output`, `requested_output`, and `output_adjusted`.
- Avoids replacing existing screenshots by appending `_2`, `_3`, and subsequent suffixes.
- Keeps `--output-dir` for multi-page screenshots and preserves `--output-name` for render mode.
- Updates the Slides Skill with explicit `--slide-number` / `--slide-id` guidance and task-scoped screenshot directories.
- Adds unit, dry-run E2E, and live workflow coverage for validation, path handling, format adjustment, and collision behavior.
2026-08-05 16:50:10 +08:00

798 lines
29 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
defaultSlidesScreenshotDir = ".lark-slides/screenshots"
maxSlidesPerScreenshot = 10
)
var (
unsafeScreenshotFileCharRegex = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
slideNumberAliasRegex = regexp.MustCompile(`^[0-9]+$`)
)
// SlidesScreenshot fetches server-rendered slide screenshots and writes them to
// local files. The raw API returns Base64 image payloads; this shortcut keeps
// those payloads out of stdout so agents only see small file metadata.
var SlidesScreenshot = common.Shortcut{
Service: "slides",
Command: "+screenshot",
Description: "Save up to 10 slide screenshots to local files without printing Base64 image data",
Risk: "read",
Scopes: []string{"slides:presentation:screenshot"},
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
listModePresentationRefFlag(),
{Name: "slide-id", Aliases: []string{"slide-ids", "slides"}, Type: "string_slice", Desc: "slide page identifier (repeat or comma-separated for multiple slides; max 10 pages per request)"},
{Name: "slide-number", Aliases: []string{"slide-numbers"}, Type: "int_array", Desc: "slide page number (repeat for multiple slides; max 10 pages per request)"},
{Name: "slide", Desc: "hidden alias routed to --slide-number for digits, otherwise --slide-id", Hidden: true},
{Name: "content", Desc: "slide XML content to render directly instead of fetching existing slides", Input: []string{common.File, common.Stdin}},
{Name: "output", Desc: "preferred relative output path for a single screenshot (extension optional; .png, .jpg, or .jpeg)"},
{Name: "output-dir", Default: defaultSlidesScreenshotDir, Desc: "relative directory for saved screenshots"},
{Name: "output-name", Desc: "file name stem for --content render output"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
renderMode := runtime.Changed("content")
selectorCount := 1
if renderMode {
if strings.TrimSpace(runtime.Str("content")) == "" {
return slidesScreenshotFlagErrorf("--content cannot be empty")
}
if slidesScreenshotHasSelectorInput(runtime) {
return slidesScreenshotContentSelectorConflictError(runtime)
}
if runtime.Changed("presentation") {
return slidesScreenshotFlagErrorf("--presentation cannot be used with --content")
}
} else {
ref, err := parsePresentationRef(slidesScreenshotPresentation(runtime))
if err != nil {
return err
}
slideIDs, slideNumbers, err := slidesScreenshotSelectors(runtime)
if err != nil {
return err
}
if ref.Kind == "wiki" {
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
return err
}
}
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
return slidesScreenshotMissingSelectorError()
}
selectorCount = len(slideIDs) + len(slideNumbers)
if err := validateSlidesScreenshotSelectorLimit(selectorCount); err != nil {
return err
}
}
if runtime.Changed("output") {
if runtime.Changed("output-dir") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be combined with --output-dir").WithParam("--output")
}
if runtime.Changed("output-name") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be combined with --output-name").WithParam("--output")
}
if selectorCount != 1 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output requires exactly one slide; use --output-dir for multiple screenshots").WithParam("--output")
}
if err := validateScreenshotOutputPath(runtime, runtime.Str("output")); err != nil {
return err
}
} else {
if !renderMode && runtime.Changed("output-name") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output-name is only supported with --content").
WithParam("--output-name").
WithHint("use --output <file> for one existing slide, or --output-dir for multiple slides")
}
if _, err := validateScreenshotOutputDir(runtime, runtime.Str("output-dir")); err != nil {
return err
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if runtime.Changed("content") {
return dryRunRenderScreenshot(runtime)
}
ref, err := parsePresentationRef(slidesScreenshotPresentation(runtime))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
slideIDs, slideNumbers, err := slidesScreenshotSelectors(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if err := validateSlidesScreenshotSelectorLimit(len(slideIDs) + len(slideNumbers)); err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
presentationID := ref.Token
dry := common.NewDryRunAPI()
if ref.Kind == "wiki" {
presentationID = "<resolved_slides_token>"
dry.Desc("2-step orchestration: resolve wiki → fetch slide screenshot(s)").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to slides presentation").
Params(map[string]interface{}{"token": ref.Token})
} else {
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
dry.Desc(fmt.Sprintf("Fetch one slide screenshot and save it as %s", outputPath))
} else {
dry.Desc(fmt.Sprintf("Fetch %d slide screenshot(s) and save files under %s", len(slideIDs)+len(slideNumbers), runtime.Str("output-dir")))
}
}
body := map[string]interface{}{}
if len(slideIDs) > 0 {
body["slide_ids"] = slideIDs
}
if len(slideNumbers) > 0 {
body["slide_numbers"] = slideNumbers
}
dry.POST(fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide_images",
validate.EncodePathSegment(presentationID),
)).
Body(body)
return setSlidesScreenshotDryRunOutput(dry, runtime).Set("base64_output", "suppressed; decoded to local files during execution")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Changed("content") {
return executeRenderScreenshot(runtime)
}
ref, err := parsePresentationRef(slidesScreenshotPresentation(runtime))
if err != nil {
return err
}
presentationID, err := resolvePresentationID(runtime, ref)
if err != nil {
return err
}
slideIDs, slideNumbers, err := slidesScreenshotSelectors(runtime)
if err != nil {
return err
}
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
return slidesScreenshotMissingSelectorError()
}
if err := validateSlidesScreenshotSelectorLimit(len(slideIDs) + len(slideNumbers)); err != nil {
return err
}
outputTarget, err := resolveSlidesScreenshotOutputTarget(runtime)
if err != nil {
return err
}
url := fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide_images",
validate.EncodePathSegment(presentationID),
)
query := larkcore.QueryParams{}
body := map[string]interface{}{}
if len(slideIDs) > 0 {
body["slide_ids"] = slideIDs
}
if len(slideNumbers) > 0 {
body["slide_numbers"] = slideNumbers
}
data, err := doSlidesScreenshotAPIJSONWithLogID(runtime, "POST", url, query, body)
if err != nil {
return enrichSlidesScreenshotSelectorError(err, slideNumbers)
}
saved, err := saveSlideScreenshots(runtime, data, outputTarget.safeOutputDir, presentationID, outputTarget.requested)
if err != nil {
return err
}
result := map[string]interface{}{
"xml_presentation_id": presentationID,
"screenshots": saved,
}
setSlidesScreenshotResultOutput(result, outputTarget, saved)
runtime.Out(result, nil)
return nil
},
}
func dryRunRenderScreenshot(runtime *common.RuntimeContext) *common.DryRunAPI {
content := runtime.Str("content")
if strings.TrimSpace(content) == "" {
return common.NewDryRunAPI().Set("error", "--content cannot be empty")
}
if slidesScreenshotHasSelectorInput(runtime) {
return common.NewDryRunAPI().Set("error", "--content cannot be used with slide selectors")
}
if runtime.Changed("presentation") {
return common.NewDryRunAPI().Set("error", "--presentation cannot be used with --content")
}
dry := common.NewDryRunAPI().Desc("Render slide XML content to a screenshot file")
dry.POST("/open-apis/slides_ai/v1/slide_image/render").
Body(map[string]interface{}{
"content": fmt.Sprintf("<xml omitted; length=%d>", len(content)),
})
return setSlidesScreenshotDryRunOutput(dry, runtime).Set("base64_output", "suppressed; decoded to local file during execution")
}
func executeRenderScreenshot(runtime *common.RuntimeContext) error {
content := runtime.Str("content")
if strings.TrimSpace(content) == "" {
return slidesScreenshotFlagErrorf("--content cannot be empty")
}
if slidesScreenshotHasSelectorInput(runtime) {
return slidesScreenshotContentSelectorConflictError(runtime)
}
if runtime.Changed("presentation") {
return slidesScreenshotFlagErrorf("--presentation cannot be used with --content")
}
outputTarget, err := resolveSlidesScreenshotOutputTarget(runtime)
if err != nil {
return err
}
data, err := doSlidesScreenshotAPIJSONWithLogID(runtime, "POST", "/open-apis/slides_ai/v1/slide_image/render", larkcore.QueryParams{}, map[string]interface{}{
"content": content,
})
if err != nil {
return err
}
saved, err := saveRenderedSlideScreenshot(runtime, data, outputTarget.safeOutputDir, runtime.Str("output-name"), outputTarget.requested)
if err != nil {
return err
}
result := map[string]interface{}{
"screenshots": saved,
}
setSlidesScreenshotResultOutput(result, outputTarget, saved)
runtime.Out(result, nil)
return nil
}
func setSlidesScreenshotDryRunOutput(dry *common.DryRunAPI, runtime *common.RuntimeContext) *common.DryRunAPI {
if outputPath := runtime.Str("output"); outputPath != "" {
return dry.Set("output", outputPath)
}
return dry.Set("output_dir", runtime.Str("output-dir"))
}
type slidesScreenshotOutputTarget struct {
requested string
requestedResolved string
outputDir string
safeOutputDir string
}
func resolveSlidesScreenshotOutputTarget(runtime *common.RuntimeContext) (slidesScreenshotOutputTarget, error) {
target := slidesScreenshotOutputTarget{
requested: runtime.Str("output"),
outputDir: runtime.Str("output-dir"),
}
if target.requested != "" {
resolved, err := runtime.ResolveSavePath(target.requested)
if err != nil {
return target, errs.NewValidationError(errs.SubtypeInvalidArgument, "--output invalid: %v", err).
WithParam("--output").
WithCause(err)
}
target.requestedResolved = resolved
return target, nil
}
safeOutputDir, err := ensureScreenshotOutputDir(runtime, target.outputDir)
if err != nil {
return target, err
}
target.safeOutputDir = safeOutputDir
return target, nil
}
func setSlidesScreenshotResultOutput(result map[string]interface{}, target slidesScreenshotOutputTarget, saved []map[string]interface{}) {
if target.requested != "" && len(saved) == 1 {
actualPath, _ := saved[0]["path"].(string)
result["output"] = actualPath
if filepath.Clean(target.requestedResolved) != filepath.Clean(actualPath) {
result["requested_output"] = target.requested
result["output_adjusted"] = true
}
return
}
result["output_dir"] = target.outputDir
}
func slidesScreenshotPresentation(runtime *common.RuntimeContext) string {
return runtime.Str("presentation")
}
func slidesScreenshotSelectors(runtime *common.RuntimeContext) ([]string, []int, error) {
aliasSlide := strings.TrimSpace(runtime.Str("slide"))
aliasSlideIsNumber := runtime.Changed("slide") && slideNumberAliasRegex.MatchString(aliasSlide)
aliasSlideIsID := runtime.Changed("slide") && aliasSlide != "" && !aliasSlideIsNumber
if runtime.Changed("slide") && aliasSlide == "" {
return nil, nil, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--slide cannot be empty",
).WithParam("--slide").WithHint("use --slide-number or --slide-id to specify the selector type explicitly")
}
slideIDValues := append([]string(nil), runtime.StrSlice("slide-id")...)
if runtime.Changed("slide-id") && len(normalizeSlideIDs(slideIDValues)) == 0 {
return nil, nil, slidesScreenshotEmptySlideIDError()
}
if aliasSlideIsID {
slideIDValues = append(slideIDValues, aliasSlide)
}
slideNumberValues := append([]int(nil), runtime.IntArray("slide-number")...)
if aliasSlideIsNumber {
n, err := strconv.Atoi(aliasSlide)
if err != nil {
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide page number is outside the supported integer range").WithParam("--slide")
}
if n < 1 {
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide must be a positive page number").WithParam("--slide")
}
slideNumberValues = append(slideNumberValues, n)
}
slideNumbers, err := normalizeSlideNumbers(slideNumberValues)
if err != nil {
return nil, nil, err
}
slideIDs := normalizeSlideIDs(slideIDValues)
if len(slideIDs) > 0 && len(slideNumbers) > 0 {
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "slide ID selectors and slide number selectors cannot be used together").
WithParams(slidesScreenshotSelectorConflictParams(runtime, aliasSlideIsID, aliasSlideIsNumber)...).
WithHint("choose either slide IDs or slide numbers for one screenshot request")
}
return slideIDs, slideNumbers, nil
}
func slidesScreenshotHasSelectorInput(runtime *common.RuntimeContext) bool {
return len(slidesScreenshotSelectorInputParams(runtime, "")) > 0
}
func slidesScreenshotSelectorConflictParams(runtime *common.RuntimeContext, aliasSlideIsID, aliasSlideIsNumber bool) []errs.InvalidParam {
params := make([]errs.InvalidParam, 0, 3)
if len(normalizeSlideIDs(runtime.StrSlice("slide-id"))) > 0 {
params = append(params, errs.InvalidParam{Name: "--slide-id", Reason: "selects by slide ID; cannot be combined with slide-number selectors"})
}
if aliasSlideIsID {
params = append(params, errs.InvalidParam{Name: "--slide", Reason: "selects by slide ID; cannot be combined with slide-number selectors"})
}
if runtime.Changed("slide-number") {
params = append(params, errs.InvalidParam{Name: "--slide-number", Reason: "selects by slide number; cannot be combined with slide-ID selectors"})
}
if aliasSlideIsNumber {
params = append(params, errs.InvalidParam{Name: "--slide", Reason: "selects by slide number; cannot be combined with slide-ID selectors"})
}
return params
}
func slidesScreenshotSelectorInputParams(runtime *common.RuntimeContext, reason string) []errs.InvalidParam {
params := make([]errs.InvalidParam, 0, 3)
if len(normalizeSlideIDs(runtime.StrSlice("slide-id"))) > 0 {
params = append(params, errs.InvalidParam{Name: "--slide-id", Reason: reason})
}
if runtime.Changed("slide-number") {
params = append(params, errs.InvalidParam{Name: "--slide-number", Reason: reason})
}
if runtime.Changed("slide") {
params = append(params, errs.InvalidParam{Name: "--slide", Reason: reason})
}
return params
}
func slidesScreenshotContentSelectorConflictError(runtime *common.RuntimeContext) error {
params := []errs.InvalidParam{{Name: "--content", Reason: "cannot be combined with slide selectors"}}
params = append(params, slidesScreenshotSelectorInputParams(runtime, "cannot be combined with --content")...)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content cannot be used with slide selectors").WithParams(params...)
}
func normalizeSlideIDs(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, v := range values {
s := strings.TrimSpace(v)
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
func normalizeSlideNumbers(values []int) ([]int, error) {
out := make([]int, 0, len(values))
seen := map[int]struct{}{}
for _, n := range values {
if n < 1 {
return nil, slidesScreenshotFlagErrorf("--slide-number must be a positive integer")
}
if _, ok := seen[n]; ok {
continue
}
seen[n] = struct{}{}
out = append(out, n)
}
return out, nil
}
func validateSlidesScreenshotSelectorLimit(count int) error {
if count > maxSlidesPerScreenshot {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "too many slide selectors: got %d, maximum is %d", count, maxSlidesPerScreenshot).
WithHint("request at most 10 pages at a time")
}
return nil
}
func slidesScreenshotMissingSelectorError() error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide-id or --slide-number is required").
WithHint("specify up to 10 slides with --slide-id <slide_id> or --slide-number <number>; repeat the flag or use comma-separated values for multiple slides")
}
func slidesScreenshotEmptySlideIDError() error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide-id cannot be empty").
WithParam("--slide-id").
WithHint("provide a non-empty slide ID or use --slide-number <number>")
}
func slidesScreenshotFlagErrorf(format string, args ...interface{}) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
}
func validateScreenshotOutputDir(runtime *common.RuntimeContext, outputDir string) (string, error) {
if _, err := runtime.ResolveSavePath(filepath.Join(outputDir, "probe.png")); err != nil {
return "", slidesScreenshotFlagErrorf("--output-dir invalid: %v", err)
}
return outputDir, nil
}
func validateScreenshotOutputPath(runtime *common.RuntimeContext, outputPath string) error {
if outputPath == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be empty").WithParam("--output")
}
if strings.TrimSpace(outputPath) != outputPath {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot have leading or trailing whitespace").
WithParam("--output").
WithHint("remove surrounding whitespace and retry")
}
if os.IsPathSeparator(outputPath[len(outputPath)-1]) {
return screenshotOutputDirectoryError(outputPath)
}
ext := strings.ToLower(filepath.Ext(outputPath))
if ext != "" && ext != ".png" && ext != ".jpg" && ext != ".jpeg" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output must have no extension or end with .png, .jpg, or .jpeg").WithParam("--output")
}
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output invalid: %v", err).WithParam("--output").WithCause(err)
}
if info, err := runtime.FileIO().Stat(outputPath); err == nil {
if info.IsDir() {
return screenshotOutputDirectoryError(outputPath)
}
} else if !isScreenshotFileNotExist(err) {
return errs.NewInternalError(errs.SubtypeFileIO, "inspect --output path %s: %v", outputPath, err).WithCause(err)
}
return nil
}
func screenshotOutputDirectoryError(outputPath string) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output expects a file path, got directory %q", outputPath).
WithParam("--output").
WithHint("use --output-dir <directory> for directory output")
}
func ensureScreenshotOutputDir(runtime *common.RuntimeContext, outputDir string) (string, error) {
return validateScreenshotOutputDir(runtime, outputDir)
}
func saveSlideScreenshots(runtime *common.RuntimeContext, data map[string]interface{}, outputDir string, presentationID string, outputPath string) ([]map[string]interface{}, error) {
items := common.GetSlice(data, "slide_images")
if len(items) == 0 {
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned no slide_images")
}
if outputPath != "" && len(items) != 1 {
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned %d slide_images for single --output", len(items))
}
saved := make([]map[string]interface{}, 0, len(items))
for i, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned invalid slide_images[%d]", i)
}
item, err := saveSlideScreenshotImage(runtime, m, outputDir, slideScreenshotListFileBase(presentationID, m, i), "", outputPath)
if err != nil {
if isSlidesScreenshotPassthroughError(err) {
return nil, err
}
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned invalid slide_images[%d]: %v", i, err)
}
saved = append(saved, item)
}
return saved, nil
}
func saveRenderedSlideScreenshot(runtime *common.RuntimeContext, data map[string]interface{}, outputDir string, outputName string, outputPath string) ([]map[string]interface{}, error) {
item := common.GetMap(data, "slide_image")
if item == nil {
return nil, slidesScreenshotAPIDataError(data, "slides render screenshot returned no slide_image")
}
saved, err := saveSlideScreenshotImage(runtime, item, outputDir, outputName, "rendered-slide", outputPath)
if err != nil {
if isSlidesScreenshotPassthroughError(err) {
return nil, err
}
return nil, slidesScreenshotAPIDataError(data, "slides render screenshot returned invalid slide_image: %v", err)
}
return []map[string]interface{}{saved}, nil
}
func saveSlideScreenshotImage(runtime *common.RuntimeContext, item map[string]interface{}, outputDir string, outputName string, fallbackName string, outputPath string) (map[string]interface{}, error) {
slideID := strings.TrimSpace(common.GetString(item, "slide_id"))
ext, label, err := slideScreenshotFormat(item)
if err != nil {
return nil, slidesScreenshotImageDataError(slideID, "%s", err)
}
encoded := strings.TrimSpace(common.GetString(item, "data"))
if encoded == "" {
return nil, slidesScreenshotImageDataError(slideID, "empty image data")
}
imageBytes, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, slidesScreenshotImageDataCauseError(slideID, err, "decode screenshot: %s", err)
}
var path string
if outputPath != "" {
path, err = writeUniqueScreenshotPath(runtime, slideScreenshotOutputPathForFormat(outputPath, ext), imageBytes)
} else {
fileBase := strings.TrimSpace(outputName)
if fileBase == "" {
fileBase = slideID
}
if fileBase == "" {
fileBase = fallbackName
}
path, err = writeUniqueScreenshotFile(runtime, outputDir, fileBase, ext, imageBytes)
}
if err != nil {
return nil, err
}
return map[string]interface{}{
"slide_id": slideID,
"slide_number": slideScreenshotInt(item, "slide_number"),
"format": label,
"path": path,
"size": len(imageBytes),
}, nil
}
func slideScreenshotOutputExtMatches(outputExt string, responseExt string) bool {
outputExt = strings.ToLower(outputExt)
if responseExt == "jpg" {
return outputExt == ".jpg" || outputExt == ".jpeg"
}
return outputExt == "."+responseExt
}
func slideScreenshotOutputPathForFormat(outputPath string, responseExt string) string {
outputExt := filepath.Ext(outputPath)
if outputExt == "" {
return outputPath + "." + responseExt
}
if slideScreenshotOutputExtMatches(outputExt, responseExt) {
return outputPath
}
return strings.TrimSuffix(outputPath, outputExt) + "." + responseExt
}
func slideScreenshotListFileBase(presentationID string, item map[string]interface{}, index int) string {
presentationID = strings.TrimSpace(presentationID)
slideID := strings.TrimSpace(common.GetString(item, "slide_id"))
slideNumber := slideScreenshotInt(item, "slide_number")
if presentationID != "" {
switch {
case slideNumber > 0 && slideID != "":
return fmt.Sprintf("%s_p%03d_%s", presentationID, slideNumber, slideID)
case slideNumber > 0:
return fmt.Sprintf("%s_p%03d", presentationID, slideNumber)
case slideID != "":
return fmt.Sprintf("%s_%s", presentationID, slideID)
}
}
if slideID != "" {
return slideID
}
if slideNumber := slideScreenshotInt(item, "slide_number"); slideNumber > 0 {
return fmt.Sprintf("slide-%d", slideNumber)
}
return fmt.Sprintf("slide-%d", index+1)
}
func slideScreenshotFormat(item map[string]interface{}) (string, string, error) {
format := slideScreenshotInt(item, "format")
switch format {
case 1:
return "png", "png", nil
case 2:
return "jpg", "jpeg", nil
default:
return "", "", errs.NewAPIError(errs.SubtypeInvalidResponse, "unsupported screenshot format %d", format)
}
}
func slidesScreenshotImageDataError(slideID string, format string, args ...interface{}) error {
msg := fmt.Sprintf(format, args...)
if slideID != "" {
msg = fmt.Sprintf("%s for slide %s", msg, slideID)
}
return errs.NewAPIError(errs.SubtypeInvalidResponse, "%s", msg)
}
func slidesScreenshotImageDataCauseError(slideID string, cause error, format string, args ...interface{}) error {
msg := fmt.Sprintf(format, args...)
if slideID != "" {
msg = fmt.Sprintf("%s for slide %s", msg, slideID)
}
return errs.NewAPIError(errs.SubtypeInvalidResponse, "%s", msg).WithCause(cause)
}
func slideScreenshotInt(item map[string]interface{}, key string) int {
n, ok := util.ToFloat64(item[key])
if !ok {
return 0
}
return int(n)
}
func doSlidesScreenshotAPIJSONWithLogID(runtime *common.RuntimeContext, method string, apiPath string, query larkcore.QueryParams, body interface{}) (map[string]interface{}, error) {
req := &larkcore.ApiReq{
HttpMethod: method,
ApiPath: apiPath,
QueryParams: query,
}
if body != nil {
req.Body = body
}
resp, err := runtime.DoAPI(req)
if err != nil {
return nil, errs.WrapInternal(err)
}
data, err := runtime.ClassifyAPIResponse(resp)
if err != nil {
return data, err
}
if data == nil {
data = map[string]interface{}{}
}
if logID := strings.TrimSpace(resp.Header.Get("x-tt-logid")); logID != "" {
data["log_id"] = logID
}
return data, nil
}
func enrichSlidesScreenshotSelectorError(err error, slideNumbers []int) error {
if len(slideNumbers) == 0 {
return err
}
p, ok := errs.ProblemOf(err)
if !ok {
return err
}
if p.Hint == "" {
p.Hint = "slide_numbers was rejected by the server; verify the page number exists in this presentation, or retry with --slide-id."
}
return err
}
func slidesScreenshotAPIDataError(data map[string]interface{}, format string, args ...interface{}) error {
msg := fmt.Sprintf(format, args...)
err := errs.NewAPIError(errs.SubtypeInvalidResponse, "%s; raw_data=%v", msg, summarizeScreenshotAPIData(data))
if logID := strings.TrimSpace(common.GetString(data, "log_id")); logID != "" {
err = err.WithLogID(logID)
}
return err
}
func isSlidesScreenshotPassthroughError(err error) bool {
_, ok := errs.ProblemOf(err)
return ok
}
func summarizeScreenshotAPIData(v interface{}) interface{} {
switch x := v.(type) {
case map[string]interface{}:
out := make(map[string]interface{}, len(x))
for k, val := range x {
out[k] = summarizeScreenshotAPIData(val)
}
return out
case []interface{}:
out := make([]interface{}, 0, len(x))
for i, val := range x {
if i >= 20 {
out = append(out, fmt.Sprintf("<omitted %d more items>", len(x)-i))
break
}
out = append(out, summarizeScreenshotAPIData(val))
}
return out
case string:
if len(x) > 512 {
return fmt.Sprintf("<omitted string length=%d prefix=%q>", len(x), x[:64])
}
return x
default:
return x
}
}
func safeScreenshotFileBase(base string) string {
name := unsafeScreenshotFileCharRegex.ReplaceAllString(base, "_")
name = strings.Trim(name, "._-")
if name == "" {
name = "slide"
}
return name
}
func writeUniqueScreenshotFile(runtime *common.RuntimeContext, outputDir string, fileBase string, ext string, imageBytes []byte) (string, error) {
base := safeScreenshotFileBase(fileBase)
return writeUniqueScreenshotPath(runtime, filepath.Join(outputDir, base+"."+ext), imageBytes)
}
func writeUniqueScreenshotPath(runtime *common.RuntimeContext, outputPath string, imageBytes []byte) (string, error) {
ext := filepath.Ext(outputPath)
base := strings.TrimSuffix(outputPath, ext)
for i := 0; i < 1000; i++ {
candidate := outputPath
if i > 0 {
candidate = fmt.Sprintf("%s_%d%s", base, i+1, ext)
}
if _, err := runtime.FileIO().Stat(candidate); err == nil {
continue
} else if !isScreenshotFileNotExist(err) {
return "", errs.NewInternalError(errs.SubtypeFileIO, "write screenshot %s: %v", candidate, err).WithCause(err)
}
if _, err := runtime.FileIO().Save(candidate, fileio.SaveOptions{}, bytes.NewReader(imageBytes)); err != nil {
return "", common.WrapSaveErrorTyped(err)
}
resolvedPath, err := runtime.ResolveSavePath(candidate)
if err != nil {
return "", errs.NewInternalError(errs.SubtypeFileIO, "resolve saved screenshot path %s: %v", candidate, err).WithCause(err)
}
return resolvedPath, nil
}
return "", errs.NewInternalError(errs.SubtypeFileIO, "write screenshot %s: too many duplicate file names", outputPath)
}
func isScreenshotFileNotExist(err error) bool {
return os.IsNotExist(err)
}