Skip to content

Commit eac7c3b

Browse files
feat: Product CLI 4期 no-meego
Change-Id: If08f236c8ae351f92683f2b861cc999eb6f1d22d
1 parent 13411d9 commit eac7c3b

17 files changed

Lines changed: 2180 additions & 11 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,5 @@ app.log
4242
/server-demo
4343
.tmp/
4444
cover*.out
45+
46+
lark-env.sh

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ require (
1919
github.com/tidwall/gjson v1.18.0
2020
github.com/zalando/go-keyring v0.2.8
2121
golang.org/x/net v0.33.0
22+
golang.org/x/sync v0.15.0
2223
golang.org/x/sys v0.33.0
2324
golang.org/x/term v0.27.0
2425
golang.org/x/text v0.23.0
@@ -61,5 +62,4 @@ require (
6162
github.com/tidwall/match v1.1.1 // indirect
6263
github.com/tidwall/pretty v1.2.0 // indirect
6364
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
64-
golang.org/x/sync v0.15.0 // indirect
6565
)

shortcuts/base/base_form_detail.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package base
5+
6+
import (
7+
"context"
8+
9+
"github.com/larksuite/cli/shortcuts/common"
10+
)
11+
12+
var BaseFormDetail = common.Shortcut{
13+
Service: "base",
14+
Command: "+form-detail",
15+
Description: "Get form detail by share token",
16+
Risk: "read",
17+
Scopes: []string{"base:form:read"},
18+
AuthTypes: []string{"user", "bot"},
19+
HasFormat: true,
20+
Flags: []common.Flag{
21+
{Name: "share-token", Desc: "Form share token (share_token)", Required: true},
22+
},
23+
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
24+
return common.NewDryRunAPI().
25+
POST("/open-apis/base/v3/bases/tables/forms/detail").
26+
Body(map[string]interface{}{
27+
"share_token": runtime.Str("share-token"),
28+
})
29+
},
30+
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
31+
body := map[string]interface{}{
32+
"share_token": runtime.Str("share-token"),
33+
}
34+
35+
data, err := baseV3Call(runtime, "POST",
36+
baseV3Path("bases", "tables", "forms", "detail"), nil, body)
37+
if err != nil {
38+
return err
39+
}
40+
41+
runtime.Out(data, nil)
42+
return nil
43+
},
44+
}

