fix: issue#1935 & whiteboard shortcut reformat (#1980)

This commit is contained in:
syh-cpdsss
2026-07-22 14:59:49 +08:00
committed by GitHub
parent fc2761d16b
commit bb246b591f
12 changed files with 1310 additions and 534 deletions

View File

@@ -12,6 +12,7 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
WhiteboardUpdate,
WhiteboardUpdateOld,
WhiteboardExport,
WhiteboardQuery,
}
}

View File

@@ -0,0 +1,728 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
const (
// WhiteboardExportAsPreview exports a whiteboard preview image.
WhiteboardExportAsPreview = "preview"
// WhiteboardExportAsSvg exports a whiteboard as SVG.
WhiteboardExportAsSvg = "svg"
// WhiteboardExportAsSource exports Mermaid or PlantUML source extracted from the whiteboard.
WhiteboardExportAsSource = "source"
// WhiteboardExportAsRaw exports the raw whiteboard node payload.
WhiteboardExportAsRaw = "raw"
// Legacy output type names accepted for backward compatibility.
WhiteboardQueryAsImage = "image"
// WhiteboardQueryAsSvg is deprecated; use WhiteboardExportAsSvg.
WhiteboardQueryAsSvg = WhiteboardExportAsSvg
WhiteboardQueryAsCode = "code"
// WhiteboardQueryAsRaw is deprecated; use WhiteboardExportAsRaw.
WhiteboardQueryAsRaw = WhiteboardExportAsRaw
)
// SyntaxType identifies the diagram syntax extracted from whiteboard code blocks.
type SyntaxType int
const (
// SyntaxTypePlantUML marks PlantUML code blocks.
SyntaxTypePlantUML SyntaxType = 1
// SyntaxTypeMermaid marks Mermaid code blocks.
SyntaxTypeMermaid SyntaxType = 2
)
// SyntaxTypeNameMap maps whiteboard syntax types to their CLI output names.
var SyntaxTypeNameMap = map[SyntaxType]string{
SyntaxTypePlantUML: "plantuml",
SyntaxTypeMermaid: "mermaid",
}
// SyntaxTypeExtensionMap maps whiteboard syntax types to their default file extensions.
var SyntaxTypeExtensionMap = map[SyntaxType]string{
SyntaxTypePlantUML: ".puml",
SyntaxTypeMermaid: ".mmd",
}
// String returns the CLI-facing name for the syntax type.
func (s SyntaxType) String() string {
return SyntaxTypeNameMap[s]
}
// ExtensionName returns the default file extension for the syntax type.
func (s SyntaxType) ExtensionName() string {
return SyntaxTypeExtensionMap[s]
}
// IsValid reports whether the syntax type is one of the supported whiteboard code syntaxes.
func (s SyntaxType) IsValid() bool {
return s == SyntaxTypePlantUML || s == SyntaxTypeMermaid
}
var wbExportScopes = []string{"board:whiteboard:node:read"}
var wbExportAuthTypes = []string{"user", "bot"}
var wbExportFlags = []common.Flag{
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
{Name: "output-type", Desc: "output whiteboard as: preview | svg | source | raw.", Required: true, Enum: []string{"preview", "svg", "source", "raw"}},
{Name: "output", Desc: "output path. It is required when --output-type preview. If not specified when --output-type svg/source/raw, it will output directly.", Required: false},
{Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
}
var wbQueryFlags = []common.Flag{
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
{Name: "output_as", Desc: "output whiteboard as: image | svg | code | raw.", Required: true, Enum: []string{"image", "svg", "code", "raw"}},
{Name: "output", Desc: "output path. It is required when output as image. If not specified when --output_as svg/code/raw, it will output directly.", Required: false},
{Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
}
func wbExportOutputType(runtime *common.RuntimeContext) (string, string) {
normalized, ok := normalizeWhiteboardExportOutputType(runtime.Str("output-type"))
if !ok {
return "", "--output-type"
}
return normalized, "--output-type"
}
func wbQueryOutputType(runtime *common.RuntimeContext) (string, string) {
normalized, ok := normalizeLegacyWhiteboardExportOutputType(runtime.Str("output_as"))
if !ok {
return "", "--output_as"
}
return normalized, "--output_as"
}
func normalizeWhiteboardExportOutputType(outputType string) (string, bool) {
switch outputType {
case WhiteboardExportAsPreview:
return WhiteboardExportAsPreview, true
case WhiteboardExportAsSvg:
return WhiteboardExportAsSvg, true
case WhiteboardExportAsSource:
return WhiteboardExportAsSource, true
case WhiteboardExportAsRaw:
return WhiteboardExportAsRaw, true
default:
return "", false
}
}
func normalizeLegacyWhiteboardExportOutputType(outputType string) (string, bool) {
switch outputType {
case WhiteboardQueryAsImage:
return WhiteboardExportAsPreview, true
case WhiteboardQueryAsCode:
return WhiteboardExportAsSource, true
default:
return normalizeWhiteboardExportOutputType(outputType)
}
}
func wbExportOutputTypeError(param string) *errs.ValidationError {
if param == "--output_as" {
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--output_as flag must be one of: image | svg | code | raw",
).WithParam("--output_as")
}
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--output-type flag must be one of: preview | svg | source | raw",
).WithParam("--output-type")
}
func wbExportValidate(ctx context.Context, runtime *common.RuntimeContext) error {
return wbExportValidateWithOutputType(ctx, runtime, wbExportOutputType)
}
func wbQueryValidate(ctx context.Context, runtime *common.RuntimeContext) error {
return wbExportValidateWithOutputType(ctx, runtime, wbQueryOutputType)
}
func wbExportValidateWithOutputType(ctx context.Context, runtime *common.RuntimeContext, outputTypeFn func(*common.RuntimeContext) (string, string)) error {
// Check if token contains control characters
token := runtime.Str("whiteboard-token")
if err := common.RejectDangerousCharsTyped("--whiteboard-token", token); err != nil {
return err
}
outputType, outputTypeParam := outputTypeFn(runtime)
if outputType == "" {
return wbExportOutputTypeError(outputTypeParam)
}
out := runtime.Str("output")
if out != "" {
if _, err := runtime.ResolveSavePath(out); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
}
}
if out == "" && outputType == WhiteboardExportAsPreview {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "need a output path to export whiteboard as preview").WithParam("--output")
}
return nil
}
func wbExportDryRun(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return wbExportDryRunWithOutputType(ctx, runtime, wbExportOutputType)
}
func wbQueryDryRun(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return wbExportDryRunWithOutputType(ctx, runtime, wbQueryOutputType)
}
func wbExportDryRunWithOutputType(ctx context.Context, runtime *common.RuntimeContext, outputTypeFn func(*common.RuntimeContext) (string, string)) *common.DryRunAPI {
outputType, outputTypeParam := outputTypeFn(runtime)
token := runtime.Str("whiteboard-token")
switch outputType {
case WhiteboardExportAsPreview:
return common.NewDryRunAPI().
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", common.MaskToken(url.PathEscape(token)))).
Desc("Export preview image of given whiteboard")
case WhiteboardExportAsSource:
return common.NewDryRunAPI().
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
Desc("Extract Mermaid/Plantuml source from given whiteboard")
case WhiteboardExportAsRaw:
return common.NewDryRunAPI().
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
Desc("Extract raw nodes structure from given whiteboard")
case WhiteboardExportAsSvg:
return common.NewDryRunAPI().
POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", common.MaskToken(url.PathEscape(token)))).
Body(map[string]string{"export_type": "svg"}).
Desc("Export SVG of given whiteboard")
default:
if outputTypeParam == "--output_as" {
return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | svg | code | raw")
}
return common.NewDryRunAPI().Desc("invalid --output-type flag, must be one of: preview | svg | source | raw")
}
}
func wbExportExecute(ctx context.Context, runtime *common.RuntimeContext) error {
return wbExportExecuteWithOutputType(ctx, runtime, wbExportOutputType)
}
func wbQueryExecute(ctx context.Context, runtime *common.RuntimeContext) error {
return wbExportExecuteWithOutputType(ctx, runtime, wbQueryOutputType)
}
func wbExportExecuteWithOutputType(ctx context.Context, runtime *common.RuntimeContext, outputTypeFn func(*common.RuntimeContext) (string, string)) error {
token := runtime.Str("whiteboard-token")
outDir := runtime.Str("output")
outputType, outputTypeParam := outputTypeFn(runtime)
switch outputType {
case WhiteboardExportAsPreview:
return exportWhiteboardPreview(ctx, runtime, token, outDir)
case WhiteboardExportAsSvg:
return exportWhiteboardSvg(runtime, token, outDir)
case WhiteboardExportAsSource:
return exportWhiteboardCode(runtime, token, outDir)
case WhiteboardExportAsRaw:
return exportWhiteboardRaw(runtime, token, outDir)
default:
return wbExportOutputTypeError(outputTypeParam)
}
}
const WhiteboardExportDescription = "Export an existing whiteboard as preview image, SVG, source code or raw nodes structure."
// WhiteboardExport registers the `whiteboard +export` shortcut.
var WhiteboardExport = common.Shortcut{
Service: "whiteboard",
Command: "+export",
Description: WhiteboardExportDescription,
Risk: "read",
Scopes: wbExportScopes,
AuthTypes: wbExportAuthTypes,
Flags: wbExportFlags,
HasFormat: true,
Validate: wbExportValidate,
DryRun: wbExportDryRun,
Execute: wbExportExecute,
}
// WhiteboardQuery registers the hidden, backward-compatible `whiteboard +query` shortcut.
var WhiteboardQuery = common.Shortcut{
Service: "whiteboard",
Command: "+query",
Description: WhiteboardExportDescription,
Risk: "read",
Scopes: wbExportScopes,
AuthTypes: wbExportAuthTypes,
Flags: wbQueryFlags,
HasFormat: true,
Hidden: true,
Validate: wbQueryValidate,
DryRun: wbQueryDryRun,
Execute: wbQueryExecute,
}
// exportReq defines the request body for whiteboard export APIs.
type exportReq struct {
ExportType string `json:"export_type"`
}
// exportResp models the whiteboard export response envelope.
type exportResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Content string `json:"content"`
MimeType string `json:"mime_type"`
} `json:"data"`
}
// exportWhiteboardSvg exports a whiteboard as SVG and writes it to stdout or a file.
func exportWhiteboardSvg(runtime *common.RuntimeContext, wbToken, outDir string) error {
reqBody := exportReq{ExportType: "svg"}
req := &larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", url.PathEscape(wbToken)),
Body: reqBody,
}
resp, err := runtime.DoAPI(req)
if err != nil {
return wrapWbNetworkErr(err, "export whiteboard svg failed: %v", err)
}
var exportData exportResp
if err := json.Unmarshal(resp.RawBody, &exportData); err == nil {
if exportData.Code != 0 {
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "export whiteboard svg failed: %s", exportData.Msg).WithCode(exportData.Code)
}
} else if resp.StatusCode == http.StatusOK {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "parse export response failed: %v", err).WithCause(err)
}
if resp.StatusCode != http.StatusOK {
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
if resp.StatusCode >= 500 {
return errs.NewNetworkError(errs.SubtypeNetworkServer, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode).
WithRetryable()
}
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode)
}
svgBytes, err := base64.StdEncoding.DecodeString(exportData.Data.Content)
if err != nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "decode svg base64 failed: %v", err).WithCause(err)
}
if outDir == "" {
runtime.OutFormat(map[string]interface{}{
"svg_content": string(svgBytes),
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "%s\n", string(svgBytes))
})
return nil
}
finalPath, size, err := saveOutputFile(outDir, ".svg", wbToken, runtime, bytes.NewReader(svgBytes))
if err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"svg_path": finalPath,
"size_bytes": size,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "SVG saved to %s\n", finalPath)
fmt.Fprintf(w, "File size: %d bytes", size)
})
return nil
}
func exportWhiteboardPreview(ctx context.Context, runtime *common.RuntimeContext, wbToken, outDir string) error {
req := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", url.PathEscape(wbToken)),
}
// Execute API request. The preview endpoint streams raw image bytes (not a
// JSON envelope), so classify by HTTP status: 5xx is retryable network,
// while 4xx remains an API-side rejection.
resp, err := runtime.DoAPI(req, larkcore.WithFileDownload())
if err != nil {
return wrapWbNetworkErr(err, "get whiteboard preview failed: %v", err)
}
if resp.StatusCode >= 400 {
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
if resp.StatusCode >= 500 {
return errs.NewNetworkError(errs.SubtypeNetworkServer, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode).
WithRetryable()
}
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode)
}
finalPath, size, err := saveWhiteboardPreviewOutput(outDir, wbToken, runtime, resp.Header, bytes.NewReader(resp.RawBody))
if err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"preview_image_path": finalPath,
"size_bytes": size,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Preview image saved to %s\n", finalPath)
fmt.Fprintf(w, "Image size: %d bytes", size)
})
return nil
}
type wbNodesResp struct {
Data struct {
Nodes []interface{} `json:"nodes"`
} `json:"data"`
}
func fetchWhiteboardNodes(runtime *common.RuntimeContext, wbToken string) (*wbNodesResp, error) {
data, err := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)), nil, nil)
if err != nil {
return nil, err
}
var nodes wbNodesResp
rawNodes, _ := data["nodes"]
if rawNodes != nil {
var ok bool
nodes.Data.Nodes, ok = rawNodes.([]interface{})
if !ok {
return nil, wbInvalidResponse("get whiteboard nodes failed: data.nodes must be an array")
}
}
return &nodes, nil
}
type syntaxInfo struct {
code string
syntaxType SyntaxType
}
func exportWhiteboardCode(runtime *common.RuntimeContext, wbToken, outDir string) error {
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
if err != nil {
return err
}
if wbNodes == nil || wbNodes.Data.Nodes == nil {
runtime.OutFormat(map[string]interface{}{
"msg": "whiteboard is empty",
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Whiteboard is empty\n")
})
return nil
}
var syntaxBlocks []syntaxInfo
for _, node := range wbNodes.Data.Nodes {
nodeMap, ok := node.(map[string]interface{})
if !ok {
continue
}
syntax, ok := nodeMap["syntax"]
if !ok {
continue
}
syntaxMap, ok := syntax.(map[string]interface{})
if !ok {
continue
}
code, _ := syntaxMap["code"].(string)
var syntaxType SyntaxType
switch v := syntaxMap["syntax_type"].(type) {
case json.Number:
// runtime.ClassifyAPIResponse decodes the response with UseNumber,
// so numeric fields arrive as json.Number rather than float64.
if n, err := v.Int64(); err == nil {
syntaxType = SyntaxType(n)
}
case float64:
syntaxType = SyntaxType(v)
case SyntaxType:
syntaxType = v
}
if code != "" && syntaxType.IsValid() {
syntaxBlocks = append(syntaxBlocks, syntaxInfo{code: code, syntaxType: syntaxType})
}
}
if len(syntaxBlocks) == 0 {
runtime.OutFormat(map[string]interface{}{
"msg": "no code blocks found in whiteboard",
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "No code blocks found in whiteboard\n")
})
return nil
}
// 目前的标准操作是导出到单一文件,和 Doc 展示画板代码块采用相同的逻辑
// 如果有需求,可以调整到导出到多个文件的模式
if len(syntaxBlocks) > 1 {
runtime.OutFormat(map[string]interface{}{
"msg": "multiple code blocks found, cannot export directly",
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Multiple code blocks found, cannot export directly\n")
})
return nil
}
block := syntaxBlocks[0]
if outDir == "" {
runtime.OutFormat(map[string]interface{}{
"code": block.code,
"syntax_type": block.syntaxType.String(),
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "%s\n", block.code)
})
return nil
}
finalPath, _, err := saveOutputFile(outDir, block.syntaxType.ExtensionName(), wbToken, runtime, strings.NewReader(block.code))
if err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"output_path": finalPath,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Whiteboard code saved to %s\n", finalPath)
})
return nil
}
func exportWhiteboardRaw(runtime *common.RuntimeContext, wbToken, outDir string) error {
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
if err != nil {
return err
}
if wbNodes == nil || wbNodes.Data.Nodes == nil {
runtime.OutFormat(map[string]interface{}{
"msg": "whiteboard is empty",
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Whiteboard is empty\n")
})
return nil
}
jsonData, err := json.MarshalIndent(wbNodes.Data, "", " ")
if err != nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "cannot marshal whiteboard data: %s", err).WithCause(err)
}
if outDir == "" {
runtime.OutFormat(wbNodes.Data, nil, func(w io.Writer) {
fmt.Fprintf(w, "%s\n", string(jsonData))
})
return nil
}
finalPath, _, err := saveOutputFile(outDir, ".json", wbToken, runtime, bytes.NewReader(jsonData))
if err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"output_path": finalPath,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Whiteboard raw node structure saved to %s\n", finalPath)
})
return nil
}
func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext, data io.Reader) (string, int64, error) {
// Step 1: Get final output path
info, err := runtime.FileIO().Stat(outPath)
var finalPath string
if err == nil && info.IsDir() {
finalPath = filepath.Join(outPath, fmt.Sprintf("whiteboard_%s%s", token, ext))
} else {
// Fix extension in path
currentExt := filepath.Ext(outPath)
if currentExt != ext {
if currentExt != "" {
outPath = outPath[:len(outPath)-len(currentExt)]
}
outPath += ext
}
finalPath = outPath
}
if _, err := runtime.ResolveSavePath(finalPath); err != nil { // double check
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
}
// Step 2: Check overwrite
_, err = runtime.FileIO().Stat(finalPath)
if err == nil {
if !runtime.Bool("overwrite") {
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file already exists: %s (use --overwrite to overwrite)", finalPath).WithParam("--overwrite")
}
} else if !os.IsNotExist(err) {
return "", 0, errs.NewInternalError(errs.SubtypeFileIO, "cannot check file existence: %s", err).WithCause(err)
}
// Step 3: Save file
var contentType string
switch ext {
case ".png":
contentType = "image/png"
case ".jpg", ".jpeg":
contentType = "image/jpeg"
case ".svg":
contentType = "image/svg+xml"
case ".json":
contentType = "application/json"
case ".mmd", ".puml":
contentType = "text/plain"
}
savResult, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
ContentType: contentType,
}, data)
if err != nil {
return "", 0, wbSaveError(err)
}
return finalPath, savResult.Size(), nil
}
var whiteboardPreviewContentTypeExt = map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
}
func saveWhiteboardPreviewOutput(outPath, token string, runtime *common.RuntimeContext, header http.Header, data io.Reader) (string, int64, error) {
contentType := header.Get("Content-Type")
ext, err := whiteboardPreviewExtFromContentType(contentType)
if err != nil {
return "", 0, err
}
finalPath, err := whiteboardPreviewOutputPath(outPath, ext, token, runtime)
if err != nil {
return "", 0, err
}
return saveResolvedOutputFile(finalPath, contentType, runtime, data)
}
func whiteboardPreviewExtFromContentType(contentType string) (string, error) {
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
mediaType = strings.TrimSpace(strings.Split(contentType, ";")[0])
}
if ext, ok := whiteboardPreviewContentTypeExt[strings.ToLower(mediaType)]; ok {
return ext, nil
}
if strings.TrimSpace(contentType) == "" {
contentType = "<empty>"
}
return "", errs.NewInternalError(
errs.SubtypeInvalidResponse,
"get whiteboard preview failed: expected image/png or image/jpeg response, got Content-Type: %s",
contentType,
)
}
func whiteboardPreviewOutputPath(outPath, ext, token string, runtime *common.RuntimeContext) (string, error) {
info, err := runtime.FileIO().Stat(outPath)
if err == nil && info.IsDir() {
finalPath := filepath.Join(outPath, fmt.Sprintf("whiteboard_%s%s", token, ext))
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
}
return finalPath, nil
}
if err != nil && !os.IsNotExist(err) {
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot check output path: %s", err).WithCause(err)
}
currentExt := strings.ToLower(filepath.Ext(outPath))
if currentExt == "" || currentExt == "." {
finalPath := strings.TrimSuffix(outPath, ".") + ext
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
}
return finalPath, nil
}
if !isWhiteboardPreviewImageExt(currentExt) {
return "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid preview output extension %q; use .png, .jpg, .jpeg, a directory, or a path without extension",
currentExt,
).WithParam("--output")
}
if !whiteboardPreviewExtMatches(currentExt, ext) {
return "", errs.NewValidationError(
errs.SubtypeFailedPrecondition,
"preview response is %s but output path has extension %s; use a matching extension or omit the extension",
ext,
currentExt,
).WithParam("--output")
}
if _, err := runtime.ResolveSavePath(outPath); err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
}
return outPath, nil
}
func isWhiteboardPreviewImageExt(ext string) bool {
return ext == ".png" || ext == ".jpg" || ext == ".jpeg"
}
func whiteboardPreviewExtMatches(outputExt, responseExt string) bool {
if responseExt == ".jpg" {
return outputExt == ".jpg" || outputExt == ".jpeg"
}
return outputExt == responseExt
}
func saveResolvedOutputFile(finalPath, contentType string, runtime *common.RuntimeContext, data io.Reader) (string, int64, error) {
_, err := runtime.FileIO().Stat(finalPath)
if err == nil {
if !runtime.Bool("overwrite") {
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file already exists: %s (use --overwrite to overwrite)", finalPath).WithParam("--overwrite")
}
} else if !os.IsNotExist(err) {
return "", 0, errs.NewInternalError(errs.SubtypeFileIO, "cannot check file existence: %s", err).WithCause(err)
}
savResult, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
ContentType: contentType,
}, data)
if err != nil {
return "", 0, wbSaveError(err)
}
return finalPath, savResult.Size(), nil
}

