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 }