feat: add csv/json format to ls

This commit is contained in:
2026-02-10 09:56:10 +02:00
parent 254ec53876
commit d69f7d2751
3 changed files with 257 additions and 33 deletions

View File

@@ -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.

View File

@@ -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)
}

View File

@@ -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"
}