View File

@@ -9,6 +9,7 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
@@ -211,6 +212,73 @@ func TestWhiteboardQuery_Validate_TypedErrors(t *testing.T) {
}
}
// TestWhiteboardExport_Validate verifies the canonical +export flag spelling
// and output type names while legacy +query validation remains covered above.
func TestWhiteboardExport_Validate(t *testing.T) {
ctx := context.Background()
chdirTemp(t)
tests := []struct {
name string
flags map[string]string
wantErr bool
wantParam string
}{
{
name: "valid: preview with output",
flags: map[string]string{
"whiteboard-token": "test-token-123",
"output-type": "preview",
"output": "output",
},
},
{
name: "valid: source without output",
flags: map[string]string{
"whiteboard-token": "test-token-123",
"output-type": "source",
},
},
{
name: "invalid: preview without output",
flags: map[string]string{
"whiteboard-token": "test-token-123",
"output-type": "preview",
},
wantErr: true,
wantParam: "--output",
},
{
name: "invalid: bad output-type value",
flags: map[string]string{
"whiteboard-token": "test-token-123",
"output-type": "image",
},
wantErr: true,
wantParam: "--output-type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := WhiteboardExport.Validate(ctx, newTestRuntime(tt.flags, nil))
if (err != nil) != tt.wantErr {
t.Fatalf("WhiteboardExport.Validate() error = %v, wantErr %v", err, tt.wantErr)
}
if err == nil {
return
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error is not *errs.ValidationError: %T", err)
}
if ve.Param != tt.wantParam {
t.Fatalf("Param = %q, want %q", ve.Param, tt.wantParam)
}
})
}
}
// TestExportWhiteboardPreview_HTTPError locks the download-path failure
// behavior: a failed preview download surfaces as a typed errs.* envelope, not
// a flat legacy error.
@@ -284,7 +352,7 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
"output": "output.png",
},
wantMethod: "GET",
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/download_as_image",
wantPath: "/open-apis/board/v1/whiteboards/test...-123/download_as_image",
},
{
name: "dry run code",
@@ -293,7 +361,7 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
"output_as": "code",
},
wantMethod: "GET",
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
wantPath: "/open-apis/board/v1/whiteboards/test...-123/nodes",
},
{
name: "dry run raw",
@@ -302,7 +370,7 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
"output_as": "raw",
},
wantMethod: "GET",
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
wantPath: "/open-apis/board/v1/whiteboards/test...-123/nodes",
},
}
@@ -313,6 +381,29 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
if dryRun == nil {
t.Fatalf("WhiteboardQuery.DryRun() returned nil")
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
data, err := json.Marshal(dryRun)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("Unmarshal() error = %v; data=%s", err, string(data))
}
if len(got.API) != 1 {
t.Fatalf("api len = %d, want 1; data=%s", len(got.API), string(data))
}
if got.API[0].Method != tt.wantMethod {
t.Fatalf("method = %q, want %q; data=%s", got.API[0].Method, tt.wantMethod, string(data))
}
if got.API[0].URL != tt.wantPath {
t.Fatalf("url = %q, want %q; data=%s", got.API[0].URL, tt.wantPath, string(data))
}
})
}
}
@@ -391,6 +482,32 @@ func TestWhiteboardQuery_ShortcutRegistration(t *testing.T) {
if len(WhiteboardQuery.Flags) == 0 {
t.Errorf("WhiteboardQuery.Flags is empty, expected at least one flag")
}
if !WhiteboardQuery.Hidden {
t.Errorf("WhiteboardQuery should be hidden because +export is the canonical command")
}
// Verify WhiteboardExport is the visible canonical shortcut.
if WhiteboardExport.Command != "+export" {
t.Errorf("WhiteboardExport.Command = %q, want \"+export\"", WhiteboardExport.Command)
}
if WhiteboardExport.Service != "whiteboard" {
t.Errorf("WhiteboardExport.Service = %q, want \"whiteboard\"", WhiteboardExport.Service)
}
if WhiteboardExport.Hidden {
t.Errorf("WhiteboardExport should be visible")
}
if flag := shortcutFlag(WhiteboardExport, "output_as"); flag != nil {
t.Errorf("WhiteboardExport --output_as should not be registered; got %#v", *flag)
}
if flag := shortcutFlag(WhiteboardExport, "output-type"); flag == nil || flag.Hidden {
t.Errorf("WhiteboardExport --output-type should exist and be visible")
}
if flag := shortcutFlag(WhiteboardQuery, "output_as"); flag == nil || flag.Hidden {
t.Errorf("WhiteboardQuery --output_as should exist and remain visible on the hidden legacy command")
}
if flag := shortcutFlag(WhiteboardQuery, "output-type"); flag != nil {
t.Errorf("WhiteboardQuery --output-type should not be registered; got %#v", *flag)
}
}
// TestSaveOutputFile verifies output saving, overwrite handling, and extension-specific paths.
@@ -862,10 +979,11 @@ func TestExportWhiteboardPreview(t *testing.T) {
// Mock download preview image API response with RawBody
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/board/v1/whiteboards/test-token-preview/download_as_image",
Status: 200,
RawBody: []byte("fake PNG image data"),
Method: "GET",
URL: "/open-apis/board/v1/whiteboards/test-token-preview/download_as_image",
Status: 200,
RawBody: []byte("fake PNG image data"),
ContentType: "image/png",
})
args := []string{"+query", "--whiteboard-token", "test-token-preview", "--output_as", "image", "--output", "output", "--overwrite"}
@@ -883,6 +1001,158 @@ func TestExportWhiteboardPreview(t *testing.T) {
}
}
// TestExportWhiteboardPreview_UsesContentTypeExtension verifies preview image
// downloads are saved according to the API response Content-Type rather than a
// hard-coded PNG suffix.
func TestExportWhiteboardPreview_UsesContentTypeExtension(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/board/v1/whiteboards/test-token-preview-jpeg/download_as_image",
Status: 200,
RawBody: []byte("fake JPEG image data"),
ContentType: "image/jpeg",
})
args := []string{"+export", "--whiteboard-token", "test-token-preview-jpeg", "--output-type", "preview", "--output", "output", "--overwrite"}
if err := runShortcut(t, WhiteboardExport, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if _, err := os.Stat("output.png"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("output.png should not exist when response Content-Type is image/jpeg, stat err=%v", err)
}
data, err := os.ReadFile("output.jpg")
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != "fake JPEG image data" {
t.Fatalf("image content = %q, want %q", string(data), "fake JPEG image data")
}
}
func TestExportWhiteboardPreview_RejectsNonImageContentTypeWithoutSiblingOverwrite(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
if err := os.WriteFile("report.html", []byte("keep me"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/board/v1/whiteboards/test-token-preview-html/download_as_image",
Status: 200,
RawBody: []byte("<html>bad gateway</html>"),
ContentType: "text/html; charset=utf-8",
})
args := []string{"+export", "--whiteboard-token", "test-token-preview-html", "--output-type", "preview", "--output", "report.png", "--overwrite"}
err := runShortcut(t, WhiteboardExport, args, factory, stdout)
if err == nil {
t.Fatal("expected error for non-image preview response")
}
assertInvalidResponse(t, err)
data, readErr := os.ReadFile("report.html")
if readErr != nil {
t.Fatalf("ReadFile() error: %v", readErr)
}
if string(data) != "keep me" {
t.Fatalf("report.html was overwritten: %q", string(data))
}
if _, statErr := os.Stat("report.png"); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("report.png should not be written on invalid response, stat err=%v", statErr)
}
}
func TestExportWhiteboardPreview_IgnoresContentDispositionExtension(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/board/v1/whiteboards/test-token-preview-disposition/download_as_image",
Status: 200,
RawBody: []byte("fake JPEG image data"),
Headers: http.Header{
"Content-Type": []string{"image/jpeg"},
"Content-Disposition": []string{`attachment; filename="payload.sh"`},
},
})
args := []string{"+export", "--whiteboard-token", "test-token-preview-disposition", "--output-type", "preview", "--output", "output", "--overwrite"}
if err := runShortcut(t, WhiteboardExport, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if _, err := os.Stat("output.sh"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("output.sh should not be created from Content-Disposition, stat err=%v", err)
}
data, err := os.ReadFile("output.jpg")
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != "fake JPEG image data" {
t.Fatalf("image content = %q, want %q", string(data), "fake JPEG image data")
}
}
func TestExportWhiteboardPreview_RejectsMismatchedExplicitExtension(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/board/v1/whiteboards/test-token-preview-mismatch/download_as_image",
Status: 200,
RawBody: []byte("fake JPEG image data"),
ContentType: "image/jpeg",
})
args := []string{"+export", "--whiteboard-token", "test-token-preview-mismatch", "--output-type", "preview", "--output", "report.png", "--overwrite"}
err := runShortcut(t, WhiteboardExport, args, factory, stdout)
if err == nil {
t.Fatal("expected error for mismatched explicit extension")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error is not *errs.ValidationError: %T (%v)", err, err)
}
if ve.Subtype != errs.SubtypeFailedPrecondition || ve.Param != "--output" {
t.Fatalf("validation details = subtype %q param %q, want %q --output", ve.Subtype, ve.Param, errs.SubtypeFailedPrecondition)
}
if _, statErr := os.Stat("report.jpg"); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("report.jpg should not be created when explicit path mismatches, stat err=%v", statErr)
}
}
func TestExportWhiteboardPreview_AllowsMatchingExplicitExtension(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/board/v1/whiteboards/test-token-preview-matching/download_as_image",
Status: 200,
RawBody: []byte("fake JPEG image data"),
ContentType: "image/jpeg",
})
args := []string{"+export", "--whiteboard-token", "test-token-preview-matching", "--output-type", "preview", "--output", "report.jpeg", "--overwrite"}
if err := runShortcut(t, WhiteboardExport, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
data, err := os.ReadFile("report.jpeg")
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != "fake JPEG image data" {
t.Fatalf("image content = %q, want %q", string(data), "fake JPEG image data")
}
}
// TestExportWhiteboardRaw_EmptyNodes verifies raw export reports empty whiteboards.
func TestExportWhiteboardRaw_EmptyNodes(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -1522,3 +1792,12 @@ func chdirTemp(t *testing.T) {
}
t.Cleanup(func() { os.Chdir(orig) })
}
func shortcutFlag(shortcut common.Shortcut, name string) *common.Flag {
for i := range shortcut.Flags {
if shortcut.Flags[i].Name == name {
return &shortcut.Flags[i]
}
}
return nil
}

