File order decided precedence, so a narrow rule had to be written above the broad one it carves an exception out of -- an ordering constraint the file cannot show and the user has to remember. *NIKOLA* below *NIK* silently matched nothing, and a catch-all * could only ever be the last line. Engine.New now sorts once and MatchIndex walks that order: most literal characters first, then fewest *, then account-scoped over unscoped. Literals are what a rule commits to and a * is what it gives up, so a bare * is tried last wherever it sits. The sort is stable, so equally specific rules keep file order and the earlier one wins -- which is all position decides now, and why AppendRule can keep appending without displacing a rule written by hand. The two orders must not be confused: Rules(), Usage and MatchIndex still speak in file positions, because that is what the rules screen numbers and what DeleteRules deletes by. A shadowed rule still reports zero usage, but a zero no longer says anything about where the rule sits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2043 lines
58 KiB
Go
2043 lines
58 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
|
|
)
|
|
|
|
// 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: three inputs on the left, and a live preview on the right
|
|
// of which untagged descriptions the glob would catch.
|
|
ruleGlob textinput.Model
|
|
ruleAccount textinput.Model
|
|
ruleTag textinput.Model
|
|
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
|
|
untagged []descGroup
|
|
ruleMatches int // untagged 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
|
|
|
|
// Rule list: every rule with the number of transactions it actually
|
|
// claims, so dead ones can be found and removed.
|
|
ruleListTable table.Model
|
|
ruleUsage []int
|
|
confirm confirmation
|
|
|
|
// 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
|
|
|
|
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 untagged description and how often it occurs.
|
|
// Grouping matters: a statement holds the same payee dozens of times, and a
|
|
// rule is written against the description, not against individual rows.
|
|
type descGroup struct {
|
|
Description string
|
|
Accounts map[string]bool
|
|
Count int
|
|
}
|
|
|
|
// reloadUntagged rebuilds the alphabetical list of untagged descriptions that
|
|
// the rule builder previews against.
|
|
func (m *Model) reloadUntagged() error {
|
|
txns, err := m.db.Transactions(store.Filter{Untagged: true})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
byDesc := map[string]*descGroup{}
|
|
for _, t := range txns {
|
|
key := model.NormalizeDescription(t.Description)
|
|
g, ok := byDesc[key]
|
|
if !ok {
|
|
g = &descGroup{Description: t.Description, Accounts: map[string]bool{}}
|
|
byDesc[key] = g
|
|
}
|
|
g.Accounts[t.AccountSlug] = true
|
|
g.Count++
|
|
}
|
|
|
|
m.untagged = make([]descGroup, 0, len(byDesc))
|
|
for _, g := range byDesc {
|
|
m.untagged = append(m.untagged, *g)
|
|
}
|
|
sort.Slice(m.untagged, func(i, j int) bool {
|
|
a := model.NormalizeDescription(m.untagged[i].Description)
|
|
b := model.NormalizeDescription(m.untagged[j].Description)
|
|
if a != b {
|
|
return a < b
|
|
}
|
|
return m.untagged[i].Description < m.untagged[j].Description
|
|
})
|
|
|
|
m.refreshRulePreview()
|
|
return nil
|
|
}
|
|
|
|
// refreshRulePreview re-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.
|
|
func (m *Model) refreshRulePreview() {
|
|
var (
|
|
pattern = strings.TrimSpace(m.ruleGlob.Value())
|
|
account = strings.TrimSpace(m.ruleAccount.Value())
|
|
rows = make([]table.Row, 0, len(m.untagged))
|
|
)
|
|
m.ruleMatches, m.ruleCandidates = 0, 0
|
|
|
|
for _, g := range m.untagged {
|
|
// An account filter narrows the preview the same way the saved rule
|
|
// will narrow its matching.
|
|
if account != "" && !g.Accounts[account] {
|
|
continue
|
|
}
|
|
m.ruleCandidates++
|
|
|
|
marker := " "
|
|
if pattern != "" {
|
|
if !glob.Match(pattern, model.NormalizeDescription(g.Description)) {
|
|
continue
|
|
}
|
|
marker = "▸"
|
|
m.ruleMatches++
|
|
}
|
|
rows = append(rows, table.Row{marker, g.Description, fmt.Sprintf("%d", g.Count)})
|
|
}
|
|
|
|
cursor := m.ruleTable.Cursor()
|
|
m.ruleTable.SetRows(rows)
|
|
if cursor >= len(rows) {
|
|
cursor = len(rows) - 1
|
|
}
|
|
if cursor < 0 {
|
|
cursor = 0
|
|
}
|
|
m.ruleTable.SetCursor(cursor)
|
|
}
|
|
|
|
// saveRule appends the composed rule to rules.toml, reloads the engine and
|
|
// retags, so the effect is visible immediately.
|
|
func (m *Model) saveRule() error {
|
|
r := config.Rule{
|
|
Match: strings.TrimSpace(m.ruleGlob.Value()),
|
|
Account: strings.TrimSpace(m.ruleAccount.Value()),
|
|
Tag: strings.TrimSpace(m.ruleTag.Value()),
|
|
Note: strings.TrimSpace(m.ruleNote.Value()),
|
|
}
|
|
if r.Match == "" {
|
|
return fmt.Errorf("enter a glob first, e.g. *LIDL*")
|
|
}
|
|
if r.Tag == "" {
|
|
return fmt.Errorf("enter a tag to apply")
|
|
}
|
|
if r.Account != "" && !m.knownAccount(r.Account) {
|
|
return fmt.Errorf("no account called %q; leave it blank to apply to every account", r.Account)
|
|
}
|
|
|
|
if err := config.AppendRule(m.root, r); err != nil {
|
|
return err
|
|
}
|
|
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
|
|
}
|
|
|
|
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.ruleReturn = m.view
|
|
}
|
|
m.view = viewRuleList
|
|
m.confirm = confirmNone
|
|
if err := m.reloadRuleList(); err != nil {
|
|
m.err = err
|
|
}
|
|
}
|
|
|
|
// updateRuleList drives the rule list, including the delete confirmations.
|
|
func (m *Model) updateRuleList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
if m.confirm != confirmNone {
|
|
pending := m.confirm
|
|
m.confirm = confirmNone
|
|
if msg.String() != "y" {
|
|
m.status = "cancelled"
|
|
return m, nil
|
|
}
|
|
m.err = nil
|
|
switch pending {
|
|
case confirmDeleteRule:
|
|
if i := m.ruleListTable.Cursor(); i >= 0 && i < len(m.ruleUsage) {
|
|
if err := m.deleteRules([]int{i}); err != nil {
|
|
m.err = err
|
|
}
|
|
}
|
|
case confirmPruneRules:
|
|
if err := m.deleteRules(m.unusedRules()); err != nil {
|
|
m.err = err
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "q", "ctrl+c":
|
|
return m, tea.Quit
|
|
case "esc":
|
|
m.view = m.ruleReturn
|
|
return m, nil
|
|
case "1":
|
|
m.view = viewAccounts
|
|
return m, nil
|
|
case "2":
|
|
m.view = viewTxns
|
|
return m, nil
|
|
case "3":
|
|
m.view = viewReport
|
|
return m, nil
|
|
case "4":
|
|
return m, m.openRuleBuilder()
|
|
case "6":
|
|
return m, m.openTransferBuilder()
|
|
case "7":
|
|
m.openTransferList()
|
|
return m, nil
|
|
|
|
case "d":
|
|
i := m.ruleListTable.Cursor()
|
|
rs := m.engine.Rules()
|
|
if i < 0 || i >= len(rs) {
|
|
return m, nil
|
|
}
|
|
m.confirm = confirmDeleteRule
|
|
m.status = fmt.Sprintf("delete rule %d (%s → %s), used by %d transactions? y/n",
|
|
i+1, rulePattern(rs[i]), rs[i].Tag, m.ruleUsage[i])
|
|
return m, nil
|
|
|
|
case "p":
|
|
unused := m.unusedRules()
|
|
if len(unused) == 0 {
|
|
m.status = "no unused rules to prune"
|
|
return m, nil
|
|
}
|
|
m.confirm = confirmPruneRules
|
|
m.status = fmt.Sprintf("delete all %d rules that match nothing? y/n", len(unused))
|
|
return m, nil
|
|
|
|
case "r":
|
|
m.err = m.reloadRuleList()
|
|
m.status = "counts refreshed"
|
|
return m, nil
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
m.ruleListTable, cmd = m.ruleListTable.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
// openRuleBuilder switches to the rule builder, remembering where to return to
|
|
// and seeding the account field from whatever account is being browsed.
|
|
func (m *Model) openRuleBuilder() tea.Cmd {
|
|
if m.view != viewRules {
|
|
m.ruleReturn = m.view
|
|
}
|
|
m.view = viewRules
|
|
if m.ruleAccount.Value() == "" && m.filter.AccountSlug != "" {
|
|
m.ruleAccount.SetValue(m.filter.AccountSlug)
|
|
}
|
|
m.setRuleFocus(0)
|
|
if err := m.reloadSuggestions(); err != nil {
|
|
m.err = err
|
|
}
|
|
if err := m.reloadUntagged(); err != nil {
|
|
m.err = err
|
|
}
|
|
return textinput.Blink
|
|
}
|
|
|
|
// ruleInputs lists the form fields in tab order.
|
|
func (m *Model) ruleInputs() []*textinput.Model {
|
|
return []*textinput.Model{&m.ruleGlob, &m.ruleAccount, &m.ruleTag, &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.
|
|
func (m *Model) openTransferList() {
|
|
if m.view != viewTransferList {
|
|
m.transferReturn = 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.transferReturn
|
|
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 "enter":
|
|
m.err = nil
|
|
if err := m.saveRule(); err != nil {
|
|
m.err = err
|
|
return m, nil
|
|
}
|
|
if err := m.reloadUntagged(); err != nil {
|
|
m.err = err
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
inputs := m.ruleInputs()
|
|
*inputs[m.ruleFocus], cmd = inputs[m.ruleFocus].Update(msg)
|
|
m.refreshRulePreview()
|
|
return m, cmd
|
|
}
|
|
|
|
func (m *Model) finishImport(msg importDoneMsg) (tea.Model, tea.Cmd) {
|
|
m.importing = false
|
|
if msg.err != nil {
|
|
m.err = msg.err
|
|
m.status = "import failed"
|
|
return m, nil
|
|
}
|
|
|
|
_, added, skipped := msg.res.Total()
|
|
m.status = fmt.Sprintf("imported: %d new, %d duplicate", added, skipped)
|
|
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.
|
|
room := m.height - 6
|
|
spaced := m.height <= 0 || room >= 25
|
|
hints := m.height <= 0 || room >= 21
|
|
|
|
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"))
|
|
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()) == "" {
|
|
summary = fmt.Sprintf("%d untagged descriptions", m.ruleCandidates)
|
|
}
|
|
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:
|
|
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:
|
|
return "tab complete/next field · ↑↓ field · ctrl+n/p other completions · pgup/pgdn scroll list · enter save rule · esc back · ctrl+c quit"
|
|
case viewRuleList:
|
|
if m.confirm != confirmNone {
|
|
return "y confirm · any other key cancels"
|
|
}
|
|
return "d delete rule · p prune all unused · r refresh counts · 4 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"
|
|
}
|
|
}
|