// Package tui is the terminal interface: browse accounts, write the rules that // tag transactions, and see where the money went. package tui import ( "fmt" "slices" "sort" "strconv" "strings" "time" "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" "git.petrovv.com/nikola/money/internal/transfers" ) type view int const ( viewAccounts view = iota viewTxns viewReport viewRules viewRuleList viewTransfers viewTransferList viewCount = 7 ) // 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 confirmDeleteTransfer confirmPruneTransfers ) // previewSort is the order the rule builder lists descriptions in. The two // answer different questions, which is why both are here rather than one being // chosen for the user: by name is how a payee you are looking at is found, and // by count is where the rule worth writing next is — one pattern that claims // forty rows, rather than the first of forty patterns claiming one. type previewSort int const ( sortByName previewSort = iota sortByCount ) // input is the modal state: the transaction list is read-only until the user // starts typing a search. type input int const ( inputNone input = iota inputSearch ) // Model is the root Bubble Tea model. type Model struct { root string db *store.DB accounts []*config.Account engine *rules.Engine links *transfers.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: four inputs on the left, and a live preview on the right of // which descriptions the glob would catch. ruleGlob textinput.Model ruleAccount textinput.Model ruleTag textinput.Model ruleNote textinput.Model ruleFocus int // which of the inputs has the cursor ruleTable table.Model ruleReturn view // the view to go back to on esc previewGroups []descGroup previewSort previewSort ruleMatches int // previewed descriptions the current glob matches // ruleCandidates is how many were in view before the glob filtered them, // which the count needs: the preview now shows only matches, so the rows on // screen can no longer say what they were chosen out of. ruleCandidates int // ruleDropped counts the descriptions an edit would let go of. It is only // ever non-zero while editing: a new rule has nothing to lose. ruleDropped int // Editing an existing rule reuses the builder rather than a second form: // the fields start filled in, and enter rewrites the rule where it sits // instead of appending. ruleEditPos is its file position, which the edit // must not change — that is what still breaks ties between equally specific // rules. ruleEditOrig is the rule as it was, so what the form does not show // (the type pattern) survives the round trip instead of being dropped. ruleEditing bool ruleEditPos int ruleEditOrig config.Rule // 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 ruleListReturn view // the view to go back to on esc confirm confirmation // Transfer builder: the two sides of a movement on the left, and on the // right the pairs it would form out of what is already imported, together // with the legs it would catch but leave unpaired. transferFrom textinput.Model transferFromDesc textinput.Model transferTo textinput.Model transferToDesc textinput.Model transferTolerance textinput.Model transferNote textinput.Model transferFocus int transferTable table.Model transferReturn view // allTxns is every transaction in the index, held so the preview can pair // across accounts without going back to the database on each keystroke. allTxns []model.Transaction previewPairs int previewUnmatched int // previewFees is what the draft's pairs would lose to fees. Every leaving // leg is on one account, so there is a single currency to render it in. previewFees int64 previewFeeDigits int // Transfer list: every definition with what it currently pairs. transferListTable table.Model transferResult transfers.Result transferListReturn view // the view to go back to on esc filter store.Filter onlyUntagged bool // The report's time axis: the windows ←/→ steps along, and which one it is // on. It is built from the whole index rather than from the rows on screen, // so changing account or search does not move the axis under the cursor. reportPeriods []report.Period reportPeriod int // reportOrder is how the breakdown is arranged. It is not part of the // filter: it changes the order of the rows, never which rows there are. reportOrder report.Order // indexEmpty is whether the index holds no transactions at all, which the // report's empty state has to tell apart from a period that happens to be // empty: one asks for an import, the other for another period. indexEmpty 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) // flatBoxStyle is the same field with its border dropped, for a form too // tall for the terminal. The padding puts the value in the same column the // border's own padding would, so the fields still line up beside a boxed one. flatBoxStyle = lipgloss.NewStyle().PaddingLeft(2).Width(ruleInputWidth + 2) hintStyle = lipgloss.NewStyle().Faint(true).PaddingLeft(2) matchCountStyle = lipgloss.NewStyle().Bold(true).PaddingLeft(2) ruleFormStyle = lipgloss.NewStyle().Width(ruleFormWidth) // The report's time axis. The header is bold and indented like the table's // own header beside it, so the two panels read as one grid. periodHeaderStyle = lipgloss.NewStyle().Bold(true).PaddingLeft(2).Width(periodListWidth) periodStyle = lipgloss.NewStyle().Faint(true).Width(periodListWidth) periodSelectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("62")).Width(periodListWidth) ) // Run starts the interface. func Run(root string, db *store.DB, accounts []*config.Account, engine *rules.Engine, links *transfers.Engine) error { m := New(root, db, accounts, engine, links) 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, links *transfers.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 } // Account slugs and tags are typed over and over, and a typo in either // silently produces a rule that catches nothing, so both fields complete // against what already exists. completing := func(in textinput.Model) textinput.Model { in.ShowSuggestions = true 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)) } m := &Model{ root: root, db: db, accounts: accounts, engine: engine, links: links, 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: "Description", Width: 40}, }), ruleGlob: newInput("*LIDL*"), ruleAccount: completing(newInput("blank = every account")), ruleTag: completing(newInput("groceries")), ruleNote: newInput("optional"), 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: "Txns", Width: 6}, {Title: "Note", Width: 24}, }), transferFrom: completing(newInput("nlb")), transferFromDesc: newInput("*TO REVOLUT*"), transferTo: completing(newInput("revolut")), transferToDesc: newInput("*FROM NLB*"), transferTolerance: newInput("0"), transferNote: newInput("optional"), transferTable: newTable([]table.Column{ {Title: " ", Width: 1}, {Title: "Date", Width: 10}, // Wide enough for both sides of an exchange, e.g. "500.00 → 977.90". {Title: "Amount", Width: 20}, {Title: "Movement", Width: 22}, {Title: "Description", Width: 30}, }), transferListTable: newTable([]table.Column{ {Title: "#", Width: 3}, {Title: " ", Width: 1}, {Title: "From", Width: 26}, {Title: "To", Width: 26}, {Title: "Pairs", Width: 6}, {Title: "Unpaired", Width: 9}, {Title: "Tol", Width: 6}, {Title: "Note", Width: 20}, }), reportTable: newTable(slices.Clone(reportColumns)), } // The default order needs its marker as much as a chosen one does. m.markSortedColumn() return m } // reportColumns are the report's headings before the sort marker is added. // They live here rather than being written into the table once, because // markSortedColumn rebuilds them every time the order changes. var reportColumns = []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}, } // markSortedColumn moves the marker onto the heading the rows are ordered by, // so the sort is visible where it is acting rather than only in the key list. func (m *Model) markSortedColumn() { marker := " ▾" if m.reportOrder.Ascending() { marker = " ▴" } cols := slices.Clone(reportColumns) for i := range cols { if cols[i].Title == m.reportOrder.Column() { cols[i].Title += marker break } } m.reportTable.SetColumns(cols) } // setOrder cycles the report to the next arrangement of the same rows. func (m *Model) setOrder(o report.Order) { m.reportOrder = o m.markSortedColumn() m.status = "sort: " + o.Label() m.reportTable.SetCursor(0) m.err = m.reloadReport() } // 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 } if err := m.reloadPeriods(); err != nil { return err } return m.reloadTxns() } // reloadPeriods rebuilds the report's time axis from the months the index now // holds, staying on the window the user was looking at if it is still there. // An import that reaches further back grows the axis, so it cannot simply be // built once at startup; but it must not throw the user back to the default // either, since the import they just ran is usually the reason they are looking. func (m *Model) reloadPeriods() error { months, err := m.db.Months() if err != nil { return err } var was string if p, ok := m.currentPeriod(); ok { was = p.Label } m.indexEmpty = len(months) == 0 m.reportPeriods = report.Periods(time.Now(), months) m.reportPeriod = report.DefaultIndex(m.reportPeriods) for i, p := range m.reportPeriods { if p.Label == was { m.reportPeriod = i break } } return nil } // currentPeriod is the window the report is on, if the axis has been built. func (m *Model) currentPeriod() (report.Period, bool) { if m.reportPeriod < 0 || m.reportPeriod >= len(m.reportPeriods) { return report.Period{}, false } return m.reportPeriods[m.reportPeriod], true } // setPeriod moves the report along its axis, clamping at both ends: the axis // runs from all time down to the oldest month, and wrapping round from one to // the other would land somewhere nobody aimed for. func (m *Model) setPeriod(i int) { if i < 0 || i >= len(m.reportPeriods) || i == m.reportPeriod { return } m.reportPeriod = i m.status = "period: " + m.reportPeriods[i].String() m.reportTable.SetCursor(0) m.err = m.reloadReport() } 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 { rows = append(rows, table.Row{ t.Date, t.AccountSlug, t.FormatAmount(), t.DisplayTag(), t.Description, }) } // Keep the cursor in range after the row count shrinks (e.g. a new rule // 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) return m.reloadReport() } // The two rows the report grows below TOTAL. They are bracketed like // report.Untagged and model.TransferTag because they are the same kind of // thing: a label the tool supplies for something no rule named. Neither is a // tag, and neither can be matched or completed against. const ( transfersRow = "(transfers)" feesRow = " ⤷ fees" ) // reloadReport re-queries under the report's own time window. // // It cannot reuse the rows the transaction list is showing: the period narrows // the report and only the report, so that opening on last month does not also // hide the rest of the index from the list beside it. Everything else about the // scope — the account, the search, the untagged toggle — is shared, so the two // screens still answer for the same selection of transactions. func (m *Model) reloadReport() error { f := m.filter f.Untagged = m.onlyUntagged if p, ok := m.currentPeriod(); ok { f.From, f.To = p.From, p.To } txns, err := m.db.Transactions(f) if err != nil { return err } rows := report.ByTag(txns, m.reportOrder) 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), "", }) } // Below the total, what ByTag held out — otherwise the report quietly // disagrees with the account balances by the amount moved between accounts, // and by any fee a tolerant definition swallowed along with it. for _, x := range report.Excluded(txns) { out = append(out, table.Row{ transfersRow, x.Currency, model.FormatMinor(x.Out, x.Digits), model.FormatMinor(x.In, x.Digits), model.FormatMinor(x.In-x.Out, x.Digits), fmt.Sprintf("%d", x.Legs), }) if x.Fee != 0 { out = append(out, table.Row{ feesRow, x.Currency, model.FormatMinor(x.Fee, x.Digits), "", model.FormatMinor(-x.Fee, x.Digits), fmt.Sprintf("%d", x.Pairs), }) } } m.reportTable.SetRows(out) return nil } // 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 description the builder previews against, 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 // Claimed marks a description the rule being edited currently tags. Nothing // sets it for a new rule, where every candidate is untagged by definition. Claimed bool } // reloadPreviewGroups rebuilds the alphabetical list of descriptions the rule // builder previews against: everything still waiting for a rule, plus — when a // rule is being edited — what that rule already claims. // // The addition is what makes editing legible. Those rows are tagged, so none of // them is in the untagged list, and the preview for a rule that works perfectly // would otherwise be empty. They are also exactly what an edit is judged // against: narrowing a glob is a decision about which of them to let go. func (m *Model) reloadPreviewGroups() error { txns, err := m.db.Transactions(store.Filter{Untagged: true}) if err != nil { return err } claimedFrom := len(txns) // everything appended below is claimed by the edited rule if m.ruleEditing { seen := make(map[int64]bool, len(txns)) for _, t := range txns { seen[t.ID] = true } all, err := m.db.Transactions(store.Filter{}) if err != nil { return err } for _, t := range all { // An index written before rules.toml last changed can hold a row // that is untagged on disk and claimed by the engine in memory; // counting it twice would overstate the group. if seen[t.ID] || m.engine.MatchIndex(t.AccountSlug, t) != m.ruleEditPos { continue } txns = append(txns, t) } } byDesc := map[string]*descGroup{} for i, 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++ g.Claimed = g.Claimed || i >= claimedFrom } m.previewGroups = make([]descGroup, 0, len(byDesc)) for _, g := range byDesc { m.previewGroups = append(m.previewGroups, *g) } m.sortPreviewGroups() return nil } // sortPreviewGroups puts the preview in the order the user last chose, marks // the column it is sorted by, and re-renders it. func (m *Model) sortPreviewGroups() { sort.Slice(m.previewGroups, func(i, j int) bool { a, b := m.previewGroups[i], m.previewGroups[j] // Count first when asked, but ties still fall back to the alphabetical // order rather than to map order, or the list would reshuffle under the // cursor every time it is rebuilt. if m.previewSort == sortByCount && a.Count != b.Count { return a.Count > b.Count } x, y := model.NormalizeDescription(a.Description), model.NormalizeDescription(b.Description) if x != y { return x < y } return a.Description < b.Description }) // Each column says what is in it. The description changes with the mode — // an edit's rows are on screen precisely because they are *not* waiting for // a rule — and the ↓ names the column the order is read from, since two // sorts of one list are otherwise told apart only by squinting at it. cols := m.ruleTable.Columns() cols[1].Title = "Untagged description" if m.ruleEditing { cols[1].Title = "Description" } cols[2].Title = "N" if m.previewSort == sortByCount { cols[2].Title += " ↓" } else { cols[1].Title += " ↓" } m.ruleTable.SetColumns(cols) m.refreshRulePreview() } // refreshRulePreview re-filters 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. // // Once a glob is typed the list is exactly what the rule would claim: the // non-matching rows go, rather than staying on as unmarked context. With the // glob still empty there is nothing to filter by, so the list is everything // still waiting for a rule — which is the other question this screen answers. // Either way the count beside the form is measured against everything in view, // so a glob that has narrowed the list to three of forty still says so. // // A description the rule being edited currently tags is the one exception to // the rows going: losing one is the thing worth seeing before saving, so it // stays on screen marked “−” instead of vanishing silently with the rest. func (m *Model) refreshRulePreview() { var ( pattern = strings.TrimSpace(m.ruleGlob.Value()) account = strings.TrimSpace(m.ruleAccount.Value()) rows = make([]table.Row, 0, len(m.previewGroups)) ) m.ruleMatches, m.ruleCandidates, m.ruleDropped = 0, 0, 0 for _, g := range m.previewGroups { // An account filter narrows the preview the same way the saved rule // will narrow its matching. inAccount := account == "" || g.Accounts[account] if inAccount { m.ruleCandidates++ } matched := inAccount && (pattern == "" || glob.Match(pattern, model.NormalizeDescription(g.Description))) marker := " " switch { case matched && pattern != "": marker = "▸" m.ruleMatches++ case matched: // No glob yet, so the row is context rather than an answer. case g.Claimed: marker = "−" m.ruleDropped++ default: continue } 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 writes the composed rule to rules.toml — appending it, or rewriting // the rule being edited in place — then 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()), Note: strings.TrimSpace(m.ruleNote.Value()), } if m.ruleEditing { // The form has no type field, so an edit carries the rule's type // through untouched rather than quietly dropping a pattern it never // showed the user. It is also why a type rule can have no glob at all. r.Type = m.ruleEditOrig.Type } if r.Match == "" && r.Type == "" { 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 m.ruleEditing { if err := config.ReplaceRule(m.root, m.ruleEditPos, r); err != nil { return err } } else if err := config.AppendRule(m.root, r); err != nil { return err } if err := m.reloadConfig(); err != nil { return fmt.Errorf("rule saved, but re-reading rules.toml failed: %w", err) } n, err := m.engine.Retag(m.db) if err != nil { return err } if m.ruleEditing { m.status = fmt.Sprintf("rule %d is now %s → %s, %d transactions retagged", m.ruleEditPos+1, rulePattern(r), r.Tag, n) // The account came from the rule rather than from the user, so it goes // with the rest; an appended rule keeps it, since the next one written // is usually for the same account. m.ruleEditing = false m.ruleEditOrig = config.Rule{} m.ruleAccount.SetValue("") } else { m.status = fmt.Sprintf("saved rule %s → %s, %d transactions retagged", r.Match, r.Tag, n) } m.ruleGlob.SetValue("") m.ruleTag.SetValue("") m.ruleNote.SetValue("") m.setRuleFocus(0) if err := m.reloadSuggestions(); err != nil { return err } return m.reload() } // reloadConfig re-reads rules.toml and rebuilds both engines from it, rather // than patching the in-memory ones, so what runs is exactly what is now on // disk. Both are rebuilt together because both come out of the same file. func (m *Model) reloadConfig() error { loaded, err := config.LoadRules(m.root) if err != nil { return err } m.engine = rules.New(loaded) m.links = transfers.New(loaded) return nil } func (m *Model) knownAccount(slug string) bool { return slices.Contains(m.accountSlugs(), slug) } // accountSlugs lists every account slug worth completing: those configured on // disk, plus those the index already holds so accounts still resolve when the // caller passed no config (as the tests do). func (m *Model) accountSlugs() []string { configured := make([]string, 0, len(m.accounts)) for _, a := range m.accounts { configured = append(configured, a.Slug) } var imported []string if accounts, err := m.db.Accounts(); err == nil { for _, a := range accounts { imported = append(imported, a.Slug) } } return sortedSet(configured, imported) } // knownTags lists the tags in use in the index plus those only named by a rule. // A rule that has not caught anything yet is exactly where a tag lives before // it appears on a transaction, and those are the ones most likely to be // mistyped into a second, near-identical tag. func (m *Model) knownTags() ([]string, error) { tagged, err := m.db.Tags() if err != nil { return nil, err } rs := m.engine.Rules() fromRules := make([]string, 0, len(rs)) for _, r := range rs { fromRules = append(fromRules, r.Tag) } return sortedSet(tagged, fromRules), nil } // sortedSet merges the groups into one sorted list with blanks and duplicates // removed. Completion cycles in this order, so it has to be stable. func sortedSet(groups ...[]string) []string { seen := map[string]bool{} var out []string for _, g := range groups { for _, v := range g { if v == "" || seen[v] { continue } seen[v] = true out = append(out, v) } } sort.Strings(out) return out } // reloadSuggestions refreshes what the account and tag fields complete against. // It runs whenever the builder is opened and after every save, since saving a // rule can introduce a tag that did not exist a moment ago. func (m *Model) reloadSuggestions() error { m.ruleAccount.SetSuggestions(m.accountSlugs()) tags, err := m.knownTags() if err != nil { return err } m.ruleTag.SetSuggestions(tags) return nil } // pendingCompletion returns the suggestion an input is currently offering, or // "" when there is nothing left to accept. Matching is case-insensitive, so a // suggestion differing only in case is not worth rewriting what was typed. func pendingCompletion(in *textinput.Model) string { s := in.CurrentSuggestion() if s == "" || strings.EqualFold(s, in.Value()) { return "" } return s } // acceptCompletion takes the offered completion into the field, reporting // whether there was one. The suggestion list is re-set afterwards because // SetValue does not re-match it, which would otherwise leave ctrl+n cycling // through candidates that no longer share the new prefix. func acceptCompletion(in *textinput.Model) bool { s := pendingCompletion(in) if s == "" { return false } in.SetValue(s) in.CursorEnd() in.SetSuggestions(in.AvailableSuggestions()) return true } // reloadRuleList counts, for every rule, how many transactions it actually // claims. A rule can match nothing because no description fits it, or because // a more specific rule already took everything it would have caught; both show // up here as a zero. The rows stay in file order — that is the number the user // can find in rules.toml, and precedence is not read off it any more. 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 = "✗" } account := r.Account if account == "" { account = "(all)" } rows = append(rows, table.Row{ fmt.Sprintf("%d", i+1), marker, rulePattern(r), account, r.Tag, fmt.Sprintf("%d", m.ruleUsage[i]), r.Note, }) } 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 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.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 } if err := m.reloadConfig(); err != nil { return fmt.Errorf("rules deleted, but re-reading rules.toml failed: %w", err) } 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.view != viewRules { m.ruleListReturn = m.view } m.showRuleList() } // showRuleList switches to the rule list without recording where esc goes. // Coming back from the builder must not record it as the way out, or esc from // the list would bounce back into the form the user just left. func (m *Model) showRuleList() { 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.ruleListReturn 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 "6": return m, m.openTransferBuilder() case "7": m.openTransferList() return m, nil case "e": return m, m.openRuleEditor(m.ruleListTable.Cursor()) 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 on a new rule, seeding the // account field from whatever account is being browsed. func (m *Model) openRuleBuilder() tea.Cmd { // Leaving an edit, the fields still hold that rule. A new rule starting out // as a copy of the one just edited would be saved as a second, almost // identical rule, so the form is emptied on the way out of edit mode. if m.ruleEditing { m.ruleEditing = false m.ruleEditOrig = config.Rule{} for _, in := range m.ruleInputs() { in.SetValue("") } } if m.ruleAccount.Value() == "" && m.filter.AccountSlug != "" { m.ruleAccount.SetValue(m.filter.AccountSlug) } return m.showRuleBuilder() } // openRuleEditor switches to the rule builder on the rule at a file position: // the fields start filled in from it, and saving rewrites it where it sits // rather than appending a near-duplicate. func (m *Model) openRuleEditor(pos int) tea.Cmd { rs := m.engine.Rules() if pos < 0 || pos >= len(rs) { return nil } r := rs[pos] m.ruleEditing, m.ruleEditPos, m.ruleEditOrig = true, pos, r m.ruleGlob.SetValue(r.Match) m.ruleAccount.SetValue(r.Account) m.ruleTag.SetValue(r.Tag) m.ruleNote.SetValue(r.Note) return m.showRuleBuilder() } // showRuleBuilder switches to the builder and loads what the preview needs, // remembering where to return to on esc. func (m *Model) showRuleBuilder() tea.Cmd { if m.view != viewRules { m.ruleReturn = m.view } m.view = viewRules m.setRuleFocus(0) if err := m.reloadSuggestions(); err != nil { m.err = err } // Last: what the preview lists depends on whether a rule is being edited. if err := m.reloadPreviewGroups(); 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, &m.ruleNote} } // setRuleFocus moves the cursor between the form fields, wrapping around. func (m *Model) setRuleFocus(i int) { m.ruleFocus = focusField(m.ruleInputs(), i) } // focusField gives the cursor to one field of a form and takes it from the // rest, wrapping the index so tabbing past either end comes back round. func focusField(inputs []*textinput.Model, i int) int { n := len(inputs) focus := ((i % n) + n) % n for j, in := range inputs { if j == focus { in.Focus() in.CursorEnd() continue } in.Blur() } return focus } // openTransferBuilder switches to the transfer builder, remembering where to // return to and loading the transactions the preview pairs against. func (m *Model) openTransferBuilder() tea.Cmd { if m.view != viewTransfers { m.transferReturn = m.view } m.view = viewTransfers m.setTransferFocus(0) slugs := m.accountSlugs() m.transferFrom.SetSuggestions(slugs) m.transferTo.SetSuggestions(slugs) // The preview pairs across accounts, so it needs everything, not the // filtered view the transaction list is showing. txns, err := m.db.Transactions(store.Filter{}) if err != nil { m.err = err } m.allTxns = txns m.refreshTransferPreview() return textinput.Blink } // transferInputs lists the form fields in tab order: the two sides in the // order the money travels, then the tolerance and the note. func (m *Model) transferInputs() []*textinput.Model { return []*textinput.Model{ &m.transferFrom, &m.transferFromDesc, &m.transferTo, &m.transferToDesc, &m.transferTolerance, &m.transferNote, } } // setTransferFocus moves the cursor between the form fields, wrapping around. func (m *Model) setTransferFocus(i int) { m.transferFocus = focusField(m.transferInputs(), i) } // draftTransfer is the definition the form currently describes. A tolerance // that does not parse yet previews as none at all — the field is retyped a // character at a time, and "1." must not stop the preview from updating. // saveTransfer is where a bad value is refused. func (m *Model) draftTransfer() config.Transfer { pct, _ := m.draftTolerance() return config.Transfer{ FromAccount: strings.TrimSpace(m.transferFrom.Value()), FromDesc: strings.TrimSpace(m.transferFromDesc.Value()), ToAccount: strings.TrimSpace(m.transferTo.Value()), ToDesc: strings.TrimSpace(m.transferToDesc.Value()), TolerancePct: pct, Note: strings.TrimSpace(m.transferNote.Value()), } } // draftTolerance reads the tolerance field. Empty means none, which is what a // definition without the key means too. func (m *Model) draftTolerance() (float64, error) { s := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(m.transferTolerance.Value()), "%")) if s == "" { return 0, nil } pct, err := strconv.ParseFloat(s, 64) if err != nil { return 0, fmt.Errorf("tolerance %q is not a number", s) } return pct, nil } // refreshTransferPreview re-pairs against whatever is typed right now. // // The draft is analysed *after* the definitions already on disk, exactly where // saving would put it, so the preview cannot promise pairs that an existing // definition would claim first. A half-written definition still previews: the // side that is filled in shows its legs as unpaired, which is the fastest way // to see that a glob is wrong. func (m *Model) refreshTransferPreview() { draft := m.draftTransfer() defs := append(append([]config.Transfer(nil), m.links.Transfers()...), draft) res := transfers.New(&config.Rules{Transfer: defs}).Analyze(m.allTxns) mine := len(defs) - 1 type entry struct { date string row table.Row } var entries []entry m.previewFees, m.previewFeeDigits = 0, 0 for _, p := range res.Pairs { if p.Def != mine { continue } m.previewFees += p.Fee() m.previewFeeDigits = p.Out.MinorDigits // Whenever the two legs disagree, both numbers are worth seeing: across // currencies they are the only place the rate the bank used shows up, // and within one they are the fee a tolerance let through, which is // exactly the thing to eyeball before saving the definition. amount := model.FormatMinor(-p.Out.AmountMinor, p.Out.MinorDigits) if p.In.Currency != p.Out.Currency || p.Fee() != 0 { amount += " → " + model.FormatMinor(p.In.AmountMinor, p.In.MinorDigits) } entries = append(entries, entry{p.Out.Date, table.Row{ "▸", p.Out.Date, amount, p.Out.AccountSlug + " → " + p.In.AccountSlug, p.Out.Description, }}) } for _, l := range res.Unmatched { if l.Def != mine { continue } amount, movement := l.Txn.AmountMinor, "? → "+l.Txn.AccountSlug if l.Out { amount, movement = -amount, l.Txn.AccountSlug+" → ?" } entries = append(entries, entry{l.Txn.Date, table.Row{ "⚠", l.Txn.Date, model.FormatMinor(amount, l.Txn.MinorDigits), movement, l.Txn.Description, }}) } // Newest first, like the transaction list: the movement worth checking is // usually the one that just came in. sort.SliceStable(entries, func(i, j int) bool { return entries[i].date > entries[j].date }) rows := make([]table.Row, 0, len(entries)) for _, e := range entries { rows = append(rows, e.row) } m.previewPairs = res.Paired[mine] m.previewUnmatched = res.Orphaned[mine] cursor := m.transferTable.Cursor() m.transferTable.SetRows(rows) if cursor >= len(rows) { cursor = len(rows) - 1 } if cursor < 0 { cursor = 0 } m.transferTable.SetCursor(cursor) } // saveTransfer appends the composed definition to rules.toml, re-pairs, and // leaves the two account fields filled in, since the next transfer written is // usually the same route in the other direction. func (m *Model) saveTransfer() error { t := m.draftTransfer() switch { case t.FromAccount == "": return fmt.Errorf("name the account the money leaves") case t.FromDesc == "": return fmt.Errorf("enter a glob for the leaving leg, e.g. *TO REVOLUT*") case t.ToAccount == "": return fmt.Errorf("name the account the money arrives in") case t.ToDesc == "": return fmt.Errorf("enter a glob for the arriving leg, e.g. *FROM NLB*") } for _, slug := range []string{t.FromAccount, t.ToAccount} { if !m.knownAccount(slug) { return fmt.Errorf("no account called %q", slug) } } // draftTransfer swallowed this so the preview could keep up with typing; // saving is where a value that never became a number has to be refused, // rather than written out as a silent 0. if _, err := m.draftTolerance(); err != nil { return err } if err := config.AppendTransfer(m.root, t); err != nil { return err } if err := m.reloadConfig(); err != nil { return fmt.Errorf("transfer saved, but re-reading rules.toml failed: %w", err) } paired, unpaired, err := m.links.Link(m.db) if err != nil { return err } m.status = fmt.Sprintf("saved transfer %s → %s, %d matched, %d leg(s) unpaired", t.FromAccount, t.ToAccount, paired, unpaired) m.transferFromDesc.SetValue("") m.transferToDesc.SetValue("") // Cleared with the globs rather than kept with the accounts: a tolerance // carried silently into the next definition would loosen a route that never // asked for one, and the reverse direction rarely charges the same fee. m.transferTolerance.SetValue("") m.transferNote.SetValue("") // The accounts are kept, so land on the first field that was cleared. m.setTransferFocus(1) if err := m.reload(); err != nil { return err } txns, err := m.db.Transactions(store.Filter{}) if err != nil { return err } m.allTxns = txns m.refreshTransferPreview() return nil } // openTransferList switches to the transfer list, remembering where to return. // As on the rule list, the builder is not somewhere to return to: it is reached // from here, and esc would bounce between the two. func (m *Model) openTransferList() { if m.view != viewTransferList && m.view != viewTransfers { m.transferListReturn = m.view } m.view = viewTransferList m.confirm = confirmNone if err := m.reloadTransferList(); err != nil { m.err = err } } // reloadTransferList recomputes what every definition currently pairs. // // Two counts are shown because they mean different things: a definition with // no pairs and no legs matches nothing at all and can go, while one with // unpaired legs is catching money leaving that never arrives — a wrong glob on // the other side, a statement not imported yet, or a movement that genuinely // went missing. func (m *Model) reloadTransferList() error { txns, err := m.db.Transactions(store.Filter{}) if err != nil { return err } defs := m.links.Transfers() m.transferResult = m.links.Analyze(txns) rows := make([]table.Row, 0, len(defs)) for i, t := range defs { marker := " " switch { case m.transferResult.Orphaned[i] > 0: marker = "⚠" case m.transferResult.Paired[i] == 0: marker = "✗" } rows = append(rows, table.Row{ fmt.Sprintf("%d", i+1), marker, t.FromAccount + " " + t.FromDesc, t.ToAccount + " " + t.ToDesc, fmt.Sprintf("%d", m.transferResult.Paired[i]), fmt.Sprintf("%d", m.transferResult.Orphaned[i]), formatTolerance(t.TolerancePct), t.Note, }) } cursor := m.transferListTable.Cursor() m.transferListTable.SetRows(rows) if cursor >= len(rows) { cursor = len(rows) - 1 } if cursor < 0 { cursor = 0 } m.transferListTable.SetCursor(cursor) return nil } // formatTolerance renders a definition's tolerance for the list. The default // is blank rather than "0%": every definition has it, so printing it down the // whole column would bury the one or two rows where the amounts are actually // allowed to disagree. func formatTolerance(pct float64) string { if pct == 0 { return "" } return strconv.FormatFloat(pct, 'f', -1, 64) + "%" } // unusedTransfers lists the positions of every definition that catches nothing // at all. A definition with unpaired legs is deliberately not in here: it is // doing something, just not completing it, and deleting it would hide the // problem instead of fixing it. func (m *Model) unusedTransfers() []int { var out []int for i, n := range m.transferResult.Paired { if n == 0 && m.transferResult.Orphaned[i] == 0 { out = append(out, i) } } return out } // unpairedLegs counts the legs no definition could complete. func (m *Model) unpairedLegs() int { return len(m.transferResult.Unmatched) } // deleteTransfers removes definitions from rules.toml, then re-pairs so the // counts on screen reflect the new file. func (m *Model) deleteTransfers(positions []int) error { n, err := config.DeleteTransfers(m.root, positions) if err != nil { return err } if err := m.reloadConfig(); err != nil { return fmt.Errorf("transfers deleted, but re-reading rules.toml failed: %w", err) } paired, unpaired, err := m.links.Link(m.db) if err != nil { return err } m.status = fmt.Sprintf("deleted %d transfer(s), %d matched, %d leg(s) unpaired", n, paired, unpaired) if err := m.reload(); err != nil { return err } return m.reloadTransferList() } // updateTransferList drives the transfer list, including the confirmations. func (m *Model) updateTransferList(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 confirmDeleteTransfer: if i := m.transferListTable.Cursor(); i >= 0 && i < len(m.links.Transfers()) { if err := m.deleteTransfers([]int{i}); err != nil { m.err = err } } case confirmPruneTransfers: if err := m.deleteTransfers(m.unusedTransfers()); err != nil { m.err = err } } return m, nil } switch msg.String() { case "q", "ctrl+c": return m, tea.Quit case "esc": m.view = m.transferListReturn 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 "5": m.openRuleList() return m, nil case "6": return m, m.openTransferBuilder() case "d": i := m.transferListTable.Cursor() defs := m.links.Transfers() if i < 0 || i >= len(defs) { return m, nil } m.confirm = confirmDeleteTransfer m.status = fmt.Sprintf("delete transfer %d (%s → %s), %d matched? y/n", i+1, defs[i].FromAccount, defs[i].ToAccount, m.transferResult.Paired[i]) return m, nil case "p": unused := m.unusedTransfers() if len(unused) == 0 { m.status = "no transfers to prune; only ones matching nothing at all are pruned" return m, nil } m.confirm = confirmPruneTransfers m.status = fmt.Sprintf("delete all %d transfers that match nothing? y/n", len(unused)) return m, nil case "r": m.err = m.reloadTransferList() m.status = "pairing refreshed" return m, nil } var cmd tea.Cmd m.transferListTable, cmd = m.transferListTable.Update(msg) return m, cmd } // updateTransfers drives the transfer builder form. Like the rule builder it // owns every printable key, so the global keymap must not reach it. func (m *Model) updateTransfers(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": return m, tea.Quit case "esc": m.view = m.transferReturn return m, nil case "tab": if acceptCompletion(m.transferInputs()[m.transferFocus]) { m.refreshTransferPreview() return m, nil } m.setTransferFocus(m.transferFocus + 1) return m, nil case "down": m.setTransferFocus(m.transferFocus + 1) return m, nil case "shift+tab", "up": m.setTransferFocus(m.transferFocus - 1) return m, nil case "right": in := m.transferInputs()[m.transferFocus] if in.Position() == len([]rune(in.Value())) && acceptCompletion(in) { m.refreshTransferPreview() return m, nil } case "pgdown": m.transferTable.MoveDown(10) return m, nil case "pgup": m.transferTable.MoveUp(10) return m, nil case "enter": m.err = nil if err := m.saveTransfer(); err != nil { m.err = err } return m, nil } var cmd tea.Cmd inputs := m.transferInputs() *inputs[m.transferFocus], cmd = inputs[m.transferFocus].Update(msg) m.refreshTransferPreview() return m, cmd } // transfersView puts the form on the left and the live pairing on the right. func (m *Model) transfersView() string { return lipgloss.JoinHorizontal(lipgloss.Top, m.transferFormView(), m.transferTable.View()) } func (m *Model) transferFormView() string { // Six fields need more room than the rule builder's four, so the form gives // ground in three stages: the blank lines first, then the hints on unfocused // fields, then their borders. Labels and values never go — six anonymous // boxes would be worse than a form that scrolls. room := m.height - 6 spaced := m.height <= 0 || room >= 37 hints := m.height <= 0 || room >= 31 boxed := m.height <= 0 || room >= 25 inputs := m.transferInputs() field := func(i int, label, help string) string { name := labelStyle.Render(" " + label) box := flatBoxStyle.Render(inputs[i].View()) if boxed { box = boxStyle.Render(inputs[i].View()) } if i == m.transferFocus { name = focusedLabelStyle.Render("▸ " + label) box = focusedBoxStyle.Render(inputs[i].View()) } out := name + "\n" + box + "\n" if hints || i == m.transferFocus { out += hintStyle.Render(help) + "\n" } if spaced { out += "\n" } return out } var b strings.Builder b.WriteString(field(0, "from account", completionHint(inputs[0], m.transferFocus == 0, "money leaves here"))) b.WriteString(field(1, "from desc", "glob vs. the leaving leg")) b.WriteString(field(2, "to account", completionHint(inputs[2], m.transferFocus == 2, "money arrives here"))) b.WriteString(field(3, "to desc", "glob vs. the arriving leg")) b.WriteString(field(4, "tolerance %", "0 = amounts must match exactly")) b.WriteString(field(5, "note", "why this transfer exists")) summary := fmt.Sprintf("%d pairs · %d unpaired", m.previewPairs, m.previewUnmatched) if m.previewPairs == 0 && m.previewUnmatched == 0 { summary = "nothing matches yet" } // Fees are the reason the tolerance field exists, so the preview says what // it is admitting rather than only how many pairs it bought. if fee := m.previewFees; fee != 0 { summary += fmt.Sprintf(" · %s in fees", model.FormatMinor(fee, m.previewFeeDigits)) } b.WriteString(matchCountStyle.Render(summary)) return ruleFormStyle.Render(b.String()) } // 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, links := m.root, m.db, m.accounts, m.engine, m.links return func() tea.Msg { res, err := importer.Run(root, db, accounts, engine, links, 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 builders are forms: every printable key belongs to the focused // input, so the global single-letter keymap cannot apply. switch m.view { case viewRules: return m.updateRules(msg) case viewTransfers: return m.updateTransfers(msg) case viewRuleList: return m.updateRuleList(msg) case viewTransferList: return m.updateTransferList(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": // Tab is overloaded on purpose: with a completion on offer it takes // it, and moving on is then one more tab. Without one it does what it // always did and moves to the next field. if acceptCompletion(m.ruleInputs()[m.ruleFocus]) { m.refreshRulePreview() return m, nil } m.setRuleFocus(m.ruleFocus + 1) return m, nil case "down": m.setRuleFocus(m.ruleFocus + 1) return m, nil case "shift+tab", "up": m.setRuleFocus(m.ruleFocus - 1) return m, nil case "right": // At the end of the line → has nothing else to do, so it accepts the // completion the way a shell does. Anywhere else it falls through and // moves the cursor. in := m.ruleInputs()[m.ruleFocus] if in.Position() == len([]rune(in.Value())) && acceptCompletion(in) { m.refreshRulePreview() return m, nil } case "pgdown": m.ruleTable.MoveDown(10) return m, nil case "pgup": m.ruleTable.MoveUp(10) return m, nil case "ctrl+s": // A form owns every printable key, so re-sorting has to be a chord: // "s" belongs to whichever glob is being typed. if m.previewSort == sortByName { m.previewSort = sortByCount } else { m.previewSort = sortByName } m.sortPreviewGroups() // The rows under the cursor are not the rows that were there a moment // ago, so start from the top rather than somewhere arbitrary. m.ruleTable.SetCursor(0) return m, nil case "enter": m.err = nil editing := m.ruleEditing // saveRule clears it on the way through if err := m.saveRule(); err != nil { m.err = err return m, nil } if editing { // An edit is a round trip from the rules screen, so it ends there // with the new counts rather than in an empty form. m.showRuleList() return m, nil } if err := m.reloadPreviewGroups(); 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) if msg.res.Unpaired > 0 { m.status += fmt.Sprintf(" · %d transfer leg(s) unpaired", msg.res.Unpaired) } // 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) m.transferTable.SetHeight(h) m.transferListTable.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) cols = m.transferTable.Columns() fixed := 0 for _, c := range cols[:len(cols)-1] { fixed += c.Width + 2 } desc = m.width - ruleFormWidth - fixed - 6 if desc < 16 { desc = 16 } cols[len(cols)-1].Width = desc m.transferTable.SetColumns(cols) } // The free-text columns come last and get whatever is left over. m.stretchLastColumn(&m.txnTable, 20) m.stretchLastColumn(&m.ruleListTable, 12) m.stretchLastColumn(&m.transferListTable, 12) } // stretchLastColumn widens a table's final column to fill the window, down to // a floor below which truncation is worse than letting the row overflow. func (m *Model) stretchLastColumn(t *table.Model, min int) { if m.width <= 0 { return } cols := t.Columns() fixed := 0 for _, c := range cols[:len(cols)-1] { fixed += c.Width + 2 } last := m.width - fixed - 4 if last < min { last = min } cols[len(cols)-1].Width = last t.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 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 "6": return m, m.openTransferBuilder() case "7": m.openTransferList() return m, nil case "s": // Re-sorting only means anything where there is a breakdown to sort. if m.view != viewReport { break } m.setOrder(m.reportOrder.Next()) return m, nil case "left", "right": // The report's time axis. Every other view leaves these to its table, // which is what breaking out of the switch here does. if m.view != viewReport { break } step := 1 if msg.String() == "left" { step = -1 } m.setPeriod(m.reportPeriod + step) return m, nil case "tab": m.view = (m.view + 1) % viewCount switch m.view { case viewRules: return m, m.openRuleBuilder() case viewRuleList: m.openRuleList() case viewTransfers: return m, m.openTransferBuilder() case viewTransferList: m.openTransferList() } 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 "/": m.view = viewTxns m.input = inputSearch m.text.SetValue(m.filter.Search) m.text.CursorEnd() m.text.Focus() 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 } // Tags and transfers are both derived from rules.toml, so one key // re-derives both; leaving the pairing stale would quietly change what // the report holds out. paired, unpaired, err := m.links.Link(m.db) if err != nil { m.err = err return m, nil } m.status = fmt.Sprintf("rules re-applied, %d rows changed, %d transfers matched", n, paired) if unpaired > 0 { m.status += fmt.Sprintf(", %d leg(s) unpaired", unpaired) } 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.reportView()) case viewRules: b.WriteString(m.rulesView()) case viewRuleList: b.WriteString(m.ruleListTable.View()) case viewTransfers: b.WriteString(m.transfersView()) case viewTransferList: b.WriteString(m.transferListTable.View()) } } b.WriteString("\n") switch { case m.importing: b.WriteString(statusStyle.Render(m.spinner.View() + m.importingLabel())) 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") // Wrap the key list rather than letting the terminal cut it off: the keys // at the end are as important as the ones at the start, and "q quit" was // the first casualty on a narrow window. help := helpStyle if m.width > 0 { help = help.Width(m.width) } b.WriteString(help.Render(m.help())) return b.String() } // periodListWidth is the width of the report's time axis, wide enough for the // longest window name plus its cursor. const periodListWidth = 18 // reportView puts the time axis to the left of the totals, so the window the // numbers are for is visible without reading the title and ←/→ has something to // point at. On a window too narrow for both, the totals win and the axis is // dropped: the title still names the period, and truncated money columns would // be worse than a hidden list of periods. func (m *Model) reportView() string { axis := m.periodList() if axis == "" { return m.reportTable.View() } return lipgloss.JoinHorizontal(lipgloss.Top, axis, m.reportTable.View()) } // periodList renders the axis, scrolled to keep the selected window on screen. // Its header stands in for the table's own, which is a single unruled line, so // the two panels line up row for row without a separator of their own. func (m *Model) periodList() string { if len(m.reportPeriods) == 0 { return "" } if m.width > 0 && m.width < tableWidth(m.reportTable)+periodListWidth { return "" } h := m.reportTable.Height() if h <= 0 || h > len(m.reportPeriods) { h = len(m.reportPeriods) } // Centre the cursor where there is room, so the windows either side of the // one being read are visible and stepping has somewhere obvious to go. start := m.reportPeriod - h/2 if start > len(m.reportPeriods)-h { start = len(m.reportPeriods) - h } if start < 0 { start = 0 } var b strings.Builder b.WriteString(periodHeaderStyle.Render("Period")) for i := start; i < start+h; i++ { line := " " + m.reportPeriods[i].Label style := periodStyle if i == m.reportPeriod { line = "▸ " + m.reportPeriods[i].Label style = periodSelectedStyle } b.WriteString("\n" + style.Render(line)) } return b.String() } // tableWidth is how many cells a table renders across, the cell style's padding // on either side of every column included. func tableWidth(t table.Model) int { w := 0 for _, c := range t.Columns() { w += c.Width + 2 } return w } // 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 { // Spaced out, the four fields come to 25 lines, which is more than a short // window has left once the same allowance the tables get is taken off. // Give up the blank lines between fields first (21 lines) and the hints on // unfocused fields second (18), rather than letting the last field run off // the bottom. Below that the box borders are the floor. // An edit carrying a type pattern renders one line more, and two once the // fields are spaced out, so it asks for that much more room before either. typed := 0 if m.ruleEditing && m.ruleEditOrig.Type != "" { typed = 1 } room := m.height - 6 spaced := m.height <= 0 || room >= 25+2*typed hints := m.height <= 0 || room >= 21+typed 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()) } out := name + "\n" + box + "\n" if hints || i == m.ruleFocus { out += hintStyle.Render(help) + "\n" } if spaced { out += "\n" } return out } var b strings.Builder inputs := m.ruleInputs() b.WriteString(field(0, "glob", "vs. the description")) // A type pattern has no field of its own, so an edit carrying one says so // rather than leaving the rule looking broader than it is. if m.ruleEditing && m.ruleEditOrig.Type != "" { b.WriteString(hintStyle.Render("+ type:"+m.ruleEditOrig.Type+" · kept") + "\n") if spaced { b.WriteString("\n") } } b.WriteString(field(1, "account", completionHint(inputs[1], m.ruleFocus == 1, "blank = all accounts"))) b.WriteString(field(2, "tag", completionHint(inputs[2], m.ruleFocus == 2, "applied to matches"))) b.WriteString(field(3, "note", "why this rule exists")) // 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, m.ruleCandidates) if strings.TrimSpace(m.ruleGlob.Value()) == "" && !m.ruleEditing { summary = fmt.Sprintf("%d untagged descriptions", m.ruleCandidates) } // What an edit gives up is not visible in a count of what it keeps. It goes // on its own line because the two together overflow the form's width, and // lipgloss would wrap it mid-phrase. if m.ruleDropped > 0 { summary += fmt.Sprintf("\n%d no longer claimed", m.ruleDropped) } b.WriteString(matchCountStyle.Render(summary)) return ruleFormStyle.Render(b.String()) } // completionHint names the key that accepts the ghosted completion, replacing // the field's usual hint while one is on offer. The hint sits directly under // the box the ghost text appears in, which is where the question is asked. func completionHint(in *textinput.Model, focused bool, fallback string) string { if !focused { return fallback } if pendingCompletion(in) == "" { return fallback } if n := len(in.MatchedSuggestions()); n > 1 { return fmt.Sprintf("tab completes · ctrl+n: %d more", n-1) } return "tab completes" } // 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\n" + "Every transaction here has a tag or belongs to a transfer.\n" + "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 "" } // A period with nothing in it is not the same as an empty index, and // saying so matters most in the default window: statements for the month // that has just ended often have not been downloaded yet. if p, ok := m.currentPeriod(); ok && p.From != "" && !m.indexEmpty { return fmt.Sprintf("Nothing in %s.\n\nPress ←/→ for another period, or i to import.", p.String()) } 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." case viewTransferList: if len(m.transferListTable.Rows()) > 0 { return "" } return "No transfers yet.\n\n" + "A transfer names both legs of money moved between your own accounts,\n" + "so the report can leave the pair out instead of counting it as spending.\n" + "Press 6 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: if m.ruleEditing { return fmt.Sprintf("money · editing rule %d · rewrites it in rules.toml", m.ruleEditPos+1) } 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 viewTransfers: return "money · transfer builder · writes to rules.toml" case viewTransferList: n := len(m.links.Transfers()) if legs := m.unpairedLegs(); legs > 0 { return fmt.Sprintf("money · transfers · %d definitions · %d leg(s) unpaired", n, legs) } return fmt.Sprintf("money · transfers · %d definitions · every leg paired", n) case viewReport: if p, ok := m.currentPeriod(); ok { return "money · report · " + p.String() + " · " + scope } return "money · report · " + scope 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 new rule · 5 rules · 6 new transfer · 7 transfers · i import · r retag · q quit" case viewRules: save := "enter save rule" if m.ruleEditing { save = "enter save changes" } // The key is named by what pressing it does now, not by the order the // list is already in. order := "ctrl+s sort by count" if m.previewSort == sortByCount { order = "ctrl+s sort by name" } return "tab complete/next field · ↑↓ field · ctrl+n/p other completions · pgup/pgdn scroll list · " + order + " · " + save + " · esc back · ctrl+c quit" case viewRuleList: if m.confirm != confirmNone { return "y confirm · any other key cancels" } return "e edit rule · d delete rule · p prune all unused · r refresh counts · 4 new rule · 7 transfers · 1 accounts · esc back · q quit" case viewTransfers: return "tab complete/next field · ↑↓ field · pgup/pgdn scroll pairs · enter save transfer · esc back · ctrl+c quit" case viewTransferList: if m.confirm != confirmNone { return "y confirm · any other key cancels" } return "d delete transfer · p prune all unmatched · r refresh pairing · 6 new transfer · 5 rules · 1 accounts · esc back · q quit" case viewReport: // The key is named by what pressing it does now, not by the order the // rows are already in. return "←/→ period · s sort by " + m.reportOrder.Next().String() + " · 1 accounts · 2 transactions · 4 new rule · 5 rules · 7 transfers · u untagged · a all accounts · q quit" default: return "4 new rule · / search · u untagged · a all · i import · r retag · 5 rules · 7 transfers · q quit" } }