mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
2 Commits
feat/frame
...
feat/white
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60e6bc2f3b | ||
|
|
1fd29e75a6 |
@@ -159,8 +159,7 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
|
||||
Transport: sdkTransport,
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
}))
|
||||
ep := core.ResolveEndpoints(acct.Brand)
|
||||
opts = append(opts, lark.WithOpenBaseUrl(ep.Open))
|
||||
opts = append(opts, lark.WithOpenBaseUrl(core.ResolveOpenBaseURL(acct.Brand)))
|
||||
return lark.NewClient(acct.AppID, credential.RuntimeAppSecret(acct.AppSecret), opts...), nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package cmdutil
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
@@ -201,14 +202,27 @@ func ShortcutHeaderOpts(ctx context.Context) larkcore.RequestOptionFunc {
|
||||
// ShortcutHeaders extracts Shortcut info from the context and returns
|
||||
// the corresponding HTTP headers. Returns nil if the context has no Shortcut info.
|
||||
func ShortcutHeaders(ctx context.Context) http.Header {
|
||||
name, ok := ShortcutNameFromContext(ctx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
h := make(http.Header)
|
||||
h.Set(HeaderShortcut, name)
|
||||
if eid, ok := ExecutionIdFromContext(ctx); ok {
|
||||
h.Set(HeaderExecutionId, eid)
|
||||
if name, ok := ShortcutNameFromContext(ctx); ok {
|
||||
h.Set(HeaderShortcut, name)
|
||||
if eid, ok := ExecutionIdFromContext(ctx); ok {
|
||||
h.Set(HeaderExecutionId, eid)
|
||||
}
|
||||
}
|
||||
if name, value := extraHeaderFromEnv(); name != "" && value != "" {
|
||||
h.Set(name, value)
|
||||
}
|
||||
if len(h) == 0 {
|
||||
return nil
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func extraHeaderFromEnv() (string, string) {
|
||||
name := strings.TrimSpace(os.Getenv(envvars.CliExtraHeaderName))
|
||||
value := strings.TrimSpace(os.Getenv(envvars.CliExtraHeaderValue))
|
||||
if name == "" || value == "" {
|
||||
return "", ""
|
||||
}
|
||||
return name, value
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
|
||||
package core
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// LarkBrand represents the Lark platform brand.
|
||||
// "feishu" targets China-mainland, "lark" targets international.
|
||||
@@ -61,5 +66,8 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
|
||||
|
||||
// ResolveOpenBaseURL returns the Open API base URL for the given brand.
|
||||
func ResolveOpenBaseURL(brand LarkBrand) string {
|
||||
if override := strings.TrimRight(strings.TrimSpace(os.Getenv(envvars.CliOpenBaseURL)), "/"); override != "" {
|
||||
return override
|
||||
}
|
||||
return ResolveEndpoints(brand).Open
|
||||
}
|
||||
|
||||
@@ -58,6 +58,13 @@ func TestResolveOpenBaseURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenBaseURL_EnvOverride(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_OPEN_BASE_URL", "https://open.feishu-boe.cn/")
|
||||
if got := ResolveOpenBaseURL(BrandFeishu); got != "https://open.feishu-boe.cn" {
|
||||
t.Errorf("ResolveOpenBaseURL(feishu with env override) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrand(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
|
||||
@@ -22,6 +22,10 @@ const (
|
||||
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
|
||||
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
||||
|
||||
CliOpenBaseURL = "LARKSUITE_CLI_OPEN_BASE_URL"
|
||||
CliExtraHeaderName = "LARKSUITE_CLI_EXTRA_HEADER_NAME"
|
||||
CliExtraHeaderValue = "LARKSUITE_CLI_EXTRA_HEADER_VALUE"
|
||||
|
||||
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
||||
CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS"
|
||||
CliCAPath = "LARKSUITE_CLI_CA_PATH"
|
||||
|
||||
@@ -98,6 +98,26 @@ func TestCallAPITyped_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallAPITyped_ExtraHeaderFromEnv(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_EXTRA_HEADER_NAME", "x-tt-env")
|
||||
t.Setenv("LARKSUITE_CLI_EXTRA_HEADER_VALUE", "boe_whiteboard_test")
|
||||
rt, reg := newCallAPITypedRuntime(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/board/v1/whiteboards/wb/nodes/batch_update",
|
||||
Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{"ids": []interface{}{"a1:1"}}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
_, err := rt.CallAPITyped("PUT", "/open-apis/board/v1/whiteboards/wb/nodes/batch_update", nil, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := stub.CapturedHeaders.Get("x-tt-env"); got != "boe_whiteboard_test" {
|
||||
t.Fatalf("x-tt-env header = %q, want boe_whiteboard_test", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIClassifyContext verifies the classify context is built from the
|
||||
// runtime: Brand / AppID from config, Identity from the resolved caller, and
|
||||
// LarkCmd from the running command path.
|
||||
|
||||
@@ -14,6 +14,9 @@ func Shortcuts() []common.Shortcut {
|
||||
WhiteboardUpdateOld,
|
||||
WhiteboardExport,
|
||||
WhiteboardQuery,
|
||||
WhiteboardNodeCreate,
|
||||
WhiteboardNodeUpdate,
|
||||
WhiteboardNodeDelete,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
145
shortcuts/whiteboard/whiteboard_node_common.go
Normal file
145
shortcuts/whiteboard/whiteboard_node_common.go
Normal file
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
type whiteboardNodeBatchPayload struct {
|
||||
Nodes []map[string]interface{} `json:"nodes"`
|
||||
}
|
||||
|
||||
func parseWhiteboardNodeBatchPayload(raw []byte, requireID bool) (whiteboardNodeBatchPayload, error) {
|
||||
var payload whiteboardNodeBatchPayload
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return whiteboardNodeBatchPayload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unmarshal input json failed: %v", err).
|
||||
WithParam("--source").
|
||||
WithCause(err)
|
||||
}
|
||||
if len(payload.Nodes) == 0 {
|
||||
return whiteboardNodeBatchPayload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, `--source must include non-empty "nodes"`).
|
||||
WithParam("--source")
|
||||
}
|
||||
if requireID {
|
||||
for i, node := range payload.Nodes {
|
||||
id, ok := node["id"].(string)
|
||||
if !ok || strings.TrimSpace(id) == "" {
|
||||
return whiteboardNodeBatchPayload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "nodes[%d].id must be a non-empty string", i).
|
||||
WithParam("--source")
|
||||
}
|
||||
}
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func parseWhiteboardNodeIDs(raw string) ([]string, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-ids is required").
|
||||
WithParam("--node-ids")
|
||||
}
|
||||
|
||||
parts := strings.Split(raw, ",")
|
||||
ids := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for i, part := range parts {
|
||||
id := strings.TrimSpace(part)
|
||||
if id == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-ids item %d must not be empty", i+1).
|
||||
WithParam("--node-ids")
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate node id %q", id).
|
||||
WithParam("--node-ids")
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func validateOptionalWhiteboardNodeIdempotentToken(raw string) error {
|
||||
if err := common.RejectDangerousCharsTyped("--idempotent-token", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if raw != "" && len(raw) < 10 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--idempotent-token must be at least 10 characters long.").
|
||||
WithParam("--idempotent-token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func callWhiteboardNodeWrite(ctx context.Context, runtime *common.RuntimeContext, method, apiPath string, params map[string]interface{}, body interface{}) (map[string]interface{}, error) {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: method,
|
||||
ApiPath: apiPath,
|
||||
Body: body,
|
||||
QueryParams: whiteboardNodeQueryParams(params),
|
||||
}
|
||||
resp, err := runtime.DoAPI(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, classifyErr := runtime.ClassifyAPIResponse(resp)
|
||||
if classifyErr == nil {
|
||||
return data, nil
|
||||
}
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
return data, classifyErr
|
||||
}
|
||||
if isWhiteboardNodeNonObjectSuccess(classifyErr, resp) {
|
||||
return nil, nil
|
||||
}
|
||||
return data, classifyErr
|
||||
}
|
||||
|
||||
func whiteboardNodeQueryParams(params map[string]interface{}) larkcore.QueryParams {
|
||||
query := make(larkcore.QueryParams)
|
||||
for key, value := range params {
|
||||
switch typed := value.(type) {
|
||||
case []string:
|
||||
for _, item := range typed {
|
||||
query.Add(key, item)
|
||||
}
|
||||
case []interface{}:
|
||||
for _, item := range typed {
|
||||
query.Add(key, whiteboardNodeQueryValue(item))
|
||||
}
|
||||
default:
|
||||
query.Set(key, whiteboardNodeQueryValue(value))
|
||||
}
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func whiteboardNodeQueryValue(value interface{}) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
|
||||
func isWhiteboardNodeNonObjectSuccess(err error, resp *larkcore.ApiResp) bool {
|
||||
if resp == nil {
|
||||
return false
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); !ok {
|
||||
return false
|
||||
}
|
||||
result, parseErr := client.ParseJSONResponse(resp)
|
||||
if parseErr != nil {
|
||||
return false
|
||||
}
|
||||
_, isObject := result.(map[string]interface{})
|
||||
return !isObject
|
||||
}
|
||||
150
shortcuts/whiteboard/whiteboard_node_common_test.go
Normal file
150
shortcuts/whiteboard/whiteboard_node_common_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestShortcutsIncludesWhiteboardNodeCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := Shortcuts()
|
||||
want := []string{
|
||||
"+update",
|
||||
"+export",
|
||||
"+query",
|
||||
"+node-create",
|
||||
"+node-update",
|
||||
"+node-delete",
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(got))
|
||||
for _, shortcut := range got {
|
||||
if seen[shortcut.Command] {
|
||||
t.Fatalf("duplicate shortcut command: %s", shortcut.Command)
|
||||
}
|
||||
seen[shortcut.Command] = true
|
||||
}
|
||||
|
||||
for _, command := range want {
|
||||
if !seen[command] {
|
||||
t.Fatalf("missing shortcut command %q in Shortcuts()", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWhiteboardNodeBatchPayload_MissingNodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWhiteboardNodeBatchPayload([]byte(`{}`), false)
|
||||
assertValidationParam(t, err, "--source", false)
|
||||
}
|
||||
|
||||
func TestParseWhiteboardNodeBatchPayload_EmptyNodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWhiteboardNodeBatchPayload([]byte(`{"nodes":[]}`), false)
|
||||
assertValidationParam(t, err, "--source", false)
|
||||
}
|
||||
|
||||
func TestParseWhiteboardNodeBatchPayload_InvalidJSONPreservesCause(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWhiteboardNodeBatchPayload([]byte(`not-json`), false)
|
||||
assertValidationParam(t, err, "--source", true)
|
||||
}
|
||||
|
||||
func TestParseWhiteboardNodeBatchPayload_RequireIDMissingID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWhiteboardNodeBatchPayload([]byte(`{"nodes":[{"text":{"text":"x"}}]}`), true)
|
||||
assertValidationParam(t, err, "--source", false)
|
||||
}
|
||||
|
||||
func TestParseWhiteboardNodeBatchPayload_RequireIDBlankID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWhiteboardNodeBatchPayload([]byte(`{"nodes":[{"id":" ","text":{"text":"x"}}]}`), true)
|
||||
assertValidationParam(t, err, "--source", false)
|
||||
}
|
||||
|
||||
func TestParseWhiteboardNodeBatchPayload_PreservesArbitraryFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload, err := parseWhiteboardNodeBatchPayload([]byte(`{"nodes":[{"id":"node-1","type":"shape","custom":{"x":1},"points":[1,2]}]}`), true)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWhiteboardNodeBatchPayload() error = %v", err)
|
||||
}
|
||||
if len(payload.Nodes) != 1 {
|
||||
t.Fatalf("len(payload.Nodes) = %d, want 1", len(payload.Nodes))
|
||||
}
|
||||
node := payload.Nodes[0]
|
||||
if got := node["id"]; got != "node-1" {
|
||||
t.Errorf("node[id] = %v, want node-1", got)
|
||||
}
|
||||
if got := node["type"]; got != "shape" {
|
||||
t.Errorf("node[type] = %v, want shape", got)
|
||||
}
|
||||
custom, ok := node["custom"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("node[custom] = %T, want map[string]interface{}", node["custom"])
|
||||
}
|
||||
if got := custom["x"]; got != float64(1) {
|
||||
t.Errorf("node[custom][x] = %v, want 1", got)
|
||||
}
|
||||
points, ok := node["points"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("node[points] = %T, want []interface{}", node["points"])
|
||||
}
|
||||
if len(points) != 2 || points[0] != float64(1) || points[1] != float64(2) {
|
||||
t.Errorf("node[points] = %#v, want [1 2]", points)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWhiteboardNodeIDs_TrimsItems(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ids, err := parseWhiteboardNodeIDs(" nodeA, nodeB ,nodeC ")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWhiteboardNodeIDs() error = %v", err)
|
||||
}
|
||||
want := []string{"nodeA", "nodeB", "nodeC"}
|
||||
if len(ids) != len(want) {
|
||||
t.Fatalf("len(ids) = %d, want %d", len(ids), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if ids[i] != want[i] {
|
||||
t.Errorf("ids[%d] = %q, want %q", i, ids[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWhiteboardNodeIDs_RejectsEmptyInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWhiteboardNodeIDs(" ")
|
||||
assertValidationParam(t, err, "--node-ids", false)
|
||||
}
|
||||
|
||||
func TestParseWhiteboardNodeIDs_RejectsEmptyItems(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWhiteboardNodeIDs("nodeA, ,nodeB")
|
||||
assertValidationParam(t, err, "--node-ids", false)
|
||||
}
|
||||
|
||||
func TestParseWhiteboardNodeIDs_RejectsDuplicateIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWhiteboardNodeIDs("nodeA,nodeB,nodeA")
|
||||
assertValidationParam(t, err, "--node-ids", false)
|
||||
}
|
||||
|
||||
func TestValidateOptionalWhiteboardNodeIdempotentToken_TooShort(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateOptionalWhiteboardNodeIdempotentToken("short")
|
||||
assertValidationParam(t, err, "--idempotent-token", false)
|
||||
}
|
||||
138
shortcuts/whiteboard/whiteboard_node_create.go
Normal file
138
shortcuts/whiteboard/whiteboard_node_create.go
Normal file
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var wbNodeCreateScopes = []string{"board:whiteboard:node:create"}
|
||||
var wbNodeCreateAuthTypes = []string{"user", "bot"}
|
||||
var wbNodeCreateFlags = []common.Flag{
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to create nodes in. You need edit permission on the whiteboard.", Required: true},
|
||||
{Name: "source", Desc: `JSON payload containing a non-empty "nodes" array.`, Required: true, Input: []string{common.Stdin, common.File}},
|
||||
{Name: "idempotent-token", Desc: "idempotent token to make create requests retry-safe. Default is empty. Minimum length is 10.", Required: false},
|
||||
}
|
||||
|
||||
type whiteboardNodeCreateReq struct {
|
||||
Nodes []map[string]interface{} `json:"nodes"`
|
||||
}
|
||||
|
||||
func wbNodeCreateValidate(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := common.RejectDangerousCharsTyped("--whiteboard-token", runtime.Str("whiteboard-token")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalWhiteboardNodeIdempotentToken(runtime.Str("idempotent-token")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), false)
|
||||
return err
|
||||
}
|
||||
|
||||
func wbNodeCreateDryRun(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
payload, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), false)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc("parse input failed: " + err.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI().
|
||||
POST(wbNodeCreateDryRunURL(runtime.Str("whiteboard-token"))).
|
||||
Body(whiteboardNodeCreateReq{Nodes: payload.Nodes}).
|
||||
Desc("create nodes in the whiteboard.")
|
||||
if params := wbNodeCreateParams(runtime); len(params) > 0 {
|
||||
dry.Params(params)
|
||||
}
|
||||
return dry
|
||||
}
|
||||
|
||||
func wbNodeCreateExecute(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
payload, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodPost,
|
||||
wbNodeCreateURL(runtime.Str("whiteboard-token")),
|
||||
wbNodeCreateParams(runtime),
|
||||
whiteboardNodeCreateReq{Nodes: payload.Nodes},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nodeIDs, err := whiteboardNodeCreateIDs(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outData := map[string]string{}
|
||||
if nodeIDs != nil {
|
||||
outData["ids"] = strings.Join(nodeIDs, ",")
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
if outData["ids"] != "" {
|
||||
fmt.Fprintf(w, "%d new nodes created.\n", len(nodeIDs))
|
||||
}
|
||||
fmt.Fprintf(w, "Create whiteboard nodes success")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func wbNodeCreateURL(token string) string {
|
||||
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(token))
|
||||
}
|
||||
|
||||
func wbNodeCreateDryRunURL(token string) string {
|
||||
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))
|
||||
}
|
||||
|
||||
func wbNodeCreateParams(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{}
|
||||
if token := runtime.Str("idempotent-token"); token != "" {
|
||||
params["client_token"] = token
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func whiteboardNodeCreateIDs(data map[string]interface{}) ([]string, error) {
|
||||
switch raw := data["ids"].(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(raw))
|
||||
for i, value := range raw {
|
||||
id, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, wbInvalidResponse("create whiteboard nodes failed: data.ids[%d] must be a string", i)
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
case []string:
|
||||
return append([]string(nil), raw...), nil
|
||||
default:
|
||||
return nil, wbInvalidResponse("create whiteboard nodes failed: data.ids must be an array of strings")
|
||||
}
|
||||
}
|
||||
|
||||
// WhiteboardNodeCreate registers the `whiteboard +node-create` shortcut.
|
||||
var WhiteboardNodeCreate = common.Shortcut{
|
||||
Service: "whiteboard",
|
||||
Command: "+node-create",
|
||||
Description: "Create nodes in an existing whiteboard.",
|
||||
Risk: "write",
|
||||
Scopes: wbNodeCreateScopes,
|
||||
AuthTypes: wbNodeCreateAuthTypes,
|
||||
Flags: wbNodeCreateFlags,
|
||||
Validate: wbNodeCreateValidate,
|
||||
DryRun: wbNodeCreateDryRun,
|
||||
Execute: wbNodeCreateExecute,
|
||||
}
|
||||
173
shortcuts/whiteboard/whiteboard_node_create_test.go
Normal file
173
shortcuts/whiteboard/whiteboard_node_create_test.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestWhiteboardNodeCreateValidate_InvalidSourceTypedParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newTestRuntime(map[string]string{
|
||||
"whiteboard-token": "test-board",
|
||||
"source": "not-json",
|
||||
}, nil)
|
||||
|
||||
err := wbNodeCreateValidate(context.Background(), rt)
|
||||
assertValidationParam(t, err, "--source", true)
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeCreateDryRun_RequestShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newTestRuntime(map[string]string{
|
||||
"whiteboard-token": "test-board",
|
||||
"idempotent-token": "create-token-12345",
|
||||
"source": `{"nodes":[{"id":"tmpNode","type":"composite_shape","x":0,"y":0,"width":260,"height":45,"text":{"text":"hello","font_weight":"regular","font_size":14,"horizontal_align":"center","vertical_align":"mid"},"style":{"border_color":"#3370ff","border_width":"narrow","border_style":"solid","fill_color":"#e8f3ff"},"composite_shape":{"type":"round_rect"}}]}`,
|
||||
}, nil)
|
||||
|
||||
dryRun := wbNodeCreateDryRun(context.Background(), rt)
|
||||
if dryRun == nil {
|
||||
t.Fatal("wbNodeCreateDryRun() returned nil")
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
data, err := json.Marshal(dryRun)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry-run: %v\njson=%s", err, string(data))
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("api len = %d, want 1; json=%s", len(got.API), string(data))
|
||||
}
|
||||
if got.API[0].Method != "POST" {
|
||||
t.Fatalf("method = %q, want POST", got.API[0].Method)
|
||||
}
|
||||
if got.API[0].URL != "/open-apis/board/v1/whiteboards/test...oard/nodes" {
|
||||
t.Fatalf("url = %q, want node-create URL", got.API[0].URL)
|
||||
}
|
||||
if got.API[0].Params["client_token"] != "create-token-12345" {
|
||||
t.Fatalf("params.client_token = %#v, want create-token-12345", got.API[0].Params["client_token"])
|
||||
}
|
||||
nodes, ok := got.API[0].Body["nodes"].([]interface{})
|
||||
if !ok || len(nodes) != 1 {
|
||||
t.Fatalf("body.nodes = %#v, want one node", got.API[0].Body["nodes"])
|
||||
}
|
||||
node, ok := nodes[0].(map[string]interface{})
|
||||
if !ok || node["type"] != "composite_shape" {
|
||||
t.Fatalf("body.nodes[0] = %#v, want type composite_shape", nodes[0])
|
||||
}
|
||||
if _, ok := node["composite_shape"].(map[string]interface{}); !ok {
|
||||
t.Fatalf("body.nodes[0].composite_shape = %#v, want object", node["composite_shape"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeCreateExecute_PostsNodes(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-board/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"ids": []string{"node-1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
source := `{"nodes":[{"id":"tmpNode","type":"composite_shape","x":0,"y":0,"width":260,"height":45,"text":{"text":"hello","font_weight":"regular","font_size":14,"horizontal_align":"center","vertical_align":"mid"},"style":{"border_color":"#3370ff","border_width":"narrow","border_style":"solid","fill_color":"#e8f3ff"},"composite_shape":{"type":"round_rect"}}]}`
|
||||
args := []string{"+node-create", "--whiteboard-token", "test-board", "--source", source}
|
||||
if err := runUpdateShortcut(t, WhiteboardNodeCreate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
nodes, ok := body["nodes"].([]interface{})
|
||||
if !ok || len(nodes) != 1 {
|
||||
t.Fatalf("body.nodes = %#v, want one node; body=%s", body["nodes"], string(stub.CapturedBody))
|
||||
}
|
||||
node, ok := nodes[0].(map[string]interface{})
|
||||
if !ok || node["type"] != "composite_shape" {
|
||||
t.Fatalf("body.nodes[0] = %#v, want type composite_shape", nodes[0])
|
||||
}
|
||||
if _, ok := node["composite_shape"].(map[string]interface{}); !ok {
|
||||
t.Fatalf("body.nodes[0].composite_shape = %#v, want object", node["composite_shape"])
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"ids": "node-1"`) {
|
||||
t.Fatalf("stdout=%s, want ids node-1", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeCreateExecute_AllowsMissingIDs(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-board/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
source := `{"nodes":[{"id":"tmpNode","type":"composite_shape","x":0,"y":0,"width":260,"height":45,"text":{"text":"hello","font_weight":"regular","font_size":14,"horizontal_align":"center","vertical_align":"mid"},"style":{"border_color":"#3370ff","border_width":"narrow","border_style":"solid","fill_color":"#e8f3ff"},"composite_shape":{"type":"round_rect"}}]}`
|
||||
args := []string{"+node-create", "--whiteboard-token", "test-board", "--source", source}
|
||||
if err := runUpdateShortcut(t, WhiteboardNodeCreate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeCreateExecute_RejectsMalformedIDs(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-board/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"ids": []interface{}{"node-1", 2},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
source := `{"nodes":[{"id":"tmpNode","type":"composite_shape","x":0,"y":0,"width":260,"height":45,"text":{"text":"hello","font_weight":"regular","font_size":14,"horizontal_align":"center","vertical_align":"mid"},"style":{"border_color":"#3370ff","border_width":"narrow","border_style":"solid","fill_color":"#e8f3ff"},"composite_shape":{"type":"round_rect"}}]}`
|
||||
args := []string{"+node-create", "--whiteboard-token", "test-board", "--source", source}
|
||||
err := runUpdateShortcut(t, WhiteboardNodeCreate, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected malformed ids error, got nil")
|
||||
}
|
||||
var internalErr *errs.InternalError
|
||||
if !errors.As(err, &internalErr) {
|
||||
t.Fatalf("error type = %T, want *errs.InternalError", err)
|
||||
}
|
||||
if internalErr.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("Subtype = %q, want %q", internalErr.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
}
|
||||
112
shortcuts/whiteboard/whiteboard_node_delete.go
Normal file
112
shortcuts/whiteboard/whiteboard_node_delete.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var wbNodeDeleteScopes = []string{"board:whiteboard:node:delete"}
|
||||
var wbNodeDeleteAuthTypes = []string{"user", "bot"}
|
||||
var wbNodeDeleteFlags = []common.Flag{
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to delete nodes from. You need edit permission on the whiteboard.", Required: true},
|
||||
{Name: "node-ids", Desc: "comma-separated whiteboard node IDs to delete.", Required: true},
|
||||
{Name: "idempotent-token", Desc: "idempotent token to make delete requests retry-safe. Default is empty. Minimum length is 10.", Required: false},
|
||||
}
|
||||
|
||||
type whiteboardNodeDeleteReq struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
|
||||
func wbNodeDeleteValidate(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := common.RejectDangerousCharsTyped("--whiteboard-token", runtime.Str("whiteboard-token")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalWhiteboardNodeIdempotentToken(runtime.Str("idempotent-token")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := parseWhiteboardNodeIDs(runtime.Str("node-ids"))
|
||||
return err
|
||||
}
|
||||
|
||||
func wbNodeDeleteDryRun(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ids, err := parseWhiteboardNodeIDs(runtime.Str("node-ids"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc("parse node ids failed: " + err.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI().
|
||||
DELETE(wbNodeDeleteDryRunURL(runtime.Str("whiteboard-token"))).
|
||||
Body(whiteboardNodeDeleteReq{IDs: ids}).
|
||||
Desc("delete nodes from the whiteboard.")
|
||||
if params := wbNodeDeleteParams(runtime); len(params) > 0 {
|
||||
dry.Params(params)
|
||||
}
|
||||
return dry
|
||||
}
|
||||
|
||||
func wbNodeDeleteExecute(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ids, err := parseWhiteboardNodeIDs(runtime.Str("node-ids"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := callWhiteboardNodeWrite(
|
||||
ctx,
|
||||
runtime,
|
||||
http.MethodDelete,
|
||||
wbNodeDeleteURL(runtime.Str("whiteboard-token")),
|
||||
wbNodeDeleteParams(runtime),
|
||||
whiteboardNodeDeleteReq{IDs: ids},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"ids": strings.Join(ids, ","),
|
||||
"count": len(ids),
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%d nodes deleted.\n", len(ids))
|
||||
fmt.Fprintf(w, "Delete whiteboard nodes success")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func wbNodeDeleteURL(token string) string {
|
||||
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_delete", url.PathEscape(token))
|
||||
}
|
||||
|
||||
func wbNodeDeleteDryRunURL(token string) string {
|
||||
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_delete", common.MaskToken(url.PathEscape(token)))
|
||||
}
|
||||
|
||||
func wbNodeDeleteParams(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{}
|
||||
if token := runtime.Str("idempotent-token"); token != "" {
|
||||
params["client_token"] = token
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// WhiteboardNodeDelete registers the `whiteboard +node-delete` shortcut.
|
||||
var WhiteboardNodeDelete = common.Shortcut{
|
||||
Service: "whiteboard",
|
||||
Command: "+node-delete",
|
||||
Description: "Delete nodes from an existing whiteboard.",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: wbNodeDeleteScopes,
|
||||
AuthTypes: wbNodeDeleteAuthTypes,
|
||||
Flags: wbNodeDeleteFlags,
|
||||
Validate: wbNodeDeleteValidate,
|
||||
DryRun: wbNodeDeleteDryRun,
|
||||
Execute: wbNodeDeleteExecute,
|
||||
}
|
||||
118
shortcuts/whiteboard/whiteboard_node_delete_test.go
Normal file
118
shortcuts/whiteboard/whiteboard_node_delete_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestWhiteboardNodeDeleteValidate_InvalidNodeIDsTypedParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newTestRuntime(map[string]string{
|
||||
"whiteboard-token": "test-board",
|
||||
"node-ids": "nodeA,,nodeB",
|
||||
}, nil)
|
||||
|
||||
err := wbNodeDeleteValidate(context.Background(), rt)
|
||||
assertValidationParam(t, err, "--node-ids", false)
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeDeleteMetadata_RiskHighRiskWrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if WhiteboardNodeDelete.Risk != "high-risk-write" {
|
||||
t.Fatalf("Risk = %q, want high-risk-write", WhiteboardNodeDelete.Risk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeDeleteDryRun_RequestShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newTestRuntime(map[string]string{
|
||||
"whiteboard-token": "test-board",
|
||||
"node-ids": "nodeA,nodeB",
|
||||
"idempotent-token": "delete-token-12345",
|
||||
}, nil)
|
||||
|
||||
dryRun := wbNodeDeleteDryRun(context.Background(), rt)
|
||||
if dryRun == nil {
|
||||
t.Fatal("wbNodeDeleteDryRun() returned nil")
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
data, err := json.Marshal(dryRun)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry-run: %v\njson=%s", err, string(data))
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("api len = %d, want 1; json=%s", len(got.API), string(data))
|
||||
}
|
||||
if got.API[0].Method != "DELETE" {
|
||||
t.Fatalf("method = %q, want DELETE", got.API[0].Method)
|
||||
}
|
||||
if got.API[0].URL != "/open-apis/board/v1/whiteboards/test...oard/nodes/batch_delete" {
|
||||
t.Fatalf("url = %q, want masked node-delete URL", got.API[0].URL)
|
||||
}
|
||||
if got.API[0].Params["client_token"] != "delete-token-12345" {
|
||||
t.Fatalf("params.client_token = %#v, want delete-token-12345", got.API[0].Params["client_token"])
|
||||
}
|
||||
ids, ok := got.API[0].Body["ids"].([]interface{})
|
||||
if !ok || len(ids) != 2 {
|
||||
t.Fatalf("body.ids = %#v, want two ids", got.API[0].Body["ids"])
|
||||
}
|
||||
if ids[0] != "nodeA" || ids[1] != "nodeB" {
|
||||
t.Fatalf("body.ids = %#v, want [nodeA nodeB]", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeDeleteExecute_PostsIDs(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-board/nodes/batch_delete",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
args := []string{"+node-delete", "--whiteboard-token", "test-board", "--node-ids", "nodeA,nodeB"}
|
||||
if err := runUpdateShortcut(t, WhiteboardNodeDelete, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
ids, ok := body["ids"].([]interface{})
|
||||
if !ok || len(ids) != 2 {
|
||||
t.Fatalf("body.ids = %#v, want two ids; body=%s", body["ids"], string(stub.CapturedBody))
|
||||
}
|
||||
if ids[0] != "nodeA" || ids[1] != "nodeB" {
|
||||
t.Fatalf("body.ids = %#v, want [nodeA nodeB]", ids)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"ids": "nodeA,nodeB"`) {
|
||||
t.Fatalf("stdout=%s, want ids nodeA,nodeB", stdout.String())
|
||||
}
|
||||
}
|
||||
141
shortcuts/whiteboard/whiteboard_node_update.go
Normal file
141
shortcuts/whiteboard/whiteboard_node_update.go
Normal file
@@ -0,0 +1,141 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var wbNodeUpdateScopes = []string{"board:whiteboard:node:update"}
|
||||
var wbNodeUpdateAuthTypes = []string{"user", "bot"}
|
||||
var wbNodeUpdateFlags = []common.Flag{
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to update nodes in. You need edit permission on the whiteboard.", Required: true},
|
||||
{Name: "source", Desc: `JSON payload containing a non-empty "nodes" array. Each node must include "id"; the batch_update body sends the full nodes array.`, Required: true, Input: []string{common.Stdin, common.File}},
|
||||
{Name: "idempotent-token", Desc: "idempotent token to make batch update requests retry-safe. Default is empty. Minimum length is 10.", Required: false},
|
||||
}
|
||||
|
||||
func wbNodeUpdateValidate(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := common.RejectDangerousCharsTyped("--whiteboard-token", runtime.Str("whiteboard-token")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalWhiteboardNodeIdempotentToken(runtime.Str("idempotent-token")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), true)
|
||||
return err
|
||||
}
|
||||
|
||||
func wbNodeUpdateDryRun(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
payload, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), true)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc("parse input failed: " + err.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI().
|
||||
PUT(wbNodeBatchUpdateDryRunURL(runtime.Str("whiteboard-token"))).
|
||||
Body(whiteboardNodeBatchUpdateBody(payload)).
|
||||
Desc("batch update nodes in the whiteboard.")
|
||||
if params := wbNodeUpdateParams(runtime); len(params) > 0 {
|
||||
dry.Params(params)
|
||||
}
|
||||
return dry
|
||||
}
|
||||
|
||||
func wbNodeUpdateExecute(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
payload, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodPut,
|
||||
wbNodeBatchUpdateURL(runtime.Str("whiteboard-token")),
|
||||
wbNodeUpdateParams(runtime),
|
||||
whiteboardNodeBatchUpdateBody(payload),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updatedNodeIDs, err := whiteboardNodeUpdateIDs(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"ids": strings.Join(updatedNodeIDs, ","),
|
||||
"count": len(updatedNodeIDs),
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%d nodes updated.\n", len(updatedNodeIDs))
|
||||
fmt.Fprintf(w, "Update whiteboard nodes success")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func wbNodeBatchUpdateURL(token string) string {
|
||||
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_update", url.PathEscape(token))
|
||||
}
|
||||
|
||||
func wbNodeBatchUpdateDryRunURL(token string) string {
|
||||
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_update", common.MaskToken(url.PathEscape(token)))
|
||||
}
|
||||
|
||||
func wbNodeUpdateParams(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{}
|
||||
if token := runtime.Str("idempotent-token"); token != "" {
|
||||
params["client_token"] = token
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func whiteboardNodeBatchUpdateBody(payload whiteboardNodeBatchPayload) map[string]interface{} {
|
||||
return map[string]interface{}{"nodes": payload.Nodes}
|
||||
}
|
||||
|
||||
func whiteboardNodeUpdateIDs(data map[string]interface{}) ([]string, error) {
|
||||
switch raw := data["ids"].(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(raw))
|
||||
for i, value := range raw {
|
||||
id, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, wbInvalidResponse("update whiteboard nodes failed: data.ids[%d] must be a string", i)
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
case []string:
|
||||
return append([]string(nil), raw...), nil
|
||||
default:
|
||||
return nil, wbInvalidResponse("update whiteboard nodes failed: data.ids must be an array of strings")
|
||||
}
|
||||
}
|
||||
|
||||
// WhiteboardNodeUpdate registers the `whiteboard +node-update` shortcut.
|
||||
var WhiteboardNodeUpdate = common.Shortcut{
|
||||
Service: "whiteboard",
|
||||
Command: "+node-update",
|
||||
Description: "Update nodes in an existing whiteboard.",
|
||||
Risk: "write",
|
||||
Scopes: wbNodeUpdateScopes,
|
||||
AuthTypes: wbNodeUpdateAuthTypes,
|
||||
Flags: wbNodeUpdateFlags,
|
||||
Tips: []string{
|
||||
`Pass --source as JSON with a non-empty "nodes" array; each node must include "id".`,
|
||||
`Execution sends one whiteboard.node batch_update request and preserves node ids in the request body.`,
|
||||
`Use --idempotent-token for retry-safe batch_update requests; the token is sent as client_token only when provided.`,
|
||||
},
|
||||
Validate: wbNodeUpdateValidate,
|
||||
DryRun: wbNodeUpdateDryRun,
|
||||
Execute: wbNodeUpdateExecute,
|
||||
}
|
||||
239
shortcuts/whiteboard/whiteboard_node_update_test.go
Normal file
239
shortcuts/whiteboard/whiteboard_node_update_test.go
Normal file
@@ -0,0 +1,239 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestWhiteboardNodeUpdateValidate_SourceMissingIDTypedParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newTestRuntime(map[string]string{
|
||||
"whiteboard-token": "test-board",
|
||||
"source": `{"nodes":[{"type":"text","text":{"content":"hello"}}]}`,
|
||||
}, nil)
|
||||
|
||||
err := wbNodeUpdateValidate(context.Background(), rt)
|
||||
assertValidationParam(t, err, "--source", false)
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeUpdateDryRun_RequestShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newTestRuntime(map[string]string{
|
||||
"whiteboard-token": "test-board",
|
||||
"idempotent-token": "update-token-12345",
|
||||
"source": `{"nodes":[` +
|
||||
`{"id":"nodeA","type":"text","text":{"content":"hello A"}},` +
|
||||
`{"id":"nodeB","type":"text","text":{"content":"hello B"}}` +
|
||||
`]}`,
|
||||
}, nil)
|
||||
|
||||
dryRun := wbNodeUpdateDryRun(context.Background(), rt)
|
||||
if dryRun == nil {
|
||||
t.Fatal("wbNodeUpdateDryRun() returned nil")
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
data, err := json.Marshal(dryRun)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry-run: %v\njson=%s", err, string(data))
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("api len = %d, want 1; json=%s", len(got.API), string(data))
|
||||
}
|
||||
if got.API[0].Method != "PUT" {
|
||||
t.Fatalf("method = %q, want PUT", got.API[0].Method)
|
||||
}
|
||||
if got.API[0].URL != "/open-apis/board/v1/whiteboards/test...oard/nodes/batch_update" {
|
||||
t.Fatalf("url = %q, want masked batch_update URL", got.API[0].URL)
|
||||
}
|
||||
if got.API[0].Params["client_token"] != "update-token-12345" {
|
||||
t.Fatalf("params.client_token = %#v, want update-token-12345", got.API[0].Params["client_token"])
|
||||
}
|
||||
nodes, ok := got.API[0].Body["nodes"].([]interface{})
|
||||
if !ok || len(nodes) != 2 {
|
||||
t.Fatalf("body.nodes = %#v, want two nodes", got.API[0].Body["nodes"])
|
||||
}
|
||||
wantText := []string{"hello A", "hello B"}
|
||||
for i := range nodes {
|
||||
node, ok := nodes[i].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("body.nodes[%d] = %T, want map; nodes=%#v", i, nodes[i], nodes)
|
||||
}
|
||||
if node["id"] != []string{"nodeA", "nodeB"}[i] {
|
||||
t.Fatalf("body.nodes[%d].id = %#v", i, node["id"])
|
||||
}
|
||||
text, ok := node["text"].(map[string]interface{})
|
||||
if !ok || text["content"] != wantText[i] {
|
||||
t.Fatalf("body.nodes[%d].text = %#v, want content %q", i, node["text"], wantText[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeUpdateExecute_BatchUpdatesNodes(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
var capturedQuery string
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-board/nodes/batch_update",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"ids": []string{"nodeA", "nodeB"},
|
||||
},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.RawQuery
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
source := `{"nodes":[` +
|
||||
`{"id":"nodeA","type":"text","text":{"content":"hello A"}},` +
|
||||
`{"id":"nodeB","type":"text","text":{"content":"hello B"}}` +
|
||||
`]}`
|
||||
args := []string{"+node-update", "--whiteboard-token", "test-board", "--source", source, "--idempotent-token", "update-token-12345"}
|
||||
if err := runUpdateShortcut(t, WhiteboardNodeUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
assertNodeBatchUpdateCapturedBody(t, stub.CapturedBody, []string{"hello A", "hello B"})
|
||||
if !strings.Contains(capturedQuery, "client_token=update-token-12345") {
|
||||
t.Fatalf("query = %q, want client_token", capturedQuery)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"ids": "nodeA,nodeB"`) {
|
||||
t.Fatalf("stdout=%s, want ids nodeA,nodeB", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"count": 2`) {
|
||||
t.Fatalf("stdout=%s, want count 2", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeUpdateExecute_WithoutIdempotentTokenOmitsClientToken(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
var capturedQuery string
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-board/nodes/batch_update",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"ids": []string{"nodeA"},
|
||||
},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.RawQuery
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
source := `{"nodes":[{"id":"nodeA","type":"text","text":{"content":"hello A"}}]}`
|
||||
args := []string{"+node-update", "--whiteboard-token", "test-board", "--source", source}
|
||||
if err := runUpdateShortcut(t, WhiteboardNodeUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if capturedQuery != "" {
|
||||
t.Fatalf("query = %q, want empty when --idempotent-token is absent", capturedQuery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeUpdateExecute_BatchFailureReturnsAPIError(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-board/nodes/batch_update",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1254001,
|
||||
"msg": "node not found",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
source := `{"nodes":[` +
|
||||
`{"id":"nodeA","type":"text","text":{"content":"hello A"}},` +
|
||||
`{"id":"nodeB","type":"text","text":{"content":"hello B"}}` +
|
||||
`]}`
|
||||
args := []string{"+node-update", "--whiteboard-token", "test-board", "--source", source}
|
||||
err := runUpdateShortcut(t, WhiteboardNodeUpdate, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected batch update failure error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("errs.ProblemOf returned false for %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryAPI {
|
||||
t.Fatalf("Category = %q, want %q", problem.Category, errs.CategoryAPI)
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error type = %T, want *errs.APIError reachable via errors.As", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardNodeUpdateTips_MentionTemporaryNonAtomicBehavior(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tips := strings.Join(WhiteboardNodeUpdate.Tips, "\n")
|
||||
for _, want := range []string{"batch_update", "client_token", "one whiteboard.node batch_update request"} {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips = %q, want substring %q", tips, want)
|
||||
}
|
||||
}
|
||||
for _, banned := range []string{"fans out", "non-atomic", "Temporary behavior"} {
|
||||
if strings.Contains(tips, banned) {
|
||||
t.Fatalf("tips = %q, should not contain old fan-out wording %q", tips, banned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertNodeBatchUpdateCapturedBody(t *testing.T, raw []byte, wantContent []string) {
|
||||
t.Helper()
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(raw))
|
||||
}
|
||||
nodes, ok := body["nodes"].([]interface{})
|
||||
if !ok || len(nodes) != len(wantContent) {
|
||||
t.Fatalf("body.nodes = %#v, want %d nodes; body=%s", body["nodes"], len(wantContent), string(raw))
|
||||
}
|
||||
for i, rawNode := range nodes {
|
||||
node, ok := rawNode.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("body.nodes[%d] = %T, want map; body=%s", i, rawNode, string(raw))
|
||||
}
|
||||
if _, exists := node["id"]; !exists {
|
||||
t.Fatalf("body.nodes[%d].id absent; body=%s", i, string(raw))
|
||||
}
|
||||
text, ok := node["text"].(map[string]interface{})
|
||||
if !ok || text["content"] != wantContent[i] {
|
||||
t.Fatalf("body.nodes[%d].text = %#v, want content %q; body=%s", i, node["text"], wantContent[i], string(raw))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
name: lark-whiteboard
|
||||
version: 1.0.0
|
||||
description: >
|
||||
飞书画板:查询和编辑飞书云文档中的画板。支持导出画板为预览图片、导出原始节点结构、使用多种格式更新画板内容。
|
||||
飞书画板:查询和编辑飞书云文档中的画板。支持导出画板为预览图片、导出原始节点结构、使用多种格式更新画板内容,并支持按节点增量创建、更新和删除。
|
||||
当用户需要查看画板内容、导出画板图片、编辑画板时使用此 skill。不负责:飞书云文档内容编辑(lark-doc)、文档内嵌电子表格/Base(lark-sheets / lark-base)。
|
||||
metadata:
|
||||
requires:
|
||||
@@ -28,7 +28,10 @@ metadata:
|
||||
| 导出 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` |
|
||||
| 定位节点 id / 查看原始节点结构 | [`+export --output-type raw`](references/lark-whiteboard-export.md) |
|
||||
| 已知 node id, 微调文字/颜色/样式 | [`+node-update`](references/lark-whiteboard-node-update.md); 先用 `+export --output-type raw` 定位节点 |
|
||||
| 追加已编译好的 OpenAPI 节点 | [`+node-create`](references/lark-whiteboard-node-create.md); 节点建议由 `npx -y @larksuite/whiteboard-cli@^0.2.13 --to openapi` 生成后整理成 `{ "nodes": [...] }` |
|
||||
| 删除已知节点 | [`+node-delete`](references/lark-whiteboard-node-delete.md); 删除前先确认 node id, 真实执行需要 `--yes` |
|
||||
| 用户**已提供** 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)** |
|
||||
@@ -39,6 +42,9 @@ metadata:
|
||||
|---------------------------------------------------|---|
|
||||
| [`+export`](references/lark-whiteboard-export.md) | 导出画板为预览图片、SVG 矢量图、代码或原始节点结构。 |
|
||||
| [`+update`](references/lark-whiteboard-update.md) | 更新画板,支持 PlantUML、Mermaid、SVG 或 OpenAPI 原生格式 |
|
||||
| [`+node-create`](references/lark-whiteboard-node-create.md) | 向已有画板追加 OpenAPI 节点;适合已由工具生成节点数据的增量新增 |
|
||||
| [`+node-update`](references/lark-whiteboard-node-update.md) | 按节点 id 批量更新已有节点;执行层发起一次 batch_update 请求 |
|
||||
| [`+node-delete`](references/lark-whiteboard-node-delete.md) | 按节点 id 删除已有节点;高风险写操作,执行前必须确认目标节点 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
- `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))。注意:导出为纯视觉快照,思维导图层级、表格结构、连接器绑定等语义信息会丢失。
|
||||
- `source`:PlantUML/Mermaid 代码。仅限画板内有且仅有一个 PlantUML/Mermaid 图时,才可导出代码,否则会在返回值中告知不存在/有多个节点。
|
||||
- `raw`:飞书 OpenAPI 原生画板节点格式。这一 json 格式不适合直接编辑复杂布局或内容,建议仅限于需要修改简单的文本内容/颜色等细节时使用。需要进行更复杂的设计/修改时,建议参考 [§ 渲染 & 写入画板](../SKILL.md#渲染--写入画板)。
|
||||
- `raw`:飞书 OpenAPI 原生画板节点格式。主要用于定位 `data.nodes[].id` 和核对节点字段。已知 node id 的局部修改优先用 [`+node-update`](./lark-whiteboard-node-update.md),删除用 [`+node-delete`](./lark-whiteboard-node-delete.md);不要手动改 raw JSON 后用 `+update --input_format raw` 做节点级微调。复杂设计/修改参考 [§ 渲染 & 写入画板](../SKILL.md#渲染--写入画板)。
|
||||
|
||||
## 示例
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# whiteboard +node-create
|
||||
|
||||
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。画板节点操作默认使用 `--as user`。
|
||||
|
||||
向已有画板追加 OpenAPI 节点。它适合局部新增已编译好的节点, 不适合从零创作复杂图表。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 已经知道 `whiteboard-token`, 且拥有画板编辑权限。
|
||||
- 已经有可追加的 OpenAPI `nodes[]`。
|
||||
- 需要向已有画板追加节点, 而不是覆盖整图。
|
||||
|
||||
## 不适用场景
|
||||
|
||||
- 从零创作复杂图表, 或需要自动布局、批量排版、复杂连线计算。
|
||||
- 只有 DSL / Mermaid / SVG, 还没有转换成 OpenAPI 节点。
|
||||
- 只想在文档正文里插入或移动画板块, 这属于 `lark-doc`。
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `--whiteboard-token` | 是 | 画板 token。 |
|
||||
| `--source` | 是 | JSON, 必须包含非空 `nodes` 数组。支持 `@path` 文件读取或 `-` stdin。 |
|
||||
| `--idempotent-token` | 否 | 幂等 token, 最少 10 个字符。重试同一次逻辑新增时复用同一个值。 |
|
||||
|
||||
## 输入
|
||||
|
||||
`nodes[]` 必须是飞书 OpenAPI 画板节点, 不是 whiteboard-cli DSL。不要把 `{"type":"shape","shape":...}` 这类 DSL 节点直接传给本命令。
|
||||
|
||||
推荐先用 `npx -y @larksuite/whiteboard-cli@^0.2.13 --to openapi --format json` 生成 OpenAPI 结果, 再整理成 `{ "nodes": [...] }`。
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "tmpNode",
|
||||
"type": "composite_shape",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 260,
|
||||
"height": 45,
|
||||
"text": {
|
||||
"text": "hello",
|
||||
"font_weight": "regular",
|
||||
"font_size": 14,
|
||||
"horizontal_align": "center",
|
||||
"vertical_align": "mid"
|
||||
},
|
||||
"style": {
|
||||
"border_color": "#3370ff",
|
||||
"border_width": "narrow",
|
||||
"border_style": "solid",
|
||||
"fill_color": "#e8f3ff"
|
||||
},
|
||||
"composite_shape": {
|
||||
"type": "round_rect"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +node-create \
|
||||
--whiteboard-token <whiteboard_token> \
|
||||
--source @./nodes.json \
|
||||
--idempotent-token <10+字符唯一串> \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
lark-cli whiteboard +node-create \
|
||||
--whiteboard-token <whiteboard_token> \
|
||||
--source @./nodes.json \
|
||||
--idempotent-token <10+字符唯一串> \
|
||||
--as user
|
||||
```
|
||||
|
||||
## 输出
|
||||
|
||||
JSON 输出使用 `data.ids`, 多个 id 用逗号拼接:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"ids": "o2:5"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Safety
|
||||
|
||||
- 写入前先用 `--dry-run` 检查 method、URL、params 和 body。
|
||||
- 对手写节点尤其要先 dry-run;dry-run 只能验证请求结构, 不能证明节点语义一定可插入。
|
||||
- 复杂图表继续走 `whiteboard-cli -> +update` 或 workflow 路径, 不要把 `+node-create` 当作默认创作入口。
|
||||
@@ -0,0 +1,75 @@
|
||||
# whiteboard +node-delete
|
||||
|
||||
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。画板节点操作默认使用 `--as user`。
|
||||
|
||||
按 node id 删除已有节点。这是高风险写操作, 只能删除已经确认的目标节点。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 已经知道 `whiteboard-token`, 且拥有画板编辑权限。
|
||||
- 已经确认要删除的 node id。
|
||||
- 需要删除已有画板中的局部节点。
|
||||
|
||||
## 不适用场景
|
||||
|
||||
- 不知道目标 node id。
|
||||
- 只是想隐藏、移动或更新节点。
|
||||
- 需要清空或整体替换画板。
|
||||
|
||||
## 定位节点
|
||||
|
||||
先导出 raw 节点结构:
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +export \
|
||||
--whiteboard-token <whiteboard_token> \
|
||||
--output-type raw \
|
||||
--as user
|
||||
```
|
||||
|
||||
从返回的 `data.nodes[].id` 读取目标 node id。不要删除从上下文猜测出来的 ambient 节点。
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `--whiteboard-token` | 是 | 画板 token。 |
|
||||
| `--node-ids` | 是 | 要删除的 node id, 多个 id 用英文逗号分隔。 |
|
||||
| `--idempotent-token` | 否 | 幂等 token, 最少 10 个字符。重试同一次逻辑删除时复用同一个值。 |
|
||||
| `--yes` | 真实执行需要 | 高风险写操作确认。先 dry-run, 确认目标后再传。 |
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +node-delete \
|
||||
--whiteboard-token <whiteboard_token> \
|
||||
--node-ids <node_id_1>,<node_id_2> \
|
||||
--idempotent-token <10+字符唯一串> \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
lark-cli whiteboard +node-delete \
|
||||
--whiteboard-token <whiteboard_token> \
|
||||
--node-ids <node_id_1>,<node_id_2> \
|
||||
--idempotent-token <10+字符唯一串> \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 输出
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"ids": "o2:5,o2:6",
|
||||
"count": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Safety
|
||||
|
||||
- 删除前必须用 `+export --output-type raw` 确认 node id。
|
||||
- 先运行 `--dry-run`, 检查 method 是 `DELETE`, URL 是 `/nodes/batch_delete`, body 是 `{"ids":[...]}`。
|
||||
- 只有确认目标节点后才传 `--yes`。
|
||||
- 不要因为用户说“删掉这个”就删除最近消息里的节点;缺少 node id 时先导出 raw 或要求定位依据。
|
||||
100
skills/lark-whiteboard/references/lark-whiteboard-node-update.md
Normal file
100
skills/lark-whiteboard/references/lark-whiteboard-node-update.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# whiteboard +node-update
|
||||
|
||||
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。画板节点操作默认使用 `--as user`。
|
||||
|
||||
按 node id 更新已有节点字段。CLI 输入是批量形态, 执行层会发起一次 `batch_update` OpenAPI 请求。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 已经知道 `whiteboard-token`, 且拥有画板编辑权限。
|
||||
- 已经知道目标 node id。
|
||||
- 需要局部修改文字、颜色、样式、位置等节点字段。
|
||||
|
||||
## 不适用场景
|
||||
|
||||
- 不知道目标 node id。
|
||||
- 需要重绘复杂图表或重新布局。
|
||||
- 想替换整个画板内容。
|
||||
|
||||
## 定位节点
|
||||
|
||||
先导出 raw 节点结构:
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +export \
|
||||
--whiteboard-token <whiteboard_token> \
|
||||
--output-type raw \
|
||||
--as user
|
||||
```
|
||||
|
||||
从返回的 `data.nodes[].id` 读取目标 node id, 再构造更新输入。
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `--whiteboard-token` | 是 | 画板 token。 |
|
||||
| `--source` | 是 | JSON, 必须包含非空 `nodes` 数组, 每个 node 必须包含 `id`。支持 `@path` 文件读取或 `-` stdin。 |
|
||||
| `--idempotent-token` | 否 | 幂等 token, 最少 10 个字符;非空时作为 `client_token` 随 batch_update 请求发送。 |
|
||||
|
||||
## 输入
|
||||
|
||||
CLI 输入保持批量形态:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "o2:5",
|
||||
"type": "composite_shape",
|
||||
"text": {
|
||||
"text": "updated",
|
||||
"font_weight": "regular",
|
||||
"font_size": 14,
|
||||
"horizontal_align": "center",
|
||||
"vertical_align": "mid"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
执行时所有节点会保持在同一个请求中:
|
||||
|
||||
- `PUT /open-apis/board/v1/whiteboards/:whiteboard_id/nodes/batch_update`
|
||||
- body 为 `{"nodes": [...]}`, 节点内的 `id` 会保留。
|
||||
- `--idempotent-token` 非空时, query 参数带 `client_token=<token>`。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +node-update \
|
||||
--whiteboard-token <whiteboard_token> \
|
||||
--source @./node-updates.json \
|
||||
--idempotent-token <10+字符唯一串> \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
lark-cli whiteboard +node-update \
|
||||
--whiteboard-token <whiteboard_token> \
|
||||
--source @./node-updates.json \
|
||||
--idempotent-token <10+字符唯一串> \
|
||||
--as user
|
||||
```
|
||||
|
||||
## 输出
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"ids": "o2:5",
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Safety
|
||||
|
||||
- 多节点更新前先使用 `--dry-run` 检查 batch_update method、URL、params 和 body。
|
||||
- batch_update 后端不承诺跨阶段事务回滚;如服务端提示请求未完整完成, 需用 `+export --output-type raw` 读回目标节点确认状态。
|
||||
- 不要在节点更新失败时自动回退到 `+update --overwrite`, 除非用户明确要求替换整个画板。
|
||||
@@ -30,6 +30,12 @@
|
||||
├─ 返回 Mermaid/PlantUML 代码
|
||||
│ → 在原代码上修改 → +update --input_format mermaid/plantuml
|
||||
├─ 无代码(SVG/DSL 或其他方式绘制的画板)
|
||||
│ ├─ 已知 node id, 只需局部微调
|
||||
│ │ → +export --output-type raw 确认节点 → +node-update → +export --output-type raw 或 preview 复验
|
||||
│ ├─ 已知 node id, 需要删除局部节点
|
||||
│ │ → +export --output-type raw 确认节点 → +node-delete --dry-run → +node-delete --yes → preview 复验
|
||||
│ ├─ 已生成 OpenAPI nodes[] 且只需追加
|
||||
│ │ → +node-create --dry-run → +node-create → +export --output-type raw 或 preview 复验
|
||||
│ ├─ 需纯新增(思维导图、流程图、时序图、类图、饼图、甘特图)图表节点
|
||||
│ │ → +export --output-type preview → 看图 → +export --output-type raw → 确定新节点坐标和层级 → [§ 渲染 & 写入画板]
|
||||
│ └─ 其他改动(几何变动/增删元素/结构调整/混合编辑等)
|
||||
@@ -83,5 +89,6 @@ diagram.png ← 渲染结果
|
||||
|
||||
- Mermaid / PlantUML / SVG 产物直接用对应的 `mermaid` / `plantuml` / `svg` 写入。
|
||||
- 只有 DSL 产物或已明确需要 OpenAPI 原生节点格式时,才先用 `npx -y @larksuite/whiteboard-cli@^0.2.13 --to openapi --format json` 转换,再用 `raw` 写入。
|
||||
- 如果目标是向已有画板追加已编译好的 OpenAPI `nodes[]`, 优先用 [`whiteboard +node-create`](./lark-whiteboard-node-create.md), 不要为了追加节点覆盖整图。
|
||||
|
||||
具体命令示例、`--overwrite`、`--idempotent-token` 和 `--as user/bot` 的使用方式,统一参考 [`whiteboard +update`](./lark-whiteboard-update.md)。
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
func TestWhiteboardNodeCreateDryRun_RequestShape(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", "+node-create",
|
||||
"--whiteboard-token", "wbcnCreateDryRun",
|
||||
"--source", `{"nodes":[{"id":"tmpNode","type":"composite_shape","x":0,"y":0,"width":260,"height":45,"text":{"text":"hello","font_weight":"regular","font_size":14,"horizontal_align":"center","vertical_align":"mid"},"style":{"border_color":"#3370ff","border_width":"narrow","border_style":"solid","fill_color":"#e8f3ff"},"composite_shape":{"type":"round_rect"}}]}`,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, int64(1), clie2e.DryRunGet(out, "api.#").Int(), out)
|
||||
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
|
||||
gotURL := clie2e.DryRunGet(out, "api.0.url").String()
|
||||
if !strings.HasPrefix(gotURL, "/open-apis/board/v1/whiteboards/") || !strings.HasSuffix(gotURL, "/nodes") || strings.Contains(gotURL, "wbcnCreateDryRun") {
|
||||
t.Fatalf("url=%q, want masked board whiteboard nodes URL\nstdout:\n%s", gotURL, out)
|
||||
}
|
||||
require.Equal(t, "composite_shape", clie2e.DryRunGet(out, "api.0.body.nodes.0.type").String(), out)
|
||||
require.Equal(t, "round_rect", clie2e.DryRunGet(out, "api.0.body.nodes.0.composite_shape.type").String(), out)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
func TestWhiteboardNodeDeleteDryRun_RequestShape(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", "+node-delete",
|
||||
"--whiteboard-token", "wbcnDeleteDryRun",
|
||||
"--node-ids", "nodeA,nodeB",
|
||||
"--idempotent-token", "delete-token-12345",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, int64(1), clie2e.DryRunGet(out, "api.#").Int(), out)
|
||||
require.Equal(t, "DELETE", clie2e.DryRunGet(out, "api.0.method").String(), out)
|
||||
gotURL := clie2e.DryRunGet(out, "api.0.url").String()
|
||||
if !strings.HasPrefix(gotURL, "/open-apis/board/v1/whiteboards/") ||
|
||||
!strings.HasSuffix(gotURL, "/nodes/batch_delete") ||
|
||||
strings.Contains(gotURL, "wbcnDeleteDryRun") {
|
||||
t.Fatalf("url=%q, want masked board whiteboard batch delete URL\nstdout:\n%s", gotURL, out)
|
||||
}
|
||||
require.Equal(t, "delete-token-12345", clie2e.DryRunGet(out, "api.0.params.client_token").String(), out)
|
||||
require.Equal(t, "nodeA", clie2e.DryRunGet(out, "api.0.body.ids.0").String(), out)
|
||||
require.Equal(t, "nodeB", clie2e.DryRunGet(out, "api.0.body.ids.1").String(), out)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
func TestWhiteboardNodeUpdateDryRun_RequestShape(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", "+node-update",
|
||||
"--whiteboard-token", "wbcnUpdateDryRun",
|
||||
"--source", `{"nodes":[{"id":"nodeA","type":"text","text":{"content":"hello A"}},{"id":"nodeB","type":"text","text":{"content":"hello B"}}]}`,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, int64(2), clie2e.DryRunGet(out, "api.#").Int(), out)
|
||||
for i, nodeID := range []string{"nodeA", "nodeB"} {
|
||||
require.Equal(t, "PUT", clie2e.DryRunGet(out, "api."+string(rune('0'+i))+".method").String(), out)
|
||||
gotURL := clie2e.DryRunGet(out, "api."+string(rune('0'+i))+".url").String()
|
||||
if !strings.HasPrefix(gotURL, "/open-apis/board/v1/whiteboards/") ||
|
||||
!strings.HasSuffix(gotURL, "/nodes/"+nodeID) ||
|
||||
strings.Contains(gotURL, "wbcnUpdateDryRun") {
|
||||
t.Fatalf("url=%q, want masked board whiteboard node update URL ending with %s\nstdout:\n%s", gotURL, nodeID, out)
|
||||
}
|
||||
require.False(t, clie2e.DryRunGet(out, "api."+string(rune('0'+i))+".body.node.id").Exists(), out)
|
||||
require.Equal(t, "text", clie2e.DryRunGet(out, "api."+string(rune('0'+i))+".body.node.type").String(), out)
|
||||
require.Equal(t, "hello "+string(rune('A'+i)), clie2e.DryRunGet(out, "api."+string(rune('0'+i))+".body.node.text.content").String(), out)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user