Files
money/internal/tui/tui.go
T
nikolaandClaude Opus 5 7d4c3eba17 Open the report on last month
The report answered for the whole index, which is the one window a spending
report is least often asked for: last January's groceries sat in the same total
as last week's, and nothing on screen said which was which. It now opens on the
month that has just ended -- the last one a statement can be complete for --
and left/right step along an axis beside the totals, from all time down through
the named windows to the oldest month the index holds.

The period narrows the report and nothing else. reloadReport runs its own query
rather than reusing the rows the transaction list is showing: the two share the
account, the search and the untagged toggle, and differ only in the date
bounds, so opening on last month must not hide the rest of the index from the
list beside it. Nothing may put the period into m.filter, which is exactly what
would make it leak.

The windows are relative to today, never to the newest statement. "Last month"
with nothing in it reports nothing and says so, because silently answering for
a month nobody asked for is worse than an empty screen; an empty period and an
empty index therefore give different messages, one asking for another period
and the other for an import. The rolling windows run to the end of this month
rather than to the last complete one -- "last 3 months" is asked in order to
see what is happening now, and leaving out the days since the 1st answers a
question nobody put. The axis is built over the whole index rather than the
rows in view, or it would grow and shrink as the account or search filter
changed and move under the cursor; a reload rebuilds it, since an import can
reach further back, but keeps the window the user was on.

s cycles how those rows are arranged: largest out first as before, then in, net
lowest first so the biggest losses lead, count, and the tag A to Z. A letter
rather than a chord because the report is not a form. The marked heading says
which column the rows are read from and the help names what the key does next,
as the rule builder's preview already does. The sort rearranges rows and never
changes which rows there are, so the order stays out of the filter for the same
reason the period does. Currency remains the outer key under every order --
there are no rates here, so two currencies interleaved by amount would invite a
comparison that cannot be made -- and every order falls back to the tag, so
ties keep a fixed position instead of reshuffling between reloads.

money report takes the same choice as --sort out|in|net|count|tag, and a
misspelt one is refused rather than silently reporting in the default order. It
keeps --month and has no equivalent of the wider windows.

store.Filter gains From and To, compared as strings because dates are stored
ISO-8601 and a string comparison is therefore a date comparison. store.Months
replaces report.Months, which nothing had ever called: the axis needs the
months of the whole index, not of a slice already in hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:45:23 +02:00

2484 lines
74 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
}
}