mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
3 Commits
docs/wiki-
...
feat/white
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60e6bc2f3b | ||
|
|
1fd29e75a6 | ||
|
|
5cf09ecfda |
@@ -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"
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -27,19 +28,23 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id, empty/absent = always shown). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"If the form may already contain questions and has not been checked, run +form-questions-list for the same --base-token, --table-id, and --form-id. A verified empty form can create directly.",
|
||||
"Each new question creates a field in the form's table; question IDs are field IDs.",
|
||||
"Unless the user explicitly requests a separate same-title question, update an existing title with +form-questions-update instead of creating a duplicate.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := parseFormQuestionsCreate(runtime.Str("questions"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
api := common.NewDryRunAPI().
|
||||
questions, _ := parseFormQuestionsCreate(runtime.Str("questions"))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", runtime.Str("table-id")).
|
||||
Set("form_id", runtime.Str("form-id"))
|
||||
// Transcribe the questions body verbatim so the preview shows exactly
|
||||
// what would be sent (including optional fields like visible_rule).
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
|
||||
api.Body(map[string]interface{}{"questions": questions})
|
||||
}
|
||||
return api
|
||||
Set("form_id", runtime.Str("form-id")).
|
||||
Body(map[string]interface{}{"questions": questions})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
@@ -47,9 +52,9 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
||||
formId := runtime.Str("form-id")
|
||||
questionsJSON := runtime.Str("questions")
|
||||
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
|
||||
return baseValidationErrorf("--questions must be a valid JSON array: %s", err)
|
||||
questions, err := parseFormQuestionsCreate(questionsJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := baseV3Call(runtime, "POST",
|
||||
@@ -78,3 +83,31 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func parseFormQuestionsCreate(raw string) ([]interface{}, error) {
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &questions); err != nil {
|
||||
return nil, baseValidationErrorf("--questions must be a valid JSON array: %s", err)
|
||||
}
|
||||
if questions == nil {
|
||||
return nil, baseValidationErrorf("--questions must be a non-null JSON array")
|
||||
}
|
||||
if len(questions) > 10 {
|
||||
return nil, baseValidationErrorf("--questions must contain at most 10 items")
|
||||
}
|
||||
for i, question := range questions {
|
||||
item, ok := question.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, baseValidationErrorf("--questions item %d must be an object", i+1)
|
||||
}
|
||||
title, ok := item["title"].(string)
|
||||
if !ok || strings.TrimSpace(title) == "" {
|
||||
return nil, baseValidationErrorf("--questions item %d must include a non-empty string \"title\"", i+1)
|
||||
}
|
||||
questionType, ok := item["type"].(string)
|
||||
if !ok || strings.TrimSpace(questionType) == "" {
|
||||
return nil, baseValidationErrorf("--questions item %d must include a non-empty string \"type\"", i+1)
|
||||
}
|
||||
}
|
||||
return questions, nil
|
||||
}
|
||||
|
||||
24
shortcuts/base/base_form_questions_create_tips_test.go
Normal file
24
shortcuts/base/base_form_questions_create_tips_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBaseFormQuestionsCreateTipsRequireExistingQuestionCheck(t *testing.T) {
|
||||
tips := strings.Join(BaseFormQuestionsCreate.Tips, "\n")
|
||||
for _, want := range []string{
|
||||
"+form-questions-list",
|
||||
"verified empty form can create directly",
|
||||
"question IDs are field IDs",
|
||||
"explicitly requests a separate same-title question",
|
||||
"+form-questions-update",
|
||||
} {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-base
|
||||
version: 1.2.3
|
||||
description: "飞书多维表格(Base)操作:建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限;遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入转 lark-drive,认证/授权转 lark-shared。"
|
||||
description: "飞书多维表格(Base)操作:建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限;遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入/导出转 lark-drive,认证/授权转 lark-shared。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -23,14 +23,15 @@ metadata:
|
||||
不要使用本 skill:
|
||||
|
||||
- 只是认证、初始化配置、切换身份、处理 scope 或权限授权恢复,转 `lark-shared`。
|
||||
- 把本地 Excel / CSV / `.base` 导入成 Base,转 `lark-drive +import --type bitable`。
|
||||
- 把本地文件导入成 Base,或将 Base 导出为本地文件,转 `lark-drive`。
|
||||
- 泛化数据分析、字段设计、公式讨论,但没有 Base/多维表格上下文。
|
||||
|
||||
## 使用边界
|
||||
|
||||
- Base 业务操作只使用 `lark-cli base +...` shortcut,不使用旧聚合式 `+table / +field / +record / +view / +history / +workspace`。
|
||||
- 执行 update 前必须先查当前 shortcut 的 `--help` 或对应 reference。若命令要求完整配置,首次请求必须基于可信的当前配置执行 read-modify-write:只修改用户明确指定的内容,保留其他仍适用的可写配置,并按命令要求的结构提交。若命令支持局部/delta update,按其契约提交最小合法 payload;不得以不完整请求试错补参。
|
||||
- 用户要把 Excel / CSV / `.base` 导入成 Base 时,先转 `lark-cli drive +import --type bitable`,导入完成后再回到 Base 命令。
|
||||
- 本地文件与 Base 之间的导入/导出转 `lark-drive`,具体格式、参数、路径限制和仅结构导出规则由 `lark-drive` 负责;导入完成后再回到 Base 命令。
|
||||
- 在线复制 Base 使用 `+base-copy`,不要绕行导出/导入。
|
||||
- 认证、初始化、scope、身份切换、权限不足恢复属于 `lark-shared`;Base 文档只保留会影响 Base 路径选择的权限规则。
|
||||
|
||||
## 先获取 Base Token 和所需 ID
|
||||
@@ -49,6 +50,7 @@ metadata:
|
||||
|---|---|---|
|
||||
| 查 Base 本体 | `+base-get` | 用返回确认 Base 名称、owner、权限和可继续操作的 token |
|
||||
| 创建/复制 Base | `+base-create` / `+base-copy` | 新建时强烈推荐用 `--table-name` + `--fields` 同时配置新 Base 里唯一一个初始数据表的 name 和 schema;写入后报告新 Base 标识和 `permission_grant` |
|
||||
| Base 文件导入/导出 | 转 `lark-drive` | 文件格式、参数、路径限制和仅结构导出规则由 `lark-drive` 负责;在线复制走 `+base-copy` |
|
||||
| 查看 Base 内资源目录 | `+base-block-list` | 想先了解一个 Base 里有哪些 table/docx/dashboard/workflow/folder 时优先用它;返回 ID 关系和 fewshot 看 `--help` |
|
||||
| 管理 Base 内资源目录 | `+base-block-create/move/rename/delete` | 创建或整理 Base 直接管理的 folder/table/docx/dashboard/workflow;资源内容继续用对应命令 |
|
||||
| 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除 |
|
||||
@@ -63,8 +65,9 @@ metadata:
|
||||
| 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 [formula-field-guide.md](references/formula-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
|
||||
| Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 [lookup-field-guide.md](references/lookup-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
|
||||
| 表单提交 | `+form-submit` | 先读 [lark-base-form-detail.md](references/lark-base-form-detail.md) 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 [lark-base-form-submit.md](references/lark-base-form-submit.md) |
|
||||
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md);题目显隐条件 `visible_rule` 结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md) |
|
||||
| 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 [lark-base-form-detail.md](references/lark-base-form-detail.md);删除前确认目标表单 |
|
||||
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | Base 内表单按 table 管理;先确定并复用真实 `table_id`。读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md);题目显隐条件 `visible_rule` 结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md) |
|
||||
| Base 内表单管理 | `+form-list/get/create/update/delete` / `+form-questions-list/delete` | 缺少或不确定归属时,先用 `+table-list` 或 `+base-block-list` 取得真实 `table_id`;这些命令使用 `--base-token + --table-id` 并在整个工作流中复用同一 `table_id`,删除前确认目标表单 |
|
||||
| 分享表单详情 | `+form-detail --share-token <share_token>` | 只接受表单分享链接里的 `share_token`,不要传 `--base-token` / `--form-id`;提交前读 [lark-base-form-detail.md](references/lark-base-form-detail.md) |
|
||||
| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取图表计算结果用 `+dashboard-block-get-data` |
|
||||
| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);list/get/enable/disable 只处理 workflow ID 与启停状态 |
|
||||
| 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);角色 create/update 或解读完整配置再读权限 JSON SSOT [role-config.md](references/role-config.md);系统角色不可删除;关闭高级权限会影响自定义角色 |
|
||||
@@ -116,6 +119,9 @@ metadata:
|
||||
|
||||
## 表单与视图细节
|
||||
|
||||
- Base 内表单 list/get/create/update/delete 和题目管理都属于具体数据表:第一个管理命令前必须已有归属明确的真实 `table_id`;缺失或归属不明确时才用 `+table-list` 或 `+base-block-list` 定位,已有真实 ID 时直接复用。后续管理命令始终传同一 `base_token + table_id`。`+form-detail` 是分享表单入口,标识域不同,只使用 `share_token`。
|
||||
- 表单问题由数据表字段承载,question `id` 就是 `field_id`。创建问题前先 `+form-questions-list`;除非用户明确要求同名的独立问题,否则标题已存在时优先用 `+form-questions-update` 修改必填状态、标题或描述,不要先创建同名问题再删除旧问题。
|
||||
- `+form-questions-delete` 会删除承载问题的数据表字段。主字段问题不可删除;不要把主字段 ID 放入 `--question-ids`,需要修改时使用 `+form-questions-update`。
|
||||
- `+form-submit` 是高风险写操作,必须带 `--yes` 确认;调用前必须先跑 `+form-detail`,读取 `questions[].type`、`required`、`filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
|
||||
- `+form-questions-update` 是题目配置全量覆盖,不是 patch;未传字段会回落默认值,传空字符串 / `null` / 空数组会直接写入空或清空。更新前先 `+form-questions-list` 读取当前题目,把要保留的 `title` / `description` / `required` / `option_display_mode` / `visible_rule` 等字段带回请求。
|
||||
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`。
|
||||
|
||||
@@ -137,9 +137,12 @@ lark-cli base +form-questions-create \
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** — 执行前必须向用户确认。
|
||||
|
||||
1. 先用 `+form-questions-list` 查看现有问题
|
||||
2. 确认要添加的问题内容
|
||||
3. 执行命令并报告新建的问题 ID
|
||||
1. 先确定表单所属的真实 `table_id`,并在整个表单管理工作流中复用它;仅在 ID 缺失或归属不明确时调用 `+table-list`。
|
||||
2. 用 `+form-questions-list` 查看现有问题。问题 `id` 是承载该问题的 `field_id`,不是独立于数据表的临时 ID。
|
||||
3. 除非用户明确要求同名的独立问题,否则目标标题已经存在时用 `+form-questions-update` 更新必填状态、标题或描述;不要创建同名问题后再删除旧问题。
|
||||
4. 创建确实不存在的问题,或用户明确要求的同名独立问题,并报告新建的问题 ID。
|
||||
|
||||
`+form-questions-delete` 会删除承载问题的数据表字段,不能删除主字段问题。不要通过“新建重复问题再删除旧问题”来替换主字段。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ This guide is the entry point for Base advanced permissions and roles. Use it to
|
||||
|
||||
| Goal | Command | Notes |
|
||||
|------|---------|-------|
|
||||
| Check advanced permission status | `+base-get` | Read `data.base.is_advanced`. There is no `+advperm-get` command. |
|
||||
| Enable advanced permissions | `+advperm-enable` | Required before creating or updating roles. Caller must be a Base admin. |
|
||||
| Disable advanced permissions | `+advperm-disable` | High-risk write. Disabling invalidates existing custom roles. |
|
||||
| Locate roles | `+role-list` | Returns role summaries. Use `+role-get` for full config. |
|
||||
@@ -14,6 +15,16 @@ This guide is the entry point for Base advanced permissions and roles. Use it to
|
||||
| Update a role | `+role-update` | Delta merge. Read current config first, then send only intended changes. |
|
||||
| Delete a role | `+role-delete` | Custom roles only. System roles cannot be deleted. |
|
||||
|
||||
## Required order
|
||||
|
||||
At the start of a role workflow, before the first `+role-list`, `+role-get`, `+role-create`, `+role-update`, or `+role-delete` call:
|
||||
|
||||
1. Run `lark-cli base +base-get --base-token <base_token>` and inspect `data.base.is_advanced`.
|
||||
2. If `is_advanced` is `false`, run `+advperm-enable` before the role command. If the user did not authorize enabling advanced permissions, stop and explain the required precondition.
|
||||
3. Run the requested role commands only after `is_advanced` is `true` or `+advperm-enable` succeeds. Reuse that confirmed status for later role calls in the same workflow.
|
||||
|
||||
Do not probe with `+advperm-get`: that command is not supported. Do not use an empty `+role-list` response to infer the advanced permission status; a disabled Base can also return an empty list.
|
||||
|
||||
## Safety boundaries
|
||||
|
||||
- Role operations require advanced permissions to be enabled and the caller to be a Base admin.
|
||||
|
||||
@@ -154,12 +154,34 @@
|
||||
"table_rule_map": {
|
||||
"订单表": {
|
||||
"perm": "edit",
|
||||
"view_rule": { "..." : "..." },
|
||||
"record_rule": { "..." : "..." },
|
||||
"field_rule": { "..." : "..." }
|
||||
"view_rule": {
|
||||
"allow_edit": true,
|
||||
"visibility": { "all_visible": true }
|
||||
},
|
||||
"record_rule": {
|
||||
"record_operations": ["add", "delete"],
|
||||
"other_record_all_read": true
|
||||
},
|
||||
"field_rule": {
|
||||
"field_perm_mode": "all_edit"
|
||||
}
|
||||
},
|
||||
"用户表": {
|
||||
"perm": "read_only"
|
||||
"perm": "read_only",
|
||||
"view_rule": {
|
||||
"allow_edit": false,
|
||||
"visibility": { "all_visible": true }
|
||||
},
|
||||
"record_rule": {
|
||||
"record_operations": [],
|
||||
"other_record_all_read": true
|
||||
},
|
||||
"field_rule": {
|
||||
"field_perm_mode": "all_read"
|
||||
}
|
||||
},
|
||||
"内部表": {
|
||||
"perm": "no_perm"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,7 +194,11 @@
|
||||
| `record_rule` | RecordRule | 记录权限配置 |
|
||||
| `field_rule` | FieldRule | 字段权限配置 |
|
||||
|
||||
**注意**: 当 `perm` 为 `no_perm` 时,`view_rule`、`record_rule`、`field_rule` 均无须再设置。
|
||||
**`+role-create` 硬约束**:
|
||||
|
||||
- 当 `perm` 为 `no_perm` 时,不要设置 `view_rule`、`record_rule`、`field_rule`。
|
||||
- 当 `perm` 为其他值时,必须同时提供完整的 `view_rule`、`record_rule`、`field_rule`,缺少任意一项都会导致创建失败。
|
||||
- `+role-update` 是 delta merge,只提交要修改的字段;不要为局部更新补造未变更配置。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ API 成功时返回空 `data`(仅 `code: 0, msg: "success"`),对应 CLI
|
||||
|
||||
## 与 wiki URL 的关系
|
||||
|
||||
传入 `/wiki/<node_token>` 时,shortcut 会直接用 `node_token` 作为路径参数并以 `type=wiki` 调用接口。如果需要先把 wiki 节点解析成 `obj_token`(例如想显式对底层 docx 申请),先使用与后续权限申请相同的身份调用 `wiki +node-get --node-token '<wiki_url>' --as user --format json`(下游使用 bot 时两步都改为 `--as bot`),读取 `data.obj_token` 和 `data.obj_type`,再把 bare `obj_token` 传给 `--token`、把真实 `obj_type` 传给 `--type`(例如 `data.obj_type` 为 `docx` 时使用 `--type docx`)。
|
||||
传入 `/wiki/<node_token>` 时,shortcut 会直接用 `node_token` 作为路径参数并以 `type=wiki` 调用接口。如果需要先把 wiki 节点解析成 `obj_token`(例如想显式对底层 docx 申请),自行先调 `wiki spaces get_node` 拿 `obj_token + obj_type`,再用 bare token + `--type docx` 调本命令。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -274,10 +274,10 @@ N. 结尾页:[结尾文案]
|
||||
|
||||
### Wiki 链接特殊处理(关键!)
|
||||
|
||||
知识库链接(`/wiki/TOKEN`)不能直接当 `xml_presentation_id`。直接调用原生 API 前,先用 Wiki shortcut 查询节点,确认 `data.obj_type == "slides"`,再用 `data.obj_token` 作为真实 presentation ID。
|
||||
知识库链接(`/wiki/TOKEN`)不能直接当 `xml_presentation_id`。直接调用原生 API 前,先查询 wiki 节点,确认 `node.obj_type == "slides"`,再用 `node.obj_token` 作为真实 presentation ID。
|
||||
|
||||
```bash
|
||||
lark-cli wiki +node-get --node-token '<wiki_url>' --as user --format json
|
||||
lark-cli wiki spaces get_node --as user --params '{"token":"wiki_token"}'
|
||||
```
|
||||
|
||||
Shortcut `+replace-slide` 和 `+media-upload` 会自动解析 `/wiki/` URL;手动调用 `xml_presentations.*` / `xml_presentation.slide.*` 时才需要自己做这一步。
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -27,14 +27,14 @@ metadata:
|
||||
- 用户要**按特定主题 / 关键词 / 内容线索查找资料并收集到知识库节点或新建知识库节点下**,必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`topic_move_collector`](../lark-drive/references/lark-drive-workflow-topic-move-collector.md) workflow。该 workflow 使用 Drive 全量搜索召回,再按 Wiki 目标解析、确认和移动;不要只用 Wiki 节点列表做局部遍历。
|
||||
- 用户要**整理 / 盘点 / 归类 / 重构知识库、个人文档库、文档库目录或 Wiki 节点结构**,或要生成整理方案、目标目录树、移动计划时,不要只使用 Wiki 节点 API。必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](../lark-drive/references/lark-drive-workflow-knowledge-organize.md) workflow;该 workflow 负责 Drive / Wiki / 个人文档库的统一入口解析、资源盘点、分类计划、写前确认和结果验证。
|
||||
- 用户要把**已有 Wiki 节点移出知识库,放到 Drive 文件夹或“我的空间”根目录**:使用 `wiki +move-to-drive`,不要使用 `wiki +move` 或 `drive +move`。这是会改变节点归属和权限继承的写操作,执行前确认源节点与目标位置。
|
||||
- 用户给的是知识库 URL(`.../wiki/<token>`),且后续要查成员/加成员/删成员:先确定下游成员操作的身份(默认 `user`;用户明确要求应用 / bot 视角时用 `bot`),再调用 `lark-cli wiki +node-get --node-token '<wiki_url>' --as user --format json`,从 `data.space_id` 获取空间 ID;下游使用 bot 时将示例中的身份改为 `--as bot`。节点解析与后续成员操作必须使用相同身份。
|
||||
- 用户给的是知识库 URL(`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`。
|
||||
- 用户要**删除**知识空间(`wiki +delete-space`)但只给了名称或 URL:**不能**把名称 / URL 原样传给 `--space-id`,必须先解析出真实 `space_id`。解析方式:
|
||||
- URL(`.../wiki/<token>`):先确定后续 `wiki +delete-space` 的身份(默认 `user`;明确要求 bot 视角时用 `bot`),再调用 `lark-cli wiki +node-get --node-token '<wiki_url>' --as user --format json`,读取 `data.space_id`;下游使用 bot 时将示例中的身份改为 `--as bot`。解析和删除必须使用相同身份。
|
||||
- URL(`.../wiki/<token>`):`lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}' --format json`,读 `data.node.space_id`。
|
||||
- 只知名称:`lark-cli wiki spaces list --format json`,边翻页边收集 items 并按 `name` 精确匹配;**一旦任一页累计到至少 1 条精确匹配就停止翻页**。只有当翻完所有页(`has_more=false`)仍无精确匹配时,才对已收集的全量 items 做宽松匹配(`name` trim 空格、大小写不敏感、子串包含)。
|
||||
- **关键安全约束**:无论精确还是模糊,**无论命中 1 条还是多条,发起删除前都必须把候选(`name` + `space_id` + `description` + `space_type`)列给用户,由用户明确选定一个 `space_id` 再执行**。不要因为"只命中一条"就自动执行删除。
|
||||
- 命中 0 条:停下来问用户是名称拼错了还是调用方无权限;**不要**自行改名字重试。
|
||||
- 用户明确选定后再执行 `lark-cli wiki +delete-space --space-id <ID> --yes`(高风险写操作,必须显式 `--yes`)。
|
||||
- 反例:不要把 wiki URL / 名称直接当 `--space-id`(如 `--space-id "https://.../wiki/<wiki_token>"`);务必先用 `wiki +node-get` 解析出 `data.space_id` 再传。
|
||||
- 反例:不要把 wiki URL / 名称直接当 `--space-id`(如 `--space-id "https://.../wiki/<wiki_token>"`);务必先用 `wiki spaces get_node` 解析出 `data.node.space_id` 再传。
|
||||
- 用户要在知识库中创建新节点,优先使用 `lark-cli wiki +node-create`。
|
||||
- 用户要列出 Wiki 节点:先用 `wiki +space-list --as user` 拿数字 `space_id`,再用 `wiki +node-list --space-id <space_id>`。不要把 wiki URL、node token、doc token、名称直接当 `--space-id`。钻子节点时 `--parent-node-token` 必须是 wiki node token;如果用户给的是 docx/sheet/base URL,先用 `wiki +node-get --node-token <url>` 解析出 `node_token`。
|
||||
- `wiki +node-list` 命中 `invalid_parameters`、`not_found`、`permission_denied` 时,不要重复调用同一参数;按 hint 修 `space_id` / `parent_node_token` / 权限。只有 `rate_limit` 才做退避重试。
|
||||
@@ -48,8 +48,6 @@ metadata:
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli wiki +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
获取或解析 Wiki 节点统一优先使用 `wiki +node-get`,包括只为获取 `space_id`、`node_token`、`obj_token` 或 `obj_type` 的中间步骤。只有当前 CLI 不提供该 shortcut,或任务明确需要 shortcut 未输出的原始响应字段时,才回退到 `wiki spaces get_node`;回退前先运行 `lark-cli schema wiki.spaces.get_node`。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+move`](references/lark-wiki-move.md) | Move a wiki node, or move a Drive document into Wiki |
|
||||
|
||||
@@ -117,16 +117,13 @@ dry-run 会展示两步调用链:
|
||||
|
||||
### 2. 只有知识库 URL(`.../wiki/<token>`)
|
||||
|
||||
先确定后续 `wiki +delete-space` 使用的身份:默认使用 `user`;用户明确要求应用 / bot 视角时使用 `bot`。下面展示默认 user 身份;下游使用 bot 时将两步都改为 `--as bot`。节点解析和删除必须使用相同身份。
|
||||
|
||||
```bash
|
||||
lark-cli wiki +node-get \
|
||||
--node-token '<wiki_url>' \
|
||||
--as user \
|
||||
lark-cli wiki spaces get_node \
|
||||
--params '{"token":"<wiki_token>"}' \
|
||||
--format json
|
||||
```
|
||||
|
||||
读取 `data.space_id`。只有当前 CLI 不提供 `+node-get`,或必须读取 shortcut 未输出的原始字段时,才在查看 `lark-cli schema wiki.spaces.get_node` 后回退到原生命令。
|
||||
读取 `data.node.space_id`。
|
||||
|
||||
### 3. 只有知识库名称
|
||||
|
||||
|
||||
@@ -55,3 +55,26 @@ func TestBaseFormDetailDryRun_MissingShareToken(t *testing.T) {
|
||||
assert.NotEqual(t, 0, result.ExitCode)
|
||||
assert.Contains(t, result.Stderr, "share-token")
|
||||
}
|
||||
|
||||
func TestBaseFormListDryRun_UsesBaseAndTableIdentifiers(t *testing.T) {
|
||||
setBaseDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+form-list",
|
||||
"--base-token", "basXXXX",
|
||||
"--table-id", "tblXXXX",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := strings.TrimSpace(result.Stdout)
|
||||
assert.Contains(t, output, "/open-apis/base/v3/bases/basXXXX/tables/tblXXXX/forms")
|
||||
assert.Contains(t, output, `"method": "GET"`)
|
||||
}
|
||||
|
||||
108
tests/cli_e2e/base/base_form_questions_create_dryrun_test.go
Normal file
108
tests/cli_e2e/base/base_form_questions_create_dryrun_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestBaseFormQuestionsCreateDryRun(t *testing.T) {
|
||||
setBaseDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+form-questions-create",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--form-id", "vew_x",
|
||||
"--questions", `[{"type":"text","title":"Risk","required":true}]`,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_x/questions", clie2e.DryRunGet(out, "api.0.url").String(), out)
|
||||
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
|
||||
require.Equal(t, "text", clie2e.DryRunGet(out, "api.0.body.questions.0.type").String(), out)
|
||||
require.Equal(t, "Risk", clie2e.DryRunGet(out, "api.0.body.questions.0.title").String(), out)
|
||||
require.True(t, clie2e.DryRunGet(out, "api.0.body.questions.0.required").Bool(), out)
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsCreateDryRunRejectsInvalidInput(t *testing.T) {
|
||||
setBaseDryRunConfigEnv(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
message string
|
||||
}{
|
||||
{name: "malformed JSON", input: "{", message: "must be a valid JSON array"},
|
||||
{name: "non-array JSON", input: "{}", message: "must be a valid JSON array"},
|
||||
{name: "null", input: "null", message: "must be a non-null JSON array"},
|
||||
{name: "non-object item", input: "[1]", message: "item 1 must be an object"},
|
||||
{name: "missing title", input: `[{"type":"text"}]`, message: `item 1 must include a non-empty string "title"`},
|
||||
{name: "blank title", input: `[{"title":" ","type":"text"}]`, message: `item 1 must include a non-empty string "title"`},
|
||||
{name: "missing type", input: `[{"title":"Risk"}]`, message: `item 1 must include a non-empty string "type"`},
|
||||
{name: "non-string type", input: `[{"title":"Risk","type":1}]`, message: `item 1 must include a non-empty string "type"`},
|
||||
{name: "more than ten items", input: `[{},{},{},{},{},{},{},{},{},{},{}]`, message: "must contain at most 10 items"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+form-questions-create",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--form-id", "vew_x",
|
||||
"--questions", tt.input,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
|
||||
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), result.Stderr)
|
||||
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
|
||||
require.Equal(t, "--questions", gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
|
||||
require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), tt.message)
|
||||
require.Empty(t, result.Stdout)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsCreateHelpShowsExistingQuestionGuard(t *testing.T) {
|
||||
setBaseDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+form-questions-create", "--help"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
require.Contains(t, strings.ToLower(result.Stdout), "form may already contain questions")
|
||||
require.Contains(t, result.Stdout, "+form-questions-list")
|
||||
require.Contains(t, result.Stdout, "+form-questions-update")
|
||||
}
|
||||
30
tests/cli_e2e/base/base_skill_contract_test.go
Normal file
30
tests/cli_e2e/base/base_skill_contract_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBaseSkillRoutesFileImportExportToDrive(t *testing.T) {
|
||||
_, currentFile, _, ok := runtime.Caller(0)
|
||||
require.True(t, ok)
|
||||
|
||||
skillPath := filepath.Join(filepath.Dir(currentFile), "..", "..", "..", "skills", "lark-base", "SKILL.md")
|
||||
content, err := vfs.ReadFile(skillPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
skill := string(content)
|
||||
require.Contains(t, skill, "文件导入/导出转 lark-drive")
|
||||
require.Contains(t, skill, "本地文件与 Base 之间的导入/导出转 `lark-drive`")
|
||||
require.Contains(t, skill, "在线复制走 `+base-copy`")
|
||||
require.NotContains(t, skill, "--only-schema")
|
||||
require.NotContains(t, skill, "--output-dir")
|
||||
require.NotContains(t, skill, "/tmp/")
|
||||
}
|
||||
@@ -1,17 +1,21 @@
|
||||
# Base CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 78 leaf commands
|
||||
- Covered: 22
|
||||
- Coverage: 28.2%
|
||||
- Denominator: 87 leaf commands
|
||||
- Covered: 28
|
||||
- Coverage: 32.2%
|
||||
|
||||
## Summary
|
||||
- TestBase_BasicWorkflow: proves `+base-create`, `+base-get`, `+table-create`, `+table-get`, and `+table-list`; key `t.Run(...)` proof points are `get base as bot`, `get table as bot`, and `list tables and find created table as bot`.
|
||||
- TestBaseBlockDryRun: proves the five `+base-block-*` shortcuts request shapes without touching live data.
|
||||
- TestBaseFieldCreateDryRunArrayCompat: proves `+field-create` dry-run request shape for the internal JSON-array compatibility path.
|
||||
- TestBaseFormQuestionsCreateDryRun: proves `+form-questions-create` preserves its POST body and renders the existing-question guard in command help.
|
||||
- TestBaseFormDetailDryRun / TestBaseFormSubmitDryRun: prove shared-form detail and submission request shapes.
|
||||
- TestBaseDashboardBlockGetDataDryRun: proves dashboard block data request shapes and identifier handling.
|
||||
- TestBaseRecordBatchUpdatePerRecordDryRun: proves `+record-batch-update` preserves the per-record `update_records` request shape.
|
||||
- TestBaseRecordBatchUpdatePerRecordWorkflow: creates two records, updates different field types in one request, asserts the minimal response contract, reads both records back, verifies a missing record ID is not prevalidated, and cleans up the temporary Base.
|
||||
- TestBase_RoleWorkflow: proves `+advperm-enable`, `+role-create`, `+role-list`, `+role-get`, and `+role-update`; key `t.Run(...)` proof points are `list as bot`, `get as bot`, and `update as bot`.
|
||||
- TestBaseFormListDryRun_UsesBaseAndTableIdentifiers: proves `+form-list` dry-run request shape uses Base and table identifiers in the endpoint.
|
||||
- TestBaseFormQuestionsCreateVisibleRuleDryRun / TestBaseFormQuestionsUpdateVisibleRuleDryRun: prove `+form-questions-create` / `+form-questions-update` dry-run request shape and that the optional `visible_rule` display condition is transcribed verbatim into the request body.
|
||||
- Cleanup note: `+table-delete` and `+role-delete` only run in cleanup and are intentionally left uncovered.
|
||||
- Blocked area: dashboard, field, most record operations, form, view, and workflow operations still lack deterministic create/read/update workflows in this suite.
|
||||
@@ -34,6 +38,7 @@
|
||||
| ✕ | base +dashboard-block-create | shortcut | | none | dashboard workflows not covered |
|
||||
| ✕ | base +dashboard-block-delete | shortcut | | none | dashboard workflows not covered |
|
||||
| ✕ | base +dashboard-block-get | shortcut | | none | dashboard workflows not covered |
|
||||
| ✓ | base +dashboard-block-get-data | shortcut | base_dashboard_block_get_data_dryrun_test.go | `--base-token`; `--dashboard-id`; `--block-id`; dry-run only | request shape and identifier handling |
|
||||
| ✕ | base +dashboard-block-list | shortcut | | none | dashboard workflows not covered |
|
||||
| ✕ | base +dashboard-block-update | shortcut | | none | dashboard workflows not covered |
|
||||
| ✕ | base +dashboard-create | shortcut | | none | dashboard workflows not covered |
|
||||
@@ -50,12 +55,14 @@
|
||||
| ✕ | base +field-update | shortcut | | none | field workflows not covered |
|
||||
| ✕ | base +form-create | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-delete | shortcut | | none | form workflows not covered |
|
||||
| ✓ | base +form-detail | shortcut | base_form_detail_dryrun_test.go::TestBaseFormDetailDryRun | `--share-token`; dry-run only | shared-form request shape |
|
||||
| ✕ | base +form-get | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-list | shortcut | | none | form workflows not covered |
|
||||
| ✓ | base +form-questions-create | shortcut | TestBaseFormQuestionsCreateVisibleRuleDryRun | questions[].visible_rule | dry-run: request shape + visible_rule body passthrough |
|
||||
| ✓ | base +form-list | shortcut | base_form_detail_dryrun_test.go::TestBaseFormListDryRun_UsesBaseAndTableIdentifiers | `--base-token`; `--table-id`; dry-run only | request shape only |
|
||||
| ✓ | base +form-questions-create | shortcut | TestBaseFormQuestionsCreateVisibleRuleDryRun; base_form_questions_create_dryrun_test.go | questions[].visible_rule; dry-run | request body, visible_rule passthrough, and help guard covered |
|
||||
| ✕ | base +form-questions-delete | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-questions-list | shortcut | | none | form workflows not covered |
|
||||
| ✓ | base +form-questions-update | shortcut | TestBaseFormQuestionsUpdateVisibleRuleDryRun | questions[].visible_rule | dry-run: request shape + visible_rule body passthrough |
|
||||
| ✓ | base +form-submit | shortcut | base_form_submit_dryrun_test.go::TestBaseFormSubmitDryRun | `--share-token`; `--json`; dry-run only | submission request shape |
|
||||
| ✕ | base +form-update | shortcut | | none | form workflows not covered |
|
||||
| ✓ | base +record-batch-create | shortcut | base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.create_records` | seeds heterogeneous live workflow records |
|
||||
| ✓ | base +record-batch-update | shortcut | base_record_batch_update_dryrun_test.go::TestBaseRecordBatchUpdatePerRecordDryRun; base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.update_records`; dry-run + live | heterogeneous select/number update with write-back verification |
|
||||
@@ -64,6 +71,7 @@
|
||||
| ✕ | base +record-history-list | shortcut | | none | record workflows not covered |
|
||||
| ✕ | base +record-list | shortcut | | none | record workflows not covered |
|
||||
| ✕ | base +record-search | shortcut | | none | record workflows not covered |
|
||||
| ✕ | base +record-share-link-create | shortcut | | none | record workflows not covered |
|
||||
| ✓ | base +record-upload-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/upload | dry-run only | request shape only |
|
||||
| ✓ | base +record-download-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/download | dry-run only | request shape only |
|
||||
| ✓ | base +record-remove-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/remove | dry-run only | request shape only |
|
||||
@@ -78,6 +86,8 @@
|
||||
| ✓ | base +table-get | shortcut | base_basic_workflow_test.go::TestBase_BasicWorkflow/get table as bot | `--base-token`; `--table-id` | |
|
||||
| ✓ | base +table-list | shortcut | base_basic_workflow_test.go::TestBase_BasicWorkflow/list tables and find created table as bot | `--base-token` | |
|
||||
| ✕ | base +table-update | shortcut | | none | no rename workflow yet |
|
||||
| ✕ | base +title-resolve | shortcut | | none | resolver workflow not covered |
|
||||
| ✕ | base +url-resolve | shortcut | | none | resolver workflow not covered |
|
||||
| ✕ | base +view-create | shortcut | | none | view workflows not covered |
|
||||
| ✕ | base +view-delete | shortcut | | none | view workflows not covered |
|
||||
| ✕ | base +view-get | shortcut | | none | view workflows not covered |
|
||||
|
||||
@@ -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