Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitkeep
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
# Updated: 2026-08-20T05:25:16.696Z
# Updated: 2026-08-20T06:10:07.182Z
# Updated: 2026-08-20T07:45:09.136Z
# Updated: 2026-08-27T11:41:42.189Z
# Updated: 2026-08-27T11:41:42.189Z
# Updated: 2026-08-27T14:37:49.393Z
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ All implementations share the same design philosophy and provide feature parity.
- **Readable by Default**: In every language `encode()` writes indented, plain-text Links Notation; the previous single-line base64 form stays available as `encode_compact()` (alias `encode_obfuscated()`)
- **One Record per Line**: `encode_line()` writes the same readable document on one line and `decode_line()` reads it back exactly, so an append-only log stays greppable, tailable and countable by `wc -l`
- **Object Identity**: Shared references and circular references are preserved by the compact format via object ids; the readable format is a plain tree and raises a circular-reference error instead
- **Full Unicode**: Strings are written as text; only a value that cannot be written as text (one holding control characters) is base64-encoded, and it is marked individually as `(base64 "…")`
- **Full Unicode**: Strings are always written as text a newline stays a newline, a tab stays a tab, and every word stays greppable; only the characters a form cannot carry are percent-escaped, in a value marked individually as `(escaped "…")`
- **Opt-in Tracing**: Set `LINO_CODEC_DEBUG=1` to trace encoding and decoding, the same way in every language
- **Simple API**: Easy-to-use `encode()` and `decode()` functions
- **JSON/Lino Conversion**: Convert between JSON and Links Notation (JavaScript)
Expand Down Expand Up @@ -420,8 +420,17 @@ object, bare-value lines make an array:
`null` are bare, so types survive a round trip
- `NaN`, `Infinity` and `-Infinity` are written as such
- An empty array is `()`; an empty object is `(` + newline + `)`
- Only a value that cannot be written as text (one containing control characters)
is base64-encoded, and it is marked individually as `(base64 "bGluZTEKbGluZTI=")`
- A string is written as text whatever it holds: a newline stays a newline and
a tab stays a tab, so every word stays greppable
- A string containing the quote delimiter is written between a run of at least
three of them — `"""say "hi""""` — which the notation's own parser reads back
unchanged, rather than by doubling the quote
- Only the characters this form cannot carry — a carriage return, which CRLF
normalisation would rewrite, and the remaining control characters — are
percent-escaped, in a value marked individually as `(escaped "first%0D")`.
`(base64 "…")` written by versions up to 0.6.0 is still decoded
- A value that occurs more than once is written out every time: a shared
reference would make one record depend on another
- The four languages produce byte-identical output, checked by the shared
fixtures in [`fixtures/readable-format/cases.json`](fixtures/readable-format/cases.json)

Expand All @@ -438,7 +447,9 @@ record per line — appending is one write, compaction cuts at a newline, and
- An object is `(o: (key value) …)` and an empty object is `(o:)`
- An array is `(value …)` and an empty array is `()`
- Scalars and strings are written exactly as in the indented form, so a string
keeps its own characters and a number keeps its type
keeps its own characters and a number keeps its type — except that a newline
would end the record, so on this form the newline, and nothing else, is
escaped: `(escaped "line one%0Aline two")` keeps both lines readable
- The `o` marker is what removes the ambiguity a flat layout otherwise has:
without it `((key value))` reads both as a one-pair object and as an array
holding a two-element array. With it a bare `( )` on one line is always an
Expand All @@ -454,7 +465,8 @@ The previous single-line form, kept for compatibility and for the object graphs
the readable tree cannot express (shared and circular references):

- Basic types are encoded with type markers: `(int 42)`, `(str aGVsbG8=)`, `(bool true)`
- Strings are base64-encoded to handle special characters and newlines
- Strings are base64-encoded here, and only here: this is the one form that
asks for it by name, and `encode()` never reaches for it
- Collections with self-references use `(obj_id: type content...)`, e.g.
`(obj_0: dict ((str c2VsZg==) obj_0))` for `{"self": obj}`
- Circular references use direct object id references: `obj_0` (without a `ref` keyword)
Expand Down
26 changes: 26 additions & 0 deletions csharp/.changeset/20260827_120000_issue_45_plain_text_values.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'Lino.Objects.Codec': minor
---

