~tgrosinger/ledger-tui

f48f3a98cec155411b2ffb7ecad73c257cfde08e — Tony Grosinger 11 months ago 25bfb52 main
wip - changes from last time I worked on this
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
}