wip
parent
d8724bb27f
commit
6f9ccea0d2
|
|
@ -1,12 +1,12 @@
|
|||
package ledger
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
type transaction struct {
|
||||
|
|
@ -37,11 +37,11 @@ func (file File) transactions() ([]transaction, error) {
|
|||
}
|
||||
defer f.Close()
|
||||
|
||||
var r io.Reader = f
|
||||
r := bufio.NewReaderSize(f, 2048)
|
||||
|
||||
result := make([]transaction, 0)
|
||||
for {
|
||||
newr, one, err := readTransaction(r)
|
||||
one, err := readTransaction(r)
|
||||
if !one.empty() {
|
||||
result = append(result, one)
|
||||
}
|
||||
|
|
@ -51,219 +51,62 @@ func (file File) transactions() ([]transaction, error) {
|
|||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
r = newr
|
||||
}
|
||||
}
|
||||
|
||||
func readTransaction(r io.Reader) (io.Reader, transaction, error) {
|
||||
result := transaction{}
|
||||
var err error
|
||||
func readTransaction(r *bufio.Reader) (transaction, error) {
|
||||
readTransactionLeadingWhitespace(r)
|
||||
|
||||
if r, err = readTransactionLeadingWhitespace(r); err != nil {
|
||||
return r, transaction{}, err
|
||||
firstLine, err := readTransactionLine(r)
|
||||
if len(bytes.TrimSpace(firstLine)) == 0 {
|
||||
return transaction{}, err
|
||||
}
|
||||
|
||||
if result.date, err = readTransactionDate(r); err != nil {
|
||||
if err == io.EOF {
|
||||
return r, transaction{}, err
|
||||
}
|
||||
return nil, result, fmt.Errorf("did not find transaction date: %w", err)
|
||||
dateDescriptionPattern := regexp.MustCompile(`^([0-9]+-[0-9]+-[0-9]+)\s+(.*)$`)
|
||||
dateDescriptionMatches := dateDescriptionPattern.FindAllSubmatch(firstLine, 4)
|
||||
if len(dateDescriptionMatches) != 1 {
|
||||
return transaction{}, fmt.Errorf("bad first line: %v matches: %q", len(dateDescriptionMatches), firstLine)
|
||||
} else if len(dateDescriptionMatches[0]) != 3 {
|
||||
return transaction{}, fmt.Errorf("bad first line: %v submatches: %q", len(dateDescriptionMatches[0]), firstLine)
|
||||
}
|
||||
|
||||
if result.description, err = readTransactionDescription(r); err != nil {
|
||||
return nil, result, fmt.Errorf("did not find transaction description: %w", err)
|
||||
}
|
||||
date := string(dateDescriptionMatches[0][1])
|
||||
description := string(dateDescriptionMatches[0][2])
|
||||
|
||||
for {
|
||||
newR, recipient, err := readTransactionRecipient(r)
|
||||
r = newR
|
||||
if !recipient.empty() {
|
||||
if recipient.currency == "" {
|
||||
result.payee = recipient.name
|
||||
} else {
|
||||
result.recipients = append(result.recipients, recipient)
|
||||
}
|
||||
}
|
||||
if err != nil || recipient.empty() {
|
||||
return r, result, err
|
||||
}
|
||||
}
|
||||
return transaction{}, fmt.Errorf("need to parse more of %q/%q", date, description)
|
||||
}
|
||||
|
||||
// from "\s*.* read \s*"
|
||||
func readTransactionLeadingWhitespace(r io.Reader) (io.Reader, error) {
|
||||
for {
|
||||
b, err := readOne(r)
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
if b != 0 && !isSpaceByte(b) {
|
||||
return io.MultiReader(bytes.NewReader([]byte{b}), r), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// from "20yy-mm-dd.* read "20yy-mm-dd"
|
||||
func readTransactionDate(r io.Reader) (string, error) {
|
||||
startOfDate := []byte{}
|
||||
for len(startOfDate) == 0 {
|
||||
b, err := readOne(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if '0' <= b && b <= '9' {
|
||||
startOfDate = append(startOfDate, b)
|
||||
}
|
||||
func readTransactionLeadingWhitespace(r *bufio.Reader) {
|
||||
b, err := r.Peek(2048)
|
||||
if err != nil && err != io.EOF {
|
||||
return
|
||||
}
|
||||
|
||||
restOfDate := make([]byte, len("2006-01-02")-len(startOfDate))
|
||||
if _, err := r.Read(restOfDate); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s%s", startOfDate, restOfDate), nil
|
||||
}
|
||||
|
||||
// from "\s*.*\n.*" read \s*.*\n.*"
|
||||
func readTransactionDescription(r io.Reader) (string, error) {
|
||||
result := make([]byte, 0, 16)
|
||||
for {
|
||||
b, err := readOne(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if b == '\n' {
|
||||
i := 0
|
||||
for i < len(b) {
|
||||
if len(bytes.TrimSpace(b[:i])) != 0 {
|
||||
break
|
||||
}
|
||||
result = append(result, b)
|
||||
i++
|
||||
}
|
||||
|
||||
if i > 0 {
|
||||
r.Read(make([]byte, i-1))
|
||||
}
|
||||
return strings.TrimSpace(string(result)), nil
|
||||
}
|
||||
|
||||
// from "\s+.*\n?.*" read "\s+.*\n?"
|
||||
func readTransactionRecipient(r io.Reader) (io.Reader, transactionRecipient, error) {
|
||||
if b, err := readOne(r); err != nil {
|
||||
return r, transactionRecipient{}, err
|
||||
} else if !isSpaceByte(b) {
|
||||
return io.MultiReader(bytes.NewReader([]byte{b}), r), transactionRecipient{}, nil
|
||||
func readTransactionLine(r *bufio.Reader) ([]byte, error) {
|
||||
b, err := r.Peek(2048)
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r, err := readTransactionLeadingWhitespace(r)
|
||||
if err != nil {
|
||||
return r, transactionRecipient{}, err
|
||||
endOfLine := len(b)
|
||||
if idx := bytes.Index(b, []byte{'\n'}); idx > -1 {
|
||||
endOfLine = idx
|
||||
}
|
||||
|
||||
result := transactionRecipient{}
|
||||
for {
|
||||
b, err := readOne(r)
|
||||
if err != nil {
|
||||
if result.empty() {
|
||||
return nil, result, err
|
||||
}
|
||||
return nil, result, fmt.Errorf("failed to read transaction recipient name after skipping whitespace: %w", err)
|
||||
} else if !isSpaceByte(b) {
|
||||
result.name += string([]byte{b})
|
||||
continue
|
||||
}
|
||||
r = io.MultiReader(bytes.NewReader([]byte{b}), r)
|
||||
break
|
||||
}
|
||||
if result.name == "" {
|
||||
return nil, result, fmt.Errorf("did not parse any name for transaction recipient")
|
||||
}
|
||||
|
||||
// read "NAME:NAME", now "(\s+|\n).*"
|
||||
|
||||
for {
|
||||
b, err := readOne(r)
|
||||
if err != nil {
|
||||
return r, result, err
|
||||
}
|
||||
if isSpaceByte(b) && b != '\n' {
|
||||
continue
|
||||
}
|
||||
r = io.MultiReader(bytes.NewReader([]byte{b}), r)
|
||||
break
|
||||
}
|
||||
|
||||
// read "\s+", now "(\n|\$1.00)"
|
||||
|
||||
if b, err := readOne(r); err != nil {
|
||||
return r, result, err
|
||||
} else if isSpaceByte(b) {
|
||||
return r, result, err
|
||||
} else if newr, value, currency, err := readTransactionValueCurrency(io.MultiReader(bytes.NewReader([]byte{b}), r)); err != nil {
|
||||
return r, result, fmt.Errorf("failed to read transaction recipient's value and currency: %w", err)
|
||||
} else {
|
||||
r = newr
|
||||
result.value = value
|
||||
result.currency = currency
|
||||
}
|
||||
return r, result, err
|
||||
}
|
||||
|
||||
func readOne(r io.Reader) (byte, error) {
|
||||
var firstByte [1]byte
|
||||
_, err := r.Read(firstByte[:])
|
||||
return firstByte[0], err
|
||||
}
|
||||
|
||||
func isSpaceByte(b byte) bool {
|
||||
return len(bytes.TrimSpace([]byte{b})) == 0
|
||||
}
|
||||
|
||||
func isNumericByte(b byte) bool {
|
||||
return '0' <= b && b <= '9' || b == '.' || b == '-'
|
||||
}
|
||||
|
||||
// from "[^\n]*\n?.*" read "[^\n]*\n?"
|
||||
func readTransactionValueCurrency(r io.Reader) (io.Reader, float64, string, error) {
|
||||
restOfLine := make([]byte, 0, 16)
|
||||
for {
|
||||
b, err := readOne(r)
|
||||
if b == '\n' || err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return r, 0, "", err
|
||||
}
|
||||
restOfLine = append(restOfLine, b)
|
||||
}
|
||||
restOfLine = bytes.TrimSpace(restOfLine)
|
||||
if len(restOfLine) == 0 {
|
||||
return r, 0, "", nil
|
||||
}
|
||||
|
||||
firstNonNumericIdx := -1
|
||||
firstNumericIdx := -1
|
||||
for i := range restOfLine {
|
||||
if !isNumericByte(restOfLine[i]) && firstNonNumericIdx == -1 {
|
||||
firstNonNumericIdx = i
|
||||
}
|
||||
if isNumericByte(restOfLine[i]) && firstNumericIdx == -1 {
|
||||
firstNumericIdx = i
|
||||
}
|
||||
}
|
||||
|
||||
if firstNonNumericIdx == -1 || firstNumericIdx == -1 {
|
||||
return r, 0, "", fmt.Errorf("needed numeric and non-numeric bytes in %q", restOfLine)
|
||||
}
|
||||
|
||||
var valueS, currency string
|
||||
if firstNonNumericIdx < firstNumericIdx {
|
||||
currency = string(restOfLine[firstNonNumericIdx:firstNumericIdx])
|
||||
valueS = string(restOfLine[firstNumericIdx:])
|
||||
} else {
|
||||
valueS = string(restOfLine[firstNumericIdx:firstNonNumericIdx])
|
||||
currency = string(restOfLine[firstNonNumericIdx:])
|
||||
}
|
||||
valueS = strings.TrimSpace(valueS)
|
||||
currency = strings.TrimSpace(currency)
|
||||
|
||||
value, err := strconv.ParseFloat(valueS, 64)
|
||||
if err != nil {
|
||||
return r, 0, "", fmt.Errorf("value %q is not a float: %w", valueS, err)
|
||||
}
|
||||
|
||||
return r, value, currency, nil
|
||||
b2 := make([]byte, endOfLine)
|
||||
n, err := r.Read(b2)
|
||||
return b2[:n], err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package ledger
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
|
@ -53,7 +54,8 @@ func TestReadTransaction(t *testing.T) {
|
|||
for name, d := range cases {
|
||||
c := d
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, got, err := readTransaction(strings.NewReader(c.input))
|
||||
r := bufio.NewReader(strings.NewReader(c.input))
|
||||
got, err := readTransaction(r)
|
||||
if err != c.err {
|
||||
t.Error(err)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue