// 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" } }