diff --git a/cmd/ledger-tui/ledger-tui.go b/cmd/ledger-tui/ledger-tui.go index e40f94c..4024dc9 100644 --- a/cmd/ledger-tui/ledger-tui.go +++ b/cmd/ledger-tui/ledger-tui.go @@ -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) diff --git a/pkg/collections/collections.go b/pkg/collections/collections.go new file mode 100644 index 0000000..e51ac5d --- /dev/null +++ b/pkg/collections/collections.go @@ -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 +} diff --git a/pkg/ledger/hledger/hledger.go b/pkg/ledger/hledger/hledger.go index abfd500..c1578dc 100644 --- a/pkg/ledger/hledger/hledger.go +++ b/pkg/ledger/hledger/hledger.go @@ -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 } diff --git a/pkg/transaction/transaction.go b/pkg/transaction/transaction.go index c95ea2b..16304c0 100644 --- a/pkg/transaction/transaction.go +++ b/pkg/transaction/transaction.go @@ -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") +} diff --git a/pkg/tui/commands/add/add.go b/pkg/tui/commands/add/add.go index 2afee96..a9359d8 100644 --- a/pkg/tui/commands/add/add.go +++ b/pkg/tui/commands/add/add.go @@ -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...) } diff --git a/pkg/tui/currencyinput/currencyinput.go b/pkg/tui/currencyinput/currencyinput.go index 27753d7..1d40f00 100644 --- a/pkg/tui/currencyinput/currencyinput.go +++ b/pkg/tui/currencyinput/currencyinput.go @@ -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 } diff --git a/pkg/tui/dateinput/dateInput.go b/pkg/tui/dateinput/dateInput.go index fa03c67..e124858 100644 --- a/pkg/tui/dateinput/dateInput.go +++ b/pkg/tui/dateinput/dateInput.go @@ -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 } diff --git a/pkg/tui/msgs.go b/pkg/tui/msgs.go index d8b0508..3bae97f 100644 --- a/pkg/tui/msgs.go +++ b/pkg/tui/msgs.go @@ -6,3 +6,11 @@ const ( NextInputMsg TUIMsg = iota PrevInputMsg ) + +type ErrMsg struct { + Err error +} + +func (e ErrMsg) Error() string { + return e.Err.Error() +} diff --git a/pkg/tui/suggestions/suggestions.go b/pkg/tui/suggestions/suggestions.go index 9a7573a..7db824d 100644 --- a/pkg/tui/suggestions/suggestions.go +++ b/pkg/tui/suggestions/suggestions.go @@ -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() Model { - return Model{} +func New(ledgerFilename string) Model { + return Model{ + ledgerFilename: ledgerFilename, + } } -func Init() tea.Cmd { - return nil +// 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 (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 }