Alphabetical order finds a payee you are already looking at, which is what the list is for once a glob is typed. With the glob empty it answers a different question -- what to write a rule for next -- and there the name is the least useful thing about a row: one pattern claiming forty rows is worth more than the first of forty claiming one, and nothing on screen said which was which until you read the whole list. ctrl+s switches between the two. A chord rather than a letter because the builder is a form and every printable key belongs to the field being typed in; the invariant that keeps q from quitting there cuts this way too. Ties fall back to the alphabetical order so the list does not reshuffle when it is rebuilt, and the cursor returns to the top, since the row under it is not the row that was there a moment ago. The header marks the column the order is read from, and the help names what the key does next rather than where the list already is. The choice is the user's, so it outlives the builder being left and reopened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2264 lines
67 KiB
Go
2264 lines
67 KiB
Go
// 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"
|
||
|
||
"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
|
||
|
||
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)
|
||
)
|
||
|
||
// 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))
|
||
}
|
||
|
||
return &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([]table.Column{
|
||
{Title: "Tag", Width: 20},
|
||
{Title: "Cur", Width: 4},
|
||
{Title: "Out", Width: 14},
|
||
{Title: "In", Width: 14},
|
||
{Title: "Net", Width: 14},
|
||
{Title: "N", Width: 5},
|
||
}),
|
||
}
|
||
}
|
||
|
||
// Init implements tea.Model.
|
||
func (m *Model) Init() tea.Cmd { return nil }
|
||
|
||
// reload refreshes whichever view is active from the index.
|
||
func (m *Model) reload() error {
|
||
if err := m.reloadAccounts(); err != nil {
|
||
return err
|
||
}
|
||
return m.reloadTxns()
|
||
}
|
||
|
||
func (m *Model) reloadAccounts() error {
|
||
accounts, err := m.db.Accounts()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rows := make([]table.Row, 0, len(accounts))
|
||
for _, a := range accounts {
|
||
bal, err := m.db.Balance(a.ID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
n, err := m.db.Count(a.ID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rows = append(rows, table.Row{
|
||
a.Name, model.FormatMinor(bal, a.MinorDigits), a.Currency, fmt.Sprintf("%d", n),
|
||
})
|
||
}
|
||
m.accountTable.SetRows(rows)
|
||
return nil
|
||
}
|
||
|
||
func (m *Model) reloadTxns() error {
|
||
f := m.filter
|
||
f.Untagged = m.onlyUntagged
|
||
txns, err := m.db.Transactions(f)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
m.txns = txns
|
||
|
||
rows := make([]table.Row, 0, len(txns))
|
||
for _, t := range txns {
|
||
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)
|
||
|
||
m.reloadReport(txns)
|
||
return nil
|
||
}
|
||
|
||
// 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"
|
||
)
|
||
|
||
func (m *Model) reloadReport(txns []model.Transaction) {
|
||
rows := report.ByTag(txns)
|
||
out := make([]table.Row, 0, len(rows)+2)
|
||
for _, r := range rows {
|
||
out = append(out, table.Row{
|
||
r.Tag, r.Currency,
|
||
model.FormatMinor(r.Out, r.Digits),
|
||
model.FormatMinor(r.In, r.Digits),
|
||
model.FormatMinor(r.Net(), r.Digits),
|
||
fmt.Sprintf("%d", r.Count),
|
||
})
|
||
}
|
||
for _, c := range report.Totals(rows) {
|
||
out = append(out, table.Row{
|
||
"TOTAL", c.Currency,
|
||
model.FormatMinor(c.Out, c.Digits),
|
||
model.FormatMinor(c.In, c.Digits),
|
||
model.FormatMinor(c.Net(), c.Digits),
|
||
"",
|
||
})
|
||
}
|
||
// 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)
|
||
}
|
||
|
||
// 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 "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.reportTable.View())
|
||
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()
|
||
}
|
||
|
||
// 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 ""
|
||
}
|
||
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:
|
||
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:
|
||
return "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"
|
||
}
|
||
}
|