Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions internal/commands/assign.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,10 @@ func assignItems(cmd *cobra.Command, args []string, assignee *string, project st
return output.ErrUsageHint("Person to assign is required", "Use --to <person>")
}

extractedIDs := extractIDs(args)
extractedIDs, err := extractIDs(args)
if err != nil {
return err
}
if len(extractedIDs) == 0 {
return missingArg(cmd, "<id|url>...")
}
Expand Down Expand Up @@ -236,7 +239,10 @@ func unassignItems(cmd *cobra.Command, args []string, assignee *string, project
return output.ErrUsageHint("Person to unassign is required", "Use --from <person>")
}

extractedIDs := extractIDs(args)
extractedIDs, err := extractIDs(args)
if err != nil {
return err
}
if len(extractedIDs) == 0 {
return missingArg(cmd, "<id|url>...")
}
Expand Down
6 changes: 5 additions & 1 deletion internal/commands/assignments.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,11 @@ func assignmentsListBreadcrumb() output.Breadcrumb {

// assignmentRecordingID resolves the <id|url> positional the Up Next verbs take.
func assignmentRecordingID(arg string) (int64, error) {
id, err := strconv.ParseInt(extractID(arg), 10, 64)
idStr, err := extractID(arg)
if err != nil {
return 0, err
}
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
return 0, output.ErrUsageHint(
fmt.Sprintf("%q is not a recording id or Basecamp URL", arg),
Expand Down
152 changes: 99 additions & 53 deletions internal/commands/attachments.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import (
// scope). Callers need to specify --type (or pass a URL, from which the
// type is parsed) so the typed endpoint can be used instead.
//
// Kept command-agnostic: fetchItemContent is shared by `attachments list`
// Kept command-agnostic: fetchAttachmentItem is shared by `attachments list`
// and `attachments download`, so the hint must not tell a download caller
// to re-run the list command (or vice versa).
const genericLookupTypeHint = "Re-run with --type todo|todolist|message|comment|card|card-table|document|schedule-entry|checkin|answer|forward|upload, or pass a URL (which encodes the type)"
Expand Down Expand Up @@ -94,7 +94,11 @@ You can pass either an ID or a Basecamp URL:
func runAttachmentsList(cmd *cobra.Command, arg, recordType string) error {
app := appctx.FromContext(cmd.Context())

id, resolvedType := resolveAttachmentTarget(arg, recordType)
target, err := resolveAttachmentTarget(arg, recordType)
if err != nil {
return err
}
id, resolvedType := target.id, target.recordType

// Validate type early (before account check) for better error messages
if !isGenericType(resolvedType) && typeToEndpoint(resolvedType, id) == "" {
Expand All @@ -111,12 +115,25 @@ func runAttachmentsList(cmd *cobra.Command, arg, recordType string) error {
// Fetch the item — same two-step pattern as show.go:
// 1. Generic recording lookup to discover type
// 2. Re-fetch via type-specific endpoint for full content
content, err := fetchItemContent(cmd, app, id, resolvedType)
data, err := fetchAttachmentItem(cmd, app, target)
if err != nil {
return err
}

attachments := richtext.ParseAttachments(content)
attachments := richtext.ParseAttachments(extractContentField(data))

// An occurrence shows its event's description but has comments of its
// own, so reads name it by its URL and a comment goes to its recording.
readRef := id
showCmd := fmt.Sprintf("basecamp show %s", id)
if showType := normalizeShowType(resolvedType); showType != "" {
showCmd = fmt.Sprintf("basecamp show %s --type %s", id, showType)
}
if target.occurrence != "" {
id = fmt.Sprint(data["id"])
readRef = target.url
showCmd = fmt.Sprintf("basecamp show %s", target.url)
}

// Styled TTY output
if app.Output.EffectiveFormat() == output.FormatStyled {
Expand Down Expand Up @@ -156,17 +173,12 @@ func runAttachmentsList(cmd *cobra.Command, arg, recordType string) error {
}
if downloadable > 0 {
fmt.Fprintln(w)
fmt.Fprintln(w, muted.Render(fmt.Sprintf("Download: basecamp attachments download %s", id)))
fmt.Fprintln(w, muted.Render(fmt.Sprintf("Download: basecamp attachments download %s", readRef)))
}
return nil
}

// JSON/agent output
showCmd := fmt.Sprintf("basecamp show %s", id)
if showType := normalizeShowType(resolvedType); showType != "" {
showCmd = fmt.Sprintf("basecamp show %s --type %s", id, showType)
}

breadcrumbs := []output.Breadcrumb{
{Action: "show", Cmd: showCmd, Description: "Show item"},
}
Expand All @@ -180,7 +192,7 @@ func runAttachmentsList(cmd *cobra.Command, arg, recordType string) error {
}
downloadable := downloadableAttachments(attachments)
if len(downloadable) > 0 {
breadcrumbs = append(breadcrumbs, attachmentBreadcrumb(id, len(downloadable)))
breadcrumbs = append(breadcrumbs, attachmentBreadcrumb(readRef, len(downloadable)))
}

respOpts := []output.ResponseOption{
Expand Down Expand Up @@ -229,7 +241,11 @@ Options:
}

// Resolve target — reuse the same URL/comment parsing as list
idStr, resolvedType := resolveAttachmentTarget(args[0], recordType)
target, err := resolveAttachmentTarget(args[0], recordType)
if err != nil {
return err
}
idStr, resolvedType := target.id, target.recordType

// Validate type early (same guard as list)
if !isGenericType(resolvedType) && typeToEndpoint(resolvedType, idStr) == "" {
Expand All @@ -240,13 +256,16 @@ Options:
}

// Fetch recording content
content, err := fetchItemContent(cmd, app, idStr, resolvedType)
data, err := fetchAttachmentItem(cmd, app, target)
if err != nil {
return err
}
if target.occurrence != "" {
idStr = target.url
}

// Parse all attachments (same parser as list — consistent indexes)
allAttachments := richtext.ParseAttachments(content)
allAttachments := richtext.ParseAttachments(extractContentField(data))
if len(allAttachments) == 0 {
return output.ErrUsageHint(
"No downloadable attachments found",
Expand Down Expand Up @@ -419,12 +438,12 @@ func isGenericType(recordType string) bool {
}
}

// shouldSuggestType reports whether the --type usage hint is appropriate
// for the current recordType. It fires only when no explicit type was
// provided. The "line"/"lines"/"replies" aliases route through the same
// generic /recordings lookup for parent discovery, but the user *did*
// pass --type — telling those callers to "specify --type" is misleading.
func shouldSuggestType(recordType string) bool {
// namesNoType reports whether recordType leaves the item's type open: no
// --type, or one meaning any recording. The "line"/"lines"/"replies" aliases
// route through the same generic /recordings lookup for parent discovery,
// but they name a type, so telling those callers to "specify --type" is
// misleading.
func namesNoType(recordType string) bool {
switch recordType {
case "", "recording", "recordings":
return true
Expand All @@ -433,15 +452,20 @@ func shouldSuggestType(recordType string) bool {
}
}

// fetchItemContent retrieves the HTML content field from a Basecamp item.
// Uses the same recording-type discovery pattern as show.go.
func fetchItemContent(cmd *cobra.Command, app *appctx.App, id, recordType string) (string, error) {
// fetchAttachmentItem retrieves the item whose rich text holds the
// attachments. Uses the same recording-type discovery pattern as show.go.
func fetchAttachmentItem(cmd *cobra.Command, app *appctx.App, target attachmentTarget) (map[string]any, error) {
id, recordType := target.id, target.recordType

// If type is provided, go directly to the type-specific endpoint.
// Caller validates the type before calling, so typeToEndpoint won't return "".
var endpoint string
if !isGenericType(recordType) {
switch {
case target.occurrence != "":
endpoint = fmt.Sprintf("/schedule_entries/%s/occurrences/%s.json", id, target.occurrence)
case !isGenericType(recordType):
endpoint = typeToEndpoint(recordType, id)
} else {
default:
// Generic recording lookup first
endpoint = fmt.Sprintf("/recordings/%s.json", id)
}
Expand All @@ -455,25 +479,25 @@ func fetchItemContent(cmd *cobra.Command, app *appctx.App, id, recordType string
// "Resource not found". Only fires when no explicit type was
// provided — a 404 under --type line|replies means the ID is
// wrong, not that --type is missing.
if shouldSuggestType(recordType) {
if namesNoType(recordType) {
var sdkErr *basecamp.Error
if errors.As(err, &sdkErr) && sdkErr.Code == basecamp.CodeNotFound {
return "", output.ErrUsageHint(
return nil, output.ErrUsageHint(
fmt.Sprintf("Item %s not found or type required", id),
genericLookupTypeHint,
)
}
}
return "", convertSDKError(err)
return nil, convertSDKError(err)
}
if resp.StatusCode == http.StatusNoContent {
if shouldSuggestType(recordType) {
return "", output.ErrUsageHint(
if namesNoType(recordType) {
return nil, output.ErrUsageHint(
fmt.Sprintf("Item %s not found or type required", id),
genericLookupTypeHint,
)
}
return "", output.ErrNotFound("item", id)
return nil, output.ErrNotFound("item", id)
}

// UseNumber so parentRecordingID sees json.Number for large IDs
Expand All @@ -482,7 +506,7 @@ func fetchItemContent(cmd *cobra.Command, app *appctx.App, id, recordType string
dec := json.NewDecoder(bytes.NewReader(resp.Data))
dec.UseNumber()
if err := dec.Decode(&data); err != nil {
return "", fmt.Errorf("failed to parse response: %w", err)
return nil, fmt.Errorf("failed to parse response: %w", err)
}

// If we used generic recording, re-fetch via type-specific endpoint for full content
Expand All @@ -500,8 +524,7 @@ func fetchItemContent(cmd *cobra.Command, app *appctx.App, id, recordType string
}
}

// Extract content — different item types use different field names
return extractContentField(data), nil
return data, nil
}

// extractContentField extracts the rich-text HTML content from a recording's
Expand Down Expand Up @@ -533,28 +556,51 @@ func extractContentField(data map[string]any) string {
return content
}

// resolveAttachmentTarget extracts the recording ID and type from a raw
// argument (plain ID or Basecamp URL) and an optional explicit type hint.
// Prefers CommentID when present and type is compatible.
func resolveAttachmentTarget(arg, recordType string) (id, resolvedType string) {
id = extractID(arg)
resolvedType = recordType

if parsed := urlarg.Parse(arg); parsed != nil {
if parsed.CommentID != "" && (resolvedType == "" || resolvedType == "comment" || resolvedType == "comments") {
id = parsed.CommentID
if resolvedType == "" {
resolvedType = "comment"
}
} else if parsed.RecordingID != "" {
id = parsed.RecordingID
}
if resolvedType == "" && parsed.Type != "" {
resolvedType = parsed.Type
// attachmentTarget is the item whose attachments list and download read.
// For one occurrence of a recurring event, id is the event's, occurrence is
// the date, and url names the occurrence without any comment fragment.
type attachmentTarget struct {
id string
recordType string
occurrence string
url string
}

// resolveAttachmentTarget reads the target from a raw argument (plain ID or
// Basecamp URL) and an optional explicit type hint. A comment fragment wins
// when the type allows a comment, even on an occurrence URL.
func resolveAttachmentTarget(arg, recordType string) (attachmentTarget, error) {
parsed := urlarg.Parse(arg)
if parsed != nil && parsed.CommentID != "" && (recordType == "" || recordType == "comment" || recordType == "comments") {
if recordType == "" {
recordType = "comment"
}
return attachmentTarget{id: parsed.CommentID, recordType: recordType}, nil
}
// Left open, the type would be looked up by the event's ID and read the
// whole series, so an occurrence URL takes its type from the URL.
if parsed != nil && (recordType == "" || (parsed.OccurrenceDate != "" && namesNoType(recordType))) {
recordType = parsed.Type
}
if parsed != nil && parsed.OccurrenceDate != "" && isScheduleEntryType(recordType) {
url, _, _ := strings.Cut(arg, "#")
return attachmentTarget{id: parsed.RecordingID, recordType: recordType, occurrence: parsed.OccurrenceDate, url: url}, nil
}

return id, resolvedType
id, err := extractID(arg)
if err != nil {
return attachmentTarget{}, err
}
return attachmentTarget{id: id, recordType: recordType}, nil
}

func isScheduleEntryType(recordType string) bool {
switch recordType {
case "schedule-entry", "schedule_entry", "schedule_entries":
return true
default:
return false
}
}

// normalizeShowType maps attachment type aliases to canonical types that
Expand Down
13 changes: 7 additions & 6 deletions internal/commands/attachments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,10 @@ func TestResolveAttachmentTarget(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
id, typ := resolveAttachmentTarget(tt.arg, tt.typeHint)
assert.Equal(t, tt.wantID, id)
assert.Equal(t, tt.wantType, typ)
target, err := resolveAttachmentTarget(tt.arg, tt.typeHint)
require.NoError(t, err)
assert.Equal(t, tt.wantID, target.id)
assert.Equal(t, tt.wantType, target.recordType)
})
}
}
Expand Down Expand Up @@ -474,11 +475,11 @@ func TestWriteBodyToFile(t *testing.T) {
})
}

// TestFetchItemContentRefetchesLineWithLargeParentID verifies that
// fetchItemContent uses UseNumber when decoding the recording response,
// TestFetchAttachmentItemRefetchesLineWithLargeParentID verifies that
// fetchAttachmentItem uses UseNumber when decoding the recording response,
// so parentRecordingID can build the correct refetch endpoint even when
// the parent ID exceeds 2^53.
func TestFetchItemContentRefetchesLineWithLargeParentID(t *testing.T) {
func TestFetchAttachmentItemRefetchesLineWithLargeParentID(t *testing.T) {
largeID := "9007199254740993" // 2^53 + 1

transport := &showTrackingTransport{
Expand Down
6 changes: 5 additions & 1 deletion internal/commands/bookmarks.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,11 @@ indistinguishable from a real error.
// takes. With no way to browse bookmarkable recordings from this group, a
// pasted URL is the natural way to name one.
func bookmarkRecordingID(arg string) (int64, error) {
id, err := strconv.ParseInt(extractID(arg), 10, 64)
idStr, err := extractID(arg)
if err != nil {
return 0, err
}
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
return 0, output.ErrUsageHint(
fmt.Sprintf("%q is not a recording id or Basecamp URL", arg),
Expand Down
Loading
Loading