Both fields are free text, and a slug or tag that is slightly wrong produces a rule that silently catches nothing — or a second, near-identical tag. They now complete against what already exists: accounts from the folders on disk and the index, tags from every tag in use plus any named in rules.toml, so a tag is completable from the moment a rule mentions it. The completion is ghosted after the cursor and never committed until it is accepted, so inventing a new tag still works. tab takes it and moves focus only when there is nothing left to complete; ctrl+n/ctrl+p cycle an ambiguous prefix, and the hint under the box says how many candidates are left. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1403 lines
36 KiB
Go
1403 lines
36 KiB
Go
// Package tui is the terminal interface: browse accounts, tag transactions,
|
|
// mark transfers by hand, and see where the money went.
|
|
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/spinner"
|
|
"github.com/charmbracelet/bubbles/table"
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"git.petrovv.com/nikola/money/internal/config"
|
|
"git.petrovv.com/nikola/money/internal/glob"
|
|
"git.petrovv.com/nikola/money/internal/importer"
|
|
"git.petrovv.com/nikola/money/internal/model"
|
|
"git.petrovv.com/nikola/money/internal/report"
|
|
"git.petrovv.com/nikola/money/internal/rules"
|
|
"git.petrovv.com/nikola/money/internal/store"
|
|
)
|
|
|
|
type view int
|
|
|
|
const (
|
|
viewAccounts view = iota
|
|
viewTxns
|
|
viewReport
|
|
viewRules
|
|
viewRuleList
|
|
|
|
viewCount = 5
|
|
)
|
|
|
|
// confirmation is a pending destructive action awaiting a y/n answer.
|
|
// Deleting rewrites a file the user maintains by hand, so it is never done on
|
|
// a single keypress.
|
|
type confirmation int
|
|
|
|
const (
|
|
confirmNone confirmation = iota
|
|
confirmDeleteRule
|
|
confirmPruneRules
|
|
)
|
|
|
|
// input is the modal state: the transaction list is read-only until the user
|
|
// starts typing a tag or a search.
|
|
type input int
|
|
|
|
const (
|
|
inputNone input = iota
|
|
inputTag
|
|
inputSearch
|
|
)
|
|
|
|
// Model is the root Bubble Tea model.
|
|
type Model struct {
|
|
root string
|
|
db *store.DB
|
|
accounts []*config.Account
|
|
engine *rules.Engine
|
|
|
|
view view
|
|
input input
|
|
|
|
accountTable table.Model
|
|
txnTable table.Model
|
|
reportTable table.Model
|
|
text textinput.Model
|
|
spinner spinner.Model
|
|
|
|
// importing is set while an import runs in the background, so the UI keeps
|
|
// redrawing instead of freezing on a slow PDF.
|
|
importing bool
|
|
|
|
txns []model.Transaction // rows currently shown in txnTable
|
|
|
|
// Rule builder: three inputs on the left, and a live preview on the right
|
|
// of which untagged descriptions the glob would catch.
|
|
ruleGlob textinput.Model
|
|
ruleAccount textinput.Model
|
|
ruleTag textinput.Model
|
|
ruleFocus int // which of the three inputs has the cursor
|
|
ruleTable table.Model
|
|
ruleReturn view // the view to go back to on esc
|
|
untagged []descGroup
|
|
ruleMatches int // untagged descriptions the current glob matches
|
|
|
|
// Rule list: every rule with the number of transactions it actually
|
|
// claims, so dead ones can be found and removed.
|
|
ruleListTable table.Model
|
|
ruleUsage []int
|
|
confirm confirmation
|
|
|
|
filter store.Filter
|
|
onlyUntagged bool
|
|
|
|
status string
|
|
err error
|
|
width int
|
|
height int
|
|
}
|
|
|
|
var (
|
|
titleStyle = lipgloss.NewStyle().Bold(true).Padding(0, 1)
|
|
statusStyle = lipgloss.NewStyle().Faint(true).Padding(0, 1)
|
|
errStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")).Padding(0, 1)
|
|
helpStyle = lipgloss.NewStyle().Faint(true).Padding(0, 1)
|
|
emptyStyle = lipgloss.NewStyle().Padding(1, 2)
|
|
|
|
labelStyle = lipgloss.NewStyle().Faint(true)
|
|
focusedLabelStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("62"))
|
|
// Both boxes are pinned to the same width: a focused input renders one
|
|
// extra cell for the cursor, which would otherwise shift the border as
|
|
// the cursor moves between fields.
|
|
boxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(lipgloss.Color("240")).Padding(0, 1).Width(ruleInputWidth + 2)
|
|
focusedBoxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(lipgloss.Color("62")).Padding(0, 1).Width(ruleInputWidth + 2)
|
|
hintStyle = lipgloss.NewStyle().Faint(true).PaddingLeft(2)
|
|
matchCountStyle = lipgloss.NewStyle().Bold(true).PaddingLeft(2)
|
|
ruleFormStyle = lipgloss.NewStyle().Width(ruleFormWidth)
|
|
)
|
|
|
|
// Run starts the interface.
|
|
func Run(root string, db *store.DB, accounts []*config.Account, engine *rules.Engine) error {
|
|
m := New(root, db, accounts, engine)
|
|
if err := m.reload(); err != nil {
|
|
return err
|
|
}
|
|
_, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
|
|
return err
|
|
}
|
|
|
|
// New builds the root model.
|
|
func New(root string, db *store.DB, accounts []*config.Account, engine *rules.Engine) *Model {
|
|
ti := textinput.New()
|
|
ti.Prompt = ""
|
|
ti.CharLimit = 64
|
|
|
|
newInput := func(placeholder string) textinput.Model {
|
|
in := textinput.New()
|
|
in.Prompt = ""
|
|
in.CharLimit = 80
|
|
in.Width = ruleInputWidth
|
|
in.Placeholder = placeholder
|
|
return in
|
|
}
|
|
|
|
// 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,
|
|
view: viewAccounts,
|
|
text: ti,
|
|
spinner: sp,
|
|
accountTable: newTable([]table.Column{
|
|
{Title: "Account", Width: 20},
|
|
{Title: "Balance", Width: 14},
|
|
{Title: "Cur", Width: 4},
|
|
{Title: "Txns", Width: 6},
|
|
}),
|
|
txnTable: newTable([]table.Column{
|
|
{Title: "Date", Width: 10},
|
|
{Title: "Account", Width: 12},
|
|
{Title: "Amount", Width: 12},
|
|
{Title: "Tag", Width: 14},
|
|
{Title: "T", Width: 1},
|
|
{Title: "Description", Width: 40},
|
|
}),
|
|
ruleGlob: newInput("*LIDL*"),
|
|
ruleAccount: completing(newInput("blank = every account")),
|
|
ruleTag: completing(newInput("groceries")),
|
|
ruleTable: newTable([]table.Column{
|
|
{Title: " ", Width: 1},
|
|
{Title: "Untagged description", Width: 44},
|
|
{Title: "N", Width: 4},
|
|
}),
|
|
ruleListTable: newTable([]table.Column{
|
|
{Title: "#", Width: 3},
|
|
{Title: " ", Width: 1},
|
|
{Title: "Pattern", Width: 34},
|
|
{Title: "Account", Width: 12},
|
|
{Title: "Tag", Width: 14},
|
|
{Title: "T", Width: 1},
|
|
{Title: "Txns", Width: 6},
|
|
}),
|
|
reportTable: newTable([]table.Column{
|
|
{Title: "Tag", Width: 20},
|
|
{Title: "Cur", Width: 4},
|
|
{Title: "Out", Width: 14},
|
|
{Title: "In", Width: 14},
|
|
{Title: "Net", Width: 14},
|
|
{Title: "N", Width: 5},
|
|
}),
|
|
}
|
|
}
|
|
|
|
// Init implements tea.Model.
|
|
func (m *Model) Init() tea.Cmd { return nil }
|
|
|
|
// reload refreshes whichever view is active from the index.
|
|
func (m *Model) reload() error {
|
|
if err := m.reloadAccounts(); err != nil {
|
|
return err
|
|
}
|
|
return m.reloadTxns()
|
|
}
|
|
|
|
func (m *Model) reloadAccounts() error {
|
|
accounts, err := m.db.Accounts()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows := make([]table.Row, 0, len(accounts))
|
|
for _, a := range accounts {
|
|
bal, err := m.db.Balance(a.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, err := m.db.Count(a.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows = append(rows, table.Row{
|
|
a.Name, model.FormatMinor(bal, a.MinorDigits), a.Currency, fmt.Sprintf("%d", n),
|
|
})
|
|
}
|
|
m.accountTable.SetRows(rows)
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) reloadTxns() error {
|
|
f := m.filter
|
|
f.Untagged = m.onlyUntagged
|
|
txns, err := m.db.Transactions(f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.txns = txns
|
|
|
|
rows := make([]table.Row, 0, len(txns))
|
|
for _, t := range txns {
|
|
transfer := ""
|
|
if t.IsTransfer() {
|
|
transfer = "T"
|
|
}
|
|
tag := t.Tag()
|
|
if tag != "" && t.ManualTag != "" {
|
|
tag += "*" // marks a manual override
|
|
}
|
|
rows = append(rows, table.Row{
|
|
t.Date, t.AccountSlug, t.FormatAmount(), tag, transfer, t.Description,
|
|
})
|
|
}
|
|
// Keep the cursor in range after the row count shrinks (e.g. tagging the
|
|
// last untagged row while the untagged filter is on).
|
|
cursor := m.txnTable.Cursor()
|
|
m.txnTable.SetRows(rows)
|
|
if cursor >= len(rows) {
|
|
cursor = len(rows) - 1
|
|
}
|
|
if cursor < 0 {
|
|
cursor = 0
|
|
}
|
|
m.txnTable.SetCursor(cursor)
|
|
|
|
m.reloadReport(txns)
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) reloadReport(txns []model.Transaction) {
|
|
rows := report.ByTag(txns)
|
|
out := make([]table.Row, 0, len(rows)+2)
|
|
for _, r := range rows {
|
|
out = append(out, table.Row{
|
|
r.Tag, r.Currency,
|
|
model.FormatMinor(r.Out, r.Digits),
|
|
model.FormatMinor(r.In, r.Digits),
|
|
model.FormatMinor(r.Net(), r.Digits),
|
|
fmt.Sprintf("%d", r.Count),
|
|
})
|
|
}
|
|
for _, c := range report.Totals(rows) {
|
|
out = append(out, table.Row{
|
|
"TOTAL", c.Currency,
|
|
model.FormatMinor(c.Out, c.Digits),
|
|
model.FormatMinor(c.In, c.Digits),
|
|
model.FormatMinor(c.Net(), c.Digits),
|
|
"",
|
|
})
|
|
}
|
|
m.reportTable.SetRows(out)
|
|
}
|
|
|
|
// ruleFormWidth is the width of the rule builder's left-hand form, and
|
|
// ruleInputWidth the editable area inside its boxes. The form must stay wider
|
|
// than a box plus its border, padding and the block cursor, or lipgloss wraps
|
|
// the border and the panel falls apart.
|
|
const (
|
|
ruleInputWidth = 26
|
|
ruleFormWidth = ruleInputWidth + 12
|
|
)
|
|
|
|
// descGroup is one distinct untagged description and how often it occurs.
|
|
// Grouping matters: a statement holds the same payee dozens of times, and a
|
|
// rule is written against the description, not against individual rows.
|
|
type descGroup struct {
|
|
Description string
|
|
Accounts map[string]bool
|
|
Count int
|
|
}
|
|
|
|
// reloadUntagged rebuilds the alphabetical list of untagged descriptions that
|
|
// the rule builder previews against.
|
|
func (m *Model) reloadUntagged() error {
|
|
txns, err := m.db.Transactions(store.Filter{Untagged: true})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
byDesc := map[string]*descGroup{}
|
|
for _, t := range txns {
|
|
key := model.NormalizeDescription(t.Description)
|
|
g, ok := byDesc[key]
|
|
if !ok {
|
|
g = &descGroup{Description: t.Description, Accounts: map[string]bool{}}
|
|
byDesc[key] = g
|
|
}
|
|
g.Accounts[t.AccountSlug] = true
|
|
g.Count++
|
|
}
|
|
|
|
m.untagged = make([]descGroup, 0, len(byDesc))
|
|
for _, g := range byDesc {
|
|
m.untagged = append(m.untagged, *g)
|
|
}
|
|
sort.Slice(m.untagged, func(i, j int) bool {
|
|
a := model.NormalizeDescription(m.untagged[i].Description)
|
|
b := model.NormalizeDescription(m.untagged[j].Description)
|
|
if a != b {
|
|
return a < b
|
|
}
|
|
return m.untagged[i].Description < m.untagged[j].Description
|
|
})
|
|
|
|
m.refreshRulePreview()
|
|
return nil
|
|
}
|
|
|
|
// refreshRulePreview re-marks the preview against whatever is typed right now.
|
|
// It runs on every keystroke, so the glob is checked against descriptions only,
|
|
// never against the database.
|
|
func (m *Model) refreshRulePreview() {
|
|
var (
|
|
pattern = strings.TrimSpace(m.ruleGlob.Value())
|
|
account = strings.TrimSpace(m.ruleAccount.Value())
|
|
rows = make([]table.Row, 0, len(m.untagged))
|
|
)
|
|
m.ruleMatches = 0
|
|
|
|
for _, g := range m.untagged {
|
|
// An account filter narrows the preview the same way the saved rule
|
|
// will narrow its matching.
|
|
if account != "" && !g.Accounts[account] {
|
|
continue
|
|
}
|
|
marker := " "
|
|
if pattern != "" && glob.Match(pattern, model.NormalizeDescription(g.Description)) {
|
|
marker = "▸"
|
|
m.ruleMatches++
|
|
}
|
|
rows = append(rows, table.Row{marker, g.Description, fmt.Sprintf("%d", g.Count)})
|
|
}
|
|
|
|
cursor := m.ruleTable.Cursor()
|
|
m.ruleTable.SetRows(rows)
|
|
if cursor >= len(rows) {
|
|
cursor = len(rows) - 1
|
|
}
|
|
if cursor < 0 {
|
|
cursor = 0
|
|
}
|
|
m.ruleTable.SetCursor(cursor)
|
|
}
|
|
|
|
// saveRule appends the composed rule to rules.toml, reloads the engine and
|
|
// retags, so the effect is visible immediately.
|
|
func (m *Model) saveRule() error {
|
|
r := config.Rule{
|
|
Match: strings.TrimSpace(m.ruleGlob.Value()),
|
|
Account: strings.TrimSpace(m.ruleAccount.Value()),
|
|
Tag: strings.TrimSpace(m.ruleTag.Value()),
|
|
}
|
|
if r.Match == "" {
|
|
return fmt.Errorf("enter a glob first, e.g. *LIDL*")
|
|
}
|
|
if r.Tag == "" {
|
|
return fmt.Errorf("enter a tag to apply")
|
|
}
|
|
if r.Account != "" && !m.knownAccount(r.Account) {
|
|
return fmt.Errorf("no account called %q; leave it blank to apply to every account", r.Account)
|
|
}
|
|
|
|
if err := config.AppendRule(m.root, r); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Re-read the file rather than appending to the in-memory engine, so what
|
|
// runs is exactly what is now on disk.
|
|
loaded, err := config.LoadRules(m.root)
|
|
if err != nil {
|
|
return fmt.Errorf("rule saved, but re-reading rules.toml failed: %w", err)
|
|
}
|
|
m.engine = rules.New(loaded)
|
|
|
|
n, err := m.engine.Retag(m.db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
m.status = fmt.Sprintf("saved rule %s → %s, %d transactions retagged", r.Match, r.Tag, n)
|
|
m.ruleGlob.SetValue("")
|
|
m.ruleTag.SetValue("")
|
|
m.setRuleFocus(0)
|
|
if err := m.reloadSuggestions(); err != nil {
|
|
return err
|
|
}
|
|
return m.reload()
|
|
}
|
|
|
|
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 focused 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 (m *Model) acceptCompletion() bool {
|
|
in := m.ruleInputs()[m.ruleFocus]
|
|
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
|
|
// an earlier rule already took everything it would have caught; both show up
|
|
// here as a zero.
|
|
func (m *Model) reloadRuleList() error {
|
|
txns, err := m.db.Transactions(store.Filter{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rs := m.engine.Rules()
|
|
m.ruleUsage = m.engine.Usage(txns)
|
|
|
|
rows := make([]table.Row, 0, len(rs))
|
|
for i, r := range rs {
|
|
marker := " "
|
|
if m.ruleUsage[i] == 0 {
|
|
marker = "✗"
|
|
}
|
|
transfer := ""
|
|
if r.Transfer {
|
|
transfer = "T"
|
|
}
|
|
account := r.Account
|
|
if account == "" {
|
|
account = "(all)"
|
|
}
|
|
rows = append(rows, table.Row{
|
|
fmt.Sprintf("%d", i+1), marker, rulePattern(r), account, r.Tag, transfer,
|
|
fmt.Sprintf("%d", m.ruleUsage[i]),
|
|
})
|
|
}
|
|
|
|
cursor := m.ruleListTable.Cursor()
|
|
m.ruleListTable.SetRows(rows)
|
|
if cursor >= len(rows) {
|
|
cursor = len(rows) - 1
|
|
}
|
|
if cursor < 0 {
|
|
cursor = 0
|
|
}
|
|
m.ruleListTable.SetCursor(cursor)
|
|
return nil
|
|
}
|
|
|
|
// rulePattern renders whichever patterns a rule sets, labelled so a
|
|
// counterparty or type rule is not mistaken for a description one.
|
|
func rulePattern(r config.Rule) string {
|
|
var parts []string
|
|
if r.Match != "" {
|
|
parts = append(parts, r.Match)
|
|
}
|
|
if r.Counterparty != "" {
|
|
parts = append(parts, "counterparty:"+r.Counterparty)
|
|
}
|
|
if r.Type != "" {
|
|
parts = append(parts, "type:"+r.Type)
|
|
}
|
|
return strings.Join(parts, " + ")
|
|
}
|
|
|
|
// unusedRules lists the positions of every rule claiming no transactions.
|
|
func (m *Model) unusedRules() []int {
|
|
var out []int
|
|
for i, n := range m.ruleUsage {
|
|
if n == 0 {
|
|
out = append(out, i)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// deleteRules removes rules from rules.toml, then reloads and retags so the
|
|
// counts on screen reflect the new file.
|
|
func (m *Model) deleteRules(positions []int) error {
|
|
n, err := config.DeleteRules(m.root, positions)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
loaded, err := config.LoadRules(m.root)
|
|
if err != nil {
|
|
return fmt.Errorf("rules deleted, but re-reading rules.toml failed: %w", err)
|
|
}
|
|
m.engine = rules.New(loaded)
|
|
|
|
retagged, err := m.engine.Retag(m.db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.status = fmt.Sprintf("deleted %d rule(s), %d transactions retagged", n, retagged)
|
|
|
|
if err := m.reload(); err != nil {
|
|
return err
|
|
}
|
|
return m.reloadRuleList()
|
|
}
|
|
|
|
// openRuleList switches to the rule list, remembering where to return to.
|
|
func (m *Model) openRuleList() {
|
|
if m.view != viewRuleList {
|
|
m.ruleReturn = m.view
|
|
}
|
|
m.view = viewRuleList
|
|
m.confirm = confirmNone
|
|
if err := m.reloadRuleList(); err != nil {
|
|
m.err = err
|
|
}
|
|
}
|
|
|
|
// updateRuleList drives the rule list, including the delete confirmations.
|
|
func (m *Model) updateRuleList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
if m.confirm != confirmNone {
|
|
pending := m.confirm
|
|
m.confirm = confirmNone
|
|
if msg.String() != "y" {
|
|
m.status = "cancelled"
|
|
return m, nil
|
|
}
|
|
m.err = nil
|
|
switch pending {
|
|
case confirmDeleteRule:
|
|
if i := m.ruleListTable.Cursor(); i >= 0 && i < len(m.ruleUsage) {
|
|
if err := m.deleteRules([]int{i}); err != nil {
|
|
m.err = err
|
|
}
|
|
}
|
|
case confirmPruneRules:
|
|
if err := m.deleteRules(m.unusedRules()); err != nil {
|
|
m.err = err
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "q", "ctrl+c":
|
|
return m, tea.Quit
|
|
case "esc":
|
|
m.view = m.ruleReturn
|
|
return m, nil
|
|
case "1":
|
|
m.view = viewAccounts
|
|
return m, nil
|
|
case "2":
|
|
m.view = viewTxns
|
|
return m, nil
|
|
case "3":
|
|
m.view = viewReport
|
|
return m, nil
|
|
case "4":
|
|
return m, m.openRuleBuilder()
|
|
|
|
case "d":
|
|
i := m.ruleListTable.Cursor()
|
|
rs := m.engine.Rules()
|
|
if i < 0 || i >= len(rs) {
|
|
return m, nil
|
|
}
|
|
m.confirm = confirmDeleteRule
|
|
m.status = fmt.Sprintf("delete rule %d (%s → %s), used by %d transactions? y/n",
|
|
i+1, rulePattern(rs[i]), rs[i].Tag, m.ruleUsage[i])
|
|
return m, nil
|
|
|
|
case "p":
|
|
unused := m.unusedRules()
|
|
if len(unused) == 0 {
|
|
m.status = "no unused rules to prune"
|
|
return m, nil
|
|
}
|
|
m.confirm = confirmPruneRules
|
|
m.status = fmt.Sprintf("delete all %d rules that match nothing? y/n", len(unused))
|
|
return m, nil
|
|
|
|
case "r":
|
|
m.err = m.reloadRuleList()
|
|
m.status = "counts refreshed"
|
|
return m, nil
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
m.ruleListTable, cmd = m.ruleListTable.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
// openRuleBuilder switches to the rule builder, remembering where to return to
|
|
// and seeding the account field from whatever account is being browsed.
|
|
func (m *Model) openRuleBuilder() tea.Cmd {
|
|
if m.view != viewRules {
|
|
m.ruleReturn = m.view
|
|
}
|
|
m.view = viewRules
|
|
if m.ruleAccount.Value() == "" && m.filter.AccountSlug != "" {
|
|
m.ruleAccount.SetValue(m.filter.AccountSlug)
|
|
}
|
|
m.setRuleFocus(0)
|
|
if err := m.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}
|
|
}
|
|
|
|
// setRuleFocus moves the cursor between the form fields, wrapping around.
|
|
func (m *Model) setRuleFocus(i int) {
|
|
inputs := m.ruleInputs()
|
|
n := len(inputs)
|
|
m.ruleFocus = ((i % n) + n) % n
|
|
for j, in := range inputs {
|
|
if j == m.ruleFocus {
|
|
in.Focus()
|
|
in.CursorEnd()
|
|
continue
|
|
}
|
|
in.Blur()
|
|
}
|
|
}
|
|
|
|
// selected returns the transaction under the cursor, if any.
|
|
func (m *Model) selected() (model.Transaction, bool) {
|
|
i := m.txnTable.Cursor()
|
|
if i < 0 || i >= len(m.txns) {
|
|
return model.Transaction{}, false
|
|
}
|
|
return m.txns[i], true
|
|
}
|
|
|
|
// importDoneMsg carries the outcome of a background import back to the model.
|
|
type importDoneMsg struct {
|
|
res importer.Result
|
|
err error
|
|
}
|
|
|
|
// importCmd runs the import off the event loop. Only values are captured, and
|
|
// the model is left untouched until the result comes back as a message.
|
|
func (m *Model) importCmd() tea.Cmd {
|
|
root, db, accounts, engine := m.root, m.db, m.accounts, m.engine
|
|
return func() tea.Msg {
|
|
res, err := importer.Run(root, db, accounts, engine, importer.Options{})
|
|
return importDoneMsg{res: res, err: err}
|
|
}
|
|
}
|
|
|
|
// Update implements tea.Model.
|
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width, m.height = msg.Width, msg.Height
|
|
m.resize()
|
|
return m, nil
|
|
|
|
case spinner.TickMsg:
|
|
if !m.importing {
|
|
return m, nil // a stale tick from a finished import
|
|
}
|
|
var cmd tea.Cmd
|
|
m.spinner, cmd = m.spinner.Update(msg)
|
|
return m, cmd
|
|
|
|
case importDoneMsg:
|
|
return m.finishImport(msg)
|
|
|
|
case tea.KeyMsg:
|
|
if m.input != inputNone {
|
|
return m.updateInput(msg)
|
|
}
|
|
// The rule builder is a form: every printable key belongs to the
|
|
// focused input, so the global single-letter keymap cannot apply.
|
|
if m.view == viewRules {
|
|
return m.updateRules(msg)
|
|
}
|
|
if m.view == viewRuleList {
|
|
return m.updateRuleList(msg)
|
|
}
|
|
return m.updateNormal(msg)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// updateRules drives the rule builder form.
|
|
func (m *Model) updateRules(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
return m, tea.Quit
|
|
case "esc":
|
|
m.view = m.ruleReturn
|
|
return m, nil
|
|
case "tab":
|
|
// 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 m.acceptCompletion() {
|
|
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())) && m.acceptCompletion() {
|
|
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)
|
|
|
|
// A failed file and a warning are both worth surfacing, but the status
|
|
// line only has room for the first thing that went wrong.
|
|
if failures := msg.res.Errs(); len(failures) > 0 {
|
|
m.err = fmt.Errorf("%s: %w", failures[0].Path, failures[0].Err)
|
|
} else if warnings := firstWarning(msg.res); warnings != "" {
|
|
m.status += " · " + warnings
|
|
}
|
|
|
|
if err := m.reload(); err != nil {
|
|
m.err = err
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func firstWarning(res importer.Result) string {
|
|
total := 0
|
|
first := ""
|
|
for _, f := range res.Files {
|
|
for _, w := range f.Warnings {
|
|
if first == "" {
|
|
first = fmt.Sprintf("%s: %s", f.Path, w)
|
|
}
|
|
total++
|
|
}
|
|
}
|
|
if total > 1 {
|
|
return fmt.Sprintf("%s (+%d more warnings)", first, total-1)
|
|
}
|
|
return first
|
|
}
|
|
|
|
func (m *Model) resize() {
|
|
h := m.height - 6 // title, status, help, padding
|
|
if h < 3 {
|
|
h = 3
|
|
}
|
|
m.accountTable.SetHeight(h)
|
|
m.txnTable.SetHeight(h)
|
|
m.reportTable.SetHeight(h)
|
|
m.ruleTable.SetHeight(h)
|
|
m.ruleListTable.SetHeight(h)
|
|
|
|
// The preview list gets whatever the form does not use.
|
|
if m.width > 0 {
|
|
cols := m.ruleTable.Columns()
|
|
desc := m.width - ruleFormWidth - cols[0].Width - cols[2].Width - 10
|
|
if desc < 20 {
|
|
desc = 20
|
|
}
|
|
cols[1].Width = desc
|
|
m.ruleTable.SetColumns(cols)
|
|
}
|
|
|
|
// Give the description column whatever is left over.
|
|
if m.width > 0 {
|
|
cols := m.txnTable.Columns()
|
|
fixed := 0
|
|
for _, c := range cols[:len(cols)-1] {
|
|
fixed += c.Width + 2
|
|
}
|
|
desc := m.width - fixed - 4
|
|
if desc < 20 {
|
|
desc = 20
|
|
}
|
|
cols[len(cols)-1].Width = desc
|
|
m.txnTable.SetColumns(cols)
|
|
}
|
|
}
|
|
|
|
// updateInput handles typing into the tag or search prompt.
|
|
func (m *Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.Type {
|
|
case tea.KeyEsc:
|
|
m.input = inputNone
|
|
m.text.Blur()
|
|
m.text.SetValue("")
|
|
return m, nil
|
|
case tea.KeyEnter:
|
|
value := strings.TrimSpace(m.text.Value())
|
|
mode := m.input
|
|
m.input = inputNone
|
|
m.text.Blur()
|
|
m.text.SetValue("")
|
|
|
|
switch mode {
|
|
case inputTag:
|
|
t, ok := m.selected()
|
|
if !ok {
|
|
return m, nil
|
|
}
|
|
if err := m.db.SetManualTag(t.ID, value); err != nil {
|
|
m.err = err
|
|
return m, nil
|
|
}
|
|
if value == "" {
|
|
m.status = "cleared manual tag"
|
|
} else {
|
|
m.status = "tagged as " + value
|
|
}
|
|
case inputSearch:
|
|
m.filter.Search = value
|
|
m.status = "search: " + value
|
|
if value == "" {
|
|
m.status = "search cleared"
|
|
}
|
|
}
|
|
m.err = m.reloadTxns()
|
|
return m, nil
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
m.text, cmd = m.text.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
// updateNormal handles the main keymap.
|
|
func (m *Model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "q", "ctrl+c":
|
|
return m, tea.Quit
|
|
|
|
case "1":
|
|
m.view = viewAccounts
|
|
return m, nil
|
|
case "2":
|
|
m.view = viewTxns
|
|
return m, nil
|
|
case "3":
|
|
m.view = viewReport
|
|
return m, nil
|
|
case "4":
|
|
return m, m.openRuleBuilder()
|
|
case "5":
|
|
m.openRuleList()
|
|
return m, nil
|
|
case "tab":
|
|
m.view = (m.view + 1) % viewCount
|
|
switch m.view {
|
|
case viewRules:
|
|
return m, m.openRuleBuilder()
|
|
case viewRuleList:
|
|
m.openRuleList()
|
|
}
|
|
return m, nil
|
|
|
|
case "enter":
|
|
// From the account list, drill into that account's transactions.
|
|
if m.view == viewAccounts {
|
|
accounts, err := m.db.Accounts()
|
|
if err != nil {
|
|
m.err = err
|
|
return m, nil
|
|
}
|
|
i := m.accountTable.Cursor()
|
|
if i >= 0 && i < len(accounts) {
|
|
m.filter.AccountSlug = accounts[i].Slug
|
|
m.status = "account: " + accounts[i].Slug
|
|
m.view = viewTxns
|
|
m.err = m.reloadTxns()
|
|
}
|
|
}
|
|
return m, nil
|
|
|
|
case "a":
|
|
m.filter.AccountSlug = ""
|
|
m.status = "all accounts"
|
|
m.err = m.reloadTxns()
|
|
return m, nil
|
|
|
|
case "u":
|
|
m.onlyUntagged = !m.onlyUntagged
|
|
if m.onlyUntagged {
|
|
m.status = "showing untagged only"
|
|
m.view = viewTxns
|
|
} else {
|
|
m.status = "showing all transactions"
|
|
}
|
|
m.err = m.reloadTxns()
|
|
return m, nil
|
|
|
|
case "t":
|
|
if m.view != viewTxns {
|
|
return m, nil
|
|
}
|
|
t, ok := m.selected()
|
|
if !ok {
|
|
return m, nil
|
|
}
|
|
m.input = inputTag
|
|
m.text.SetValue(t.ManualTag)
|
|
m.text.CursorEnd()
|
|
m.text.Focus()
|
|
return m, nil
|
|
|
|
case "/":
|
|
m.view = viewTxns
|
|
m.input = inputSearch
|
|
m.text.SetValue(m.filter.Search)
|
|
m.text.CursorEnd()
|
|
m.text.Focus()
|
|
return m, nil
|
|
|
|
case "x":
|
|
// Toggle the manual transfer override away from whatever is in effect.
|
|
if m.view != viewTxns {
|
|
return m, nil
|
|
}
|
|
t, ok := m.selected()
|
|
if !ok {
|
|
return m, nil
|
|
}
|
|
v := !t.IsTransfer()
|
|
if err := m.db.SetManualTransfer(t.ID, &v); err != nil {
|
|
m.err = err
|
|
return m, nil
|
|
}
|
|
if v {
|
|
m.status = "marked as transfer"
|
|
} else {
|
|
m.status = "unmarked as transfer"
|
|
}
|
|
m.err = m.reloadTxns()
|
|
return m, nil
|
|
|
|
case "c":
|
|
// Drop manual overrides and fall back to what the rules say.
|
|
if m.view != viewTxns {
|
|
return m, nil
|
|
}
|
|
t, ok := m.selected()
|
|
if !ok {
|
|
return m, nil
|
|
}
|
|
if err := m.db.ClearOverrides(t.ID); err != nil {
|
|
m.err = err
|
|
return m, nil
|
|
}
|
|
m.status = "manual overrides cleared"
|
|
m.err = m.reloadTxns()
|
|
return m, nil
|
|
|
|
case "r":
|
|
if m.importing {
|
|
m.status = "import in progress…"
|
|
return m, nil
|
|
}
|
|
n, err := m.engine.Retag(m.db)
|
|
if err != nil {
|
|
m.err = err
|
|
return m, nil
|
|
}
|
|
m.status = fmt.Sprintf("rules re-applied, %d rows changed", n)
|
|
m.err = m.reload()
|
|
return m, nil
|
|
|
|
case "i":
|
|
if m.importing {
|
|
return m, nil // already running; ignore the repeat press
|
|
}
|
|
m.importing = true
|
|
m.err = nil
|
|
m.status = ""
|
|
return m, tea.Batch(m.spinner.Tick, m.importCmd())
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
switch m.view {
|
|
case viewAccounts:
|
|
m.accountTable, cmd = m.accountTable.Update(msg)
|
|
case viewTxns:
|
|
m.txnTable, cmd = m.txnTable.Update(msg)
|
|
case viewReport:
|
|
m.reportTable, cmd = m.reportTable.Update(msg)
|
|
}
|
|
return m, cmd
|
|
}
|
|
|
|
// View implements tea.Model.
|
|
func (m *Model) View() string {
|
|
var b strings.Builder
|
|
|
|
b.WriteString(titleStyle.Render(m.title()))
|
|
b.WriteString("\n")
|
|
|
|
// An empty table is just a bare header, which says nothing about why it is
|
|
// empty or what to do next.
|
|
if empty := m.emptyMessage(); empty != "" {
|
|
b.WriteString(emptyStyle.Render(empty))
|
|
} else {
|
|
switch m.view {
|
|
case viewAccounts:
|
|
b.WriteString(m.accountTable.View())
|
|
case viewTxns:
|
|
b.WriteString(m.txnTable.View())
|
|
case viewReport:
|
|
b.WriteString(m.reportTable.View())
|
|
case viewRules:
|
|
b.WriteString(m.rulesView())
|
|
case viewRuleList:
|
|
b.WriteString(m.ruleListTable.View())
|
|
}
|
|
}
|
|
b.WriteString("\n")
|
|
|
|
switch {
|
|
case m.importing:
|
|
b.WriteString(statusStyle.Render(m.spinner.View() + m.importingLabel()))
|
|
case m.input == inputTag:
|
|
b.WriteString(statusStyle.Render("tag: ") + m.text.View())
|
|
case m.input == inputSearch:
|
|
b.WriteString(statusStyle.Render("search: ") + m.text.View())
|
|
case m.err != nil:
|
|
b.WriteString(errStyle.Render("error: " + m.err.Error()))
|
|
default:
|
|
b.WriteString(statusStyle.Render(m.status))
|
|
}
|
|
b.WriteString("\n")
|
|
// 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 {
|
|
field := func(i int, label, help string) string {
|
|
name := labelStyle.Render(" " + label)
|
|
box := boxStyle.Render(m.ruleInputs()[i].View())
|
|
if i == m.ruleFocus {
|
|
name = focusedLabelStyle.Render("▸ " + label)
|
|
box = focusedBoxStyle.Render(m.ruleInputs()[i].View())
|
|
}
|
|
return name + "\n" + box + "\n" + hintStyle.Render(help) + "\n"
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString(field(0, "glob", "vs. the description"))
|
|
b.WriteString("\n")
|
|
b.WriteString(field(1, "account", m.completionHint(1, "blank = all accounts")))
|
|
b.WriteString("\n")
|
|
b.WriteString(field(2, "tag", m.completionHint(2, "applied to matches")))
|
|
b.WriteString("\n")
|
|
|
|
// The count is the whole point of the preview: it says what the rule will
|
|
// do before it is written to disk.
|
|
summary := fmt.Sprintf("%d of %d descriptions match", m.ruleMatches, len(m.ruleTable.Rows()))
|
|
if strings.TrimSpace(m.ruleGlob.Value()) == "" {
|
|
summary = fmt.Sprintf("%d untagged descriptions", len(m.ruleTable.Rows()))
|
|
}
|
|
b.WriteString(matchCountStyle.Render(summary))
|
|
|
|
return ruleFormStyle.Render(b.String())
|
|
}
|
|
|
|
// 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 (m *Model) completionHint(i int, fallback string) string {
|
|
if i != m.ruleFocus {
|
|
return fallback
|
|
}
|
|
in := m.ruleInputs()[i]
|
|
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\nEvery transaction here has a tag. Press u to see them all."
|
|
case m.filter.AccountSlug != "":
|
|
return fmt.Sprintf(
|
|
"No transactions in %s.\n\nPress i to import, or a to see every account.",
|
|
m.filter.AccountSlug)
|
|
}
|
|
return "No transactions yet.\n\nPress i to import (same as running `money import`)."
|
|
|
|
case viewReport:
|
|
if len(m.reportTable.Rows()) > 0 {
|
|
return ""
|
|
}
|
|
return "Nothing to report yet.\n\nPress i to import some statements first."
|
|
|
|
case viewRuleList:
|
|
if len(m.ruleListTable.Rows()) > 0 {
|
|
return ""
|
|
}
|
|
return "No rules yet.\n\nPress 4 to build one, or write rules.toml by hand."
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (m *Model) title() string {
|
|
scope := "all accounts"
|
|
if m.filter.AccountSlug != "" {
|
|
scope = m.filter.AccountSlug
|
|
}
|
|
if m.onlyUntagged {
|
|
scope += " · untagged"
|
|
}
|
|
if m.filter.Search != "" {
|
|
scope += " · /" + m.filter.Search
|
|
}
|
|
|
|
switch m.view {
|
|
case viewAccounts:
|
|
return "money · accounts"
|
|
case viewRules:
|
|
return "money · rule builder · writes to rules.toml"
|
|
case viewRuleList:
|
|
unused := len(m.unusedRules())
|
|
if unused == 0 {
|
|
return fmt.Sprintf("money · rules · %d rules, all in use", len(m.engine.Rules()))
|
|
}
|
|
return fmt.Sprintf("money · rules · %d rules · %d match nothing", len(m.engine.Rules()), unused)
|
|
case viewReport:
|
|
return "money · report · " + scope + " · transfers excluded"
|
|
default:
|
|
return fmt.Sprintf("money · transactions · %s · %d rows", scope, len(m.txns))
|
|
}
|
|
}
|
|
|
|
// importingLabel names what the import is working through, since extracting
|
|
// text from PDFs is where the wait actually comes from.
|
|
func (m *Model) importingLabel() string {
|
|
if n := len(m.accounts); n > 0 {
|
|
return fmt.Sprintf("importing %d account(s)… parsing statements can take a while for PDFs", n)
|
|
}
|
|
return "importing…"
|
|
}
|
|
|
|
func (m *Model) help() string {
|
|
if m.importing {
|
|
return "importing… · q quit"
|
|
}
|
|
if m.input != inputNone {
|
|
return "enter confirm · esc cancel"
|
|
}
|
|
switch m.view {
|
|
case viewAccounts:
|
|
return "enter open · 2 transactions · 3 report · 4 new rule · 5 rules · 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 · 1 accounts · esc back · q quit"
|
|
case viewReport:
|
|
return "1 accounts · 2 transactions · 4 new rule · 5 rules · u untagged · a all accounts · q quit"
|
|
default:
|
|
return "t tag · x transfer · c clear · / search · u untagged · a all · i import · r retag · 4 new rule · 5 rules · q quit"
|
|
}
|
|
}
|