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:
2026-09-04 16:45:03 +02:00
parent fd6c302c17
commit a30114efbb
32 changed files with 2010 additions and 48 deletions
@@ -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())
}