Key ls --uniq on the description alone
The listing keyed on account and description, so a payee seen on two accounts was two rows. The rule builder groups by description alone and showed one, and the two lists answer the same question -- what still needs a rule -- so the one run from the shell overstated the work left and disagreed with the one on screen. A rule matches on the description and only optionally narrows to an account, so one payee is one pattern to write however many accounts it turns up on. The account column goes with the key; --account is still how the listing is scoped to one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -78,26 +78,31 @@ imported — creating an `account.toml` is not enough on its own. Run
|
|||||||
`money import` (or press `i` in the TUI) after adding one.
|
`money import` (or press `i` in the TUI) after adding one.
|
||||||
|
|
||||||
`--uniq` turns `ls` into a list of patterns still to write rather than a list of
|
`--uniq` turns `ls` into a list of patterns still to write rather than a list of
|
||||||
rows to read: one line per distinct account and description, since fifty visits
|
rows to read: one line per distinct description, since fifty visits to the same
|
||||||
to the same shop are one glob, not fifty lines. Descriptions print exactly as a
|
shop are one glob, not fifty lines. Descriptions print exactly as a glob sees
|
||||||
glob sees them — upper-cased with whitespace collapsed — so two statements that
|
them — upper-cased with whitespace collapsed — so two statements that worded the
|
||||||
worded the same payee differently collapse into the one row they deserve, and a
|
same payee differently collapse into the one row they deserve, and a pattern
|
||||||
pattern written from the list matches what the list showed you.
|
written from the list matches what the list showed you.
|
||||||
|
|
||||||
```
|
```
|
||||||
$ money ls --untagged --uniq
|
$ money ls --untagged --uniq
|
||||||
ACCOUNT DESCRIPTION
|
DESCRIPTION
|
||||||
checking KAUFLAND 4412 SOFIA
|
INTEREST PAID
|
||||||
checking LIDL SOFIA 4412
|
KAUFLAND 4412 SOFIA
|
||||||
savings INTEREST PAID
|
LIDL SOFIA 4412
|
||||||
|
|
||||||
3 distinct descriptions
|
3 distinct descriptions
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The account is not part of what makes a row distinct. A rule matches on the
|
||||||
|
description and only optionally narrows to an account, so one payee seen on two
|
||||||
|
accounts is still one pattern to write — and it is one row in the rule builder
|
||||||
|
on `4`, which groups the same way. Pass `--account` to scope the listing
|
||||||
|
instead.
|
||||||
|
|
||||||
It composes with the other filters (`--account`, `--month`, `--search`), and
|
It composes with the other filters (`--account`, `--month`, `--search`), and
|
||||||
`--limit` caps the rows printed, saying how many it held back. It cannot be
|
`--limit` caps the rows printed, saying how many it held back. It cannot be
|
||||||
combined with `--wide`: a reported balance belongs to one transaction, and
|
combined with `--wide`, whose columns all belong to a single transaction.
|
||||||
there is nothing sensible to print for a whole group of them.
|
|
||||||
|
|
||||||
### TUI keys
|
### TUI keys
|
||||||
|
|
||||||
|
|||||||
+24
-25
@@ -270,13 +270,13 @@ func cmdLs(root string, args []string) error {
|
|||||||
untagged := fs.Bool("untagged", false, "only transactions no rule tagged and no transfer claimed")
|
untagged := fs.Bool("untagged", false, "only transactions no rule tagged and no transfer claimed")
|
||||||
limit := fs.Int("limit", 0, "maximum rows (0 = no limit)")
|
limit := fs.Int("limit", 0, "maximum rows (0 = no limit)")
|
||||||
wide := fs.Bool("wide", false, "also show type and reported balance")
|
wide := fs.Bool("wide", false, "also show type and reported balance")
|
||||||
uniq := fs.Bool("uniq", false, "one row per distinct account and description")
|
uniq := fs.Bool("uniq", false, "one row per distinct description")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if *uniq && *wide {
|
if *uniq && *wide {
|
||||||
// Both add columns, but a reported balance belongs to one row and
|
// --wide's columns all belong to a single transaction, and nothing
|
||||||
// nothing sensible can be printed for a whole group of them.
|
// sensible can be printed for a whole group of them.
|
||||||
return fmt.Errorf("--uniq and --wide cannot be combined")
|
return fmt.Errorf("--uniq and --wide cannot be combined")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,35 +332,34 @@ func cmdLs(root string, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// printUniq lists each account and description once, which is the shape of the
|
// printUniq lists each description once, which is the shape of the question
|
||||||
// question "what still needs a rule?" — fifty visits to one shop are one
|
// "what still needs a rule?" — fifty visits to one shop are one pattern to
|
||||||
// pattern to write, not fifty rows to read.
|
// write, not fifty rows to read.
|
||||||
|
//
|
||||||
|
// The account is deliberately not part of the key. A rule matches on the
|
||||||
|
// description and only optionally narrows to an account, so the same payee
|
||||||
|
// seen on two accounts is still one pattern; splitting the row per account
|
||||||
|
// would overstate the work left and disagree with the rule builder's list,
|
||||||
|
// which groups the same way. Use --account to scope the listing instead.
|
||||||
//
|
//
|
||||||
// Descriptions are printed as rules.Engine matches them: normalised, since that
|
// Descriptions are printed as rules.Engine matches them: normalised, since that
|
||||||
// is the string a glob is actually tested against, so a pattern written from
|
// is the string a glob is actually tested against, so a pattern written from
|
||||||
// this list behaves the way the list reads. It also means two statements that
|
// this list behaves the way the list reads. It also means two statements that
|
||||||
// differ only in spacing or case collapse to the one row they deserve.
|
// differ only in spacing or case collapse to the one row they deserve.
|
||||||
func printUniq(out io.Writer, txns []model.Transaction, limit int) error {
|
func printUniq(out io.Writer, txns []model.Transaction, limit int) error {
|
||||||
type row struct{ account, desc string }
|
seen := map[string]bool{}
|
||||||
seen := map[row]bool{}
|
var rows []string
|
||||||
var rows []row
|
|
||||||
for _, t := range txns {
|
for _, t := range txns {
|
||||||
r := row{t.AccountSlug, model.NormalizeDescription(t.Description)}
|
desc := model.NormalizeDescription(t.Description)
|
||||||
if seen[r] {
|
if seen[desc] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[r] = true
|
seen[desc] = true
|
||||||
rows = append(rows, r)
|
rows = append(rows, desc)
|
||||||
}
|
}
|
||||||
// Grouped by account and alphabetical within it: the same payee under
|
// Alphabetical, so the same payee under slightly different wordings lands
|
||||||
// slightly different wordings then lands on adjacent lines, where one glob
|
// on adjacent lines, where one glob covering both is easy to see.
|
||||||
// covering both is easy to see.
|
sort.Strings(rows)
|
||||||
sort.Slice(rows, func(i, j int) bool {
|
|
||||||
if rows[i].account != rows[j].account {
|
|
||||||
return rows[i].account < rows[j].account
|
|
||||||
}
|
|
||||||
return rows[i].desc < rows[j].desc
|
|
||||||
})
|
|
||||||
|
|
||||||
total := len(rows)
|
total := len(rows)
|
||||||
if limit > 0 && len(rows) > limit {
|
if limit > 0 && len(rows) > limit {
|
||||||
@@ -368,9 +367,9 @@ func printUniq(out io.Writer, txns []model.Transaction, limit int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
||||||
fmt.Fprintln(w, "ACCOUNT\tDESCRIPTION")
|
fmt.Fprintln(w, "DESCRIPTION")
|
||||||
for _, r := range rows {
|
for _, desc := range rows {
|
||||||
fmt.Fprintf(w, "%s\t%s\n", r.account, r.desc)
|
fmt.Fprintln(w, desc)
|
||||||
}
|
}
|
||||||
w.Flush()
|
w.Flush()
|
||||||
|
|
||||||
|
|||||||
+10
-5
@@ -52,15 +52,20 @@ func TestUniqNormalisesLikeAGlob(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The same description on two accounts is two rows: a rule can be scoped to an
|
// The account is not part of the key. A rule matches on the description and
|
||||||
// account, and the two may well want different tags.
|
// only optionally narrows to an account, so one payee seen on two accounts is
|
||||||
func TestUniqKeepsAccountsApart(t *testing.T) {
|
// still one pattern to write -- and the rule builder's list groups the same
|
||||||
|
// way, which is the list this one has to agree with.
|
||||||
|
func TestUniqIgnoresTheAccount(t *testing.T) {
|
||||||
out := uniqOutput(t, 0,
|
out := uniqOutput(t, 0,
|
||||||
txn("checking", "TRANSFER"),
|
txn("checking", "TRANSFER"),
|
||||||
txn("savings", "TRANSFER"),
|
txn("savings", "TRANSFER"),
|
||||||
)
|
)
|
||||||
if !strings.Contains(out, "2 distinct descriptions") {
|
if !strings.Contains(out, "1 distinct descriptions") {
|
||||||
t.Errorf("want a row per account:\n%s", out)
|
t.Errorf("want one row for one description:\n%s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "checking") || strings.Contains(out, "savings") {
|
||||||
|
t.Errorf("the account has no column here:\n%s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user