From 91c5b3ad152049a43c1781c6d4a6bda8d576b320 Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Tue, 10 Feb 2026 15:03:04 -0600 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=93=9D=20Add=20documentation=20for=20?= =?UTF-8?q?nested=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/README.md b/README.md index 8b4aa0c..d10730c 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,42 @@ As JSON, that would be: }] ``` +#### Nested fields + +This format also supports deeper structures. Of course, you can use raw JSON, like this: + +``` +@begin February 3, 2015 at 01:33PM - Check-in at Piggly Wiggly +@metadata {"lat":"33.6798911","lng":"-84.3959460","shop":"supermarket","osm_id":11617197123,"osm_type":"node","copyright":"The data included in this document is from www.openstreetmap.org. The data is made available under ODbL."} +@url https://www.openstreetmap.org/node/11617197123 @end +``` + +This becomes exactly what you expect: + +```json +{ + "title":"Check-in at Piggly Wiggly", + "date":"2015-02-03T13:33:00Z", + "metadata":{"lat":"33.6798911","lng":"-84.3959460","shop":"supermarket","osm_id":11617197123,"osm_type":"node","copyright":"The data included in this document is from www.openstreetmap.org. The data is made available under ODbL."}, + "url":"https://www.openstreetmap.org/node/11617197123" +} +``` + +This `metadata` field is a bit clunky, though. We can expand it into multiple fields for better readability: + +``` +@begin February 3, 2015 at 01:33PM - Check-in at Piggly Wiggly +@metadata:lat 33.6798911 +@metadata:lng -84.3959460 +@metadata:shop supermarket +@metadata:osm_id 11617197123 +@metadata:osm_type node +@metadata:copyright The data included in this document is from www.openstreetmap.org. The data is made available under ODbL. +@url https://www.openstreetmap.org/node/11617197123 @end +``` + +In addition to improving readability, it may be more suitable for generation by other tools. + ### Adding log entries As was previously noted, the idea is that you can figure out the best way for you to add to the log file. But, `my-log` also comes with a command to add them from the command line. Run `my-log drop --help` for instructions on how to use it. But, here's a few examples: From c4e864afa5c0b118a245c66daa104d1270be04fb Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Tue, 10 Feb 2026 16:13:39 -0600 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8=20Add=20support=20for=20nested=20?= =?UTF-8?q?fields=20in=20log=20entries=20and=20JSON=20marshalling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces the ability to handle nested fields within log entries. The file has been updated with a function that transforms flat keys with a : delimiter (e.g., obj:foo) into nested JSON objects (e.g., ). The file includes new test cases to verify that: - Nested fields are correctly unmarshalled from string representations. - Nested fields are correctly marshalled into JSON objects. This enhancement allows for more structured and organized metadata within log entries. --- models/entry_test.go | 10 ++++++++++ models/metas.go | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/models/entry_test.go b/models/entry_test.go index 89223eb..852277b 100644 --- a/models/entry_test.go +++ b/models/entry_test.go @@ -125,6 +125,14 @@ func TestEntryUnmarshal(t *testing.T) { []Meta{{"me", json.RawMessage(`{"name":"Dan","coder":true}`)}}, nil, }, + { + "nested-field", + "@begin " + whens + " - A Title\n@me:name Dan\n@me:coder true @end", + "A Title", + when, + []Meta{{"me:name", "Dan"}, {"me:coder", true}}, + nil, + }, { "json-field", "@begin " + whens + " - Some Guy\n" + `@json {"name":"Dan","coder":true} @end`, @@ -209,6 +217,8 @@ func TestEntryJsonMarshal(t *testing.T) { {"obj-field", "A Title", when, []Meta{{"obj", json.RawMessage(`{"foo":"bar","title":"Sub-title"}`)}}, `{"title":"A Title","date":"` + whens + `","obj":{"foo":"bar","title":"Sub-title"}}`, nil}, {"date-field", "A Title", when, []Meta{{"when", when.Add(time.Hour)}}, `{"title":"A Title","date":"` + whens + `","when":"` + when.Add(time.Hour).Format(time.RFC3339) + `"}`, nil}, {"json-field", "A Title", when, []Meta{{"json", json.RawMessage(`{"age": 41, "cool": true, "name": "Jim"}`)}}, `{"title":"A Title","date":"` + whens + `","age":41,"cool": true, "name": "Jim"}`, nil}, + {"nested-field", "A Title", when, []Meta{{"obj:foo", "bar"}, {"obj:title", "Sub-title"}}, `{"title":"A Title","date":"` + whens + `","obj":{"foo":"bar","title":"Sub-title"}}`, nil}, + {"double-nested-field", "A Title", when, []Meta{{"obj:foo", "bar"}, {"obj:me:name", "Dan"}, {"obj:me:age", 27}}, `{"title":"A Title","date":"` + whens + `","obj":{"foo":"bar","me":{"name":"Dan","age":27}}}`, nil}, } for _, tt := range tests { diff --git a/models/metas.go b/models/metas.go index cf4c23e..5b68455 100644 --- a/models/metas.go +++ b/models/metas.go @@ -2,6 +2,7 @@ package models import ( "encoding/json" + "strings" "time" ) @@ -33,9 +34,32 @@ func (ms Metas) Map() map[string]any { } } } + + // Next we have to go through them again to find nested fields + parseNestedFields(out) + return out } +func parseNestedFields(f map[string]any) { + for k, v := range f { + if strings.Contains(k, ":") { + idx := strings.Index(k, ":") + top := k[:idx] + bottom := k[(idx + 1):] + + nest, ok := f[top].(map[string]any) + if !ok { + nest = map[string]any{} + } + nest[bottom] = v + parseNestedFields(nest) + f[top] = nest + delete(f, k) + } + } +} + // Implements json.Marshaler func (ms Metas) MarshalJSON() ([]byte, error) { return json.Marshal(ms.Map()) From 4fc1c623a0957be936a9ec01f6ee2e2e7ce8bf98 Mon Sep 17 00:00:00 2001 From: Dan Jones Date: Tue, 10 Feb 2026 18:15:07 -0600 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=A8=20Implement=20full=20support=20fo?= =?UTF-8?q?r=20nested=20fields=20in=20Meta=20and=20Entry=20marshalling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit completes the implementation of nested field support. - : - now correctly handles and by recursively flattening them into format. - Introduced for recursive map marshalling. - Refactored for cleaner buffer writing. - : Added comprehensive test cases for nested JSON, nested maps, double-nested maps, and nested keys within JSON to ensure correct marshalling and unmarshalling. - : Updated tests to reflect the new nil handling and removed redundant JSON object test. This allows for more flexible and structured data representation within log entries. --- models/entry_test.go | 37 +++++++++++++++++++++++++++++ models/meta.go | 56 ++++++++++++++++++++++++++++++++++++++++---- models/meta_test.go | 3 +-- 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/models/entry_test.go b/models/entry_test.go index 852277b..a4fd2b7 100644 --- a/models/entry_test.go +++ b/models/entry_test.go @@ -61,6 +61,42 @@ func TestEntryMarshal(t *testing.T) { []string{"@age 41", "@cool true", "@name Jim"}, nil, }, + { + "nested-json", + "Title N", + when, + []Meta{{"me", json.RawMessage(`{"age": 43, "cool": true}`)}}, + "@begin " + whens + " - Title N", + []string{"@me:age 43", "@me:cool true"}, + nil, + }, + { + "nested-map", + "Title M", + when, + []Meta{{"me", map[string]any{"age": 43, "cool": true}}}, + "@begin " + whens + " - Title M", + []string{"@me:age 43", "@me:cool true"}, + nil, + }, + { + "double-nested-map", + "Title DM", + when, + []Meta{{"me", map[string]any{"age": 43, "name": map[string]any{"first": "Dan", "last": "Jones"}}}}, + "@begin " + whens + " - Title DM", + []string{"@me:age 43", "@me:name:first Dan", "@me:name:last Jones"}, + nil, + }, + { + "nested-keys-in-json", + "Title NKJ", + when, + []Meta{{"me", json.RawMessage(`{"name:first": "Dan", "name:last": "Jones"}`)}}, + "@begin " + whens + " - Title NKJ", + []string{"@me:name:first Dan", "@me:name:last Jones"}, + nil, + }, } for _, tt := range tests { @@ -219,6 +255,7 @@ func TestEntryJsonMarshal(t *testing.T) { {"json-field", "A Title", when, []Meta{{"json", json.RawMessage(`{"age": 41, "cool": true, "name": "Jim"}`)}}, `{"title":"A Title","date":"` + whens + `","age":41,"cool": true, "name": "Jim"}`, nil}, {"nested-field", "A Title", when, []Meta{{"obj:foo", "bar"}, {"obj:title", "Sub-title"}}, `{"title":"A Title","date":"` + whens + `","obj":{"foo":"bar","title":"Sub-title"}}`, nil}, {"double-nested-field", "A Title", when, []Meta{{"obj:foo", "bar"}, {"obj:me:name", "Dan"}, {"obj:me:age", 27}}, `{"title":"A Title","date":"` + whens + `","obj":{"foo":"bar","me":{"name":"Dan","age":27}}}`, nil}, + {"nested-plus-json", "A Title", when, []Meta{{"obj:foo", "bar"}, {"obj:me", json.RawMessage(`{"name":"Dan","age":27}`)}}, `{"title":"A Title","date":"` + whens + `","obj":{"foo":"bar","me":{"name":"Dan","age":27}}}`, nil}, } for _, tt := range tests { diff --git a/models/meta.go b/models/meta.go index dba99b2..c35f302 100644 --- a/models/meta.go +++ b/models/meta.go @@ -2,6 +2,7 @@ package models import ( "bytes" + "encoding/json" "errors" "fmt" "regexp" @@ -18,16 +19,63 @@ func (m Meta) MarshalText() ([]byte, error) { if regexp.MustCompile(`\s`).MatchString(m.Key) { return []byte{}, fmt.Errorf("whitespace is not allowed in key: %s", m.Key) } + buff := &bytes.Buffer{} + + if jv, ok := m.Value.(map[string]any); ok { + err := marshalMap(m.Key, jv, buff) + return buff.Bytes(), err + } + + if jj, ok := m.Value.(json.RawMessage); ok { + mp := map[string]any{} + err := json.Unmarshal(jj, &mp) + if err == nil { + err := marshalMap(m.Key, mp, buff) + return buff.Bytes(), err + } + } + + if err := m.marshalToBuff(buff); err != nil { + return nil, err + } + + return buff.Bytes(), nil +} + +func marshalMap(pre string, mp map[string]any, buff *bytes.Buffer) error { + var idx uint + for k, v := range mp { + if idx > 0 { + buff.WriteRune('\n') + } + idx++ + if subM, ok := v.(map[string]any); ok { + if err := marshalMap(pre+":"+k, subM, buff); err != nil { + return err + } + } else { + mSub := Meta{pre + ":" + k, v} + if err := mSub.marshalToBuff(buff); err != nil { + return err + } + } + } + return nil +} + +func (m Meta) marshalToBuff(buff *bytes.Buffer) error { buff.WriteRune('@') buff.WriteString(m.Key) buff.WriteRune(' ') n, err := tools.WriteValue(buff, m.Value) - if n == 0 || err != nil { - return []byte{}, err + if err != nil { + return err } - - return buff.Bytes(), nil + if n == 0 { + return ErrorParsing + } + return nil } func (m *Meta) UnmarshalText(in []byte) error { diff --git a/models/meta_test.go b/models/meta_test.go index 29e32a1..c6dd0e4 100644 --- a/models/meta_test.go +++ b/models/meta_test.go @@ -35,12 +35,11 @@ func TestMeta(t *testing.T) { {"json number", "num", json.Number("42.13"), "@num 42.13", nil, 42.13}, {"true", "b", true, "@b true", nil, true}, {"false", "b", false, "@b false", nil, false}, - {"nil", "n", nil, "", nil, ErrorParsing}, + {"nil", "n", nil, "", ErrorParsing, ErrorParsing}, {"time", "when", when, "@when " + when.Format(time.RFC3339), nil, when}, {"rune", "char", '@', "@char @", nil, "@"}, {"bytes", "byteme", []byte("yo"), "@byteme yo", nil, "yo"}, {"byte", "byteme", byte(67), "@byteme C", nil, "C"}, - {"json-obj", "obj", json.RawMessage(`{"foo":"bar","baz":"quux"}`), `@obj {"foo":"bar","baz":"quux"}`, nil, json.RawMessage(`{"foo":"bar","baz":"quux"}`)}, {"json-arr", "arr", json.RawMessage(`["foo",42,"bar", null,"quux", true]`), `@arr ["foo",42,"bar", null,"quux", true]`, nil, json.RawMessage(`["foo",42,"bar", null,"quux", true]`)}, {"chan", "nope", make(chan bool), "", errors.New("Unsupported type chan bool"), ""}, {"whitespace-key", "no space", "hi", "", errors.New("whitespace is not allowed in key: no space"), ""},