Skip to content
Merged
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/basecamp/bcq
go 1.25.6

require (
github.com/basecamp/basecamp-sdk/go v0.0.0-20260126021743-c24713ba916d
github.com/basecamp/basecamp-sdk/go v0.0.0-20260126021925-f4b3d7ca1658
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v0.8.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/basecamp/basecamp-sdk/go v0.0.0-20260126021743-c24713ba916d h1:OYvYxPgKCgTiEj5f7PR7qQHbellXhtsZQc8xgNYpuuM=
github.com/basecamp/basecamp-sdk/go v0.0.0-20260126021743-c24713ba916d/go.mod h1:RnIREa1vKW6DqkxUjXwAdmlKY8NcOk/9iYeUZoXHHAs=
github.com/basecamp/basecamp-sdk/go v0.0.0-20260126021925-f4b3d7ca1658 h1:sHLrf0NGmMKMXxcq8xhjdp3BRKRXV+XGgx7rEO28MEk=
github.com/basecamp/basecamp-sdk/go v0.0.0-20260126021925-f4b3d7ca1658/go.mod h1:RnIREa1vKW6DqkxUjXwAdmlKY8NcOk/9iYeUZoXHHAs=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
Expand Down
161 changes: 69 additions & 92 deletions internal/commands/recordings.go
Original file line number Diff line number Diff line change
@@ -1,30 +1,16 @@
package commands

import (
"encoding/json"
"fmt"
"net/url"
"strconv"

"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
"github.com/spf13/cobra"

"github.com/basecamp/bcq/internal/appctx"
"github.com/basecamp/bcq/internal/output"
)

// Recording represents a Basecamp recording from the recordings endpoint.
type Recording struct {
ID int64 `json:"id"`
Type string `json:"type"`
Title string `json:"title,omitempty"`
Content string `json:"content,omitempty"`
Status string `json:"status"`
UpdatedAt string `json:"updated_at"`
Bucket struct {
ID int64 `json:"id"`
Name string `json:"name"`
} `json:"bucket"`
}

// NewRecordingsCmd creates the recordings command for cross-project browsing.
func NewRecordingsCmd() *cobra.Command {
var recordingType string
Expand Down Expand Up @@ -59,10 +45,6 @@ Type is required: todos, messages, documents, comments, cards, uploads.`,
)
}

if err := app.API.RequireAccount(); err != nil {
return err
}

return runRecordingsList(cmd, app, effectiveType, project, status, sortBy, direction, limit)
},
}
Expand Down Expand Up @@ -139,10 +121,6 @@ func newRecordingsListCmd(project *string) *cobra.Command {
)
}

if err := app.API.RequireAccount(); err != nil {
return err
}

return runRecordingsList(cmd, app, effectiveType, *project, status, sortBy, direction, limit)
},
}
Expand All @@ -157,30 +135,28 @@ func newRecordingsListCmd(project *string) *cobra.Command {
}

func runRecordingsList(cmd *cobra.Command, app *appctx.App, recordingType, project, status, sortBy, direction string, limit int) error {
// Build query string
params := url.Values{}
params.Set("type", recordingType)
params.Set("status", status)
params.Set("sort", sortBy)
params.Set("direction", direction)
// Build options
opts := &basecamp.RecordingsListOptions{
Status: status,
Sort: sortBy,
Direction: direction,
}

if project != "" {
resolvedProjectID, _, err := app.Names.ResolveProject(cmd.Context(), project)
if err != nil {
return err
}
params.Set("bucket", resolvedProjectID)
projectID, err := strconv.ParseInt(resolvedProjectID, 10, 64)
if err != nil {
return output.ErrUsage("Invalid project ID")
}
opts.Bucket = []int64{projectID}
}

path := fmt.Sprintf("/projects/recordings.json?%s", params.Encode())
resp, err := app.API.Get(cmd.Context(), path)
recordings, err := app.SDK.Recordings().List(cmd.Context(), basecamp.RecordingType(recordingType), opts)
if err != nil {
return err
}

var recordings []json.RawMessage
if err := resp.UnmarshalData(&recordings); err != nil {
return fmt.Errorf("failed to parse recordings: %w", err)
return convertSDKError(err)
}

// Apply client-side limit if specified
Expand Down Expand Up @@ -211,9 +187,6 @@ func newRecordingsTrashCmd(project *string) *cobra.Command {
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())
if err := app.API.RequireAccount(); err != nil {
return err
}
return runRecordingsStatus(cmd, app, args[0], *project, "trashed")
},
}
Expand All @@ -229,9 +202,6 @@ func newRecordingsArchiveCmd(project *string) *cobra.Command {
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())
if err := app.API.RequireAccount(); err != nil {
return err
}
return runRecordingsStatus(cmd, app, args[0], *project, "archived")
},
}
Expand All @@ -247,43 +217,55 @@ func newRecordingsRestoreCmd(project *string) *cobra.Command {
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())
if err := app.API.RequireAccount(); err != nil {
return err
}
return runRecordingsStatus(cmd, app, args[0], *project, "active")
},
}
return cmd
}

func runRecordingsStatus(cmd *cobra.Command, app *appctx.App, recordingID, project, newStatus string) error {
func runRecordingsStatus(cmd *cobra.Command, app *appctx.App, recordingIDStr, project, newStatus string) error {
// Parse recording ID
recordingID, err := strconv.ParseInt(recordingIDStr, 10, 64)
if err != nil {
return output.ErrUsage("Invalid recording ID")
}

// Resolve project
projectID := project
if projectID == "" {
projectID = app.Flags.Project
projectIDStr := project
if projectIDStr == "" {
projectIDStr = app.Flags.Project
}
if projectID == "" {
projectID = app.Config.ProjectID
if projectIDStr == "" {
projectIDStr = app.Config.ProjectID
}
if projectID == "" {
if projectIDStr == "" {
return output.ErrUsage("--project is required")
}

resolvedProjectID, _, err := app.Names.ResolveProject(cmd.Context(), projectID)
resolvedProjectIDStr, _, err := app.Names.ResolveProject(cmd.Context(), projectIDStr)
if err != nil {
return err
}

path := fmt.Sprintf("/buckets/%s/recordings/%s/status/%s.json", resolvedProjectID, recordingID, newStatus)
resp, err := app.API.Put(cmd.Context(), path, map[string]string{})
bucketID, err := strconv.ParseInt(resolvedProjectIDStr, 10, 64)
if err != nil {
return err
return output.ErrUsage("Invalid project ID")
}

// Call appropriate SDK method based on status
switch newStatus {
case "trashed":
err = app.SDK.Recordings().Trash(cmd.Context(), bucketID, recordingID)
case "archived":
err = app.SDK.Recordings().Archive(cmd.Context(), bucketID, recordingID)
case "active":
err = app.SDK.Recordings().Unarchive(cmd.Context(), bucketID, recordingID)
Comment on lines +261 to +262

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore trashed recordings instead of only unarchiving

The CLI help says recordings restore should bring back items from trash or archive, but the active branch only calls Recordings().Unarchive. That only targets archived recordings; a trashed recording won’t be restored (it will likely 404 or stay trashed), so bcq recordings restore <id> fails for trashed items. Consider calling the SDK method that maps to the status/active/restore endpoint (or adding a trash-restore path) so trashed recordings are handled too.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified that SDK Unarchive() calls PUT /status/active.json which works for BOTH archived AND trashed recordings per the Basecamp API. Added clarifying comment in PR #73.

default:
return output.ErrUsage(fmt.Sprintf("Unknown status: %s", newStatus))
}

// Handle 204 No Content
var data any = map[string]any{}
if len(resp.Data) > 0 {
data = json.RawMessage(resp.Data)
if err != nil {
return convertSDKError(err)
}

var statusMsg string
Expand All @@ -298,14 +280,14 @@ func runRecordingsStatus(cmd *cobra.Command, app *appctx.App, recordingID, proje
statusMsg = fmt.Sprintf("Changed to %s", newStatus)
}

summary := fmt.Sprintf("%s recording #%s", statusMsg, recordingID)
summary := fmt.Sprintf("%s recording #%s", statusMsg, recordingIDStr)

return app.Output.OK(data,
return app.Output.OK(map[string]any{"id": recordingID, "status": newStatus},
output.WithSummary(summary),
output.WithBreadcrumbs(
output.Breadcrumb{
Action: "show",
Cmd: fmt.Sprintf("bcq show %s --in %s", recordingID, resolvedProjectID),
Cmd: fmt.Sprintf("bcq show %s --in %s", recordingIDStr, resolvedProjectIDStr),
Description: "View recording",
},
),
Expand All @@ -324,11 +306,12 @@ func newRecordingsVisibilityCmd(project *string) *cobra.Command {
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())
if err := app.API.RequireAccount(); err != nil {
return err
}

recordingID := args[0]
recordingIDStr := args[0]
recordingID, err := strconv.ParseInt(recordingIDStr, 10, 64)
if err != nil {
return output.ErrUsage("Invalid recording ID")
}

// Determine visibility
var isVisible bool
Expand All @@ -341,51 +324,45 @@ func newRecordingsVisibilityCmd(project *string) *cobra.Command {
isVisible = visible

// Resolve project
projectID := *project
if projectID == "" {
projectID = app.Flags.Project
projectIDStr := *project
if projectIDStr == "" {
projectIDStr = app.Flags.Project
}
if projectID == "" {
projectID = app.Config.ProjectID
if projectIDStr == "" {
projectIDStr = app.Config.ProjectID
}
if projectID == "" {
if projectIDStr == "" {
return output.ErrUsage("--project is required")
}

resolvedProjectID, _, err := app.Names.ResolveProject(cmd.Context(), projectID)
resolvedProjectIDStr, _, err := app.Names.ResolveProject(cmd.Context(), projectIDStr)
if err != nil {
return err
}

body := map[string]bool{
"visible_to_clients": isVisible,
}

path := fmt.Sprintf("/buckets/%s/recordings/%s/client_visibility.json", resolvedProjectID, recordingID)
resp, err := app.API.Put(cmd.Context(), path, body)
bucketID, err := strconv.ParseInt(resolvedProjectIDStr, 10, 64)
if err != nil {
return err
return output.ErrUsage("Invalid project ID")
}

// Handle 204 No Content
var data any = map[string]any{}
if len(resp.Data) > 0 {
data = json.RawMessage(resp.Data)
recording, err := app.SDK.Recordings().SetClientVisibility(cmd.Context(), bucketID, recordingID, isVisible)
if err != nil {
return convertSDKError(err)
}

var summary string
if isVisible {
summary = fmt.Sprintf("Recording #%s now visible to clients", recordingID)
summary = fmt.Sprintf("Recording #%s now visible to clients", recordingIDStr)
} else {
summary = fmt.Sprintf("Recording #%s now hidden from clients", recordingID)
summary = fmt.Sprintf("Recording #%s now hidden from clients", recordingIDStr)
}

return app.Output.OK(data,
return app.Output.OK(recording,
output.WithSummary(summary),
output.WithBreadcrumbs(
output.Breadcrumb{
Action: "show",
Cmd: fmt.Sprintf("bcq show %s --in %s", recordingID, resolvedProjectID),
Cmd: fmt.Sprintf("bcq show %s --in %s", recordingIDStr, resolvedProjectIDStr),
Description: "View recording",
},
),
Expand Down
Loading