Nothing showed whether a rule was still earning its place. The new screen, on 5, lists every rule in file order with the number of transactions it claims, marking those that claim none. The count comes from Engine.Usage, which counts by first match, so a rule shadowed by an earlier one reports zero even though its glob matches. That is the case worth catching: such a rule looks correct in isolation and can never fire. d removes the selected rule and p removes every unused one, each behind a y/n confirmation since this rewrites a hand-maintained file. config.DeleteRules edits rules.toml textually rather than re-serialising the parsed rules, so comments and layout survive; a comment directly above a rule goes with it, while one separated by a blank line is left as a heading. The result is re-parsed before it replaces the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1269 lines
32 KiB
Go
1269 lines
32 KiB
Go
// Package tui is the terminal interface: browse accounts, tag transactions,
|
|
// mark transfers by hand, and see where the money went.
|
|
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/spinner"
|
|
"github.com/charmbracelet/bubbles/table"
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"git.petrovv.com/nikola/money/internal/config"
|
|
"git.petrovv.com/nikola/money/internal/glob"
|
|
"git.petrovv.com/nikola/money/internal/importer"
|
|
"git.petrovv.com/nikola/money/internal/model"
|
|
"git.petrovv.com/nikola/money/internal/report"
|
|
"git.petrovv.com/nikola/money/internal/rules"
|
|
"git.petrovv.com/nikola/money/internal/store"
|
|
)
|
|
|
|
type view int
|
|
|
|
const (
|
|
viewAccounts view = iota
|
|
viewTxns
|
|
viewReport
|
|
viewRules
|
|
viewRuleList
|
|
|
|
viewCount = 5
|
|
)
|
|
|
|
// confirmation is a pending destructive action awaiting a y/n answer.
|
|
// Deleting rewrites a file the user maintains by hand, so it is never done on
|
|
// a single keypress.
|
|
type confirmation int
|
|
|
|
const (
|
|
confirmNone confirmation = iota
|
|
confirmDeleteRule
|
|
confirmPruneRules
|
|
)
|
|
|
|
// input is the modal state: the transaction list is read-only until the user
|
|
// starts typing a tag or a search.
|
|
type input int
|
|
|
|
const (
|
|
inputNone input = iota
|
|
inputTag
|
|
inputSearch
|
|
)
|
|
|
|
// Model is the root Bubble Tea model.
|
|
type Model struct {
|
|
root string
|
|
db *store.DB
|
|
accounts []*config.Account
|
|
engine *rules.Engine
|
|
|
|
view view
|
|
input input
|
|
|
|
accountTable table.Model
|
|
txnTable table.Model
|
|
reportTable table.Model
|
|
text textinput.Model
|
|
spinner spinner.Model
|
|
|
|
// importing is set while an import runs in the background, so the UI keeps
|
|
// redrawing instead of freezing on a slow PDF.
|
|
importing bool
|
|
|
|
txns []model.Transaction // rows currently shown in txnTable
|
|
|
|
// Rule builder: three inputs on the left, and a live preview on the right
|
|
// of which untagged descriptions the glob would catch.
|
|
ruleGlob textinput.Model
|
|
ruleAccount textinput.Model
|
|
ruleTag textinput.Model
|
|
ruleFocus int // which of the three inputs has the cursor
|
|
ruleTable table.Model
|
|
ruleReturn view // the view to go back to on esc
|
|
untagged []descGroup
|
|
ruleMatches int // untagged descriptions the current glob matches
|
|
|
|
// Rule list: every rule with the number of transactions it actually
|
|
// claims, so dead ones can be found and removed.
|
|
ruleListTable table.Model
|
|
ruleUsage []int
|
|
confirm confirmation
|
|
|
|
filter store.Filter
|
|
onlyUntagged bool
|
|
|
|
status string
|
|
err error
|
|
width int
|
|
height int
|
|
}
|
|
|
|
var (
|
|
titleStyle = lipgloss.NewStyle().Bold(true).Padding(0, 1)
|
|
statusStyle = lipgloss.NewStyle().Faint(true).Padding(0, 1)
|
|
errStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")).Padding(0, 1)
|
|
helpStyle = lipgloss.NewStyle().Faint(true).Padding(0, 1)
|
|
emptyStyle = lipgloss.NewStyle().Padding(1, 2)
|
|
|
|
labelStyle = lipgloss.NewStyle().Faint(true)
|
|
focusedLabelStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("62"))
|
|
// Both boxes are pinned to the same width: a focused input renders one
|
|
// extra cell for the cursor, which would otherwise shift the border as
|
|
// the cursor moves between fields.
|
|
boxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(lipgloss.Color("240")).Padding(0, 1).Width(ruleInputWidth + 2)
|
|
focusedBoxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(lipgloss.Color("62")).Padding(0, 1).Width(ruleInputWidth + 2)
|
|
hintStyle = lipgloss.NewStyle().Faint(true).PaddingLeft(2)
|
|
matchCountStyle = lipgloss.NewStyle().Bold(true).PaddingLeft(2)
|
|
ruleFormStyle = lipgloss.NewStyle().Width(ruleFormWidth)
|
|
)
|
|
|
|
// Run starts the interface.
|
|
func Run(root string, db *store.DB, accounts []*config.Account, engine *rules.Engine) error {
|
|
m := New(root, db, accounts, engine)
|
|
if err := m.reload(); err != nil {
|
|
return err
|
|
}
|
|
_, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
|
|
return err
|
|
}
|
|
|
|
// New builds the root model.
|
|
func New(root string, db *store.DB, accounts []*config.Account, engine *rules.Engine) *Model {
|
|
ti := textinput.New()
|
|
ti.Prompt = ""
|
|
ti.CharLimit = 64
|
|
|
|
newInput := func(placeholder string) textinput.Model {
|
|
in := textinput.New()
|
|
in.Prompt = ""
|
|
in.CharLimit = 80
|
|
in.Width = ruleInputWidth
|
|
in.Placeholder = placeholder
|
|
return in
|
|
}
|
|
|
|
sp := spinner.New()
|
|
sp.Spinner = spinner.Dot
|
|
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("62"))
|
|
|
|
styles := table.DefaultStyles()
|
|
styles.Header = styles.Header.Bold(true)
|
|
styles.Selected = styles.Selected.Bold(true).Foreground(lipgloss.Color("15")).Background(lipgloss.Color("62"))
|
|
|
|
newTable := func(cols []table.Column) table.Model {
|
|
return table.New(table.WithColumns(cols), table.WithFocused(true),
|
|
table.WithHeight(15), table.WithStyles(styles))
|
|
}
|
|
|
|
return &Model{
|
|
root: root,
|
|
db: db,
|
|
accounts: accounts,
|
|
engine: engine,
|
|
view: viewAccounts,
|
|
text: ti,
|
|
spinner: sp,
|
|
accountTable: newTable([]table.Column{
|
|
{Title: "Account", Width: 20},
|
|
{Title: "Balance", Width: 14},
|
|
{Title: "Cur", Width: 4},
|
|
{Title: "Txns", Width: 6},
|
|
}),
|
|
txnTable: newTable([]table.Column{
|
|
{Title: "Date", Width: 10},
|
|
{Title: "Account", Width: 12},
|
|
{Title: "Amount", Width: 12},
|
|
{Title: "Tag", Width: 14},
|
|
{Title: "T", Width: 1},
|
|
{Title: "Description", Width: 40},
|
|
}),
|
|
ruleGlob: newInput("*LIDL*"),
|
|
ruleAccount: newInput("blank = every account"),
|
|
ruleTag: newInput("groceries"),
|
|
ruleTable: newTable([]table.Column{
|
|
{Title: " ", Width: 1},
|
|
{Title: "Untagged description", Width: 44},
|
|
{Title: "N", Width: 4},
|
|
}),
|
|
ruleListTable: newTable([]table.Column{
|
|
{Title: "#", Width: 3},
|
|
{Title: " ", Width: 1},
|
|
{Title: "Pattern", Width: 34},
|
|
{Title: "Account", Width: 12},
|
|
{Title: "Tag", Width: 14},
|
|
{Title: "T", Width: 1},
|
|
{Title: "Txns", Width: 6},
|
|
}),
|
|
reportTable: newTable([]table.Column{
|
|
{Title: "Tag", Width: 20},
|
|
{Title: "Cur", Width: 4},
|
|
{Title: "Out", Width: 14},
|
|
{Title: "In", Width: 14},
|
|
{Title: "Net", Width: 14},
|
|
{Title: "N", Width: 5},
|
|
}),
|
|
}
|
|
}
|
|
|
|
// Init implements tea.Model.
|
|
func (m *Model) Init() tea.Cmd { return nil }
|
|
|
|
// reload refreshes whichever view is active from the index.
|
|
func (m *Model) reload() error {
|
|
if err := m.reloadAccounts(); err != nil {
|
|
return err
|
|
}
|
|
return m.reloadTxns()
|
|
}
|
|
|
|
func (m *Model) reloadAccounts() error {
|
|
accounts, err := m.db.Accounts()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows := make([]table.Row, 0, len(accounts))
|
|
for _, a := range accounts {
|
|
bal, err := m.db.Balance(a.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, err := m.db.Count(a.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows = append(rows, table.Row{
|
|
a.Name, model.FormatMinor(bal, a.MinorDigits), a.Currency, fmt.Sprintf("%d", n),
|
|
})
|
|
}
|
|
m.accountTable.SetRows(rows)
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) reloadTxns() error {
|
|
f := m.filter
|
|
f.Untagged = m.onlyUntagged
|
|
txns, err := m.db.Transactions(f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.txns = txns
|
|
|
|
rows := make([]table.Row, 0, len(txns))
|
|
for _, t := range txns {
|
|
transfer := ""
|
|
if t.IsTransfer() {
|
|
transfer = "T"
|
|
}
|
|
tag := t.Tag()
|
|
if tag != "" && t.ManualTag != "" {
|
|
tag += "*" // marks a manual override
|
|
}
|
|
rows = append(rows, table.Row{
|
|
t.Date, t.AccountSlug, t.FormatAmount(), tag, transfer, t.Description,
|
|
})
|
|
}
|
|
// Keep the cursor in range after the row count shrinks (e.g. tagging the
|
|
// last untagged row while the untagged filter is on).
|
|
cursor := m.txnTable.Cursor()
|
|
m.txnTable.SetRows(rows)
|
|
if cursor >= len(rows) {
|
|
cursor = len(rows) - 1
|
|
}
|
|
if cursor < 0 {
|
|
cursor = 0
|
|
}
|
|
m.txnTable.SetCursor(cursor)
|
|
|
|
m.reloadReport(txns)
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) reloadReport(txns []model.Transaction) {
|
|
rows := report.ByTag(txns)
|
|
out := make([]table.Row, 0, len(rows)+2)
|
|
for _, r := range rows {
|
|
out = append(out, table.Row{
|
|
r.Tag, r.Currency,
|
|
model.FormatMinor(r.Out, r.Digits),
|
|
model.FormatMinor(r.In, r.Digits),
|
|
model.FormatMinor(r.Net(), r.Digits),
|
|
fmt.Sprintf("%d", r.Count),
|
|
})
|
|
}
|
|
for _, c := range report.Totals(rows) {
|
|
out = append(out, table.Row{
|
|
"TOTAL", c.Currency,
|
|
model.FormatMinor(c.Out, c.Digits),
|
|
model.FormatMinor(c.In, c.Digits),
|
|
model.FormatMinor(c.Net(), c.Digits),
|
|
"",
|
|
})
|
|
}
|
|
m.reportTable.SetRows(out)
|
|
}
|
|
|
|
// ruleFormWidth is the width of the rule builder's left-hand form, and
|
|
// ruleInputWidth the editable area inside its boxes. The form must stay wider
|
|
// than a box plus its border, padding and the block cursor, or lipgloss wraps
|
|
// the border and the panel falls apart.
|
|
const (
|
|
ruleInputWidth = 26
|
|
ruleFormWidth = ruleInputWidth + 12
|
|
)
|
|
|
|
// descGroup is one distinct untagged description and how often it occurs.
|
|
// Grouping matters: a statement holds the same payee dozens of times, and a
|
|
// rule is written against the description, not against individual rows.
|
|
type descGroup struct {
|
|
Description string
|
|
Accounts map[string]bool
|
|
Count int
|
|
}
|
|
|
|
// reloadUntagged rebuilds the alphabetical list of untagged descriptions that
|
|
// the rule builder previews against.
|
|
func (m *Model) reloadUntagged() error {
|
|
txns, err := m.db.Transactions(store.Filter{Untagged: true})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
byDesc := map[string]*descGroup{}
|
|
for _, t := range txns {
|
|
key := model.NormalizeDescription(t.Description)
|
|
g, ok := byDesc[key]
|
|
if !ok {
|
|
g = &descGroup{Description: t.Description, Accounts: map[string]bool{}}
|
|
byDesc[key] = g
|
|
}
|
|
g.Accounts[t.AccountSlug] = true
|
|
g.Count++
|
|
}
|
|
|
|
m.untagged = make([]descGroup, 0, len(byDesc))
|
|
for _, g := range byDesc {
|
|
m.untagged = append(m.untagged, *g)
|
|
}
|
|
sort.Slice(m.untagged, func(i, j int) bool {
|
|
a := model.NormalizeDescription(m.untagged[i].Description)
|
|
b := model.NormalizeDescription(m.untagged[j].Description)
|
|
if a != b {
|
|
return a < b
|
|
}
|
|
return m.untagged[i].Description < m.untagged[j].Description
|
|
})
|
|
|
|
m.refreshRulePreview()
|
|
return nil
|
|
}
|
|
|
|
// refreshRulePreview re-marks the preview against whatever is typed right now.
|
|
// It runs on every keystroke, so the glob is checked against descriptions only,
|
|
// never against the database.
|
|
func (m *Model) refreshRulePreview() {
|
|
var (
|
|
pattern = strings.TrimSpace(m.ruleGlob.Value())
|
|
account = strings.TrimSpace(m.ruleAccount.Value())
|
|
rows = make([]table.Row, 0, len(m.untagged))
|
|
)
|
|
m.ruleMatches = 0
|
|
|
|
for _, g := range m.untagged {
|
|
// An account filter narrows the preview the same way the saved rule
|
|
// will narrow its matching.
|
|
if account != "" && !g.Accounts[account] {
|
|
continue
|
|
}
|
|
marker := " "
|
|
if pattern != "" && glob.Match(pattern, model.NormalizeDescription(g.Description)) {
|
|
marker = "▸"
|
|
m.ruleMatches++
|
|
}
|
|
rows = append(rows, table.Row{marker, g.Description, fmt.Sprintf("%d", g.Count)})
|
|
}
|
|
|
|
cursor := m.ruleTable.Cursor()
|
|
m.ruleTable.SetRows(rows)
|
|
if cursor >= len(rows) {
|
|
cursor = len(rows) - 1
|
|
}
|
|
if cursor < 0 {
|
|
cursor = 0
|
|
}
|
|
m.ruleTable.SetCursor(cursor)
|
|
}
|
|
|
|
// saveRule appends the composed rule to rules.toml, reloads the engine and
|
|
// retags, so the effect is visible immediately.
|
|
func (m *Model) saveRule() error {
|
|
r := config.Rule{
|
|
Match: strings.TrimSpace(m.ruleGlob.Value()),
|
|
Account: strings.TrimSpace(m.ruleAccount.Value()),
|
|
Tag: strings.TrimSpace(m.ruleTag.Value()),
|
|
}
|
|
if r.Match == "" {
|
|
return fmt.Errorf("enter a glob first, e.g. *LIDL*")
|
|
}
|
|
if r.Tag == "" {
|
|
return fmt.Errorf("enter a tag to apply")
|
|
}
|
|
if r.Account != "" && !m.knownAccount(r.Account) {
|
|
return fmt.Errorf("no account called %q; leave it blank to apply to every account", r.Account)
|
|
}
|
|
|
|
if err := config.AppendRule(m.root, r); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Re-read the file rather than appending to the in-memory engine, so what
|
|
// runs is exactly what is now on disk.
|
|
loaded, err := config.LoadRules(m.root)
|
|
if err != nil {
|
|
return fmt.Errorf("rule saved, but re-reading rules.toml failed: %w", err)
|
|
}
|
|
m.engine = rules.New(loaded)
|
|
|
|
n, err := m.engine.Retag(m.db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
m.status = fmt.Sprintf("saved rule %s → %s, %d transactions retagged", r.Match, r.Tag, n)
|
|
m.ruleGlob.SetValue("")
|
|
m.ruleTag.SetValue("")
|
|
m.setRuleFocus(0)
|
|
return m.reload()
|
|
}
|
|
|
|
func (m *Model) knownAccount(slug string) bool {
|
|
for _, a := range m.accounts {
|
|
if a.Slug == slug {
|
|
return true
|
|
}
|
|
}
|
|
// Fall back to the index, so accounts work even when the caller passed no
|
|
// config (as the tests do).
|
|
accounts, err := m.db.Accounts()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, a := range accounts {
|
|
if a.Slug == slug {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// reloadRuleList counts, for every rule, how many transactions it actually
|
|
// claims. A rule can match nothing because no description fits it, or because
|
|
// an earlier rule already took everything it would have caught; both show up
|
|
// here as a zero.
|
|
func (m *Model) reloadRuleList() error {
|
|
txns, err := m.db.Transactions(store.Filter{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rs := m.engine.Rules()
|
|
m.ruleUsage = m.engine.Usage(txns)
|
|
|
|
rows := make([]table.Row, 0, len(rs))
|
|
for i, r := range rs {
|
|
marker := " "
|
|
if m.ruleUsage[i] == 0 {
|
|
marker = "✗"
|
|
}
|
|
transfer := ""
|
|
if r.Transfer {
|
|
transfer = "T"
|
|
}
|
|
account := r.Account
|
|
if account == "" {
|
|
account = "(all)"
|
|
}
|
|
rows = append(rows, table.Row{
|
|
fmt.Sprintf("%d", i+1), marker, rulePattern(r), account, r.Tag, transfer,
|
|
fmt.Sprintf("%d", m.ruleUsage[i]),
|
|
})
|
|
}
|
|
|
|
cursor := m.ruleListTable.Cursor()
|
|
m.ruleListTable.SetRows(rows)
|
|
if cursor >= len(rows) {
|
|
cursor = len(rows) - 1
|
|
}
|
|
if cursor < 0 {
|
|
cursor = 0
|
|
}
|
|
m.ruleListTable.SetCursor(cursor)
|
|
return nil
|
|
}
|
|
|
|
// rulePattern renders whichever patterns a rule sets, labelled so a
|
|
// counterparty or type rule is not mistaken for a description one.
|
|
func rulePattern(r config.Rule) string {
|
|
var parts []string
|
|
if r.Match != "" {
|
|
parts = append(parts, r.Match)
|
|
}
|
|
if r.Counterparty != "" {
|
|
parts = append(parts, "counterparty:"+r.Counterparty)
|
|
}
|
|
if r.Type != "" {
|
|
parts = append(parts, "type:"+r.Type)
|
|
}
|
|
return strings.Join(parts, " + ")
|
|
}
|
|
|
|
// unusedRules lists the positions of every rule claiming no transactions.
|
|
func (m *Model) unusedRules() []int {
|
|
var out []int
|
|
for i, n := range m.ruleUsage {
|
|
if n == 0 {
|
|
out = append(out, i)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// deleteRules removes rules from rules.toml, then reloads and retags so the
|
|
// counts on screen reflect the new file.
|
|
func (m *Model) deleteRules(positions []int) error {
|
|
n, err := config.DeleteRules(m.root, positions)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
loaded, err := config.LoadRules(m.root)
|
|
if err != nil {
|
|
return fmt.Errorf("rules deleted, but re-reading rules.toml failed: %w", err)
|
|
}
|
|
m.engine = rules.New(loaded)
|
|
|
|
retagged, err := m.engine.Retag(m.db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.status = fmt.Sprintf("deleted %d rule(s), %d transactions retagged", n, retagged)
|
|
|
|
if err := m.reload(); err != nil {
|
|
return err
|
|
}
|
|
return m.reloadRuleList()
|
|
}
|
|
|
|
// openRuleList switches to the rule list, remembering where to return to.
|
|
func (m *Model) openRuleList() {
|
|
if m.view != viewRuleList {
|
|
m.ruleReturn = m.view
|
|
}
|
|
m.view = viewRuleList
|
|
m.confirm = confirmNone
|
|
if err := m.reloadRuleList(); err != nil {
|
|
m.err = err
|
|
}
|
|
}
|
|
|
|
// updateRuleList drives the rule list, including the delete confirmations.
|
|
func (m *Model) updateRuleList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
if m.confirm != confirmNone {
|
|
pending := m.confirm
|
|
m.confirm = confirmNone
|
|
if msg.String() != "y" {
|
|
m.status = "cancelled"
|
|
return m, nil
|
|
}
|
|
m.err = nil
|
|
switch pending {
|
|
case confirmDeleteRule:
|
|
if i := m.ruleListTable.Cursor(); i >= 0 && i < len(m.ruleUsage) {
|
|
if err := m.deleteRules([]int{i}); err != nil {
|
|
m.err = err
|
|
}
|
|
}
|
|
case confirmPruneRules:
|
|
if err := m.deleteRules(m.unusedRules()); err != nil {
|
|
m.err = err
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "q", "ctrl+c":
|
|
return m, tea.Quit
|
|
case "esc":
|
|
m.view = m.ruleReturn
|
|
return m, nil
|
|
case "1":
|
|
m.view = viewAccounts
|
|
return m, nil
|
|
case "2":
|
|
m.view = viewTxns
|
|
return m, nil
|
|
case "3":
|
|
m.view = viewReport
|
|
return m, nil
|
|
case "4":
|
|
return m, m.openRuleBuilder()
|
|
|
|
case "d":
|
|
i := m.ruleListTable.Cursor()
|
|
rs := m.engine.Rules()
|
|
if i < 0 || i >= len(rs) {
|
|
return m, nil
|
|
}
|
|
m.confirm = confirmDeleteRule
|
|
m.status = fmt.Sprintf("delete rule %d (%s → %s), used by %d transactions? y/n",
|
|
i+1, rulePattern(rs[i]), rs[i].Tag, m.ruleUsage[i])
|
|
return m, nil
|
|
|
|
case "p":
|
|
unused := m.unusedRules()
|
|
if len(unused) == 0 {
|
|
m.status = "no unused rules to prune"
|
|
return m, nil
|
|
}
|
|
m.confirm = confirmPruneRules
|
|
m.status = fmt.Sprintf("delete all %d rules that match nothing? y/n", len(unused))
|
|
return m, nil
|
|
|
|
case "r":
|
|
m.err = m.reloadRuleList()
|
|
m.status = "counts refreshed"
|
|
return m, nil
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
m.ruleListTable, cmd = m.ruleListTable.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
// openRuleBuilder switches to the rule builder, remembering where to return to
|
|
// and seeding the account field from whatever account is being browsed.
|
|
func (m *Model) openRuleBuilder() tea.Cmd {
|
|
if m.view != viewRules {
|
|
m.ruleReturn = m.view
|
|
}
|
|
m.view = viewRules
|
|
if m.ruleAccount.Value() == "" && m.filter.AccountSlug != "" {
|
|
m.ruleAccount.SetValue(m.filter.AccountSlug)
|
|
}
|
|
m.setRuleFocus(0)
|
|
if err := m.reloadUntagged(); err != nil {
|
|
m.err = err
|
|
}
|
|
return textinput.Blink
|
|
}
|
|
|
|
// ruleInputs lists the form fields in tab order.
|
|
func (m *Model) ruleInputs() []*textinput.Model {
|
|
return []*textinput.Model{&m.ruleGlob, &m.ruleAccount, &m.ruleTag}
|
|
}
|
|
|
|
// setRuleFocus moves the cursor between the form fields, wrapping around.
|
|
func (m *Model) setRuleFocus(i int) {
|
|
inputs := m.ruleInputs()
|
|
n := len(inputs)
|
|
m.ruleFocus = ((i % n) + n) % n
|
|
for j, in := range inputs {
|
|
if j == m.ruleFocus {
|
|
in.Focus()
|
|
in.CursorEnd()
|
|
continue
|
|
}
|
|
in.Blur()
|
|
}
|
|
}
|
|
|
|
// selected returns the transaction under the cursor, if any.
|
|
func (m *Model) selected() (model.Transaction, bool) {
|
|
i := m.txnTable.Cursor()
|
|
if i < 0 || i >= len(m.txns) {
|
|
return model.Transaction{}, false
|
|
}
|
|
return m.txns[i], true
|
|
}
|
|
|
|
// importDoneMsg carries the outcome of a background import back to the model.
|
|
type importDoneMsg struct {
|
|
res importer.Result
|
|
err error
|
|
}
|
|
|
|
// importCmd runs the import off the event loop. Only values are captured, and
|
|
// the model is left untouched until the result comes back as a message.
|
|
func (m *Model) importCmd() tea.Cmd {
|
|
root, db, accounts, engine := m.root, m.db, m.accounts, m.engine
|
|
return func() tea.Msg {
|
|
res, err := importer.Run(root, db, accounts, engine, importer.Options{})
|
|
return importDoneMsg{res: res, err: err}
|
|
}
|
|
}
|
|
|
|
// Update implements tea.Model.
|
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width, m.height = msg.Width, msg.Height
|
|
m.resize()
|
|
return m, nil
|
|
|
|
case spinner.TickMsg:
|
|
if !m.importing {
|
|
return m, nil // a stale tick from a finished import
|
|
}
|
|
var cmd tea.Cmd
|
|
m.spinner, cmd = m.spinner.Update(msg)
|
|
return m, cmd
|
|
|
|
case importDoneMsg:
|
|
return m.finishImport(msg)
|
|
|
|
case tea.KeyMsg:
|
|
if m.input != inputNone {
|
|
return m.updateInput(msg)
|
|
}
|
|
// The rule builder is a form: every printable key belongs to the
|
|
// focused input, so the global single-letter keymap cannot apply.
|
|
if m.view == viewRules {
|
|
return m.updateRules(msg)
|
|
}
|
|
if m.view == viewRuleList {
|
|
return m.updateRuleList(msg)
|
|
}
|
|
return m.updateNormal(msg)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// updateRules drives the rule builder form.
|
|
func (m *Model) updateRules(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
return m, tea.Quit
|
|
case "esc":
|
|
m.view = m.ruleReturn
|
|
return m, nil
|
|
case "tab", "down":
|
|
m.setRuleFocus(m.ruleFocus + 1)
|
|
return m, nil
|
|
case "shift+tab", "up":
|
|
m.setRuleFocus(m.ruleFocus - 1)
|
|
return m, nil
|
|
case "pgdown":
|
|
m.ruleTable.MoveDown(10)
|
|
return m, nil
|
|
case "pgup":
|
|
m.ruleTable.MoveUp(10)
|
|
return m, nil
|
|
case "enter":
|
|
m.err = nil
|
|
if err := m.saveRule(); err != nil {
|
|
m.err = err
|
|
return m, nil
|
|
}
|
|
if err := m.reloadUntagged(); err != nil {
|
|
m.err = err
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
inputs := m.ruleInputs()
|
|
*inputs[m.ruleFocus], cmd = inputs[m.ruleFocus].Update(msg)
|
|
m.refreshRulePreview()
|
|
return m, cmd
|
|
}
|
|
|
|
func (m *Model) finishImport(msg importDoneMsg) (tea.Model, tea.Cmd) {
|
|
m.importing = false
|
|
if msg.err != nil {
|
|
m.err = msg.err
|
|
m.status = "import failed"
|
|
return m, nil
|
|
}
|
|
|
|
_, added, skipped := msg.res.Total()
|
|
m.status = fmt.Sprintf("imported: %d new, %d duplicate", added, skipped)
|
|
|
|
// A failed file and a warning are both worth surfacing, but the status
|
|
// line only has room for the first thing that went wrong.
|
|
if failures := msg.res.Errs(); len(failures) > 0 {
|
|
m.err = fmt.Errorf("%s: %w", failures[0].Path, failures[0].Err)
|
|
} else if warnings := firstWarning(msg.res); warnings != "" {
|
|
m.status += " · " + warnings
|
|
}
|
|
|
|
if err := m.reload(); err != nil {
|
|
m.err = err
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func firstWarning(res importer.Result) string {
|
|
total := 0
|
|
first := ""
|
|
for _, f := range res.Files {
|
|
for _, w := range f.Warnings {
|
|
if first == "" {
|
|
first = fmt.Sprintf("%s: %s", f.Path, w)
|
|
}
|
|
total++
|
|
}
|
|
}
|
|
if total > 1 {
|
|
return fmt.Sprintf("%s (+%d more warnings)", first, total-1)
|
|
}
|
|
return first
|
|
}
|
|
|
|
func (m *Model) resize() {
|
|
h := m.height - 6 // title, status, help, padding
|
|
if h < 3 {
|
|
h = 3
|
|
}
|
|
m.accountTable.SetHeight(h)
|
|
m.txnTable.SetHeight(h)
|
|
m.reportTable.SetHeight(h)
|
|
m.ruleTable.SetHeight(h)
|
|
m.ruleListTable.SetHeight(h)
|
|
|
|
// The preview list gets whatever the form does not use.
|
|
if m.width > 0 {
|
|
cols := m.ruleTable.Columns()
|
|
desc := m.width - ruleFormWidth - cols[0].Width - cols[2].Width - 10
|
|
if desc < 20 {
|
|
desc = 20
|
|
}
|
|
cols[1].Width = desc
|
|
m.ruleTable.SetColumns(cols)
|
|
}
|
|
|
|
// Give the description column whatever is left over.
|
|
if m.width > 0 {
|
|
cols := m.txnTable.Columns()
|
|
fixed := 0
|
|
for _, c := range cols[:len(cols)-1] {
|
|
fixed += c.Width + 2
|
|
}
|
|
desc := m.width - fixed - 4
|
|
if desc < 20 {
|
|
desc = 20
|
|
}
|
|
cols[len(cols)-1].Width = desc
|
|
m.txnTable.SetColumns(cols)
|
|
}
|
|
}
|
|
|
|
// updateInput handles typing into the tag or search prompt.
|
|
func (m *Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.Type {
|
|
case tea.KeyEsc:
|
|
m.input = inputNone
|
|
m.text.Blur()
|
|
m.text.SetValue("")
|
|
return m, nil
|
|
case tea.KeyEnter:
|
|
value := strings.TrimSpace(m.text.Value())
|
|
mode := m.input
|
|
m.input = inputNone
|
|
m.text.Blur()
|
|
m.text.SetValue("")
|
|
|
|
switch mode {
|
|
case inputTag:
|
|
t, ok := m.selected()
|
|
if !ok {
|
|
return m, nil
|
|
}
|
|
if err := m.db.SetManualTag(t.ID, value); err != nil {
|
|
m.err = err
|
|
return m, nil
|
|
}
|
|
if value == "" {
|
|
m.status = "cleared manual tag"
|
|
} else {
|
|
m.status = "tagged as " + value
|
|
}
|
|
case inputSearch:
|
|
m.filter.Search = value
|
|
m.status = "search: " + value
|
|
if value == "" {
|
|
m.status = "search cleared"
|
|
}
|
|
}
|
|
m.err = m.reloadTxns()
|
|
return m, nil
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
m.text, cmd = m.text.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
// updateNormal handles the main keymap.
|
|
func (m *Model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "q", "ctrl+c":
|
|
return m, tea.Quit
|
|
|
|
case "1":
|
|
m.view = viewAccounts
|
|
return m, nil
|
|
case "2":
|
|
m.view = viewTxns
|
|
return m, nil
|
|
case "3":
|
|
m.view = viewReport
|
|
return m, nil
|
|
case "4":
|
|
return m, m.openRuleBuilder()
|
|
case "5":
|
|
m.openRuleList()
|
|
return m, nil
|
|
case "tab":
|
|
m.view = (m.view + 1) % viewCount
|
|
switch m.view {
|
|
case viewRules:
|
|
return m, m.openRuleBuilder()
|
|
case viewRuleList:
|
|
m.openRuleList()
|
|
}
|
|
return m, nil
|
|
|
|
case "enter":
|
|
// From the account list, drill into that account's transactions.
|
|
if m.view == viewAccounts {
|
|
accounts, err := m.db.Accounts()
|
|
if err != nil {
|
|
m.err = err
|
|
return m, nil
|
|
}
|
|
i := m.accountTable.Cursor()
|
|
if i >= 0 && i < len(accounts) {
|
|
m.filter.AccountSlug = accounts[i].Slug
|
|
m.status = "account: " + accounts[i].Slug
|
|
m.view = viewTxns
|
|
m.err = m.reloadTxns()
|
|
}
|
|
}
|
|
return m, nil
|
|
|
|
case "a":
|
|
m.filter.AccountSlug = ""
|
|
m.status = "all accounts"
|
|
m.err = m.reloadTxns()
|
|
return m, nil
|
|
|
|
case "u":
|
|
m.onlyUntagged = !m.onlyUntagged
|
|
if m.onlyUntagged {
|
|
m.status = "showing untagged only"
|
|
m.view = viewTxns
|
|
} else {
|
|
m.status = "showing all transactions"
|
|
}
|
|
m.err = m.reloadTxns()
|
|
return m, nil
|
|
|
|
case "t":
|
|
if m.view != viewTxns {
|
|
return m, nil
|
|
}
|
|
t, ok := m.selected()
|
|
if !ok {
|
|
return m, nil
|
|
}
|
|
m.input = inputTag
|
|
m.text.SetValue(t.ManualTag)
|
|
m.text.CursorEnd()
|
|
m.text.Focus()
|
|
return m, nil
|
|
|
|
case "/":
|
|
m.view = viewTxns
|
|
m.input = inputSearch
|
|
m.text.SetValue(m.filter.Search)
|
|
m.text.CursorEnd()
|
|
m.text.Focus()
|
|
return m, nil
|
|
|
|
case "x":
|
|
// Toggle the manual transfer override away from whatever is in effect.
|
|
if m.view != viewTxns {
|
|
return m, nil
|
|
}
|
|
t, ok := m.selected()
|
|
if !ok {
|
|
return m, nil
|
|
}
|
|
v := !t.IsTransfer()
|
|
if err := m.db.SetManualTransfer(t.ID, &v); err != nil {
|
|
m.err = err
|
|
return m, nil
|
|
}
|
|
if v {
|
|
m.status = "marked as transfer"
|
|
} else {
|
|
m.status = "unmarked as transfer"
|
|
}
|
|
m.err = m.reloadTxns()
|
|
return m, nil
|
|
|
|
case "c":
|
|
// Drop manual overrides and fall back to what the rules say.
|
|
if m.view != viewTxns {
|
|
return m, nil
|
|
}
|
|
t, ok := m.selected()
|
|
if !ok {
|
|
return m, nil
|
|
}
|
|
if err := m.db.ClearOverrides(t.ID); err != nil {
|
|
m.err = err
|
|
return m, nil
|
|
}
|
|
m.status = "manual overrides cleared"
|
|
m.err = m.reloadTxns()
|
|
return m, nil
|
|
|
|
case "r":
|
|
if m.importing {
|
|
m.status = "import in progress…"
|
|
return m, nil
|
|
}
|
|
n, err := m.engine.Retag(m.db)
|
|
if err != nil {
|
|
m.err = err
|
|
return m, nil
|
|
}
|
|
m.status = fmt.Sprintf("rules re-applied, %d rows changed", n)
|
|
m.err = m.reload()
|
|
return m, nil
|
|
|
|
case "i":
|
|
if m.importing {
|
|
return m, nil // already running; ignore the repeat press
|
|
}
|
|
m.importing = true
|
|
m.err = nil
|
|
m.status = ""
|
|
return m, tea.Batch(m.spinner.Tick, m.importCmd())
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
switch m.view {
|
|
case viewAccounts:
|
|
m.accountTable, cmd = m.accountTable.Update(msg)
|
|
case viewTxns:
|
|
m.txnTable, cmd = m.txnTable.Update(msg)
|
|
case viewReport:
|
|
m.reportTable, cmd = m.reportTable.Update(msg)
|
|
}
|
|
return m, cmd
|
|
}
|
|
|
|
// View implements tea.Model.
|
|
func (m *Model) View() string {
|
|
var b strings.Builder
|
|
|
|
b.WriteString(titleStyle.Render(m.title()))
|
|
b.WriteString("\n")
|
|
|
|
// An empty table is just a bare header, which says nothing about why it is
|
|
// empty or what to do next.
|
|
if empty := m.emptyMessage(); empty != "" {
|
|
b.WriteString(emptyStyle.Render(empty))
|
|
} else {
|
|
switch m.view {
|
|
case viewAccounts:
|
|
b.WriteString(m.accountTable.View())
|
|
case viewTxns:
|
|
b.WriteString(m.txnTable.View())
|
|
case viewReport:
|
|
b.WriteString(m.reportTable.View())
|
|
case viewRules:
|
|
b.WriteString(m.rulesView())
|
|
case viewRuleList:
|
|
b.WriteString(m.ruleListTable.View())
|
|
}
|
|
}
|
|
b.WriteString("\n")
|
|
|
|
switch {
|
|
case m.importing:
|
|
b.WriteString(statusStyle.Render(m.spinner.View() + m.importingLabel()))
|
|
case m.input == inputTag:
|
|
b.WriteString(statusStyle.Render("tag: ") + m.text.View())
|
|
case m.input == inputSearch:
|
|
b.WriteString(statusStyle.Render("search: ") + m.text.View())
|
|
case m.err != nil:
|
|
b.WriteString(errStyle.Render("error: " + m.err.Error()))
|
|
default:
|
|
b.WriteString(statusStyle.Render(m.status))
|
|
}
|
|
b.WriteString("\n")
|
|
b.WriteString(helpStyle.Render(m.help()))
|
|
return b.String()
|
|
}
|
|
|
|
// rulesView puts the form on the left and the live preview on the right.
|
|
func (m *Model) rulesView() string {
|
|
return lipgloss.JoinHorizontal(lipgloss.Top, m.ruleFormView(), m.ruleTable.View())
|
|
}
|
|
|
|
func (m *Model) ruleFormView() string {
|
|
field := func(i int, label, help string) string {
|
|
name := labelStyle.Render(" " + label)
|
|
box := boxStyle.Render(m.ruleInputs()[i].View())
|
|
if i == m.ruleFocus {
|
|
name = focusedLabelStyle.Render("▸ " + label)
|
|
box = focusedBoxStyle.Render(m.ruleInputs()[i].View())
|
|
}
|
|
return name + "\n" + box + "\n" + hintStyle.Render(help) + "\n"
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString(field(0, "glob", "vs. the description"))
|
|
b.WriteString("\n")
|
|
b.WriteString(field(1, "account", "blank = all accounts"))
|
|
b.WriteString("\n")
|
|
b.WriteString(field(2, "tag", "applied to matches"))
|
|
b.WriteString("\n")
|
|
|
|
// The count is the whole point of the preview: it says what the rule will
|
|
// do before it is written to disk.
|
|
summary := fmt.Sprintf("%d of %d descriptions match", m.ruleMatches, len(m.ruleTable.Rows()))
|
|
if strings.TrimSpace(m.ruleGlob.Value()) == "" {
|
|
summary = fmt.Sprintf("%d untagged descriptions", len(m.ruleTable.Rows()))
|
|
}
|
|
b.WriteString(matchCountStyle.Render(summary))
|
|
|
|
return ruleFormStyle.Render(b.String())
|
|
}
|
|
|
|
// emptyMessage explains an empty view and names the key that fixes it, or
|
|
// returns "" when there is something to show.
|
|
func (m *Model) emptyMessage() string {
|
|
switch m.view {
|
|
case viewAccounts:
|
|
if len(m.accountTable.Rows()) > 0 {
|
|
return ""
|
|
}
|
|
// An account.toml on disk is not enough: folders are only registered
|
|
// in the index by an import, so say that rather than showing nothing.
|
|
if n := len(m.accounts); n > 0 {
|
|
return fmt.Sprintf(
|
|
"No accounts imported yet.\n\n"+
|
|
"%d account folder(s) configured in %s.\n"+
|
|
"Press i to import their statements (same as running `money import`).",
|
|
n, m.root)
|
|
}
|
|
return fmt.Sprintf(
|
|
"No account folders found in %s.\n\n"+
|
|
"An account is a folder containing an account.toml.\n"+
|
|
"Create one, drop statements in, then press i to import.",
|
|
m.root)
|
|
|
|
case viewTxns:
|
|
if len(m.txns) > 0 {
|
|
return ""
|
|
}
|
|
switch {
|
|
case m.filter.Search != "":
|
|
return fmt.Sprintf("No transactions match /%s.\n\nPress / to change the search.", m.filter.Search)
|
|
case m.onlyUntagged:
|
|
return "Nothing untagged.\n\nEvery transaction here has a tag. Press u to see them all."
|
|
case m.filter.AccountSlug != "":
|
|
return fmt.Sprintf(
|
|
"No transactions in %s.\n\nPress i to import, or a to see every account.",
|
|
m.filter.AccountSlug)
|
|
}
|
|
return "No transactions yet.\n\nPress i to import (same as running `money import`)."
|
|
|
|
case viewReport:
|
|
if len(m.reportTable.Rows()) > 0 {
|
|
return ""
|
|
}
|
|
return "Nothing to report yet.\n\nPress i to import some statements first."
|
|
|
|
case viewRuleList:
|
|
if len(m.ruleListTable.Rows()) > 0 {
|
|
return ""
|
|
}
|
|
return "No rules yet.\n\nPress 4 to build one, or write rules.toml by hand."
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (m *Model) title() string {
|
|
scope := "all accounts"
|
|
if m.filter.AccountSlug != "" {
|
|
scope = m.filter.AccountSlug
|
|
}
|
|
if m.onlyUntagged {
|
|
scope += " · untagged"
|
|
}
|
|
if m.filter.Search != "" {
|
|
scope += " · /" + m.filter.Search
|
|
}
|
|
|
|
switch m.view {
|
|
case viewAccounts:
|
|
return "money · accounts"
|
|
case viewRules:
|
|
return "money · rule builder · writes to rules.toml"
|
|
case viewRuleList:
|
|
unused := len(m.unusedRules())
|
|
if unused == 0 {
|
|
return fmt.Sprintf("money · rules · %d rules, all in use", len(m.engine.Rules()))
|
|
}
|
|
return fmt.Sprintf("money · rules · %d rules · %d match nothing", len(m.engine.Rules()), unused)
|
|
case viewReport:
|
|
return "money · report · " + scope + " · transfers excluded"
|
|
default:
|
|
return fmt.Sprintf("money · transactions · %s · %d rows", scope, len(m.txns))
|
|
}
|
|
}
|
|
|
|
// importingLabel names what the import is working through, since extracting
|
|
// text from PDFs is where the wait actually comes from.
|
|
func (m *Model) importingLabel() string {
|
|
if n := len(m.accounts); n > 0 {
|
|
return fmt.Sprintf("importing %d account(s)… parsing statements can take a while for PDFs", n)
|
|
}
|
|
return "importing…"
|
|
}
|
|
|
|
func (m *Model) help() string {
|
|
if m.importing {
|
|
return "importing… · q quit"
|
|
}
|
|
if m.input != inputNone {
|
|
return "enter confirm · esc cancel"
|
|
}
|
|
switch m.view {
|
|
case viewAccounts:
|
|
return "enter open · 2 transactions · 3 report · 4 rules · i import · r retag · q quit"
|
|
case viewRules:
|
|
return "tab/↑↓ field · pgup/pgdn scroll list · enter save rule · esc back · ctrl+c quit"
|
|
case viewRuleList:
|
|
if m.confirm != confirmNone {
|
|
return "y confirm · any other key cancels"
|
|
}
|
|
return "d delete rule · p prune all unused · r refresh counts · 4 rule builder · esc back · q quit"
|
|
case viewReport:
|
|
return "1 accounts · 2 transactions · 4 rules · u untagged · a all accounts · q quit"
|
|
default:
|
|
return "t tag · x transfer · c clear · / search · u untagged · a all · i import · r retag · 4 rules · 1 accounts · 3 report · q quit"
|
|
}
|
|
}
|