feat(cli): add robust box listing filters and sorting
This commit is contained in:
192
cmd/cmd_box.go
192
cmd/cmd_box.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"warpbox/lib/models"
|
||||
)
|
||||
|
||||
// ── List output ──────────────────────────────────────────────
|
||||
|
||||
func formatBoxSummariesTable(summaries []models.BoxSummary) error {
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ID\tFiles\tSize\tCreated\tExpires\tPassword\tOne-Time\tExpired")
|
||||
@@ -53,6 +55,31 @@ func formatBoxSummariesJSON(summaries []models.BoxSummary) error {
|
||||
return enc.Encode(out)
|
||||
}
|
||||
|
||||
// ── View output ──────────────────────────────────────────────
|
||||
|
||||
func formatBoxSummaryJSON(s *models.BoxSummary) error {
|
||||
type summaryOut struct {
|
||||
ID string `json:"id"`
|
||||
FileCount int `json:"file_count"`
|
||||
TotalSize int64 `json:"total_size"`
|
||||
TotalSizeLabel string `json:"total_size_label"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Expired bool `json:"expired"`
|
||||
OneTimeDownload bool `json:"one_time_download"`
|
||||
PasswordProtected bool `json:"password_protected"`
|
||||
}
|
||||
out := summaryOut{
|
||||
ID: s.ID, FileCount: s.FileCount, TotalSize: s.TotalSize,
|
||||
TotalSizeLabel: s.TotalSizeLabel, CreatedAt: s.CreatedAt,
|
||||
ExpiresAt: s.ExpiresAt, Expired: s.Expired,
|
||||
OneTimeDownload: s.OneTimeDownload, PasswordProtected: s.PasswordProtected,
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(out)
|
||||
}
|
||||
|
||||
func printBoxSummary(s *models.BoxSummary) {
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintf(w, "ID:\t%s\n", s.ID)
|
||||
@@ -70,6 +97,80 @@ func printBoxSummary(s *models.BoxSummary) {
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
// ── Get output ───────────────────────────────────────────────
|
||||
|
||||
func formatBoxGetJSON(boxID string, manifest models.BoxManifest) error {
|
||||
type getOut struct {
|
||||
BoxID string `json:"box_id"`
|
||||
URL string `json:"url"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
Expired bool `json:"expired"`
|
||||
PasswordProtected bool `json:"password_protected"`
|
||||
OneTimeDownload bool `json:"one_time_download"`
|
||||
RetentionKey string `json:"retention_key,omitempty"`
|
||||
RetentionLabel string `json:"retention_label,omitempty"`
|
||||
}
|
||||
out := getOut{
|
||||
BoxID: boxID, URL: "/box/" + boxID,
|
||||
Expired: boxstore.IsExpired(manifest),
|
||||
}
|
||||
if !manifest.CreatedAt.IsZero() {
|
||||
out.CreatedAt = manifest.CreatedAt
|
||||
}
|
||||
if !manifest.ExpiresAt.IsZero() {
|
||||
out.ExpiresAt = manifest.ExpiresAt
|
||||
}
|
||||
out.PasswordProtected = boxstore.IsPasswordProtected(manifest)
|
||||
out.OneTimeDownload = manifest.OneTimeDownload
|
||||
out.RetentionKey = manifest.RetentionKey
|
||||
out.RetentionLabel = manifest.RetentionLabel
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(out)
|
||||
}
|
||||
|
||||
// ── Change output ────────────────────────────────────────────
|
||||
|
||||
func formatChangeResultJSON(boxID string, manifest models.BoxManifest) error {
|
||||
type changeOut struct {
|
||||
BoxID string `json:"box_id"`
|
||||
Updated bool `json:"updated"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
Expired bool `json:"expired"`
|
||||
PasswordProtected bool `json:"password_protected"`
|
||||
OneTimeDownload bool `json:"one_time_download"`
|
||||
DisableZip bool `json:"disable_zip"`
|
||||
RetentionKey string `json:"retention_key,omitempty"`
|
||||
RetentionLabel string `json:"retention_label,omitempty"`
|
||||
RetentionSeconds int64 `json:"retention_seconds,omitempty"`
|
||||
FileCount int `json:"file_count"`
|
||||
}
|
||||
out := changeOut{
|
||||
BoxID: boxID, Updated: true,
|
||||
Expired: boxstore.IsExpired(manifest),
|
||||
PasswordProtected: boxstore.IsPasswordProtected(manifest),
|
||||
OneTimeDownload: manifest.OneTimeDownload,
|
||||
DisableZip: manifest.DisableZip,
|
||||
RetentionKey: manifest.RetentionKey,
|
||||
RetentionLabel: manifest.RetentionLabel,
|
||||
RetentionSeconds: manifest.RetentionSecs,
|
||||
FileCount: len(manifest.Files),
|
||||
}
|
||||
if !manifest.CreatedAt.IsZero() {
|
||||
out.CreatedAt = manifest.CreatedAt
|
||||
}
|
||||
if !manifest.ExpiresAt.IsZero() {
|
||||
out.ExpiresAt = manifest.ExpiresAt
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(out)
|
||||
}
|
||||
|
||||
// ── Retention options ────────────────────────────────────────
|
||||
|
||||
func printRetentionOptions() {
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "Key\tLabel\tSeconds")
|
||||
|
||||
Reference in New Issue
Block a user