package ledger import ( "bufio" "bytes" "fmt" "io" "io/fs" "io/ioutil" "os" "path" "path/filepath" "sort" "strings" "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) Amend(old, now Delta) error { if now.isSet { return fmt.Errorf("cannot ammend: immutable isSet is set") } xactions, err := files.transactions() if err != nil { return err } var transaction transaction for _, xaction := range xactions { if xaction.name != old.Transaction { continue } transaction = xaction break } if transaction.payee == old.Name { if len(transaction.recipients) != 1 { return fmt.Errorf("cannot amend: modifying original payee, but many recipients cant share new value") } transaction.payee, transaction.recipients[0].name, transaction.recipients[0].value = transaction.recipients[0].name, transaction.payee, transaction.recipients[0].value*-1.0 } idx := -1 for i, recipient := range transaction.recipients { if recipient.name == old.Name && recipient.value == old.Value { idx = i } } if idx == -1 { return fmt.Errorf("cannot amend: no recipient with name %q value %.2f found in %+v to set new value", old.Name, old.Value, transaction) } old.Value *= -1 return files.Add(transaction.payee, []Delta{old, now}...) } 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 { p := files.paths()[0] newFile, err := func() (string, error) { w, err := ioutil.TempFile(os.TempDir(), path.Base(p)) if err != nil { return "", err } defer w.Close() r, err := os.Open(p) if err != nil { return "", err } defer r.Close() if _, err := peekLastNLines(w, bufio.NewReader(r), n); err != nil { return "", err } for i := range lines { if len(strings.TrimSpace(lines[i])) == 0 { continue } if _, err := fmt.Fprintln(w, lines[i]); err != nil { return "", err } } if err := w.Close(); err != nil { return "", err } return w.Name(), nil }() if err != nil { return err } r, err := os.Open(newFile) if err != nil { return err } defer r.Close() w, err := os.Create(p) if err != nil { return err } defer w.Close() _, err = io.Copy(w, r) return err } 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(bytes.TrimSpace(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, deltas ...Delta) error { for _, delta := range deltas { if err := files.add(payee, delta); err != nil { return err } } return nil } 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 { p := path.Join(path.Dir(files.paths()[0]), "inbox.txt") if err := files.trimTrailingWhitespace(p); err != nil { return err } f, err := os.OpenFile(p, 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) trimTrailingWhitespace(p string) error { idx, err := files._lastNonWhitespacePos(p) if err != nil { return err } if idx < 1 { return nil } f, err := os.OpenFile(p, 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(p string) (int, error) { f, err := os.Open(p) 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 { result = append(result, transaction.deltas()...) } 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 }