View File

@@ -1,494 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
const (
// WhiteboardQueryAsImage exports a whiteboard preview image.
WhiteboardQueryAsImage = "image"
// WhiteboardQueryAsSvg exports a whiteboard as SVG.
WhiteboardQueryAsSvg = "svg"
// WhiteboardQueryAsCode exports Mermaid or PlantUML source extracted from the whiteboard.
WhiteboardQueryAsCode = "code"
// WhiteboardQueryAsRaw exports the raw whiteboard node payload.
WhiteboardQueryAsRaw = "raw"
)
// SyntaxType identifies the diagram syntax extracted from whiteboard code blocks.
type SyntaxType int
const (
// SyntaxTypePlantUML marks PlantUML code blocks.
SyntaxTypePlantUML SyntaxType = 1
// SyntaxTypeMermaid marks Mermaid code blocks.
SyntaxTypeMermaid SyntaxType = 2
)
// SyntaxTypeNameMap maps whiteboard syntax types to their CLI output names.
var SyntaxTypeNameMap = map[SyntaxType]string{
SyntaxTypePlantUML: "plantuml",
SyntaxTypeMermaid: "mermaid",
}
// SyntaxTypeExtensionMap maps whiteboard syntax types to their default file extensions.
var SyntaxTypeExtensionMap = map[SyntaxType]string{
SyntaxTypePlantUML: ".puml",
SyntaxTypeMermaid: ".mmd",
}
// String returns the CLI-facing name for the syntax type.
func (s SyntaxType) String() string {
return SyntaxTypeNameMap[s]
}
// ExtensionName returns the default file extension for the syntax type.
func (s SyntaxType) ExtensionName() string {
return SyntaxTypeExtensionMap[s]
}
// IsValid reports whether the syntax type is one of the supported whiteboard code syntaxes.
func (s SyntaxType) IsValid() bool {
return s == SyntaxTypePlantUML || s == SyntaxTypeMermaid
}
// WhiteboardQuery registers the `whiteboard +query` shortcut.
var WhiteboardQuery = common.Shortcut{
Service: "whiteboard",
Command: "+query",
Description: "Query a existing whiteboard, export it as preview image or raw nodes structure.",
Risk: "read",
Scopes: []string{"board:whiteboard:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
{Name: "output_as", Desc: "output whiteboard as: image | svg | code | raw.", Required: true},
{Name: "output", Desc: "output directory. It is required when output as image. If not specified when --output_as svg/code/raw, it will output directly.", Required: false},
{Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
},
HasFormat: true,
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
// Check if token contains control characters
token := runtime.Str("whiteboard-token")
if err := common.RejectDangerousCharsTyped("--whiteboard-token", token); err != nil {
return err
}
out := runtime.Str("output")
if out != "" {
if _, err := runtime.ResolveSavePath(out); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
}
}
if out == "" && runtime.Str("output_as") == WhiteboardQueryAsImage {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "need a output directory to query whiteboard as image").WithParam("--output")
}
as := runtime.Str("output_as")
if as != WhiteboardQueryAsImage && as != WhiteboardQueryAsSvg && as != WhiteboardQueryAsCode && as != WhiteboardQueryAsRaw {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | svg | code | raw").WithParam("--output_as")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
as := runtime.Str("output_as")
token := runtime.Str("whiteboard-token")
switch as {
case WhiteboardQueryAsImage:
return common.NewDryRunAPI().
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", common.MaskToken(url.PathEscape(token)))).
Desc("Export preview image of given whiteboard")
case WhiteboardQueryAsCode:
return common.NewDryRunAPI().
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
Desc("Extract Mermaid/Plantuml code from given whiteboard")
case WhiteboardQueryAsRaw:
return common.NewDryRunAPI().
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
Desc("Extract raw nodes structure from given whiteboard")
case WhiteboardQueryAsSvg:
return common.NewDryRunAPI().
POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", common.MaskToken(url.PathEscape(token)))).
Body(map[string]string{"export_type": "svg"}).
Desc("Export SVG of given whiteboard")
default:
return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | svg | code | raw")
}
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
// 构建 API 请求
token := runtime.Str("whiteboard-token")
outDir := runtime.Str("output")
as := runtime.Str("output_as")
switch as {
case WhiteboardQueryAsImage:
return exportWhiteboardPreview(ctx, runtime, token, outDir)
case WhiteboardQueryAsSvg:
return exportWhiteboardSvg(runtime, token, outDir)
case WhiteboardQueryAsCode:
return exportWhiteboardCode(runtime, token, outDir)
case WhiteboardQueryAsRaw:
return exportWhiteboardRaw(runtime, token, outDir)
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | svg | code | raw").WithParam("--output_as")
}
},
}
// exportReq defines the request body for whiteboard export APIs.
type exportReq struct {
ExportType string `json:"export_type"`
}
// exportResp models the whiteboard export response envelope.
type exportResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Content string `json:"content"`
MimeType string `json:"mime_type"`
} `json:"data"`
}
// exportWhiteboardSvg exports a whiteboard as SVG and writes it to stdout or a file.
func exportWhiteboardSvg(runtime *common.RuntimeContext, wbToken, outDir string) error {
reqBody := exportReq{ExportType: "svg"}
req := &larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", url.PathEscape(wbToken)),
Body: reqBody,
}
resp, err := runtime.DoAPI(req)
if err != nil {
return wrapWbNetworkErr(err, "export whiteboard svg failed: %v", err)
}
var exportData exportResp
if err := json.Unmarshal(resp.RawBody, &exportData); err == nil {
if exportData.Code != 0 {
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "export whiteboard svg failed: %s", exportData.Msg).WithCode(exportData.Code)
}
} else if resp.StatusCode == http.StatusOK {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "parse export response failed: %v", err).WithCause(err)
}
if resp.StatusCode != http.StatusOK {
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
if resp.StatusCode >= 500 {
return errs.NewNetworkError(errs.SubtypeNetworkServer, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode).
WithRetryable()
}
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode)
}
svgBytes, err := base64.StdEncoding.DecodeString(exportData.Data.Content)
if err != nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "decode svg base64 failed: %v", err).WithCause(err)
}
if outDir == "" {
runtime.OutFormat(map[string]interface{}{
"svg_content": string(svgBytes),
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "%s\n", string(svgBytes))
})
return nil
}
finalPath, size, err := saveOutputFile(outDir, ".svg", wbToken, runtime, bytes.NewReader(svgBytes))
if err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"svg_path": finalPath,
"size_bytes": size,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "SVG saved to %s\n", finalPath)
fmt.Fprintf(w, "File size: %d bytes", size)
})
return nil
}
func exportWhiteboardPreview(ctx context.Context, runtime *common.RuntimeContext, wbToken, outDir string) error {
req := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", url.PathEscape(wbToken)),
}
// Execute API request. The preview endpoint streams raw image bytes (not a
// JSON envelope), so classify by HTTP status: 5xx is retryable network,
// while 4xx remains an API-side rejection.
resp, err := runtime.DoAPI(req, larkcore.WithFileDownload())
if err != nil {
return wrapWbNetworkErr(err, "get whiteboard preview failed: %v", err)
}
if resp.StatusCode >= 400 {
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
if resp.StatusCode >= 500 {
return errs.NewNetworkError(errs.SubtypeNetworkServer, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode).
WithRetryable()
}
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode)
}
finalPath, size, err := saveOutputFile(outDir, ".png", wbToken, runtime, bytes.NewReader(resp.RawBody))
if err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"preview_image_path": finalPath,
"size_bytes": size,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Preview image saved to %s\n", finalPath)
fmt.Fprintf(w, "Image size: %d bytes", size)
})
return nil
}
type wbNodesResp struct {
Data struct {
Nodes []interface{} `json:"nodes"`
} `json:"data"`
}
func fetchWhiteboardNodes(runtime *common.RuntimeContext, wbToken string) (*wbNodesResp, error) {
data, err := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)), nil, nil)
if err != nil {
return nil, err
}
var nodes wbNodesResp
rawNodes, _ := data["nodes"]
if rawNodes != nil {
var ok bool
nodes.Data.Nodes, ok = rawNodes.([]interface{})
if !ok {
return nil, wbInvalidResponse("get whiteboard nodes failed: data.nodes must be an array")
}
}
return &nodes, nil
}
type syntaxInfo struct {
code string
syntaxType SyntaxType
}
func exportWhiteboardCode(runtime *common.RuntimeContext, wbToken, outDir string) error {
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
if err != nil {
return err
}
if wbNodes == nil || wbNodes.Data.Nodes == nil {
runtime.OutFormat(map[string]interface{}{
"msg": "whiteboard is empty",
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Whiteboard is empty\n")
})
return nil
}
var syntaxBlocks []syntaxInfo
for _, node := range wbNodes.Data.Nodes {
nodeMap, ok := node.(map[string]interface{})
if !ok {
continue
}
syntax, ok := nodeMap["syntax"]
if !ok {
continue
}
syntaxMap, ok := syntax.(map[string]interface{})
if !ok {
continue
}
code, _ := syntaxMap["code"].(string)
var syntaxType SyntaxType
switch v := syntaxMap["syntax_type"].(type) {
case json.Number:
// runtime.ClassifyAPIResponse decodes the response with UseNumber,
// so numeric fields arrive as json.Number rather than float64.
if n, err := v.Int64(); err == nil {
syntaxType = SyntaxType(n)
}
case float64:
syntaxType = SyntaxType(v)
case SyntaxType:
syntaxType = v
}
if code != "" && syntaxType.IsValid() {
syntaxBlocks = append(syntaxBlocks, syntaxInfo{code: code, syntaxType: syntaxType})
}
}
if len(syntaxBlocks) == 0 {
runtime.OutFormat(map[string]interface{}{
"msg": "no code blocks found in whiteboard",
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "No code blocks found in whiteboard\n")
})
return nil
}
// 目前的标准操作是导出到单一文件,和 Doc 展示画板代码块采用相同的逻辑
// 如果有需求,可以调整到导出到多个文件的模式
if len(syntaxBlocks) > 1 {
runtime.OutFormat(map[string]interface{}{
"msg": "multiple code blocks found, cannot export directly",
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Multiple code blocks found, cannot export directly\n")
})
return nil
}
block := syntaxBlocks[0]
if outDir == "" {
runtime.OutFormat(map[string]interface{}{
"code": block.code,
"syntax_type": block.syntaxType.String(),
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "%s\n", block.code)
})
return nil
}
finalPath, _, err := saveOutputFile(outDir, block.syntaxType.ExtensionName(), wbToken, runtime, strings.NewReader(block.code))
if err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"output_path": finalPath,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Whiteboard code saved to %s\n", finalPath)
})
return nil
}
func exportWhiteboardRaw(runtime *common.RuntimeContext, wbToken, outDir string) error {
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
if err != nil {
return err
}
if wbNodes == nil || wbNodes.Data.Nodes == nil {
runtime.OutFormat(map[string]interface{}{
"msg": "whiteboard is empty",
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Whiteboard is empty\n")
})
return nil
}
jsonData, err := json.MarshalIndent(wbNodes.Data, "", " ")
if err != nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "cannot marshal whiteboard data: %s", err).WithCause(err)
}
if outDir == "" {
runtime.OutFormat(wbNodes.Data, nil, func(w io.Writer) {
fmt.Fprintf(w, "%s\n", string(jsonData))
})
return nil
}
finalPath, _, err := saveOutputFile(outDir, ".json", wbToken, runtime, bytes.NewReader(jsonData))
if err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"output_path": finalPath,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Whiteboard raw node structure saved to %s\n", finalPath)
})
return nil
}
func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext, data io.Reader) (string, int64, error) {
// Step 1: Get final output path
info, err := runtime.FileIO().Stat(outPath)
var finalPath string
if err == nil && info.IsDir() {
finalPath = filepath.Join(outPath, fmt.Sprintf("whiteboard_%s%s", token, ext))
} else {
// Fix extension in path
currentExt := filepath.Ext(outPath)
if currentExt != ext {
if currentExt != "" {
outPath = outPath[:len(outPath)-len(currentExt)]
}
outPath += ext
}
finalPath = outPath
}
if _, err := runtime.ResolveSavePath(finalPath); err != nil { // double check
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
}
// Step 2: Check overwrite
_, err = runtime.FileIO().Stat(finalPath)
if err == nil {
if !runtime.Bool("overwrite") {
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file already exists: %s (use --overwrite to overwrite)", finalPath).WithParam("--overwrite")
}
} else if !os.IsNotExist(err) {
return "", 0, errs.NewInternalError(errs.SubtypeFileIO, "cannot check file existence: %s", err).WithCause(err)
}
// Step 3: Save file
var contentType string
switch ext {
case ".png":
contentType = "image/png"
case ".svg":
contentType = "image/svg+xml"
case ".json":
contentType = "application/json"
case ".mmd", ".puml":
contentType = "text/plain"
}
savResult, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
ContentType: contentType,
}, data)
if err != nil {
return "", 0, wbSaveError(err)
}
return finalPath, savResult.Size(), nil
}