`Codec.Encode` and `Codec.EncodeLine` never reach for base64. A single control
character used to turn a whole string into base64, so a log message holding one
newline hid its own text: the message, the stack trace and every word a reader
would grep for. Both readable forms now write the text as it is and escape only
what the form itself cannot carry — the newline on a single line, the carriage
return everywhere, and the remaining control characters — in a value marked
`(escaped "line one%0Aline two")` whose payload is percent-escaped, so even the
escaped part stays readable. base64 is reachable only through
`Codec.EncodeCompact` / `Codec.EncodeObfuscated`, which say so by name.

A string containing the quote delimiter is written between a run of at least
three of them — `"""say "hi""""` — instead of by doubling the quote, which
desynchronises the notation's own parser. `Readable.EscapedMarker` names the new
`escaped` link id.

Fixes a key holding a control character, which used to be written as
`(base64 "…")` in key position and read back as a list element, so
`{"a\nb": "a\nb"}` decoded to `["a\nb", "a\nb"]`. `(base64 "…")` is still
decoded, so every document written by an earlier version keeps reading; the
shared fixtures pin this in a `legacy` section.

See [issue #45](https://github.com/link-foundation/lino-objects-codec/issues/45).
18 changes: 13 additions & 5 deletions csharp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ A C# library for working with Links Notation format. This library provides unive
- **Readable by Default**: `Codec.Encode()` writes plain, indented text that can be read and reviewed
- **One Record per Line**: `Codec.EncodeLine()` writes the same document on one line and `Codec.DecodeLine()` reads it back exactly, so an append-only log stays greppable, tailable and countable by `wc -l`
- **Object Identity**: Shared references and circular references are preserved by the compact format (`Codec.EncodeCompact`) via object ids
- **Full Unicode**: Strings are written as text; only a value that cannot be written as text (one holding control characters) is base64-encoded, and it is marked individually as `(base64 "…")`
- **Full Unicode**: Strings are always written as text a newline stays a newline and a tab stays a tab, so every word stays greppable; only the characters a form cannot carry are percent-escaped, in a value marked individually as `(escaped "…")`
- **Opt-in Tracing**: Set `LINO_CODEC_DEBUG=1` to trace encoding and decoding, the same way in every language
- **Simple API**: Easy-to-use `Codec.Encode()` and `Codec.Decode()` functions
- **Thread Safe**: Each operation uses a fresh codec instance
Expand Down Expand Up @@ -215,9 +215,16 @@ bare-value lines make a list:
- Numbers, `true`, `false` and `null` are bare, so types survive a round trip
- `NaN`, `Infinity` and `-Infinity` are written as such
- An empty list is `()`; an empty dictionary is `(` + newline + `)`
- A value that cannot be written as text (one containing control characters) is
base64-encoded on its own and marked as `(base64 "bGluZTEKbGluZTI=")`;
everything around it stays readable
- A string is written as text whatever it holds: a newline stays a newline and
a tab stays a tab, so every word stays greppable
- A string containing the quote delimiter is written between a run of at least
three of them — `"""say "hi""""` — rather than by doubling the quote
- Only the characters this form cannot carry — a carriage return and the
remaining control characters — are percent-escaped, in a value marked on its
own as `(escaped "first%0D")`; everything around it stays readable, and
`(base64 "…")` written by earlier versions is still decoded
- A value that occurs more than once is written out every time: a shared
reference would make one record depend on another

### Single-line format (`Codec.EncodeLine`)

Expand All @@ -244,7 +251,8 @@ The previous single-line form, kept for compatibility and for the object graphs
the readable tree cannot express (shared and circular references):

- Basic types carry a type marker: `(int 42)`, `(str SGVsbG8=)`, `(bool true)`
- Strings are base64-encoded to handle special characters and newlines
- Strings are base64-encoded here, and only here: this is the one form that
asks for it by name, and `encode()` never reaches for it
- Collections with self-references use `(obj_id: type content...)`, e.g.
`(obj_0: dict ((str c2VsZg==) obj_0))` for `{"self": obj}`
- Circular references use direct object ID references: `obj_0` (without a `ref` keyword)
Expand Down
Loading
Loading