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
14 changes: 14 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -1253,6 +1253,8 @@ FLAG basecamp cards list --page type=int
FLAG basecamp cards list --profile type=string
FLAG basecamp cards list --project type=string
FLAG basecamp cards list --quiet type=bool
FLAG basecamp cards list --reverse type=bool
FLAG basecamp cards list --sort type=string
FLAG basecamp cards list --stats type=bool
FLAG basecamp cards list --styled type=bool
FLAG basecamp cards list --todolist type=string
Expand Down Expand Up @@ -3765,6 +3767,8 @@ FLAG basecamp messages list --page type=int
FLAG basecamp messages list --profile type=string
FLAG basecamp messages list --project type=string
FLAG basecamp messages list --quiet type=bool
FLAG basecamp messages list --reverse type=bool
FLAG basecamp messages list --sort type=string
FLAG basecamp messages list --stats type=bool
FLAG basecamp messages list --styled type=bool
FLAG basecamp messages list --todolist type=string
Expand Down Expand Up @@ -4112,6 +4116,8 @@ FLAG basecamp people list --page type=int
FLAG basecamp people list --profile type=string
FLAG basecamp people list --project type=string
FLAG basecamp people list --quiet type=bool
FLAG basecamp people list --reverse type=bool
FLAG basecamp people list --sort type=string
FLAG basecamp people list --stats type=bool
FLAG basecamp people list --styled type=bool
FLAG basecamp people list --todolist type=string
Expand Down Expand Up @@ -4369,6 +4375,8 @@ FLAG basecamp projects list --page type=int
FLAG basecamp projects list --profile type=string
FLAG basecamp projects list --project type=string
FLAG basecamp projects list --quiet type=bool
FLAG basecamp projects list --reverse type=bool
FLAG basecamp projects list --sort type=string
FLAG basecamp projects list --stats type=bool
FLAG basecamp projects list --status type=string
FLAG basecamp projects list --styled type=bool
Expand Down Expand Up @@ -4767,7 +4775,9 @@ FLAG basecamp schedule entries --page type=int
FLAG basecamp schedule entries --profile type=string
FLAG basecamp schedule entries --project type=string
FLAG basecamp schedule entries --quiet type=bool
FLAG basecamp schedule entries --reverse type=bool
FLAG basecamp schedule entries --schedule type=string
FLAG basecamp schedule entries --sort type=string
FLAG basecamp schedule entries --stats type=bool
FLAG basecamp schedule entries --status type=string
FLAG basecamp schedule entries --styled type=bool
Expand Down Expand Up @@ -5654,6 +5664,8 @@ FLAG basecamp todolists list --page type=int
FLAG basecamp todolists list --profile type=string
FLAG basecamp todolists list --project type=string
FLAG basecamp todolists list --quiet type=bool
FLAG basecamp todolists list --reverse type=bool
FLAG basecamp todolists list --sort type=string
FLAG basecamp todolists list --stats type=bool
FLAG basecamp todolists list --styled type=bool
FLAG basecamp todolists list --todolist type=string
Expand Down Expand Up @@ -5848,6 +5860,8 @@ FLAG basecamp todos list --page type=int
FLAG basecamp todos list --profile type=string
FLAG basecamp todos list --project type=string
FLAG basecamp todos list --quiet type=bool
FLAG basecamp todos list --reverse type=bool
FLAG basecamp todos list --sort type=string
FLAG basecamp todos list --stats type=bool
FLAG basecamp todos list --status type=string
FLAG basecamp todos list --styled type=bool
Expand Down
33 changes: 31 additions & 2 deletions internal/commands/cards.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,25 +55,29 @@ func newCardsListCmd(project, cardTable *string) *cobra.Command {
var limit int
var page int
var all bool
var sortField string
var reverse bool

cmd := &cobra.Command{
Use: "list",
Short: "List cards",
Long: "List all cards in a project's card table.",
RunE: func(cmd *cobra.Command, args []string) error {
return runCardsList(cmd, *project, column, *cardTable, limit, page, all)
return runCardsList(cmd, *project, column, *cardTable, limit, page, all, sortField, reverse)
},
}

cmd.Flags().StringVarP(&column, "column", "c", "", "Filter by column ID or name")
cmd.Flags().IntVarP(&limit, "limit", "n", 0, "Maximum number of cards to fetch (0 = all)")
cmd.Flags().BoolVar(&all, "all", false, "Fetch all cards (no limit)")
cmd.Flags().IntVar(&page, "page", 0, "Fetch a single page (use --all for everything)")
cmd.Flags().StringVar(&sortField, "sort", "", "Sort by field (title, created, updated, position, due)")
cmd.Flags().BoolVar(&reverse, "reverse", false, "Reverse sort order")

return cmd
}

