From 77bc0be06c2c1a39b02b292174b17ec8907a8409 Mon Sep 17 00:00:00 2001 From: zgz2048 Date: Thu, 14 May 2026 17:51:33 +0800 Subject: [PATCH 1/3] feat: support base attachment APIs --- shortcuts/base/base_dryrun_ops_test.go | 15 +- shortcuts/base/base_execute_test.go | 390 ++++++++--- shortcuts/base/base_shortcuts_test.go | 92 ++- shortcuts/base/record_upload_attachment.go | 627 ++++++++++++++---- .../base/record_upload_attachment_test.go | 18 + shortcuts/base/shortcuts.go | 2 + skills/lark-base/SKILL.md | 9 +- .../references/lark-base-cell-value.md | 6 +- .../lark-base-record-upload-attachment.md | 50 -- .../lark-base/references/lark-base-record.md | 10 +- .../base/base_attachment_dryrun_test.go | 121 ++++ tests/cli_e2e/base/coverage.md | 4 +- 12 files changed, 1061 insertions(+), 283 deletions(-) delete mode 100644 skills/lark-base/references/lark-base-record-upload-attachment.md create mode 100644 tests/cli_e2e/base/base_attachment_dryrun_test.go diff --git a/shortcuts/base/base_dryrun_ops_test.go b/shortcuts/base/base_dryrun_ops_test.go index b3d59aa7b..f25b99ac2 100644 --- a/shortcuts/base/base_dryrun_ops_test.go +++ b/shortcuts/base/base_dryrun_ops_test.go @@ -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"`, ) } diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 741b2f0e3..73abfa682 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -7,6 +7,10 @@ import ( "bytes" "context" "encoding/json" + "image" + "image/color" + "image/png" + "net/url" "os" "path/filepath" "strings" @@ -1589,12 +1593,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 +1615,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 +1624,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 +1653,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 +1665,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 +1698,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 +1737,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 +1762,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 +1802,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 +1880,260 @@ 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 rejects duplicate target names", 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_b", "name": "same.txt", "size": 8}, + }, + }, + }, + }, + }, + }) + + 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", + "--overwrite", + }, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "multiple attachments resolve to the same output path") { + t.Fatalf("err=%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) { diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index eeca3b8d1..d1ceade8a 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -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", @@ -169,14 +169,15 @@ func TestBaseTableDeleteRisk(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 { @@ -332,6 +333,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) diff --git a/shortcuts/base/record_upload_attachment.go b/shortcuts/base/record_upload_attachment.go index 67fde80f8..19ab1eb2e 100644 --- a/shortcuts/base/record_upload_attachment.go +++ b/shortcuts/base/record_upload_attachment.go @@ -8,8 +8,13 @@ import ( "context" "errors" "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" "io" "mime" + "net/http" "path/filepath" "strings" "unicode/utf8" @@ -17,18 +22,22 @@ import ( "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 +46,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 --table-id --record-id --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 --table-id --record-id --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 --table-id --record-id --field-id --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 := "" + fileName := "" + 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 +146,7 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont "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": "", "seq": "", @@ -80,14 +154,14 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont "file": "", }). 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": "", "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 +171,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{}{ - "": []interface{}{ - map[string]interface{}{ - "file_token": "", - "name": "", - "deprecated_set_attachment": true, - }, - map[string]interface{}{ - "file_token": "", - "name": fileName, - "mime_type": "", - "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": "", + "image_width": "", + "image_height": "", + }, + }, }, }, }) } -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") +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", ""). + Set("output", runtime.Str("output")). + Params(map[string]interface{}{"extra": ""}) +} + +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") } - fileInfo, err := fio.Stat(filePath) + files, err := normalizeAttachmentFiles(runtime.StrArray("file")) if err != nil { - if errors.Is(err, fileio.ErrPathValidation) { - return output.ErrValidation("unsafe file path: %s", err) + return err + } + for _, path := range files { + if _, err := validateAttachmentInputFile(runtime, path); err != nil { + return 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())) + return nil +} + +func validateRecordDownloadAttachment(runtime *common.RuntimeContext) error { + tokens, err := normalizeOptionalAttachmentFileTokens(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 +} - fileName := strings.TrimSpace(runtime.Str("name")) - if fileName == "" { - fileName = filepath.Base(filePath) +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,106 +261,174 @@ 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") + } + + 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)) + } - record, err := fetchBaseRecord(runtime, runtime.Str("base-token"), baseTableID(runtime), runtime.Str("record-id")) + 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 +} - 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") +func executeRecordRemoveAttachment(runtime *common.RuntimeContext) error { + tokens, err := normalizeAttachmentFileTokens(runtime.StrArray("file-token")) + if err != nil { + return err } - - attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, runtime.Str("base-token"), fileInfo.Size()) + field, err := fetchBaseField(runtime, runtime.Str("base-token"), baseTableID(runtime), runtime.Str("field-id")) if err != nil { return err } - - attachments, err := mergeRecordAttachments(record, fieldName(field), attachment) + 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 +} - body := map[string]interface{}{ - fieldName(field): attachments, +func executeRecordDownloadAttachment(ctx context.Context, runtime *common.RuntimeContext) error { + tokens, err := normalizeOptionalAttachmentFileTokens(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 } - data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, body) + items, err := selectAttachmentDownloadItems(attachments, runtime.Str("record-id"), tokens) if err != nil { return err } - runtime.Out(map[string]interface{}{ - "record": data, - "attachment": attachment, - "attachments": attachments, - "updated": true, - }, nil) + if err := validateDownloadTargetConflicts(runtime, items, runtime.Str("output"), len(tokens) != 1); err != nil { + return err + } + downloaded := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + saved, err := downloadBaseAttachment(ctx, runtime, item, runtime.Str("output"), len(tokens) != 1 || len(items) > 1, runtime.Bool("overwrite")) + if err != nil { + return err + } + downloaded = append(downloaded, saved) + } + runtime.Out(map[string]interface{}{"downloaded": downloaded}, nil) return nil } -func baseAttachmentShouldUseMultipart(fio fileio.FileIO, filePath string) bool { - info, err := fio.Stat(filePath) +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 { - return false + 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) } - return info.Mode().IsRegular() && info.Size() > common.MaxDriveMediaUploadSinglePartSize + 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 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 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 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 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 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 - } - current, exists := fields[fieldName] - if !exists || util.IsNil(current) { - return []interface{}{uploaded}, nil - } - items, ok := current.([]interface{}) - if !ok { - return nil, output.ErrValidation("record field %q has unexpected attachment payload type %T", fieldName, current) - } - 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)) +func normalizeOptionalAttachmentFileTokens(tokens []string) ([]string, error) { + if len(tokens) == 0 { + return nil, nil } - merged = append(merged, uploaded) - return merged, nil + return normalizeAttachmentFileTokens(tokens) } -func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]interface{} { - normalized := map[string]interface{}{} - if fileToken, _ := attachment["file_token"].(string); fileToken != "" { - normalized["file_token"] = fileToken +func baseAttachmentShouldUseMultipart(fio fileio.FileIO, filePath string) bool { + if fio == nil { + return false } - if name, _ := attachment["name"].(string); name != "" { - normalized["name"] = name + info, err := fio.Stat(filePath) + if err != nil { + return false } - if mimeType, _ := attachment["mime_type"].(string); mimeType != "" { - normalized["mime_type"] = mimeType + 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, output.ErrValidation("provide at least one record id") } - if size, ok := attachment["size"]; ok && !util.IsNil(size) { - normalized["size"] = size + if len(recordIDs) > baseAttachmentGetMaxRecords { + return nil, output.ErrValidation("get attachments record selection exceeds maximum limit of %d (got %d)", baseAttachmentGetMaxRecords, len(recordIDs)) } - if imageWidth, ok := attachment["image_width"]; ok && !util.IsNil(imageWidth) { - normalized["image_width"] = imageWidth + 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 } - if imageHeight, ok := attachment["image_height"]; ok && !util.IsNil(imageHeight) { - normalized["image_height"] = imageHeight + attachments, _ := data["attachments"].(map[string]interface{}) + if attachments == nil { + return map[string]interface{}{}, nil } - 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 +463,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 +530,192 @@ 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{} +} + +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{} + for currentFieldID, rawList := range fields { + 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) + } + 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 validateDownloadTargetConflicts(runtime *common.RuntimeContext, items []baseAttachmentDownloadItem, outputPath string, outputIsDir bool) error { + if len(items) <= 1 || !outputIsDir { + return nil + } + seen := map[string]string{} + for _, item := range items { + targetPath := downloadTargetPath(runtime, item, outputPath, outputIsDir) + resolved, err := runtime.ResolveSavePath(targetPath) + if err != nil { + return output.ErrValidation("unsafe output path: %s", err) + } + if resolved == "" { + resolved = targetPath + } + if previous, exists := seen[resolved]; exists { + name := strings.TrimSpace(item.Name) + if name == "" { + name = item.FileToken + } + return output.ErrValidation("multiple attachments resolve to the same output path %q (%s and %s); download them separately or choose a different directory", resolved, previous, name) + } + name := strings.TrimSpace(item.Name) + if name == "" { + name = item.FileToken + } + seen[resolved] = name + } + return nil +} + +func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext, item baseAttachmentDownloadItem, outputPath string, outputIsDir bool, overwrite bool) (map[string]interface{}, error) { + targetPath := downloadTargetPath(runtime, item, outputPath, outputIsDir) + 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 downloadTargetPath(runtime *common.RuntimeContext, item baseAttachmentDownloadItem, outputPath string, outputIsDir bool) string { + if outputIsDir || outputPathLooksDirectory(runtime, outputPath) { + name := strings.TrimSpace(item.Name) + if name == "" { + name = item.FileToken + } + return filepath.Join(outputPath, filepath.Base(name)) + } + return outputPath +} + +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] diff --git a/shortcuts/base/record_upload_attachment_test.go b/shortcuts/base/record_upload_attachment_test.go index 69ff360e2..a68252099 100644 --- a/shortcuts/base/record_upload_attachment_test.go +++ b/shortcuts/base/record_upload_attachment_test.go @@ -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} diff --git a/shortcuts/base/shortcuts.go b/shortcuts/base/shortcuts.go index 60ebfe000..672dc6519 100644 --- a/shortcuts/base/shortcuts.go +++ b/shortcuts/base/shortcuts.go @@ -44,6 +44,8 @@ func Shortcuts() []common.Shortcut { BaseRecordBatchUpdate, BaseRecordShareLinkCreate, BaseRecordUploadAttachment, + BaseRecordDownloadAttachment, + BaseRecordRemoveAttachment, BaseRecordDelete, BaseRecordHistoryList, BaseBaseGet, diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index b3585ad48..cd4486f24 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -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 --output ` | `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 --output `,可加 `--file-token ` 只下指定附件 | Base 附件必须用这个命令下载;用其他下载入口可能失败 | | 基于视图做筛选读取 | `+view-set-filter` + `+record-list` | 不要跳过视图筛选直接猜条件 | | 本地 Excel / CSV / `.base` 导入为 Base | `lark-cli drive +import --type bitable` | 不要误走 `+base-create`、`+table-create` 或 `+record-upsert` | diff --git a/skills/lark-base/references/lark-base-cell-value.md b/skills/lark-base/references/lark-base-cell-value.md index 1da76779b..d308b029b 100644 --- a/skills/lark-base/references/lark-base-cell-value.md +++ b/skills/lark-base/references/lark-base-cell-value.md @@ -119,9 +119,9 @@ ### 2.9 attachment(不作为普通 CellValue 写入) -用户要把本地文件加到记录里时,必须使用 `lark-cli base +record-upload-attachment --file ` 上传到已有记录。不能用普通记录操作接口来上传附件。 - -`+record-get` 返回的附件字段单元格包含 `file_token` 和文件名,可以把 `file_token` 交给 `lark-cli docs +media-download` 进行附件下载。 +- 追加附件:使用 `lark-cli base +record-upload-attachment --record-id --field-id --file `;可重复 `--file` 一次追加多个附件,不能用普通记录操作接口写附件值。 +- 删除附件:使用 `lark-cli base +record-remove-attachment --record-id --field-id --file-token --yes`;可重复 `--file-token` 一次删除同一单元格里的多个附件。 +- 下载附件:使用 `lark-cli base +record-download-attachment --record-id --file-token --output `;不传 `--file-token` 时下载整行所有附件,也可重复 `--file-token` 只下载指定附件。Base 附件必须用这个命令下载,用其他下载入口可能失败。 ## 3. 只读字段(不要写) diff --git a/skills/lark-base/references/lark-base-record-upload-attachment.md b/skills/lark-base/references/lark-base-record-upload-attachment.md deleted file mode 100644 index 45abe0ba4..000000000 --- a/skills/lark-base/references/lark-base-record-upload-attachment.md +++ /dev/null @@ -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 \ - --table-id \ - --record-id \ - --field-id \ - --file ./report.pdf - -lark-cli base +record-upload-attachment \ - --base-token \ - --table-id \ - --record-id \ - --field-id "附件" \ - --file ./report.pdf \ - --name "Q1-final.pdf" -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--base-token ` | 是 | Base Token | -| `--table-id ` | 是 | 表 ID 或表名 | -| `--record-id ` | 是 | 记录 ID | -| `--field-id ` | 是 | 附件字段 ID 或字段名 | -| `--file ` | 是 | 本地文件路径,最大 2GB | -| `--name ` | 否 | 写入附件字段时显示的文件名,默认使用本地文件名 | - - -## 工作流 - -> [!CAUTION] -> 这是写入操作。用户已经明确要上传到某条记录的某个附件字段时可直接执行;如果 `record-id` 或目标字段仍有歧义,再先确认。 - -## 坑点 - -- ⚠️ 目标字段必须是 `attachment` 字段。 -- ⚠️ 记录里的附件 `file_token` 属于 Drive media token;下载时不要走 `lark-cli drive +download`,应使用 `lark-cli docs +media-download --token --output `。 - -## 参考 - -- [lark-base-record.md](lark-base-record.md) — record 索引页 diff --git a/skills/lark-base/references/lark-base-record.md b/skills/lark-base/references/lark-base-record.md index 66df82aba..245f1a7cf 100644 --- a/skills/lark-base/references/lark-base-record.md +++ b/skills/lark-base/references/lark-base-record.md @@ -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 --output `,用法见 [`../../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`。 diff --git a/tests/cli_e2e/base/base_attachment_dryrun_test.go b/tests/cli_e2e/base/base_attachment_dryrun_test.go new file mode 100644 index 000000000..006ffdda3 --- /dev/null +++ b/tests/cli_e2e/base/base_attachment_dryrun_test.go @@ -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, "", 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, "", 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") +} diff --git a/tests/cli_e2e/base/coverage.md b/tests/cli_e2e/base/coverage.md index 45b0629e4..b1b7f80a1 100644 --- a/tests/cli_e2e/base/coverage.md +++ b/tests/cli_e2e/base/coverage.md @@ -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 | From b1728c69aa38740ccfd796486421cf4ce92c7430 Mon Sep 17 00:00:00 2001 From: zgz2048 Date: Thu, 14 May 2026 18:25:05 +0800 Subject: [PATCH 2/3] fix: handle duplicate base attachment downloads --- shortcuts/base/base_execute_test.go | 182 ++++++++++++++++- shortcuts/base/record_upload_attachment.go | 215 +++++++++++++++++---- 2 files changed, 358 insertions(+), 39 deletions(-) diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 73abfa682..ed2c3c139 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "image" "image/color" "image/png" @@ -19,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" ) @@ -2048,7 +2050,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { } }) - t.Run("download all rejects duplicate target names", func(t *testing.T) { + 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", @@ -2059,6 +2061,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { "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}, }, @@ -2067,6 +2070,167 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { }, }, }) + 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) @@ -2080,11 +2244,23 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { "--table-id", "tbl_x", "--record-id", "rec_x", "--output", "downloads", - "--overwrite", }, factory, stdout) - if err == nil || !strings.Contains(err.Error(), "multiple attachments resolve to the same output path") { + 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) { diff --git a/shortcuts/base/record_upload_attachment.go b/shortcuts/base/record_upload_attachment.go index 19ab1eb2e..f6a37ba0c 100644 --- a/shortcuts/base/record_upload_attachment.go +++ b/shortcuts/base/record_upload_attachment.go @@ -16,6 +16,7 @@ import ( "mime" "net/http" "path/filepath" + "sort" "strings" "unicode/utf8" @@ -230,7 +231,7 @@ func validateRecordUploadAttachment(runtime *common.RuntimeContext) error { } func validateRecordDownloadAttachment(runtime *common.RuntimeContext) error { - tokens, err := normalizeOptionalAttachmentFileTokens(runtime.StrArray("file-token")) + tokens, err := normalizeOptionalDownloadAttachmentFileTokens(runtime.StrArray("file-token")) if err != nil { return err } @@ -319,7 +320,7 @@ func executeRecordRemoveAttachment(runtime *common.RuntimeContext) error { } func executeRecordDownloadAttachment(ctx context.Context, runtime *common.RuntimeContext) error { - tokens, err := normalizeOptionalAttachmentFileTokens(runtime.StrArray("file-token")) + tokens, err := normalizeOptionalDownloadAttachmentFileTokens(runtime.StrArray("file-token")) if err != nil { return err } @@ -331,14 +332,16 @@ func executeRecordDownloadAttachment(ctx context.Context, runtime *common.Runtim if err != nil { return err } - if err := validateDownloadTargetConflicts(runtime, items, runtime.Str("output"), len(tokens) != 1); err != nil { + 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(items)) - for _, item := range items { - saved, err := downloadBaseAttachment(ctx, runtime, item, runtime.Str("output"), len(tokens) != 1 || len(items) > 1, runtime.Bool("overwrite")) + 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 { - return err + failed := attachmentDownloadFailure(target, err) + return attachmentDownloadProgressError(err, downloaded, []map[string]interface{}{failed}) } downloaded = append(downloaded, saved) } @@ -396,6 +399,38 @@ func normalizeOptionalAttachmentFileTokens(tokens []string) ([]string, error) { return normalizeAttachmentFileTokens(tokens) } +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 @@ -557,6 +592,12 @@ type baseAttachmentDownloadItem struct { 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 { @@ -567,7 +608,13 @@ func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID return nil, output.ErrValidation("record %q attachment metadata has unexpected type %T", recordID, recordRaw) } byToken := map[string]baseAttachmentDownloadItem{} - for currentFieldID, rawList := range fields { + 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) @@ -607,6 +654,14 @@ func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID 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 { @@ -619,38 +674,91 @@ func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID return result, nil } -func validateDownloadTargetConflicts(runtime *common.RuntimeContext, items []baseAttachmentDownloadItem, outputPath string, outputIsDir bool) error { - if len(items) <= 1 || !outputIsDir { - return nil - } - seen := map[string]string{} +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 { - targetPath := downloadTargetPath(runtime, item, outputPath, outputIsDir) + targetName := names[item.FileToken] + targetPath := outputPath + if targetName != "" { + targetPath = filepath.Join(outputPath, targetName) + } resolved, err := runtime.ResolveSavePath(targetPath) if err != nil { - return output.ErrValidation("unsafe output path: %s", err) - } - if resolved == "" { - resolved = targetPath + return nil, output.ErrValidation("unsafe output path: %s", err) } if previous, exists := seen[resolved]; exists { - name := strings.TrimSpace(item.Name) - if name == "" { - name = item.FileToken + 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) } - return output.ErrValidation("multiple attachments resolve to the same output path %q (%s and %s); download them separately or choose a different directory", resolved, previous, name) } - name := strings.TrimSpace(item.Name) - if name == "" { - name = item.FileToken + 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 } - seen[resolved] = name + b.WriteByte('_') } - return nil + suffix := strings.Trim(b.String(), "_") + if suffix == "" { + return "file" + } + return suffix } -func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext, item baseAttachmentDownloadItem, outputPath string, outputIsDir bool, overwrite bool) (map[string]interface{}, error) { - targetPath := downloadTargetPath(runtime, item, outputPath, outputIsDir) +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) } @@ -697,15 +805,50 @@ func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext, }, nil } -func downloadTargetPath(runtime *common.RuntimeContext, item baseAttachmentDownloadItem, outputPath string, outputIsDir bool) string { - if outputIsDir || outputPathLooksDirectory(runtime, outputPath) { - name := strings.TrimSpace(item.Name) - if name == "" { - name = item.FileToken +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 filepath.Join(outputPath, filepath.Base(name)) } - return outputPath + 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 { From 7d715ff932173123c0d4e8d3d15f7b2399fd5162 Mon Sep 17 00:00:00 2001 From: zgz2048 Date: Thu, 14 May 2026 19:03:04 +0800 Subject: [PATCH 3/3] fix: remove unused attachment token helper --- shortcuts/base/record_upload_attachment.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/shortcuts/base/record_upload_attachment.go b/shortcuts/base/record_upload_attachment.go index f6a37ba0c..6414577cd 100644 --- a/shortcuts/base/record_upload_attachment.go +++ b/shortcuts/base/record_upload_attachment.go @@ -392,13 +392,6 @@ func normalizeAttachmentFileTokens(tokens []string) ([]string, error) { }) } -func normalizeOptionalAttachmentFileTokens(tokens []string) ([]string, error) { - if len(tokens) == 0 { - return nil, nil - } - return normalizeAttachmentFileTokens(tokens) -} - func normalizeOptionalDownloadAttachmentFileTokens(tokens []string) ([]string, error) { if len(tokens) == 0 { return nil, nil