diff --git a/micron/src/main/java/network/columba/app/micron/MicronParser.kt b/micron/src/main/java/network/columba/app/micron/MicronParser.kt index b491fc885..6eed931cc 100644 --- a/micron/src/main/java/network/columba/app/micron/MicronParser.kt +++ b/micron/src/main/java/network/columba/app/micron/MicronParser.kt @@ -1,5 +1,7 @@ package network.columba.app.micron +import java.time.DateTimeException + /** * Parser for Micron markup language used by NomadNet. * @@ -20,6 +22,7 @@ package network.columba.app.micron * - `` `f `` reset foreground, `` `b `` reset background * - `` `c `` center, `` `l `` left, `` `r `` right, `` `a `` default alignment * - `` ` `` (backtick + backtick or end of token) reset all formatting + * - `` `T`T `` / `` `T|`T `` timestamp, see [MicronTimestamp] */ object MicronParser { private const val DEFAULT_FIELD_WIDTH = 24 @@ -334,6 +337,16 @@ object MicronParser { continue } + // Timestamp: `T`T or `T|`T + if (cmd == 'T') { + flushText() + val timestamp = parseTimestamp(line, i) + if (timestamp != null) elements.add(MicronElement.Text(timestamp.rendered, style)) + // The marker is always consumed; an unusable payload stays text. + i = timestamp?.nextIndex ?: i + 2 + continue + } + // Unknown formatting command — consume backtick + command char // (matches Python MicronParser.py lines 684-686: unrecognized // chars in formatting mode are silently discarded) @@ -363,6 +376,51 @@ object MicronParser { val nextIndex: Int, ) + /** A parsed timestamp construct: its rendered text and the index just past it. */ + private data class Timestamp( + val rendered: String, + val nextIndex: Int, + ) + + /** + * Parses a `` `T `` construct whose opening marker is at [backtickIndex], or returns + * null when it is unusable: no closing marker, a payload that is not unix seconds, or + * an instant this platform cannot represent. + */ + private fun parseTimestamp( + line: String, + backtickIndex: Int, + ): Timestamp? { + val markerLength = MicronTimestamp.MARKER.length + val body = backtickIndex + markerLength + val end = line.indexOf(MicronTimestamp.MARKER, body) + if (end < 0) return null + + val payload = line.substring(body, end) + val separator = payload.indexOf('|') + val secondsText = if (separator < 0) payload else payload.substring(0, separator) + val format = if (separator < 0) "" else payload.substring(separator + 1) + val seconds = secondsText.trim().toLongOrNull() ?: return null + + return renderTimestamp(seconds, format)?.let { Timestamp(it, end + markerLength) } + } + + /** + * Renders [seconds], or null when the instant falls outside `java.time`'s calendar, + * which ends near year ±1e9 and so is reachable from a Long. Rendering a page must not + * throw on the seconds a page chose to name. + */ + @Suppress("SwallowedException") + private fun renderTimestamp( + seconds: Long, + format: String, + ): String? = + try { + MicronTimestamp.formatUnix(seconds, format) + } catch (e: DateTimeException) { + null + } + @Suppress("CyclomaticComplexMethod") private fun processFormatCommand( cmd: Char, diff --git a/micron/src/main/java/network/columba/app/micron/MicronTimestamp.kt b/micron/src/main/java/network/columba/app/micron/MicronTimestamp.kt new file mode 100644 index 000000000..b8cc9975a --- /dev/null +++ b/micron/src/main/java/network/columba/app/micron/MicronTimestamp.kt @@ -0,0 +1,250 @@ +package network.columba.app.micron + +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.format.TextStyle +import java.time.temporal.IsoFields +import java.util.Locale +import kotlin.math.abs + +/** + * Renders the `` `T `` timestamp construct of Micron markup: + * + * ``` + * `T`T + * `T|`T + * ``` + * + * The page supplies unix seconds, which carry no timezone, and the client renders that + * instant in the reader's own zone. [format] is a strftime format — every conversion C + * strftime defines, plus the glibc and BSD extensions — and an empty one selects + * [DEFAULT_FORMAT]. The conversions strftime resolves through the locale are pinned to + * their C locale forms, so a page renders the same text on every device. + * + * The engine is hand-written because [DateTimeFormatter] has no padding flags and rejects + * an unknown conversion instead of passing it through. + */ +object MicronTimestamp { + /** Used when a construct names no format: `Fri Sep 11, 2026 9:08:27PM EST`. */ + const val DEFAULT_FORMAT = "%a %b %d, %Y %-I:%M:%S%p %Z" + + /** Opens and closes a timestamp construct. */ + internal const val MARKER = "`T" + + /** Padding flags that may follow a `%`: no padding, space padding, zero padding. */ + private const val PAD_FLAGS = "-_0" + + /** Modifiers that may precede a conversion; POSIX makes both no-ops in the C locale. */ + private const val MODIFIERS = "EO" + + /** + * Renders unix [seconds] in [zone], the device's own zone by default. Throws + * [java.time.DateTimeException] for an instant outside `java.time`'s calendar. + */ + fun formatUnix( + seconds: Long, + format: String = "", + zone: ZoneId = ZoneId.systemDefault(), + ): String = + formatTime( + ZonedDateTime.ofInstant(Instant.ofEpochSecond(seconds), zone), + format.ifEmpty { DEFAULT_FORMAT }, + ) + + /** Expands every conversion in [format] against [time]. */ + private fun formatTime( + time: ZonedDateTime, + format: String, + ): String { + val out = StringBuilder() + var i = 0 + + while (i < format.length) { + if (format[i] != '%') { + out.append(format[i]) + i++ + } else { + val expansion = expansionAt(time, format, i) + out.append(expansion.text) + i = expansion.nextIndex + } + } + + return out.toString() + } + + /** The text of one conversion and the index just past it. */ + private data class Expansion( + val text: String, + val nextIndex: Int, + ) + + /** + * Expands the conversion at the `%` at [percent], including any padding flag and E/O + * modifier. An unrecognised conversion is copied through verbatim: POSIX leaves it + * undefined, and that keeps a typo in a page's format visible. + */ + private fun expansionAt( + time: ZonedDateTime, + format: String, + percent: Int, + ): Expansion { + var cursor = percent + 1 + val flag = if (cursor < format.length && format[cursor] in PAD_FLAGS) format[cursor] else null + if (flag != null) cursor++ + if (cursor + 1 < format.length && format[cursor] in MODIFIERS) cursor++ + if (cursor >= format.length) return Expansion("%", format.length) + + val expansion = expand(time, format[cursor], flag) + return Expansion(expansion ?: format.substring(percent, cursor + 1), cursor + 1) + } + + /** A dispatch table over the conversions, so it is long rather than deep. */ + @Suppress("CyclomaticComplexMethod") + private fun expand( + time: ZonedDateTime, + conversion: Char, + flag: Char?, + ): String? = + when (conversion) { + '%' -> "%" + '+' -> formatTime(time, "%a %b %e %H:%M:%S %Z %Y") // the date(1) default format + 'a', 'A', 'b', 'h', 'B' -> name(time, conversion) + 'c', 'D', 'F', 'r', 'R', 'T', 'v', 'x', 'X' -> composite(time, conversion) + 'd', 'e', 'H', 'I', 'j', 'k', 'l', 'm', 'M', 'S', 'y', 'C', 'V' -> number(time, conversion, flag) + 'g' -> pad(isoWeekYear(time) % 100, 2, flag, spaceFill = false) + 'G' -> isoWeekYear(time).toString() + 'n' -> "\n" + 'p' -> if (time.hour < 12) "AM" else "PM" + 'P' -> if (time.hour < 12) "am" else "pm" + 's' -> time.toEpochSecond().toString() + 't' -> "\t" + 'u' -> time.dayOfWeek.value.toString() + 'U' -> pad(weekOfYear(time, sundayFirst = true), 2, flag, spaceFill = false) + 'w' -> (time.dayOfWeek.value % 7).toString() + 'W' -> pad(weekOfYear(time, sundayFirst = false), 2, flag, spaceFill = false) + 'Y' -> time.year.toString() + 'z' -> zoneOffset(time) + 'Z' -> zoneName(time) + else -> null + } + + private fun name( + time: ZonedDateTime, + conversion: Char, + ): String = + when (conversion) { + 'a' -> time.dayOfWeek.getDisplayName(TextStyle.SHORT, ENGLISH) + 'A' -> time.dayOfWeek.getDisplayName(TextStyle.FULL, ENGLISH) + 'b', 'h' -> time.month.getDisplayName(TextStyle.SHORT, ENGLISH) + 'B' -> time.month.getDisplayName(TextStyle.FULL, ENGLISH) + else -> "" + } + + private fun number( + time: ZonedDateTime, + conversion: Char, + flag: Char?, + ): String = + when (conversion) { + 'C' -> pad(time.year / 100, 2, flag, spaceFill = false) + 'd' -> pad(time.dayOfMonth, 2, flag, spaceFill = false) + 'e' -> pad(time.dayOfMonth, 2, flag, spaceFill = true) + 'H' -> pad(time.hour, 2, flag, spaceFill = false) + 'I' -> pad(hour12(time.hour), 2, flag, spaceFill = false) + 'j' -> pad(time.dayOfYear, 3, flag, spaceFill = false) + 'k' -> pad(time.hour, 2, flag, spaceFill = true) + 'l' -> pad(hour12(time.hour), 2, flag, spaceFill = true) + 'm' -> pad(time.monthValue, 2, flag, spaceFill = false) + 'M' -> pad(time.minute, 2, flag, spaceFill = false) + 'S' -> pad(time.second, 2, flag, spaceFill = false) + 'V' -> pad(time.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR), 2, flag, spaceFill = false) + 'y' -> pad(time.year % 100, 2, flag, spaceFill = false) + else -> "" + } + + /** Conversions that are shorthand for a format of their own, in the C locale. */ + private fun composite( + time: ZonedDateTime, + conversion: Char, + ): String = + when (conversion) { + 'c' -> formatTime(time, "%a %b %e %H:%M:%S %Y") + 'D', 'x' -> formatTime(time, "%m/%d/%y") + 'F' -> formatTime(time, "%Y-%m-%d") + 'r' -> formatTime(time, "%I:%M:%S %p") + 'R' -> formatTime(time, "%H:%M") + 'T', 'X' -> formatTime(time, "%H:%M:%S") + 'v' -> formatTime(time, "%e-%b-%Y") + else -> "" + } + + /** Reports an hour on the 12-hour clock, where midnight and noon are both 12. */ + private fun hour12(hour: Int): Int { + val wrapped = hour % 12 + return if (wrapped == 0) 12 else wrapped + } + + /** The ISO 8601 week-based year, which can differ from the calendar year. */ + private fun isoWeekYear(time: ZonedDateTime): Int = time.get(IsoFields.WEEK_BASED_YEAR) + + /** + * The week of the year that `%U` and `%W` count. Days before the year's first Sunday or + * Monday are week zero. + */ + private fun weekOfYear( + time: ZonedDateTime, + sundayFirst: Boolean, + ): Int { + val weekday = if (sundayFirst) time.dayOfWeek.value % 7 else time.dayOfWeek.value - 1 + return (time.dayOfYear + 7 - weekday) / 7 + } + + /** + * Renders [value] at [width]. [flag] is the padding flag that preceded the conversion: + * `-` leaves it bare, `_` pads with spaces, `0` pads with zeros. With no flag, + * [spaceFill] selects the space padding that `%e`, `%k` and `%l` have by default. + */ + private fun pad( + value: Int, + width: Int, + flag: Char?, + spaceFill: Boolean, + ): String = + when { + flag == '-' -> value.toString() + flag == '_' -> value.toString().padStart(width, ' ') + flag == '0' -> value.toString().padStart(width, '0') + spaceFill -> value.toString().padStart(width, ' ') + else -> value.toString().padStart(width, '0') + } + + /** `%z`: the numeric offset from UTC, `-0500`. An offset with seconds is truncated, as strftime is. */ + private fun zoneOffset(time: ZonedDateTime): String { + val total = time.offset.totalSeconds + val magnitude = abs(total) + + return buildString { + append(if (total < 0) '-' else '+') + append((magnitude / 3600).toString().padStart(2, '0')) + append(((magnitude % 3600) / 60).toString().padStart(2, '0')) + } + } + + /** + * `%Z`, read from the `z` pattern rather than [ZoneId.getDisplayName]: the latter + * returns CLDR metazone titles (`ET`) where strftime returns the abbreviation + * (`EDT`). java.time spells UTC's name `Z`, so that one is normalized. + */ + private fun zoneName(time: ZonedDateTime): String { + val name = ZONE_TEXT.format(time) + return if (name == "Z") "UTC" else name + } + + /** Names are pinned to English so a page renders the same on every device. */ + private val ENGLISH = Locale.ENGLISH + + private val ZONE_TEXT = DateTimeFormatter.ofPattern("z", ENGLISH) +} diff --git a/micron/src/test/java/network/columba/app/micron/MicronTimestampTest.kt b/micron/src/test/java/network/columba/app/micron/MicronTimestampTest.kt new file mode 100644 index 000000000..89d2944a2 --- /dev/null +++ b/micron/src/test/java/network/columba/app/micron/MicronTimestampTest.kt @@ -0,0 +1,186 @@ +package network.columba.app.micron + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.ZoneId +import java.time.ZoneOffset + +/** + * The `` `T `` construct and the strftime engine behind it. Expected values follow Python + * 3's `time.strftime`, the reference for the format language. + * + * The zones are real ones with a steady abbreviation — `America/Jamaica` is UTC-5 with no + * daylight saving and `Asia/Tokyo` is UTC+9 — since `java.time.ZoneId` cannot represent an + * arbitrary fixed offset under a name. + */ +class MicronTimestampTest { + private val est = ZoneId.of("America/Jamaica") + private val jst = ZoneId.of("Asia/Tokyo") + + private val fridayEvening = 1_789_178_907L // 2026-09-11 21:08:27 in UTC-5 + private val tuesdayMorning = 1_788_271_507L // 2026-09-01 09:05:07 in UTC-5 + + @Test + fun `strftime conversions`() { + val cases = + listOf( + FormatCase("default format", fridayEvening, "", "Fri Sep 11, 2026 9:08:27PM EST"), + FormatCase("explicit default format", fridayEvening, "%a %b %d, %Y %-I:%M:%S%p %Z", "Fri Sep 11, 2026 9:08:27PM EST"), + FormatCase("12-hour pads by default", tuesdayMorning, "%I:%M:%S %p", "09:05:07 AM"), + FormatCase("no-pad flag", tuesdayMorning, "%-I:%-M:%-S %-d/%-m", "9:5:7 1/9"), + FormatCase("day of month", tuesdayMorning, "%d|%e|%-d", "01| 1|1"), + FormatCase("month and year", tuesdayMorning, "%b %B %y %Y", "Sep September 26 2026"), + FormatCase("h is a synonym for b", tuesdayMorning, "%h", "Sep"), + FormatCase("weekday", fridayEvening, "%a %A", "Fri Friday"), + FormatCase("24-hour clock", fridayEvening, "%H:%M:%S", "21:08:27"), + FormatCase("iso shorthands", fridayEvening, "%F %T", "2026-09-11 21:08:27"), + FormatCase("time shorthand", fridayEvening, "%R", "21:08"), + FormatCase("us shorthand", fridayEvening, "%D", "09/11/26"), + FormatCase("unix seconds", fridayEvening, "%s", "1789178907"), + FormatCase("numeric zone offset", fridayEvening, "%z", "-0500"), + FormatCase("zone abbreviation", fridayEvening, "%Z", "EST"), + FormatCase("day of year", tuesdayMorning, "%j", "244"), + FormatCase("day of year is three digits wide", 1_767_589_200L, "%j|%-j", "005|5"), + FormatCase("literal percent", fridayEvening, "100%%", "100%"), + FormatCase("trailing percent", fridayEvening, "50%", "50%"), + FormatCase("unknown specifier passes through", fridayEvening, "%q", "%q"), + FormatCase("unknown specifier keeps its no-pad flag", fridayEvening, "%-q", "%-q"), + FormatCase("unknown multi-character specifier", fridayEvening, "%10d", "%10d"), + FormatCase("bare percent", fridayEvening, "%", "%"), + FormatCase("trailing no-pad percent", fridayEvening, "abc%-", "abc%"), + FormatCase("no-pad is ignored where there is nothing to pad", fridayEvening, "%-Z|%-s|%-p", "EST|1789178907|PM"), + FormatCase("text around the conversions", fridayEvening, "at %H:%M on %F", "at 21:08 on 2026-09-11"), + FormatCase("century", tuesdayMorning, "%C", "20"), + FormatCase("locale date and time, in the C locale", tuesdayMorning, "%c", "Tue Sep 1 09:05:07 2026"), + FormatCase("iso week-based year", fridayEvening, "%G|%g", "2026|26"), + FormatCase("hour, space padded", tuesdayMorning, "%k", " 9"), + FormatCase("12-hour clock, space padded", tuesdayMorning, "%l", " 9"), + FormatCase("locale date, in the C locale", fridayEvening, "%x", "09/11/26"), + FormatCase("locale time, in the C locale", fridayEvening, "%X", "21:08:27"), + FormatCase("locale 12-hour time, in the C locale", tuesdayMorning, "%r", "09:05:07 AM"), + FormatCase("newline and tab", fridayEvening, "%n%t", "\n\t"), + FormatCase("iso weekday and week number", fridayEvening, "%u|%w", "5|5"), + FormatCase("week number, Sunday first", fridayEvening, "%U", "36"), + FormatCase("week number, Monday first", fridayEvening, "%W", "36"), + FormatCase("iso week number", fridayEvening, "%V", "37"), + FormatCase("bsd date form", tuesdayMorning, "%v", " 1-Sep-2026"), + FormatCase("date(1) form", tuesdayMorning, "%+", "Tue Sep 1 09:05:07 EST 2026"), + FormatCase("space and zero padding flags", tuesdayMorning, "%-d|%_d|%0d|%-H|%_H|%0H", "1| 1|01|9| 9|09"), + FormatCase("E and O modifiers change nothing in the C locale", tuesdayMorning, "%Ec|%Od|%OX|%OY", "Tue Sep 1 09:05:07 2026|01|09:05:07|2026"), + ) + + for (case in cases) { + assertEquals(case.name, case.want, MicronTimestamp.formatUnix(case.seconds, case.format, est)) + } + } + + @Test + fun `midnight and noon are twelve on the 12-hour clock`() { + assertEquals("12:00 AM", MicronTimestamp.formatUnix(1_789_102_800L, "%I:%M %p", est)) + assertEquals("12:00 PM", MicronTimestamp.formatUnix(1_789_146_000L, "%I:%M %p", est)) + } + + @Test + fun `the viewer's zone decides the rendering`() { + assertEquals("Fri Sep 11, 2026 9:08:27PM EST", MicronTimestamp.formatUnix(fridayEvening, "", est)) + assertEquals("Sat Sep 12, 2026 2:08:27AM UTC", MicronTimestamp.formatUnix(fridayEvening, "", ZoneOffset.UTC)) + assertEquals("Sat Sep 12, 2026 2:08:27AM UTC", MicronTimestamp.formatUnix(fridayEvening, "", ZoneId.of("UTC"))) + assertEquals("Sat Sep 12, 2026 11:08:27AM JST", MicronTimestamp.formatUnix(fridayEvening, "", jst)) + assertEquals("Fri Sep 11, 2026 10:08:27PM EDT", MicronTimestamp.formatUnix(fridayEvening, "", ZoneId.of("America/New_York"))) + } + + @Test + fun `offset zones have no abbreviation of their own`() { + assertEquals("-05:00", MicronTimestamp.formatUnix(fridayEvening, "%Z", ZoneOffset.ofHours(-5))) + assertEquals("UTC", MicronTimestamp.formatUnix(fridayEvening, "%Z", ZoneOffset.UTC)) + // %z is minute-granular, as strftime is: the offset's seconds are dropped. + assertEquals("-0044", MicronTimestamp.formatUnix(fridayEvening, "%z", ZoneOffset.ofTotalSeconds(-2670))) + assertEquals("+0545", MicronTimestamp.formatUnix(fridayEvening, "%z", ZoneOffset.ofTotalSeconds(20700))) + } + + @Test + fun `format and zone default when omitted`() { + assertEquals("Fri Sep 11, 2026 9:08:27PM EST", MicronTimestamp.formatUnix(fridayEvening, zone = est)) + assertEquals( + MicronTimestamp.formatUnix(fridayEvening, "", ZoneId.systemDefault()), + MicronTimestamp.formatUnix(fridayEvening), + ) + } + + @Test + fun `construct rendering`() { + val local = MicronTimestamp.formatUnix(fridayEvening) + val cases = + listOf( + MarkupCase("seconds alone use the default format", "`T1789178907`T", local), + MarkupCase("a format overrides the default", "`T1789178907|%F %T`T", MicronTimestamp.formatUnix(fridayEvening, "%F %T")), + MarkupCase("an empty format uses the default", "`T1789178907|`T", local), + MarkupCase("the format is everything after the first pipe", "`T1789178907|%F|%T`T", MicronTimestamp.formatUnix(fridayEvening, "%F|%T")), + MarkupCase("seconds are trimmed", "`T 1789178907 `T", local), + MarkupCase("surrounding text is preserved", "posted `T1789178907|%s`T by Glenn", "posted 1789178907 by Glenn"), + MarkupCase("two constructs on one line", "`T1789178907|%s`T and `T1788271507|%s`T", "1789178907 and 1788271507"), + MarkupCase("adjacent constructs", "`T1`T`T2`T", MicronTimestamp.formatUnix(1) + MicronTimestamp.formatUnix(2)), + ) + + for (case in cases) { + assertEquals(case.name, case.want, stripText(MicronParser.parse(case.markup))) + } + } + + @Test + fun `a line-initial construct is not a partial`() { + val text = stripText(MicronParser.parse("`T1789178907|%s`T Glenn: hello")) + assertEquals("1789178907 Glenn: hello", text) + assertFalse(text.contains("{")) + } + + @Test + fun `constructs that cannot be rendered stay text`() { + val cases = + listOf( + MarkupCase("no closing marker", "`T1789178907", "1789178907"), + MarkupCase("no digits", "`Tsoon`T", "soon"), + MarkupCase("empty", "`T`T", ""), + MarkupCase("seconds past a Long", "`T99999999999999999999`T", "99999999999999999999"), + // Inside a Long, but outside java.time's calendar, which ends near year 1e9. + MarkupCase("seconds past the calendar", "`T40000000000000000`T", "40000000000000000"), + MarkupCase("seconds past the calendar with a format", "`T40000000000000000|%F`T", "40000000000000000|%F"), + MarkupCase("Long.MAX_VALUE", "`T9223372036854775807`T", "9223372036854775807"), + ) + + for (case in cases) { + assertEquals(case.name, case.want, stripText(MicronParser.parse(case.markup))) + } + } + + @Test + fun `a construct inherits the line style`() { + val document = MicronParser.parse("`!posted `T1789178907|%s`T by Glenn`!") + val line = document.lines.single() + val texts = line.elements.filterIsInstance() + assertEquals(listOf("posted ", "1789178907", " by Glenn"), texts.map { it.content }) + assertTrue(texts.all { it.style.bold }) + } + + /** The visible text of a parsed document, for comparing an expansion. */ + private fun stripText(document: MicronDocument): String = + document.lines + .flatMap { it.elements } + .filterIsInstance() + .joinToString("") { it.content } + + private data class FormatCase( + val name: String, + val seconds: Long, + val format: String, + val want: String, + ) + + private data class MarkupCase( + val name: String, + val markup: String, + val want: String, + ) +} diff --git a/port-deviations.md b/port-deviations.md index 2c7057984..dbe8cbdb4 100644 --- a/port-deviations.md +++ b/port-deviations.md @@ -42,3 +42,21 @@ unrestricted IP interfaces stay up while Wi-Fi-only and cellular-only interfaces safely filtered out. If Android exposes a deterministic underlying transport on the same default-network capabilities, that underlying transport wins. `NONE` is reserved for the absence of a live default route. + +--- + +## Micron `` `T `` timestamp construct + +`MicronParser` understands `` `T`T `` and +`` `T|`T ``, which render an instant in the reader's own +timezone. Python's `MicronParser.make_output` has no such construct — it consumes the +marker and renders the payload as text — so a reader on the reference client sees raw +seconds where Columba shows a local time. + +The construct is defined by the gonomadnet port, and its format language is POSIX strftime: +the conversions Python 3's `time.strftime` accepts, so a page renders identically on every +client that supports it. A page that does not use `` `T `` parses exactly as before. + +One divergence is accepted: an instant outside `java.time`'s calendar renders as its payload +rather than as a year beyond it. `%Z` is read from Java's `z` formatter, since +`ZoneId.getDisplayName` returns CLDR metazone titles (`ET` where strftime says `EDT`).