mirror of
https://github.com/chenasraf/cospend-cli.git
synced 2026-05-17 17:38:04 +00:00
feat: add csv/json format to ls
This commit is contained in:
@@ -235,6 +235,10 @@ cospend list -p myproject --recent 1m
|
||||
|
||||
# Combine multiple filters
|
||||
cospend list -p myproject -b alice -c restaurant --amount ">=20"
|
||||
|
||||
# Output as CSV or JSON
|
||||
cospend list -p myproject --format csv
|
||||
cospend list -p myproject --format json
|
||||
```
|
||||
|
||||
#### List Command Flags
|
||||
@@ -253,6 +257,7 @@ cospend list -p myproject -b alice -c restaurant --amount ">=20"
|
||||
| | `--this-month` | Filter bills from the current month |
|
||||
| | `--this-week` | Filter bills from the current calendar week |
|
||||
| | `--recent` | Filter recent bills (e.g., `7d`, `2w`, `1m`) |
|
||||
| | `--format` | Output format: `table` (default), `csv`, `json` |
|
||||
| `-h` | `--help` | Display help information |
|
||||
|
||||
The output includes the bill ID for each expense, which can be used with the delete command.
|
||||
|
||||
132
cmd/list.go
132
cmd/list.go
@@ -1,6 +1,8 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
@@ -27,6 +29,7 @@ var (
|
||||
listThisMonth bool
|
||||
listThisWeek bool
|
||||
listRecent string
|
||||
listFormat string
|
||||
)
|
||||
|
||||
// amountFilter holds parsed amount filter criteria
|
||||
@@ -69,6 +72,7 @@ Examples:
|
||||
cmd.Flags().BoolVar(&listThisMonth, "this-month", false, "Filter bills from the current month")
|
||||
cmd.Flags().BoolVar(&listThisWeek, "this-week", false, "Filter bills from the current calendar week")
|
||||
cmd.Flags().StringVar(&listRecent, "recent", "", "Filter recent bills (e.g., 7d, 2w, 1m)")
|
||||
cmd.Flags().StringVar(&listFormat, "format", "table", "Output format: table, csv, json")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -78,6 +82,12 @@ func runList(cmd *cobra.Command, _ []string) error {
|
||||
return fmt.Errorf("project is required (use -p or --project)")
|
||||
}
|
||||
|
||||
switch listFormat {
|
||||
case "table", "csv", "json":
|
||||
default:
|
||||
return fmt.Errorf("unsupported format: %s (expected table, csv, or json)", listFormat)
|
||||
}
|
||||
|
||||
// Parameters validated, silence usage for subsequent errors
|
||||
cmd.SilenceUsage = true
|
||||
|
||||
@@ -138,9 +148,18 @@ func runList(cmd *cobra.Command, _ []string) error {
|
||||
// Apply filters
|
||||
filteredBills := applyFilters(bills, filters)
|
||||
|
||||
// Print table
|
||||
// Output results
|
||||
formatter := format.NewAmountFormatter(locale, project.CurrencyName)
|
||||
printBillsTable(cmd, project, filteredBills, formatter)
|
||||
resolved := resolveBillNames(project, filteredBills)
|
||||
|
||||
switch listFormat {
|
||||
case "csv":
|
||||
printBillsCSV(cmd, resolved)
|
||||
case "json":
|
||||
printBillsJSON(cmd, resolved)
|
||||
default:
|
||||
printBillsTable(cmd, resolved, formatter)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -421,12 +440,19 @@ func parseRecent(s string) (time.Time, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func printBillsTable(cmd *cobra.Command, project *api.Project, bills []api.BillResponse, formatter *format.AmountFormatter) {
|
||||
if len(bills) == 0 {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "No bills found.")
|
||||
return
|
||||
}
|
||||
// resolvedBill holds a bill with human-readable names resolved from IDs
|
||||
type resolvedBill struct {
|
||||
ID int `json:"id"`
|
||||
Date string `json:"date"`
|
||||
Name string `json:"name"`
|
||||
Amount float64 `json:"amount"`
|
||||
PaidBy string `json:"paid_by"`
|
||||
PaidFor []string `json:"paid_for"`
|
||||
Category string `json:"category"`
|
||||
PaymentMethod string `json:"payment_method"`
|
||||
}
|
||||
|
||||
func resolveBillNames(project *api.Project, bills []api.BillResponse) []resolvedBill {
|
||||
// Sort by date (newest first), then by timestamp for same-date entries
|
||||
sort.Slice(bills, func(i, j int) bool {
|
||||
if bills[i].Date != bills[j].Date {
|
||||
@@ -440,34 +466,27 @@ func printBillsTable(cmd *cobra.Command, project *api.Project, bills []api.BillR
|
||||
bills = bills[:listLimit]
|
||||
}
|
||||
|
||||
// Build lookup maps for names
|
||||
// Build lookup maps
|
||||
memberNames := make(map[int]string)
|
||||
for _, m := range project.Members {
|
||||
memberNames[m.ID] = m.Name
|
||||
}
|
||||
|
||||
categoryNames := make(map[int]string)
|
||||
for _, c := range project.Categories {
|
||||
categoryNames[c.ID] = c.Name
|
||||
}
|
||||
|
||||
paymentModeNames := make(map[int]string)
|
||||
for _, pm := range project.PaymentModes {
|
||||
paymentModeNames[pm.ID] = pm.Name
|
||||
}
|
||||
|
||||
table := NewTable("ID", "DATE", "NAME", "AMOUNT", "PAID BY", "PAID FOR", "CATEGORY", "METHOD")
|
||||
|
||||
var totalAmount float64
|
||||
var result []resolvedBill
|
||||
for _, bill := range bills {
|
||||
totalAmount += bill.Amount
|
||||
// Get payer name
|
||||
payerName := memberNames[bill.PayerID]
|
||||
if payerName == "" {
|
||||
payerName = fmt.Sprintf("#%d", bill.PayerID)
|
||||
}
|
||||
|
||||
// Get owed member names
|
||||
var owerNames []string
|
||||
for _, ower := range bill.Owers {
|
||||
name := memberNames[ower.ID]
|
||||
@@ -476,33 +495,60 @@ func printBillsTable(cmd *cobra.Command, project *api.Project, bills []api.BillR
|
||||
}
|
||||
owerNames = append(owerNames, name)
|
||||
}
|
||||
owersStr := strings.Join(owerNames, ", ")
|
||||
|
||||
// Get category name
|
||||
catName := categoryNames[bill.CategoryID]
|
||||
if catName == "" && bill.CategoryID != 0 {
|
||||
catName = fmt.Sprintf("#%d", bill.CategoryID)
|
||||
}
|
||||
if catName == "" {
|
||||
catName = "-"
|
||||
}
|
||||
|
||||
// Get payment method name
|
||||
methodName := paymentModeNames[bill.PaymentModeID]
|
||||
if methodName == "" && bill.PaymentModeID != 0 {
|
||||
methodName = fmt.Sprintf("#%d", bill.PaymentModeID)
|
||||
}
|
||||
if methodName == "" {
|
||||
methodName = "-"
|
||||
}
|
||||
|
||||
// Sanitize and truncate name
|
||||
name := strings.Map(func(r rune) rune {
|
||||
if r == '\n' || r == '\r' || r == '\t' {
|
||||
return ' '
|
||||
}
|
||||
return r
|
||||
}, strings.TrimSpace(bill.What))
|
||||
|
||||
result = append(result, resolvedBill{
|
||||
ID: bill.ID,
|
||||
Date: bill.Date,
|
||||
Name: name,
|
||||
Amount: bill.Amount,
|
||||
PaidBy: payerName,
|
||||
PaidFor: owerNames,
|
||||
Category: catName,
|
||||
PaymentMethod: methodName,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func printBillsTable(cmd *cobra.Command, bills []resolvedBill, formatter *format.AmountFormatter) {
|
||||
if len(bills) == 0 {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "No bills found.")
|
||||
return
|
||||
}
|
||||
|
||||
table := NewTable("ID", "DATE", "NAME", "AMOUNT", "PAID BY", "PAID FOR", "CATEGORY", "METHOD")
|
||||
|
||||
var totalAmount float64
|
||||
for _, bill := range bills {
|
||||
totalAmount += bill.Amount
|
||||
|
||||
catName := bill.Category
|
||||
if catName == "" {
|
||||
catName = "-"
|
||||
}
|
||||
methodName := bill.PaymentMethod
|
||||
if methodName == "" {
|
||||
methodName = "-"
|
||||
}
|
||||
|
||||
name := bill.Name
|
||||
if len(name) > 30 {
|
||||
name = name[:27] + "..."
|
||||
}
|
||||
@@ -512,8 +558,8 @@ func printBillsTable(cmd *cobra.Command, project *api.Project, bills []api.BillR
|
||||
bill.Date,
|
||||
name,
|
||||
formatter.Format(bill.Amount),
|
||||
payerName,
|
||||
owersStr,
|
||||
bill.PaidBy,
|
||||
strings.Join(bill.PaidFor, ", "),
|
||||
catName,
|
||||
methodName,
|
||||
)
|
||||
@@ -523,3 +569,33 @@ func printBillsTable(cmd *cobra.Command, project *api.Project, bills []api.BillR
|
||||
table.Render(out)
|
||||
_, _ = fmt.Fprintf(out, "\nTotal: %d bill(s), %s\n", len(bills), formatter.Format(totalAmount))
|
||||
}
|
||||
|
||||
func printBillsCSV(cmd *cobra.Command, bills []resolvedBill) {
|
||||
out := cmd.OutOrStdout()
|
||||
w := csv.NewWriter(out)
|
||||
|
||||
_ = w.Write([]string{"ID", "Date", "Name", "Amount", "Paid By", "Paid For", "Category", "Payment Method"})
|
||||
for _, bill := range bills {
|
||||
_ = w.Write([]string{
|
||||
strconv.Itoa(bill.ID),
|
||||
bill.Date,
|
||||
bill.Name,
|
||||
strconv.FormatFloat(bill.Amount, 'f', 2, 64),
|
||||
bill.PaidBy,
|
||||
strings.Join(bill.PaidFor, ", "),
|
||||
bill.Category,
|
||||
bill.PaymentMethod,
|
||||
})
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
func printBillsJSON(cmd *cobra.Command, bills []resolvedBill) {
|
||||
out := cmd.OutOrStdout()
|
||||
if bills == nil {
|
||||
bills = []resolvedBill{}
|
||||
}
|
||||
enc := json.NewEncoder(out)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(bills)
|
||||
}
|
||||
|
||||
153
cmd/list_test.go
153
cmd/list_test.go
@@ -2,7 +2,9 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -162,12 +164,14 @@ func TestPrintBillsTable(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
resolved := resolveBillNames(project, bills)
|
||||
|
||||
cmd := NewListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
|
||||
formatter := format.NewAmountFormatter("en_US", "USD")
|
||||
printBillsTable(cmd, project, bills, formatter)
|
||||
printBillsTable(cmd, resolved, formatter)
|
||||
|
||||
output := buf.String()
|
||||
|
||||
@@ -198,15 +202,12 @@ func TestPrintBillsTable(t *testing.T) {
|
||||
func TestPrintBillsTableEmpty(t *testing.T) {
|
||||
resetListFlags()
|
||||
|
||||
project := &api.Project{}
|
||||
bills := []api.BillResponse{}
|
||||
|
||||
cmd := NewListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
|
||||
formatter := format.NewAmountFormatter("en_US", "")
|
||||
printBillsTable(cmd, project, bills, formatter)
|
||||
printBillsTable(cmd, nil, formatter)
|
||||
|
||||
output := buf.String()
|
||||
if !bytes.Contains([]byte(output), []byte("No bills found")) {
|
||||
@@ -503,6 +504,147 @@ func TestBuildFiltersRecent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintBillsCSV(t *testing.T) {
|
||||
resetListFlags()
|
||||
|
||||
project := &api.Project{
|
||||
Members: []api.Member{
|
||||
{ID: 1, Name: "Alice", UserID: "alice"},
|
||||
{ID: 2, Name: "Bob", UserID: "bob"},
|
||||
},
|
||||
Categories: []api.Category{
|
||||
{ID: 1, Name: "Food"},
|
||||
},
|
||||
PaymentModes: []api.PaymentMode{
|
||||
{ID: 1, Name: "Cash"},
|
||||
},
|
||||
}
|
||||
|
||||
bills := []api.BillResponse{
|
||||
{
|
||||
ID: 1,
|
||||
What: "Groceries",
|
||||
Amount: 50.00,
|
||||
Date: "2026-02-03",
|
||||
PayerID: 1,
|
||||
Owers: []api.Ower{{ID: 1, Weight: 1}, {ID: 2, Weight: 1}},
|
||||
CategoryID: 1,
|
||||
PaymentModeID: 1,
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
What: "Coffee",
|
||||
Amount: 5.50,
|
||||
Date: "2026-02-04",
|
||||
PayerID: 2,
|
||||
Owers: []api.Ower{{ID: 2, Weight: 1}},
|
||||
},
|
||||
}
|
||||
|
||||
resolved := resolveBillNames(project, bills)
|
||||
|
||||
cmd := NewListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
|
||||
printBillsCSV(cmd, resolved)
|
||||
|
||||
output := buf.String()
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("Expected 3 lines (header + 2 rows), got %d:\n%s", len(lines), output)
|
||||
}
|
||||
if lines[0] != "ID,Date,Name,Amount,Paid By,Paid For,Category,Payment Method" {
|
||||
t.Errorf("Wrong CSV header: %s", lines[0])
|
||||
}
|
||||
if !strings.Contains(lines[1], "Coffee") {
|
||||
t.Errorf("First data row should contain 'Coffee' (newest first), got: %s", lines[1])
|
||||
}
|
||||
if !strings.Contains(lines[2], "Groceries") {
|
||||
t.Errorf("Second data row should contain 'Groceries', got: %s", lines[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintBillsJSON(t *testing.T) {
|
||||
resetListFlags()
|
||||
|
||||
project := &api.Project{
|
||||
Members: []api.Member{
|
||||
{ID: 1, Name: "Alice", UserID: "alice"},
|
||||
},
|
||||
Categories: []api.Category{
|
||||
{ID: 1, Name: "Food"},
|
||||
},
|
||||
PaymentModes: []api.PaymentMode{
|
||||
{ID: 1, Name: "Cash"},
|
||||
},
|
||||
}
|
||||
|
||||
bills := []api.BillResponse{
|
||||
{
|
||||
ID: 1,
|
||||
What: "Groceries",
|
||||
Amount: 50.00,
|
||||
Date: "2026-02-03",
|
||||
PayerID: 1,
|
||||
Owers: []api.Ower{{ID: 1, Weight: 1}},
|
||||
CategoryID: 1,
|
||||
PaymentModeID: 1,
|
||||
},
|
||||
}
|
||||
|
||||
resolved := resolveBillNames(project, bills)
|
||||
|
||||
cmd := NewListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
|
||||
printBillsJSON(cmd, resolved)
|
||||
|
||||
var result []resolvedBill
|
||||
if err := json.Unmarshal(buf.Bytes(), &result); err != nil {
|
||||
t.Fatalf("Invalid JSON output: %v\n%s", err, buf.String())
|
||||
}
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("Expected 1 bill, got %d", len(result))
|
||||
}
|
||||
if result[0].Name != "Groceries" {
|
||||
t.Errorf("Wrong name: %s", result[0].Name)
|
||||
}
|
||||
if result[0].Amount != 50.00 {
|
||||
t.Errorf("Wrong amount: %f", result[0].Amount)
|
||||
}
|
||||
if result[0].PaidBy != "Alice" {
|
||||
t.Errorf("Wrong paid_by: %s", result[0].PaidBy)
|
||||
}
|
||||
if result[0].Category != "Food" {
|
||||
t.Errorf("Wrong category: %s", result[0].Category)
|
||||
}
|
||||
if result[0].PaymentMethod != "Cash" {
|
||||
t.Errorf("Wrong payment_method: %s", result[0].PaymentMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintBillsJSONEmpty(t *testing.T) {
|
||||
resetListFlags()
|
||||
|
||||
cmd := NewListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
|
||||
printBillsJSON(cmd, nil)
|
||||
|
||||
var result []resolvedBill
|
||||
if err := json.Unmarshal(buf.Bytes(), &result); err != nil {
|
||||
t.Fatalf("Invalid JSON output: %v\n%s", err, buf.String())
|
||||
}
|
||||
if len(result) != 0 {
|
||||
t.Errorf("Expected empty array, got %d items", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func resetListFlags() {
|
||||
ProjectID = ""
|
||||
listPaidBy = ""
|
||||
@@ -516,4 +658,5 @@ func resetListFlags() {
|
||||
listThisMonth = false
|
||||
listThisWeek = false
|
||||
listRecent = ""
|
||||
listFormat = "table"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user