This commit is contained in:
Lars Nolden
2026-09-10 12:30:42 +02:00
commit 9843fe0c50
79 changed files with 16318 additions and 0 deletions
+390
View File
@@ -0,0 +1,390 @@
package journal
import (
"bytes"
"encoding/json"
"fmt"
"io"
"reflect"
"sort"
"strings"
"unicode/utf8"
"finance-duck/internal/domain"
)
// Grammar: kind { on its own line, followed by field: JSON values, then }.
// JSON values may span lines. Blank lines and full-line # or // comments are
// permitted between fields and blocks. Strings use JSON escaping, including \n.
type fieldSpan struct{ start, end int }
type block struct {
kind, id string
line int
lines []string
fields map[string]fieldSpan
value any
}
type piece struct {
text string
block *block
}
type document struct {
path string
pieces []piece
}
func comment(s string) bool {
s = strings.TrimSpace(s)
return s == "" || strings.HasPrefix(s, "#") || strings.HasPrefix(s, "//")
}
func decodeStrict(raw []byte, value any) error {
check := json.NewDecoder(bytes.NewReader(raw))
check.UseNumber()
if err := checkJSON(check); err != nil {
return err
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(value); err != nil {
return err
}
var extra any
if err := dec.Decode(&extra); err != io.EOF {
return fmt.Errorf("expected one JSON value")
}
return nil
}
func checkJSON(dec *json.Decoder) error {
token, err := dec.Token()
if err != nil {
return err
}
delimiter, ok := token.(json.Delim)
if !ok {
return nil
}
switch delimiter {
case '{':
keys := map[string]bool{}
for dec.More() {
key, err := dec.Token()
if err != nil {
return err
}
name, ok := key.(string)
if !ok {
return fmt.Errorf("expected JSON object key")
}
if keys[name] {
return fmt.Errorf("duplicate JSON key %q", name)
}
keys[name] = true
if err = checkJSON(dec); err != nil {
return err
}
}
case '[':
for dec.More() {
if err = checkJSON(dec); err != nil {
return err
}
}
default:
return fmt.Errorf("unexpected JSON delimiter")
}
_, err = dec.Token()
return err
}
func fieldsOf(value any) (map[string]json.RawMessage, []string, error) {
raw, err := json.Marshal(value)
if err != nil {
return nil, nil, err
}
m := map[string]json.RawMessage{}
if err = json.Unmarshal(raw, &m); err != nil {
return nil, nil, err
}
typ := reflect.TypeOf(value)
keys := []string{}
for i := range typ.NumField() {
key := strings.Split(typ.Field(i).Tag.Get("json"), ",")[0]
if _, ok := m[key]; ok {
keys = append(keys, key)
}
}
return m, keys, nil
}
func parseDocument(path string, raw []byte) (*document, error) {
fail := func(line int, err any) (*document, error) { return nil, fmt.Errorf("%s:%d: %v", path, line, err) }
if !utf8.Valid(raw) {
return fail(1, "file is not valid UTF-8")
}
lines := strings.SplitAfter(string(raw), "\n")
doc := &document{path: path}
pending := ""
for i := 0; i < len(lines); {
trimmed := strings.TrimSpace(lines[i])
if comment(trimmed) {
pending += lines[i]
i++
continue
}
if pending != "" {
doc.pieces = append(doc.pieces, piece{text: pending})
pending = ""
}
header := strings.Fields(trimmed)
if len(header) != 2 || header[1] != "{" {
return fail(i+1, "expected 'account|category|tag|merchant|transaction {'")
}
kind := header[0]
var value any
switch kind {
case "account":
value = &domain.Account{}
case "category":
value = &domain.Category{}
case "tag":
value = &domain.Tag{}
case "merchant":
value = &domain.Merchant{}
case "transaction":
value = &domain.Transaction{}
default:
return fail(i+1, "unknown block kind "+kind)
}
fieldTypes := map[string]reflect.StructField{}
typ := reflect.TypeOf(value).Elem()
for n := range typ.NumField() {
field := typ.Field(n)
fieldTypes[strings.Split(field.Tag.Get("json"), ",")[0]] = field
}
start := i
i++
fields := map[string]fieldSpan{}
closed := false
for i < len(lines) {
s := strings.TrimSpace(lines[i])
if s == "}" {
i++
closed = true
break
}
if comment(s) {
i++
continue
}
colon := strings.Index(s, ":")
if colon <= 0 {
return fail(i+1, "expected field: JSON")
}
key := strings.TrimSpace(s[:colon])
if strings.ContainsAny(key, " \t\"{}") {
return fail(i+1, "invalid field name")
}
if _, exists := fields[key]; exists {
return fail(i+1, "duplicate field "+key)
}
fieldStart := i
jsonText := strings.TrimSpace(s[colon+1:])
for !json.Valid([]byte(jsonText)) {
if jsonText != "" {
var probe any
err := json.Unmarshal([]byte(jsonText), &probe)
if err != nil && !strings.Contains(err.Error(), "unexpected end of JSON input") {
return fail(fieldStart+1, "field "+key+": "+err.Error())
}
}
i++
if i >= len(lines) {
return fail(fieldStart+1, "unterminated JSON value for "+key)
}
jsonText += "\n" + strings.TrimSuffix(lines[i], "\n")
}
fieldType, known := fieldTypes[key]
if !known {
return fail(fieldStart+1, "unknown field "+key)
}
fieldValue := reflect.New(fieldType.Type)
if err := decodeStrict([]byte(jsonText), fieldValue.Interface()); err != nil {
return fail(fieldStart+1, "field "+key+": "+err.Error())
}
reflect.ValueOf(value).Elem().FieldByIndex(fieldType.Index).Set(fieldValue.Elem())
fields[key] = fieldSpan{start: fieldStart - start, end: i - start}
i++
}
if !closed {
return fail(start+1, "unterminated block")
}
b := &block{kind: kind, line: start + 1, lines: append([]string{}, lines[start:i]...), fields: fields}
switch v := value.(type) {
case *domain.Account:
b.id = v.ID
b.value = *v
case *domain.Category:
b.id = v.ID
b.value = *v
case *domain.Tag:
b.id = v.ID
b.value = *v
case *domain.Merchant:
if v.Aliases == nil {
v.Aliases = []string{}
}
if v.DefaultTagIDs == nil {
v.DefaultTagIDs = []string{}
}
b.id = v.ID
b.value = *v
case *domain.Transaction:
if v.Enrichment.TagIDs == nil {
v.Enrichment.TagIDs = []string{}
}
b.id = v.Facts.ID
b.value = *v
}
doc.pieces = append(doc.pieces, piece{block: b})
}
if pending != "" {
doc.pieces = append(doc.pieces, piece{text: pending})
}
return doc, nil
}
func renderNew(kind string, value any) ([]byte, error) {
fields, keys, err := fieldsOf(value)
if err != nil {
return nil, err
}
var out strings.Builder
out.WriteString(kind + " {\n")
for _, key := range keys {
out.WriteString(" " + key + ": " + string(fields[key]) + "\n")
}
out.WriteString("}\n")
return []byte(out.String()), nil
}
func (b *block) render(value any) ([]byte, error) {
if reflect.DeepEqual(b.value, value) {
return []byte(strings.Join(b.lines, "")), nil
}
fields, keys, err := fieldsOf(value)
if err != nil {
return nil, err
}
oldFields, _, err := fieldsOf(b.value)
if err != nil {
return nil, err
}
starts := map[int]string{}
for key, f := range b.fields {
starts[f.start] = key
}
var out strings.Builder
for i := 0; i < len(b.lines); i++ {
if i == len(b.lines)-1 {
for _, key := range keys {
if _, ok := b.fields[key]; !ok {
out.WriteString(" " + key + ": " + string(fields[key]) + "\n")
}
}
}
key, ok := starts[i]
if !ok {
out.WriteString(b.lines[i])
continue
}
f := b.fields[key]
next, exists := fields[key]
if exists {
if bytes.Equal(oldFields[key], next) {
out.WriteString(strings.Join(b.lines[i:f.end+1], ""))
} else {
out.WriteString(" " + key + ": " + string(next) + "\n")
}
}
i = f.end
}
return []byte(out.String()), nil
}
func datasetFiles(d domain.Dataset) map[string]map[string]piece {
files := map[string]map[string]piece{}
for _, p := range []string{"accounts.finance", "categories.finance", "tags.finance", "merchants.finance"} {
files[p] = map[string]piece{}
}
add := func(path, kind, id string, value any) {
if files[path] == nil {
files[path] = map[string]piece{}
}
files[path][id] = piece{block: &block{kind: kind, id: id, value: value}}
}
for _, v := range d.Accounts {
add("accounts.finance", "account", v.ID, v)
}
for _, v := range d.Categories {
add("categories.finance", "category", v.ID, v)
}
for _, v := range d.Tags {
add("tags.finance", "tag", v.ID, v)
}
for _, v := range d.Merchants {
add("merchants.finance", "merchant", v.ID, v)
}
for _, v := range d.Transactions {
month := v.Facts.BookingDate[:7]
add("journal/"+month[:4]+"/"+month+".finance", "transaction", v.Facts.ID, v)
}
return files
}
func renderFiles(d domain.Dataset, docs map[string]*document) (map[string][]byte, error) {
wanted := datasetFiles(d)
for path := range docs {
if wanted[path] == nil {
wanted[path] = map[string]piece{}
}
}
out := map[string][]byte{}
for path, blocks := range wanted {
var buf bytes.Buffer
if doc := docs[path]; doc != nil {
for _, p := range doc.pieces {
if p.block == nil {
buf.WriteString(p.text)
continue
}
b := p.block
if next, ok := blocks[b.id]; ok {
raw, err := b.render(next.block.value)
if err != nil {
return nil, err
}
buf.Write(raw)
delete(blocks, b.id)
} else {
for _, line := range b.lines {
if comment(line) {
buf.WriteString(line)
}
}
}
}
}
ids := make([]string, 0, len(blocks))
for id := range blocks {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
if buf.Len() > 0 && !bytes.HasSuffix(buf.Bytes(), []byte("\n")) {
buf.WriteByte('\n')
}
p := blocks[id]
raw, err := renderNew(p.block.kind, p.block.value)
if err != nil {
return nil, err
}
buf.Write(raw)
}
out[path] = buf.Bytes()
}
return out, nil
}