diff --git a/shortcuts/whiteboard/shortcuts.go b/shortcuts/whiteboard/shortcuts.go index 36840dac1..3737c32c5 100644 --- a/shortcuts/whiteboard/shortcuts.go +++ b/shortcuts/whiteboard/shortcuts.go @@ -12,6 +12,7 @@ func Shortcuts() []common.Shortcut { return []common.Shortcut{ WhiteboardUpdate, WhiteboardUpdateOld, + WhiteboardExport, WhiteboardQuery, } } diff --git a/shortcuts/whiteboard/whiteboard_export.go b/shortcuts/whiteboard/whiteboard_export.go new file mode 100644 index 000000000..de64f37b1 --- /dev/null +++ b/shortcuts/whiteboard/whiteboard_export.go @@ -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 = "" + } + 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 +} diff --git a/shortcuts/whiteboard/whiteboard_query_test.go b/shortcuts/whiteboard/whiteboard_export_test.go similarity index 82% rename from shortcuts/whiteboard/whiteboard_query_test.go rename to shortcuts/whiteboard/whiteboard_export_test.go index 9ccc18782..22e1e933a 100644 --- a/shortcuts/whiteboard/whiteboard_query_test.go +++ b/shortcuts/whiteboard/whiteboard_export_test.go @@ -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("bad gateway"), + 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 +} diff --git a/shortcuts/whiteboard/whiteboard_query.go b/shortcuts/whiteboard/whiteboard_query.go deleted file mode 100644 index e650ecb45..000000000 --- a/shortcuts/whiteboard/whiteboard_query.go +++ /dev/null @@ -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 -} diff --git a/shortcuts/whiteboard/whiteboard_update_test.go b/shortcuts/whiteboard/whiteboard_update_test.go index e48ef8e12..9d3a5af9e 100644 --- a/shortcuts/whiteboard/whiteboard_update_test.go +++ b/shortcuts/whiteboard/whiteboard_update_test.go @@ -255,6 +255,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { got := Shortcuts() want := []string{ "+update", + "+export", "+query", } diff --git a/skills/lark-whiteboard/SKILL.md b/skills/lark-whiteboard/SKILL.md index 85e5b69f7..41a2601fd 100644 --- a/skills/lark-whiteboard/SKILL.md +++ b/skills/lark-whiteboard/SKILL.md @@ -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 原生格式 | --- diff --git a/skills/lark-whiteboard/references/lark-whiteboard-query.md b/skills/lark-whiteboard/references/lark-whiteboard-export.md similarity index 56% rename from skills/lark-whiteboard/references/lark-whiteboard-query.md rename to skills/lark-whiteboard/references/lark-whiteboard-export.md index 9980fcb44..6dca592d0 100644 --- a/skills/lark-whiteboard/references/lark-whiteboard-query.md +++ b/skills/lark-whiteboard/references/lark-whiteboard-export.md @@ -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_.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 ``` diff --git a/skills/lark-whiteboard/references/lark-whiteboard-workflow.md b/skills/lark-whiteboard/references/lark-whiteboard-workflow.md index 6d1f66847..2abba1afd 100644 --- a/skills/lark-whiteboard/references/lark-whiteboard-workflow.md +++ b/skills/lark-whiteboard/references/lark-whiteboard-workflow.md @@ -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)(视觉高保真还原,大部分场景适用) └─ 用户有明确要求 → 以用户要求优先 diff --git a/skills/lark-whiteboard/routes/svg-edit.md b/skills/lark-whiteboard/routes/svg-edit.md index c6ce17d39..954716ec4 100644 --- a/skills/lark-whiteboard/routes/svg-edit.md +++ b/skills/lark-whiteboard/routes/svg-edit.md @@ -25,9 +25,9 @@ SVG 导出是**纯视觉快照**,再次导入后画板语义(思维导图层 ### 1. 导出当前画板 SVG ```bash -lark-cli whiteboard +query \ +lark-cli whiteboard +export \ --whiteboard-token \ - --output_as svg \ + --output-type svg \ --output /original.svg \ --as user ``` diff --git a/skills/lark-whiteboard/routes/svg.md b/skills/lark-whiteboard/routes/svg.md index 19b611b9c..179d7996f 100644 --- a/skills/lark-whiteboard/routes/svg.md +++ b/skills/lark-whiteboard/routes/svg.md @@ -54,8 +54,6 @@ - 阴影:`` 里放 `` 或标准 drop/inner primitive 链 (`` + `` + `` + `` + ``), 会被识别成节点阴影, drop 至多 1 个, inner 至多 1 个; 其余 filter 效果不识别 - 渐变:`` / `` 在 `` 中定义, 通过 `fill="url(#id)"` 引用 (载体限 `` / `` / `` / `` / ``), 需要至少 2 个 ``, `gradientUnits` 只支持默认的 `objectBoundingBox` (不写即可); -> [!IMPORTANT] -> ⚠️ **不支持的装饰特性** - +**⚠️ [!IMPORTANT] 不支持的装饰特性** - `` / `` / `` / 非阴影用途的 `` (blur / hue-rotate / 复合合成 / `flood-color=url(...)` / 多个 `` 等) → 画板不支持,**请避免使用,否则会导致画板渲染问题** - 渐变边界:`gradientUnits="userSpaceOnUse"` / `spreadMethod="reflect|repeat"` / stops 少于 2 个 / 复杂 `gradientTransform` 会变成不可编辑图片, 视觉正确但失去可编辑性, 若无必要请沿用默认 `objectBoundingBox` diff --git a/tests/cli_e2e/whiteboard/whiteboard_export_dryrun_test.go b/tests/cli_e2e/whiteboard/whiteboard_export_dryrun_test.go new file mode 100644 index 000000000..bac1f1891 --- /dev/null +++ b/tests/cli_e2e/whiteboard/whiteboard_export_dryrun_test.go @@ -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") +} diff --git a/tests/cli_e2e/whiteboard/whiteboard_export_workflow_test.go b/tests/cli_e2e/whiteboard/whiteboard_export_workflow_test.go new file mode 100644 index 000000000..145acd942 --- /dev/null +++ b/tests/cli_e2e/whiteboard/whiteboard_export_workflow_test.go @@ -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 +}