Files
larksuite__cli/shortcuts/whiteboard/whiteboard_update.go
cl900811 644c3c77dd doc(whiteboard):support export whiteboard as SVG and update whiteboard via SVG (#1559)
* feat(lark-whiteboard): update shortcut, support query or update whiteboard by svg

* feat(whiteboard): pin whiteboard-cli to v0.2.12 in lark-whiteboard skill

* fix(whiteboard): whiteboard shortcuts unit test

* fix(whiteboard): add whiteboard query shortcut unit test
2026-06-25 11:08:16 +08:00

306 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
const (
// FormatRaw sends raw whiteboard node JSON to the create-nodes API.
FormatRaw = "raw"
// FormatPlantUML sends PlantUML source through the diagram import API.
FormatPlantUML = "plantuml"
// FormatMermaid sends Mermaid source through the diagram import API.
FormatMermaid = "mermaid"
// FormatSVG sends SVG source through the diagram import API.
FormatSVG = "svg"
)
var formatCodeMap = map[string]int{
FormatRaw: 0,
FormatPlantUML: 1,
FormatMermaid: 2,
FormatSVG: 3,
}
var wbUpdateScopes = []string{"board:whiteboard:node:create"}
var wbUpdateAuthTypes = []string{"user", "bot"}
var wbUpdateFlags = []common.Flag{
{Name: "idempotent-token", Desc: "idempotent token to ensure the update is idempotent. Default is empty. min length is 10.", Required: false},
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to update. You will need edit permission to update the whiteboard.", Required: true},
{Name: "overwrite", Desc: "overwrite the whiteboard content, delete all existing content before update. Default is false.", Required: false, Type: "bool"},
{Name: "source", Desc: "Input whiteboard data.", Required: true, Input: []string{common.Stdin, common.File}},
{Name: "input_format", Desc: "format of input data: raw | plantuml | mermaid | svg. Default is raw.", Required: false},
}
func wbUpdateValidate(ctx context.Context, runtime *common.RuntimeContext) error {
// 检查 token 是否包含控制字符(空字符串下自动跳过了)
if err := common.RejectDangerousCharsTyped("--whiteboard-token", runtime.Str("whiteboard-token")); err != nil {
return err
}
itoken := runtime.Str("idempotent-token")
if err := common.RejectDangerousCharsTyped("--idempotent-token", itoken); err != nil {
return err
}
if itoken != "" && len(itoken) < 10 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--idempotent-token must be at least 10 characters long.").WithParam("--idempotent-token")
}
// 检查 --input_format 标志
format := getFormat(runtime)
if format != FormatRaw && format != FormatPlantUML && format != FormatMermaid && format != FormatSVG {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--input_format must be one of: raw | plantuml | mermaid | svg").WithParam("--input_format")
}
return nil
}
// getFormat 获取 format,默认返回 raw
func getFormat(runtime *common.RuntimeContext) string {
format := runtime.Str("input_format")
if format == "" {
return FormatRaw
}
return format
}
func wbUpdateDryRun(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
// 读取输入内容
input := runtime.Str("source")
if input == "" {
return common.NewDryRunAPI().Desc("read input failed: source is required")
}
format := getFormat(runtime)
token := runtime.Str("whiteboard-token")
overwrite := runtime.Bool("overwrite")
descStr := "will call whiteboard open api to update content."
desc := common.NewDryRunAPI().Desc(descStr)
switch format {
case FormatRaw:
nodes, err, _ := parseWBcliNodes([]byte(input))
if err != nil {
return common.NewDryRunAPI().Desc("parse input failed: " + err.Error())
}
reqBody := rawNodesCreateReq{
Nodes: nodes,
Overwrite: overwrite,
}
desc.POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).Body(reqBody).Desc("create all nodes of the whiteboard.")
case FormatPlantUML, FormatMermaid, FormatSVG:
syntaxType := formatCodeMap[format]
reqBody := plantumlCreateReq{
PlantUmlCode: input,
SyntaxType: syntaxType,
ParseMode: 1,
DiagramType: 0,
Overwrite: overwrite,
}
desc.POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/plantuml", common.MaskToken(url.PathEscape(token)))).Body(reqBody).Desc(fmt.Sprintf("create %s node on the whiteboard.", format))
}
return desc
}
func wbUpdateExecute(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("whiteboard-token")
overwrite := runtime.Bool("overwrite")
idempotentToken := runtime.Str("idempotent-token")
format := getFormat(runtime)
input := runtime.Str("source")
if input == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "read input failed: source is required").WithParam("--source")
}
switch format {
case FormatRaw:
return updateWhiteboardByRawNodes(ctx, runtime, token, []byte(input), overwrite, idempotentToken)
case FormatPlantUML, FormatMermaid, FormatSVG:
return updateWhiteboardByCode(ctx, runtime, token, []byte(input), format, overwrite, idempotentToken)
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported format: %s", format).WithParam("--input_format")
}
}
// WhiteboardUpdateDescription describes the whiteboard update shortcut.
const WhiteboardUpdateDescription = "Update an existing whiteboard in lark document with mermaid, plantuml or whiteboard dsl. refer to lark-whiteboard skill for more details."
// WhiteboardUpdate registers the `whiteboard +update` shortcut.
var WhiteboardUpdate = common.Shortcut{
Service: "whiteboard",
Command: "+update",
Description: WhiteboardUpdateDescription,
Risk: "write",
Scopes: wbUpdateScopes,
AuthTypes: wbUpdateAuthTypes,
Flags: wbUpdateFlags,
HasFormat: false, // 不使用 lark 的 format flag(使用画板内部的格式)
Validate: wbUpdateValidate,
DryRun: wbUpdateDryRun,
Execute: wbUpdateExecute,
}
// WhiteboardUpdateOld 向前兼容历史版本 Doc 域下的更新命令
var WhiteboardUpdateOld = common.Shortcut{
Service: "docs",
Command: "+whiteboard-update",
Description: WhiteboardUpdateDescription,
Risk: "write",
Scopes: wbUpdateScopes,
AuthTypes: wbUpdateAuthTypes,
Flags: wbUpdateFlags,
HasFormat: false, // 不使用 lark 的 format flag(使用画板内部的格式)
Validate: wbUpdateValidate,
DryRun: wbUpdateDryRun,
Execute: wbUpdateExecute,
}
type plantumlCreateReq struct {
PlantUmlCode string `json:"plant_uml_code"`
SyntaxType int `json:"syntax_type"`
DiagramType int `json:"diagram_type,omitempty"`
ParseMode int `json:"parse_mode,omitempty"`
Overwrite bool `json:"overwrite,omitempty"`
}
type rawNodesCreateReq struct {
Nodes []interface{} `json:"nodes"`
Overwrite bool `json:"overwrite,omitempty"`
}
func parseWBcliNodes(rawjson []byte) (wbNodes []interface{}, err error, isRaw bool) {
var wbOutput WbCliOutput
if err := json.Unmarshal(rawjson, &wbOutput); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unmarshal input json failed: %v", err).WithParam("--source").WithCause(err), false
}
if (wbOutput.Code != 0 || wbOutput.Data.To != "openapi") && wbOutput.RawNodes == nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "whiteboard-cli failed. please check previous log.").WithParam("--source"), false
}
if wbOutput.RawNodes != nil {
wbNodes = wbOutput.RawNodes
isRaw = true
} else {
if wbOutput.Data.Result.Nodes == nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "whiteboard-cli failed. please check previous log.").WithParam("--source"), false
}
wbNodes = wbOutput.Data.Result.Nodes
}
return wbNodes, nil, isRaw
}
// updateWhiteboardByCode 使用 plantuml/mermaid 代码更新画板
func updateWhiteboardByCode(ctx context.Context, runtime *common.RuntimeContext, wbToken string, input []byte, format string, overwrite bool, idempotentToken string) error {
syntaxType := formatCodeMap[format]
reqBody := plantumlCreateReq{
PlantUmlCode: string(input),
SyntaxType: syntaxType,
ParseMode: 1,
DiagramType: 0, // 0 表示自动识别
Overwrite: overwrite,
}
params := map[string]interface{}{}
if idempotentToken != "" {
params["client_token"] = idempotentToken
}
data, err := runtime.CallAPITyped(http.MethodPost, fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/plantuml", url.PathEscape(wbToken)), params, reqBody)
if err != nil {
return err
}
nodeID := common.GetString(data, "node_id")
if nodeID == "" {
return wbInvalidResponse("update whiteboard by code failed: missing data.node_id")
}
outData := map[string]string{"created_node_id": nodeID}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "New node created.\n")
fmt.Fprintf(w, "Update whiteboard success")
})
return nil
}
// updateWhiteboardByRawNodes 使用原始 Open API 格式数据更新画板
func updateWhiteboardByRawNodes(ctx context.Context, runtime *common.RuntimeContext, wbToken string, input []byte, overwrite bool, idempotentToken string) error {
nodes, err, isRaw := parseWBcliNodes(input)
if err != nil {
return err
}
reqBody := rawNodesCreateReq{
Nodes: nodes,
Overwrite: overwrite,
}
params := map[string]interface{}{}
if idempotentToken != "" {
params["client_token"] = idempotentToken
}
data, err := runtime.CallAPITyped(http.MethodPost, fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)), params, reqBody)
if err != nil {
// Raw open-api JSON is hand-edited far more often than the DSL path, so
// steer the user back to the recommended workflow on any API failure.
if isRaw {
if p, ok := errs.ProblemOf(err); ok {
rawHint := "It is not advised to edit openapi format json directly. " +
"Please follow instruction in lark-whiteboard skill, using whiteboard-cli " +
"to transcript Whiteboard DSL pattern instead."
if strings.TrimSpace(p.Hint) != "" {
p.Hint = p.Hint + "\n" + rawHint
} else {
p.Hint = rawHint
}
}
}
return err
}
nodeIDs, err := stringSlice(data["ids"])
if err != nil {
return err
}
outData := map[string]string{"created_node_ids": strings.Join(nodeIDs, ",")}
runtime.OutFormat(outData, nil, func(w io.Writer) {
if outData["created_node_ids"] != "" {
fmt.Fprintf(w, "%d new nodes created.\n", len(nodeIDs))
}
fmt.Fprintf(w, "Update whiteboard success")
})
return nil
}
// stringSlice coerces the JSON ids array into []string. A missing or malformed
// ids field is a response-shape bug, not a successful update with no output.
func stringSlice(v interface{}) ([]string, error) {
switch raw := v.(type) {
case []interface{}:
out := make([]string, 0, len(raw))
for i, e := range raw {
s, ok := e.(string)
if !ok {
return nil, wbInvalidResponse("update whiteboard failed: data.ids[%d] must be a string", i)
}
out = append(out, s)
}
return out, nil
case []string:
return append([]string(nil), raw...), nil
default:
return nil, wbInvalidResponse("update whiteboard failed: data.ids must be an array of strings")
}
}