mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 16:18:05 +08:00
feat: support Base attachment APIs (#887)
* feat: support base attachment APIs * fix: handle duplicate base attachment downloads * fix: remove unused attachment token helper
This commit is contained in:
@@ -149,29 +149,26 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
assertDryRunContains(t, dryRunRecordGet(ctx, getJSONRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_3"]`, `"select_fields":["Status"]`)
|
||||
assertDryRunContains(t, dryRunRecordDelete(ctx, getJSONRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_delete", `"record_id_list":["rec_3"]`)
|
||||
|
||||
uploadAttachmentRT := newBaseTestRuntime(
|
||||
uploadAttachmentRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
"table-id": "tbl_1",
|
||||
"record-id": "rec_1",
|
||||
"field-id": "fld_att",
|
||||
"file": "/tmp/report.pdf",
|
||||
"name": "report-final.pdf",
|
||||
},
|
||||
map[string][]string{"file": {"/tmp/report.pdf"}},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assertDryRunContains(t,
|
||||
BaseRecordUploadAttachment.DryRun(ctx, uploadAttachmentRT),
|
||||
"GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_att",
|
||||
"GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1",
|
||||
"POST /open-apis/drive/v1/medias/upload_all",
|
||||
"bitable_file",
|
||||
"PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1",
|
||||
"report-final.pdf",
|
||||
`"mime_type":"\u003cdetected_mime_type\u003e"`,
|
||||
`"size":"\u003cfile_size\u003e"`,
|
||||
"deprecated_set_attachment",
|
||||
"POST /open-apis/base/v3/bases/app_x/tables/tbl_1/append_attachments",
|
||||
"report.pdf",
|
||||
`"image_width":"\u003cimage_width_if_image\u003e"`,
|
||||
`"image_height":"\u003cimage_height_if_image\u003e"`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -15,6 +20,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -1589,12 +1595,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
t.Run("upload attachment", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "base-attachment-*.txt")
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "base-attachment-*.png")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp() err=%v", err)
|
||||
}
|
||||
if _, err := tmpFile.WriteString("hello attachment"); err != nil {
|
||||
t.Fatalf("WriteString() err=%v", err)
|
||||
img := image.NewRGBA(image.Rect(0, 0, 3, 2))
|
||||
img.Set(0, 0, color.RGBA{R: 255, A: 255})
|
||||
if err := png.Encode(tmpFile, img); err != nil {
|
||||
t.Fatalf("png.Encode() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
t.Fatalf("Close() err=%v", err)
|
||||
@@ -1609,28 +1617,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{
|
||||
"附件": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "existing_tok",
|
||||
"name": "existing.pdf",
|
||||
"size": 2048,
|
||||
"image_width": 640,
|
||||
"image_height": 480,
|
||||
"deprecated_set_attachment": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
@@ -1640,34 +1626,27 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
updateStub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
appendStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/append_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{
|
||||
"附件": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "existing_tok",
|
||||
"name": "existing.pdf",
|
||||
"size": 2048,
|
||||
"image_width": 640,
|
||||
"image_height": 480,
|
||||
"deprecated_set_attachment": true,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"file_token": "file_tok_1",
|
||||
"name": "report.txt",
|
||||
"deprecated_set_attachment": true,
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "file_tok_1",
|
||||
"name": "base-attachment.png",
|
||||
"size": 73,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(updateStub)
|
||||
reg.Register(appendStub)
|
||||
|
||||
if err := runShortcut(t, BaseRecordUploadAttachment, []string{
|
||||
"+record-upload-attachment",
|
||||
@@ -1676,11 +1655,10 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file", "./" + filepath.Base(tmpFile.Name()),
|
||||
"--name", "report.txt",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"file_tok_1"`) || !strings.Contains(got, `"report.txt"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"file_tok_1"`) || strings.Contains(got, `"updated"`) || strings.Contains(got, `"uploaded"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
|
||||
@@ -1689,19 +1667,13 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
t.Fatalf("upload body=%s", uploadBody)
|
||||
}
|
||||
|
||||
updateBody := string(updateStub.CapturedBody)
|
||||
if !strings.Contains(updateBody, `"附件"`) ||
|
||||
!strings.Contains(updateBody, `"file_token":"existing_tok"`) ||
|
||||
!strings.Contains(updateBody, `"name":"existing.pdf"`) ||
|
||||
!strings.Contains(updateBody, `"size":2048`) ||
|
||||
!strings.Contains(updateBody, `"image_width":640`) ||
|
||||
!strings.Contains(updateBody, `"image_height":480`) ||
|
||||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) ||
|
||||
!strings.Contains(updateBody, `"file_token":"file_tok_1"`) ||
|
||||
!strings.Contains(updateBody, `"name":"report.txt"`) ||
|
||||
!strings.Contains(updateBody, `"size":16`) ||
|
||||
!strings.Contains(updateBody, `"mime_type":"text/plain"`) {
|
||||
t.Fatalf("update body=%s", updateBody)
|
||||
appendBody := string(appendStub.CapturedBody)
|
||||
if !strings.Contains(appendBody, `"rec_x"`) ||
|
||||
!strings.Contains(appendBody, `"fld_att"`) ||
|
||||
!strings.Contains(appendBody, `"file_token":"file_tok_1"`) ||
|
||||
!strings.Contains(appendBody, `"image_width":3`) ||
|
||||
!strings.Contains(appendBody, `"image_height":2`) {
|
||||
t.Fatalf("append body=%s", appendBody)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1728,17 +1700,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
prepareStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -1778,26 +1739,23 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
reg.Register(finishStub)
|
||||
|
||||
updateStub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
appendStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/append_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{
|
||||
"附件": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "file_tok_big",
|
||||
"name": "large-report.bin",
|
||||
"deprecated_set_attachment": true,
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "file_tok_big"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(updateStub)
|
||||
reg.Register(appendStub)
|
||||
|
||||
if err := runShortcut(t, BaseRecordUploadAttachment, []string{
|
||||
"+record-upload-attachment",
|
||||
@@ -1806,17 +1764,16 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file", "./" + filepath.Base(tmpFile.Name()),
|
||||
"--name", "large-report.bin",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"file_tok_big"`) || !strings.Contains(got, `"large-report.bin"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"file_tok_big"`) || strings.Contains(got, `"updated"`) || strings.Contains(got, `"uploaded"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
|
||||
prepareBody := string(prepareStub.CapturedBody)
|
||||
if !strings.Contains(prepareBody, `"file_name":"large-report.bin"`) ||
|
||||
if !strings.Contains(prepareBody, `"file_name":"`+filepath.Base(tmpFile.Name())+`"`) ||
|
||||
!strings.Contains(prepareBody, `"parent_type":"bitable_file"`) ||
|
||||
!strings.Contains(prepareBody, `"parent_node":"app_x"`) ||
|
||||
!strings.Contains(prepareBody, `"size":20971521`) {
|
||||
@@ -1847,14 +1804,11 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
t.Fatalf("finish body=%s", finishBody)
|
||||
}
|
||||
|
||||
updateBody := string(updateStub.CapturedBody)
|
||||
if !strings.Contains(updateBody, `"附件"`) ||
|
||||
!strings.Contains(updateBody, `"file_token":"file_tok_big"`) ||
|
||||
!strings.Contains(updateBody, `"name":"large-report.bin"`) ||
|
||||
!strings.Contains(updateBody, `"size":20971521`) ||
|
||||
!strings.Contains(updateBody, `"mime_type":"application/octet-stream"`) ||
|
||||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) {
|
||||
t.Fatalf("update body=%s", updateBody)
|
||||
appendBody := string(appendStub.CapturedBody)
|
||||
if !strings.Contains(appendBody, `"rec_x"`) ||
|
||||
!strings.Contains(appendBody, `"fld_att"`) ||
|
||||
!strings.Contains(appendBody, `"file_token":"file_tok_big"`) {
|
||||
t.Fatalf("append body=%s", appendBody)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1928,6 +1882,434 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload attachment rejects deprecated name flag", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "base-name-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
t.Fatalf("Close() err=%v", err)
|
||||
}
|
||||
withBaseWorkingDir(t, filepath.Dir(tmpFile.Name()))
|
||||
|
||||
err = runShortcut(t, BaseRecordUploadAttachment, []string{
|
||||
"+record-upload-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file", "./" + filepath.Base(tmpFile.Name()),
|
||||
"--name", "renamed.txt",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--name is no longer supported") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download attachment uses extra info", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
extra := `{"bitablePerm":{"tableId":"tbl_x","attachments":{"fld_att":{"rec_x":["box_a"]}}}}`
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "box_a",
|
||||
"name": "pic.png",
|
||||
"size": 7,
|
||||
"extra_info": extra,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
downloadStub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_a/download?" + url.Values{"extra": []string{extra}}.Encode(),
|
||||
RawBody: []byte("payload"),
|
||||
ContentType: "image/png",
|
||||
}
|
||||
reg.Register(downloadStub)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := os.Mkdir("downloads", 0700); err != nil {
|
||||
t.Fatalf("Mkdir() err=%v", err)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--file-token", "box_a",
|
||||
"--output", "downloads",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "pic.png")); err != nil {
|
||||
t.Fatalf("expected downloaded file: %v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
gotItems, _ := data["downloaded"].([]interface{})
|
||||
if len(gotItems) != 1 {
|
||||
t.Fatalf("downloaded=%#v", data["downloaded"])
|
||||
}
|
||||
got, _ := gotItems[0].(map[string]interface{})
|
||||
if got["file_token"] != "box_a" || got["saved_path"] == "" || got["extra_info_used"] != nil {
|
||||
t.Fatalf("download output=%#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download all row attachments when file token omitted", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
|
||||
map[string]interface{}{"file_token": "box_b", "name": "b.txt", "size": 8},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_a/download",
|
||||
RawBody: []byte("payload-a"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_b/download",
|
||||
RawBody: []byte("payload-b"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := os.Mkdir("downloads", 0700); err != nil {
|
||||
t.Fatalf("Mkdir() err=%v", err)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--output", "downloads",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
|
||||
t.Fatalf("expected downloaded file a.txt: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "b.txt")); err != nil {
|
||||
t.Fatalf("expected downloaded file b.txt: %v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
gotItems, _ := data["downloaded"].([]interface{})
|
||||
if len(gotItems) != 2 {
|
||||
t.Fatalf("downloaded=%#v", data["downloaded"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download without file token requires output directory", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
|
||||
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--output", "file.txt",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--output must be an existing directory") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download all disambiguates duplicate attachment names with file token", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "box_a", "name": "same.txt", "size": 7},
|
||||
map[string]interface{}{"file_token": "box_a", "name": "same.txt", "size": 7},
|
||||
map[string]interface{}{"file_token": "box_b", "name": "same.txt", "size": 8},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_a/download",
|
||||
RawBody: []byte("payload-a"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_b/download",
|
||||
RawBody: []byte("payload-b"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := os.Mkdir("downloads", 0700); err != nil {
|
||||
t.Fatalf("Mkdir() err=%v", err)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--output", "downloads",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "same_box_a.txt")); err != nil {
|
||||
t.Fatalf("expected downloaded file same_box_a.txt: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "same_box_b.txt")); err != nil {
|
||||
t.Fatalf("expected downloaded file same_box_b.txt: %v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
gotItems, _ := data["downloaded"].([]interface{})
|
||||
if len(gotItems) != 2 {
|
||||
t.Fatalf("downloaded=%#v", data["downloaded"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download duplicate requested file token only once", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_a/download",
|
||||
RawBody: []byte("payload-a"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--file-token", "box_a",
|
||||
"--file-token", "box_a",
|
||||
"--output", "a.txt",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
gotItems, _ := data["downloaded"].([]interface{})
|
||||
if len(gotItems) != 1 {
|
||||
t.Fatalf("downloaded=%#v", data["downloaded"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download all preflights local target conflicts before writing", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
|
||||
map[string]interface{}{"file_token": "box_b", "name": "b.txt", "size": 8},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := os.Mkdir("downloads", 0700); err != nil {
|
||||
t.Fatalf("Mkdir() err=%v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("downloads", "b.txt"), []byte("existing"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile() err=%v", err)
|
||||
}
|
||||
|
||||
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--output", "downloads",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "output file already exists: downloads/b.txt") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err == nil {
|
||||
t.Fatalf("a.txt should not be written after preflight conflict")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download reports progress when later attachment fails", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
|
||||
map[string]interface{}{"file_token": "box_b", "name": "b.txt", "size": 8},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_a/download",
|
||||
RawBody: []byte("payload-a"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_b/download",
|
||||
Status: 500,
|
||||
RawBody: []byte("server error"),
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := os.Mkdir("downloads", 0700); err != nil {
|
||||
t.Fatalf("Mkdir() err=%v", err)
|
||||
}
|
||||
|
||||
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--output", "downloads",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "download failed after 1 attachment(s) succeeded and 1 failed") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured error, got %T %v", err, err)
|
||||
}
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
downloaded, _ := detail["downloaded"].([]map[string]interface{})
|
||||
failed, _ := detail["failed"].([]map[string]interface{})
|
||||
if len(downloaded) != 1 || downloaded[0]["file_token"] != "box_a" || len(failed) != 1 || failed[0]["file_token"] != "box_b" {
|
||||
t.Fatalf("detail=%#v", exitErr.Detail.Detail)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
|
||||
t.Fatalf("expected first file to remain: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remove attachment", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_att",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"},
|
||||
},
|
||||
})
|
||||
removeStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/remove_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{"fld_att": []interface{}{}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(removeStub)
|
||||
|
||||
if err := runShortcut(t, BaseRecordRemoveAttachment, []string{
|
||||
"+record-remove-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file-token", "box_a",
|
||||
"--file-token", "box_b",
|
||||
"--yes",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); strings.Contains(got, `"removed"`) || strings.Contains(got, `"updated"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(removeStub.CapturedBody)
|
||||
if !strings.Contains(body, `"rec_x"`) ||
|
||||
!strings.Contains(body, `"fld_att"`) ||
|
||||
!strings.Contains(body, `"file_token":"box_a"`) ||
|
||||
!strings.Contains(body, `"file_token":"box_b"`) {
|
||||
t.Fatalf("remove body=%s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {
|
||||
|
||||
@@ -132,7 +132,7 @@ func TestShortcutsCatalog(t *testing.T) {
|
||||
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
|
||||
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
|
||||
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
|
||||
"+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-share-link-create", "+record-upload-attachment", "+record-delete",
|
||||
"+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-share-link-create", "+record-upload-attachment", "+record-download-attachment", "+record-remove-attachment", "+record-delete",
|
||||
"+record-history-list",
|
||||
"+base-get", "+base-copy", "+base-create",
|
||||
"+role-create", "+role-delete", "+role-update", "+role-list", "+role-get", "+advperm-enable", "+advperm-disable",
|
||||
@@ -175,14 +175,15 @@ func TestBaseFieldUpdateRisk(t *testing.T) {
|
||||
|
||||
func TestBaseDeleteShortcutsRisk(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
BaseFieldDelete.Command: BaseFieldDelete.Risk,
|
||||
BaseViewDelete.Command: BaseViewDelete.Risk,
|
||||
BaseRecordDelete.Command: BaseRecordDelete.Risk,
|
||||
BaseFormDelete.Command: BaseFormDelete.Risk,
|
||||
BaseFormQuestionsDelete.Command: BaseFormQuestionsDelete.Risk,
|
||||
BaseDashboardDelete.Command: BaseDashboardDelete.Risk,
|
||||
BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk,
|
||||
BaseRoleDelete.Command: BaseRoleDelete.Risk,
|
||||
BaseFieldDelete.Command: BaseFieldDelete.Risk,
|
||||
BaseViewDelete.Command: BaseViewDelete.Risk,
|
||||
BaseRecordDelete.Command: BaseRecordDelete.Risk,
|
||||
BaseRecordRemoveAttachment.Command: BaseRecordRemoveAttachment.Risk,
|
||||
BaseFormDelete.Command: BaseFormDelete.Risk,
|
||||
BaseFormQuestionsDelete.Command: BaseFormQuestionsDelete.Risk,
|
||||
BaseDashboardDelete.Command: BaseDashboardDelete.Risk,
|
||||
BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk,
|
||||
BaseRoleDelete.Command: BaseRoleDelete.Risk,
|
||||
}
|
||||
|
||||
for command, risk := range cases {
|
||||
@@ -338,6 +339,79 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantHelp []string
|
||||
wantTips []string
|
||||
}{
|
||||
{
|
||||
name: "upload attachment",
|
||||
shortcut: BaseRecordUploadAttachment,
|
||||
wantHelp: []string{
|
||||
"repeat to append multiple attachments in one cell",
|
||||
"max 50 files, max 2GB each",
|
||||
},
|
||||
wantTips: []string{
|
||||
"lark-cli base +record-upload-attachment",
|
||||
"Repeat --file to append multiple attachments",
|
||||
"Reuse returned file_token values for download/remove",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "download attachment",
|
||||
shortcut: BaseRecordDownloadAttachment,
|
||||
wantHelp: []string{
|
||||
"repeat to download selected files",
|
||||
"omit to download all attachments in the record",
|
||||
"with multiple or omitted file tokens this must be an existing directory",
|
||||
},
|
||||
wantTips: []string{
|
||||
"lark-cli base +record-download-attachment",
|
||||
"Omit --file-token to download every attachment in the record",
|
||||
"Base attachments should be downloaded with this command",
|
||||
"other download commands may fail",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove attachment",
|
||||
shortcut: BaseRecordRemoveAttachment,
|
||||
wantHelp: []string{
|
||||
"remove from the target cell",
|
||||
"max 50 tokens",
|
||||
},
|
||||
wantTips: []string{
|
||||
"lark-cli base +record-remove-attachment",
|
||||
"Repeat --file-token",
|
||||
"requires --yes",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
help := cmd.Flags().FlagUsages()
|
||||
for _, want := range tt.wantHelp {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("flag help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
for _, want := range tt.wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertHelpOrder(t *testing.T, help string, before string, after string) {
|
||||
t.Helper()
|
||||
beforeIndex := strings.Index(help, before)
|
||||
|
||||
@@ -8,27 +8,37 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"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"
|
||||
baseAttachmentMaxBatchSize = 50
|
||||
baseAttachmentGetMaxRecords = 10
|
||||
)
|
||||
|
||||
var BaseRecordUploadAttachment = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-upload-attachment",
|
||||
Description: "Upload a local file to a Base attachment field and write it into the target record",
|
||||
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(),
|
||||
@@ -37,34 +47,99 @@ var BaseRecordUploadAttachment = common.Shortcut{
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(true),
|
||||
fieldRefFlag(true),
|
||||
{Name: "file", Desc: "local file path (max 2GB; files > 20MB use multipart upload automatically)", Required: true},
|
||||
{Name: "name", Desc: "attachment file name (default: local file name)"},
|
||||
{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{
|
||||
`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 {
|
||||
filePath := runtime.Str("file")
|
||||
fileName := strings.TrimSpace(runtime.Str("name"))
|
||||
if fileName == "" {
|
||||
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("4-step orchestration: validate attachment field → read existing record attachments → upload file to Base → patch merged attachment array").
|
||||
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")).
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
Desc("[2] Read current record to preserve existing attachments in the target cell").
|
||||
Set("record_id", runtime.Str("record-id"))
|
||||
Set("field_id", runtime.Str("field-id"))
|
||||
if baseAttachmentShouldUseMultipart(runtime.FileIO(), filePath) {
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_prepare").
|
||||
Desc("[3a] Initialize multipart attachment upload to the current Base").
|
||||
Desc("[2a] Initialize multipart attachment upload to the current Base").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": baseAttachmentParentType,
|
||||
@@ -72,7 +147,7 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
"size": "<file_size>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_part").
|
||||
Desc("[3b] Upload attachment parts (repeated)").
|
||||
Desc("[2b] Upload attachment parts (repeated for each large file)").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
@@ -80,14 +155,14 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
"file": "<chunk_binary>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_finish").
|
||||
Desc("[3c] Finalize multipart attachment upload and get file token").
|
||||
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("[3] Upload local file to the current Base as attachment media (multipart/form-data)").
|
||||
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,
|
||||
@@ -97,46 +172,87 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
})
|
||||
}
|
||||
return dry.
|
||||
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
Desc("[4] Update the target attachment cell with existing attachments plus the uploaded file token").
|
||||
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{}{
|
||||
"<attachment_field_name>": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "<existing_file_token>",
|
||||
"name": "<existing_file_name>",
|
||||
"deprecated_set_attachment": true,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"file_token": "<uploaded_file_token>",
|
||||
"name": fileName,
|
||||
"mime_type": "<detected_mime_type>",
|
||||
"size": "<file_size>",
|
||||
"deprecated_set_attachment": true,
|
||||
"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 executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
filePath := runtime.Str("file")
|
||||
fio := runtime.FileIO()
|
||||
if fio == nil {
|
||||
return output.ErrValidation("file operations require a FileIO provider")
|
||||
}
|
||||
fileInfo, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return output.ErrValidation("unsafe file path: %s", err)
|
||||
}
|
||||
return output.ErrValidation("file not accessible: %s: %v", filePath, err)
|
||||
}
|
||||
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
||||
return output.ErrValidation("file %s exceeds 2GB limit", common.FormatSize(fileInfo.Size()))
|
||||
}
|
||||
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>"})
|
||||
}
|
||||
|
||||
fileName := strings.TrimSpace(runtime.Str("name"))
|
||||
if fileName == "" {
|
||||
fileName = filepath.Base(filePath)
|
||||
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 common.FlagErrorf("--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 {
|
||||
info, statErr := runtime.FileIO().Stat(runtime.Str("output"))
|
||||
if statErr != nil || !info.IsDir() {
|
||||
return common.FlagErrorf("--output must be an existing directory when downloading multiple attachments or when --file-token is omitted")
|
||||
}
|
||||
}
|
||||
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"))
|
||||
@@ -146,44 +262,172 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" {
|
||||
return output.ErrValidation("field %q is type %q, expected attachment", fieldName(field), normalized)
|
||||
}
|
||||
resolvedFieldID := fieldID(field)
|
||||
if resolvedFieldID == "" {
|
||||
resolvedFieldID = runtime.Str("field-id")
|
||||
}
|
||||
|
||||
record, err := fetchBaseRecord(runtime, runtime.Str("base-token"), baseTableID(runtime), runtime.Str("record-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, runtime.Str("base-token"), fileInfo.Size())
|
||||
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
|
||||
}
|
||||
|
||||
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, runtime.Str("base-token"), fileInfo.Size())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
attachments, err := mergeRecordAttachments(record, fieldName(field), attachment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
fieldName(field): attachments,
|
||||
}
|
||||
data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"record": data,
|
||||
"attachment": attachment,
|
||||
"attachments": attachments,
|
||||
"updated": true,
|
||||
}, nil)
|
||||
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 output.ErrValidation("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(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, output.ErrValidation("file operations require a FileIO provider")
|
||||
}
|
||||
fileInfo, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return nil, output.ErrValidation("unsafe file path: %s", err)
|
||||
}
|
||||
return nil, output.ErrValidation("file not accessible: %s: %v", filePath, err)
|
||||
}
|
||||
if fileInfo.IsDir() {
|
||||
return nil, output.ErrValidation("file path is a directory: %s", filePath)
|
||||
}
|
||||
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
||||
return nil, output.ErrValidation("file %s exceeds 2GB limit", 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, common.FlagErrorf("attachment file token %d must not be empty", index+1)
|
||||
}
|
||||
normalized = append(normalized, token)
|
||||
}
|
||||
normalized = dedupeStringsPreserveOrder(normalized)
|
||||
if len(normalized) > baseAttachmentMaxBatchSize {
|
||||
return nil, common.FlagErrorf("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
|
||||
@@ -195,57 +439,24 @@ func fetchBaseField(runtime *common.RuntimeContext, baseToken, tableIDValue, fie
|
||||
return baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil)
|
||||
}
|
||||
|
||||
func fetchBaseRecord(runtime *common.RuntimeContext, baseToken, tableIDValue, recordID string) (map[string]interface{}, error) {
|
||||
return baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "records", recordID), nil, nil)
|
||||
}
|
||||
|
||||
func mergeRecordAttachments(record map[string]interface{}, fieldName string, uploaded map[string]interface{}) ([]interface{}, error) {
|
||||
fields, _ := record["fields"].(map[string]interface{})
|
||||
if fields == nil {
|
||||
return []interface{}{uploaded}, nil
|
||||
func fetchBaseAttachments(runtime *common.RuntimeContext, baseToken, tableIDValue string, recordIDs []string) (map[string]interface{}, error) {
|
||||
if len(recordIDs) == 0 {
|
||||
return nil, output.ErrValidation("provide at least one record id")
|
||||
}
|
||||
current, exists := fields[fieldName]
|
||||
if !exists || util.IsNil(current) {
|
||||
return []interface{}{uploaded}, nil
|
||||
if len(recordIDs) > baseAttachmentGetMaxRecords {
|
||||
return nil, output.ErrValidation("get attachments record selection exceeds maximum limit of %d (got %d)", baseAttachmentGetMaxRecords, len(recordIDs))
|
||||
}
|
||||
items, ok := current.([]interface{})
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record field %q has unexpected attachment payload type %T", fieldName, current)
|
||||
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
|
||||
}
|
||||
merged := make([]interface{}, 0, len(items)+1)
|
||||
for _, item := range items {
|
||||
attachment, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record field %q contains unexpected attachment item type %T", fieldName, item)
|
||||
}
|
||||
merged = append(merged, normalizeAttachmentForPatch(attachment))
|
||||
attachments, _ := data["attachments"].(map[string]interface{})
|
||||
if attachments == nil {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
merged = append(merged, uploaded)
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]interface{} {
|
||||
normalized := map[string]interface{}{}
|
||||
if fileToken, _ := attachment["file_token"].(string); fileToken != "" {
|
||||
normalized["file_token"] = fileToken
|
||||
}
|
||||
if name, _ := attachment["name"].(string); name != "" {
|
||||
normalized["name"] = name
|
||||
}
|
||||
if mimeType, _ := attachment["mime_type"].(string); mimeType != "" {
|
||||
normalized["mime_type"] = mimeType
|
||||
}
|
||||
if size, ok := attachment["size"]; ok && !util.IsNil(size) {
|
||||
normalized["size"] = size
|
||||
}
|
||||
if imageWidth, ok := attachment["image_width"]; ok && !util.IsNil(imageWidth) {
|
||||
normalized["image_width"] = imageWidth
|
||||
}
|
||||
if imageHeight, ok := attachment["image_height"]; ok && !util.IsNil(imageHeight) {
|
||||
normalized["image_height"] = imageHeight
|
||||
}
|
||||
normalized["deprecated_set_attachment"] = true
|
||||
return normalized
|
||||
return attachments, nil
|
||||
}
|
||||
|
||||
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) {
|
||||
@@ -280,15 +491,51 @@ func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName,
|
||||
}
|
||||
|
||||
attachment := map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"name": fileName,
|
||||
"mime_type": mimeType,
|
||||
"size": fileSize,
|
||||
"deprecated_set_attachment": true,
|
||||
"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 strings.HasPrefix(mimeType, "image/") {
|
||||
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
|
||||
@@ -311,6 +558,300 @@ func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (str
|
||||
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
|
||||
}
|
||||
|
||||
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, output.ErrValidation("record %q has no attachment metadata; verify the record-id", recordID)
|
||||
}
|
||||
fields, ok := recordRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("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, output.ErrValidation("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, output.ErrValidation("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, output.ErrValidation("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, output.ErrValidation("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, output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
if previous, exists := seen[resolved]; exists {
|
||||
return nil, output.ErrValidation("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, output.ErrValidation("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, output.ErrValidation("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, output.ErrNetwork("download failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if !overwrite {
|
||||
if _, statErr := runtime.FileIO().Stat(targetPath); statErr == nil {
|
||||
return nil, output.ErrValidation("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, common.WrapSaveErrorByCategory(err, "io")
|
||||
}
|
||||
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{} {
|
||||
return 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(),
|
||||
}
|
||||
}
|
||||
|
||||
func attachmentDownloadProgressError(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)
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
return &output.ExitError{
|
||||
Code: exitErr.Code,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: exitErr.Detail.Type,
|
||||
Code: exitErr.Detail.Code,
|
||||
Message: msg,
|
||||
Hint: "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists.",
|
||||
Detail: map[string]interface{}{
|
||||
"downloaded": downloaded,
|
||||
"failed": failed,
|
||||
},
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitInternal,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "io",
|
||||
Message: msg,
|
||||
Hint: "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists.",
|
||||
Detail: map[string]interface{}{
|
||||
"downloaded": downloaded,
|
||||
"failed": failed,
|
||||
},
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
@@ -5,6 +5,9 @@ package base
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
@@ -82,6 +85,21 @@ func TestDetectAttachmentMIMETypeFallsBackToContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAttachmentImageDimensions(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
img := image.NewRGBA(image.Rect(0, 0, 4, 3))
|
||||
img.Set(0, 0, color.RGBA{G: 255, A: 255})
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("png.Encode() error = %v", err)
|
||||
}
|
||||
fio := attachmentTestFileIO{openFile: newAttachmentTestFile(buf.Bytes())}
|
||||
|
||||
width, height, ok := detectAttachmentImageDimensions(fio, "image.png", "image/png")
|
||||
if !ok || width != 4 || height != 3 {
|
||||
t.Fatalf("detectAttachmentImageDimensions() = (%d,%d,%v), want (4,3,true)", width, height, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAttachmentMIMETypeWrapsOpenError(t *testing.T) {
|
||||
fio := attachmentTestFileIO{openErr: os.ErrNotExist}
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ func Shortcuts() []common.Shortcut {
|
||||
BaseRecordBatchUpdate,
|
||||
BaseRecordShareLinkCreate,
|
||||
BaseRecordUploadAttachment,
|
||||
BaseRecordDownloadAttachment,
|
||||
BaseRecordRemoveAttachment,
|
||||
BaseRecordDelete,
|
||||
BaseRecordHistoryList,
|
||||
BaseBaseGet,
|
||||
|
||||
@@ -107,8 +107,9 @@ metadata:
|
||||
|------|------------------|----------------|----------|
|
||||
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或按 ID 获取一条或多条记录 | [`lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md) | 记录读取统一先读 data analysis SOP:已知 `record_id` 用 `+record-get`;明确关键词用 `+record-search`;普通明细用 `+record-list`;明确筛选 / 排序 / Top N 用临时视图投影后 `+record-list --view-id`;统计聚合才分流到 `+data-query`;`+record-get` 支持重复 `--record-id` 或 `--json` 读取多条记录 |
|
||||
| `+record-upsert / +record-batch-create / +record-batch-update` | 创建、更新或批量写入记录 | [`lark-base-record-upsert.md`](references/lark-base-record-upsert.md)、[`lark-base-record-batch-create.md`](references/lark-base-record-batch-create.md)、[`lark-base-record-batch-update.md`](references/lark-base-record-batch-update.md)、[`lark-base-cell-value.md`](references/lark-base-cell-value.md) | 写前先 `+field-list`;只写存储字段;`+record-batch-update` 为同值更新(同一 patch 应用到多条记录);批量单次不超过 `200` 条;附件不要走这里 |
|
||||
| `+record-upload-attachment` | 给已有记录上传附件 | [`lark-base-record-upload-attachment.md`](references/lark-base-record-upload-attachment.md) | 附件上传专用链路,不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
|
||||
| `lark-cli docs +media-download` | 下载 Base 附件文件到本地 | [`../lark-doc/references/lark-doc-media-download.md`](../lark-doc/references/lark-doc-media-download.md) | Base 附件的 `file_token` 从 `+record-get` 返回的附件字段数组里取;**不要用 `lark-cli drive +download`**(对 Base 附件返回 403) |
|
||||
| `+record-upload-attachment` | 给已有记录上传一个或多个附件 | 看 `lark-cli base +record-upload-attachment --help` | 附件上传专用链路,不要用 `+record-upsert` / `+record-batch-*` 伪造附件值;不支持 `--name` |
|
||||
| `+record-download-attachment` | 下载一个或多个 Base 附件到本地 | 看 `lark-cli base +record-download-attachment --help` | Base 附件必须用这个命令下载;用其他下载入口可能失败 |
|
||||
| `+record-remove-attachment` | 删除附件字段里的一个或多个附件 | 看 `lark-cli base +record-remove-attachment --help` | 删除操作;确认目标后带 `--yes` |
|
||||
| `+record-delete` | 删除一条或多条记录 | [`lark-base-record-delete.md`](references/lark-base-record-delete.md) | 删除多条时重复传 `--record-id` 指定多个记录;用户已明确目标可直接执行并带 `--yes` |
|
||||
| `+record-history-list` | 查询指定记录的变更历史 | [`lark-base-record-history-list.md`](references/lark-base-record-history-list.md) | 按 `table-id + record-id` 查询,不支持整表扫描;`+record-history-list` 只能串行执行 |
|
||||
| `+record-share-link-create` | 为一条或多条记录生成分享链接 | [`lark-base-record-share-link-create.md`](references/lark-base-record-share-link-create.md) | 单次最多 100 条;重复 record_id 会自动去重;适合分享单条记录或批量分享场景 |
|
||||
@@ -211,7 +212,7 @@ metadata:
|
||||
| 字段类型 | 含义 | 能否直接作为 `+record-upsert / +record-batch-create / +record-batch-update` 写入目标 | 说明 |
|
||||
|----------|------|-----------------------------------------------------------|------|
|
||||
| 存储字段 | 真实存用户输入的数据 | 可以 | 常见如文本、数字、日期、单选、多选、人员、关联 |
|
||||
| 附件字段 | 存储文件附件 | 不应直接按普通字段写 | 上传附件走 `+record-upload-attachment`;下载附件走 `lark-cli docs +media-download` |
|
||||
| 附件字段 | 存储文件附件 | 不应直接按普通字段写 | 上传附件走 `+record-upload-attachment`;下载附件走 `+record-download-attachment`;删除附件走 `+record-remove-attachment` |
|
||||
| 系统字段 | 平台自动维护 | 不可以 | 常见如创建时间、更新时间、创建人、修改人、自动编号 |
|
||||
| `formula` 字段 | 通过表达式计算 | 不可以 | 只读字段 |
|
||||
| `lookup` 字段 | 通过跨表规则查找引用 | 不可以 | 只读字段 |
|
||||
@@ -225,7 +226,7 @@ metadata:
|
||||
| 用户明确要求 lookup,或天然是固定查找配置 | `lookup` 字段 | 不要默认先上 lookup;先判断 formula 是否更合适 |
|
||||
| 读取原始记录明细 / 关键词检索 / 导出 | `+record-search / +record-list / +record-get` | 不要拿 `+data-query` 当取数命令 |
|
||||
| 上传附件到记录 | `+record-upload-attachment` | 不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
|
||||
| 下载记录里的附件文件 | `lark-cli docs +media-download --token <file_token> --output <path>` | `file_token` 从 `+record-get` 返回的附件字段里取;用法见 [`../lark-doc/references/lark-doc-media-download.md`](../lark-doc/references/lark-doc-media-download.md) |
|
||||
| 下载记录里的附件文件 | `+record-download-attachment --record-id <record_id> --output <dir>`,可加 `--file-token <file_token>` 只下指定附件 | Base 附件必须用这个命令下载;用其他下载入口可能失败 |
|
||||
| 基于视图做筛选读取 | `+view-set-filter` + `+record-list` | 不要跳过视图筛选直接猜条件 |
|
||||
| 本地 Excel / CSV / `.base` 导入为 Base | `lark-cli drive +import --type bitable` | 不要误走 `+base-create`、`+table-create` 或 `+record-upsert` |
|
||||
|
||||
|
||||
@@ -119,9 +119,9 @@
|
||||
|
||||
### 2.9 attachment(不作为普通 CellValue 写入)
|
||||
|
||||
用户要把本地文件加到记录里时,必须使用 `lark-cli base +record-upload-attachment --file <path>` 上传到已有记录。不能用普通记录操作接口来上传附件。
|
||||
|
||||
`+record-get` 返回的附件字段单元格包含 `file_token` 和文件名,可以把 `file_token` 交给 `lark-cli docs +media-download` 进行附件下载。
|
||||
- 追加附件:使用 `lark-cli base +record-upload-attachment --record-id <record_id> --field-id <field_id> --file <path>`;可重复 `--file` 一次追加多个附件,不能用普通记录操作接口写附件值。
|
||||
- 删除附件:使用 `lark-cli base +record-remove-attachment --record-id <record_id> --field-id <field_id> --file-token <file_token> --yes`;可重复 `--file-token` 一次删除同一单元格里的多个附件。
|
||||
- 下载附件:使用 `lark-cli base +record-download-attachment --record-id <record_id> --file-token <file_token> --output <dir>`;不传 `--file-token` 时下载整行所有附件,也可重复 `--file-token` 只下载指定附件。Base 附件必须用这个命令下载,用其他下载入口可能失败。
|
||||
|
||||
## 3. 只读字段(不要写)
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
# base +record-upload-attachment
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
上传本地文件到当前 Base,并把附件值写入指定记录的附件字段。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
lark-cli base +record-upload-attachment \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--record-id <record_id> \
|
||||
--field-id <field_id> \
|
||||
--file ./report.pdf
|
||||
|
||||
lark-cli base +record-upload-attachment \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--record-id <record_id> \
|
||||
--field-id "附件" \
|
||||
--file ./report.pdf \
|
||||
--name "Q1-final.pdf"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-token <token>` | 是 | Base Token |
|
||||
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
|
||||
| `--record-id <id>` | 是 | 记录 ID |
|
||||
| `--field-id <id_or_name>` | 是 | 附件字段 ID 或字段名 |
|
||||
| `--file <path>` | 是 | 本地文件路径,最大 2GB |
|
||||
| `--name <name>` | 否 | 写入附件字段时显示的文件名,默认使用本地文件名 |
|
||||
|
||||
|
||||
## 工作流
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是写入操作。用户已经明确要上传到某条记录的某个附件字段时可直接执行;如果 `record-id` 或目标字段仍有歧义,再先确认。
|
||||
|
||||
## 坑点
|
||||
|
||||
- ⚠️ 目标字段必须是 `attachment` 字段。
|
||||
- ⚠️ 记录里的附件 `file_token` 属于 Drive media token;下载时不要走 `lark-cli drive +download`,应使用 `lark-cli docs +media-download --token <file_token> --output <path>`。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-base-record.md](lark-base-record.md) — record 索引页
|
||||
@@ -12,8 +12,9 @@ record 相关命令索引。
|
||||
| [lark-base-record-upsert.md](lark-base-record-upsert.md) | `+record-upsert` | 创建或更新记录 |
|
||||
| [lark-base-record-batch-create.md](lark-base-record-batch-create.md) | `+record-batch-create` | 按 `fields/rows` 批量创建记录 |
|
||||
| [lark-base-record-batch-update.md](lark-base-record-batch-update.md) | `+record-batch-update` | 批量更新记录 |
|
||||
| [lark-base-record-upload-attachment.md](lark-base-record-upload-attachment.md) | `+record-upload-attachment` | 上传本地文件到附件字段并更新记录 |
|
||||
| [`../../lark-doc/references/lark-doc-media-download.md`](../../lark-doc/references/lark-doc-media-download.md) | `lark-cli docs +media-download` | 下载 Base 附件到本地(附件的 `file_token` 来自 `+record-get` 的附件字段) |
|
||||
| `--help` | `+record-upload-attachment` | 上传一个或多个本地文件到附件字段 |
|
||||
| `--help` | `+record-download-attachment` | 下载一个或多个 Base 附件到本地;Base 附件必须用这个命令下载 |
|
||||
| `--help` | `+record-remove-attachment` | 删除附件字段中的一个或多个附件 |
|
||||
| [lark-base-record-delete.md](lark-base-record-delete.md) | `+record-delete` | 删除一条或多条记录 |
|
||||
| [lark-base-record-share-link-create.md](lark-base-record-share-link-create.md) | `+record-share-link-create` | 生成记录分享链接(支持单条或批量,最多 100 条)|
|
||||
|
||||
@@ -25,5 +26,6 @@ record 相关命令索引。
|
||||
- `+record-list` 支持重复传参 `--field-id` 做字段筛选。
|
||||
- `+record-get` 支持重复 `--record-id` 或 `--json '{"record_id_list":[...]}'` 批量读取;也支持重复传参 `--field-id` 裁剪返回字段,避免返回全字段。
|
||||
- 写记录 JSON 前优先阅读 [lark-base-cell-value.md](lark-base-cell-value.md)。
|
||||
- 本地文件写入附件字段时,必须使用 `+record-upload-attachment`。
|
||||
- 从附件字段下载文件时,用 `lark-cli docs +media-download --token <file_token> --output <path>`,用法见 [`../../lark-doc/references/lark-doc-media-download.md`](../../lark-doc/references/lark-doc-media-download.md)。
|
||||
- 本地文件写入一个或多个附件字段时,必须使用 `+record-upload-attachment`。
|
||||
- 从附件字段下载一个或多个文件时,用 `+record-download-attachment`。
|
||||
- 删除附件字段里的文件时,用 `+record-remove-attachment --yes`。
|
||||
|
||||
121
tests/cli_e2e/base/base_attachment_dryrun_test.go
Normal file
121
tests/cli_e2e/base/base_attachment_dryrun_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestBase_AttachmentDryRun(t *testing.T) {
|
||||
setBaseDryRunConfigEnv(t)
|
||||
|
||||
workDir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(workDir, "report.txt"), []byte("hello"), 0o600))
|
||||
require.NoError(t, os.Mkdir(filepath.Join(workDir, "downloads"), 0o700))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
t.Run("upload", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+record-upload-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file", "report.txt",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
out := result.Stdout
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_att", gjson.Get(out, "api.0.url").String(), out)
|
||||
require.Equal(t, "/open-apis/drive/v1/medias/upload_all", gjson.Get(out, "api.1.url").String(), out)
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/append_attachments", gjson.Get(out, "api.2.url").String(), out)
|
||||
require.Equal(t, "<uploaded_file_token>", gjson.Get(out, "api.2.body.attachments.rec_x.fld_att.0.file_token").String(), out)
|
||||
})
|
||||
|
||||
t.Run("download", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--file-token", "box_a",
|
||||
"--output", "downloads",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
out := result.Stdout
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments", gjson.Get(out, "api.0.url").String(), out)
|
||||
require.Equal(t, "/open-apis/drive/v1/medias/%3Cfile_token%3E/download", gjson.Get(out, "api.1.url").String(), out)
|
||||
require.Equal(t, "<extra_info_if_present>", gjson.Get(out, "api.1.params.extra").String(), out)
|
||||
})
|
||||
|
||||
t.Run("download all", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--output", "downloads",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
out := result.Stdout
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments", gjson.Get(out, "api.0.url").String(), out)
|
||||
require.Equal(t, "/open-apis/drive/v1/medias/%3Cfile_token%3E/download", gjson.Get(out, "api.1.url").String(), out)
|
||||
})
|
||||
|
||||
t.Run("remove", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+record-remove-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file-token", "box_a",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
out := result.Stdout
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/remove_attachments", gjson.Get(out, "api.0.url").String(), out)
|
||||
require.Equal(t, "box_a", gjson.Get(out, "api.0.body.attachments.rec_x.fld_att.0.file_token").String(), out)
|
||||
})
|
||||
}
|
||||
|
||||
func setBaseDryRunConfigEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "base_dryrun_test")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "base_dryrun_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
}
|
||||
@@ -54,7 +54,9 @@
|
||||
| ✕ | base +record-history-list | shortcut | | none | record workflows not covered |
|
||||
| ✕ | base +record-list | shortcut | | none | record workflows not covered |
|
||||
| ✕ | base +record-search | shortcut | | none | record workflows not covered |
|
||||
| ✕ | base +record-upload-attachment | shortcut | | none | record workflows not covered |
|
||||
| ✓ | base +record-upload-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/upload | dry-run only | request shape only |
|
||||
| ✓ | base +record-download-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/download | dry-run only | request shape only |
|
||||
| ✓ | base +record-remove-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/remove | dry-run only | request shape only |
|
||||
| ✕ | base +record-upsert | shortcut | | none | record workflows not covered |
|
||||
| ✓ | base +role-create | shortcut | base/helpers_test.go::createRole | `--base-token`; `--json` | helper asserts created role id |
|
||||
| ✕ | base +role-delete | shortcut | | none | cleanup only |
|
||||
|
||||
Reference in New Issue
Block a user