package ledger import ( "bufio" "bytes" "fmt" "io" "io/fs" "os" "path/filepath" "sort" "unicode" ) var filesAppendDelim = "\t" type Files []string func NewFiles(p string, q ...string) (Files, error) { f := Files(append([]string{p}, q...)) _, err := f.Deltas() return f, err } func (files Files) TempGetLastNLines(n int) ([]string, error) { p := files.paths()[0] f, err := os.Open(p) if err != nil { return nil, err } defer f.Close() return peekLastNLines(io.Discard, bufio.NewReader(f), n) } func (files Files) TempSetLastNLines(n int, lines []string) error { panic("e") } func peekLastNLines(w io.Writer, r *bufio.Reader, n int) ([]string, error) { lastNLines := make([]string, 0, n) for { line, err := r.ReadBytes('\n') if len(line) > 0 { lastNLines = append(lastNLines, string(bytes.TrimRight(line, "\n"))) for i := 0; i < len(lastNLines)-n; i++ { fmt.Fprintln(w, lastNLines[i]) } lastNLines = lastNLines[max(0, len(lastNLines)-n):] } if err == io.EOF { break } if err != nil { return nil, err } } return lastNLines, nil } func (files Files) paths() []string { result := make([]string, 0, len(files)) for i := range files { if info, err := os.Stat(files[i]); err == nil && info.IsDir() { if err := filepath.WalkDir(files[i], func(p string, d fs.DirEntry, err error) error { if err != nil { return err } if !d.IsDir() { result = append(result, p) } return nil }); err != nil { panic(err) } } else { result = append(result, files[i]) } } return result } func (files Files) Add(payee string, delta Delta) error { currencyValue := fmt.Sprintf("%s%.2f", delta.Currency, delta.Value) if delta.Currency != USD { currencyValue = fmt.Sprintf("%.2f %s", delta.Value, delta.Currency) } return files.append(fmt.Sprintf("%s %s\n%s%s%s%s\n%s%s", delta.Date, delta.Description, filesAppendDelim, delta.Name, filesAppendDelim+filesAppendDelim+filesAppendDelim, currencyValue, filesAppendDelim, payee, )) } func (files Files) append(s string) error { if err := files.trimTrainlingWhitespace(); err != nil { return err } f, err := os.OpenFile(string(files.paths()[0]), os.O_APPEND|os.O_CREATE|os.O_WRONLY, os.ModePerm) if err != nil { return err } defer f.Close() fmt.Fprintf(f, "\n%s", s) return f.Close() } func (files Files) trimTrainlingWhitespace() error { idx, err := files._lastNonWhitespacePos() if err != nil { return err } if idx < 1 { return nil } f, err := os.OpenFile(string(files.paths()[0]), os.O_CREATE|os.O_WRONLY, os.ModePerm) if err != nil { return err } defer f.Close() return f.Truncate(int64(idx + 1)) } func (files Files) _lastNonWhitespacePos() (int, error) { f, err := os.Open(string(files.paths()[0])) if os.IsNotExist(err) { return -1, nil } if err != nil { return -1, err } defer f.Close() b, err := io.ReadAll(f) if err != nil { return -1, err } for i := len(b) - 1; i >= 0; i-- { if !unicode.IsSpace(rune(b[i])) { return i, nil } } return len(b) - 1, nil } func (files Files) Deltas(like ...Like) (Deltas, error) { transactions, err := files.transactions() if err != nil { return nil, err } sort.Slice(transactions, func(i, j int) bool { return fmt.Sprintf("%s %s", transactions[i].date, transactions[i].description) < fmt.Sprintf("%s %s", transactions[j].date, transactions[j].description) }) result := make(Deltas, 0, len(transactions)*2) for _, transaction := range transactions { sums := map[string]float64{} for _, recipient := range transaction.recipients { sums[recipient.currency] += recipient.value delta := newDelta( transaction.date, transaction.description, recipient.name, recipient.value, recipient.currency, recipient.isSet, ) result = append(result, delta) } for currency, value := range sums { if value == 0 { continue } if transaction.payee == "" { //return nil, fmt.Errorf("didnt find net zero and no dumping ground payee set: %+v", transaction) } else { delta := newDelta( transaction.date, transaction.description, transaction.payee, -1.0*value, currency, false, ) result = append(result, delta) } } } balances := make(Balances) for i := range result { if result[i].isSet { var was float64 if m, ok := balances[result[i].Name]; ok { was = m[result[i].Currency] } result[i].Value = result[i].Value - was result[i].isSet = false } balances.Push(result[i]) } for i := range result { if result[i].isSet { return nil, fmt.Errorf("failed to resolve isSet: %+v", result[i]) } } return result.Like(like...), nil }