Import ran inline in the update loop, so the whole interface froze for as long as pdftotext took over a stack of statements, with no way to tell work in progress apart from a hang. It now runs as a command off the event loop, with a spinner and a note that PDFs take a while. A repeat i press is ignored and retag is refused while an import is running, so nothing writes to the index concurrently. The result arrives as a message carrying the counts, the first failure, and the first warning with a count of any others. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
691 lines
16 KiB
Go
691 lines
16 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"
|
|
"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/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
|
|
)
|
|
|
|
// 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
|
|
|
|
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)
|
|
)
|
|
|
|
// 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
|
|
|
|
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},
|
|
}),
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
return m.updateNormal(msg)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
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)
|
|
|
|
// 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 "tab":
|
|
m.view = (m.view + 1) % 3
|
|
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())
|
|
}
|
|
}
|
|
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")
|
|
b.WriteString(helpStyle.Render(m.help()))
|
|
return b.String()
|
|
}
|
|
|
|
// 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."
|
|
}
|
|
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 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 · i import · r retag · q quit"
|
|
case viewReport:
|
|
return "1 accounts · 2 transactions · u untagged · a all accounts · q quit"
|
|
default:
|
|
return "t tag · x transfer · c clear · / search · u untagged · a all · i import · r retag · 1 accounts · 3 report · q quit"
|
|
}
|
|
}
|