Add a tags screen

Screen 8 lists every distinct tag -- the ones the index holds and the
ones rules.toml names -- with how many transactions carry it and the
rules that write it, each by file position with the transactions it
wins. It is where near-duplicate tags and tags several rules feed show
up, and a rule beaten by a more specific one reads (0) there as it does
on the rules screen. A tag no rule names any more says the next retag
clears it. The transfer label is not a tag and is never listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 23:30:38 +02:00
co-authored by Claude Opus 5.5
parent ba7e0e2b42
commit 3d72a54be1
5 changed files with 169 additions and 5 deletions
+50
View File
@@ -78,6 +78,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/report", s.read(s.report))
mux.HandleFunc("GET /api/rules", s.read(s.ruleList))
mux.HandleFunc("GET /api/tags", s.read(s.tagList))
mux.HandleFunc("POST /api/rules/preview", s.read(s.rulePreview))
mux.HandleFunc("POST /api/rules", s.write(s.createRule))
mux.HandleFunc("PUT /api/rules/{pos}", s.write(s.editRule))
@@ -556,6 +557,55 @@ func (s *Server) ruleList(*http.Request) (any, error) {
return map[string]any{"rules": rows}, nil
}
type tagRule struct {
Pos int `json:"pos"` // file position, as the rules screen numbers it
Pattern string `json:"pattern"`
Account string `json:"account"`
Usage int `json:"usage"`
}
type tagEntry struct {
Tag string `json:"tag"`
Transactions int `json:"transactions"`
Rules []tagRule `json:"rules"`
}
// tagList is every distinct tag — the ones the index holds and the ones
// rules.toml names — with how many transactions carry it and the rules that
// write it. A tag is only ever what a rule wrote, so the transfer label is
// never among them, and a tag no rule names any more is one a retag will
// clear.
func (s *Server) tagList(*http.Request) (any, error) {
txns, err := s.db.Transactions(store.Filter{})
if err != nil {
return nil, err
}
counts := map[string]int{}
for _, t := range txns {
if t.RuleTag != "" {
counts[t.RuleTag]++
}
}
rules := map[string][]tagRule{}
usage := s.engine.Usage(txns)
for i, r := range s.engine.Rules() {
rules[r.Tag] = append(rules[r.Tag], tagRule{Pos: i, Pattern: rulePattern(r), Account: r.Account, Usage: usage[i]})
}
tags, err := s.knownTags()
if err != nil {
return nil, err
}
rows := make([]tagEntry, 0, len(tags))
for _, tag := range tags {
rs := rules[tag]
if rs == nil {
rs = []tagRule{}
}
rows = append(rows, tagEntry{Tag: tag, Transactions: counts[tag], Rules: rs})
}
return map[string]any{"tags": rows}, nil
}
// rulePattern renders whichever patterns a rule sets, labelled so a type rule
// is not mistaken for a description one.
func rulePattern(r config.Rule) string {