shortcuts/base/base_form_submit.go

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package base
5+
6+
import (
7+
"context"
8+
"encoding/json"
9+
"errors"
10+
"fmt"
11+
"path/filepath"
12+
"sync"
13+
14+
"golang.org/x/sync/errgroup"
15+
16+
"github.com/larksuite/cli/extension/fileio"
17+
"github.com/larksuite/cli/internal/output"
18+
"github.com/larksuite/cli/internal/validate"
19+
"github.com/larksuite/cli/shortcuts/common"
20+
)
21+
22+
const (
23+
uploadAttachConcurrency = 5
24+
)
25+
26+
var BaseFormSubmit = common.Shortcut{
27+
Service: "base",
28+
Command: "+form-submit",
29+
Description: "Submit a form (fill and submit form data)",
30+
Risk: "write",
31+
Scopes: []string{"base:form:update", "docs:document.media:upload"},
32+
AuthTypes: authTypes(),
33+
HasFormat: true,
34+
Flags: []common.Flag{
35+
{Name: "share-token", Desc: "Form share token (required), extracted from the form share link", Required: true},
36+
{Name: "base-token", Desc: "Base token (required when --json contains attachments, used for uploading attachments to Base Drive Media)"},
37+
{Name: "json", Desc: `JSON object containing "fields" (field values) and "attachments" (attachment file paths). Example: '{"fields":{"Rating":5,"Review":"Good"},"attachments":{"Attachment":["./a.pdf","./b.png"]}}'`, Required: true},
38+
},
39+
Tips: []string{
40+
`Example (no attachments): --share-token shrXXXX --json '{"fields":{"Service Rating":5,"Review":"Good service"}}'`,
41+
`Example (with attachments): --share-token shrXXXX --base-token basXXX --json '{"fields":{"Service Rating":5},"attachments":{"Attachment":["./report.pdf"]}}'`,
42+
`Cell values in "fields" follow lark-base-cell-value.md conventions; "attachments" maps field names to local file path arrays — the CLI uploads them in parallel and merges them into the submission.`,
43+
},
44+
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
45+
return validateFormSubmit(runtime)
46+
},
47+
DryRun: dryRunFormSubmit,
48+
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
49+
return executeFormSubmit(runtime)
50+
},
51+
}
52+
53+
func validateFormSubmit(runtime *common.RuntimeContext) error {
54+
// 校验 --json 结构:提取 "fields" 和 "attachments"
55+
pc := newParseCtx(runtime)
56+
raw, err := parseJSONObject(pc, runtime.Str("json"), "json")
57+
if err != nil {
58+
return err
59+
}
60+
61+
fields, _ := raw["fields"].(map[string]interface{})
62+
attachments, hasAttachments := raw["attachments"]
63+
64+
if !hasAttachments && fields == nil {
65+
return common.FlagErrorf("--json must contain at least \"fields\" or \"attachments\"")
66+
}
67+
68+
if hasAttachments {
69+
// 有附件时 --base-token 必填(上传附件到 Base Drive Media 需要)
70+
if runtime.Str("base-token") == "" {
71+
return common.FlagErrorf("--base-token is required when --json contains \"attachments\"")
72+
}
73+
74+
attMap, ok := attachments.(map[string]interface{})
75+
if !ok {
76+
return common.FlagErrorf("--json.attachments must be a JSON object mapping field names to file path arrays")
77+
}
78+
for fieldName, value := range attMap {
79+
paths, ok := value.([]interface{})
80+
if !ok {
81+
return common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
82+
}
83+
for i, item := range paths {
84+
if _, ok := item.(string); !ok {
85+
return common.FlagErrorf("--json.attachments.%q[%d] must be a file path string, got %T", fieldName, i, item)
86+
}
87+
}
88+
if len(paths) == 0 {
89+
return common.FlagErrorf("--json.attachments.%q must not be empty; remove it or provide at least one file path", fieldName)
90+
}
91+
}
92+
}
93+
94+
return nil
95+
}
96+
97+
// parseFormSubmitJSON 将 --json 解析为字段和附件映射。
98+
func parseFormSubmitJSON(runtime *common.RuntimeContext) (map[string]interface{}, map[string][]string, error) {
99+
pc := newParseCtx(runtime)
100+
raw, err := parseJSONObject(pc, runtime.Str("json"), "json")
101+
if err != nil {
102+
return nil, nil, err
103+
}
104+
105+
fields, _ := raw["fields"].(map[string]interface{})
106+
if fields == nil {
107+
fields = make(map[string]interface{})
108+
}
109+
110+
var attMap map[string][]string
111+
if attachments, ok := raw["attachments"]; ok {
112+
attObj, ok := attachments.(map[string]interface{})
113+
if !ok {
114+
return nil, nil, common.FlagErrorf(`--json.attachments must be a JSON object mapping field names to file path arrays`)
115+
}
116+
if len(attObj) > 0 {
117+
attMap = make(map[string][]string, len(attObj))
118+
for fieldName, value := range attObj {
119+
paths, ok := value.([]interface{})
120+
if !ok {
121+
return nil, nil, common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
122+
}
123+
filePaths := make([]string, 0, len(paths))
124+
for _, item := range paths {
125+
if s, ok := item.(string); ok {
126+
filePaths = append(filePaths, s)
127+
} else {
128+
return nil, nil, common.FlagErrorf("--json.attachments.%q must contain file path strings only, got %T", fieldName, item)
129+
}
130+
}
131+
if len(filePaths) > 0 {
132+
attMap[fieldName] = filePaths
133+
}
134+
}
135+
}
136+
}
137+
138+
return fields, attMap, nil
139+
}
140+
141+
func dryRunFormSubmit(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
142+
fields, attachmentMap, err := parseFormSubmitJSON(runtime)
143+
if err != nil {
144+
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
145+
}
146+
147+
if len(attachmentMap) > 0 {
148+
dry := common.NewDryRunAPI().
149+
Desc("Form submit with attachments: upload local files per field → merge with fields → submit")
150+
151+
for fieldName, filePaths := range attachmentMap {
152+
for _, p := range filePaths {
153+
fileName := filepath.Base(p)
154+
dry = dry.POST("/open-apis/drive/v1/medias/upload_all").
155+
Desc(fmt.Sprintf("Upload attachment for field %q: %s", fieldName, fileName)).
156+
Body(map[string]interface{}{
157+
"file_name": fileName,
158+
"parent_type": baseFormAttachmentParentType,
159+
"parent_node": runtime.Str("base-token"),
160+
"extra": baseFormAttachmentExtra(runtime.Str("share-token")),
161+
"file": "@" + p,
162+
"size": "<file_size>",
163+
})
164+
}
165+
}
166+
167+
body := buildFormSubmitBody(runtime, fields)
168+
dry = dry.POST("/open-apis/base/v3/bases/tables/forms/submit").
169+
Body(body).
170+
Desc("Submit form with uploaded attachment tokens merged with fields")
171+
return dry
172+
}
173+
174+
body := buildFormSubmitBody(runtime, fields)
175+
return common.NewDryRunAPI().
176+
POST("/open-apis/base/v3/bases/tables/forms/submit").
177+
Body(body)
178+
}
179+
180+
func buildFormSubmitBody(runtime *common.RuntimeContext, content map[string]interface{}) map[string]interface{} {
181+
return map[string]interface{}{
182+
"share_token": runtime.Str("share-token"),
183+
"content": content,
184+
}
185+
}
186+
187+
func executeFormSubmit(runtime *common.RuntimeContext) error {
188+
fields, attachmentMap, err := parseFormSubmitJSON(runtime)
189+
if err != nil {
190+
return err
191+
}
192+
193+
// 上传附件并合并到字段中
194+
if len(attachmentMap) > 0 {
195+
baseToken := runtime.Str("base-token")
196+
fio := runtime.FileIO()
197+
if fio == nil {
198+
return output.ErrValidation("file operations require a FileIO provider (needed for attachments in --json)")
199+
}
200+
201+
// Step 1: 收集所有唯一路径(跨字段去重)
202+
allPaths := collectUniquePaths(attachmentMap)
203+
if len(allPaths) == 0 {
204+
return common.FlagErrorf("attachments in --json contains no valid file paths")
205+
}
206+
207+
// Step 2: 前置校验所有文件路径安全性与可访问性,同时收集文件大小供上传使用
208+
sizeMap := make(map[string]int64, len(allPaths))
209+
for _, filePath := range allPaths {
210+
if _, err := validate.SafeInputPath(filePath); err != nil {
211+
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
212+
}
213+
fileInfo, err := fio.Stat(filePath)
214+
if err != nil {
215+
if errors.Is(err, fileio.ErrPathValidation) {
216+
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
217+
}
218+
return output.ErrValidation("attachment file not accessible: %s: %v", filePath, err)
219+
}
220+
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
221+
return output.ErrValidation("attachment file %s exceeds 2GB limit", filePath)
222+
}
223+
if !fileInfo.Mode().IsRegular() {
224+
return output.ErrValidation("attachment file %s is not a regular file", filePath)
225+
}
226+
sizeMap[filePath] = fileInfo.Size()
227+
}
228+
229+
// Step 3: 并行上传,构建路径 → 附件结果映射
230+
fmt.Fprintf(runtime.IO().ErrOut, "Uploading %d unique attachment(s)...\n", len(allPaths))
231+
resultMap, err := uploadAttachmentsParallel(runtime, allPaths, baseFormAttachmentUploadTarget(baseToken, runtime.Str("share-token")), sizeMap)
232+
if err != nil {
233+
return err
234+
}
235+
236+
// Step 4: 根据共享结果映射,按字段组装单元格
237+
for fieldName, filePaths := range attachmentMap {
238+
cell := make([]interface{}, 0, len(filePaths))
239+
for _, p := range filePaths {
240+
if att, ok := resultMap[p]; ok {
241+
cell = append(cell, att)
242+
}
243+
}
244+
fields[fieldName] = cell
245+
}
246+
fmt.Fprintf(runtime.IO().ErrOut, "Uploaded %d unique file(s) into %d field(s)\n", len(resultMap), len(attachmentMap))
247+
}
248+
249+
body := buildFormSubmitBody(runtime, fields)
250+
data, err := baseV3Call(runtime, "POST",
251+
baseV3Path("bases", "tables", "forms", "submit"),
252+
nil, body)
253+
if err != nil {
254+
return err
255+
}
256+
257+
runtime.Out(data, nil)
258+
return nil
259+
}
260+
261+
// collectUniquePaths 收集所有字段中的文件路径,返回去重后的有序列表。
262+
func collectUniquePaths(attachmentMap map[string][]string) []string {
263+
seen := make(map[string]bool, len(attachmentMap)*4)
264+
var order []string
265+
for _, filePaths := range attachmentMap {
266+
for _, p := range filePaths {
267+
if !seen[p] {
268+
seen[p] = true
269+
order = append(order, p)
270+
}
271+
}
272+
}
273+
return order
274+
}
275+
276+
func baseFormAttachmentUploadTarget(baseToken, shareToken string) baseAttachmentUploadTarget {
277+
return baseAttachmentUploadTarget{
278+
ParentType: baseFormAttachmentParentType,
279+
ParentNode: baseToken,
280+
Extra: baseFormAttachmentExtra(shareToken),
281+
}
282+
}
283+
284+
func baseFormAttachmentExtra(shareToken string) string {
285+
extra, err := json.Marshal(map[string]string{"share_token": shareToken})
286+
if err != nil {
287+
return ""
288+
}
289+
return string(extra)
290+
}
291+
292+
// uploadAttachmentsParallel 并发上传文件,返回路径 → 附件对象的映射。
293+
func uploadAttachmentsParallel(runtime *common.RuntimeContext, paths []string, target baseAttachmentUploadTarget, sizeMap map[string]int64) (map[string]interface{}, error) {
294+
var (
295+
mu sync.Mutex
296+
resultMap = make(map[string]interface{}, len(paths))
297+
)
298+
299+
g, _ := errgroup.WithContext(runtime.Ctx())
300+
g.SetLimit(uploadAttachConcurrency) // 限制并发数
301+
302+
for _, filePath := range paths {
303+
fp := filePath // 捕获循环变量
304+
g.Go(func() error {
305+
fileName := filepath.Base(fp)
306+
fmt.Fprintf(runtime.IO().ErrOut, " Uploading: %s\n", fileName)
307+
308+
att, err := uploadSingleAttachment(runtime, fp, fileName, sizeMap[fp], target)
309+
if err != nil {
310+
return err
311+
}
312+
313+
mu.Lock()
314+
resultMap[fp] = att
315+
mu.Unlock()
316+
return nil
317+
})
318+
}
319+
320+
if err := g.Wait(); err != nil {
321+
return nil, err
322+
}
323+
return resultMap, nil
324+
}
325+
326+
// uploadSingleAttachment 上传单个文件,返回附件单元格项。
327+
// 前置条件:文件已通过校验(存在、常规文件、大小在限制内)。
328+
func uploadSingleAttachment(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, target baseAttachmentUploadTarget) (interface{}, error) {
329+
att, err := uploadAttachmentToBase(runtime, filePath, fileName, fileSize, target)
330+
if err != nil {
331+
return nil, fmt.Errorf("failed to upload attachment %s: %w", filePath, err)
332+
}
333+
return att, nil
334+
}

0 commit comments

Comments
 (0)