bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
938 lines
32 KiB
Go
938 lines
32 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package base
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
_ "image/gif"
|
|
_ "image/jpeg"
|
|
_ "image/png"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/extension/fileio"
|
|
"github.com/larksuite/cli/internal/util"
|
|
"github.com/larksuite/cli/internal/validate"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
|
)
|
|
|
|
const (
|
|
baseAttachmentUploadMaxFileSize int64 = 2 * 1024 * 1024 * 1024
|
|
baseAttachmentParentType = "bitable_file"
|
|
baseFormAttachmentParentType = "bitable_tmp_point"
|
|
baseAttachmentMaxBatchSize = 50
|
|
baseAttachmentGetMaxRecords = 10
|
|
)
|
|
|
|
type baseAttachmentUploadTarget struct {
|
|
ParentType string
|
|
ParentNode string
|
|
Extra string
|
|
}
|
|
|
|
var BaseRecordUploadAttachment = common.Shortcut{
|
|
Service: "base",
|
|
Command: "+record-upload-attachment",
|
|
Description: "Upload one or more local files and append the returned file_token values to a Base attachment cell",
|
|
Risk: "write",
|
|
Scopes: []string{"base:record:update", "base:field:read", "docs:document.media:upload"},
|
|
AuthTypes: authTypes(),
|
|
Flags: []common.Flag{
|
|
baseTokenFlag(true),
|
|
tableRefFlag(true),
|
|
recordRefFlag(true),
|
|
fieldRefFlag(true),
|
|
{Name: "file", Type: "string_array", Desc: "local file path; repeat to append multiple attachments in one cell; max 50 files, max 2GB each; files > 20MB use multipart upload automatically", Required: true},
|
|
{Name: "name", Desc: "deprecated; attachment names are derived from local file basenames", Hidden: true},
|
|
},
|
|
Tips: []string{
|
|
`Example: lark-cli base +record-upload-attachment --base-token <base_token> --table-id <table_id> --record-id <record_id> --field-id <attachment_field_id> --file ./report.pdf`,
|
|
`Repeat --file to append multiple attachments: --file ./report.pdf --file ./screenshot.png`,
|
|
`Reuse returned file_token values for download/remove`,
|
|
},
|
|
DryRun: dryRunRecordUploadAttachment,
|
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
return validateRecordUploadAttachment(runtime)
|
|
},
|
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
return executeRecordUploadAttachment(runtime)
|
|
},
|
|
}
|
|
|
|
var BaseRecordDownloadAttachment = common.Shortcut{
|
|
Service: "base",
|
|
Command: "+record-download-attachment",
|
|
Description: "Download Base record attachments by record-id, optionally filtering by file-token",
|
|
Risk: "read",
|
|
Scopes: []string{"base:record:read", "docs:document.media:download"},
|
|
AuthTypes: authTypes(),
|
|
Flags: []common.Flag{
|
|
baseTokenFlag(true),
|
|
tableRefFlag(true),
|
|
recordRefFlag(true),
|
|
{Name: "file-token", Type: "string_array", Desc: "attachment file_token returned by Base; repeat to download selected files; omit to download all attachments in the record", Required: false},
|
|
{Name: "output", Desc: "local save path; with exactly one file token this may be a file path; with multiple or omitted file tokens this must be an existing directory", Required: true},
|
|
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
|
},
|
|
Tips: []string{
|
|
`Example: lark-cli base +record-download-attachment --base-token <base_token> --table-id <table_id> --record-id <record_id> --file-token <file_token> --output ./downloads/`,
|
|
`Omit --file-token to download every attachment in the record.`,
|
|
`Base attachments should be downloaded with this command; other download commands may fail for Base attachment files.`,
|
|
`With one --file-token, --output may be a file path or directory; with multiple or omitted --file-token values, --output must be an existing directory.`,
|
|
},
|
|
DryRun: dryRunRecordDownloadAttachment,
|
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
return validateRecordDownloadAttachment(runtime)
|
|
},
|
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
return executeRecordDownloadAttachment(ctx, runtime)
|
|
},
|
|
}
|
|
|
|
var BaseRecordRemoveAttachment = common.Shortcut{
|
|
Service: "base",
|
|
Command: "+record-remove-attachment",
|
|
Description: "Remove one or more file_token values from a Base record attachment cell",
|
|
Risk: "high-risk-write",
|
|
Scopes: []string{"base:record:update", "base:field:read"},
|
|
AuthTypes: authTypes(),
|
|
Flags: []common.Flag{
|
|
baseTokenFlag(true),
|
|
tableRefFlag(true),
|
|
recordRefFlag(true),
|
|
fieldRefFlag(true),
|
|
{Name: "file-token", Type: "string_array", Desc: "attachment file_token to remove from the target cell; repeat to remove multiple attachments; max 50 tokens", Required: true},
|
|
},
|
|
Tips: []string{
|
|
baseHighRiskYesTip,
|
|
`Example: lark-cli base +record-remove-attachment --base-token <base_token> --table-id <table_id> --record-id <record_id> --field-id <attachment_field_id> --file-token <file_token> --yes`,
|
|
`Repeat --file-token to remove multiple attachments from the same cell in one call.`,
|
|
`This is a high-risk write command and requires --yes.`,
|
|
},
|
|
DryRun: dryRunRecordRemoveAttachment,
|
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
return validateRecordRemoveAttachment(runtime)
|
|
},
|
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
return executeRecordRemoveAttachment(runtime)
|
|
},
|
|
}
|
|
|
|
func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
files := runtime.StrArray("file")
|
|
filePath := "<file>"
|
|
fileName := "<local_file_name>"
|
|
if len(files) > 0 {
|
|
filePath = files[0]
|
|
fileName = filepath.Base(filePath)
|
|
}
|
|
dry := common.NewDryRunAPI().
|
|
Desc("3-step orchestration: validate attachment field → upload local file(s) to Base → append uploaded file token(s) to the attachment cell").
|
|
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
|
|
Desc("[1] Read target field and ensure it is an attachment field").
|
|
Set("base_token", runtime.Str("base-token")).
|
|
Set("table_id", baseTableID(runtime)).
|
|
Set("field_id", runtime.Str("field-id"))
|
|
if baseAttachmentShouldUseMultipart(runtime.FileIO(), filePath) {
|
|
dry.POST("/open-apis/drive/v1/medias/upload_prepare").
|
|
Desc("[2a] Initialize multipart attachment upload to the current Base").
|
|
Body(map[string]interface{}{
|
|
"file_name": fileName,
|
|
"parent_type": baseAttachmentParentType,
|
|
"parent_node": runtime.Str("base-token"),
|
|
"size": "<file_size>",
|
|
}).
|
|
POST("/open-apis/drive/v1/medias/upload_part").
|
|
Desc("[2b] Upload attachment parts (repeated for each large file)").
|
|
Body(map[string]interface{}{
|
|
"upload_id": "<upload_id>",
|
|
"seq": "<chunk_index>",
|
|
"size": "<chunk_size>",
|
|
"file": "<chunk_binary>",
|
|
}).
|
|
POST("/open-apis/drive/v1/medias/upload_finish").
|
|
Desc("[2c] Finalize multipart attachment upload and get file token").
|
|
Body(map[string]interface{}{
|
|
"upload_id": "<upload_id>",
|
|
"block_num": "<block_num>",
|
|
})
|
|
} else {
|
|
dry.POST("/open-apis/drive/v1/medias/upload_all").
|
|
Desc("[2] Upload local file(s) to the current Base as attachment media (multipart/form-data)").
|
|
Body(map[string]interface{}{
|
|
"file_name": fileName,
|
|
"parent_type": baseAttachmentParentType,
|
|
"parent_node": runtime.Str("base-token"),
|
|
"file": "@" + filePath,
|
|
"size": "<file_size>",
|
|
})
|
|
}
|
|
return dry.
|
|
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/append_attachments").
|
|
Desc("[3] Append uploaded file token(s) to the target attachment cell").
|
|
Body(map[string]interface{}{
|
|
"attachments": map[string]interface{}{
|
|
runtime.Str("record-id"): map[string]interface{}{
|
|
runtime.Str("field-id"): []interface{}{
|
|
map[string]interface{}{
|
|
"file_token": "<uploaded_file_token>",
|
|
"image_width": "<image_width_if_image>",
|
|
"image_height": "<image_height_if_image>",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func dryRunRecordDownloadAttachment(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
return common.NewDryRunAPI().
|
|
Desc("2-step orchestration: read Base attachment metadata → download each requested attachment file").
|
|
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/get_attachments").
|
|
Desc("[1] Read attachment metadata for the record").
|
|
Body(map[string]interface{}{"record_id_list": []string{runtime.Str("record-id")}}).
|
|
Set("base_token", runtime.Str("base-token")).
|
|
Set("table_id", baseTableID(runtime)).
|
|
GET("/open-apis/drive/v1/medias/:file_token/download").
|
|
Desc("[2] Download attachment media through the Base attachment flow").
|
|
Set("file_token", "<file_token>").
|
|
Set("output", runtime.Str("output")).
|
|
Params(map[string]interface{}{"extra": "<extra_info_if_present>"})
|
|
}
|
|
|
|
func dryRunRecordRemoveAttachment(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
body := buildSingleCellAttachmentsBody(runtime.Str("record-id"), runtime.Str("field-id"), fileTokenPatchItems(runtime.StrArray("file-token")))
|
|
return common.NewDryRunAPI().
|
|
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/remove_attachments").
|
|
Desc("Remove attachment file token(s) from the target attachment cell").
|
|
Body(body).
|
|
Set("base_token", runtime.Str("base-token")).
|
|
Set("table_id", baseTableID(runtime))
|
|
}
|
|
|
|
func validateRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
|
if runtime.Changed("name") {
|
|
return baseFlagErrorf("--name is no longer supported; uploaded attachment names are derived from local file basenames")
|
|
}
|
|
files, err := normalizeAttachmentFiles(runtime.StrArray("file"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, path := range files {
|
|
if _, err := validateAttachmentInputFile(runtime, path); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRecordDownloadAttachment(runtime *common.RuntimeContext) error {
|
|
tokens, err := normalizeOptionalDownloadAttachmentFileTokens(runtime.StrArray("file-token"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(tokens) != 1 {
|
|
const outputDirRequired = "--output must be an existing directory when downloading multiple attachments or when --file-token is omitted"
|
|
info, statErr := runtime.FileIO().Stat(runtime.Str("output"))
|
|
if statErr != nil {
|
|
if errors.Is(statErr, fileio.ErrPathValidation) {
|
|
return baseValidationErrorf("unsafe output path: %s", statErr)
|
|
}
|
|
return baseFlagErrorf(outputDirRequired)
|
|
}
|
|
if !info.IsDir() {
|
|
return baseFlagErrorf(outputDirRequired)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRecordRemoveAttachment(runtime *common.RuntimeContext) error {
|
|
_, err := normalizeAttachmentFileTokens(runtime.StrArray("file-token"))
|
|
return err
|
|
}
|
|
|
|
func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
|
files, err := normalizeAttachmentFiles(runtime.StrArray("file"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
field, err := fetchBaseField(runtime, runtime.Str("base-token"), baseTableID(runtime), runtime.Str("field-id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" {
|
|
return baseValidationErrorf("field %q is type %q, expected attachment", fieldName(field), normalized)
|
|
}
|
|
resolvedFieldID := fieldID(field)
|
|
if resolvedFieldID == "" {
|
|
resolvedFieldID = runtime.Str("field-id")
|
|
}
|
|
|
|
appendItems := make([]interface{}, 0, len(files))
|
|
for _, filePath := range files {
|
|
fileInfo, err := validateAttachmentInputFile(runtime, filePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fileName := filepath.Base(filePath)
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Uploading attachment: %s -> record %s field %s\n", fileName, runtime.Str("record-id"), fieldName(field))
|
|
if fileInfo.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
|
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
|
}
|
|
attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, fileInfo.Size(), baseAttachmentUploadTarget{
|
|
ParentType: baseAttachmentParentType,
|
|
ParentNode: runtime.Str("base-token"),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
appendItems = append(appendItems, attachmentAppendItem(attachment))
|
|
}
|
|
|
|
body := buildSingleCellAttachmentsBody(runtime.Str("record-id"), resolvedFieldID, appendItems)
|
|
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "append_attachments"), nil, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
runtime.Out(data, nil)
|
|
return nil
|
|
}
|
|
|
|
func executeRecordRemoveAttachment(runtime *common.RuntimeContext) error {
|
|
tokens, err := normalizeAttachmentFileTokens(runtime.StrArray("file-token"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
field, err := fetchBaseField(runtime, runtime.Str("base-token"), baseTableID(runtime), runtime.Str("field-id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" {
|
|
return baseValidationErrorf("field %q is type %q, expected attachment", fieldName(field), normalized)
|
|
}
|
|
resolvedFieldID := fieldID(field)
|
|
if resolvedFieldID == "" {
|
|
resolvedFieldID = runtime.Str("field-id")
|
|
}
|
|
body := buildSingleCellAttachmentsBody(runtime.Str("record-id"), resolvedFieldID, fileTokenPatchItems(tokens))
|
|
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "remove_attachments"), nil, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
runtime.Out(data, nil)
|
|
return nil
|
|
}
|
|
|
|
func executeRecordDownloadAttachment(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
tokens, err := normalizeOptionalDownloadAttachmentFileTokens(runtime.StrArray("file-token"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
attachments, err := fetchBaseAttachments(runtime, runtime.Str("base-token"), baseTableID(runtime), []string{runtime.Str("record-id")})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
items, err := selectAttachmentDownloadItems(attachments, runtime.Str("record-id"), tokens)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
targets, err := planAttachmentDownloadTargets(runtime, items, runtime.Str("output"), len(tokens) != 1 || len(items) > 1, runtime.Bool("overwrite"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
downloaded := make([]map[string]interface{}, 0, len(targets))
|
|
for _, target := range targets {
|
|
saved, err := downloadBaseAttachment(ctx, runtime, target.Item, target.TargetPath, runtime.Bool("overwrite"))
|
|
if err != nil {
|
|
failed := attachmentDownloadFailure(target, err)
|
|
return attachmentDownloadProgressError(runtime, err, downloaded, []map[string]interface{}{failed})
|
|
}
|
|
downloaded = append(downloaded, saved)
|
|
}
|
|
runtime.Out(map[string]interface{}{"downloaded": downloaded}, nil)
|
|
return nil
|
|
}
|
|
|
|
func validateAttachmentInputFile(runtime *common.RuntimeContext, filePath string) (fileio.FileInfo, error) {
|
|
fio := runtime.FileIO()
|
|
if fio == nil {
|
|
return nil, baseValidationErrorf("file operations require a FileIO provider")
|
|
}
|
|
fileInfo, err := fio.Stat(filePath)
|
|
if err != nil {
|
|
if errors.Is(err, fileio.ErrPathValidation) {
|
|
return nil, baseValidationErrorf("unsafe file path: %s", err)
|
|
}
|
|
return nil, baseValidationErrorf("file not accessible: %s: %v", filePath, err)
|
|
}
|
|
if fileInfo.IsDir() {
|
|
return nil, baseValidationErrorf("file path is a directory: %s", filePath)
|
|
}
|
|
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
|
return nil, baseValidationErrorf("file %s exceeds 2GB limit (size: %s)", filePath, common.FormatSize(fileInfo.Size()))
|
|
}
|
|
return fileInfo, nil
|
|
}
|
|
|
|
func normalizeAttachmentFiles(files []string) ([]string, error) {
|
|
return normalizeStringList(files, stringListNormalizeOptions{
|
|
typeError: "attachment files must be a string array",
|
|
emptyError: "provide at least one --file",
|
|
itemName: "attachment file",
|
|
duplicateName: "attachment file",
|
|
limitName: "attachment file count",
|
|
max: baseAttachmentMaxBatchSize,
|
|
})
|
|
}
|
|
|
|
func normalizeAttachmentFileTokens(tokens []string) ([]string, error) {
|
|
return normalizeStringList(tokens, stringListNormalizeOptions{
|
|
typeError: "attachment file tokens must be a string array",
|
|
emptyError: "provide at least one --file-token",
|
|
itemName: "attachment file token",
|
|
duplicateName: "attachment file token",
|
|
limitName: "attachment file token count",
|
|
max: baseAttachmentMaxBatchSize,
|
|
})
|
|
}
|
|
|
|
func normalizeOptionalDownloadAttachmentFileTokens(tokens []string) ([]string, error) {
|
|
if len(tokens) == 0 {
|
|
return nil, nil
|
|
}
|
|
normalized := make([]string, 0, len(tokens))
|
|
for index, token := range tokens {
|
|
token = strings.TrimSpace(token)
|
|
if token == "" {
|
|
return nil, baseFlagErrorf("attachment file token %d must not be empty", index+1)
|
|
}
|
|
normalized = append(normalized, token)
|
|
}
|
|
normalized = dedupeStringsPreserveOrder(normalized)
|
|
if len(normalized) > baseAttachmentMaxBatchSize {
|
|
return nil, baseFlagErrorf("attachment file token count exceeds maximum limit of %d (got %d)", baseAttachmentMaxBatchSize, len(normalized))
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
func dedupeStringsPreserveOrder(values []string) []string {
|
|
seen := make(map[string]struct{}, len(values))
|
|
result := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
if _, exists := seen[value]; exists {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
result = append(result, value)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func baseAttachmentShouldUseMultipart(fio fileio.FileIO, filePath string) bool {
|
|
if fio == nil {
|
|
return false
|
|
}
|
|
info, err := fio.Stat(filePath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return info.Mode().IsRegular() && info.Size() > common.MaxDriveMediaUploadSinglePartSize
|
|
}
|
|
|
|
func fetchBaseField(runtime *common.RuntimeContext, baseToken, tableIDValue, fieldRef string) (map[string]interface{}, error) {
|
|
return baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil)
|
|
}
|
|
|
|
func fetchBaseAttachments(runtime *common.RuntimeContext, baseToken, tableIDValue string, recordIDs []string) (map[string]interface{}, error) {
|
|
if len(recordIDs) == 0 {
|
|
return nil, baseValidationErrorf("provide at least one record id")
|
|
}
|
|
if len(recordIDs) > baseAttachmentGetMaxRecords {
|
|
return nil, baseValidationErrorf("get attachments record selection exceeds maximum limit of %d (got %d)", baseAttachmentGetMaxRecords, len(recordIDs))
|
|
}
|
|
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableIDValue, "get_attachments"), nil, map[string]interface{}{
|
|
"record_id_list": recordIDs,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
attachments, _ := data["attachments"].(map[string]interface{})
|
|
if attachments == nil {
|
|
return map[string]interface{}{}, nil
|
|
}
|
|
return attachments, nil
|
|
}
|
|
|
|
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, target baseAttachmentUploadTarget) (map[string]interface{}, error) {
|
|
mimeType, err := detectAttachmentMIMEType(runtime.FileIO(), filePath, fileName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var (
|
|
fileToken string
|
|
)
|
|
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
|
|
parentNode := target.ParentNode
|
|
fileToken, err = common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
|
|
FilePath: filePath,
|
|
FileName: fileName,
|
|
FileSize: fileSize,
|
|
ParentType: target.ParentType,
|
|
ParentNode: &parentNode,
|
|
Extra: target.Extra,
|
|
})
|
|
} else {
|
|
fileToken, err = common.UploadDriveMediaMultipartTyped(runtime, common.DriveMediaMultipartUploadConfig{
|
|
FilePath: filePath,
|
|
FileName: fileName,
|
|
FileSize: fileSize,
|
|
ParentType: target.ParentType,
|
|
ParentNode: target.ParentNode,
|
|
Extra: target.Extra,
|
|
})
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
attachment := map[string]interface{}{
|
|
"file_token": fileToken,
|
|
"name": fileName,
|
|
"mime_type": mimeType,
|
|
"size": fileSize,
|
|
}
|
|
if width, height, ok := detectAttachmentImageDimensions(runtime.FileIO(), filePath, mimeType); ok {
|
|
attachment["image_width"] = width
|
|
attachment["image_height"] = height
|
|
} else if attachmentImageDimensionsWarningEnabled(mimeType) {
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Warning: image dimensions unavailable for %s; attachment may display as square\n", fileName)
|
|
}
|
|
return attachment, nil
|
|
}
|
|
|
|
func attachmentAppendItem(attachment map[string]interface{}) map[string]interface{} {
|
|
item := map[string]interface{}{
|
|
"file_token": attachment["file_token"],
|
|
}
|
|
if width, ok := attachment["image_width"]; ok && !util.IsNil(width) {
|
|
item["image_width"] = width
|
|
}
|
|
if height, ok := attachment["image_height"]; ok && !util.IsNil(height) {
|
|
item["image_height"] = height
|
|
}
|
|
return item
|
|
}
|
|
|
|
func fileTokenPatchItems(tokens []string) []interface{} {
|
|
items := make([]interface{}, 0, len(tokens))
|
|
for _, token := range tokens {
|
|
items = append(items, map[string]interface{}{"file_token": token})
|
|
}
|
|
return items
|
|
}
|
|
|
|
func buildSingleCellAttachmentsBody(recordID, fieldID string, items []interface{}) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"attachments": map[string]interface{}{
|
|
recordID: map[string]interface{}{
|
|
fieldID: items,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (string, error) {
|
|
if byExt := strings.TrimSpace(mime.TypeByExtension(strings.ToLower(filepath.Ext(fileName)))); byExt != "" {
|
|
return stripMIMEParams(byExt), nil
|
|
}
|
|
if byExt := strings.TrimSpace(mime.TypeByExtension(strings.ToLower(filepath.Ext(filePath)))); byExt != "" {
|
|
return stripMIMEParams(byExt), nil
|
|
}
|
|
|
|
f, err := fio.Open(filePath)
|
|
if err != nil {
|
|
return "", baseInputStatError(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
buf := make([]byte, 512)
|
|
n, readErr := f.Read(buf)
|
|
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
|
return "", baseValidationErrorf("cannot read file: %s", readErr)
|
|
}
|
|
return detectAttachmentMIMEFromContent(buf[:n]), nil
|
|
}
|
|
|
|
func detectAttachmentImageDimensions(fio fileio.FileIO, filePath string, mimeType string) (int, int, bool) {
|
|
if fio == nil || !strings.HasPrefix(mimeType, "image/") {
|
|
return 0, 0, false
|
|
}
|
|
f, err := fio.Open(filePath)
|
|
if err != nil {
|
|
return 0, 0, false
|
|
}
|
|
defer f.Close()
|
|
cfg, _, err := image.DecodeConfig(f)
|
|
if err != nil || cfg.Width <= 0 || cfg.Height <= 0 {
|
|
return 0, 0, false
|
|
}
|
|
return cfg.Width, cfg.Height, true
|
|
}
|
|
|
|
func attachmentImageDimensionsWarningEnabled(mimeType string) bool {
|
|
switch mimeType {
|
|
case "image/gif", "image/jpeg", "image/png":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
type baseAttachmentDownloadItem struct {
|
|
RecordID string
|
|
FieldID string
|
|
FileToken string
|
|
Name string
|
|
Size interface{}
|
|
ExtraInfo string
|
|
MimeType string
|
|
RawPayload map[string]interface{}
|
|
}
|
|
|
|
type baseAttachmentDownloadTarget struct {
|
|
Item baseAttachmentDownloadItem
|
|
TargetPath string
|
|
ResolvedPath string
|
|
}
|
|
|
|
func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID string, tokens []string) ([]baseAttachmentDownloadItem, error) {
|
|
recordRaw, ok := attachments[recordID]
|
|
if !ok {
|
|
return nil, baseValidationErrorf("record %q has no attachment metadata; verify the record-id", recordID)
|
|
}
|
|
fields, ok := recordRaw.(map[string]interface{})
|
|
if !ok {
|
|
return nil, baseValidationErrorf("record %q attachment metadata has unexpected type %T", recordID, recordRaw)
|
|
}
|
|
byToken := map[string]baseAttachmentDownloadItem{}
|
|
fieldIDs := make([]string, 0, len(fields))
|
|
for currentFieldID := range fields {
|
|
fieldIDs = append(fieldIDs, currentFieldID)
|
|
}
|
|
sort.Strings(fieldIDs)
|
|
for _, currentFieldID := range fieldIDs {
|
|
rawList := fields[currentFieldID]
|
|
items, ok := rawList.([]interface{})
|
|
if !ok {
|
|
return nil, baseValidationErrorf("record %q field %q attachment metadata has unexpected type %T", recordID, currentFieldID, rawList)
|
|
}
|
|
for _, rawItem := range items {
|
|
item, ok := rawItem.(map[string]interface{})
|
|
if !ok {
|
|
return nil, baseValidationErrorf("record %q field %q contains unexpected attachment item type %T", recordID, currentFieldID, rawItem)
|
|
}
|
|
fileToken, _ := item["file_token"].(string)
|
|
if fileToken == "" {
|
|
continue
|
|
}
|
|
if _, exists := byToken[fileToken]; exists {
|
|
continue
|
|
}
|
|
name, _ := item["name"].(string)
|
|
extraInfo, _ := item["extra_info"].(string)
|
|
mimeType, _ := item["mime_type"].(string)
|
|
byToken[fileToken] = baseAttachmentDownloadItem{
|
|
RecordID: recordID,
|
|
FieldID: currentFieldID,
|
|
FileToken: fileToken,
|
|
Name: name,
|
|
Size: item["size"],
|
|
ExtraInfo: extraInfo,
|
|
MimeType: mimeType,
|
|
RawPayload: item,
|
|
}
|
|
}
|
|
}
|
|
result := make([]baseAttachmentDownloadItem, 0, len(tokens))
|
|
if len(tokens) == 0 {
|
|
for _, item := range byToken {
|
|
result = append(result, item)
|
|
}
|
|
if len(result) == 0 {
|
|
return nil, baseValidationErrorf("record %q has no attachments to download", recordID)
|
|
}
|
|
sort.SliceStable(result, func(i, j int) bool {
|
|
leftName := strings.ToLower(baseAttachmentDownloadName(result[i]))
|
|
rightName := strings.ToLower(baseAttachmentDownloadName(result[j]))
|
|
if leftName != rightName {
|
|
return leftName < rightName
|
|
}
|
|
return result[i].FileToken < result[j].FileToken
|
|
})
|
|
return result, nil
|
|
}
|
|
for _, token := range tokens {
|
|
item, ok := byToken[token]
|
|
if !ok {
|
|
return nil, baseValidationErrorf("attachment file_token %q not found in record %q; verify the record-id/file-token pair", token, recordID)
|
|
}
|
|
result = append(result, item)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func planAttachmentDownloadTargets(runtime *common.RuntimeContext, items []baseAttachmentDownloadItem, outputPath string, outputIsDir bool, overwrite bool) ([]baseAttachmentDownloadTarget, error) {
|
|
names := downloadTargetNames(items, outputIsDir || outputPathLooksDirectory(runtime, outputPath))
|
|
targets := make([]baseAttachmentDownloadTarget, 0, len(items))
|
|
seen := map[string]baseAttachmentDownloadItem{}
|
|
for _, item := range items {
|
|
targetName := names[item.FileToken]
|
|
targetPath := outputPath
|
|
if targetName != "" {
|
|
targetPath = filepath.Join(outputPath, targetName)
|
|
}
|
|
resolved, err := runtime.ResolveSavePath(targetPath)
|
|
if err != nil {
|
|
return nil, baseValidationErrorf("unsafe output path: %s", err)
|
|
}
|
|
if previous, exists := seen[resolved]; exists {
|
|
return nil, baseValidationErrorf("multiple attachments resolve to the same output path %q (%s and %s); download them separately or choose a different directory", resolved, previous.FileToken, item.FileToken)
|
|
}
|
|
seen[resolved] = item
|
|
if !overwrite {
|
|
if _, statErr := runtime.FileIO().Stat(targetPath); statErr == nil {
|
|
return nil, baseValidationErrorf("output file already exists: %s (use --overwrite to replace)", targetPath)
|
|
}
|
|
}
|
|
targets = append(targets, baseAttachmentDownloadTarget{
|
|
Item: item,
|
|
TargetPath: targetPath,
|
|
ResolvedPath: resolved,
|
|
})
|
|
}
|
|
return targets, nil
|
|
}
|
|
|
|
func downloadTargetNames(items []baseAttachmentDownloadItem, outputIsDir bool) map[string]string {
|
|
if !outputIsDir {
|
|
return nil
|
|
}
|
|
nameCounts := make(map[string]int, len(items))
|
|
for _, item := range items {
|
|
nameCounts[baseAttachmentDownloadName(item)]++
|
|
}
|
|
names := make(map[string]string, len(items))
|
|
for _, item := range items {
|
|
name := baseAttachmentDownloadName(item)
|
|
if nameCounts[name] > 1 {
|
|
name = attachmentNameWithTokenSuffix(name, item.FileToken)
|
|
}
|
|
names[item.FileToken] = name
|
|
}
|
|
return names
|
|
}
|
|
|
|
func baseAttachmentDownloadName(item baseAttachmentDownloadItem) string {
|
|
name := filepath.Base(strings.TrimSpace(item.Name))
|
|
if name == "" || name == "." || name == string(filepath.Separator) {
|
|
name = item.FileToken
|
|
}
|
|
return name
|
|
}
|
|
|
|
func attachmentNameWithTokenSuffix(name, fileToken string) string {
|
|
ext := filepath.Ext(name)
|
|
stem := strings.TrimSuffix(name, ext)
|
|
if stem == "" {
|
|
stem = name
|
|
}
|
|
return stem + "_" + safeAttachmentFileTokenSuffix(fileToken) + ext
|
|
}
|
|
|
|
func safeAttachmentFileTokenSuffix(fileToken string) string {
|
|
var b strings.Builder
|
|
for _, r := range fileToken {
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
|
b.WriteRune(r)
|
|
continue
|
|
}
|
|
b.WriteByte('_')
|
|
}
|
|
suffix := strings.Trim(b.String(), "_")
|
|
if suffix == "" {
|
|
return "file"
|
|
}
|
|
return suffix
|
|
}
|
|
|
|
func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext, item baseAttachmentDownloadItem, targetPath string, overwrite bool) (map[string]interface{}, error) {
|
|
if _, err := runtime.ResolveSavePath(targetPath); err != nil {
|
|
return nil, baseValidationErrorf("unsafe output path: %s", err)
|
|
}
|
|
|
|
query := larkcore.QueryParams{}
|
|
if item.ExtraInfo != "" {
|
|
query.Set("extra", item.ExtraInfo)
|
|
}
|
|
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
|
HttpMethod: http.MethodGet,
|
|
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/download", validate.EncodePathSegment(item.FileToken)),
|
|
QueryParams: query,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if !overwrite {
|
|
if _, statErr := runtime.FileIO().Stat(targetPath); statErr == nil {
|
|
return nil, baseValidationErrorf("output file already exists: %s (use --overwrite to replace)", targetPath)
|
|
}
|
|
}
|
|
result, err := runtime.FileIO().Save(targetPath, fileio.SaveOptions{
|
|
ContentType: resp.Header.Get("Content-Type"),
|
|
ContentLength: resp.ContentLength,
|
|
}, resp.Body)
|
|
if err != nil {
|
|
return nil, baseSaveError(err)
|
|
}
|
|
savedPath, _ := runtime.ResolveSavePath(targetPath)
|
|
if savedPath == "" {
|
|
savedPath = targetPath
|
|
}
|
|
return map[string]interface{}{
|
|
"record_id": item.RecordID,
|
|
"field_id": item.FieldID,
|
|
"file_token": item.FileToken,
|
|
"name": item.Name,
|
|
"size": item.Size,
|
|
"saved_path": savedPath,
|
|
"size_bytes": result.Size(),
|
|
"content_type": resp.Header.Get("Content-Type"),
|
|
}, nil
|
|
}
|
|
|
|
func attachmentDownloadFailure(target baseAttachmentDownloadTarget, err error) map[string]interface{} {
|
|
failure := map[string]interface{}{
|
|
"record_id": target.Item.RecordID,
|
|
"field_id": target.Item.FieldID,
|
|
"file_token": target.Item.FileToken,
|
|
"name": target.Item.Name,
|
|
"target_path": target.TargetPath,
|
|
"resolved_path": target.ResolvedPath,
|
|
"error": err.Error(),
|
|
}
|
|
if p, ok := errs.ProblemOf(err); ok {
|
|
failure["type"] = string(p.Category)
|
|
failure["subtype"] = string(p.Subtype)
|
|
if p.Code != 0 {
|
|
failure["code"] = p.Code
|
|
}
|
|
if p.LogID != "" {
|
|
failure["log_id"] = p.LogID
|
|
}
|
|
}
|
|
return failure
|
|
}
|
|
|
|
func attachmentDownloadProgressError(runtime *common.RuntimeContext, err error, downloaded []map[string]interface{}, failed []map[string]interface{}) error {
|
|
msg := fmt.Sprintf("download failed after %d attachment(s) succeeded and %d failed: %v", len(downloaded), len(failed), err)
|
|
payload := map[string]interface{}{
|
|
"message": msg,
|
|
"downloaded": downloaded,
|
|
"failed": failed,
|
|
}
|
|
const hint = "Some files may already have been saved. Inspect downloaded before retrying, or rerun with --overwrite if the failed target now exists."
|
|
payload["hint"] = hint
|
|
if p, ok := errs.ProblemOf(err); ok {
|
|
payload["type"] = string(p.Category)
|
|
payload["subtype"] = string(p.Subtype)
|
|
if p.Code != 0 {
|
|
payload["code"] = p.Code
|
|
}
|
|
}
|
|
if logID := baseAttachmentDownloadLogID(err); logID != "" {
|
|
payload["log_id"] = logID
|
|
}
|
|
return runtime.OutPartialFailure(payload, nil)
|
|
}
|
|
|
|
func baseAttachmentDownloadLogID(err error) string {
|
|
if p, ok := errs.ProblemOf(err); ok {
|
|
if logID := strings.TrimSpace(p.LogID); logID != "" {
|
|
return logID
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func outputPathLooksDirectory(runtime *common.RuntimeContext, outputPath string) bool {
|
|
if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, string(filepath.Separator)) {
|
|
return true
|
|
}
|
|
info, err := runtime.FileIO().Stat(outputPath)
|
|
return err == nil && info.IsDir()
|
|
}
|
|
|
|
func stripMIMEParams(value string) string {
|
|
if i := strings.IndexByte(value, ';'); i != -1 {
|
|
value = value[:i]
|
|
}
|
|
return strings.TrimSpace(value)
|
|
}
|
|
|
|
func detectAttachmentMIMEFromContent(content []byte) string {
|
|
if len(content) == 0 {
|
|
return "application/octet-stream"
|
|
}
|
|
if bytes.HasPrefix(content, []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) {
|
|
return "image/png"
|
|
}
|
|
if bytes.HasPrefix(content, []byte{0xff, 0xd8, 0xff}) {
|
|
return "image/jpeg"
|
|
}
|
|
if bytes.HasPrefix(content, []byte("GIF87a")) || bytes.HasPrefix(content, []byte("GIF89a")) {
|
|
return "image/gif"
|
|
}
|
|
if len(content) >= 12 && bytes.Equal(content[:4], []byte("RIFF")) && bytes.Equal(content[8:12], []byte("WEBP")) {
|
|
return "image/webp"
|
|
}
|
|
if bytes.HasPrefix(content, []byte("%PDF-")) {
|
|
return "application/pdf"
|
|
}
|
|
if looksLikeText(content) {
|
|
return "text/plain"
|
|
}
|
|
return "application/octet-stream"
|
|
}
|
|
|
|
func looksLikeText(content []byte) bool {
|
|
if !utf8.Valid(content) {
|
|
return false
|
|
}
|
|
for _, r := range string(content) {
|
|
if r == '\n' || r == '\r' || r == '\t' {
|
|
continue
|
|
}
|
|
if r < 0x20 || r == 0x7f {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|