Skip to content
This repository was archived by the owner on Mar 12, 2026. It is now read-only.

Commit ee6302f

Browse files
authored
feat: download-attachments for comments & documents, shared download helper (#148)
Credit to @brianevanmiller for this contribution. feat: download-attachments for comments & documents, shared download helper
2 parents eb7db76 + b250389 commit ee6302f

13 files changed

Lines changed: 1169 additions & 467 deletions

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Added
11+
- `comment download-attachments` command for downloading attachments from comments
12+
- `document download-attachments` command for downloading attachments from documents
13+
- `--include-comments` flag on card/todo/message/document download-attachments to also download comment attachments
14+
- Attachment display in `comment view` output
15+
16+
### Changed
17+
- Refactored download logic into shared `internal/download` package, reducing code duplication
18+
819
## [0.13.0] - 2026-01-19
920

1021
### Added

README.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,10 @@ bc4 document create "Spec Document" --content "# Overview\n\nThis is the spec...
357357
# Edit an existing document
358358
bc4 document edit 12345
359359
bc4 document edit 12345 --title "Updated Title"
360+
361+
# Download attachments from a document
362+
bc4 document download-attachments 12345
363+
bc4 document download-attachments 12345 --include-comments
360364
```
361365

362366
### Card Management
@@ -503,12 +507,16 @@ bc4 comment attach 12345 --attach ./log.txt
503507

504508
# Append an attachment to a specific comment by ID
505509
bc4 comment attach 12345 --comment-id 67890 --attach ./screenshot.png
510+
511+
# Download attachments from a comment
512+
bc4 comment download-attachments 67890
513+
bc4 comment download-attachments 67890 --output-dir ~/Downloads
506514
```
507515

508516

509517
### Downloading Attachments
510518

511-
bc4 can download images and files attached to cards, todos, and messages using OAuth authentication:
519+
bc4 can download images and files attached to cards, todos, messages, documents, and comments using OAuth authentication:
512520

513521
```bash
514522
# Download all attachments from a card
@@ -520,6 +528,13 @@ bc4 todo download-attachments 789012 --output-dir ~/Downloads
520528
# Download from a message (only first attachment)
521529
bc4 message download-attachments 345678 --attachment 1
522530

531+
# Download from a comment or document
532+
bc4 comment download-attachments 456789
533+
bc4 document download-attachments 567890
534+
535+
# Include attachments from comments on a resource
536+
bc4 card download-attachments 123456 --include-comments
537+
523538
# Overwrite existing files
524539
bc4 card download-attachments 123456 --overwrite
525540

@@ -531,8 +546,9 @@ bc4 card download-attachments https://3.basecamp.com/123/buckets/456/card_tables
531546
- `--output-dir, -o` - Directory to save attachments (default: current directory)
532547
- `--attachment N` - Download only the Nth attachment (1-based index)
533548
- `--overwrite` - Replace existing files without prompting
549+
- `--include-comments` - Also download attachments from comments (available on card, todo, message, document)
534550

535-
**Note:** Comment attachments use blob storage that requires browser authentication and cannot be downloaded via OAuth. To download comment attachments, access them through your web browser while logged into Basecamp.
551+
**Note:** Some Basecamp attachments use blob storage URLs that require browser session authentication and cannot be downloaded via the API. When encountered, bc4 will display the URL so you can open it in your browser while logged into Basecamp.
536552

537553
### Activity & Events
538554

@@ -832,4 +848,4 @@ Thanks to everyone who has contributed to bc4!
832848
833849
#### Known Limitations
834850
835-
**Comment Attachments:** Comment attachments use blob storage URLs that require browser session cookies and do not support OAuth Bearer token authentication. The `download-attachments` command works for card body attachments, todo attachments, and message attachments, but not for attachments added in comments. To download comment attachments, use a web browser while authenticated to Basecamp.
851+
**Blob Storage URLs:** Some Basecamp attachments use blob storage URLs (containing `/blobs/` or `preview.3.basecamp.com`) that require browser session cookies and cannot be downloaded via OAuth. When bc4 encounters these URLs, it displays a helpful message with the URL so you can open it in your browser while logged into Basecamp.

cmd/card/download_attachments.go

Lines changed: 22 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,11 @@ package card
22

33
import (
44
"fmt"
5-
"os"
6-
"path/filepath"
75
"strconv"
8-
"strings"
96

107
"github.com/spf13/cobra"
118

12-
"github.com/needmore/bc4/internal/attachments"
9+
"github.com/needmore/bc4/internal/download"
1310
"github.com/needmore/bc4/internal/factory"
1411
"github.com/needmore/bc4/internal/parser"
1512
)
@@ -20,6 +17,7 @@ func newDownloadAttachmentsCmd(f *factory.Factory) *cobra.Command {
2017
var outputDir string
2118
var overwrite bool
2219
var attachmentIndex int
20+
var includeComments bool
2321

2422
cmd := &cobra.Command{
2523
Use: "download-attachments [card-id or URL]",
@@ -45,27 +43,25 @@ You can specify the card using either:
4543
# Overwrite existing files
4644
bc4 card download-attachments 123456 --overwrite
4745
46+
# Include attachments from comments
47+
bc4 card download-attachments 123456 --include-comments
48+
4849
# Using Basecamp URL
4950
bc4 card download-attachments https://3.basecamp.com/123/buckets/456/card_tables/cards/789`,
5051
Args: cobra.ExactArgs(1),
5152
RunE: func(cmd *cobra.Command, args []string) error {
52-
// Apply account override if specified
5353
if accountID != "" {
5454
f = f.WithAccount(accountID)
5555
}
56-
57-
// Apply project override if specified
5856
if projectID != "" {
5957
f = f.WithProject(projectID)
6058
}
6159

62-
// Parse card ID (could be numeric ID or URL)
6360
cardID, parsedURL, err := parser.ParseArgument(args[0])
6461
if err != nil {
6562
return fmt.Errorf("invalid card ID or URL: %s", args[0])
6663
}
6764

68-
// If a URL was parsed, override account and project IDs if provided
6965
var bucketID string
7066
if parsedURL != nil {
7167
if parsedURL.ResourceType != parser.ResourceTypeCard {
@@ -80,15 +76,13 @@ You can specify the card using either:
8076
}
8177
}
8278

83-
// Get API client from factory
8479
client, err := f.ApiClient()
8580
if err != nil {
8681
return err
8782
}
8883
cardOps := client.Cards()
8984
uploadOps := client.Uploads()
9085

91-
// Get resolved project ID (bucket ID)
9286
resolvedProjectID, err := f.ProjectID()
9387
if err != nil {
9488
return err
@@ -97,165 +91,43 @@ You can specify the card using either:
9791
bucketID = resolvedProjectID
9892
}
9993

100-
// Fetch the card details
10194
card, err := cardOps.GetCard(f.Context(), resolvedProjectID, cardID)
10295
if err != nil {
10396
return fmt.Errorf("failed to get card: %w", err)
10497
}
10598

106-
// Parse attachments from card content
107-
atts := attachments.ParseAttachments(card.Content)
108-
if len(atts) == 0 {
109-
fmt.Println("No attachments found in this card")
110-
return nil
111-
}
112-
113-
// Store original count before filtering
114-
originalCount := len(atts)
115-
116-
// Filter to specific attachment if requested
117-
if attachmentIndex > 0 {
118-
if attachmentIndex > originalCount {
119-
return fmt.Errorf("attachment index %d out of range (card has %d attachments)", attachmentIndex, originalCount)
120-
}
121-
atts = []attachments.Attachment{atts[attachmentIndex-1]}
122-
}
123-
124-
// Use current directory if no output directory specified
125-
if outputDir == "" {
126-
outputDir = "."
99+
sources := []download.AttachmentSource{
100+
{Label: "card", Content: card.Content},
127101
}
128102

129-
// Download each attachment
130-
successful := 0
131-
failed := 0
132-
ctx := f.Context()
133-
134-
for i, att := range atts {
135-
displayIndex := i + 1
136-
if attachmentIndex > 0 {
137-
displayIndex = attachmentIndex
138-
}
139-
140-
// Show appropriate progress message based on whether filtering
141-
if attachmentIndex > 0 {
142-
fmt.Printf("Downloading attachment %d: %s\n", displayIndex, att.GetDisplayName())
143-
} else {
144-
fmt.Printf("Downloading attachment %d/%d: %s\n", displayIndex, originalCount, att.GetDisplayName())
145-
}
146-
147-
// Try to extract upload ID from URL or Href
148-
result, err := attachments.TryExtractUploadID(&att)
103+
if includeComments {
104+
comments, err := client.ListComments(f.Context(), resolvedProjectID, card.ID)
149105
if err != nil {
150-
if result != nil && result.IsBlobURL {
151-
// This is a blob URL - provide helpful guidance
152-
fmt.Println(" ✗ Cannot download via API: This attachment uses a browser-only URL")
153-
fmt.Printf(" URL: %s\n", result.BlobURL)
154-
fmt.Println(" Tip: Open this URL in your browser while logged into Basecamp to download")
155-
} else {
156-
fmt.Printf(" ✗ Failed: %v\n", err)
157-
}
158-
failed++
159-
continue
106+
return fmt.Errorf("failed to fetch comments: %w", err)
160107
}
161-
162-
// Get full upload details including download URL
163-
upload, err := uploadOps.GetUpload(ctx, bucketID, result.UploadID)
164-
if err != nil {
165-
fmt.Printf(" ✗ Failed to get upload details: %v\n", err)
166-
failed++
167-
continue
108+
for _, c := range comments {
109+
sources = append(sources, download.AttachmentSource{
110+
Label: fmt.Sprintf("comment #%d by %s", c.ID, c.Creator.Name),
111+
Content: c.Content,
112+
})
168113
}
169-
170-
// Sanitize filename for filesystem safety
171-
filename := sanitizeFilename(upload.Filename)
172-
destPath := filepath.Join(outputDir, filename)
173-
174-
// Check if file exists
175-
if !overwrite {
176-
if _, err := os.Stat(destPath); err == nil {
177-
fmt.Printf(" ⚠ File already exists: %s (use --overwrite to replace)\n", destPath)
178-
fmt.Println(" Skipping...")
179-
continue
180-
}
181-
}
182-
183-
// Download the attachment
184-
err = uploadOps.DownloadAttachment(ctx, upload.DownloadURL, destPath)
185-
if err != nil {
186-
fmt.Printf(" ✗ Failed to download: %v\n", err)
187-
failed++
188-
continue
189-
}
190-
191-
// Format file size
192-
sizeStr := formatByteSize(upload.ByteSize)
193-
fmt.Printf(" ✓ Downloaded: %s (%s)\n", destPath, sizeStr)
194-
successful++
195114
}
196115

197-
// Print summary
198-
fmt.Println()
199-
if successful > 0 {
200-
fmt.Printf("Successfully downloaded: %d/%d attachments\n", successful, len(atts))
201-
}
202-
if failed > 0 {
203-
fmt.Printf("Failed: %d attachments\n", failed)
204-
return fmt.Errorf("some attachments failed to download")
205-
}
206-
207-
return nil
116+
_, err = download.DownloadFromSources(f.Context(), uploadOps, bucketID, sources, download.Options{
117+
OutputDir: outputDir,
118+
Overwrite: overwrite,
119+
AttachmentIndex: attachmentIndex,
120+
})
121+
return err
208122
},
209123
}
210124

211-
// Add flags
212125
cmd.Flags().StringVarP(&accountID, "account", "a", "", "Specify account ID")
213126
cmd.Flags().StringVarP(&projectID, "project", "p", "", "Specify project ID")
214127
cmd.Flags().StringVarP(&outputDir, "output-dir", "o", "", "Directory to save attachments (default: current directory)")
215128
cmd.Flags().BoolVar(&overwrite, "overwrite", false, "Overwrite existing files without prompting")
216129
cmd.Flags().IntVar(&attachmentIndex, "attachment", 0, "Download only specified attachment (1-based index)")
130+
cmd.Flags().BoolVar(&includeComments, "include-comments", false, "Also download attachments from comments on this card")
217131

218132
return cmd
219133
}
220-
221-
// sanitizeFilename removes or replaces characters that are unsafe for filenames
222-
// to prevent path traversal attacks and filesystem errors
223-
func sanitizeFilename(filename string) string {
224-
// Remove path separators to prevent directory traversal
225-
cleaned := filepath.Base(filename)
226-
227-
// Remove null bytes and other control characters
228-
cleaned = strings.Map(func(r rune) rune {
229-
if r < 32 || r == 127 {
230-
return -1
231-
}
232-
return r
233-
}, cleaned)
234-
235-
// Replace filesystem-unsafe characters with underscores
236-
unsafe := []string{"<", ">", ":", "\"", "|", "?", "*"}
237-
for _, char := range unsafe {
238-
cleaned = strings.ReplaceAll(cleaned, char, "_")
239-
}
240-
241-
// Prevent empty filenames
242-
if cleaned == "" || cleaned == "." || cleaned == ".." {
243-
cleaned = "attachment"
244-
}
245-
246-
return cleaned
247-
}
248-
249-
// formatByteSize formats a byte size in a human-readable format
250-
func formatByteSize(bytes int64) string {
251-
const unit = 1024
252-
if bytes < unit {
253-
return fmt.Sprintf("%d B", bytes)
254-
}
255-
div, exp := int64(unit), 0
256-
for n := bytes / unit; n >= unit; n /= unit {
257-
div *= unit
258-
exp++
259-
}
260-
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
261-
}

cmd/comment/comment.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ on your team's work.`,
3333
cmd.AddCommand(newEditCmd(f))
3434
cmd.AddCommand(newAttachCmd(f))
3535
cmd.AddCommand(newDeleteCmd(f))
36+
cmd.AddCommand(newDownloadAttachmentsCmd(f))
3637

3738
return cmd
3839
}

0 commit comments

Comments
 (0)