mirror of
https://github.com/chenasraf/cospend-cli.git
synced 2026-05-17 17:38:04 +00:00
feat: add action confirmations
This commit is contained in:
2
.prettierignore
Normal file
2
.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
||||
gen/
|
||||
CHANGELOG.md
|
||||
15
.prettierrc
Normal file
15
.prettierrc
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.md",
|
||||
"options": {
|
||||
"printWidth": 100,
|
||||
"proseWrap": "always"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
62
README.md
62
README.md
@@ -20,6 +20,7 @@ add and list expenses directly from your terminal without opening the web interf
|
||||
- **Currency code support** (e.g., `usd`, `eur`, `gbp`) with automatic symbol resolution
|
||||
- **Local caching** of project data with 1-hour TTL for faster subsequent calls
|
||||
- **Default project** - set once with `config set`, no need to pass `-p` every time
|
||||
- **Optional confirmations** - preview and confirm before adding, deleting, or updating expenses
|
||||
- **Global project flag** - set `-p` before the command for easy shell aliases
|
||||
- **Secure browser login** - OAuth-style authentication with 2FA support
|
||||
- Cross-platform support: **macOS**, **Linux**, and **Windows**
|
||||
@@ -204,18 +205,18 @@ cospend add "Gym" 50.00 -p myproject -r w # weekly
|
||||
|
||||
#### Add Command Flags
|
||||
|
||||
| Short | Long | Description |
|
||||
| ----- | ------------ | --------------------------------------------------------- |
|
||||
| `-p` | `--project` | Project ID (required) |
|
||||
| `-c` | `--category` | Category by ID or case-insensitive name |
|
||||
| `-b` | `--by` | Paying member username (defaults to authenticated user) |
|
||||
| `-f` | `--for` | Owed member username (repeatable; defaults to payer only) |
|
||||
| `-C` | `--convert` | Currency to convert to (by ID, name, or code like `usd`) |
|
||||
| `-m` | `--method` | Payment method by ID or case-insensitive name |
|
||||
| `-o` | `--comment` | Additional details about the bill |
|
||||
| `-d` | `--date` | Date of expense (`YYYY-MM-DD`, `MM-DD`, or relative like `-1d`, `+2w`) |
|
||||
| Short | Long | Description |
|
||||
| ----- | ------------ | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `-p` | `--project` | Project ID (required) |
|
||||
| `-c` | `--category` | Category by ID or case-insensitive name |
|
||||
| `-b` | `--by` | Paying member username (defaults to authenticated user) |
|
||||
| `-f` | `--for` | Owed member username (repeatable; defaults to payer only) |
|
||||
| `-C` | `--convert` | Currency to convert to (by ID, name, or code like `usd`) |
|
||||
| `-m` | `--method` | Payment method by ID or case-insensitive name |
|
||||
| `-o` | `--comment` | Additional details about the bill |
|
||||
| `-d` | `--date` | Date of expense (`YYYY-MM-DD`, `MM-DD`, or relative like `-1d`, `+2w`) |
|
||||
| `-r` | `--repeat` | Repeat frequency: `d` (daily), `w` (weekly), `b` (biweekly), `s` (semi-monthly), `m` (monthly), `y` (yearly) |
|
||||
| `-h` | `--help` | Display help information |
|
||||
| `-h` | `--help` | Display help information |
|
||||
|
||||
---
|
||||
|
||||
@@ -318,19 +319,19 @@ cospend edit 123 -p myproject -d 2026-06-15 -o "corrected date"
|
||||
|
||||
#### Edit Command Flags
|
||||
|
||||
| Short | Long | Description |
|
||||
| ----- | ------------ | ---------------------------------------------------------------------- |
|
||||
| `-p` | `--project` | Project ID (required) |
|
||||
| `-n` | `--name` | New name/description |
|
||||
| `-a` | `--amount` | New amount |
|
||||
| `-c` | `--category` | Category by ID or case-insensitive name |
|
||||
| `-b` | `--by` | Paying member username |
|
||||
| `-f` | `--for` | Owed member username (repeatable) |
|
||||
| `-m` | `--method` | Payment method by ID or case-insensitive name |
|
||||
| `-o` | `--comment` | Comment |
|
||||
| `-d` | `--date` | Date (`YYYY-MM-DD`, `MM-DD`, or relative like `-1d`, `+2w`) |
|
||||
| Short | Long | Description |
|
||||
| ----- | ------------ | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `-p` | `--project` | Project ID (required) |
|
||||
| `-n` | `--name` | New name/description |
|
||||
| `-a` | `--amount` | New amount |
|
||||
| `-c` | `--category` | Category by ID or case-insensitive name |
|
||||
| `-b` | `--by` | Paying member username |
|
||||
| `-f` | `--for` | Owed member username (repeatable) |
|
||||
| `-m` | `--method` | Payment method by ID or case-insensitive name |
|
||||
| `-o` | `--comment` | Comment |
|
||||
| `-d` | `--date` | Date (`YYYY-MM-DD`, `MM-DD`, or relative like `-1d`, `+2w`) |
|
||||
| `-r` | `--repeat` | Repeat frequency: `n` (none), `d` (daily), `w` (weekly), `b` (biweekly), `s` (semi-monthly), `m` (monthly), `y` (yearly) |
|
||||
| `-h` | `--help` | Display help information |
|
||||
| `-h` | `--help` | Display help information |
|
||||
|
||||
---
|
||||
|
||||
@@ -392,9 +393,12 @@ cospend config get <key>
|
||||
|
||||
#### Supported Keys
|
||||
|
||||
| Key | Description |
|
||||
| ------------------- | -------------------------------------------------- |
|
||||
| `default-project` | Default project ID (used when `-p` is not specified) |
|
||||
| Key | Description | Default |
|
||||
| ----------------- | ----------------------------------------------------- | ------- |
|
||||
| `default-project` | Default project ID (used when `-p` is not specified) | (none) |
|
||||
| `confirm-add` | Ask for confirmation before adding (`true`/`false`) | `false` |
|
||||
| `confirm-delete` | Ask for confirmation before deleting (`true`/`false`) | `false` |
|
||||
| `confirm-update` | Ask for confirmation before updating (`true`/`false`) | `false` |
|
||||
|
||||
#### Examples
|
||||
|
||||
@@ -404,6 +408,12 @@ cospend config set default-project myproject
|
||||
|
||||
# View the current default project
|
||||
cospend config get default-project
|
||||
|
||||
# Enable confirmation before deleting
|
||||
cospend config set confirm-delete true
|
||||
|
||||
# Show all current settings
|
||||
cospend config list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
92
cmd/add.go
92
cmd/add.go
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -199,50 +200,65 @@ func runAdd(cmd *cobra.Command, args []string) error {
|
||||
bill.Repeat = repeat
|
||||
}
|
||||
|
||||
formatter := format.NewAmountFormatter(locale, project.CurrencyName)
|
||||
out := cmd.OutOrStdout()
|
||||
|
||||
printBillSummary := func() {
|
||||
_, _ = fmt.Fprintf(out, " Amount: %s\n", formatter.Format(bill.Amount))
|
||||
if convertTo != "" {
|
||||
origFormatter := format.NewAmountFormatter(locale, convertTo)
|
||||
_, _ = fmt.Fprintf(out, " Original: %s\n", origFormatter.Format(amount))
|
||||
}
|
||||
_, _ = fmt.Fprintf(out, " Paid by: %s\n", memberNames[payerID])
|
||||
var owerNames []string
|
||||
for _, id := range owedIDs {
|
||||
owerNames = append(owerNames, memberNames[id])
|
||||
}
|
||||
_, _ = fmt.Fprintf(out, " Paid for: %s\n", strings.Join(owerNames, ", "))
|
||||
if bill.CategoryID != 0 {
|
||||
for _, c := range project.Categories {
|
||||
if c.ID == bill.CategoryID {
|
||||
_, _ = fmt.Fprintf(out, " Category: %s\n", c.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if bill.PaymentModeID != 0 {
|
||||
for _, pm := range project.PaymentModes {
|
||||
if pm.ID == bill.PaymentModeID {
|
||||
_, _ = fmt.Fprintf(out, " Method: %s\n", pm.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if bill.Comment != "" {
|
||||
_, _ = fmt.Fprintf(out, " Comment: %s\n", bill.Comment)
|
||||
}
|
||||
if addDate != "" {
|
||||
_, _ = fmt.Fprintf(out, " Date: %s\n", bill.Date)
|
||||
}
|
||||
if bill.Repeat != "" && bill.Repeat != "n" {
|
||||
_, _ = fmt.Fprintf(out, " Repeat: %s\n", api.ValidRepeatFrequencies[bill.Repeat])
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm if configured
|
||||
if cfg.ConfirmAdd {
|
||||
_, _ = fmt.Fprintf(out, "New expense: %s\n", expenseName)
|
||||
printBillSummary()
|
||||
if !confirm(os.Stdin, out, "Add bill?") {
|
||||
_, _ = fmt.Fprintln(out, "Cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Create the bill
|
||||
if err := client.CreateBill(ProjectID, bill); err != nil {
|
||||
return fmt.Errorf("creating bill: %w", err)
|
||||
}
|
||||
|
||||
formatter := format.NewAmountFormatter(locale, project.CurrencyName)
|
||||
out := cmd.OutOrStdout()
|
||||
_, _ = fmt.Fprintf(out, "Added expense: %s\n", expenseName)
|
||||
_, _ = fmt.Fprintf(out, " Amount: %s\n", formatter.Format(bill.Amount))
|
||||
if convertTo != "" {
|
||||
origFormatter := format.NewAmountFormatter(locale, convertTo)
|
||||
_, _ = fmt.Fprintf(out, " Original: %s\n", origFormatter.Format(amount))
|
||||
}
|
||||
_, _ = fmt.Fprintf(out, " Paid by: %s\n", memberNames[payerID])
|
||||
var owerNames []string
|
||||
for _, id := range owedIDs {
|
||||
owerNames = append(owerNames, memberNames[id])
|
||||
}
|
||||
_, _ = fmt.Fprintf(out, " Paid for: %s\n", strings.Join(owerNames, ", "))
|
||||
if bill.CategoryID != 0 {
|
||||
for _, c := range project.Categories {
|
||||
if c.ID == bill.CategoryID {
|
||||
_, _ = fmt.Fprintf(out, " Category: %s\n", c.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if bill.PaymentModeID != 0 {
|
||||
for _, pm := range project.PaymentModes {
|
||||
if pm.ID == bill.PaymentModeID {
|
||||
_, _ = fmt.Fprintf(out, " Method: %s\n", pm.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if bill.Comment != "" {
|
||||
_, _ = fmt.Fprintf(out, " Comment: %s\n", bill.Comment)
|
||||
}
|
||||
if addDate != "" {
|
||||
_, _ = fmt.Fprintf(out, " Date: %s\n", bill.Date)
|
||||
}
|
||||
if bill.Repeat != "" && bill.Repeat != "n" {
|
||||
_, _ = fmt.Fprintf(out, " Repeat: %s\n", api.ValidRepeatFrequencies[bill.Repeat])
|
||||
}
|
||||
printBillSummary()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Debug enables debug output when true
|
||||
var Debug bool
|
||||
|
||||
// ProjectID is the project to operate on (shared across commands)
|
||||
var ProjectID string
|
||||
|
||||
// confirm prompts the user with a [Y/n] question and returns true if confirmed.
|
||||
// Defaults to yes (empty input = yes).
|
||||
func confirm(in io.Reader, out io.Writer, prompt string) bool {
|
||||
_, _ = fmt.Fprintf(out, "%s [Y/n] ", prompt)
|
||||
scanner := bufio.NewScanner(in)
|
||||
if !scanner.Scan() {
|
||||
return false
|
||||
}
|
||||
answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
|
||||
return answer == "" || answer == "y" || answer == "yes"
|
||||
}
|
||||
|
||||
38
cmd/common_test.go
Normal file
38
cmd/common_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfirm(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
prompt string
|
||||
}{
|
||||
{"empty input defaults to yes", "\n", true, "Do it?"},
|
||||
{"y confirms", "y\n", true, "Do it?"},
|
||||
{"Y confirms", "Y\n", true, "Do it?"},
|
||||
{"yes confirms", "yes\n", true, "Do it?"},
|
||||
{"n declines", "n\n", false, "Do it?"},
|
||||
{"no declines", "no\n", false, "Do it?"},
|
||||
{"random text declines", "maybe\n", false, "Do it?"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
in := strings.NewReader(tt.input)
|
||||
var out bytes.Buffer
|
||||
got := confirm(in, &out, tt.prompt)
|
||||
if got != tt.want {
|
||||
t.Errorf("confirm(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte("[Y/n]")) {
|
||||
t.Errorf("Expected [Y/n] prompt, got: %s", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/chenasraf/cospend-cli/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -32,11 +33,15 @@ Supported keys:
|
||||
domain Nextcloud instance URL
|
||||
user Nextcloud username
|
||||
default-project Default project ID (used when -p is not specified)
|
||||
confirm-add Ask for confirmation before adding (true/false)
|
||||
confirm-delete Ask for confirmation before deleting (true/false)
|
||||
confirm-update Ask for confirmation before updating (true/false)
|
||||
|
||||
Examples:
|
||||
cospend config set domain https://cloud.example.com
|
||||
cospend config set user alice
|
||||
cospend config set default-project myproject`,
|
||||
cospend config set default-project myproject
|
||||
cospend config set confirm-delete true`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: runConfigSet,
|
||||
}
|
||||
@@ -52,11 +57,15 @@ Supported keys:
|
||||
domain Nextcloud instance URL
|
||||
user Nextcloud username
|
||||
default-project Default project ID (used when -p is not specified)
|
||||
confirm-add Ask for confirmation before adding (true/false)
|
||||
confirm-delete Ask for confirmation before deleting (true/false)
|
||||
confirm-update Ask for confirmation before updating (true/false)
|
||||
|
||||
Examples:
|
||||
cospend config get domain
|
||||
cospend config get user
|
||||
cospend config get default-project`,
|
||||
cospend config get default-project
|
||||
cospend config get confirm-delete`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runConfigGet,
|
||||
}
|
||||
@@ -106,6 +115,9 @@ func runConfigList(cmd *cobra.Command, _ []string) error {
|
||||
if cfg.DefaultProject != "" {
|
||||
_, _ = fmt.Fprintf(out, " default-project: %s\n", cfg.DefaultProject)
|
||||
}
|
||||
_, _ = fmt.Fprintf(out, " confirm-add: %v\n", cfg.ConfirmAdd)
|
||||
_, _ = fmt.Fprintf(out, " confirm-delete: %v\n", cfg.ConfirmDelete)
|
||||
_, _ = fmt.Fprintf(out, " confirm-update: %v\n", cfg.ConfirmUpdate)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -133,6 +145,24 @@ func runConfigSet(cmd *cobra.Command, args []string) error {
|
||||
cfg.User = value
|
||||
case "default-project":
|
||||
cfg.DefaultProject = value
|
||||
case "confirm-add":
|
||||
b, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid boolean value: %s (use true or false)", value)
|
||||
}
|
||||
cfg.ConfirmAdd = b
|
||||
case "confirm-delete":
|
||||
b, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid boolean value: %s (use true or false)", value)
|
||||
}
|
||||
cfg.ConfirmDelete = b
|
||||
case "confirm-update":
|
||||
b, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid boolean value: %s (use true or false)", value)
|
||||
}
|
||||
cfg.ConfirmUpdate = b
|
||||
default:
|
||||
return fmt.Errorf("unknown config key: %s", key)
|
||||
}
|
||||
@@ -168,6 +198,12 @@ func runConfigGet(cmd *cobra.Command, args []string) error {
|
||||
value = cfg.User
|
||||
case "default-project":
|
||||
value = cfg.DefaultProject
|
||||
case "confirm-add":
|
||||
value = strconv.FormatBool(cfg.ConfirmAdd)
|
||||
case "confirm-delete":
|
||||
value = strconv.FormatBool(cfg.ConfirmDelete)
|
||||
case "confirm-update":
|
||||
value = strconv.FormatBool(cfg.ConfirmUpdate)
|
||||
default:
|
||||
return fmt.Errorf("unknown config key: %s", key)
|
||||
}
|
||||
|
||||
@@ -351,6 +351,10 @@ func TestConfigListNoDefaultProject(t *testing.T) {
|
||||
if bytes.Contains([]byte(output), []byte("default-project")) {
|
||||
t.Errorf("Should not show default-project when not set, got: %s", output)
|
||||
}
|
||||
// Confirm settings should always show, even when false
|
||||
if !bytes.Contains([]byte(output), []byte("confirm-add: false")) {
|
||||
t.Errorf("Should show confirm-add even when false, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigListNoConfigFile(t *testing.T) {
|
||||
@@ -367,6 +371,125 @@ func TestConfigListNoConfigFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSetConfirmAdd(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
t.Setenv("HOME", tempDir)
|
||||
|
||||
configDir := filepath.Join(tempDir, "cospend")
|
||||
if err := os.MkdirAll(configDir, 0700); err != nil {
|
||||
t.Fatalf("Failed to create config dir: %v", err)
|
||||
}
|
||||
configPath := filepath.Join(configDir, "cospend.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"domain":"https://example.com","user":"alice","password":"pass"}`), 0600); err != nil {
|
||||
t.Fatalf("Failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cmd := NewConfigCommand()
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetArgs([]string{"set", "confirm-add", "true"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Contains(stdout.Bytes(), []byte("Set confirm-add = true")) {
|
||||
t.Errorf("Expected success message, got: %s", stdout.String())
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read config: %v", err)
|
||||
}
|
||||
if !bytes.Contains(data, []byte("confirm_add")) {
|
||||
t.Errorf("Config should contain 'confirm_add', got: %s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigGetConfirmDelete(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
t.Setenv("HOME", tempDir)
|
||||
|
||||
configDir := filepath.Join(tempDir, "cospend")
|
||||
if err := os.MkdirAll(configDir, 0700); err != nil {
|
||||
t.Fatalf("Failed to create config dir: %v", err)
|
||||
}
|
||||
configPath := filepath.Join(configDir, "cospend.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"domain":"https://example.com","user":"alice","password":"pass","confirm_delete":true}`), 0600); err != nil {
|
||||
t.Fatalf("Failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cmd := NewConfigCommand()
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetArgs([]string{"get", "confirm-delete"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Contains(stdout.Bytes(), []byte("true")) {
|
||||
t.Errorf("Expected 'true', got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSetConfirmInvalidBool(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
t.Setenv("HOME", tempDir)
|
||||
|
||||
configDir := filepath.Join(tempDir, "cospend")
|
||||
if err := os.MkdirAll(configDir, 0700); err != nil {
|
||||
t.Fatalf("Failed to create config dir: %v", err)
|
||||
}
|
||||
configPath := filepath.Join(configDir, "cospend.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"domain":"x","user":"u","password":"p"}`), 0600); err != nil {
|
||||
t.Fatalf("Failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cmd := NewConfigCommand()
|
||||
cmd.SetArgs([]string{"set", "confirm-add", "notabool"})
|
||||
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Error("Expected error for invalid boolean value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigListShowsConfirmations(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
t.Setenv("HOME", tempDir)
|
||||
|
||||
configDir := filepath.Join(tempDir, "cospend")
|
||||
if err := os.MkdirAll(configDir, 0700); err != nil {
|
||||
t.Fatalf("Failed to create config dir: %v", err)
|
||||
}
|
||||
configPath := filepath.Join(configDir, "cospend.json")
|
||||
configContent := `{"domain":"https://example.com","user":"alice","password":"pass","confirm_add":true,"confirm_delete":true}`
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
|
||||
t.Fatalf("Failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cmd := NewConfigCommand()
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetArgs([]string{"list"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
output := stdout.String()
|
||||
if !bytes.Contains([]byte(output), []byte("confirm-add")) {
|
||||
t.Errorf("Should show confirm-add, got: %s", output)
|
||||
}
|
||||
if !bytes.Contains([]byte(output), []byte("confirm-delete")) {
|
||||
t.Errorf("Should show confirm-delete, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskPassword(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
|
||||
@@ -2,10 +2,13 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/chenasraf/cospend-cli/internal/api"
|
||||
"github.com/chenasraf/cospend-cli/internal/cache"
|
||||
"github.com/chenasraf/cospend-cli/internal/config"
|
||||
"github.com/chenasraf/cospend-cli/internal/format"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -55,6 +58,65 @@ func runDelete(cmd *cobra.Command, args []string) error {
|
||||
client.Debug = Debug
|
||||
client.DebugWriter = cmd.ErrOrStderr()
|
||||
|
||||
// Confirm if configured
|
||||
if cfg.ConfirmDelete {
|
||||
// Fetch bill details for preview
|
||||
bills, err := client.GetBills(ProjectID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching bills: %w", err)
|
||||
}
|
||||
|
||||
var bill *api.BillResponse
|
||||
for i := range bills {
|
||||
if bills[i].ID == billID {
|
||||
bill = &bills[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
out := cmd.OutOrStdout()
|
||||
if bill != nil {
|
||||
// Fetch project for member names and currency
|
||||
project, ok := cache.Load(ProjectID)
|
||||
if !ok {
|
||||
project, err = client.GetProject(ProjectID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching project: %w", err)
|
||||
}
|
||||
}
|
||||
memberNames := make(map[int]string)
|
||||
for _, m := range project.Members {
|
||||
memberNames[m.ID] = m.Name
|
||||
}
|
||||
|
||||
locale := "en_US"
|
||||
userInfo, ok := cache.LoadUserInfo()
|
||||
if !ok {
|
||||
userInfo, err = client.GetUserInfo()
|
||||
if err == nil {
|
||||
_ = cache.SaveUserInfo(userInfo)
|
||||
}
|
||||
}
|
||||
if userInfo != nil && userInfo.Locale != "" {
|
||||
locale = userInfo.Locale
|
||||
} else if userInfo != nil && userInfo.Language != "" {
|
||||
locale = userInfo.Language
|
||||
}
|
||||
|
||||
formatter := format.NewAmountFormatter(locale, project.CurrencyName)
|
||||
_, _ = fmt.Fprintf(out, "Bill #%d:\n", billID)
|
||||
_, _ = fmt.Fprintf(out, " Name: %s\n", bill.What)
|
||||
_, _ = fmt.Fprintf(out, " Amount: %s\n", formatter.Format(bill.Amount))
|
||||
_, _ = fmt.Fprintf(out, " Date: %s\n", bill.Date)
|
||||
_, _ = fmt.Fprintf(out, " Paid by: %s\n", memberNames[bill.PayerID])
|
||||
}
|
||||
|
||||
if !confirm(os.Stdin, out, fmt.Sprintf("Delete bill #%d?", billID)) {
|
||||
_, _ = fmt.Fprintln(out, "Cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the bill
|
||||
if err := client.DeleteBill(ProjectID, billID); err != nil {
|
||||
return fmt.Errorf("deleting bill: %w", err)
|
||||
|
||||
88
cmd/edit.go
88
cmd/edit.go
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -195,11 +196,6 @@ func runEdit(cmd *cobra.Command, args []string) error {
|
||||
bill.Repeat = editRepeat
|
||||
}
|
||||
|
||||
// Edit the bill
|
||||
if err := client.EditBill(ProjectID, billID, bill); err != nil {
|
||||
return fmt.Errorf("editing bill: %w", err)
|
||||
}
|
||||
|
||||
// Fetch user info for locale-aware formatting
|
||||
locale := "en_US"
|
||||
userInfo, ok := cache.LoadUserInfo()
|
||||
@@ -217,38 +213,58 @@ func runEdit(cmd *cobra.Command, args []string) error {
|
||||
|
||||
formatter := format.NewAmountFormatter(locale, project.CurrencyName)
|
||||
out := cmd.OutOrStdout()
|
||||
|
||||
printBillSummary := func() {
|
||||
_, _ = fmt.Fprintf(out, " Name: %s\n", bill.What)
|
||||
_, _ = fmt.Fprintf(out, " Amount: %s\n", formatter.Format(bill.Amount))
|
||||
_, _ = fmt.Fprintf(out, " Date: %s\n", bill.Date)
|
||||
_, _ = fmt.Fprintf(out, " Paid by: %s\n", memberNames[bill.PayerID])
|
||||
var owerNames []string
|
||||
for _, id := range bill.OwedTo {
|
||||
owerNames = append(owerNames, memberNames[id])
|
||||
}
|
||||
_, _ = fmt.Fprintf(out, " Paid for: %s\n", strings.Join(owerNames, ", "))
|
||||
if bill.CategoryID != 0 {
|
||||
for _, c := range project.Categories {
|
||||
if c.ID == bill.CategoryID {
|
||||
_, _ = fmt.Fprintf(out, " Category: %s\n", c.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if bill.PaymentModeID != 0 {
|
||||
for _, pm := range project.PaymentModes {
|
||||
if pm.ID == bill.PaymentModeID {
|
||||
_, _ = fmt.Fprintf(out, " Method: %s\n", pm.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if bill.Comment != "" {
|
||||
_, _ = fmt.Fprintf(out, " Comment: %s\n", bill.Comment)
|
||||
}
|
||||
if bill.Repeat != "" && bill.Repeat != "n" {
|
||||
_, _ = fmt.Fprintf(out, " Repeat: %s\n", api.ValidRepeatFrequencies[bill.Repeat])
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm if configured
|
||||
if cfg.ConfirmUpdate {
|
||||
_, _ = fmt.Fprintf(out, "Update bill #%d:\n", billID)
|
||||
printBillSummary()
|
||||
if !confirm(os.Stdin, out, "Update bill?") {
|
||||
_, _ = fmt.Fprintln(out, "Cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Edit the bill
|
||||
if err := client.EditBill(ProjectID, billID, bill); err != nil {
|
||||
return fmt.Errorf("editing bill: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(out, "Updated bill #%d\n", billID)
|
||||
_, _ = fmt.Fprintf(out, " Name: %s\n", bill.What)
|
||||
_, _ = fmt.Fprintf(out, " Amount: %s\n", formatter.Format(bill.Amount))
|
||||
_, _ = fmt.Fprintf(out, " Date: %s\n", bill.Date)
|
||||
_, _ = fmt.Fprintf(out, " Paid by: %s\n", memberNames[bill.PayerID])
|
||||
var owerNames []string
|
||||
for _, id := range bill.OwedTo {
|
||||
owerNames = append(owerNames, memberNames[id])
|
||||
}
|
||||
_, _ = fmt.Fprintf(out, " Paid for: %s\n", strings.Join(owerNames, ", "))
|
||||
if bill.CategoryID != 0 {
|
||||
for _, c := range project.Categories {
|
||||
if c.ID == bill.CategoryID {
|
||||
_, _ = fmt.Fprintf(out, " Category: %s\n", c.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if bill.PaymentModeID != 0 {
|
||||
for _, pm := range project.PaymentModes {
|
||||
if pm.ID == bill.PaymentModeID {
|
||||
_, _ = fmt.Fprintf(out, " Method: %s\n", pm.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if bill.Comment != "" {
|
||||
_, _ = fmt.Fprintf(out, " Comment: %s\n", bill.Comment)
|
||||
}
|
||||
if bill.Repeat != "" && bill.Repeat != "n" {
|
||||
_, _ = fmt.Fprintf(out, " Repeat: %s\n", api.ValidRepeatFrequencies[bill.Repeat])
|
||||
}
|
||||
printBillSummary()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ type Config struct {
|
||||
User string `json:"user" yaml:"user" toml:"user"`
|
||||
Password string `json:"password" yaml:"password" toml:"password"`
|
||||
DefaultProject string `json:"default_project,omitempty" yaml:"default_project,omitempty" toml:"default_project,omitempty"`
|
||||
ConfirmAdd bool `json:"confirm_add,omitempty" yaml:"confirm_add,omitempty" toml:"confirm_add,omitempty"`
|
||||
ConfirmDelete bool `json:"confirm_delete,omitempty" yaml:"confirm_delete,omitempty" toml:"confirm_delete,omitempty"`
|
||||
ConfirmUpdate bool `json:"confirm_update,omitempty" yaml:"confirm_update,omitempty" toml:"confirm_update,omitempty"`
|
||||
}
|
||||
|
||||
// configExtensions lists supported config file extensions in order of preference
|
||||
@@ -240,5 +243,14 @@ password = %q
|
||||
if cfg.DefaultProject != "" {
|
||||
content += fmt.Sprintf("default_project = %q\n", cfg.DefaultProject)
|
||||
}
|
||||
if cfg.ConfirmAdd {
|
||||
content += "confirm_add = true\n"
|
||||
}
|
||||
if cfg.ConfirmDelete {
|
||||
content += "confirm_delete = true\n"
|
||||
}
|
||||
if cfg.ConfirmUpdate {
|
||||
content += "confirm_update = true\n"
|
||||
}
|
||||
return []byte(content), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user