View File

@@ -255,6 +255,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
got := Shortcuts()
want := []string{
"+update",
"+export",
"+query",
}

View File

@@ -22,21 +22,22 @@ metadata:
**身份**:画板操作默认使用 `--as user`。仅当需要以应用身份上传时使用 `--as bot`
| 用户需求 | 行动 |
|-----------------------------------------|-----------------------------------------------------------------------------------------------|
| 查看画板内容 / 导出图片 / 导出 SVG 矢量图 | [`+query --output_as image/svg`](references/lark-whiteboard-query.md) |
| 获取画板的 Mermaid/PlantUML 代码 | [`+query --output_as code`](references/lark-whiteboard-query.md) |
| 检查画板是否由代码绘制 | [`+query --output_as code`](references/lark-whiteboard-query.md) |
| 仅微调节点文字/颜色 | `+query --output_as raw` → 手动改 JSON → `+update --input_format raw` |
| 用户需求 | 行动 |
|-----------------------------------------|---------------------------------------------------------------------------------------------------|
| 查看画板内容 / 导出图片 | [`+export --output-type preview`](references/lark-whiteboard-export.md) |
| 导出 SVG 矢量图 | [`+export --output-type svg`](references/lark-whiteboard-export.md) |
| 获取画板的 Mermaid/PlantUML 代码 | [`+export --output-type source`](references/lark-whiteboard-export.md) |
| 检查画板是否由代码绘制 | [`+export --output-type source`](references/lark-whiteboard-export.md) |
| 仅微调节点文字/颜色 | `+export --output-type raw` → 手动改 JSON → `+update --input_format raw` |
| 用户**已提供** Mermaid/PlantUML/SVG 代码,或明确指定用该格式 | 自己生成/使用代码 → [`+update --input_format mermaid/plantuml/svg`](references/lark-whiteboard-update.md) |
| 新建/创作复杂图表(架构/流程/组织等) | → **[§ 创作 Workflow](references/lark-whiteboard-workflow.md#创作-workflow)** |
| 修改/重绘已有画板 | → **[§ 修改 Workflow](references/lark-whiteboard-workflow.md#修改-workflow)** |
| 新建/创作复杂图表(架构/流程/组织等) | → **[§ 创作 Workflow](references/lark-whiteboard-workflow.md#创作-workflow)** |
| 修改/重绘已有画板 | → **[§ 修改 Workflow](references/lark-whiteboard-workflow.md#修改-workflow)** |
## Shortcuts
| Shortcut | 说明 |
|---|---|
| [`+query`](references/lark-whiteboard-query.md) | 查询画板,导出为预览图片、SVG 矢量图、代码或原始节点结构。 |
| Shortcut | 说明 |
|---------------------------------------------------|---|
| [`+export`](references/lark-whiteboard-export.md) | 导出画板为预览图片、SVG 矢量图、代码或原始节点结构。 |
| [`+update`](references/lark-whiteboard-update.md) | 更新画板,支持 PlantUML、Mermaid、SVG 或 OpenAPI 原生格式 |
---

View File

@@ -1,23 +1,23 @@
# whiteboard +query查询画板)
# whiteboard +export导出画板)
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
查询画板内容支持导出为预览图片、SVG 矢量图、提取 PlantUML/Mermaid 代码,或获取飞书 OpenAPI 原生画板节点格式。
导出画板内容支持导出为预览图片、SVG 矢量图、提取 PlantUML/Mermaid 代码,或获取飞书 OpenAPI 原生画板节点格式。
## 参数
| 参数 | 必填 | 说明 |
|----------------------|----|------------------------------------------------------------------------|
| `--whiteboard-token` | 是 | 画板 token需要拥有画板的读权限 |
| `--output_as` | 是 | 输出格式:`image`(预览图片)、`svg`SVG 矢量图)、`code`PlantUML/Mermaid 代码)、`raw`OpenAPI 原生画板节点格式) |
| `--output` | 否 | 输出路径。当 `--output_as image` 时必填;当 `--output_as svg/code/raw` 时可选,不填则直接输出到终端 |
| `--output-type` | 是 | 输出格式:`preview`(预览图片)、`svg`SVG 矢量图)、`source`PlantUML/Mermaid 代码)、`raw`OpenAPI 原生画板节点格式) |
| `--output` | 否 | 输出路径。当 `--output-type preview` 时必填,推荐传入无后缀文件路径(如 `./preview`);当 `--output-type svg/source/raw` 时可选,不填则直接输出到终端 |
| `--overwrite` | 否 | 覆盖已存在的文件,默认为 false |
## 输出格式
- `image`:预览图片
- `preview`:预览图片。推荐 `--output ./preview` 这类无后缀文件路径CLI 会按实际图片类型保存为 `./preview.png``./preview.jpg`。如果 `--output` 是目录,会保存为该目录下的 `whiteboard_<whiteboard-token>.png/.jpg`;如果显式写了后缀,需要和实际图片类型匹配。`--overwrite` 检查的是补齐后缀后的最终路径,例如返回 PNG 时 `--output ./preview` 对应覆盖 `./preview.png`
- `svg`:导出画板为标准 SVG 矢量图。可用于 SVG 编辑后回写画板(见 [`routes/svg-edit.md`](../routes/svg-edit.md))。注意:导出为纯视觉快照,思维导图层级、表格结构、连接器绑定等语义信息会丢失。
- `code`PlantUML/Mermaid 代码。仅限画板内有且仅有一个 PlantUML/Mermaid 图时,才可导出代码,否则会在返回值中告知不存在/有多个节点。
- `source`PlantUML/Mermaid 代码。仅限画板内有且仅有一个 PlantUML/Mermaid 图时,才可导出代码,否则会在返回值中告知不存在/有多个节点。
- `raw`:飞书 OpenAPI 原生画板节点格式。这一 json 格式不适合直接编辑复杂布局或内容,建议仅限于需要修改简单的文本内容/颜色等细节时使用。需要进行更复杂的设计/修改时,建议参考 [§ 渲染 & 写入画板](../SKILL.md#渲染--写入画板)。
## 示例
@@ -25,26 +25,26 @@
### 示例 1导出画板为预览图片
```bash
lark-cli whiteboard +query \
lark-cli whiteboard +export \
--whiteboard-token "wbcnxxxxxxxx" \
--output_as image \
--output ./preview.png
--output-type preview \
--output ./preview
```
### 示例 2提取画板中的代码并直接输出
```bash
lark-cli whiteboard +query \
lark-cli whiteboard +export \
--whiteboard-token "wbcnxxxxxxxx" \
--output_as code
--output-type source
```
### 示例 3导出画板为 SVG 矢量图
```bash
lark-cli whiteboard +query \
lark-cli whiteboard +export \
--whiteboard-token "wbcnxxxxxxxx" \
--output_as svg \
--output-type svg \
--output ./whiteboard.svg \
--as user
```
@@ -52,9 +52,9 @@ lark-cli whiteboard +query \
### 示例 4导出画板原始节点结构到文件
```bash
lark-cli whiteboard +query \
lark-cli whiteboard +export \
--whiteboard-token "wbcnxxxxxxxx" \
--output_as raw \
--output-type raw \
--output ./nodes.json \
--overwrite
```

View File

@@ -26,12 +26,12 @@
**Step 2判断修改策略**
```
+query --output_as code
+export --output-type source
├─ 返回 Mermaid/PlantUML 代码
│ → 在原代码上修改 → +update --input_format mermaid/plantuml
├─ 无代码SVG/DSL 或其他方式绘制的画板)
│ ├─ 需纯新增(思维导图、流程图、时序图、类图、饼图、甘特图)图表节点
│ │ → +query --output_as image → 看图 → +query --output_as raw → 确定新节点坐标和层级 → [§ 渲染 & 写入画板]
│ │ → +export --output-type preview → 看图 → +export --output-type raw → 确定新节点坐标和层级 → [§ 渲染 & 写入画板]
│ └─ 其他改动(几何变动/增删元素/结构调整/混合编辑等)
│ → [`../routes/svg-edit.md`](../routes/svg-edit.md)(视觉高保真还原,大部分场景适用)
└─ 用户有明确要求 → 以用户要求优先

View File

@@ -25,9 +25,9 @@ SVG 导出是**纯视觉快照**,再次导入后画板语义(思维导图层
### 1. 导出当前画板 SVG
```bash
lark-cli whiteboard +query \
lark-cli whiteboard +export \
--whiteboard-token <TOKEN> \
--output_as svg \
--output-type svg \
--output <dir>/original.svg \
--as user
```

View File

@@ -54,8 +54,6 @@
- 阴影:`<filter>` 里放 `<feDropShadow>` 或标准 drop/inner primitive 链 (`<feGaussianBlur in="SourceAlpha">` + `<feOffset>` + `<feFlood>` + `<feComposite>` + `<feMerge>`), 会被识别成节点阴影, drop 至多 1 个, inner 至多 1 个; 其余 filter 效果不识别
- 渐变:`<linearGradient>` / `<radialGradient>``<defs>` 中定义, 通过 `fill="url(#id)"` 引用 (载体限 `<rect>` / `<circle>` / `<ellipse>` / `<polygon>` / `<path>`), 需要至少 2 个 `<stop>`, `gradientUnits` 只支持默认的 `objectBoundingBox` (不写即可);
> [!IMPORTANT]
> ⚠️ **不支持的装饰特性**
**⚠️ [!IMPORTANT] 不支持的装饰特性**
- `<pattern>` / `<clipPath>` / `<mask>` / 非阴影用途的 `<filter>` (blur / hue-rotate / 复合合成 / `flood-color=url(...)` / 多个 `<feDropShadow>` 等) → 画板不支持,**请避免使用,否则会导致画板渲染问题**
- 渐变边界:`gradientUnits="userSpaceOnUse"` / `spreadMethod="reflect|repeat"` / stops 少于 2 个 / 复杂 `gradientTransform` 会变成不可编辑图片, 视觉正确但失去可编辑性, 若无必要请沿用默认 `objectBoundingBox`

View File

@@ -0,0 +1,191 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestWhiteboardExportDryRun_RequestShapes(t *testing.T) {
setWhiteboardDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
tests := []struct {
name string
args []string
wantMethod string
wantSuffix string
wantBody map[string]string
}{
{
name: "preview",
args: []string{
"whiteboard", "+export",
"--whiteboard-token", "wbcnDryRunPreview",
"--output-type", "preview",
"--output", "preview",
"--dry-run",
},
wantMethod: "GET",
wantSuffix: "/download_as_image",
},
{
name: "svg",
args: []string{
"whiteboard", "+export",
"--whiteboard-token", "wbcnDryRunSvg",
"--output-type", "svg",
"--dry-run",
},
wantMethod: "POST",
wantSuffix: "/export",
wantBody: map[string]string{
"export_type": "svg",
},
},
{
name: "source",
args: []string{
"whiteboard", "+export",
"--whiteboard-token", "wbcnDryRunSource",
"--output-type", "source",
"--dry-run",
},
wantMethod: "GET",
wantSuffix: "/nodes",
},
{
name: "raw",
args: []string{
"whiteboard", "+export",
"--whiteboard-token", "wbcnDryRunRaw",
"--output-type", "raw",
"--dry-run",
},
wantMethod: "GET",
wantSuffix: "/nodes",
},
}
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)
out := result.Stdout
if got := clie2e.DryRunGet(out, "api.#").Int(); got != 1 {
t.Fatalf("api count=%d, want 1\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != tt.wantMethod {
t.Fatalf("method=%q, want %q\nstdout:\n%s", got, tt.wantMethod, out)
}
gotURL := clie2e.DryRunGet(out, "api.0.url").String()
if !strings.HasPrefix(gotURL, "/open-apis/board/v1/whiteboards/") || !strings.HasSuffix(gotURL, tt.wantSuffix) {
t.Fatalf("url=%q, want board whiteboard URL ending %q\nstdout:\n%s", gotURL, tt.wantSuffix, out)
}
for key, want := range tt.wantBody {
if got := clie2e.DryRunGet(out, "api.0.body."+key).String(); got != want {
t.Fatalf("body.%s=%q, want %q\nstdout:\n%s", key, got, want, out)
}
}
})
}
}
func TestWhiteboardQueryDryRun_LegacySmoke(t *testing.T) {
setWhiteboardDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"whiteboard", "+query",
"--whiteboard-token", "wbcnDryRunLegacy",
"--output_as", "image",
"--output", "preview",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method=%q, want GET\nstdout:\n%s", got, out)
}
gotURL := clie2e.DryRunGet(out, "api.0.url").String()
if !strings.HasPrefix(gotURL, "/open-apis/board/v1/whiteboards/") || !strings.HasSuffix(gotURL, "/download_as_image") {
t.Fatalf("url=%q, want preview download\nstdout:\n%s", gotURL, out)
}
}
func TestWhiteboardExportSelectorRequiredBeforeAuth(t *testing.T) {
setWhiteboardDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
t.Run("export requires output-type", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"whiteboard", "+export",
"--whiteboard-token", "wbcnMissingSelector",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
output := result.Stdout + "\n" + result.Stderr
if got := gjson.Get(output, "error.type").String(); got != "validation" {
t.Fatalf("error.type=%q, want validation\nstdout:\n%s\nstderr:\n%s", got, result.Stdout, result.Stderr)
}
if got := gjson.Get(output, "error.message").String(); !strings.Contains(got, "output-type") {
t.Fatalf("error.message=%q, want output-type\nstdout:\n%s\nstderr:\n%s", got, result.Stdout, result.Stderr)
}
})
t.Run("legacy query requires output_as", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"whiteboard", "+query",
"--whiteboard-token", "wbcnMissingSelector",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
output := result.Stdout + "\n" + result.Stderr
if got := gjson.Get(output, "error.type").String(); got != "validation" {
t.Fatalf("error.type=%q, want validation\nstdout:\n%s\nstderr:\n%s", got, result.Stdout, result.Stderr)
}
if got := gjson.Get(output, "error.message").String(); !strings.Contains(got, "output_as") {
t.Fatalf("error.message=%q, want output_as\nstdout:\n%s\nstderr:\n%s", got, result.Stdout, result.Stderr)
}
})
}
func setWhiteboardDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "whiteboard_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "whiteboard_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
func TestWhiteboardExportPreview_JPEGLiveWorkflow(t *testing.T) {
token := os.Getenv("LARK_WHITEBOARD_E2E_TOKEN")
if token == "" {
t.Skip("skipped: LARK_WHITEBOARD_E2E_TOKEN not set")
}
clie2e.SkipWithoutUserToken(t)
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
t.Cleanup(cancel)
workDir := t.TempDir()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"whiteboard", "+export",
"--whiteboard-token", token,
"--output-type", "preview",
"--output", "preview",
"--overwrite",
},
DefaultAs: "user",
Format: "json",
WorkDir: workDir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
saved := filepath.Join(workDir, "preview.jpg")
data, err := os.ReadFile(saved)
require.NoError(t, err, "expected JPEG preview at %s\nstdout:\n%s\nstderr:\n%s", saved, result.Stdout, result.Stderr)
require.True(t, isJPEG(data), "expected JPEG data in %s", saved)
mismatch, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"whiteboard", "+export",
"--whiteboard-token", token,
"--output-type", "preview",
"--output", "preview.png",
"--overwrite",
},
DefaultAs: "user",
Format: "json",
WorkDir: workDir,
})
require.NoError(t, err)
mismatch.AssertExitCode(t, 2)
if !strings.Contains(mismatch.Stdout+"\n"+mismatch.Stderr, "failed_precondition") {
t.Fatalf("expected failed_precondition for mismatched extension\nstdout:\n%s\nstderr:\n%s", mismatch.Stdout, mismatch.Stderr)
}
}
func isJPEG(data []byte) bool {
return len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff
}