mirror of
https://github.com/larksuite/cli.git
synced 2026-07-05 07:31:22 +08:00
335 lines
11 KiB
Go
335 lines
11 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package base
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"sync"
|
|
|
|
"golang.org/x/sync/errgroup"
|
|
|
|
"github.com/larksuite/cli/extension/fileio"
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/larksuite/cli/internal/validate"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
const (
|
|
uploadAttachConcurrency = 5
|
|
)
|
|
|
|
var BaseFormSubmit = common.Shortcut{
|
|
Service: "base",
|
|
Command: "+form-submit",
|
|
Description: "Submit a form (fill and submit form data)",
|
|
Risk: "write",
|
|
Scopes: []string{"base:form:update", "docs:document.media:upload"},
|
|
AuthTypes: authTypes(),
|
|
HasFormat: true,
|
|
Flags: []common.Flag{
|
|
{Name: "share-token", Desc: "Form share token (required), extracted from the form share link", Required: true},
|
|
{Name: "base-token", Desc: "Base token (required when --json contains attachments, used for uploading attachments to Base Drive Media)"},
|
|
{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},
|
|
},
|
|
Tips: []string{
|
|
`Example (no attachments): --share-token shrXXXX --json '{"fields":{"Service Rating":5,"Review":"Good service"}}'`,
|
|
`Example (with attachments): --share-token shrXXXX --base-token basXXX --json '{"fields":{"Service Rating":5},"attachments":{"Attachment":["./report.pdf"]}}'`,
|
|
`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.`,
|
|
},
|
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
return validateFormSubmit(runtime)
|
|
},
|
|
DryRun: dryRunFormSubmit,
|
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
return executeFormSubmit(runtime)
|
|
},
|
|
}
|
|
|
|
func validateFormSubmit(runtime *common.RuntimeContext) error {
|
|
// 校验 --json 结构:提取 "fields" 和 "attachments"
|
|
pc := newParseCtx(runtime)
|
|
raw, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fields, _ := raw["fields"].(map[string]interface{})
|
|
attachments, hasAttachments := raw["attachments"]
|
|
|
|
if !hasAttachments && fields == nil {
|
|
return common.FlagErrorf("--json must contain at least \"fields\" or \"attachments\"")
|
|
}
|
|
|
|
if hasAttachments {
|
|
// 有附件时 --base-token 必填(上传附件到 Base Drive Media 需要)
|
|
if runtime.Str("base-token") == "" {
|
|
return common.FlagErrorf("--base-token is required when --json contains \"attachments\"")
|
|
}
|
|
|
|
attMap, ok := attachments.(map[string]interface{})
|
|
if !ok {
|
|
return common.FlagErrorf("--json.attachments must be a JSON object mapping field names to file path arrays")
|
|
}
|
|
for fieldName, value := range attMap {
|
|
paths, ok := value.([]interface{})
|
|
if !ok {
|
|
return common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
|
|
}
|
|
for i, item := range paths {
|
|
if _, ok := item.(string); !ok {
|
|
return common.FlagErrorf("--json.attachments.%q[%d] must be a file path string, got %T", fieldName, i, item)
|
|
}
|
|
}
|
|
if len(paths) == 0 {
|
|
return common.FlagErrorf("--json.attachments.%q must not be empty; remove it or provide at least one file path", fieldName)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// parseFormSubmitJSON 将 --json 解析为字段和附件映射。
|
|
func parseFormSubmitJSON(runtime *common.RuntimeContext) (map[string]interface{}, map[string][]string, error) {
|
|
pc := newParseCtx(runtime)
|
|
raw, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
fields, _ := raw["fields"].(map[string]interface{})
|
|
if fields == nil {
|
|
fields = make(map[string]interface{})
|
|
}
|
|
|
|
var attMap map[string][]string
|
|
if attachments, ok := raw["attachments"]; ok {
|
|
attObj, ok := attachments.(map[string]interface{})
|
|
if !ok {
|
|
return nil, nil, common.FlagErrorf(`--json.attachments must be a JSON object mapping field names to file path arrays`)
|
|
}
|
|
if len(attObj) > 0 {
|
|
attMap = make(map[string][]string, len(attObj))
|
|
for fieldName, value := range attObj {
|
|
paths, ok := value.([]interface{})
|
|
if !ok {
|
|
return nil, nil, common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
|
|
}
|
|
filePaths := make([]string, 0, len(paths))
|
|
for _, item := range paths {
|
|
if s, ok := item.(string); ok {
|
|
filePaths = append(filePaths, s)
|
|
} else {
|
|
return nil, nil, common.FlagErrorf("--json.attachments.%q must contain file path strings only, got %T", fieldName, item)
|
|
}
|
|
}
|
|
if len(filePaths) > 0 {
|
|
attMap[fieldName] = filePaths
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return fields, attMap, nil
|
|
}
|
|
|
|
func dryRunFormSubmit(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
fields, attachmentMap, err := parseFormSubmitJSON(runtime)
|
|
if err != nil {
|
|
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
|
|
}
|
|
|
|
if len(attachmentMap) > 0 {
|
|
dry := common.NewDryRunAPI().
|
|
Desc("Form submit with attachments: upload local files per field → merge with fields → submit")
|
|
|
|
for fieldName, filePaths := range attachmentMap {
|
|
for _, p := range filePaths {
|
|
fileName := filepath.Base(p)
|
|
dry = dry.POST("/open-apis/drive/v1/medias/upload_all").
|
|
Desc(fmt.Sprintf("Upload attachment for field %q: %s", fieldName, fileName)).
|
|
Body(map[string]interface{}{
|
|
"file_name": fileName,
|
|
"parent_type": baseFormAttachmentParentType,
|
|
"parent_node": runtime.Str("base-token"),
|
|
"extra": baseFormAttachmentExtra(runtime.Str("share-token")),
|
|
"file": "@" + p,
|
|
"size": "<file_size>",
|
|
})
|
|
}
|
|
}
|
|
|
|
body := buildFormSubmitBody(runtime, fields)
|
|
dry = dry.POST("/open-apis/base/v3/bases/tables/forms/submit").
|
|
Body(body).
|
|
Desc("Submit form with uploaded attachment tokens merged with fields")
|
|
return dry
|
|
}
|
|
|
|
body := buildFormSubmitBody(runtime, fields)
|
|
return common.NewDryRunAPI().
|
|
POST("/open-apis/base/v3/bases/tables/forms/submit").
|
|
Body(body)
|
|
}
|
|
|
|
func buildFormSubmitBody(runtime *common.RuntimeContext, content map[string]interface{}) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"share_token": runtime.Str("share-token"),
|
|
"content": content,
|
|
}
|
|
}
|
|
|
|
func executeFormSubmit(runtime *common.RuntimeContext) error {
|
|
fields, attachmentMap, err := parseFormSubmitJSON(runtime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 上传附件并合并到字段中
|
|
if len(attachmentMap) > 0 {
|
|
baseToken := runtime.Str("base-token")
|
|
fio := runtime.FileIO()
|
|
if fio == nil {
|
|
return output.ErrValidation("file operations require a FileIO provider (needed for attachments in --json)")
|
|
}
|
|
|
|
// Step 1: 收集所有唯一路径(跨字段去重)
|
|
allPaths := collectUniquePaths(attachmentMap)
|
|
if len(allPaths) == 0 {
|
|
return common.FlagErrorf("attachments in --json contains no valid file paths")
|
|
}
|
|
|
|
// Step 2: 前置校验所有文件路径安全性与可访问性,同时收集文件大小供上传使用
|
|
sizeMap := make(map[string]int64, len(allPaths))
|
|
for _, filePath := range allPaths {
|
|
if _, err := validate.SafeInputPath(filePath); err != nil {
|
|
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
|
|
}
|
|
fileInfo, err := fio.Stat(filePath)
|
|
if err != nil {
|
|
if errors.Is(err, fileio.ErrPathValidation) {
|
|
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
|
|
}
|
|
return output.ErrValidation("attachment file not accessible: %s: %v", filePath, err)
|
|
}
|
|
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
|
return output.ErrValidation("attachment file %s exceeds 2GB limit", filePath)
|
|
}
|
|
if !fileInfo.Mode().IsRegular() {
|
|
return output.ErrValidation("attachment file %s is not a regular file", filePath)
|
|
}
|
|
sizeMap[filePath] = fileInfo.Size()
|
|
}
|
|
|
|
// Step 3: 并行上传,构建路径 → 附件结果映射
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Uploading %d unique attachment(s)...\n", len(allPaths))
|
|
resultMap, err := uploadAttachmentsParallel(runtime, allPaths, baseFormAttachmentUploadTarget(baseToken, runtime.Str("share-token")), sizeMap)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Step 4: 根据共享结果映射,按字段组装单元格
|
|
for fieldName, filePaths := range attachmentMap {
|
|
cell := make([]interface{}, 0, len(filePaths))
|
|
for _, p := range filePaths {
|
|
if att, ok := resultMap[p]; ok {
|
|
cell = append(cell, att)
|
|
}
|
|
}
|
|
fields[fieldName] = cell
|
|
}
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Uploaded %d unique file(s) into %d field(s)\n", len(resultMap), len(attachmentMap))
|
|
}
|
|
|
|
body := buildFormSubmitBody(runtime, fields)
|
|
data, err := baseV3Call(runtime, "POST",
|
|
baseV3Path("bases", "tables", "forms", "submit"),
|
|
nil, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
runtime.Out(data, nil)
|
|
return nil
|
|
}
|
|
|
|
// collectUniquePaths 收集所有字段中的文件路径,返回去重后的有序列表。
|
|
func collectUniquePaths(attachmentMap map[string][]string) []string {
|
|
seen := make(map[string]bool, len(attachmentMap)*4)
|
|
var order []string
|
|
for _, filePaths := range attachmentMap {
|
|
for _, p := range filePaths {
|
|
if !seen[p] {
|
|
seen[p] = true
|
|
order = append(order, p)
|
|
}
|
|
}
|
|
}
|
|
return order
|
|
}
|
|
|
|
func baseFormAttachmentUploadTarget(baseToken, shareToken string) baseAttachmentUploadTarget {
|
|
return baseAttachmentUploadTarget{
|
|
ParentType: baseFormAttachmentParentType,
|
|
ParentNode: baseToken,
|
|
Extra: baseFormAttachmentExtra(shareToken),
|
|
}
|
|
}
|
|
|
|
func baseFormAttachmentExtra(shareToken string) string {
|
|
extra, err := json.Marshal(map[string]string{"share_token": shareToken})
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(extra)
|
|
}
|
|
|
|
// uploadAttachmentsParallel 并发上传文件,返回路径 → 附件对象的映射。
|
|
func uploadAttachmentsParallel(runtime *common.RuntimeContext, paths []string, target baseAttachmentUploadTarget, sizeMap map[string]int64) (map[string]interface{}, error) {
|
|
var (
|
|
mu sync.Mutex
|
|
resultMap = make(map[string]interface{}, len(paths))
|
|
)
|
|
|
|
g, _ := errgroup.WithContext(runtime.Ctx())
|
|
g.SetLimit(uploadAttachConcurrency) // 限制并发数
|
|
|
|
for _, filePath := range paths {
|
|
fp := filePath // 捕获循环变量
|
|
g.Go(func() error {
|
|
fileName := filepath.Base(fp)
|
|
fmt.Fprintf(runtime.IO().ErrOut, " Uploading: %s\n", fileName)
|
|
|
|
att, err := uploadSingleAttachment(runtime, fp, fileName, sizeMap[fp], target)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
mu.Lock()
|
|
resultMap[fp] = att
|
|
mu.Unlock()
|
|
return nil
|
|
})
|
|
}
|
|
|
|
if err := g.Wait(); err != nil {
|
|
return nil, err
|
|
}
|
|
return resultMap, nil
|
|
}
|
|
|
|
// uploadSingleAttachment 上传单个文件,返回附件单元格项。
|
|
// 前置条件:文件已通过校验(存在、常规文件、大小在限制内)。
|
|
func uploadSingleAttachment(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, target baseAttachmentUploadTarget) (interface{}, error) {
|
|
att, err := uploadAttachmentToBase(runtime, filePath, fileName, fileSize, target)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to upload attachment %s: %w", filePath, err)
|
|
}
|
|
return att, nil
|
|
}
|