Compare commits

..

2 Commits

Author SHA1 Message Date
zhengkenghong
60e6bc2f3b feat(whiteboard): route node update through batch_update
Co-authored-by: TRAE CLI <noreply@bytedance.com>
2026-07-31 17:18:48 +08:00
zhengkenghong
1fd29e75a6 feat: add whiteboard node shortcuts
Add whiteboard node create, update, and delete shortcuts with focused dry-run and unit coverage. Document the new node-level operations in the embedded lark-whiteboard skill and split node shortcut unit tests by command for maintainability.

Co-authored-by: TRAE CLI <noreply@bytedance.com>
2026-07-31 17:18:45 +08:00
46 changed files with 1818 additions and 874 deletions

View File

@@ -2,35 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.81] - 2026-07-31
### Features
- support visible_rule for form questions (#1891)
- **contact**: add bot search shortcut (#2083)
- add SXSD schema validation to Slides lint (#2103)
- **drive**: add comment-operation shortcuts (#1898)
- **drive**: extend permission shortcuts for Miaoda (#2070)
- **apps**: add cache debug commands (+cache-get/-delete/-clear) (#1896)
- support source file preview artifacts (#2085)
### Bug Fixes
- **contact**: stop bot match segments carrying tags or empty entries (#2115)
- **base**: resolve Base URL block types accurately (#2099)
- **drive**: use title for default download filename (#2089)
- drop stale target version from root upgrade prompt (#2100)
### Documentation
- **calendar**: warn against container-default timezone in time conversion (#2104)
- **calendar**: confirm scope before editing recurring events (#2119)
- **base**: clarify form and file operation routing (#2110)
### Misc
- add protected public domain allowlists (#2111)
## [v1.0.80] - 2026-07-29
### Features
@@ -1751,7 +1722,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.81]: https://github.com/larksuite/cli/releases/tag/v1.0.81
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78

View File

@@ -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
})
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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"

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@larksuite/cli",
"version": "1.0.81",
"version": "1.0.80",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.81",
"version": "1.0.80",
"cpu": [
"x64",
"arm64",

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.81",
"version": "1.0.80",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -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.

View File

@@ -202,7 +202,7 @@ var DriveDownload = common.Shortcut{
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return withDriveDownloadForbiddenPreviewHint(wrapDriveNetworkErr(err, "download failed: %s", err), fileToken)
return wrapDriveNetworkErr(err, "download failed: %s", err)
}
defer resp.Body.Close()

View File

@@ -5,8 +5,6 @@ package drive
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
@@ -23,30 +21,6 @@ func wrapDriveNetworkErr(err error, format string, args ...any) error {
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
}
// withDriveDownloadForbiddenPreviewHint keeps the HTTP 403 network error from
// +download intact while giving callers a preview-based path to view content.
func withDriveDownloadForbiddenPreviewHint(err error, _ string) error {
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork || problem.Code != http.StatusForbidden {
return err
}
if strings.Contains(problem.Hint, "drive +preview") {
return err
}
hint := driveDownloadForbiddenPreviewHint()
if strings.TrimSpace(problem.Hint) == "" {
problem.Hint = hint
return err
}
problem.Hint = strings.TrimSpace(problem.Hint) + " " + hint
return err
}
func driveDownloadForbiddenPreviewHint() string {
const tokenArg = "<FILE_TOKEN>"
return fmt.Sprintf("Direct Drive download returned HTTP 403. To view file content through preview artifacts, try `lark-cli drive +preview --file-token %s --type source_file --output <path>`; for PDF/text/image preview choices, run `lark-cli drive +preview --file-token %s --list-only`.", tokenArg, tokenArg)
}
// driveInputStatError maps a FileIO.Stat/Open error for input file validation
// to a typed validation error:
// - Path validation failures → "unsafe file path: ..."

View File

@@ -1580,84 +1580,6 @@ func TestDriveDownloadAllowsOverwriteFlag(t *testing.T) {
}
}
func TestDriveDownloadHTTP403SuggestsPreview(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/file_403/download",
Status: http.StatusForbidden,
RawBody: []byte("permission denied"),
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveDownload, []string{
"+download",
"--file-token", "file_403",
"--output", "blocked.md",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected HTTP 403 error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Category != errs.CategoryNetwork {
t.Fatalf("category=%q, want network", problem.Category)
}
if problem.Code != http.StatusForbidden {
t.Fatalf("code=%d, want %d", problem.Code, http.StatusForbidden)
}
if !strings.Contains(problem.Hint, "drive +preview") {
t.Fatalf("hint=%q, want preview guidance", problem.Hint)
}
if strings.Contains(problem.Hint, "file_403") {
t.Fatalf("hint=%q, want placeholder file token", problem.Hint)
}
if !strings.Contains(problem.Hint, "--file-token <FILE_TOKEN>") {
t.Fatalf("hint=%q, want file token placeholder", problem.Hint)
}
if !strings.Contains(problem.Hint, "--type source_file") || !strings.Contains(problem.Hint, "--output <path>") {
t.Fatalf("hint=%q, want source_file output command", problem.Hint)
}
}
func TestDriveDownloadHTTP404DoesNotSuggestPreview(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/file_missing/download",
Status: http.StatusNotFound,
RawBody: []byte("not found"),
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveDownload, []string{
"+download",
"--file-token", "file_missing",
"--output", "missing.md",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected HTTP 404 error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Code != http.StatusNotFound {
t.Fatalf("code=%d, want %d", problem.Code, http.StatusNotFound)
}
if strings.Contains(problem.Hint, "drive +preview") {
t.Fatalf("hint=%q, want no preview guidance for non-403", problem.Hint)
}
}
func TestDriveDownloadDefaultOutputPathSanitizesSlashOnlyNames(t *testing.T) {
header := http.Header{
"Content-Disposition": []string{`attachment; filename="////"`},

View File

@@ -16,13 +16,13 @@ import (
var DrivePreview = common.Shortcut{
Service: "drive",
Command: "+preview",
Description: "View or download Drive file content, or list and fetch available preview artifacts",
Description: "List or download available preview artifacts for a Drive file",
Risk: "read",
Scopes: []string{"drive:file:download"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "Drive file token", Required: true},
{Name: "type", Desc: "preview type to download: pdf | html | text | image | source_file"},
{Name: "type", Desc: "preview type to download: pdf | html | text | image | source"},
{Name: "version", Desc: "optional file version"},
{Name: "list-only", Type: "bool", Desc: "list preview candidates without downloading"},
{Name: "output", Desc: "local output path for downloaded preview"},
@@ -40,25 +40,6 @@ var DrivePreview = common.Shortcut{
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
fileToken := runtime.Str("file-token")
version := strings.TrimSpace(runtime.Str("version"))
requestedType := strings.TrimSpace(runtime.Str("type"))
if requestedType == "source_file" {
downloadParams := map[string]interface{}{
"preview_type": drivePreviewTypeSourceFile,
}
if version != "" {
downloadParams["version"] = version
}
return common.NewDryRunAPI().
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("Download the source file artifact").
Params(downloadParams).
Set("file_token", fileToken).
Set("mode", "download").
Set("requested_type", requestedType).
Set("selected_type", "source_file").
Set("selected_type_code", drivePreviewTypeSourceFile).
Set("output", runtime.Str("output"))
}
body := map[string]interface{}{}
if version != "" {
body["version"] = version
@@ -86,7 +67,7 @@ var DrivePreview = common.Shortcut{
Desc("[2] Download the requested preview after selecting a matching candidate from preview_result").
Params(downloadParams).
Set("mode", "download").
Set("requested_type", requestedType).
Set("requested_type", runtime.Str("type")).
Set("output", runtime.Str("output"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -101,25 +82,9 @@ var DrivePreview = common.Shortcut{
body["version"] = version
}
if requestedType == "source_file" {
fmt.Fprintf(runtime.IO().ErrOut, "Downloading source file artifact: %s\n", common.MaskToken(fileToken))
result, err := downloadDrivePreviewArtifact(ctx, runtime, fileToken, drivePreviewTypeSourceFile, version, outputPath, ifExists, drivePreviewFallbackExt("source_file"))
if err != nil {
return err
}
result["mode"] = "download"
result["file_token"] = fileToken
result["selected_type"] = "source_file"
runtime.Out(result, nil)
return nil
}
fmt.Fprintf(runtime.IO().ErrOut, "Fetching preview candidates: %s\n", common.MaskToken(fileToken))
data, candidates, err := fetchDrivePreviewCandidates(runtime, fileToken, body)
if err != nil {
if runtime.Bool("list-only") {
return withDrivePreviewSourceFileHint(err)
}
return err
}
if runtime.Bool("list-only") {

View File

@@ -27,8 +27,6 @@ const (
drivePreviewIfExistsError = "error"
drivePreviewIfExistsOverwrite = "overwrite"
drivePreviewIfExistsRename = "rename"
drivePreviewTypeSourceFile = "16"
drivePreviewSourceFileHint = "Preview candidates are unavailable for this file. To fetch the source file artifact, rerun with --type source_file --output <path>."
)
type drivePreviewCandidate struct {
@@ -90,9 +88,7 @@ var drivePreviewMimeToExt = map[string]string{
"image/webp": ".webp",
"text/csv": ".csv",
"text/html": ".html",
"text/markdown": ".md",
"text/plain": ".txt",
"text/x-markdown": ".md",
"text/xml": ".xml",
"video/mp4": ".mp4",
"application/octet-stream": "",
@@ -468,7 +464,7 @@ func downloadDrivePreviewArtifactWithParams(ctx context.Context, runtime *common
}
defer resp.Body.Close()
finalPath, _, err := resolveDrivePreviewOutputPath(runtime, outputPath, resp.Header, fallbackExt, ifExists, fileToken)
finalPath, _, err := resolveDrivePreviewOutputPath(runtime, outputPath, resp.Header, fallbackExt, ifExists)
if err != nil {
return nil, err
}
@@ -496,8 +492,8 @@ func downloadDrivePreviewArtifactWithParams(ctx context.Context, runtime *common
// resolveDrivePreviewOutputPath finalizes the save path, applying extension
// inference and the selected collision policy.
func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, ifExists, fallbackName string) (string, *driveExtensionResolution, error) {
finalPath, resolution := resolveDrivePreviewOutputPathName(runtime, outputPath, header, fallbackExt, fallbackName)
func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, ifExists string) (string, *driveExtensionResolution, error) {
finalPath, resolution := autoAppendDrivePreviewExtension(outputPath, header, fallbackExt)
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
}
@@ -526,32 +522,6 @@ func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath st
}
}
func resolveDrivePreviewOutputPathName(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, fallbackName string) (string, *driveExtensionResolution) {
if drivePreviewOutputIsDirectory(runtime, outputPath) {
fileName, resolution := drivePreviewDefaultFileName(header, fallbackExt, fallbackName)
return filepath.Join(outputPath, fileName), resolution
}
return autoAppendDrivePreviewExtension(outputPath, header, fallbackExt)
}
func drivePreviewOutputIsDirectory(runtime *common.RuntimeContext, outputPath string) bool {
if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, "\\") {
return true
}
info, err := runtime.FileIO().Stat(outputPath)
return err == nil && info.IsDir()
}
func drivePreviewDefaultFileName(header http.Header, fallbackExt, fallbackName string) (string, *driveExtensionResolution) {
name := driveDownloadNormalizeFileName(larkcore.FileNameByHeader(header))
if name == "" {
name = driveDownloadNormalizeFileName(fallbackName)
}
name = sanitizeExportFileName(name, "preview")
name, resolution := autoAppendDrivePreviewExtension(name, header, fallbackExt)
return name, resolution
}
// nextAvailableDrivePreviewPath finds the first unused "name (n)" variant for a
// target output path.
func nextAvailableDrivePreviewPath(fio fileio.FileIO, path string) (string, error) {
@@ -586,15 +556,6 @@ func autoAppendDrivePreviewExtension(outputPath string, header http.Header, fall
if filepath.Ext(outputPath) == "." {
normalizedPath = strings.TrimSuffix(outputPath, ".")
}
if fallbackExt == "" {
if resolution := drivePreviewExtensionByContentDisposition(header); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
if resolution := drivePreviewExtensionByContentType(header.Get("Content-Type")); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
return normalizedPath, nil
}
if resolution := drivePreviewExtensionByContentType(header.Get("Content-Type")); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
@@ -843,36 +804,6 @@ func wrapDrivePreviewNotReady(fileToken, requested string, candidate drivePrevie
return errs.NewValidationError(errs.SubtypeFailedPrecondition, reason).WithHint(hint).WithParam("--type")
}
// withDrivePreviewSourceFileHint adds source_file guidance to preview candidate
// API failures without changing their classification or server diagnostics.
func withDrivePreviewSourceFileHint(err error) error {
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryAPI {
return err
}
if problem.Retryable || problem.Subtype == errs.SubtypeRateLimit {
return err
}
if strings.Contains(problem.Hint, "--type source_file") {
return err
}
if !isDrivePreviewCandidatesUnavailableProblem(problem) {
return err
}
if strings.TrimSpace(problem.Hint) == "" {
problem.Hint = drivePreviewSourceFileHint
return err
}
problem.Hint = strings.TrimSpace(problem.Hint) + " " + drivePreviewSourceFileHint
return err
}
func isDrivePreviewCandidatesUnavailableProblem(problem *errs.Problem) bool {
return problem != nil &&
problem.Code == 1 &&
strings.Contains(problem.Message, "mGetFilePreviewCore failed")
}
// wrapDriveCoverUnavailable builds a validation error for an unknown cover
// spec.
func wrapDriveCoverUnavailable(requested string) error {

View File

@@ -147,63 +147,6 @@ func TestDrivePreviewDownloadUsesResolvedTypeCodeAndRenamePolicy(t *testing.T) {
}
}
// TestDrivePreviewSourceFileDirectDownloadSkipsPreviewResult verifies
// source_file downloads the source file artifact without first fetching preview
// candidates.
func TestDrivePreviewSourceFileDirectDownloadSkipsPreviewResult(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/file_source/preview_download?preview_type=16",
Status: 200,
Body: []byte("# markdown\n"),
Headers: http.Header{
"Content-Disposition": []string{`attachment; filename="README.md"`},
"Content-Type": []string{"text/plain; charset=utf-8"},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_source",
"--type", "source_file",
"--output", "artifacts/",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if _, ok := data["requested_type"]; ok {
t.Fatalf("requested_type should be omitted from execute output: %#v", data)
}
if got := data["selected_type"]; got != "source_file" {
t.Fatalf("selected_type=%v, want source_file", got)
}
if _, ok := data["selected_type_code"]; ok {
t.Fatalf("selected_type_code should be omitted from execute output: %#v", data)
}
resolvedTmpDir, err := filepath.EvalSymlinks(tmpDir)
if err != nil {
t.Fatalf("EvalSymlinks() error: %v", err)
}
wantPath := filepath.Join(resolvedTmpDir, "artifacts", "README.md")
if got := data["output_path"]; got != wantPath {
t.Fatalf("output_path=%v, want %s", got, wantPath)
}
gotBody, err := os.ReadFile(wantPath)
if err != nil {
t.Fatalf("ReadFile(%q) error: %v", wantPath, err)
}
if string(gotBody) != "# markdown\n" {
t.Fatalf("saved body=%q, want markdown source", string(gotBody))
}
}
// TestDrivePreviewRejectsUnavailableType verifies unavailable preview types
// return an actionable validation error.
func TestDrivePreviewRejectsUnavailableType(t *testing.T) {
@@ -491,72 +434,6 @@ func TestDrivePreviewDryRunIncludesVersionAndMode(t *testing.T) {
}
}
// TestDrivePreviewDryRunSourceFileDocumentsDirectDownload verifies source_file
// dry-run documents the direct source artifact download path.
func TestDrivePreviewDryRunSourceFileDocumentsDirectDownload(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
"file-token": "file_source",
"type": "source_file",
"version": "7",
"output": "source",
}, nil)
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
if got := data["mode"]; got != "download" {
t.Fatalf("mode=%v, want download", got)
}
if got := data["requested_type"]; got != "source_file" {
t.Fatalf("requested_type=%v, want source_file", got)
}
if got := data["selected_type"]; got != "source_file" {
t.Fatalf("selected_type=%v, want source_file", got)
}
if got := data["selected_type_code"]; got != drivePreviewTypeSourceFile {
t.Fatalf("selected_type_code=%v, want %s", got, drivePreviewTypeSourceFile)
}
api, _ := data["api"].([]interface{})
if len(api) != 1 {
t.Fatalf("len(api)=%d, want 1", len(api))
}
call, _ := api[0].(map[string]interface{})
if got := call["method"]; got != "GET" {
t.Fatalf("method=%v, want GET", got)
}
if got := call["url"]; got != "/open-apis/drive/v1/medias/file_source/preview_download" {
t.Fatalf("url=%v, want preview_download", got)
}
params, _ := call["params"].(map[string]interface{})
if got := params["preview_type"]; got != drivePreviewTypeSourceFile {
t.Fatalf("params.preview_type=%v, want %s", got, drivePreviewTypeSourceFile)
}
if got := params["version"]; got != "7" {
t.Fatalf("params.version=%v, want 7", got)
}
}
// TestDrivePreviewDryRunSourceAliasUsesPreviewCandidates verifies only the
// explicit source_file request bypasses preview_result.
func TestDrivePreviewDryRunSourceAliasUsesPreviewCandidates(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
"file-token": "file_source",
"type": "source",
"output": "source",
}, nil)
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
api, _ := data["api"].([]interface{})
if len(api) != 2 {
t.Fatalf("len(api)=%d, want 2", len(api))
}
call, _ := api[0].(map[string]interface{})
if got := call["url"]; got != "/open-apis/drive/v1/medias/file_source/preview_result" {
t.Fatalf("url=%v, want preview_result", got)
}
if _, ok := data["selected_type_code"]; ok {
t.Fatalf("selected_type_code should be omitted for non-source_file dry-run: %#v", data)
}
}
// TestDrivePreviewDryRunListOmitsBodyWithoutVersion verifies list-mode DryRun
// omits the request body when no version is supplied.
func TestDrivePreviewDryRunListOmitsBodyWithoutVersion(t *testing.T) {
@@ -735,135 +612,6 @@ func TestDrivePreviewNotReadyReturnsFailedPrecondition(t *testing.T) {
}
}
// TestDrivePreviewListOnlyErrorAddsSourceFileHint verifies preview_result API
// failures keep server diagnostics while guiding callers to source_file.
func TestDrivePreviewListOnlyErrorAddsSourceFileHint(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/file_markdown/preview_result",
Body: map[string]interface{}{
"code": 1,
"msg": "fail:mGetFilePreviewCore failed",
"log_id": "log-preview-result",
"error": map[string]interface{}{
"troubleshooter": "https://open.feishu.cn/document/troubleshoot/preview-result",
"details": []interface{}{
map[string]interface{}{"value": "server preview_result detail"},
},
},
},
})
err := mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_markdown",
"--list-only",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected preview_result error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Category != errs.CategoryAPI {
t.Fatalf("category=%q, want api", problem.Category)
}
if problem.Code != 1 {
t.Fatalf("code=%d, want 1", problem.Code)
}
if problem.LogID != "log-preview-result" {
t.Fatalf("log_id=%q, want log-preview-result", problem.LogID)
}
if problem.Troubleshooter != "https://open.feishu.cn/document/troubleshoot/preview-result" {
t.Fatalf("troubleshooter=%q, want passthrough", problem.Troubleshooter)
}
if !strings.Contains(problem.Hint, "server preview_result detail") {
t.Fatalf("hint=%q, want server detail preserved", problem.Hint)
}
if !strings.Contains(problem.Hint, "--type source_file") || !strings.Contains(problem.Hint, "--output") {
t.Fatalf("hint=%q, want source_file output guidance", problem.Hint)
}
}
// TestDrivePreviewListOnlyRateLimitKeepsOriginalHint verifies retryable API
// errors are not reframed as source_file recovery.
func TestDrivePreviewListOnlyRateLimitKeepsOriginalHint(t *testing.T) {
err := withDrivePreviewSourceFileHint(errs.NewAPIError(errs.SubtypeRateLimit, "request trigger frequency limit").WithCode(99991400).WithRetryable())
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Hint != "" {
t.Fatalf("hint=%q, want empty hint for rate limit", problem.Hint)
}
if !problem.Retryable {
t.Fatal("retryable=false, want true")
}
}
// TestDrivePreviewSourceFileHintGuards verifies source_file recovery guidance
// only rewrites eligible API errors and preserves existing source_file hints.
func TestDrivePreviewSourceFileHintGuards(t *testing.T) {
plainErr := errors.New("plain failure")
if got := withDrivePreviewSourceFileHint(plainErr); got != plainErr {
t.Fatalf("non-API error changed: got %T %v, want original", got, got)
}
for _, tt := range []struct {
name string
err *errs.APIError
want string
}{
{
name: "already has source file hint",
err: errs.NewAPIError(errs.SubtypeServerError, "preview_result failed").WithHint("rerun with --type source_file --output <path>"),
want: "rerun with --type source_file --output <path>",
},
{
name: "candidate core failure empty hint",
err: errs.NewAPIError(errs.SubtypeServerError, "fail:mGetFilePreviewCore failed").WithCode(1),
want: drivePreviewSourceFileHint,
},
{
name: "candidate core failure whitespace hint",
err: errs.NewAPIError(errs.SubtypeServerError, "fail:mGetFilePreviewCore failed").WithCode(1).WithHint(" \n\t "),
want: drivePreviewSourceFileHint,
},
{
name: "generic server error",
err: errs.NewAPIError(errs.SubtypeServerError, "preview_result failed"),
want: "",
},
{
name: "not found",
err: errs.NewAPIError(errs.SubtypeNotFound, "file not found").WithCode(1061044),
want: "",
},
{
name: "invalid parameters",
err: errs.NewAPIError(errs.SubtypeInvalidParameters, "invalid file token").WithCode(1063007),
want: "",
},
} {
t.Run(tt.name, func(t *testing.T) {
gotErr := withDrivePreviewSourceFileHint(tt.err)
if gotErr != tt.err {
t.Fatalf("API error pointer changed: got %T, want original", gotErr)
}
problem, ok := errs.ProblemOf(gotErr)
if !ok {
t.Fatalf("expected typed error, got %T: %v", gotErr, gotErr)
}
if problem.Hint != tt.want {
t.Fatalf("hint=%q, want %q", problem.Hint, tt.want)
}
})
}
}
// TestDriveCoverRejectsUnknownSpec verifies unsupported cover specs produce a
// validation error with available alternatives.
func TestDriveCoverRejectsUnknownSpec(t *testing.T) {
@@ -973,21 +721,6 @@ func TestDrivePreviewCommonHelpers(t *testing.T) {
if path != "cover.pdf" || fallback != nil {
t.Fatalf("explicit ext append = (%q, %+v), want unchanged path", path, fallback)
}
header = http.Header{}
header.Set("Content-Type", "text/plain")
header.Set("Content-Disposition", `attachment; filename="README.md"`)
path, fallback = autoAppendDrivePreviewExtension("source", header, "")
if path != "source.md" || fallback == nil || fallback.Source != "Content-Disposition" {
t.Fatalf("source_file append = (%q, %+v), want source.md from Content-Disposition", path, fallback)
}
header = http.Header{}
header.Set("Content-Type", "text/plain")
path, fallback = autoAppendDrivePreviewExtension("source", header, "")
if path != "source.txt" || fallback == nil || fallback.Source != "Content-Type" {
t.Fatalf("source_file content-type append = (%q, %+v), want source.txt from Content-Type", path, fallback)
}
}
// TestDrivePreviewMetadataAndPathResolution verifies metadata normalization
@@ -1018,7 +751,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
header := http.Header{}
header.Set("Content-Type", "application/pdf")
renamed, _, err := resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", drivePreviewIfExistsRename, "file_preview")
renamed, _, err := resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", drivePreviewIfExistsRename)
if err != nil {
t.Fatalf("resolveDrivePreviewOutputPath(rename) error: %v", err)
}
@@ -1026,7 +759,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
t.Fatalf("renamed=%q, want preview (1).pdf suffix", renamed)
}
_, _, err = resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", "keep", "file_preview")
_, _, err = resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", "keep")
if err == nil {
t.Fatal("expected invalid if-exists error, got nil")
}
@@ -1038,20 +771,6 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
t.Fatalf("param=%q, want --if-exists", validationErr.Param)
}
if err := os.Mkdir("artifacts", 0755); err != nil {
t.Fatalf("Mkdir() error: %v", err)
}
sourceHeader := http.Header{}
sourceHeader.Set("Content-Type", "text/plain")
sourceHeader.Set("Content-Disposition", `attachment; filename="README.md"`)
dirOutput, _, err := resolveDrivePreviewOutputPath(runtime, "artifacts", sourceHeader, "", drivePreviewIfExistsError, "file_source")
if err != nil {
t.Fatalf("resolveDrivePreviewOutputPath(directory) error: %v", err)
}
if !strings.HasSuffix(dirOutput, filepath.Join("artifacts", "README.md")) {
t.Fatalf("dirOutput=%q, want artifacts/README.md suffix", dirOutput)
}
unusedPath, err := nextAvailableDrivePreviewPath(runtime.FileIO(), "fresh.pdf")
if err != nil {
t.Fatalf("nextAvailableDrivePreviewPath(unused) error: %v", err)
@@ -1060,7 +779,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
t.Fatalf("unusedPath=%q, want fresh.pdf", unusedPath)
}
overwritten, _, err := resolveDrivePreviewOutputPath(runtime, "preview.pdf", header, ".pdf", drivePreviewIfExistsOverwrite, "file_preview")
overwritten, _, err := resolveDrivePreviewOutputPath(runtime, "preview.pdf", header, ".pdf", drivePreviewIfExistsOverwrite)
if err != nil {
t.Fatalf("resolveDrivePreviewOutputPath(overwrite) error: %v", err)
}
@@ -1072,7 +791,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
f.FileIOProvider = &statErrorProvider{inner: f.FileIOProvider, err: fs.ErrPermission}
runtimeWithStatErr := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
runtimeWithStatErr.Factory = f
_, _, err = resolveDrivePreviewOutputPath(runtimeWithStatErr, "blocked.pdf", header, ".pdf", drivePreviewIfExistsError, "file_preview")
_, _, err = resolveDrivePreviewOutputPath(runtimeWithStatErr, "blocked.pdf", header, ".pdf", drivePreviewIfExistsError)
if err == nil {
t.Fatal("expected stat permission error, got nil")
}
@@ -1157,6 +876,7 @@ func TestDrivePreviewAliasAndAvailabilityHelpers(t *testing.T) {
if got := normalizeDrivePreviewRequest(" Source File "); got != "source_file" {
t.Fatalf("normalizeDrivePreviewRequest()=%q, want source_file", got)
}
aliases := previewAliasesForCandidate(drivePreviewCandidate{TypeCode: "1"})
if len(aliases) == 0 || aliases[0] != "image" {
t.Fatalf("previewAliasesForCandidate()=%v, want image alias", aliases)

View File

@@ -32,7 +32,6 @@ const (
markdownUploadPrepareAction = "initialize markdown multipart upload failed"
markdownUploadFinishAction = "finalize markdown multipart upload failed"
markdownFetchNameAction = "fetch existing markdown file name failed"
markdownSourceFilePreviewType = "16"
)
var markdownUploadRetryBackoffs = []time.Duration{
@@ -193,14 +192,9 @@ func resolveMarkdownOverwriteFileName(runtime *common.RuntimeContext, spec markd
}
func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken string) (*http.Response, error) {
query, err := markdownSourceFilePreviewQuery("", "")
if err != nil {
return nil, err
}
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", validate.EncodePathSegment(fileToken)),
QueryParams: query,
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return nil, wrapMarkdownDownloadError(err)
@@ -236,15 +230,15 @@ func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec)
return size, nil
}
func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeContext, fileToken, version, versionParam string) (*http.Response, string, error) {
query, err := markdownSourceFilePreviewQuery(version, versionParam)
if err != nil {
return nil, "", err
}
func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (*http.Response, string, error) {
req := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", validate.EncodePathSegment(fileToken)),
QueryParams: query,
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
}
if strings.TrimSpace(version) != "" {
req.QueryParams = larkcore.QueryParams{
"version": []string{strings.TrimSpace(version)},
}
}
resp, err := runtime.DoAPIStream(ctx, req)
@@ -254,58 +248,6 @@ func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeCon
return resp, fileNameFromDownloadHeader(resp.Header, fileToken+".md"), nil
}
func markdownSourceFilePreviewQuery(version, versionParam string) (larkcore.QueryParams, error) {
if err := validateMarkdownSourceFilePreviewVersion(version, versionParam); err != nil {
return nil, err
}
query := larkcore.QueryParams{
"preview_type": []string{markdownSourceFilePreviewType},
}
if version != "" {
query["version"] = []string{version}
}
return query, nil
}
func markdownSourceFilePreviewDryRunParams(version, versionParam string) (map[string]interface{}, error) {
if err := validateMarkdownSourceFilePreviewVersion(version, versionParam); err != nil {
return nil, err
}
params := map[string]interface{}{
"preview_type": markdownSourceFilePreviewType,
}
if version != "" {
params["version"] = version
}
return params, nil
}
func markdownSourceFilePreviewDryRunParamsForValidatedVersion(version, versionParam string) map[string]interface{} {
params, err := markdownSourceFilePreviewDryRunParams(version, versionParam)
if err != nil {
// Shortcut validation runs before DryRun. If a caller bypasses that
// contract, preserve the supplied value instead of silently dropping it.
params = map[string]interface{}{
"preview_type": markdownSourceFilePreviewType,
"version": version,
}
}
return params
}
func validateMarkdownSourceFilePreviewVersion(version, flagName string) error {
if version == "" {
return nil
}
if strings.TrimSpace(version) != "" {
return nil
}
if flagName == "" {
flagName = "--version"
}
return markdownValidationParamError(flagName, "%s cannot be empty", flagName)
}
func markdownDryRunFileField(spec markdownUploadSpec) string {
if spec.FilePath != "" {
return "@" + spec.FilePath

View File

@@ -112,8 +112,9 @@ func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffS
}
func validateMarkdownDiffVersionValue(value, flagName string) error {
if err := validateMarkdownSourceFilePreviewVersion(value, flagName); err != nil {
return err
value = strings.TrimSpace(value)
if value == "" {
return markdownValidationParamError(flagName, "%s cannot be empty", flagName)
}
if !markdownDiffVersionRe.MatchString(value) {
return markdownValidationParamError(flagName, "%s must be a numeric version string", flagName)
@@ -133,33 +134,31 @@ func markdownDiffDryRun(spec markdownDiffSpec) *common.DryRunAPI {
switch markdownDiffMode(spec) {
case markdownDiffModeRemoteVsLocal:
if spec.FromVersion != "" {
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[1] Download the specified remote Markdown source file preview artifact").
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the specified remote Markdown version").
Set("file_token", spec.FileToken).
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion(spec.FromVersion, "--from-version"))
Params(map[string]interface{}{"version": spec.FromVersion})
} else {
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[1] Download the latest remote Markdown source file preview artifact").
Set("file_token", spec.FileToken).
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", ""))
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the latest remote Markdown version").
Set("file_token", spec.FileToken)
}
dry.Set("local_file", spec.FilePath)
dry.Set("mode", markdownDiffModeRemoteVsLocal)
default:
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[1] Download the base remote Markdown source file preview artifact").
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the base remote Markdown version").
Set("file_token", spec.FileToken).
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion(spec.FromVersion, "--from-version"))
Params(map[string]interface{}{"version": spec.FromVersion})
if spec.ToVersion != "" {
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[2] Download the target remote Markdown source file preview artifact").
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[2] Download the target remote Markdown version").
Set("file_token", spec.FileToken).
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion(spec.ToVersion, "--to-version"))
Params(map[string]interface{}{"version": spec.ToVersion})
} else {
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[2] Download the latest remote Markdown source file preview artifact").
Set("file_token", spec.FileToken).
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", ""))
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[2] Download the latest remote Markdown version").
Set("file_token", spec.FileToken)
}
dry.Set("mode", markdownDiffModeRemoteVsRemote)
}
@@ -167,8 +166,8 @@ func markdownDiffDryRun(spec markdownDiffSpec) *common.DryRunAPI {
return dry
}
func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext, fileToken, version, versionParam string) (string, string, error) {
resp, fileName, err := openMarkdownDownloadVersion(ctx, runtime, fileToken, version, versionParam)
func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (string, string, error) {
resp, fileName, err := openMarkdownDownloadVersion(ctx, runtime, fileToken, version)
if err != nil {
return "", "", err
}
@@ -447,8 +446,8 @@ var MarkdownDiff = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateMarkdownDiffSpec(runtime, markdownDiffSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FromVersion: runtime.Str("from-version"),
ToVersion: runtime.Str("to-version"),
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
FilePath: strings.TrimSpace(runtime.Str("file")),
ContextLines: runtime.Int("context-lines"),
Format: runtime.Format,
@@ -457,8 +456,8 @@ var MarkdownDiff = common.Shortcut{
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return markdownDiffDryRun(markdownDiffSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FromVersion: runtime.Str("from-version"),
ToVersion: runtime.Str("to-version"),
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
FilePath: strings.TrimSpace(runtime.Str("file")),
ContextLines: runtime.Int("context-lines"),
})
@@ -466,8 +465,8 @@ var MarkdownDiff = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := markdownDiffSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FromVersion: runtime.Str("from-version"),
ToVersion: runtime.Str("to-version"),
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
FilePath: strings.TrimSpace(runtime.Str("file")),
ContextLines: runtime.Int("context-lines"),
}
@@ -488,7 +487,7 @@ var MarkdownDiff = common.Shortcut{
} else {
fromLabel += "@latest"
}
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion, "--from-version")
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
if err != nil {
return err
}
@@ -500,17 +499,17 @@ var MarkdownDiff = common.Shortcut{
}
default:
fromLabel = "a/" + spec.FileToken + "@version:" + spec.FromVersion
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion, "--from-version")
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
if err != nil {
return err
}
if spec.ToVersion != "" {
toLabel = "b/" + spec.FileToken + "@version:" + spec.ToVersion
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.ToVersion, "--to-version")
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.ToVersion)
} else {
toLabel = "b/" + spec.FileToken + "@latest"
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, "", "")
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, "")
}
if err != nil {
return err

View File

@@ -48,73 +48,6 @@ func TestMarkdownDiffRejectsToVersionWithoutFromVersion(t *testing.T) {
}
}
func TestMarkdownDiffRejectsBlankVersion(t *testing.T) {
tests := []struct {
name string
args []string
wantParam string
}{
{
name: "from version",
args: []string{
"+diff",
"--file-token", "box_md_diff",
"--from-version", " \t",
"--file", "./local.md",
},
wantParam: "--from-version",
},
{
name: "to version",
args: []string{
"+diff",
"--file-token", "box_md_diff",
"--from-version", "7633658129540910621",
"--to-version", " ",
},
wantParam: "--to-version",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
err := mountAndRunMarkdown(t, MarkdownDiff, tt.args, f, stdout)
requireMarkdownValidationParam(t, err, tt.wantParam)
if !strings.Contains(err.Error(), "cannot be empty") {
t.Fatalf("expected empty version validation error, got %v", err)
}
})
}
}
func TestMarkdownSourceFilePreviewParamsValidateAndPreserveVersion(t *testing.T) {
version := " 7633658129540910621 "
query, err := markdownSourceFilePreviewQuery(version, "--from-version")
if err != nil {
t.Fatalf("markdownSourceFilePreviewQuery() error: %v", err)
}
if got := query["version"]; len(got) != 1 || got[0] != version {
t.Fatalf("query version = %#v, want original %q", got, version)
}
params, err := markdownSourceFilePreviewDryRunParams(version, "--from-version")
if err != nil {
t.Fatalf("markdownSourceFilePreviewDryRunParams() error: %v", err)
}
if got := params["version"]; got != version {
t.Fatalf("dry-run version = %#v, want original %q", got, version)
}
_, err = markdownSourceFilePreviewQuery(" \n", "--from-version")
requireMarkdownValidationParam(t, err, "--from-version")
_, err = markdownSourceFilePreviewDryRunParams(" \t", "--to-version")
requireMarkdownValidationParam(t, err, "--to-version")
}
func TestMarkdownDiffMissingVersionAndFileNamesCandidateParams(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
@@ -146,7 +79,7 @@ func TestMarkdownDiffRemoteVsRemoteJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910621",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
Status: 200,
RawBody: []byte("# Title\n\n- alpha\n- beta\n"),
Headers: http.Header{
@@ -155,7 +88,7 @@ func TestMarkdownDiffRemoteVsRemoteJSON(t *testing.T) {
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910628",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
Status: 200,
RawBody: []byte("# Title\n\n- alpha\n- beta updated\n- gamma\n"),
Headers: http.Header{
@@ -218,7 +151,7 @@ func TestMarkdownDiffRemoteVsLocalPretty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
Status: 200,
RawBody: []byte("# Title\n\nhello old\n"),
Headers: http.Header{
@@ -258,7 +191,7 @@ func TestMarkdownDiffRejectsOversizedRemoteContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
Status: 200,
RawBody: bytes.Repeat([]byte("x"), markdownDiffMaxContentBytes+1),
})
@@ -285,7 +218,7 @@ func TestMarkdownDiffRejectsOversizedLocalContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
Status: 200,
RawBody: []byte("# Title\n"),
})
@@ -404,7 +337,7 @@ func TestMarkdownDiffRemoteVsRemoteJSONMultipleHunks(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910621",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
Status: 200,
RawBody: []byte("line1\nline2\nline3\nline4\nline5\nline6\n"),
Headers: http.Header{
@@ -413,7 +346,7 @@ func TestMarkdownDiffRemoteVsRemoteJSONMultipleHunks(t *testing.T) {
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910628",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
Status: 200,
RawBody: []byte("line1\nline2 changed\nline3\nline4\nline5 changed\nline6\n"),
Headers: http.Header{
@@ -465,13 +398,13 @@ func TestMarkdownDiffNoChangesPretty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910621",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
Status: 200,
RawBody: []byte("# Title\n"),
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
Status: 200,
RawBody: []byte("# Title\n"),
})
@@ -512,11 +445,8 @@ func TestMarkdownDiffDryRunRemoteVsLocal(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "/open-apis/drive/v1/medias/box_md_diff/preview_download") {
t.Fatalf("dry-run missing source preview download call: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `"preview_type": "16"`) {
t.Fatalf("dry-run missing source_file preview_type: %s", stdout.String())
if !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/:file_token/download") && !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/box_md_diff/download") {
t.Fatalf("dry-run missing download call: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `"local_file": "local.md"`) && !strings.Contains(stdout.String(), `"local_file": "./local.md"`) {
t.Fatalf("dry-run missing local file metadata: %s", stdout.String())

View File

@@ -5,10 +5,14 @@ package markdown
import (
"context"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -43,9 +47,8 @@ var MarkdownFetch = common.Shortcut{
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
dry := common.NewDryRunAPI().
Desc("download markdown source file preview artifact bytes; when --output is omitted the CLI returns content as UTF-8 text").
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", "")).
Desc("download markdown file bytes; when --output is omitted the CLI returns content as UTF-8 text").
GET("/open-apis/drive/v1/files/:file_token/download").
Set("file_token", runtime.Str("file-token"))
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
dry.Set("output", outputPath)
@@ -58,9 +61,12 @@ var MarkdownFetch = common.Shortcut{
fileToken := strings.TrimSpace(runtime.Str("file-token"))
outputPath := strings.TrimSpace(runtime.Str("output"))
resp, err := openMarkdownDownload(ctx, runtime, fileToken)
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return err
return wrapMarkdownDownloadError(err)
}
defer resp.Body.Close()

View File

@@ -62,9 +62,8 @@ var MarkdownPatch = common.Shortcut{
sizeThreshold := common.FormatSize(markdownSinglePartSizeLimit)
return common.NewDryRunAPI().
Desc("Download the current Markdown file, apply the replacement locally, and overwrite the file only when matches are found").
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[1] Download the current Markdown source file preview artifact").
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", "")).
GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the current Markdown content").
Set("file_token", spec.FileToken).
POST("/open-apis/drive/v1/metas/batch_query").
Desc("[2] Read current file metadata to preserve the existing file name before overwrite").

View File

@@ -85,12 +85,9 @@ func TestMarkdownPatchDryRunLiteral(t *testing.T) {
if got := len(dry.API); got != 6 {
t.Fatalf("api steps = %d, want 6", got)
}
if got := dry.API[0].URL; got != "/open-apis/drive/v1/medias/box_md_patch/preview_download" {
if got := dry.API[0].URL; got != "/open-apis/drive/v1/files/box_md_patch/download" {
t.Fatalf("download url = %q", got)
}
if got := dry.API[0].Params["preview_type"]; got != markdownSourceFilePreviewType {
t.Fatalf("download preview_type = %#v", got)
}
if got := dry.API[1].URL; got != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("metas url = %q", got)
}
@@ -123,7 +120,7 @@ func TestMarkdownPatchDryRunRegex(t *testing.T) {
if got := dry.Mode; got != markdownPatchModeRegex {
t.Fatalf("mode = %q, want %q", got, markdownPatchModeRegex)
}
if got := dry.API[0].Desc; !strings.Contains(got, "Download the current Markdown source file preview artifact") {
if got := dry.API[0].Desc; !strings.Contains(got, "Download the current Markdown content") {
t.Fatalf("download desc = %q", got)
}
if got := dry.API[3].Desc; !strings.Contains(got, "multipart overwrite upload") {
@@ -147,7 +144,7 @@ func TestMarkdownPatchReturnsSuccessWhenNothingMatches(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("# hello\n"),
})
@@ -190,7 +187,7 @@ func TestMarkdownPatchPrettyOutputWhenNothingMatches(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("# hello\n"),
})
@@ -227,7 +224,7 @@ func TestMarkdownPatchLiteralOverwrite(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("# TODO\nTODO\n"),
Headers: map[string][]string{
@@ -302,7 +299,7 @@ func TestMarkdownPatchPrettyOutputWhenUpdated(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("# TODO\n"),
Headers: map[string][]string{
@@ -363,7 +360,7 @@ func TestMarkdownPatchRegexOverwrite(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("Version: 12\nVersion: 34\n"),
})
@@ -432,7 +429,7 @@ func TestMarkdownPatchAllowsEmptyReplacement(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("hello world\n"),
})
@@ -481,7 +478,7 @@ func TestMarkdownPatchRejectsEmptyPatchedContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("hello\n"),
})
@@ -512,10 +509,9 @@ func decodeMarkdownEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]inter
type markdownPatchDryRunOutput struct {
Mode string `json:"mode"`
API []struct {
Desc string `json:"desc"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
Desc string `json:"desc"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}

View File

@@ -1984,7 +1984,7 @@ func TestMarkdownFetchReturnsContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2050,7 +2050,7 @@ func TestMarkdownFetchPrettyReturnsContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2078,7 +2078,7 @@ func TestMarkdownFetchSavesFile(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2122,7 +2122,7 @@ func TestMarkdownFetchRejectsExistingFileWithoutOverwrite(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2151,7 +2151,7 @@ func TestMarkdownFetchOverwritesExistingFileWhenRequested(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2189,7 +2189,7 @@ func TestMarkdownFetchSavesUsingRemoteNameWhenOutputIsExistingDirectory(t *testi
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2226,7 +2226,7 @@ func TestMarkdownFetchSavesUsingRemoteNameWhenOutputUsesDirectorySyntax(t *testi
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2260,7 +2260,7 @@ func TestMarkdownFetchPrettySavesFile(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2295,7 +2295,7 @@ func TestMarkdownFetchSaveFailure(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{

View File

@@ -14,6 +14,9 @@ func Shortcuts() []common.Shortcut {
WhiteboardUpdateOld,
WhiteboardExport,
WhiteboardQuery,
WhiteboardNodeCreate,
WhiteboardNodeUpdate,
WhiteboardNodeDelete,
}
}

View 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
}

View 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)
}

View 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,
}

View 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)
}
}

View 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,
}

View 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())
}
}

View 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,
}

View 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))
}
}
}

View File

@@ -43,7 +43,7 @@ metadata:
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history``drive +version-get``drive +version-revert``drive +version-delete`;这组命令同时支持 `--as user``--as bot`,自动化场景优先 `--as bot`
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`
- 用户要在云空间(云盘/云存储)里新建文件夹,优先使用 `lark-cli drive +create-folder`
- 用户要查看或下载文件内容,或者查看文件可用预览格式并获取 PDF / HTML / 文本 / 图片等转换预览产物,使用 `lark-cli drive +preview`
- 用户要查看某个文件有哪些可下载预览格式,或想下载 PDF / HTML / 文本 / 图片等预览产物,使用 `lark-cli drive +preview`
- 用户要获取某个文件的封面图,优先使用 `lark-cli drive +cover`;先 `--list-only` 看规格,再选 `--spec` 下载。
- 用户要导出云文档时,优先使用 `lark-cli drive +export --url '<文档 URL>' --file-extension <格式>`详细参数、Wiki token 和错误码处理见 [`references/lark-drive-export.md`](references/lark-drive-export.md)。
- 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token <wiki_token>`;不要误切到 `wiki` 域命令。
@@ -121,7 +121,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+upload`](references/lark-drive-upload.md) | 上传本地文件到 Drive 文件夹或 wiki 节点;修改/重写/更新已有文件时优先覆盖上传,而不是直接上传一个新文件。 |
| [`+create-folder`](references/lark-drive-create-folder.md) | 新建 Drive 文件夹,支持父文件夹与 bot 创建后自动授权。 |
| [`+download`](references/lark-drive-download.md) | 下载 Drive 文件到本地。 |
| [`+preview`](references/lark-drive-preview.md) | 查看或下载文件内容,或者查看文件可用预览格式并获取 PDF / HTML / 文本 / 图片等转换预览产物。 |
| [`+preview`](references/lark-drive-preview.md) | 查看或下载文件 PDF / HTML / 文本 / 图片等预览产物。 |
| [`+cover`](references/lark-drive-cover.md) | 查看或下载文件封面图规格。 |
| [`+status`](references/lark-drive-status.md) | 比较本地目录与 Drive 文件夹差异;默认按 SHA-256 精确比较,`--quick` 使用修改时间近似比较。 |
| [`+pull`](references/lark-drive-pull.md) | 从 Drive 拉取文件到本地目录,支持重复远端路径处理和增量模式。 |

View File

@@ -25,10 +25,6 @@ https://xxx.feishu.cn/drive/file/boxbc_xxx
file_token
```
## 排障
- 如果返回 `HTTP 403`,可以使用 [lark-drive-preview](lark-drive-preview.md) 下载源文件产物。
## 参考
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令

View File

@@ -2,24 +2,15 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、权限处理和安全规则。
查看或下载 Drive 文件内容,或列出并获取文件可用的预览产物。这个 shortcut 不猜测默认类型:
列出或下载 Drive 文件可用的预览产物。这个 shortcut 不猜测默认类型:
- 如果只需要查看或下载文件内容,或不关心 PDF/text/image 等转换预览,优先使用 `--type source_file --output <path>`
- 只想看候选项时,用 `--list-only`
- 如果需要服务端生成的预览效果,例如 doc/docx 的 PDF 版式预览,先用 `--list-only` 查看候选项,再按候选项选择 `--type pdf` / `text` / `image`
- 想下载时,必须显式传 `--type``--output`
- 如果 `--list-only` 没有可用预览候选项,或错误提示明确建议使用 `--type source_file`,可以改用 `--type source_file --output <path>` 查看文件内容资源不存在、token 无效等终态错误需要先修正输入
- 如果某个候选项还在生成中,会返回结构化错误并提示先重新 `--list-only`
### 命令
```bash
# 查看文件内容
lark-cli drive +preview \
--file-token "<FILE_TOKEN>" \
--type source_file \
--output ./artifacts/source
# 列出可用预览候选项
lark-cli drive +preview \
--file-token "<FILE_TOKEN>" \
@@ -87,7 +78,6 @@ lark-cli drive +preview \
- 不传 `--list-only` 时,必须显式传 `--type``--output`
- 不会隐式选择“第一个候选项”作为默认下载目标
- `--type source_file` 用于查看文件内容,不依赖 `--list-only` 返回的候选项;它适合读取或保存源内容,不等同于 PDF/text/image 等转换预览
- 候选项状态来自后端 `preview_status` 枚举,例如 `READY` / `PROCESSING` / `FAILED` / `NO_SUPPORT`
- 本地文件名在未显式带扩展名时,会结合响应头自动补扩展名

View File

@@ -2,7 +2,7 @@
name: lark-whiteboard
version: 1.0.0
description: >
飞书画板:查询和编辑飞书云文档中的画板。支持导出画板为预览图片、导出原始节点结构、使用多种格式更新画板内容。
飞书画板:查询和编辑飞书云文档中的画板。支持导出画板为预览图片、导出原始节点结构、使用多种格式更新画板内容,并支持按节点增量创建、更新和删除
当用户需要查看画板内容、导出画板图片、编辑画板时使用此 skill。不负责飞书云文档内容编辑lark-doc、文档内嵌电子表格/Baselark-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 删除已有节点;高风险写操作,执行前必须确认目标节点 |
---

View File

@@ -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#渲染--写入画板)。
## 示例

View File

@@ -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-rundry-run 只能验证请求结构, 不能证明节点语义一定可插入。
- 复杂图表继续走 `whiteboard-cli -> +update` 或 workflow 路径, 不要把 `+node-create` 当作默认创作入口。

View File

@@ -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 或要求定位依据。

View 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`, 除非用户明确要求替换整个画板。

View File

@@ -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)。

View File

@@ -93,55 +93,6 @@ func TestDrivePreviewDryRun_Download(t *testing.T) {
}
}
// TestDrivePreviewDryRun_SourceFile verifies source_file mode maps to a direct
// source artifact download request.
func TestDrivePreviewDryRun_SourceFile(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+preview",
"--file-token", "fileDryRunPreview",
"--type", "source_file",
"--version", "12",
"--output", "./artifacts/source",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := clie2e.DryRunGet(out, "api.#").Int(); got != 1 {
t.Fatalf("api count=%d, want 1\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method=%q, want GET\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/drive/v1/medias/fileDryRunPreview/preview_download" {
t.Fatalf("url=%q, want preview download endpoint\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.params.preview_type").String(); got != "16" {
t.Fatalf("preview_type=%q, want 16\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.params.version").String(); got != "12" {
t.Fatalf("version=%q, want 12\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "requested_type").String(); got != "source_file" {
t.Fatalf("requested_type=%q, want source_file\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "selected_type").String(); got != "source_file" {
t.Fatalf("selected_type=%q, want source_file\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "selected_type_code").String(); got != "16" {
t.Fatalf("selected_type_code=%q, want 16\nstdout:\n%s", got, out)
}
}
// TestDriveCoverDryRun_Download verifies cover dry-run request structure for
// download mode.
func TestDriveCoverDryRun_Download(t *testing.T) {

View File

@@ -35,41 +35,6 @@ func TestDrive_PreviewAndCoverWorkflow(t *testing.T) {
fileToken := uploadPreviewFixture(t, parentT, ctx, workDir, folderToken, sourceRelPath, "report.txt")
t.Run("source file download", func(t *testing.T) {
downloadDir := t.TempDir()
downloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+preview",
"--file-token", fileToken,
"--type", "source_file",
"--output", "./artifacts/report-source",
},
WorkDir: downloadDir,
DefaultAs: "bot",
})
require.NoError(t, err)
downloadResult.AssertExitCode(t, 0)
downloadResult.AssertStdoutStatus(t, true)
stdout := downloadResult.Stdout
if gjson.Get(stdout, "data.requested_type").Exists() {
t.Fatalf("requested_type should be omitted from execute output\nstdout:\n%s", stdout)
}
if got := gjson.Get(stdout, "data.selected_type").String(); got != "source_file" {
t.Fatalf("selected_type=%q, want source_file\nstdout:\n%s", got, stdout)
}
if gjson.Get(stdout, "data.selected_type_code").Exists() {
t.Fatalf("selected_type_code should be omitted from execute output\nstdout:\n%s", stdout)
}
outputPath := gjson.Get(stdout, "data.output_path").String()
require.NotEmpty(t, outputPath, "source file preview should return output_path")
data, readErr := os.ReadFile(outputPath)
require.NoError(t, readErr)
if string(data) != sourceContent {
t.Fatalf("source file preview content=%q want %q", string(data), sourceContent)
}
})
t.Run("preview list and download", func(t *testing.T) {
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{

View File

@@ -149,14 +149,11 @@ func TestMarkdownDiffDryRun_RemoteVsRemote(t *testing.T) {
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/medias/boxcnMarkdownDryRun/preview_download")
require.Equal(t, "remote_vs_remote", clie2e.DryRunGet(output, "mode").String(), output)
require.Equal(t, int64(2), clie2e.DryRunGet(output, "api.#").Int(), output)
require.Equal(t, "16", clie2e.DryRunGet(output, "api.0.params.preview_type").String(), output)
require.Equal(t, "7633658129540910621", clie2e.DryRunGet(output, "api.0.params.version").String(), output)
require.Equal(t, "16", clie2e.DryRunGet(output, "api.1.params.preview_type").String(), output)
require.Equal(t, "7633658129540910628", clie2e.DryRunGet(output, "api.1.params.version").String(), output)
require.Equal(t, int64(1), clie2e.DryRunGet(output, "context_lines").Int(), output)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, `"mode": "remote_vs_remote"`)
assert.Contains(t, output, `"version": "7633658129540910621"`)
assert.Contains(t, output, `"version": "7633658129540910628"`)
assert.Contains(t, output, `"context_lines": 1`)
}
func TestMarkdownDiffDryRun_RemoteVsLocal(t *testing.T) {
@@ -182,9 +179,8 @@ func TestMarkdownDiffDryRun_RemoteVsLocal(t *testing.T) {
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/medias/boxcnMarkdownDryRun/preview_download")
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, `"mode": "remote_vs_local"`)
assert.Contains(t, output, `"preview_type": "16"`)
assert.Contains(t, output, `"local_file": "./draft.md"`)
}
@@ -228,8 +224,7 @@ func TestMarkdownFetchDryRun_OutputFile(t *testing.T) {
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/medias/boxcnMarkdownDryRun/preview_download")
assert.Contains(t, output, `"preview_type": "16"`)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, `"output": "./copy.md"`)
}
@@ -310,8 +305,7 @@ func TestMarkdownPatchDryRun_Content(t *testing.T) {
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/medias/boxcnMarkdownDryRun/preview_download")
assert.Contains(t, output, `"preview_type": "16"`)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, "/open-apis/drive/v1/metas/batch_query")
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_prepare")

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}
}