mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
fix: issue#1935 & whiteboard shortcut reformat (#1980)
This commit is contained in:
@@ -12,6 +12,7 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
WhiteboardUpdate,
|
||||
WhiteboardUpdateOld,
|
||||
WhiteboardExport,
|
||||
WhiteboardQuery,
|
||||
}
|
||||
}
|
||||
|
||||
728
shortcuts/whiteboard/whiteboard_export.go
Normal file
728
shortcuts/whiteboard/whiteboard_export.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -255,6 +255,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
got := Shortcuts()
|
||||
want := []string{
|
||||
"+update",
|
||||
"+export",
|
||||
"+query",
|
||||
}
|
||||
|
||||
|
||||
@@ -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 原生格式 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)(视觉高保真还原,大部分场景适用)
|
||||
└─ 用户有明确要求 → 以用户要求优先
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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`
|
||||
|
||||
191
tests/cli_e2e/whiteboard/whiteboard_export_dryrun_test.go
Normal file
191
tests/cli_e2e/whiteboard/whiteboard_export_dryrun_test.go
Normal 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")
|
||||
}
|
||||
71
tests/cli_e2e/whiteboard/whiteboard_export_workflow_test.go
Normal file
71
tests/cli_e2e/whiteboard/whiteboard_export_workflow_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user