func runCardsList(cmd *cobra.Command, project, column, cardTable string, limit, page int, all bool) error {
func runCardsList(cmd *cobra.Command, project, column, cardTable string, limit, page int, all bool, sortField string, reverse bool) error {
app := appctx.FromContext(cmd.Context())

// Validate flag combinations
Expand All @@ -86,6 +90,14 @@ func runCardsList(cmd *cobra.Command, project, column, cardTable string, limit,
if page > 1 {
return output.ErrUsage("only --page 1 is supported; use --all to fetch everything")
}
if sortField != "" {
// Validate against the superset of all allowed fields early, before any
// API calls. Context-specific restrictions (e.g. no position in aggregate)
// are enforced at each branch below.
if err := validateSortField(sortField, []string{"title", "created", "updated", "position", "due"}); err != nil {
return err
}
}

// Pagination flags only make sense when listing a single column
// When aggregating across columns, pagination is per-column which is confusing
Expand Down Expand Up @@ -153,6 +165,10 @@ func runCardsList(cmd *cobra.Command, project, column, cardTable string, limit,
return convertSDKError(err)
}

if sortField != "" {
sortCards(cardsResult.Cards, sortField, reverse)
}

return app.OK(cardsResult.Cards,
output.WithSummary(fmt.Sprintf("%d cards", len(cardsResult.Cards))),
output.WithBreadcrumbs(
Expand Down Expand Up @@ -203,7 +219,16 @@ func runCardsList(cmd *cobra.Command, project, column, cardTable string, limit,
return convertSDKError(err)
}
allCards = cardsResult.Cards

if sortField != "" {
sortCards(allCards, sortField, reverse)
}
} else {
// No position in aggregate — it's only meaningful within a single column
if sortField == "position" {
return output.ErrUsage("--sort position requires --column (position is per-column)")
}

// Get cards from all columns (no pagination - already validated above)
for _, col := range cardTableData.Lists {
cardsResult, err := app.Account().Cards().List(cmd.Context(), col.ID, nil)
Expand All @@ -212,6 +237,10 @@ func runCardsList(cmd *cobra.Command, project, column, cardTable string, limit,
}
allCards = append(allCards, cardsResult.Cards...)
}

if sortField != "" {
sortCards(allCards, sortField, reverse)
}
}

return app.OK(allCards,
Expand Down
17 changes: 15 additions & 2 deletions internal/commands/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,24 +55,28 @@ use --message-board <id> to specify which one.`,
func newMessagesListCmd(project *string, messageBoard *string) *cobra.Command {
var limit, page int
var all bool
var sortField string
var reverse bool

cmd := &cobra.Command{
Use: "list",
Short: "List messages",
Long: "List all messages in a project's message board.",
RunE: func(cmd *cobra.Command, args []string) error {
return runMessagesList(cmd, *project, *messageBoard, limit, page, all)
return runMessagesList(cmd, *project, *messageBoard, limit, page, all, sortField, reverse)
},
}

cmd.Flags().IntVarP(&limit, "limit", "n", 0, "Maximum number of messages to fetch (0 = default 100)")
cmd.Flags().BoolVar(&all, "all", false, "Fetch all messages (no limit)")
cmd.Flags().IntVar(&page, "page", 0, "Fetch a single page (use --all for everything)")
cmd.Flags().StringVar(&sortField, "sort", "", "Sort by field (title, created, updated)")
cmd.Flags().BoolVar(&reverse, "reverse", false, "Reverse sort order")

return cmd
}

func runMessagesList(cmd *cobra.Command, project string, messageBoard string, limit, page int, all bool) error {
func runMessagesList(cmd *cobra.Command, project string, messageBoard string, limit, page int, all bool, sortField string, reverse bool) error {
app := appctx.FromContext(cmd.Context())

// Validate flag combinations
Expand All @@ -85,6 +89,11 @@ func runMessagesList(cmd *cobra.Command, project string, messageBoard string, li
if page > 1 {
return output.ErrUsage("only --page 1 is supported; use --all to fetch everything")
}
if sortField != "" {
if err := validateSortField(sortField, []string{"title", "created", "updated"}); err != nil {
return err
}
}

// Resolve account (enables interactive prompt if needed)
if err := ensureAccount(cmd, app); err != nil {
Expand Down Expand Up @@ -142,6 +151,10 @@ func runMessagesList(cmd *cobra.Command, project string, messageBoard string, li
}
messages := messagesResult.Messages

if sortField != "" {
sortMessages(messages, sortField, reverse)
}

// Build response options
respOpts := []output.ResponseOption{
output.WithSummary(fmt.Sprintf("%d messages", len(messages))),
Expand Down
31 changes: 25 additions & 6 deletions internal/commands/people.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package commands

import (
"fmt"
"slices"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -159,25 +160,29 @@ func newPeopleListCmd() *cobra.Command {
var projectID string
var limit, page int
var all bool
var sortField string
var reverse bool

cmd := &cobra.Command{
Use: "list",
Short: "List people",
Long: "List all people in your Basecamp account, or in a specific project.",
RunE: func(cmd *cobra.Command, args []string) error {
return runPeopleList(cmd, projectID, limit, page, all)
return runPeopleList(cmd, projectID, limit, page, all, sortField, reverse)
},
}

cmd.Flags().StringVarP(&projectID, "project", "p", "", "List people in a specific project")
cmd.Flags().IntVarP(&limit, "limit", "n", 0, "Maximum number of people to fetch (0 = all)")
cmd.Flags().BoolVar(&all, "all", false, "Fetch all people (no limit)")
cmd.Flags().IntVar(&page, "page", 0, "Fetch a single page (use --all for everything)")
cmd.Flags().StringVar(&sortField, "sort", "", "Sort by field (name)")
cmd.Flags().BoolVar(&reverse, "reverse", false, "Reverse sort order")

return cmd
}

func runPeopleList(cmd *cobra.Command, projectID string, limit, page int, all bool) error {
func runPeopleList(cmd *cobra.Command, projectID string, limit, page int, all bool, sortField string, reverse bool) error {
app := appctx.FromContext(cmd.Context())

// Validate flag combinations
Expand All @@ -190,6 +195,11 @@ func runPeopleList(cmd *cobra.Command, projectID string, limit, page int, all bo
if page > 1 {
return output.ErrUsage("only --page 1 is supported; use --all to fetch everything")
}
if sortField != "" {
if err := validateSortField(sortField, []string{"name"}); err != nil {
return err
}
}

if err := ensureAccount(cmd, app); err != nil {
return err
Expand Down Expand Up @@ -235,7 +245,19 @@ func runPeopleList(cmd *cobra.Command, projectID string, limit, page int, all bo
updatePeopleCache(people, app.Config.CacheDir)
}

// Slim output and sort by name
// Sort raw people before slimming (sort functions need full SDK type)
if sortField != "" {
sortPeople(people, sortField, reverse)
} else {
sort.Slice(people, func(i, j int) bool {
return strings.ToLower(people[i].Name) < strings.ToLower(people[j].Name)
})
if reverse {
slices.Reverse(people)
}
}

// Slim output
type personListItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
Expand All @@ -253,9 +275,6 @@ func runPeopleList(cmd *cobra.Command, projectID string, limit, page int, all bo
Admin: p.Admin,
}
}
sort.Slice(items, func(i, j int) bool {
return items[i].Name < items[j].Name
})

summary := fmt.Sprintf("%d people", len(items))
breadcrumbs := []output.Breadcrumb{
Expand Down
27 changes: 21 additions & 6 deletions internal/commands/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package commands
import (
"errors"
"fmt"
"slices"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -45,25 +46,29 @@ func newProjectsListCmd() *cobra.Command {
var status string
var limit, page int
var all bool
var sortField string
var reverse bool

cmd := &cobra.Command{
Use: "list",
Short: "List projects",
Long: "List all accessible projects in the account.",
RunE: func(cmd *cobra.Command, args []string) error {
return runProjectsList(cmd, status, limit, page, all)
return runProjectsList(cmd, status, limit, page, all, sortField, reverse)
},
}

cmd.Flags().StringVar(&status, "status", "", "Filter by status (active, archived, trashed)")
cmd.Flags().IntVarP(&limit, "limit", "n", 0, "Maximum number of projects to fetch (0 = all)")
cmd.Flags().BoolVar(&all, "all", false, "Fetch all projects (no limit)")
cmd.Flags().IntVar(&page, "page", 0, "Fetch a single page (use --all for everything)")
cmd.Flags().StringVar(&sortField, "sort", "", "Sort by field (title, created, updated)")
cmd.Flags().BoolVar(&reverse, "reverse", false, "Reverse sort order")

return cmd
}

func runProjectsList(cmd *cobra.Command, status string, limit, page int, all bool) error {
func runProjectsList(cmd *cobra.Command, status string, limit, page int, all bool, sortField string, reverse bool) error {
app := appctx.FromContext(cmd.Context())
if app == nil {
return fmt.Errorf("app not initialized")
Expand All @@ -79,6 +84,11 @@ func runProjectsList(cmd *cobra.Command, status string, limit, page int, all boo
if page > 1 {
return output.ErrUsage("only --page 1 is supported; use --all to fetch everything")
}
if sortField != "" {
if err := validateSortField(sortField, []string{"title", "created", "updated"}); err != nil {
return err
}
}

// Resolve account if not configured (enables interactive prompt)
if err := ensureAccount(cmd, app); err != nil {
Expand Down Expand Up @@ -107,13 +117,18 @@ func runProjectsList(cmd *cobra.Command, status string, limit, page int, all boo

projects := result.Projects

// Sort alphabetically by name (API returns reverse_chronologically).
// Only sort when we have the full result set — alphabetizing a partial
// page would create a misleading view.
if page == 0 && limit == 0 {
if sortField != "" {
sortProjects(projects, sortField, reverse)
} else if page == 0 && limit == 0 {
// Default: sort alphabetically by name (API returns reverse_chronologically).
// Only sort when we have the full result set — alphabetizing a partial
// page would create a misleading view.
sort.Slice(projects, func(i, j int) bool {
return strings.ToLower(projects[i].Name) < strings.ToLower(projects[j].Name)
})
if reverse {
slices.Reverse(projects)
}
}

// Opportunistic cache refresh: update completion cache as a side-effect.
Expand Down
Loading
Loading