M cmd/ledger-tui/ledger-tui.go => cmd/ledger-tui/ledger-tui.go +34 -0
@@ 1,6 1,7 @@
package main
import (
+ "errors"
"fmt"
"io"
"log"
@@ 23,6 24,8 @@ var rootCmd = &cobra.Command{
var debugLogging bool
var debugLoggingFile *os.File
+var ledgerFilePath string
+
func configureLogging(_ *cobra.Command, _ []string) {
if debugLogging {
var err error
@@ 43,9 46,40 @@ func loggingCleanup(_ *cobra.Command, _ []string) {
}
}
+func verifyLedgerFile() {
+ f, err := rootCmd.Flags().GetString("file")
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "Error loading value of the file argument: "+err.Error())
+ os.Exit(1)
+ }
+
+ if f == "" {
+ var ok bool
+ f, ok = os.LookupEnv("LEDGER_FILE")
+ if !ok {
+ fmt.Fprintln(os.Stderr,
+ "You must specify the -f option or set the LEDGER_FILE environment variable")
+ fmt.Fprintln(os.Stderr, "Try 'ledger-tui --help' for more information.")
+ os.Exit(1)
+ }
+
+ rootCmd.Flags().Set("file", f)
+ }
+
+ if _, err := os.Stat(f); errors.Is(err, os.ErrNotExist) {
+ fmt.Fprintln(os.Stderr, "Ledger file specified does not exist.")
+ os.Exit(1)
+ }
+}
+
func main() {
rootCmd.PersistentFlags().BoolVar(&debugLogging, "debug", false,
"Output debug logs to debug.log file")
+ rootCmd.PersistentFlags().StringVarP(&ledgerFilePath, "file", "f", "",
+ "Path to the ledger file that should be loaded. If empty, the value "+
+ "from the LEDGER_FILE environment variable is used.")
+
+ cobra.OnInitialize(verifyLedgerFile)
rootCmd.AddCommand(add.AddCmd)
rootCmd.AddCommand(license.LicenseCmd)
A pkg/collections/collections.go => pkg/collections/collections.go +9 -0
@@ 0,0 1,9 @@
+package collections
+
+func Map[T any, M any](a []T, f func(T) M) []M {
+ n := make([]M, len(a))
+ for i, e := range a {
+ n[i] = f(e)
+ }
+ return n
+}
M pkg/ledger/hledger/hledger.go => pkg/ledger/hledger/hledger.go +107 -18
@@ 1,8 1,12 @@
package hledger
import (
+ "bytes"
+ "encoding/json"
"errors"
+ "os/exec"
+ "github.com/tgrosinger/ledger-tui/pkg/transaction"
tx "github.com/tgrosinger/ledger-tui/pkg/transaction"
)
@@ 12,8 16,30 @@ type HLedger struct {
filepath string
}
+func New(filepath string) HLedger {
+ return HLedger{
+ filepath: filepath,
+ }
+}
+
+// GetTransactions returns a slice of transactions loaded from the configured
+// ledger file.
func (hl HLedger) GetTransactions() ([]tx.Transaction, error) {
- return nil, errors.New("Method not implemented")
+ out, err := hl.exec("print", "-O", "json")
+ if err != nil {
+ return nil, err
+ }
+
+ var results []Transaction
+ err = json.Unmarshal([]byte(out), &results)
+ if err != nil {
+ return nil, err
+ }
+
+ converted, errors := hl.convertTransactions(results)
+ return converted, transaction.TransactionParseErrors{
+ Errors: errors,
+ }
}
func (hl HLedger) AddTransaction(newTx tx.Transaction) error {
@@ 21,15 47,59 @@ func (hl HLedger) AddTransaction(newTx tx.Transaction) error {
}
+// exec runs an hledger command against the configured ledger file.
+func (hl HLedger) exec(args ...string) (string, error) {
+ allArgs := make([]string, 0, len(args)+2)
+ allArgs = append(allArgs, "-f", hl.filepath)
+ allArgs = append(allArgs, args...)
+
+ cmd := exec.Command("hledger", allArgs...)
+
+ var out bytes.Buffer
+ cmd.Stdout = &out
+
+ err := cmd.Run()
+ if err != nil {
+ return "", err
+ }
+
+ return out.String(), nil
+}
+
+// convertTransaction converts the transactions as loaded from hledger into a
+// friendlier and simplified format used by this application.
+func (hl HLedger) convertTransactions(txs []Transaction) ([]transaction.Transaction, []transaction.TransactionParseError) {
+ output := make([]transaction.Transaction, 0, len(txs))
+ errors := make([]transaction.TransactionParseError, 0)
+ for _, tx := range txs {
+ converted, err := tx.convert()
+ if err != nil {
+ position := -1
+ if len(tx.Tsourcepos) > 0 {
+ position = tx.Tsourcepos[0].SourceLine
+ }
+ errors = append(errors, transaction.TransactionParseError{
+ Err: err,
+ Date: tx.Tdate,
+ Description: tx.Tdescription,
+ Line: position,
+ })
+ } else {
+ output = append(output, converted)
+ }
+ }
+ return output, errors
+}
+
// Transaction is the data format for transactions as they come from hledger
// when executing `hledger print -O json`.
type Transaction struct {
- Tcode string `json:"tcode"`
- Tcomment string `json:"tcomment"`
- Tdate string `json:"tdate"`
- Tdate2 interface{} `json:"tdate2"`
- Tdescription string `json:"tdescription"`
- Tindex int `json:"tindex"`
+ Tcode string `json:"tcode"`
+ Tcomment string `json:"tcomment"`
+ Tdate string `json:"tdate"`
+ Tdate2 string `json:"tdate2"`
+ Tdescription string `json:"tdescription"`
+ Tindex int `json:"tindex"`
Tpostings []struct {
Paccount string `json:"paccount"`
Pamount []struct {
@@ 48,15 118,15 @@ type Transaction struct {
Asprecision int `json:"asprecision"`
} `json:"astyle"`
} `json:"pamount"`
- Pbalanceassertion interface{} `json:"pbalanceassertion"`
- Pcomment string `json:"pcomment"`
- Pdate interface{} `json:"pdate"`
- Pdate2 interface{} `json:"pdate2"`
- Poriginal interface{} `json:"poriginal"`
- Pstatus string `json:"pstatus"`
- Ptags []interface{} `json:"ptags"`
- Ptransaction string `json:"ptransaction_"`
- Ptype string `json:"ptype"`
+ Pbalanceassertion interface{} `json:"pbalanceassertion"`
+ Pcomment string `json:"pcomment"`
+ Pdate interface{} `json:"pdate"`
+ Pdate2 interface{} `json:"pdate2"`
+ Poriginal interface{} `json:"poriginal"`
+ Pstatus string `json:"pstatus"`
+ Ptags []string `json:"ptags"`
+ Ptransaction string `json:"ptransaction_"`
+ Ptype string `json:"ptype"`
} `json:"tpostings"`
Tprecedingcomment string `json:"tprecedingcomment"`
Tsourcepos []struct {
@@ 64,6 134,25 @@ type Transaction struct {
SourceLine int `json:"sourceLine"`
SourceName string `json:"sourceName"`
} `json:"tsourcepos"`
- Tstatus string `json:"tstatus"`
- Ttags []interface{} `json:"ttags"`
+ Tstatus string `json:"tstatus"`
+ Ttags []string `json:"ttags"`
+}
+
+func (t *Transaction) convert() (transaction.Transaction, error) {
+ tx := transaction.Transaction{
+ Index: t.Tindex,
+ Date: t.Tdate,
+ Description: t.Tdescription,
+ Comment: t.Tcomment,
+ PreceedingComment: t.Tprecedingcomment,
+ Postings: make([]transaction.Posting, len(t.Tpostings)),
+ Status: t.Tstatus,
+ Tags: t.Ttags,
+ }
+
+ for _, posting := range t.Tpostings {
+ tx.Postings = append(tx.Postings, posting.convert())
+ }
+
+ return tx, nil
}
M pkg/transaction/transaction.go => pkg/transaction/transaction.go +30 -2
@@ 1,6 1,11 @@
package transaction
-import "time"
+import (
+ "strings"
+ "time"
+
+ "github.com/tgrosinger/ledger-tui/pkg/collections"
+)
// Transaction contains the information stored in a ledger file about a single
// transaction.
@@ 24,6 29,29 @@ type Posting struct {
Tags []string
}
-func (t Transaction) string() string {
+func (t Transaction) String() string {
return "TODO"
}
+
+type TransactionParseError struct {
+ Err error
+ Date string
+ Description string
+ Line int
+}
+
+func (e TransactionParseError) Error() string {
+ return e.Err.Error()
+}
+
+type TransactionParseErrors struct {
+ Errors []TransactionParseError
+}
+
+func (e TransactionParseErrors) Error() string {
+ return strings.Join(collections.Map(
+ e.Errors,
+ func(t TransactionParseError) string {
+ return t.Error()
+ }), "\n")
+}
M pkg/tui/commands/add/add.go => pkg/tui/commands/add/add.go +19 -5
@@ 29,13 29,19 @@ var AddCmd = &cobra.Command{
}
func executeAddTUI(cmd *cobra.Command, args []string) {
+ f, err := cmd.Flags().GetString("file")
+ if err != nil {
+ log.Fatalf("Missing ledger file path: %s", err.Error())
+ }
+ log.Println("Using ledger file in " + f)
+
addTUI := AddTxTUI{
focused: date,
furthestFocus: date,
date: dateinput.New(time.Now(), true),
description: textinput.New(),
total: currencyinput.New("$"),
- suggestionBox: suggestions.New(),
+ suggestionBox: suggestions.New(f),
help: help.New(),
keymap: keymap{
nextField: key.NewBinding(
@@ 91,9 97,11 @@ type AddTxTUI struct {
// Add New Tx Form
focused focus
furthestFocus focus
- date dateinput.Model
- description textinput.Model
- total currencyinput.Model
+
+ // Fields
+ date dateinput.Model
+ description textinput.Model
+ total currencyinput.Model
suggestionBox suggestions.Model
@@ 106,7 114,11 @@ type AddTxTUI struct {
}
func (m AddTxTUI) Init() tea.Cmd {
- return textinput.Blink
+ return tea.Batch(
+ m.date.Init(),
+ m.total.Init(),
+ m.suggestionBox.Init(),
+ )
}
func (m AddTxTUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ 171,6 183,8 @@ func (m AddTxTUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
m.total, cmd = m.total.Update(msg)
cmds = append(cmds, cmd)
+ m.suggestionBox, cmd = m.suggestionBox.Update(msg)
+ cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
M pkg/tui/currencyinput/currencyinput.go => pkg/tui/currencyinput/currencyinput.go +1 -0
@@ 46,6 46,7 @@ func numberValidator(s string) error {
}
func (m Model) Init() tea.Cmd {
+ // Sub-components do not have Init functions to call.
return textinput.Blink
}
M pkg/tui/dateinput/dateInput.go => pkg/tui/dateinput/dateInput.go +1 -0
@@ 120,6 120,7 @@ func dayValidator(s string) error {
}
func (m Model) Init() tea.Cmd {
+ // Sub-components do not have Init functions to call.
return textinput.Blink
}
M pkg/tui/msgs.go => pkg/tui/msgs.go +8 -0
@@ 6,3 6,11 @@ const (
NextInputMsg TUIMsg = iota
PrevInputMsg
)
+
+type ErrMsg struct {
+ Err error
+}
+
+func (e ErrMsg) Error() string {
+ return e.Err.Error()
+}
M pkg/tui/suggestions/suggestions.go => pkg/tui/suggestions/suggestions.go +53 -5
@@ 1,19 1,67 @@
package suggestions
-import tea "github.com/charmbracelet/bubbletea"
+import (
+ "fmt"
+ "log"
+ "os"
+
+ tea "github.com/charmbracelet/bubbletea"
+
+ "github.com/tgrosinger/ledger-tui/pkg/ledger/hledger"
+ "github.com/tgrosinger/ledger-tui/pkg/transaction"
+ "github.com/tgrosinger/ledger-tui/pkg/tui"
+)
+
+// TransactionsMsg indicates that transactions were successfully loaded and are
+// now available for use by the suggestions engine.
+type TransactionsMsg []transaction.Transaction
+
+type SuggestionErrMsg tui.ErrMsg
type Model struct {
+ ledgerFilename string
+ transactions []transaction.Transaction
+}
+
+func New(ledgerFilename string) Model {
+ return Model{
+ ledgerFilename: ledgerFilename,
+ }
}
-func New() Model {
- return Model{}
+// loadTransactions returns a bubbletea command which can be executed
+// asynchronously to load transactions from a specified ledger file.
+func loadTransactions(filepath string) func() tea.Msg {
+ return func() tea.Msg {
+ log.Println("Loading ledger file at " + filepath)
+
+ ledger := hledger.New(filepath)
+ txs, err := ledger.GetTransactions()
+ if err != nil {
+ return SuggestionErrMsg{Err: err}
+ }
+
+ return TransactionsMsg(txs)
+ }
}
-func Init() tea.Cmd {
- return nil
+func (m Model) Init() tea.Cmd {
+ return loadTransactions(m.ledgerFilename)
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
+
+ switch msg := msg.(type) {
+ case TransactionsMsg:
+ log.Println("Transactions loaded")
+ m.transactions = msg
+ case SuggestionErrMsg:
+ // TODO: This error is not visible to the user anywhere
+ fmt.Fprintln(os.Stderr, "Encountered an error: "+msg.Err.Error())
+ log.Println("Encountered an error: " + msg.Err.Error())
+ return m, tea.Quit
+ }
+
return m, nil
}