diff --git a/cmd/add_test.go b/cmd/add_test.go index a64466a..293cc1a 100644 --- a/cmd/add_test.go +++ b/cmd/add_test.go @@ -41,6 +41,7 @@ func resetFlags() { convertTo = "" paymentMethod = "" comment = "" + infoCached = false } func setupTestEnv(t *testing.T, domain string) func() { diff --git a/cmd/info.go b/cmd/info.go index 7719592..d1a1b0f 100644 --- a/cmd/info.go +++ b/cmd/info.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "strconv" "github.com/chenasraf/cospend-cli/internal/api" "github.com/chenasraf/cospend-cli/internal/cache" @@ -9,14 +10,20 @@ import ( "github.com/spf13/cobra" ) +var infoCached bool + // NewInfoCommand creates the info command func NewInfoCommand() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "info", Short: "Show account and configuration info", - Long: `Show the configured Nextcloud server, authenticated user, and user locale/language.`, + Long: `Show the configured Nextcloud server, authenticated user, and user locale/language. When --project is set, also show project details.`, RunE: runInfo, } + + cmd.Flags().BoolVar(&infoCached, "cached", false, "Use cached data instead of fetching fresh from API") + + return cmd } func runInfo(cmd *cobra.Command, _ []string) error { @@ -31,8 +38,11 @@ func runInfo(cmd *cobra.Command, _ []string) error { client.Debug = Debug client.DebugWriter = cmd.ErrOrStderr() - userInfo, ok := cache.LoadUserInfo() - if !ok { + var userInfo *api.UserInfo + if infoCached { + userInfo, _ = cache.LoadUserInfo() + } + if userInfo == nil { userInfo, err = client.GetUserInfo() if err != nil { return fmt.Errorf("fetching user info: %w", err) @@ -48,5 +58,62 @@ func runInfo(cmd *cobra.Command, _ []string) error { _, _ = fmt.Fprintf(out, "Locale: %s\n", userInfo.Locale) _, _ = fmt.Fprintf(out, "Language: %s\n", userInfo.Language) + if ProjectID != "" { + var project *api.Project + if infoCached { + project, _ = cache.Load(ProjectID) + } + if project == nil { + project, err = client.GetProject(ProjectID) + if err != nil { + return fmt.Errorf("fetching project: %w", err) + } + if err := cache.Save(ProjectID, project); err != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to cache project: %v\n", err) + } + } + + _, _ = fmt.Fprintf(out, "\nProject: %s\n", project.Name) + _, _ = fmt.Fprintf(out, "Currency: %s\n", project.CurrencyName) + + _, _ = fmt.Fprintln(out) + membersTable := NewTable("ID", "Name", "UserID") + for _, m := range project.Members { + membersTable.AddRow(strconv.Itoa(m.ID), m.Name, m.UserID) + } + _, _ = fmt.Fprintln(out, "Members:") + membersTable.Render(out) + + if len(project.Categories) > 0 { + _, _ = fmt.Fprintln(out) + catTable := NewTable("ID", "Icon", "Name") + for _, c := range project.Categories { + catTable.AddRow(strconv.Itoa(c.ID), c.Icon, c.Name) + } + _, _ = fmt.Fprintln(out, "Categories:") + catTable.Render(out) + } + + if len(project.PaymentModes) > 0 { + _, _ = fmt.Fprintln(out) + pmTable := NewTable("ID", "Icon", "Name") + for _, pm := range project.PaymentModes { + pmTable.AddRow(strconv.Itoa(pm.ID), pm.Icon, pm.Name) + } + _, _ = fmt.Fprintln(out, "Payment Modes:") + pmTable.Render(out) + } + + if len(project.Currencies) > 0 { + _, _ = fmt.Fprintln(out) + currTable := NewTable("ID", "Name", "Exchange Rate") + for _, cur := range project.Currencies { + currTable.AddRow(strconv.Itoa(cur.ID), cur.Name, strconv.FormatFloat(cur.ExchangeRate, 'f', -1, 64)) + } + _, _ = fmt.Fprintln(out, "Currencies:") + currTable.Render(out) + } + } + return nil } diff --git a/cmd/info_test.go b/cmd/info_test.go index 1f2025a..4f66825 100644 --- a/cmd/info_test.go +++ b/cmd/info_test.go @@ -7,6 +7,8 @@ import ( "net/http/httptest" "strings" "testing" + + "github.com/chenasraf/cospend-cli/internal/api" ) func TestInfoCommand(t *testing.T) { @@ -103,3 +105,77 @@ func TestInfoCommandAPIError(t *testing.T) { t.Error("Expected error from API") } } + +func TestInfoCommandWithProject(t *testing.T) { + project := api.Project{ + ID: "test-project", + Name: "Test Project", + CurrencyName: "EUR", + Members: []api.Member{ + {ID: 1, Name: "Alice", UserID: "alice"}, + {ID: 2, Name: "Bob", UserID: "bob"}, + }, + Categories: []api.Category{ + {ID: 5, Name: "Food", Icon: "\U0001F354", Color: "#ff0000"}, + {ID: 12, Name: "Transport", Icon: "\U0001F697", Color: "#00ff00"}, + }, + PaymentModes: []api.PaymentMode{ + {ID: 3, Name: "Credit Card", Icon: "\U0001F4B3", Color: "#0000ff"}, + }, + Currencies: []api.Currency{ + {ID: 1, Name: "USD", ExchangeRate: 1.1}, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/ocs/v2.php/cloud/user" { + _ = json.NewEncoder(w).Encode(makeOCSResponse(200, map[string]string{ + "locale": "en_US", + "language": "en", + })) + return + } + if r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project" { + _ = json.NewEncoder(w).Encode(makeOCSResponse(200, project)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + cleanup := setupTestEnv(t, server.URL) + defer cleanup() + + ProjectID = "test-project" + cmd := NewInfoCommand() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + + err := cmd.Execute() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + output := stdout.String() + + expected := []string{ + "Project: Test Project", + "Currency: EUR", + "Members:", + "Alice", + "Bob", + "Categories:", + "Food", + "Transport", + "Payment Modes:", + "Credit Card", + "Currencies:", + "USD", + "1.1", + } + for _, exp := range expected { + if !strings.Contains(output, exp) { + t.Errorf("Output missing %q, got:\n%s", exp, output) + } + } +} diff --git a/cmd/table.go b/cmd/table.go index 1b53485..5bea7e9 100644 --- a/cmd/table.go +++ b/cmd/table.go @@ -4,6 +4,8 @@ import ( "fmt" "io" "strings" + + "github.com/mattn/go-runewidth" ) // Table border characters @@ -32,7 +34,7 @@ type Table struct { func NewTable(headers ...string) *Table { colWidths := make([]int, len(headers)) for i, h := range headers { - colWidths[i] = len(h) + colWidths[i] = runewidth.StringWidth(h) } return &Table{ headers: headers, @@ -55,8 +57,8 @@ func (t *Table) AddRow(values ...string) { // Update column widths for i, v := range values { - if len(v) > t.colWidths[i] { - t.colWidths[i] = len(v) + if w := runewidth.StringWidth(v); w > t.colWidths[i] { + t.colWidths[i] = w } } } @@ -88,7 +90,8 @@ func (t *Table) printBorder(w io.Writer, left, mid, right string) { func (t *Table) printRow(w io.Writer, values []string) { _, _ = fmt.Fprint(w, borderVertical) for i, val := range values { - _, _ = fmt.Fprintf(w, " %-*s %s", t.colWidths[i], val, borderVertical) + padded := runewidth.FillRight(val, t.colWidths[i]) + _, _ = fmt.Fprintf(w, " %s %s", padded, borderVertical) } _, _ = fmt.Fprintln(w) } diff --git a/go.mod b/go.mod index 3d9d9cc..72dabb8 100644 --- a/go.mod +++ b/go.mod @@ -5,14 +5,16 @@ go 1.24.0 require ( github.com/BurntSushi/toml v1.6.0 github.com/adrg/xdg v0.4.0 + github.com/mattn/go-runewidth v0.0.19 github.com/spf13/cobra v1.8.0 golang.org/x/term v0.39.0 + golang.org/x/text v0.33.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect ) diff --git a/go.sum b/go.sum index 74ba84d..5732406 100644 --- a/go.sum +++ b/go.sum @@ -2,11 +2,15 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/internal/api/client.go b/internal/api/client.go index 69f0f95..566e0d3 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -32,14 +32,18 @@ type Member struct { // Category represents a bill category type Category struct { - ID int `json:"id"` - Name string `json:"name"` + ID int `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + Color string `json:"color"` } // PaymentMode represents a payment method type PaymentMode struct { - ID int `json:"id"` - Name string `json:"name"` + ID int `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + Color string `json:"color"` } // Currency represents a currency diff --git a/internal/api/client_test.go b/internal/api/client_test.go index afa1a0f..3d1aa58 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -570,28 +570,52 @@ func TestProjectUnmarshalObjectKeyed(t *testing.T) { t.Fatalf("Unmarshal error: %v", err) } - // Verify categories have correct IDs from map keys - catByName := make(map[string]int) + // Verify categories have correct IDs, icons, and colors from map keys + catByName := make(map[string]Category) for _, c := range project.Categories { - catByName[c.Name] = c.ID + catByName[c.Name] = c } - if catByName["Food"] != 5 { - t.Errorf("Category Food ID = %d, want 5", catByName["Food"]) + if catByName["Food"].ID != 5 { + t.Errorf("Category Food ID = %d, want 5", catByName["Food"].ID) } - if catByName["Transport"] != 12 { - t.Errorf("Category Transport ID = %d, want 12", catByName["Transport"]) + if catByName["Food"].Icon != "\U0001F354" { + t.Errorf("Category Food Icon = %q, want %q", catByName["Food"].Icon, "\U0001F354") + } + if catByName["Food"].Color != "#ff0000" { + t.Errorf("Category Food Color = %q, want %q", catByName["Food"].Color, "#ff0000") + } + if catByName["Transport"].ID != 12 { + t.Errorf("Category Transport ID = %d, want 12", catByName["Transport"].ID) + } + if catByName["Transport"].Icon != "\U0001F697" { + t.Errorf("Category Transport Icon = %q, want %q", catByName["Transport"].Icon, "\U0001F697") + } + if catByName["Transport"].Color != "#00ff00" { + t.Errorf("Category Transport Color = %q, want %q", catByName["Transport"].Color, "#00ff00") } - // Verify payment modes have correct IDs from map keys - pmByName := make(map[string]int) + // Verify payment modes have correct IDs, icons, and colors from map keys + pmByName := make(map[string]PaymentMode) for _, pm := range project.PaymentModes { - pmByName[pm.Name] = pm.ID + pmByName[pm.Name] = pm } - if pmByName["Credit Card"] != 3 { - t.Errorf("PaymentMode Credit Card ID = %d, want 3", pmByName["Credit Card"]) + if pmByName["Credit Card"].ID != 3 { + t.Errorf("PaymentMode Credit Card ID = %d, want 3", pmByName["Credit Card"].ID) } - if pmByName["Cash"] != 7 { - t.Errorf("PaymentMode Cash ID = %d, want 7", pmByName["Cash"]) + if pmByName["Credit Card"].Icon != "\U0001F4B3" { + t.Errorf("PaymentMode Credit Card Icon = %q, want %q", pmByName["Credit Card"].Icon, "\U0001F4B3") + } + if pmByName["Credit Card"].Color != "#0000ff" { + t.Errorf("PaymentMode Credit Card Color = %q, want %q", pmByName["Credit Card"].Color, "#0000ff") + } + if pmByName["Cash"].ID != 7 { + t.Errorf("PaymentMode Cash ID = %d, want 7", pmByName["Cash"].ID) + } + if pmByName["Cash"].Icon != "\U0001F4B5" { + t.Errorf("PaymentMode Cash Icon = %q, want %q", pmByName["Cash"].Icon, "\U0001F4B5") + } + if pmByName["Cash"].Color != "#00ff00" { + t.Errorf("PaymentMode Cash Color = %q, want %q", pmByName["Cash"].Color, "#00ff00") } }