sync(chunk 1): VTODO mapper with an unknown-property round-trip
A hand-rolled content-line model instead of ical4j: a raw (name, params, value) tree is what the round-trip requirement wants, and a typed model normalises away exactly what has to survive. lib-recur already does RRULE and java.time is native at minSdk 29, so the 2.2 MB of zone data and the registry shims buy nothing. Deviates from SYNC.md's library table — see SYNC-PLAN.md decision 4. - domain/ical: parser, serialiser, value codecs. No Android, no data types, so the floret-kit extraction stays a file move. - data/tasks/ical/VTodoMapper: VTODO <-> TaskEntity. Claims a property only when it can reproduce it exactly; everything else round-trips verbatim through TaskEntity.unknown_properties, which already existed at v1 — no migration needed, and BEGIN/END lines carry the nesting the plan thought needed a second table. - 19 fixtures as the specification, with the canonical comparison harness from SYNC.md: no property lost, modulo the enumerated allowlist. - ICalendarWriter now delegates folding and escaping rather than carrying its own copy. VALARM ownership settled: neither side writes the other's alarms. VALARMs round-trip in the residue, local reminders stay in task_alarms. The setAlarm collision was the provider's; the two stores are now disjoint.
This commit is contained in:
@@ -0,0 +1,442 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.ical
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalParam
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* VTODO ↔ [TaskEntity].
|
||||
*
|
||||
* ## The residue
|
||||
*
|
||||
* Everything the mapper does not claim — unknown properties, unknown parameters,
|
||||
* `VALARM`s, whole unknown sub-components — is serialised verbatim into
|
||||
* [TaskEntity.unknownProperties] and re-emitted on write. RFC 5545 §3.1 requires
|
||||
* it ("Applications MUST preserve the value data for x-name and iana-token
|
||||
* values that they don't recognize"), and failing it destroys other people's
|
||||
* data invisibly — invisible in our own UI precisely because we are the client
|
||||
* that does not understand the property.
|
||||
*
|
||||
* ## What "claimed" means, and why it is narrow
|
||||
*
|
||||
* The mapper claims a property only when it can **reproduce it exactly** from
|
||||
* its columns. Everything else stays in the residue and round-trips untouched:
|
||||
*
|
||||
* - a value it cannot parse or that is out of range (`PRIORITY:11`,
|
||||
* `PERCENT-COMPLETE:abc`, `STATUS:X-DEFERRED`, `SEQUENCE:x`) — clamping or
|
||||
* defaulting these would be a silent rewrite of somebody's data;
|
||||
* - a time it can read but not reproduce — a floating stamp, or a `TZID` this
|
||||
* device's tzdb has never heard of. The column still gets a best-effort
|
||||
* instant so the UI has something to show;
|
||||
* - a `DTSTART`/`DUE` pair that disagrees on value type or timezone, where
|
||||
* authoring both from one `is_all_day` flag and one `timezone` column would
|
||||
* destroy the odd one out.
|
||||
*
|
||||
* ## Residue eviction
|
||||
*
|
||||
* A suppressed property is only suppressed while it still *agrees* with its
|
||||
* column. [contradictsResidue] compares the two on write: if the user has since
|
||||
* edited that field, the stale residue copy is evicted and the column is
|
||||
* authored. Without this, editing the due date of a task imported with a
|
||||
* floating `DUE` would silently do nothing on the server.
|
||||
*
|
||||
* ## Alarms
|
||||
*
|
||||
* `VALARM`s round-trip in the residue and are never authored here. Local
|
||||
* reminders live in `task_alarms` and are never serialised. The two stores are
|
||||
* disjoint, so neither can destroy the other — which is what `docs/SYNC.md`'s
|
||||
* `setAlarm` collision was actually about, and it does not survive into the
|
||||
* own-store world. Merging them is a later decision, not a silent one.
|
||||
*/
|
||||
object VTodoMapper {
|
||||
|
||||
/**
|
||||
* DAVx5's limit, and ours for the same two reasons: Android's `CursorWindow`
|
||||
* row cap, and `CALDAV:max-resource-size`, whose violation is a failed PUT.
|
||||
*/
|
||||
const val MAX_RESIDUE_BYTES = 25 * 1024
|
||||
|
||||
/**
|
||||
* Cardinality-one properties the mapper authors from a column, and therefore
|
||||
* must not author while the residue still holds the original.
|
||||
*/
|
||||
private val SUPPRESSED_BY_RESIDUE = setOf(
|
||||
"DTSTART", "DUE", "COMPLETED", "RECURRENCE-ID", "CREATED", "LAST-MODIFIED",
|
||||
"STATUS", "RELATED-TO",
|
||||
)
|
||||
|
||||
/** What a VTODO yields. Row identity ([TaskEntity.id], `listId`) is the caller's. */
|
||||
data class Mapped(
|
||||
val entity: TaskEntity,
|
||||
/** `RELATED-TO;RELTYPE=PARENT`, for the caller to resolve to a row id. */
|
||||
val parentUid: String?,
|
||||
/** Set when the source carried no `UID` and the caller must mint one. */
|
||||
val uidWasMissing: Boolean,
|
||||
/** Residue that exceeded [MAX_RESIDUE_BYTES] and had to be dropped. */
|
||||
val droppedResidue: Boolean,
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- read
|
||||
|
||||
fun read(vtodo: ICalComponent, listId: Long = 0L): Mapped {
|
||||
// Claims are tracked by index, not by value: two identical content lines
|
||||
// are two pieces of data, and claiming one must not swallow the other on
|
||||
// the way into the residue.
|
||||
val claimed = mutableSetOf<Int>()
|
||||
fun claim(property: ICalProperty?) {
|
||||
property?.let { p ->
|
||||
vtodo.properties.indexOfFirst { it === p }.takeIf { it >= 0 }?.let(claimed::add)
|
||||
}
|
||||
}
|
||||
|
||||
/** Claims [property] only if [value] could be read from it. */
|
||||
fun <T> take(property: ICalProperty?, value: T?): T? =
|
||||
value?.also { claim(property) }
|
||||
|
||||
val uidProperty = vtodo.property("UID")
|
||||
// Claimed even when empty: an empty UID must not reach the residue, or
|
||||
// write would emit the caller's minted UID alongside the empty one.
|
||||
val uid = take(uidProperty, uidProperty?.value?.trim()).orEmpty()
|
||||
|
||||
val dtstart = readTime(vtodo, "DTSTART")
|
||||
val due = readTime(vtodo, "DUE")
|
||||
|
||||
// A task's primary stamp is DUE; DTSTART only decides when there is no
|
||||
// DUE. Deriving one all-day flag from "either is a DATE" turns a
|
||||
// date/date-time pair into two DATEs and destroys the time half.
|
||||
val allDay = if (due.present) due.isDate else dtstart.isDate
|
||||
val timezone = dtstart.tzid ?: due.tzid
|
||||
|
||||
// Claim a stamp only when the single is_all_day flag and the single
|
||||
// timezone column can reproduce it. Otherwise it stays in the residue and
|
||||
// is re-emitted exactly as it arrived.
|
||||
if (dtstart.reproducible(allDay, timezone)) claim(dtstart.property)
|
||||
if (due.reproducible(allDay, timezone)) claim(due.property)
|
||||
|
||||
val recurrenceId = readTime(vtodo, "RECURRENCE-ID")
|
||||
if (recurrenceId.reproducible(allDay, timezone)) claim(recurrenceId.property)
|
||||
|
||||
// These three are UTC-only on the way out, so a zoned or date-valued one
|
||||
// is not reproducible.
|
||||
val completed = readTime(vtodo, "COMPLETED")
|
||||
if (completed.isUtcDateTime) claim(completed.property)
|
||||
val created = readTime(vtodo, "CREATED")
|
||||
if (created.isUtcDateTime) claim(created.property)
|
||||
val lastModified = readTime(vtodo, "LAST-MODIFIED")
|
||||
if (lastModified.isUtcDateTime) claim(lastModified.property)
|
||||
|
||||
val statusProperty = vtodo.property("STATUS")
|
||||
val status = take(
|
||||
statusProperty,
|
||||
when (statusProperty?.value?.trim()?.uppercase()) {
|
||||
"NEEDS-ACTION" -> TaskStatus.NEEDS_ACTION
|
||||
"IN-PROCESS" -> TaskStatus.IN_PROCESS
|
||||
"COMPLETED" -> TaskStatus.COMPLETED
|
||||
"CANCELLED" -> TaskStatus.CANCELLED
|
||||
else -> null
|
||||
},
|
||||
) ?: TaskStatus.NEEDS_ACTION
|
||||
|
||||
val percentProperty = vtodo.property("PERCENT-COMPLETE")
|
||||
val percent = take(
|
||||
percentProperty,
|
||||
percentProperty?.value?.trim()?.toIntOrNull()?.takeIf { it in 0..100 },
|
||||
)
|
||||
|
||||
// 0 = undefined, 1 = highest, 9 = lowest. Stored raw: bucketing on the way
|
||||
// in would rewrite a server's PRIORITY:3 as 1 and lose it on write-back.
|
||||
val priorityProperty = vtodo.property("PRIORITY")
|
||||
val priority = take(
|
||||
priorityProperty,
|
||||
priorityProperty?.value?.trim()?.toIntOrNull()?.takeIf { it in 0..9 },
|
||||
) ?: PRIORITY_NONE
|
||||
|
||||
val classProperty = vtodo.property("CLASS")
|
||||
val classification = take(
|
||||
classProperty,
|
||||
CLASS_NAMES.indexOf(classProperty?.value?.trim()?.uppercase()).takeIf { it >= 0 },
|
||||
)
|
||||
|
||||
val sequenceProperty = vtodo.property("SEQUENCE")
|
||||
val sequence = take(
|
||||
sequenceProperty,
|
||||
sequenceProperty?.value?.trim()?.toIntOrNull()?.takeIf { it >= 0 },
|
||||
) ?: 0
|
||||
|
||||
// RELTYPE defaults to PARENT (§3.2.15), and §3.8.4.5 reads backwards to
|
||||
// most implementers: the *referencing* component is the subordinate one,
|
||||
// so this points at our parent.
|
||||
//
|
||||
// Deliberately **not** claimed. TaskEntity holds only a local `parent_id`,
|
||||
// so a parent that is not in this store — not fetched yet, in another
|
||||
// collection, deleted locally — would leave nothing to write back and the
|
||||
// relationship would be destroyed. Keeping it in the residue means the
|
||||
// link survives even when the parent row does not.
|
||||
val parentUid = vtodo.properties("RELATED-TO")
|
||||
.firstOrNull { (it.param("RELTYPE") ?: "PARENT").equals("PARENT", ignoreCase = true) }
|
||||
?.value?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
val entity = TaskEntity(
|
||||
listId = listId,
|
||||
uid = uid,
|
||||
title = take(vtodo.property("SUMMARY"), vtodo.property("SUMMARY")?.text()),
|
||||
description = take(vtodo.property("DESCRIPTION"), vtodo.property("DESCRIPTION")?.text()),
|
||||
location = take(vtodo.property("LOCATION"), vtodo.property("LOCATION")?.text()),
|
||||
// URI, not TEXT — escaping it would corrupt a query string.
|
||||
url = take(vtodo.property("URL"), vtodo.property("URL")?.value?.trim()),
|
||||
status = status,
|
||||
percentComplete = percent,
|
||||
completedAt = completed.instant,
|
||||
priority = priority,
|
||||
classification = classification,
|
||||
dtstart = dtstart.instant,
|
||||
due = due.instant,
|
||||
duration = take(vtodo.property("DURATION"), vtodo.property("DURATION")?.value?.trim()),
|
||||
isAllDay = allDay,
|
||||
timezone = timezone,
|
||||
rrule = take(vtodo.property("RRULE"), vtodo.property("RRULE")?.value?.trim()),
|
||||
rdate = take(vtodo.property("RDATE"), vtodo.property("RDATE")?.value?.trim()),
|
||||
exdate = take(vtodo.property("EXDATE"), vtodo.property("EXDATE")?.value?.trim()),
|
||||
recurrenceId = recurrenceId.instant,
|
||||
createdAt = created.instant,
|
||||
lastModified = lastModified.instant,
|
||||
// The Organizer's revision counter (§3.8.7.4) — preserved verbatim,
|
||||
// never ours to bump.
|
||||
sequence = sequence,
|
||||
)
|
||||
|
||||
// DTSTAMP is regenerated on every serialisation and carries no state, so
|
||||
// it is dropped rather than stored. LAST-MODIFIED, which does carry
|
||||
// state, is a column above — conflating the two makes every sync look
|
||||
// like an edit.
|
||||
val residueProperties = vtodo.properties
|
||||
.filterIndexed { index, _ -> index !in claimed }
|
||||
.filterNot { it.name.equals("DTSTAMP", ignoreCase = true) }
|
||||
|
||||
val residueText = ICalSerializer.serializeProperties(residueProperties) +
|
||||
ICalSerializer.serializeAll(vtodo.components)
|
||||
val tooLarge = residueText.toByteArray(Charsets.UTF_8).size > MAX_RESIDUE_BYTES
|
||||
|
||||
return Mapped(
|
||||
entity = entity.copy(
|
||||
unknownProperties = residueText.takeIf { it.isNotEmpty() && !tooLarge },
|
||||
),
|
||||
parentUid = parentUid,
|
||||
uidWasMissing = uid.isEmpty(),
|
||||
droppedResidue = tooLarge,
|
||||
)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- write
|
||||
|
||||
/**
|
||||
* Serialises [entity] back to a VTODO.
|
||||
*
|
||||
* [parentUid] is the parent's `UID`, which the entity holds only as a row id.
|
||||
* [now] is the `DTSTAMP`.
|
||||
*/
|
||||
fun write(
|
||||
entity: TaskEntity,
|
||||
parentUid: String? = null,
|
||||
now: Instant = Clock.System.now(),
|
||||
): ICalComponent {
|
||||
val residue = parseResidue(entity.unknownProperties)
|
||||
val keptResidue = residue.properties.filterNot { contradictsResidue(it, entity, parentUid) }
|
||||
val suppressed = keptResidue
|
||||
.map { it.name.uppercase() }
|
||||
.filterTo(mutableSetOf()) { it in SUPPRESSED_BY_RESIDUE }
|
||||
|
||||
val properties = mutableListOf<ICalProperty>()
|
||||
fun add(name: String, value: String?, vararg params: ICalParam) {
|
||||
if (value == null || name.uppercase() in suppressed) return
|
||||
properties += ICalProperty(name, params.toList(), value)
|
||||
}
|
||||
fun addTime(name: String, instant: Instant?) {
|
||||
if (instant == null || name in suppressed) return
|
||||
properties += timeProperty(name, instant, entity)
|
||||
}
|
||||
|
||||
add("UID", entity.uid)
|
||||
add("DTSTAMP", ICalValues.formatDateTime(now, null))
|
||||
add("SEQUENCE", entity.sequence.toString())
|
||||
add("SUMMARY", entity.title?.let(ICalValues::escapeText))
|
||||
add("DESCRIPTION", entity.description?.let(ICalValues::escapeText))
|
||||
add("LOCATION", entity.location?.let(ICalValues::escapeText))
|
||||
add("URL", entity.url)
|
||||
|
||||
add("STATUS", entity.status.toICalName())
|
||||
add("PERCENT-COMPLETE", entity.percentComplete?.toString())
|
||||
// §3.8.2.1: COMPLETED MUST be UTC — no TZID, no floating, no DATE.
|
||||
add("COMPLETED", entity.completedAt?.let { ICalValues.formatDateTime(it, null) })
|
||||
add("PRIORITY", entity.priority.takeIf { it != PRIORITY_NONE }?.toString())
|
||||
add("CLASS", entity.classification?.let { CLASS_NAMES.getOrNull(it) })
|
||||
|
||||
addTime("DTSTART", entity.dtstart)
|
||||
addTime("DUE", entity.due)
|
||||
add("DURATION", entity.duration)
|
||||
|
||||
add("RRULE", entity.rrule)
|
||||
add("RDATE", entity.rdate)
|
||||
add("EXDATE", entity.exdate)
|
||||
addTime("RECURRENCE-ID", entity.recurrenceId)
|
||||
|
||||
add("CREATED", entity.createdAt?.let { ICalValues.formatDateTime(it, null) })
|
||||
add("LAST-MODIFIED", entity.lastModified?.let { ICalValues.formatDateTime(it, null) })
|
||||
|
||||
if (parentUid != null && "RELATED-TO" !in suppressed) {
|
||||
properties += ICalProperty("RELATED-TO", listOf(ICalParam("RELTYPE", "PARENT")), parentUid)
|
||||
}
|
||||
|
||||
return ICalComponent(
|
||||
name = "VTODO",
|
||||
properties = properties + keptResidue,
|
||||
components = residue.components,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a suppressing residue property no longer describes what its
|
||||
* column holds — i.e. the user has edited that field since it was imported,
|
||||
* so the stale copy must go and the column must be authored instead.
|
||||
*/
|
||||
private fun contradictsResidue(
|
||||
property: ICalProperty,
|
||||
entity: TaskEntity,
|
||||
parentUid: String?,
|
||||
): Boolean {
|
||||
fun sameInstant(column: Instant?) =
|
||||
ICalValues.readInstant(ICalValues.parseTime(property)) == column
|
||||
|
||||
return when (property.name.uppercase()) {
|
||||
"DTSTART" -> !sameInstant(entity.dtstart)
|
||||
"DUE" -> !sameInstant(entity.due)
|
||||
"COMPLETED" -> !sameInstant(entity.completedAt)
|
||||
"RECURRENCE-ID" -> !sameInstant(entity.recurrenceId)
|
||||
"CREATED" -> !sameInstant(entity.createdAt)
|
||||
"LAST-MODIFIED" -> !sameInstant(entity.lastModified)
|
||||
// The residue only ever holds a STATUS we could not read, which left
|
||||
// the column at its NEEDS-ACTION fallback. Anything else means the
|
||||
// user has since set a real status.
|
||||
"STATUS" -> entity.status != TaskStatus.NEEDS_ACTION
|
||||
// A null parentUid is "the parent is not in this store", not "there is
|
||||
// no parent" — dropping the link there would destroy a relationship
|
||||
// over a row we simply have not fetched.
|
||||
"RELATED-TO" -> parentUid != null && property.value.trim() != parentUid
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `VALARM` with `TRIGGER;RELATED=END` needs `DUE`, or `DTSTART` plus
|
||||
* `DURATION` (§3.8.6.3). Clearing the due date on a task that has an
|
||||
* end-relative reminder produces a resource the server rejects permanently —
|
||||
* and it is reachable from ordinary UI actions, so it is checked before PUT
|
||||
* rather than discovered as a 415.
|
||||
*/
|
||||
fun validate(vtodo: ICalComponent): List<String> {
|
||||
val problems = mutableListOf<String>()
|
||||
val due = vtodo.property("DUE")
|
||||
val dtstart = vtodo.property("DTSTART")
|
||||
val duration = vtodo.property("DURATION")
|
||||
|
||||
if (due != null && dtstart != null) {
|
||||
val start = ICalValues.readInstant(ICalValues.parseTime(dtstart))
|
||||
val end = ICalValues.readInstant(ICalValues.parseTime(due))
|
||||
// sabre answers 415 for both of these, not a 4xx that names them.
|
||||
if (start != null && end != null && end < start) problems += "DUE precedes DTSTART"
|
||||
if (isDateValue(dtstart) != isDateValue(due)) {
|
||||
problems += "DTSTART and DUE disagree on value type"
|
||||
}
|
||||
}
|
||||
|
||||
if (due == null && (dtstart == null || duration == null)) {
|
||||
val endRelative = vtodo.components("VALARM").any { alarm ->
|
||||
alarm.property("TRIGGER")?.param("RELATED").equals("END", ignoreCase = true)
|
||||
}
|
||||
if (endRelative) problems += "TRIGGER;RELATED=END with neither DUE nor DTSTART+DURATION"
|
||||
}
|
||||
|
||||
if (vtodo.property("METHOD") != null) problems += "METHOD is not allowed on a stored resource"
|
||||
return problems
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- helpers
|
||||
|
||||
private val CLASS_NAMES = listOf("PUBLIC", "PRIVATE", "CONFIDENTIAL")
|
||||
|
||||
private class TimeRead(
|
||||
val property: ICalProperty?,
|
||||
val instant: Instant?,
|
||||
val tzid: String?,
|
||||
val isDate: Boolean,
|
||||
val representable: Boolean,
|
||||
) {
|
||||
val present get() = property != null
|
||||
|
||||
/** True when one `is_all_day` flag and one `timezone` column reproduce it. */
|
||||
fun reproducible(allDay: Boolean, timezone: String?) =
|
||||
present && representable && isDate == allDay && tzid == timezone
|
||||
|
||||
/** True when it is a UTC date-time — the only form we author for these. */
|
||||
val isUtcDateTime get() = present && representable && !isDate && tzid == null
|
||||
}
|
||||
|
||||
private fun readTime(vtodo: ICalComponent, name: String): TimeRead {
|
||||
val property = vtodo.property(name) ?: return TimeRead(null, null, null, false, false)
|
||||
return when (val value = ICalValues.parseTime(property)) {
|
||||
is ICalValues.TimeValue.Date -> TimeRead(property, value.instant, null, true, true)
|
||||
is ICalValues.TimeValue.Timed -> TimeRead(property, value.instant, value.tzid, false, true)
|
||||
// Readable but not reproducible: the column gets the best-effort
|
||||
// instant, the property round-trips from the residue verbatim.
|
||||
is ICalValues.TimeValue.Unrepresentable ->
|
||||
TimeRead(property, value.instant, null, false, false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun timeProperty(name: String, instant: Instant, entity: TaskEntity): ICalProperty {
|
||||
if (entity.isAllDay) {
|
||||
return ICalProperty(
|
||||
name,
|
||||
listOf(ICalParam("VALUE", "DATE")),
|
||||
ICalValues.formatDate(instant),
|
||||
)
|
||||
}
|
||||
// Resolve the zone once. Letting the parameter and the value each decide
|
||||
// separately produces TZID on a `…Z` value, which §3.3.5 forbids and
|
||||
// sabre answers 415 for.
|
||||
val tzid = entity.timezone?.takeIf { runCatching { ZoneId.of(it) }.isSuccess }
|
||||
val params = if (tzid == null) emptyList() else listOf(ICalParam("TZID", tzid))
|
||||
return ICalProperty(name, params, ICalValues.formatDateTime(instant, tzid))
|
||||
}
|
||||
|
||||
/** The same DATE test [ICalValues.parseTime] applies, so the two cannot disagree. */
|
||||
private fun isDateValue(property: ICalProperty): Boolean =
|
||||
ICalValues.parseTime(property) is ICalValues.TimeValue.Date
|
||||
|
||||
private fun ICalProperty.text(): String = ICalValues.unescapeText(value)
|
||||
|
||||
private fun parseResidue(text: String?): ICalComponent {
|
||||
if (text.isNullOrEmpty()) return ICalComponent("VTODO")
|
||||
// The residue is stored as bare properties followed by whole sub-component
|
||||
// blocks, so it parses as the body of a VTODO with the wrapper restored.
|
||||
return runCatching {
|
||||
ICalParser.parse("BEGIN:VTODO\r\n$text\r\nEND:VTODO\r\n")
|
||||
}.getOrElse { ICalComponent("VTODO") }
|
||||
}
|
||||
|
||||
private fun TaskStatus.toICalName(): String = when (this) {
|
||||
TaskStatus.NEEDS_ACTION -> "NEEDS-ACTION"
|
||||
TaskStatus.IN_PROCESS -> "IN-PROCESS"
|
||||
TaskStatus.COMPLETED -> "COMPLETED"
|
||||
TaskStatus.CANCELLED -> "CANCELLED"
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.domain.export
|
||||
import de.jeanlucmakiola.agendula.domain.Priority
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.calendarDate
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
||||
import de.jeanlucmakiola.agendula.domain.toICal
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
@@ -30,9 +32,6 @@ object ICalendarWriter {
|
||||
|
||||
private const val PRODUCT_ID = "-//Jean-Luc Makiola//Agendula//EN"
|
||||
|
||||
/** RFC 5545 caps a content line at 75 octets, excluding the CRLF. */
|
||||
private const val MAX_LINE_OCTETS = 75
|
||||
|
||||
private val DATE = DateTimeFormatter.ofPattern("yyyyMMdd")
|
||||
private val DATE_TIME_UTC = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")
|
||||
|
||||
@@ -145,53 +144,17 @@ object ICalendarWriter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes a TEXT value per RFC 5545 §3.3.11. Backslash first, or it would
|
||||
* double the backslashes introduced by the later replacements.
|
||||
*/
|
||||
internal fun escapeText(value: String): String = value
|
||||
.replace("\\", "\\\\")
|
||||
.replace(";", "\\;")
|
||||
.replace(",", "\\,")
|
||||
.replace("\r\n", "\\n")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\n")
|
||||
|
||||
/**
|
||||
* Folds a content line to at most [MAX_LINE_OCTETS] octets, continuing with
|
||||
* CRLF + a single space.
|
||||
* TEXT escaping and folding are the same operations the sync mapper needs,
|
||||
* and having two implementations of either is how the two halves drift.
|
||||
* These delegate; the implementations live in `domain/ical/`.
|
||||
*
|
||||
* Counted in **octets, not characters** — the limit is defined that way, and an
|
||||
* emoji in a task title is four of them. Splits are kept on character
|
||||
* boundaries so folding can never cut a UTF-8 sequence in half and corrupt the
|
||||
* text; an importer unfolds by removing CRLF + leading whitespace, recovering
|
||||
* the original exactly.
|
||||
* This writer itself stays separate from `VTodoMapper` on purpose: it
|
||||
* serialises **domain** export models, which exist in both storage modes,
|
||||
* whereas the mapper serialises Room entities, which exist only in one.
|
||||
*/
|
||||
internal fun fold(content: String): String {
|
||||
if (content.utf8Size() <= MAX_LINE_OCTETS) return content
|
||||
internal fun escapeText(value: String): String = ICalValues.escapeText(value)
|
||||
|
||||
val out = StringBuilder()
|
||||
var octets = 0
|
||||
// First line takes the full budget; every continuation loses one octet to
|
||||
// the leading space.
|
||||
var budget = MAX_LINE_OCTETS
|
||||
var index = 0
|
||||
while (index < content.length) {
|
||||
val codePoint = content.codePointAt(index)
|
||||
val charCount = Character.charCount(codePoint)
|
||||
val size = String(Character.toChars(codePoint)).utf8Size()
|
||||
if (octets + size > budget) {
|
||||
out.append(CRLF).append(' ')
|
||||
octets = 0
|
||||
budget = MAX_LINE_OCTETS - 1
|
||||
}
|
||||
out.append(content, index, index + charCount)
|
||||
octets += size
|
||||
index += charCount
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun String.utf8Size(): Int = toByteArray(Charsets.UTF_8).size
|
||||
internal fun fold(content: String): String = ICalSerializer.fold(content)
|
||||
|
||||
private fun TaskStatus.toICalName(): String = when (this) {
|
||||
TaskStatus.NEEDS_ACTION -> "NEEDS-ACTION"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.jeanlucmakiola.agendula.domain.ical
|
||||
|
||||
/**
|
||||
* A parameter on a content line: `TZID=Europe/Berlin`, `MEMBER="a","b"`.
|
||||
*
|
||||
* Values are held **unquoted**. Quoting is optional in RFC 5545 and carries no
|
||||
* meaning, so it is normalised away on parse and reapplied on serialise only
|
||||
* where the grammar forces it — `docs/SYNC-PLAN.md` puts parameter quoting on
|
||||
* the round-trip allowlist for exactly this reason.
|
||||
*/
|
||||
data class ICalParam(val name: String, val values: List<String>) {
|
||||
constructor(name: String, value: String) : this(name, listOf(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* One content line.
|
||||
*
|
||||
* [value] is kept **exactly as it arrived**, unfolded but still escaped. That is
|
||||
* deliberate and it is the whole reason this model exists instead of a typed
|
||||
* one: a property we do not model is re-emitted from this string verbatim, so it
|
||||
* cannot be normalised, reordered inside itself, or lost. Decoding happens in
|
||||
* the mapper, for the properties the mapper actually claims.
|
||||
*/
|
||||
data class ICalProperty(
|
||||
val name: String,
|
||||
val params: List<ICalParam> = emptyList(),
|
||||
val value: String,
|
||||
) {
|
||||
/** First value of [name], unquoted, or `null`. */
|
||||
fun param(name: String): String? =
|
||||
params.firstOrNull { it.name.equals(name, ignoreCase = true) }?.values?.firstOrNull()
|
||||
}
|
||||
|
||||
/** A `BEGIN:`/`END:` block — `VCALENDAR`, `VTODO`, `VALARM`, `VTIMEZONE`, or one we don't know. */
|
||||
data class ICalComponent(
|
||||
val name: String,
|
||||
val properties: List<ICalProperty> = emptyList(),
|
||||
val components: List<ICalComponent> = emptyList(),
|
||||
) {
|
||||
fun property(name: String): ICalProperty? =
|
||||
properties.firstOrNull { it.name.equals(name, ignoreCase = true) }
|
||||
|
||||
fun properties(name: String): List<ICalProperty> =
|
||||
properties.filter { it.name.equals(name, ignoreCase = true) }
|
||||
|
||||
fun components(name: String): List<ICalComponent> =
|
||||
components.filter { it.name.equals(name, ignoreCase = true) }
|
||||
|
||||
/** This component with every property in [names] removed. Sub-components are untouched. */
|
||||
fun without(names: Set<String>): ICalComponent {
|
||||
val upper = names.map { it.uppercase() }.toSet()
|
||||
return copy(properties = properties.filterNot { it.name.uppercase() in upper })
|
||||
}
|
||||
|
||||
/** True when nothing survives — no properties and no sub-components worth keeping. */
|
||||
fun isEmpty(): Boolean = properties.isEmpty() && components.isEmpty()
|
||||
}
|
||||
|
||||
/** Thrown when input is not recoverable as iCalendar at all. */
|
||||
class ICalParseException(message: String) : Exception(message)
|
||||
@@ -0,0 +1,149 @@
|
||||
package de.jeanlucmakiola.agendula.domain.ical
|
||||
|
||||
/**
|
||||
* Parses RFC 5545 text into an [ICalComponent] tree.
|
||||
*
|
||||
* Lexical only: it splits content lines into name, parameters and value and
|
||||
* nests `BEGIN`/`END` blocks. It does not interpret a single value — that is the
|
||||
* mapper's job, and keeping the two apart is what lets an unrecognised property
|
||||
* survive a read-modify-write cycle untouched.
|
||||
*
|
||||
* Deliberately tolerant, because servers and other clients are not careful:
|
||||
* unknown components nest like any other, a stray `END` without its `BEGIN` is
|
||||
* ignored rather than fatal, and a line with no colon is skipped. Only a missing
|
||||
* outer component is fatal.
|
||||
*/
|
||||
object ICalParser {
|
||||
|
||||
fun parse(text: String): ICalComponent =
|
||||
parseAll(text).firstOrNull() ?: throw ICalParseException("no component found")
|
||||
|
||||
/** Every top-level component in [text]. Normally one `VCALENDAR`. */
|
||||
fun parseAll(text: String): List<ICalComponent> {
|
||||
val roots = mutableListOf<ICalComponent>()
|
||||
val stack = ArrayDeque<Builder>()
|
||||
|
||||
for (line in unfold(text)) {
|
||||
val property = parseLine(line) ?: continue
|
||||
when {
|
||||
property.name.equals("BEGIN", ignoreCase = true) ->
|
||||
stack.addLast(Builder(property.value.trim().uppercase()))
|
||||
|
||||
property.name.equals("END", ignoreCase = true) -> {
|
||||
// Close by *name*, not by position. A resource with a missing
|
||||
// END:VTODO would otherwise have its END:VCALENDAR close the
|
||||
// VTODO, leave VCALENDAR open, and end with no component at
|
||||
// all — discarding a whole multiget response over one
|
||||
// malformed task.
|
||||
val name = property.value.trim().uppercase()
|
||||
val depth = stack.indexOfLast { it.name == name }
|
||||
if (depth < 0) continue
|
||||
repeat(stack.size - depth) { close(stack, roots) }
|
||||
}
|
||||
|
||||
// A property before any BEGIN is malformed; dropping it is the only
|
||||
// option that doesn't invent a component to hang it on.
|
||||
else -> stack.lastOrNull()?.properties?.add(property)
|
||||
}
|
||||
}
|
||||
// Anything still open at end of input is missing its END. Keeping it is
|
||||
// strictly better than dropping it.
|
||||
while (stack.isNotEmpty()) close(stack, roots)
|
||||
return roots
|
||||
}
|
||||
|
||||
private fun close(stack: ArrayDeque<Builder>, roots: MutableList<ICalComponent>) {
|
||||
val component = stack.removeLast().build()
|
||||
val parent = stack.lastOrNull()
|
||||
if (parent == null) roots += component else parent.components += component
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits [text] into unfolded content lines.
|
||||
*
|
||||
* RFC 5545 §3.1: a CRLF followed by a single space or tab is a fold and both
|
||||
* are removed. Bare LF is accepted because plenty of real files carry it, and
|
||||
* a leading BOM is stripped.
|
||||
*/
|
||||
internal fun unfold(text: String): List<String> {
|
||||
val lines = mutableListOf<StringBuilder>()
|
||||
val normalised = text.removePrefix("")
|
||||
for (raw in normalised.split("\r\n", "\n", "\r")) {
|
||||
if (raw.isEmpty()) continue
|
||||
val continuation = raw[0] == ' ' || raw[0] == '\t'
|
||||
if (continuation && lines.isNotEmpty()) {
|
||||
lines.last().append(raw, 1, raw.length)
|
||||
} else {
|
||||
lines += StringBuilder(raw)
|
||||
}
|
||||
}
|
||||
return lines.map { it.toString() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits one unfolded line into name, parameters and value.
|
||||
*
|
||||
* The value separator is the first colon **outside a quoted parameter value**
|
||||
* — `ATTENDEE;CN="Smith, J:r":mailto:x` has three colons and only the third
|
||||
* one ends the parameter list.
|
||||
*/
|
||||
internal fun parseLine(line: String): ICalProperty? {
|
||||
var quoted = false
|
||||
var colon = -1
|
||||
for (i in line.indices) {
|
||||
val c = line[i]
|
||||
if (c == '"') quoted = !quoted
|
||||
else if (c == ':' && !quoted) { colon = i; break }
|
||||
}
|
||||
// An unbalanced quote in the parameter section leaves the scan stuck
|
||||
// inside a quoted string forever. Falling back to the first colon keeps
|
||||
// the line instead of dropping it whole.
|
||||
if (colon < 0) colon = line.indexOf(':')
|
||||
if (colon < 0) return null
|
||||
|
||||
val head = line.substring(0, colon)
|
||||
val value = line.substring(colon + 1)
|
||||
|
||||
val segments = splitUnquoted(head, ';')
|
||||
val name = segments.firstOrNull()?.trim().orEmpty()
|
||||
if (name.isEmpty()) return null
|
||||
|
||||
val params = segments.drop(1).mapNotNull { segment ->
|
||||
val eq = segment.indexOf('=')
|
||||
// A parameter with no '=' is non-conformant. Keep it as a valueless
|
||||
// parameter rather than dropping it — it is still someone's data.
|
||||
if (eq < 0) return@mapNotNull ICalParam(segment.trim(), emptyList())
|
||||
val paramName = segment.substring(0, eq).trim()
|
||||
if (paramName.isEmpty()) return@mapNotNull null
|
||||
val values = splitUnquoted(segment.substring(eq + 1), ',').map { it.unquote() }
|
||||
ICalParam(paramName, values)
|
||||
}
|
||||
|
||||
return ICalProperty(name, params, value)
|
||||
}
|
||||
|
||||
/** Splits on [delimiter], ignoring delimiters inside double quotes. */
|
||||
private fun splitUnquoted(text: String, delimiter: Char): List<String> {
|
||||
val out = mutableListOf<String>()
|
||||
val current = StringBuilder()
|
||||
var quoted = false
|
||||
for (c in text) {
|
||||
when {
|
||||
c == '"' -> { quoted = !quoted; current.append(c) }
|
||||
c == delimiter && !quoted -> { out += current.toString(); current.clear() }
|
||||
else -> current.append(c)
|
||||
}
|
||||
}
|
||||
out += current.toString()
|
||||
return out
|
||||
}
|
||||
|
||||
private fun String.unquote(): String =
|
||||
if (length >= 2 && startsWith('"') && endsWith('"')) substring(1, length - 1) else this
|
||||
|
||||
private class Builder(val name: String) {
|
||||
val properties = mutableListOf<ICalProperty>()
|
||||
val components = mutableListOf<ICalComponent>()
|
||||
fun build() = ICalComponent(name, properties.toList(), components.toList())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package de.jeanlucmakiola.agendula.domain.ical
|
||||
|
||||
/**
|
||||
* Serialises an [ICalComponent] tree back to RFC 5545 text.
|
||||
*
|
||||
* The inverse of [ICalParser] for everything the parser preserves: property
|
||||
* order within a component, parameter order, and property values byte-for-byte.
|
||||
* What it does *not* preserve is fold position and parameter quoting, neither of
|
||||
* which carries information — both are on the round-trip allowlist.
|
||||
*/
|
||||
object ICalSerializer {
|
||||
|
||||
private const val CRLF = "\r\n"
|
||||
|
||||
/** RFC 5545 §3.1 caps a content line at 75 octets, excluding the CRLF. */
|
||||
private const val MAX_LINE_OCTETS = 75
|
||||
|
||||
fun serialize(component: ICalComponent): String = buildString {
|
||||
write(component)
|
||||
}
|
||||
|
||||
/** Serialises [components] one after another — the form the residue is stored in. */
|
||||
fun serializeAll(components: List<ICalComponent>): String = buildString {
|
||||
components.forEach { write(it) }
|
||||
}
|
||||
|
||||
/** Serialises bare properties with no enclosing component. */
|
||||
fun serializeProperties(properties: List<ICalProperty>): String = buildString {
|
||||
properties.forEach { line(render(it)) }
|
||||
}
|
||||
|
||||
private fun StringBuilder.write(component: ICalComponent) {
|
||||
line("BEGIN:${component.name}")
|
||||
component.properties.forEach { line(render(it)) }
|
||||
component.components.forEach { write(it) }
|
||||
line("END:${component.name}")
|
||||
}
|
||||
|
||||
private fun StringBuilder.line(content: String) {
|
||||
append(fold(content)).append(CRLF)
|
||||
}
|
||||
|
||||
internal fun render(property: ICalProperty): String = buildString {
|
||||
append(property.name)
|
||||
for (param in property.params) {
|
||||
append(';').append(param.name)
|
||||
if (param.values.isNotEmpty()) {
|
||||
append('=')
|
||||
append(param.values.joinToString(",") { quoteIfNeeded(it) })
|
||||
}
|
||||
}
|
||||
append(':').append(property.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* A parameter value is quoted only when the grammar forces it — it may not
|
||||
* contain a colon, semicolon or comma unquoted. Any embedded double quote is
|
||||
* dropped, because RFC 5545 gives it no escape and emitting one produces a
|
||||
* line no parser can read back.
|
||||
*/
|
||||
private fun quoteIfNeeded(value: String): String {
|
||||
if (value.none { it == ':' || it == ';' || it == ',' }) return value
|
||||
return "\"" + value.replace("\"", "") + "\""
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds a content line to at most [MAX_LINE_OCTETS] octets, continuing with
|
||||
* CRLF + a single space.
|
||||
*
|
||||
* Counted in **octets, not characters** — the limit is defined that way, and
|
||||
* an emoji in a task title is four of them. Splits stay on character
|
||||
* boundaries so a fold can never cut a UTF-8 sequence in half.
|
||||
*/
|
||||
internal fun fold(content: String): String {
|
||||
if (content.utf8Size() <= MAX_LINE_OCTETS) return content
|
||||
|
||||
val out = StringBuilder()
|
||||
var octets = 0
|
||||
// The first line takes the full budget; every continuation loses one octet
|
||||
// to its leading space.
|
||||
var budget = MAX_LINE_OCTETS
|
||||
var index = 0
|
||||
while (index < content.length) {
|
||||
val codePoint = content.codePointAt(index)
|
||||
val charCount = Character.charCount(codePoint)
|
||||
val size = String(Character.toChars(codePoint)).utf8Size()
|
||||
if (octets + size > budget) {
|
||||
out.append(CRLF).append(' ')
|
||||
octets = 0
|
||||
budget = MAX_LINE_OCTETS - 1
|
||||
}
|
||||
out.append(content, index, index + charCount)
|
||||
octets += size
|
||||
index += charCount
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun String.utf8Size(): Int = toByteArray(Charsets.UTF_8).size
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package de.jeanlucmakiola.agendula.domain.ical
|
||||
|
||||
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Value-level codecs: RFC 5545 TEXT escaping and the DATE / DATE-TIME forms. */
|
||||
object ICalValues {
|
||||
|
||||
private val DATE = DateTimeFormatter.ofPattern("yyyyMMdd")
|
||||
private val DATE_TIME = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss")
|
||||
|
||||
/**
|
||||
* RFC 5545 §3.3.11. Note the asymmetry with [unescapeText]: a literal colon
|
||||
* needs no escape in a property value, and escaping it is a common bug that
|
||||
* other clients then have to undo.
|
||||
*/
|
||||
fun escapeText(value: String): String = value
|
||||
.replace("\\", "\\\\")
|
||||
.replace(";", "\\;")
|
||||
.replace(",", "\\,")
|
||||
.replace("\r\n", "\\n")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\n")
|
||||
|
||||
fun unescapeText(value: String): String {
|
||||
val out = StringBuilder(value.length)
|
||||
var i = 0
|
||||
while (i < value.length) {
|
||||
val c = value[i]
|
||||
if (c == '\\' && i + 1 < value.length) {
|
||||
when (val next = value[i + 1]) {
|
||||
'n', 'N' -> out.append('\n')
|
||||
'\\', ';', ',' -> out.append(next)
|
||||
// An unknown escape is left as it was found; inventing a
|
||||
// meaning for it would corrupt the value on write-back.
|
||||
else -> out.append(c).append(next)
|
||||
}
|
||||
i += 2
|
||||
} else {
|
||||
out.append(c)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
/** How a DATE / DATE-TIME property was written, and whether we can reproduce it. */
|
||||
sealed interface TimeValue {
|
||||
/** `VALUE=DATE` — date-only. */
|
||||
data class Date(val instant: Instant) : TimeValue
|
||||
|
||||
/** A UTC instant (`…Z`), or a zoned one whose `TZID` the device knows. */
|
||||
data class Timed(val instant: Instant, val tzid: String?) : TimeValue
|
||||
|
||||
/**
|
||||
* A form we can read but not reproduce: a floating time (no `Z`, no
|
||||
* `TZID`), or a `TZID` absent from the device's tzdb. [instant] is a
|
||||
* best-effort reading for the UI; the property itself stays in the
|
||||
* residue and is re-emitted verbatim, so nothing is lost on write-back.
|
||||
*/
|
||||
data class Unrepresentable(val instant: Instant?, val reason: String) : TimeValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a DATE or DATE-TIME property.
|
||||
*
|
||||
* The unknown-`TZID` case is the one that matters: RFC 5545 lets a file
|
||||
* carry its own `VTIMEZONE` for a zone the device has never heard of, and
|
||||
* guessing UTC there silently moves the user's task by hours. It is reported
|
||||
* as [TimeValue.Unrepresentable] instead.
|
||||
*/
|
||||
fun parseTime(property: ICalProperty): TimeValue {
|
||||
val raw = property.value.trim()
|
||||
val isDate = property.param("VALUE").equals("DATE", ignoreCase = true) ||
|
||||
(raw.length == 8 && !raw.contains('T'))
|
||||
|
||||
if (isDate) {
|
||||
val date = runCatching { LocalDate.parse(raw, DATE) }.getOrNull()
|
||||
?: return TimeValue.Unrepresentable(null, "unparseable DATE")
|
||||
return TimeValue.Date(allDayInstantOf(date))
|
||||
}
|
||||
|
||||
val utc = raw.endsWith("Z")
|
||||
val local = runCatching { LocalDateTime.parse(raw.removeSuffix("Z"), DATE_TIME) }.getOrNull()
|
||||
?: return TimeValue.Unrepresentable(null, "unparseable DATE-TIME")
|
||||
|
||||
if (utc) return TimeValue.Timed(local.toInstant(ZoneOffset.UTC).toKotlin(), null)
|
||||
|
||||
val tzid = property.param("TZID")
|
||||
?: return TimeValue.Unrepresentable(
|
||||
local.toInstant(ZoneOffset.UTC).toKotlin(),
|
||||
"floating time",
|
||||
)
|
||||
|
||||
val zone = runCatching { ZoneId.of(tzid) }.getOrNull()
|
||||
?: return TimeValue.Unrepresentable(
|
||||
local.toInstant(ZoneOffset.UTC).toKotlin(),
|
||||
"unknown TZID $tzid",
|
||||
)
|
||||
|
||||
return TimeValue.Timed(local.atZone(zone).toInstant().toKotlin(), tzid)
|
||||
}
|
||||
|
||||
/** The instant this value denotes, however well it could be read. */
|
||||
fun readInstant(value: TimeValue): Instant? = when (value) {
|
||||
is TimeValue.Date -> value.instant
|
||||
is TimeValue.Timed -> value.instant
|
||||
is TimeValue.Unrepresentable -> value.instant
|
||||
}
|
||||
|
||||
/** `20260904` — the all-day form. */
|
||||
fun formatDate(instant: Instant): String =
|
||||
java.time.Instant.ofEpochMilli(instant.toEpochMilliseconds())
|
||||
.atZone(ZoneOffset.UTC)
|
||||
.format(DATE)
|
||||
|
||||
/** `20260904T080000Z`, or the local form when [tzid] names a zone we know. */
|
||||
fun formatDateTime(instant: Instant, tzid: String?): String {
|
||||
val moment = java.time.Instant.ofEpochMilli(instant.toEpochMilliseconds())
|
||||
val zone = tzid?.let { runCatching { ZoneId.of(it) }.getOrNull() }
|
||||
return if (zone == null) {
|
||||
moment.atZone(ZoneOffset.UTC).format(DATE_TIME) + "Z"
|
||||
} else {
|
||||
moment.atZone(zone).format(DATE_TIME)
|
||||
}
|
||||
}
|
||||
|
||||
private fun java.time.Instant.toKotlin(): Instant =
|
||||
Instant.fromEpochMilliseconds(toEpochMilli())
|
||||
}
|
||||
Reference in New Issue
Block a user