feat(cli): add robust box listing filters and sorting

This commit is contained in:
2026-04-30 12:46:44 +03:00
parent b0bdf798a9
commit b8bb75f7e0
4 changed files with 291 additions and 713 deletions

View File

@@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
@@ -36,10 +38,20 @@ func newBoxCommand() *cobra.Command {
func newBoxListCommand() *cobra.Command {
var format string
var uploadRoot string
var sortBy string
var sortOrder string
var filterExpired string
var filterPassword string
var filterOneTime string
var filterSizeMin string
var filterSizeMax string
var filterCreatedAfter string
var filterCreatedBefore string
cmd := &cobra.Command{
Use: "ls",
Aliases: []string{"list", "view"},
Short: "List all boxes",
Long: "List all boxes with optional sorting and filtering.",
RunE: func(cmd *cobra.Command, args []string) error {
if uploadRoot != "" {
boxstore.SetUploadRoot(uploadRoot)
@@ -52,6 +64,18 @@ func newBoxListCommand() *cobra.Command {
fmt.Println("No boxes found.")
return nil
}
// Apply filters
summaries = filterBoxes(summaries, filterExpired, filterPassword, filterOneTime, filterSizeMin, filterSizeMax, filterCreatedAfter, filterCreatedBefore)
if len(summaries) == 0 {
fmt.Println("No boxes match the given filters.")
return nil
}
// Apply sorting
sortBoxes(summaries, sortBy, sortOrder)
switch format {
case "json":
return formatBoxSummariesJSON(summaries)
@@ -64,11 +88,140 @@ func newBoxListCommand() *cobra.Command {
}
cmd.Flags().StringVarP(&format, "format", "o", "table", "Output format: table, json")
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
cmd.Flags().StringVar(&sortBy, "sort", "created", "Sort field: created, expires, size, files")
cmd.Flags().StringVar(&sortOrder, "sort-order", "desc", "Sort order: asc, desc")
cmd.Flags().StringVar(&filterExpired, "filter-expired", "", "Filter by expiry: yes, no, all")
cmd.Flags().StringVar(&filterPassword, "filter-password", "", "Filter by password: yes, no, all")
cmd.Flags().StringVar(&filterOneTime, "filter-one-time", "", "Filter by one-time: yes, no, all")
cmd.Flags().StringVar(&filterSizeMin, "filter-size-min", "", "Minimum total size in bytes (e.g. 1024, 1k, 1m, 1g)")
cmd.Flags().StringVar(&filterSizeMax, "filter-size-max", "", "Maximum total size in bytes (e.g. 1024, 1k, 1m, 1g)")
cmd.Flags().StringVar(&filterCreatedAfter, "filter-created-after", "", "Only boxes created after this time (RFC3339)")
cmd.Flags().StringVar(&filterCreatedBefore, "filter-created-before", "", "Only boxes created before this time (RFC3339)")
return cmd
}
func filterBoxes(summaries []models.BoxSummary, filterExpired, filterPassword, filterOneTime, filterSizeMin, filterSizeMax, filterCreatedAfter, filterCreatedBefore string) []models.BoxSummary {
result := make([]models.BoxSummary, 0, len(summaries))
minSize, _ := parseSizeFilter(filterSizeMin)
maxSize, _ := parseSizeFilter(filterSizeMax)
createdAfter, _ := time.Parse(time.RFC3339, filterCreatedAfter)
createdBefore, _ := time.Parse(time.RFC3339, filterCreatedBefore)
for _, s := range summaries {
if filterExpired != "" && filterExpired != "all" {
match := "no"
if s.Expired {
match = "yes"
}
if match != filterExpired {
continue
}
}
if filterPassword != "" && filterPassword != "all" {
match := "no"
if s.PasswordProtected {
match = "yes"
}
if match != filterPassword {
continue
}
}
if filterOneTime != "" && filterOneTime != "all" {
match := "no"
if s.OneTimeDownload {
match = "yes"
}
if match != filterOneTime {
continue
}
}
if minSize > 0 && s.TotalSize < minSize {
continue
}
if maxSize > 0 && s.TotalSize > maxSize {
continue
}
if !createdAfter.IsZero() && s.CreatedAt.Before(createdAfter) {
continue
}
if !createdBefore.IsZero() && !s.CreatedAt.Before(createdBefore) {
continue
}
result = append(result, s)
}
return result
}
func parseSizeFilter(s string) (int64, error) {
if s == "" {
return 0, nil
}
s = strings.TrimSpace(s)
lower := strings.ToLower(s)
multiplier := int64(1)
switch {
case strings.HasSuffix(lower, "g"):
multiplier = 1024 * 1024 * 1024
s = strings.TrimSuffix(lower, "g")
case strings.HasSuffix(lower, "m"):
multiplier = 1024 * 1024
s = strings.TrimSuffix(lower, "m")
case strings.HasSuffix(lower, "k"):
multiplier = 1024
s = strings.TrimSuffix(lower, "k")
}
val, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid size filter: %s", s)
}
return val * multiplier, nil
}
func sortBoxes(summaries []models.BoxSummary, sortBy, sortOrder string) {
reverse := false
if strings.EqualFold(sortOrder, "desc") {
reverse = true
}
sort.SliceStable(summaries, func(i, j int) bool {
var less bool
switch strings.ToLower(sortBy) {
case "size":
less = summaries[i].TotalSize < summaries[j].TotalSize
case "files":
less = summaries[i].FileCount < summaries[j].FileCount
case "expires":
// Boxes with no expiry go last
iZero := summaries[i].ExpiresAt.IsZero()
jZero := summaries[j].ExpiresAt.IsZero()
if iZero && jZero {
return false
}
if iZero {
return false
}
if jZero {
return true
}
less = summaries[i].ExpiresAt.Before(summaries[j].ExpiresAt)
case "created", "":
less = summaries[i].CreatedAt.Before(summaries[j].CreatedAt)
default:
less = summaries[i].ID < summaries[j].ID
}
if reverse {
return !less
}
return less
})
}
func newBoxViewCommand() *cobra.Command {
var uploadRoot string
var asJSON bool
cmd := &cobra.Command{
Use: "view",
Short: "View box summary",
@@ -83,17 +236,22 @@ func newBoxViewCommand() *cobra.Command {
if err != nil {
return fmt.Errorf("failed to view box %s: %w", boxID, err)
}
if asJSON {
return formatBoxSummaryJSON(&summary)
}
printBoxSummary(&summary)
return nil
},
}
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
return cmd
}
func newBoxInspectCommand() *cobra.Command {
var uploadRoot string
var full bool
var asJSON bool
cmd := &cobra.Command{
Use: "inspect",
Short: "Inspect box manifest (raw JSON)",
@@ -122,12 +280,15 @@ func newBoxInspectCommand() *cobra.Command {
}
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
cmd.Flags().BoolVar(&full, "full", false, "Show sensitive fields (password hash, auth token)")
cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON (default for inspect)")
_ = asJSON // inspect is always JSON; flag kept for consistency
return cmd
}
func newBoxDeleteCommand() *cobra.Command {
var uploadRoot string
var force bool
var asJSON bool
cmd := &cobra.Command{
Use: "rm",
Aliases: []string{"del", "delete"},
@@ -146,19 +307,33 @@ func newBoxDeleteCommand() *cobra.Command {
confirm = "n"
}
if strings.ToLower(strings.TrimSpace(confirm)) != "y" {
fmt.Println("Aborted.")
if asJSON {
fmt.Println(`{"deleted": false, "reason": "aborted"}`)
} else {
fmt.Println("Aborted.")
}
return nil
}
}
if err := boxstore.DeleteBox(boxID); err != nil {
return fmt.Errorf("failed to delete box %s: %w", boxID, err)
if asJSON {
fmt.Printf(`{"deleted": false, "error": "%s"}\n`, strings.ReplaceAll(err.Error(), `"`, `\"`))
} else {
return fmt.Errorf("failed to delete box %s: %w", boxID, err)
}
return nil
}
if asJSON {
fmt.Printf(`{"deleted": true, "box_id": "%s"}\n`, boxID)
} else {
fmt.Printf("Box %s deleted.\n", boxID)
}
fmt.Printf("Box %s deleted.\n", boxID)
return nil
},
}
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt")
cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
return cmd
}
@@ -171,6 +346,7 @@ func newBoxChangeCommand() *cobra.Command {
var oneTime bool
var renew bool
var renewSeconds int64
var asJSON bool
cmd := &cobra.Command{
Use: "change",
@@ -214,6 +390,9 @@ func newBoxChangeCommand() *cobra.Command {
return fmt.Errorf("failed to save manifest for box %s: %w", boxID, err)
}
if asJSON {
return formatChangeResultJSON(boxID, manifest)
}
fmt.Printf("Box %s updated.\n", boxID)
return nil
},
@@ -227,6 +406,7 @@ func newBoxChangeCommand() *cobra.Command {
cmd.Flags().BoolVar(&oneTime, "one-time", false, "Enable one-time download mode")
cmd.Flags().BoolVar(&renew, "renew", false, "Renew box expiry (use --renew-seconds for duration)")
cmd.Flags().Int64Var(&renewSeconds, "renew-seconds", 0, "Seconds to extend expiry by (used with --renew)")
cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
return cmd
}
@@ -332,6 +512,7 @@ func renewBoxExpiry(m *models.BoxManifest, seconds int64) error {
func newBoxGetCommand() *cobra.Command {
var uploadRoot string
var asJSON bool
cmd := &cobra.Command{
Use: "get",
Short: "Get box URL and info",
@@ -346,6 +527,10 @@ func newBoxGetCommand() *cobra.Command {
return fmt.Errorf("failed to read manifest for box %s: %w", boxID, err)
}
if asJSON {
return formatBoxGetJSON(boxID, manifest)
}
fmt.Printf("Box ID:\t%s\n", boxID)
fmt.Printf("URL:\t/box/%s\n", boxID)
if !manifest.CreatedAt.IsZero() {
@@ -364,5 +549,6 @@ func newBoxGetCommand() *cobra.Command {
},
}
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
return cmd
}