Complete syntax

This commit is contained in:
SeraphJACK 2023-09-26 11:43:43 +08:00
parent f68ce60bfc
commit 6bb7af6c43
Signed by: SeraphJACK
GPG Key ID: B4FFEA56F3BE0D0C
7 changed files with 255 additions and 2 deletions

2
.gitignore vendored
View File

@ -20,4 +20,4 @@
# Go workspace file
go.work
.idea

View File

@ -1,3 +1,3 @@
# beanbot
Bean bot to assist beancount
Bean bot to assist beancount

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module git.s8k.top/SeraphJACK/beanbot
go 1.21.1

143
syntax/syntax.go Normal file
View File

@ -0,0 +1,143 @@
package syntax
import (
"errors"
"strconv"
"time"
)
var ErrMissingPayee = errors.New("missing payee")
var ErrUnknownAccount = errors.New("unknown account")
type Config struct {
Currencies []string
Accounts map[string]string
DefaultCurrency string
}
func (c *Config) hasCurrency(cur string) bool {
for _, v := range c.Currencies {
if v == cur {
return true
}
}
return false
}
func (c *Config) account(abbr string) (string, bool) {
if res, ok := c.Accounts[abbr]; ok {
return res, true
}
return "", false
}
type tokenReader struct {
tokens []string
cur int
}
func (r *tokenReader) HasNext() bool {
return r.cur < len(r.tokens)
}
func (r *tokenReader) Peek() string {
return r.tokens[r.cur]
}
func (r *tokenReader) Next() string {
res := r.tokens[r.cur]
r.cur++
return res
}
func Parse(tokens []string, cfg *Config) (*Transaction, error) {
r := &tokenReader{tokens: tokens}
txn := &Transaction{}
// optional transaction date
if r.HasNext() {
t, err := time.Parse("2006-01-02", r.Peek())
if err == nil {
txn.Date = t.Format("2006-01-02")
r.Next()
} else {
// default date: current date
txn.Date = time.Now().Format("2006-01-02")
}
}
// optional txn mark
if r.HasNext() {
if r.Peek() == "*" || r.Peek() == "!" {
txn.Flag = r.Next()
} else {
// default flag is * (complete txn)
txn.Flag = "*"
}
}
// mandatory payee
if !r.HasNext() {
return nil, ErrMissingPayee
}
txn.Payee = r.Next()
p, err := readPosting(r, cfg)
if err != nil {
// account does not exist, token is optional narration
txn.Narration = r.Next()
} else {
txn.Postings = append(txn.Postings, p)
}
// remaining postings
for r.HasNext() {
p, err := readPosting(r, cfg)
if err != nil {
return nil, err
}
txn.Postings = append(txn.Postings, p)
}
return txn, txn.Validate()
}
func readPosting(r *tokenReader, cfg *Config) (*Posting, error) {
p := &Posting{}
// mandatory account
if !r.HasNext() {
return nil, ErrUnknownAccount
}
account, ok := cfg.account(r.Peek())
if !ok {
return nil, ErrUnknownAccount
}
p.Account = account
r.Next()
// optional flag
if r.HasNext() && (r.Peek() == "!" || r.Peek() == "*") {
p.Flag = r.Next()
} else {
p.Flag = "*"
}
// optional amount
if r.HasNext() {
amount, err := strconv.ParseFloat(r.Peek(), 64)
if err == nil {
p.Amount = amount
r.Next()
}
}
// optional currency
if r.HasNext() && cfg.hasCurrency(r.Peek()) {
p.Currency = r.Next()
} else {
p.Currency = cfg.DefaultCurrency
}
return p, nil
}

34
syntax/syntax_test.go Normal file
View File

@ -0,0 +1,34 @@
package syntax
import (
"fmt"
"strings"
"testing"
)
var Cfg = &Config{
Currencies: []string{"CNY", "USD"},
Accounts: map[string]string{
"zfb": "Assets::Digital::Alipay",
"wx": "Assets::Digital::Wechat",
"dt": "Expenses::Travel::Train",
"lunch": "Expenses::Food::Lunch",
},
DefaultCurrency: "CNY",
}
func TestParse(t *testing.T) {
for _, str := range []string{
"地铁 dt 3 zfb -1 wx",
"午饭 lunch 11.20 wx",
"2023-01-01 转账 zfb 100 wx",
} {
t.Run(str, func(t *testing.T) {
txn, err := Parse(strings.Split(str, " "), Cfg)
if err != nil {
t.Fatalf("%v", err)
}
fmt.Print(txn.ToBeanLanguageSyntax())
})
}
}

70
syntax/transaction.go Normal file
View File

@ -0,0 +1,70 @@
package syntax
import (
"bytes"
_ "embed"
"errors"
"text/template"
)
//go:embed transaction.tmpl
var txnTmpl string
var parsedTmpl *template.Template
func init() {
parsedTmpl = template.Must(template.New("bean-language-txn").Parse(txnTmpl))
}
type Posting struct {
Account string
Amount float64
Currency string
Flag string
}
type Transaction struct {
Date string
Payee string
Narration string
Flag string
Postings []*Posting
}
func (txn *Transaction) Validate() error {
// currency: amount
sum := map[string]float64{}
zeros := map[string]int{}
zerop := map[string]*Posting{}
for _, p := range txn.Postings {
sum[p.Currency] += p.Amount
if p.Amount == 0 {
zeros[p.Currency]++
zerop[p.Currency] = p
}
}
for cur, s := range sum {
if zeros[cur] > 1 {
return errors.New("multiple zero amount postings")
}
if zeros[cur] == 1 {
zerop[cur].Amount = -s
}
if zeros[cur] == 0 && s != 0 {
return errors.New("sum of amount is non-zero")
}
}
return nil
}
func (txn *Transaction) ToBeanLanguageSyntax() string {
buf := &bytes.Buffer{}
err := parsedTmpl.Execute(buf, txn)
if err != nil {
panic(err)
}
return buf.String()
}

3
syntax/transaction.tmpl Normal file
View File

@ -0,0 +1,3 @@
{{.Date}} {{.Flag}} "{{.Payee}}"{{if ne .Narration ""}} "{{.Narration}}"{{end}}
{{range .Postings}} {{if ne .Flag "*" }}{{.Flag}} {{end}}{{.Account}} {{printf "%.2f" .Amount}} {{.Currency}}
{{end}}