init
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user