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())
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.ical
|
||||
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
||||
|
||||
/**
|
||||
* Reduces a component tree to the multiset of facts a faithful round-trip must
|
||||
* preserve, so two serialisations can be compared for *meaning* rather than for
|
||||
* bytes.
|
||||
*
|
||||
* `docs/SYNC.md` is explicit that byte-stability is unachievable and that
|
||||
* asserting it produces a corpus which gets normalised until it tests nothing.
|
||||
* Six things legitimately differ across a round-trip — `PRODID` must change,
|
||||
* fold position carries no information, parameter quoting is optional, property
|
||||
* order within a component is unconstrained, `DTSTAMP` is regenerated, and
|
||||
* `VTIMEZONE` is re-emitted — so those are enumerated here and **nothing else
|
||||
* may differ**. In particular the unfolded value octets of every property are
|
||||
* compared exactly.
|
||||
*/
|
||||
object ICalCanonical {
|
||||
|
||||
/** Properties whose value is allowed to differ across a round-trip. */
|
||||
private val ALLOWED_TO_DIFFER = setOf("PRODID", "DTSTAMP", "LAST-MODIFIED", "SEQUENCE")
|
||||
|
||||
/** Re-emitted from its own rules rather than preserved verbatim. */
|
||||
private val OPAQUE_COMPONENTS = setOf("VTIMEZONE")
|
||||
|
||||
/**
|
||||
* The one value-level equivalence, and it is an equivalence rather than a
|
||||
* concession: RFC 5545 section 3.8.1.11 gives a to-do no default `STATUS`,
|
||||
* and an absent one is universally read as needing action. Normalising
|
||||
* between the two directions loses nothing, so both are dropped before
|
||||
* comparison. A `STATUS` that is *not* `NEEDS-ACTION` is compared normally,
|
||||
* so losing a completion still fails.
|
||||
*/
|
||||
private fun isNeutralStatus(name: String, value: String) =
|
||||
name == "STATUS" && value == "NEEDS-ACTION"
|
||||
|
||||
data class Fact(
|
||||
val path: String,
|
||||
val name: String,
|
||||
val params: Map<String, List<String>>,
|
||||
val value: String,
|
||||
)
|
||||
|
||||
fun facts(component: ICalComponent, path: String = ""): List<Fact> {
|
||||
if (component.name in OPAQUE_COMPONENTS) return emptyList()
|
||||
val here = if (path.isEmpty()) component.name else "$path/${component.name}"
|
||||
val own = component.properties
|
||||
.filterNot { it.name.uppercase() in ALLOWED_TO_DIFFER }
|
||||
.filterNot { isNeutralStatus(it.name.uppercase(), it.value.trim()) }
|
||||
.map { property ->
|
||||
Fact(
|
||||
path = here,
|
||||
name = property.name.uppercase(),
|
||||
params = property.params
|
||||
.associate { it.name.uppercase() to it.values }
|
||||
.toSortedMap()
|
||||
.toMap(),
|
||||
// The comparison unit `docs/SYNC.md` specifies: the unfolded,
|
||||
// **unescaped** value. `\N` and `\n` are the same escape, and a
|
||||
// bare comma in a TEXT value is malformed input that we repair
|
||||
// on write — both are equivalences, not losses. Anything that
|
||||
// actually changes the value still fails.
|
||||
value = ICalValues.unescapeText(property.value),
|
||||
)
|
||||
}
|
||||
return own + component.components.flatMap { facts(it, here) }
|
||||
}
|
||||
|
||||
/** Facts present in [before] but missing from [after] — i.e. what was lost. */
|
||||
fun lost(before: ICalComponent, after: ICalComponent): List<Fact> {
|
||||
val remaining = facts(after).toMutableList()
|
||||
return facts(before).filterNot { remaining.remove(it) }
|
||||
}
|
||||
|
||||
/** Facts [after] gained that [before] never had. */
|
||||
fun invented(before: ICalComponent, after: ICalComponent): List<Fact> = lost(after, before)
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.ical
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
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.ICalParser
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
class VTodoMapperTest {
|
||||
|
||||
private fun vtodo(vararg lines: String): ICalComponent =
|
||||
ICalParser.parse((listOf("BEGIN:VTODO", "UID:t-1") + lines + "END:VTODO").joinToString("\r\n"))
|
||||
|
||||
@Nested
|
||||
inner class Reading {
|
||||
|
||||
@Test
|
||||
fun `PRIORITY is stored raw, because bucketing on the way in loses it`() {
|
||||
// Priority.HIGH folds 1-4 into one bucket, so rewriting a server's
|
||||
// PRIORITY:3 as 1 would be a silent edit on the next write-back.
|
||||
assertThat(VTodoMapper.read(vtodo("PRIORITY:3")).entity.priority).isEqualTo(3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an out-of-range PRIORITY falls back to none`() {
|
||||
assertThat(VTodoMapper.read(vtodo("PRIORITY:42")).entity.priority).isEqualTo(PRIORITY_NONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unrecognised STATUS is not flattened to NEEDS-ACTION`() {
|
||||
val mapped = VTodoMapper.read(vtodo("STATUS:X-DEFERRED"))
|
||||
assertThat(mapped.entity.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
// It survives in the residue instead of being rewritten.
|
||||
assertThat(mapped.entity.unknownProperties).contains("STATUS:X-DEFERRED")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RELATED-TO points up - the referencing component is the subordinate one`() {
|
||||
val mapped = VTodoMapper.read(vtodo("RELATED-TO;RELTYPE=PARENT:parent-uid"))
|
||||
assertThat(mapped.parentUid).isEqualTo("parent-uid")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RELTYPE defaults to PARENT when absent`() {
|
||||
assertThat(VTodoMapper.read(vtodo("RELATED-TO:parent-uid")).parentUid).isEqualTo("parent-uid")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a RELATED-TO with another RELTYPE is somebody else's relationship`() {
|
||||
val mapped = VTodoMapper.read(vtodo("RELATED-TO;RELTYPE=SIBLING:other-uid"))
|
||||
assertThat(mapped.parentUid).isNull()
|
||||
assertThat(mapped.entity.unknownProperties).contains("RELTYPE=SIBLING")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a parent link is reported but left in the residue`() {
|
||||
// TaskEntity holds only a local parent_id, so a parent that is not in
|
||||
// this store would leave nothing to write back. Keeping the property
|
||||
// means the relationship survives a parent we have not fetched.
|
||||
val mapped = VTodoMapper.read(
|
||||
vtodo("RELATED-TO:parent-uid", "RELATED-TO;RELTYPE=SIBLING:other-uid"),
|
||||
)
|
||||
assertThat(mapped.parentUid).isEqualTo("parent-uid")
|
||||
assertThat(mapped.entity.unknownProperties).contains("parent-uid")
|
||||
assertThat(mapped.entity.unknownProperties).contains("other-uid")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a malformed scalar stays in the residue instead of being clamped`() {
|
||||
val mapped = VTodoMapper.read(
|
||||
vtodo("PRIORITY:11", "PERCENT-COMPLETE:150", "SEQUENCE:x"),
|
||||
)
|
||||
assertThat(mapped.entity.priority).isEqualTo(PRIORITY_NONE)
|
||||
assertThat(mapped.entity.percentComplete).isNull()
|
||||
assertThat(mapped.entity.sequence).isEqualTo(0)
|
||||
val residue = mapped.entity.unknownProperties.orEmpty()
|
||||
assertThat(residue).contains("PRIORITY:11")
|
||||
assertThat(residue).contains("PERCENT-COMPLETE:150")
|
||||
assertThat(residue).contains("SEQUENCE:x")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a mixed value-type pair keeps the odd one out in the residue`() {
|
||||
// One is_all_day flag cannot author a DATE start and a timed due, and
|
||||
// flattening both to dates destroys the 09:00.
|
||||
val mapped = VTodoMapper.read(
|
||||
vtodo("DTSTART;VALUE=DATE:20260901", "DUE;TZID=Europe/Berlin:20260901T090000"),
|
||||
)
|
||||
assertThat(mapped.entity.isAllDay).isFalse()
|
||||
assertThat(mapped.entity.unknownProperties).contains("DTSTART;VALUE=DATE:20260901")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second timezone stays in the residue rather than being collapsed`() {
|
||||
val mapped = VTodoMapper.read(
|
||||
vtodo(
|
||||
"DTSTART;TZID=Europe/Berlin:20260901T080000",
|
||||
"DUE;TZID=America/New_York:20260901T140000",
|
||||
),
|
||||
)
|
||||
assertThat(mapped.entity.timezone).isEqualTo("Europe/Berlin")
|
||||
assertThat(mapped.entity.unknownProperties).contains("America/New_York")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DTSTAMP is dropped, LAST-MODIFIED is kept`() {
|
||||
val mapped = VTodoMapper.read(
|
||||
vtodo("DTSTAMP:20260901T120000Z", "LAST-MODIFIED:20260801T090000Z"),
|
||||
)
|
||||
assertThat(mapped.entity.unknownProperties.orEmpty()).doesNotContain("DTSTAMP")
|
||||
assertThat(mapped.entity.lastModified).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing UID is reported rather than invented`() {
|
||||
val bare = ICalParser.parse("BEGIN:VTODO\r\nSUMMARY:no uid\r\nEND:VTODO")
|
||||
assertThat(VTodoMapper.read(bare).uidWasMissing).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a DUE date with no DTSTART still reads as all-day`() {
|
||||
val mapped = VTodoMapper.read(vtodo("DUE;VALUE=DATE:20260901"))
|
||||
assertThat(mapped.entity.isAllDay).isTrue()
|
||||
assertThat(mapped.entity.due).isNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class Writing {
|
||||
|
||||
@Test
|
||||
fun `SEQUENCE is preserved verbatim and never bumped`() {
|
||||
// It is the Organizer's revision counter (RFC 5545 section 3.8.7.4);
|
||||
// a client that increments it confuses scheduling-aware peers.
|
||||
val entity = entity(sequence = 7)
|
||||
assertThat(VTodoMapper.write(entity).property("SEQUENCE")?.value).isEqualTo("7")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `COMPLETED is written in UTC, with no TZID and no DATE form`() {
|
||||
val entity = entity(
|
||||
completedAt = Instant.parse("2026-09-01T19:00:00Z"),
|
||||
isAllDay = true,
|
||||
timezone = "Europe/Berlin",
|
||||
)
|
||||
val completed = VTodoMapper.write(entity).property("COMPLETED")
|
||||
assertThat(completed?.value).isEqualTo("20260901T190000Z")
|
||||
assertThat(completed?.params).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day task writes VALUE=DATE`() {
|
||||
val entity = entity(due = Instant.parse("2026-09-01T00:00:00Z"), isAllDay = true)
|
||||
val due = VTodoMapper.write(entity).property("DUE")
|
||||
assertThat(due?.param("VALUE")).isEqualTo("DATE")
|
||||
assertThat(due?.value).isEqualTo("20260901")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unedited residue property suppresses the column it came from`() {
|
||||
val entity = entity(
|
||||
// What read() would have stored: the best-effort reading of the
|
||||
// unknown-zone value it could not reproduce.
|
||||
due = Instant.parse("2026-09-10T17:00:00Z"),
|
||||
unknownProperties = "DUE;TZID=Custom/HQ:20260910T170000\r\n",
|
||||
)
|
||||
val written = VTodoMapper.write(entity)
|
||||
assertThat(written.properties("DUE")).hasSize(1)
|
||||
assertThat(written.property("DUE")?.param("TZID")).isEqualTo("Custom/HQ")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an edited column evicts the stale residue copy`() {
|
||||
// Without eviction, changing the due date of a task imported with an
|
||||
// unreproducible stamp would silently do nothing on the server.
|
||||
val entity = entity(
|
||||
due = Instant.parse("2026-09-20T17:00:00Z"),
|
||||
unknownProperties = "DUE;TZID=Custom/HQ:20260910T170000\r\n",
|
||||
)
|
||||
val written = VTodoMapper.write(entity)
|
||||
assertThat(written.properties("DUE")).hasSize(1)
|
||||
assertThat(written.property("DUE")?.value).isEqualTo("20260920T170000Z")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreadable STATUS in the residue is not doubled by the default`() {
|
||||
// STATUS has cardinality one; authoring NEEDS-ACTION next to the
|
||||
// residue copy produces a resource with two of them.
|
||||
val entity = entity(unknownProperties = "STATUS:X-DEFERRED\r\n")
|
||||
val written = VTodoMapper.write(entity)
|
||||
assertThat(written.properties("STATUS")).hasSize(1)
|
||||
assertThat(written.property("STATUS")?.value).isEqualTo("X-DEFERRED")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a locally set status evicts the unreadable one`() {
|
||||
val entity = entity(unknownProperties = "STATUS:X-DEFERRED\r\n")
|
||||
.copy(status = TaskStatus.COMPLETED)
|
||||
val written = VTodoMapper.write(entity)
|
||||
assertThat(written.properties("STATUS")).hasSize(1)
|
||||
assertThat(written.property("STATUS")?.value).isEqualTo("COMPLETED")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timezone the device cannot resolve never becomes TZID on a UTC value`() {
|
||||
// RFC 5545 section 3.3.5 forbids TZID on a `...Z` value, and sabre
|
||||
// answers 415 for it.
|
||||
val entity = entity(due = Instant.parse("2026-09-05T05:30:00Z"), timezone = "Custom/Nope")
|
||||
val due = VTodoMapper.write(entity).property("DUE")
|
||||
assertThat(due?.params).isEmpty()
|
||||
assertThat(due?.value).isEqualTo("20260905T053000Z")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PRIORITY 0 is omitted, because 0 means undefined`() {
|
||||
assertThat(VTodoMapper.write(entity()).property("PRIORITY")).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class Validation {
|
||||
|
||||
@Test
|
||||
fun `DUE before DTSTART is caught here, not as a 415 from sabre`() {
|
||||
val problems = VTodoMapper.validate(
|
||||
vtodo("DTSTART:20260910T100000Z", "DUE:20260901T100000Z"),
|
||||
)
|
||||
assertThat(problems).contains("DUE precedes DTSTART")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DTSTART and DUE must agree on value type`() {
|
||||
val problems = VTodoMapper.validate(
|
||||
vtodo("DTSTART;VALUE=DATE:20260901", "DUE:20260910T100000Z"),
|
||||
)
|
||||
assertThat(problems).contains("DTSTART and DUE disagree on value type")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an end-relative alarm with no DUE is invalid, and reachable from the UI`() {
|
||||
// Clearing the due date on a task that has an end-relative reminder is
|
||||
// an ordinary UI action that produces a permanently rejected resource.
|
||||
val withAlarm = ICalParser.parse(
|
||||
"""
|
||||
BEGIN:VTODO
|
||||
UID:t-1
|
||||
SUMMARY:x
|
||||
BEGIN:VALARM
|
||||
ACTION:DISPLAY
|
||||
TRIGGER;RELATED=END:-PT15M
|
||||
END:VALARM
|
||||
END:VTODO
|
||||
""".trimIndent(),
|
||||
)
|
||||
assertThat(VTodoMapper.validate(withAlarm))
|
||||
.contains("TRIGGER;RELATED=END with neither DUE nor DTSTART+DURATION")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `METHOD is rejected, because sabre answers 415 for it`() {
|
||||
assertThat(VTodoMapper.validate(vtodo("METHOD:REQUEST")))
|
||||
.contains("METHOD is not allowed on a stored resource")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an implicit DATE is recognised as one, so value types do not falsely disagree`() {
|
||||
// A bare 8-digit value with no T is a DATE whether or not VALUE=DATE
|
||||
// says so. Two different date tests would block a legitimate PUT.
|
||||
assertThat(
|
||||
VTodoMapper.validate(vtodo("DTSTART:20260101", "DUE;VALUE=DATE:20260102")),
|
||||
).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a well-formed task has no problems`() {
|
||||
assertThat(
|
||||
VTodoMapper.validate(vtodo("DTSTART:20260901T100000Z", "DUE:20260910T100000Z")),
|
||||
).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private fun entity(
|
||||
sequence: Int = 0,
|
||||
completedAt: Instant? = null,
|
||||
due: Instant? = null,
|
||||
isAllDay: Boolean = false,
|
||||
timezone: String? = null,
|
||||
unknownProperties: String? = null,
|
||||
) = TaskEntity(
|
||||
listId = 1L,
|
||||
uid = "t-1",
|
||||
title = "A task",
|
||||
sequence = sequence,
|
||||
completedAt = completedAt,
|
||||
due = due,
|
||||
isAllDay = isAllDay,
|
||||
timezone = timezone,
|
||||
unknownProperties = unknownProperties,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.ical
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
|
||||
import org.junit.jupiter.api.DynamicTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestFactory
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The corpus in `app/src/test/resources/vtodo/` is the mapper's specification.
|
||||
*
|
||||
* The requirement being encoded is RFC 5545 §3.1: *"Applications MUST preserve
|
||||
* the value data for x-name and iana-token values that they don't recognize."*
|
||||
* 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.
|
||||
*/
|
||||
class VTodoRoundTripTest {
|
||||
|
||||
private val fixtures = listOf(
|
||||
"nextcloud-shared-confidential",
|
||||
"recurring-master-overrides",
|
||||
"valarm-unknown-nested",
|
||||
"unknown-component",
|
||||
"unknown-tzid",
|
||||
"uid-special-chars",
|
||||
"rrule-due-no-dtstart",
|
||||
"moz-alarm-props",
|
||||
"apple-sort-order",
|
||||
"floating-time",
|
||||
"completion-model-a",
|
||||
"completion-model-b",
|
||||
"completion-model-c",
|
||||
"completion-model-d",
|
||||
"quoted-param-colon",
|
||||
"folded-utf8",
|
||||
"mixed-value-types",
|
||||
"malformed-scalars",
|
||||
"two-timezones",
|
||||
)
|
||||
|
||||
@TestFactory
|
||||
fun `every fixture round-trips without losing a property`(): List<DynamicTest> =
|
||||
fixtures.map { name ->
|
||||
DynamicTest.dynamicTest(name) {
|
||||
val todos = vtodosOf(name)
|
||||
assertThat(todos).isNotEmpty()
|
||||
todos.forEach { original ->
|
||||
val roundTripped = roundTrip(original)
|
||||
assertThat(ICalCanonical.lost(original, roundTripped)).isEmpty()
|
||||
assertThat(ICalCanonical.invented(original, roundTripped)).isEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown property nested inside a VALARM survives`() {
|
||||
val original = vtodosOf("valarm-unknown-nested").single()
|
||||
val roundTripped = roundTrip(original)
|
||||
|
||||
val alarm = roundTripped.components("VALARM").single()
|
||||
assertThat(alarm.property("X-WR-ALARMUID")?.value)
|
||||
.isEqualTo("8E6C4A1E-0000-4C1B-9B1A-3F5F4C2D9A11")
|
||||
// RFC 9074's alarm properties are what other clients now write; a flat
|
||||
// property-per-row model would have dropped this one with the nesting.
|
||||
assertThat(alarm.property("ACKNOWLEDGED")?.value).isEqualTo("20260901T113000Z")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an entirely unknown sub-component survives`() {
|
||||
val roundTripped = roundTrip(vtodosOf("unknown-component").single())
|
||||
val vendor = roundTripped.components("X-VENDOR-METADATA").single()
|
||||
assertThat(vendor.property("X-VENDOR-KEY")?.value).isEqualTo("project-alpha")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a TZID the device does not know is re-emitted verbatim, not guessed`() {
|
||||
val original = vtodosOf("unknown-tzid").single()
|
||||
val mapped = VTodoMapper.read(original)
|
||||
val roundTripped = roundTrip(original)
|
||||
|
||||
val due = roundTripped.property("DUE")
|
||||
assertThat(due?.param("TZID")).isEqualTo("Custom/Company-HQ")
|
||||
assertThat(due?.value).isEqualTo("20260910T170000")
|
||||
// Exactly one DUE: the residue owns it, so the mapper must not also
|
||||
// author one from its column.
|
||||
assertThat(roundTripped.properties("DUE")).hasSize(1)
|
||||
// The column still gets a best-effort instant so the UI has something.
|
||||
assertThat(mapped.entity.due).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a floating time is re-emitted verbatim, not pinned to a zone`() {
|
||||
val roundTripped = roundTrip(vtodosOf("floating-time").single())
|
||||
assertThat(roundTripped.property("DTSTART")?.value).isEqualTo("20260905T070000")
|
||||
assertThat(roundTripped.property("DTSTART")?.params).isEmpty()
|
||||
assertThat(roundTripped.properties("DTSTART")).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `X-MOZ alarm bookkeeping survives, because dropping it causes alarm storms`() {
|
||||
val roundTripped = roundTrip(vtodosOf("moz-alarm-props").single())
|
||||
assertThat(roundTripped.property("X-MOZ-LASTACK")?.value).isEqualTo("20260904T090000Z")
|
||||
assertThat(roundTripped.property("X-MOZ-SNOOZE-TIME")?.value).isEqualTo("20260905T093000Z")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `X-APPLE-SORT-ORDER is preserved and never interpreted`() {
|
||||
val original = vtodosOf("apple-sort-order").single()
|
||||
val mapped = VTodoMapper.read(original)
|
||||
assertThat(roundTrip(original).property("X-APPLE-SORT-ORDER")?.value).isEqualTo("734829163")
|
||||
// Ours is local ordering and has nothing to do with Apple's.
|
||||
assertThat(mapped.entity.sortOrder).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a quoted parameter containing a colon and a comma survives`() {
|
||||
val roundTripped = roundTrip(vtodosOf("quoted-param-colon").single())
|
||||
val attendee = roundTripped.property("ATTENDEE")
|
||||
assertThat(attendee?.param("CN")).isEqualTo("Smith, J:r")
|
||||
assertThat(attendee?.value).isEqualTo("mailto:jr@example.com")
|
||||
// And it is re-quoted on the way out, or nothing can read it back.
|
||||
assertThat(ICalSerializer.render(attendee!!)).contains("CN=\"Smith, J:r\"")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `residue past the size cap is dropped loudly rather than truncated`() {
|
||||
val huge = "X-BULK:" + "a".repeat(VTodoMapper.MAX_RESIDUE_BYTES + 1)
|
||||
val vtodo = ICalParser.parse(
|
||||
"BEGIN:VTODO\r\nUID:big-001\r\nSUMMARY:Big\r\n$huge\r\nEND:VTODO\r\n",
|
||||
)
|
||||
val mapped = VTodoMapper.read(vtodo)
|
||||
|
||||
assertThat(mapped.droppedResidue).isTrue()
|
||||
assertThat(mapped.entity.unknownProperties).isNull()
|
||||
}
|
||||
|
||||
private fun roundTrip(vtodo: ICalComponent): ICalComponent {
|
||||
val mapped = VTodoMapper.read(vtodo)
|
||||
val written = VTodoMapper.write(
|
||||
entity = mapped.entity,
|
||||
parentUid = mapped.parentUid,
|
||||
now = Instant.fromEpochMilliseconds(0),
|
||||
)
|
||||
// Serialise and re-parse rather than comparing the tree directly: folding
|
||||
// and parameter quoting are where a serialiser loses data, and skipping
|
||||
// the text would skip exactly that.
|
||||
return ICalParser.parse(
|
||||
"BEGIN:VCALENDAR\r\n" + ICalSerializer.serialize(written) + "END:VCALENDAR\r\n",
|
||||
).components("VTODO").single()
|
||||
}
|
||||
|
||||
private fun vtodosOf(name: String): List<ICalComponent> {
|
||||
val text = checkNotNull(javaClass.getResourceAsStream("/vtodo/$name.ics")) {
|
||||
"missing fixture $name.ics"
|
||||
}.bufferedReader().readText()
|
||||
return ICalParser.parse(text).components("VTODO")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package de.jeanlucmakiola.agendula.domain.ical
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The lexer. Worth testing closely because every correctness guarantee above it
|
||||
* assumes a content line was split where RFC 5545 says it splits — and the
|
||||
* places it does not split are where other clients' data goes missing.
|
||||
*/
|
||||
class ICalParserTest {
|
||||
|
||||
@Nested
|
||||
inner class Unfolding {
|
||||
|
||||
@Test
|
||||
fun `unfolding removes the CRLF and the whitespace, not just the CRLF`() {
|
||||
// RFC 5545 section 3.1: a fold is CRLF + one WSP and *both* go. A
|
||||
// folder that keeps the space silently inserts one into every long
|
||||
// value it ever wrote.
|
||||
val lines = ICalParser.unfold("SUMMARY:Hello\r\n world\r\n")
|
||||
assertThat(lines).containsExactly("SUMMARY:Helloworld")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tab folds too`() {
|
||||
assertThat(ICalParser.unfold("SUMMARY:Hello\r\n\tworld")).containsExactly("SUMMARY:Helloworld")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bare LF is accepted, because real files carry it`() {
|
||||
assertThat(ICalParser.unfold("A:1\nB:2")).containsExactly("A:1", "B:2")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a leading BOM is stripped rather than glued onto the first name`() {
|
||||
val component = ICalParser.parse("BEGIN:VTODO\r\nUID:x\r\nEND:VTODO\r\n")
|
||||
assertThat(component.name).isEqualTo("VTODO")
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class ContentLines {
|
||||
|
||||
@Test
|
||||
fun `the value starts at the first colon outside a quoted parameter`() {
|
||||
val property = ICalParser.parseLine("""ATTENDEE;CN="Smith, J:r":mailto:jr@example.com""")
|
||||
assertThat(property?.name).isEqualTo("ATTENDEE")
|
||||
assertThat(property?.param("CN")).isEqualTo("Smith, J:r")
|
||||
assertThat(property?.value).isEqualTo("mailto:jr@example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-valued parameter splits only on unquoted commas`() {
|
||||
val property = ICalParser.parseLine("""X-THING;MEMBER="a,b",c:v""")
|
||||
assertThat(property?.params?.single()?.values).containsExactly("a,b", "c").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a value may contain colons`() {
|
||||
assertThat(ICalParser.parseLine("URL:https://example.com/a:b")?.value)
|
||||
.isEqualTo("https://example.com/a:b")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the value is kept escaped, because that is what makes it reproducible`() {
|
||||
assertThat(ICalParser.parseLine("""DESCRIPTION:a\, b\; c""")?.value)
|
||||
.isEqualTo("""a\, b\; c""")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a parameter with no equals sign is kept rather than dropped`() {
|
||||
val property = ICalParser.parseLine("X-THING;BROKEN:v")
|
||||
assertThat(property?.params?.single()?.name).isEqualTo("BROKEN")
|
||||
assertThat(property?.params?.single()?.values).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a line with no colon is not a content line`() {
|
||||
assertThat(ICalParser.parseLine("GARBAGE")).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class Structure {
|
||||
|
||||
@Test
|
||||
fun `unknown components nest like any other`() {
|
||||
val component = ICalParser.parse(
|
||||
"""
|
||||
BEGIN:VTODO
|
||||
UID:x
|
||||
BEGIN:X-VENDOR
|
||||
X-KEY:v
|
||||
END:X-VENDOR
|
||||
END:VTODO
|
||||
""".trimIndent(),
|
||||
)
|
||||
assertThat(component.components("X-VENDOR").single().property("X-KEY")?.value).isEqualTo("v")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stray END is ignored rather than fatal`() {
|
||||
val component = ICalParser.parse("END:VALARM\nBEGIN:VTODO\nUID:x\nEND:VTODO\n")
|
||||
assertThat(component.name).isEqualTo("VTODO")
|
||||
assertThat(component.property("UID")?.value).isEqualTo("x")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `component names are upper-cased so lookups are not case-dependent`() {
|
||||
assertThat(ICalParser.parse("begin:vtodo\nuid:x\nend:vtodo\n").name).isEqualTo("VTODO")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text with no component at all is fatal`() {
|
||||
runCatching { ICalParser.parse("SUMMARY:orphan\r\n") }
|
||||
.onSuccess { error("expected ICalParseException") }
|
||||
.onFailure { assertThat(it).isInstanceOf(ICalParseException::class.java) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing END does not swallow the enclosing component`() {
|
||||
// Closing by position would let 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 roots = ICalParser.parseAll(
|
||||
"BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:x\r\nEND:VCALENDAR\r\n",
|
||||
)
|
||||
val calendar = roots.single()
|
||||
assertThat(calendar.name).isEqualTo("VCALENDAR")
|
||||
assertThat(calendar.components("VTODO").single().property("UID")?.value).isEqualTo("x")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a component left open at end of input is still returned`() {
|
||||
val calendar = ICalParser.parse("BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:x\r\n")
|
||||
assertThat(calendar.components("VTODO").single().property("UID")?.value).isEqualTo("x")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unbalanced quote does not swallow the whole line`() {
|
||||
val property = ICalParser.parseLine("""X-PROP;CN="unclosed:value""")
|
||||
assertThat(property?.name).isEqualTo("X-PROP")
|
||||
assertThat(property?.value).isEqualTo("value")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate properties are both kept`() {
|
||||
val component = ICalParser.parse("BEGIN:VTODO\nCATEGORIES:a\nCATEGORIES:a\nEND:VTODO\n")
|
||||
assertThat(component.properties("CATEGORIES")).hasSize(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.jeanlucmakiola.agendula.domain.ical
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ICalSerializerTest {
|
||||
|
||||
@Test
|
||||
fun `a line is folded at 75 octets, not 75 characters`() {
|
||||
// Each 'ä' is two octets, so 40 of them is 80 — over the limit at 40
|
||||
// characters, which a character-counting folder would let through.
|
||||
val property = ICalProperty("SUMMARY", value = "ä".repeat(40))
|
||||
val folded = ICalSerializer.fold(ICalSerializer.render(property))
|
||||
|
||||
assertThat(folded).contains("\r\n ")
|
||||
folded.split("\r\n").forEach {
|
||||
assertThat(it.toByteArray(Charsets.UTF_8).size).isAtMost(75)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `folding never splits a multi-byte character`() {
|
||||
val value = "🌱".repeat(30)
|
||||
val folded = ICalSerializer.fold("SUMMARY:$value")
|
||||
val unfolded = ICalParser.unfold(folded + "\r\n").single()
|
||||
assertThat(unfolded).isEqualTo("SUMMARY:$value")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a parameter value is quoted only when the grammar forces it`() {
|
||||
assertThat(ICalSerializer.render(ICalProperty("X", listOf(ICalParam("P", "plain")), "v")))
|
||||
.isEqualTo("X;P=plain:v")
|
||||
assertThat(ICalSerializer.render(ICalProperty("X", listOf(ICalParam("P", "a:b")), "v")))
|
||||
.isEqualTo("""X;P="a:b":v""")
|
||||
assertThat(ICalSerializer.render(ICalProperty("X", listOf(ICalParam("P", "a,b")), "v")))
|
||||
.isEqualTo("""X;P="a,b":v""")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an embedded double quote survives when the value need not be quoted`() {
|
||||
// The parser only unquotes a matched leading/trailing pair, so stripping
|
||||
// unconditionally would lose two characters from a value it never quoted.
|
||||
assertThat(ICalSerializer.render(ICalProperty("X", listOf(ICalParam("P", """a"b""")), "v")))
|
||||
.isEqualTo("""X;P=a"b:v""")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an embedded double quote is dropped only when quoting is forced`() {
|
||||
assertThat(ICalSerializer.render(ICalProperty("X", listOf(ICalParam("P", """a"b,c""")), "v")))
|
||||
.isEqualTo("""X;P="ab,c":v""")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `components round-trip through serialise and parse`() {
|
||||
val original = ICalComponent(
|
||||
name = "VTODO",
|
||||
properties = listOf(ICalProperty("UID", value = "x")),
|
||||
components = listOf(
|
||||
ICalComponent("VALARM", listOf(ICalProperty("ACTION", value = "DISPLAY"))),
|
||||
),
|
||||
)
|
||||
assertThat(ICalParser.parse(ICalSerializer.serialize(original))).isEqualTo(original)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# VTODO fixture corpus
|
||||
|
||||
The mapper's **specification**, not its regression net. Every file here is a
|
||||
shape that was found in the wild and that a plausible implementation gets wrong.
|
||||
A clean, server-generated VTODO catches nothing and is deliberately absent.
|
||||
|
||||
`RoundTripTest` asserts every fixture survives `parse → map → write → serialise`
|
||||
with no property lost, compared as a canonical multiset modulo the allowlist in
|
||||
`docs/SYNC-PLAN.md` § chunk 1 (`PRODID`, `DTSTAMP`, `LAST-MODIFIED`, `SEQUENCE`,
|
||||
VTIMEZONE bodies, fold positions, parameter quoting).
|
||||
|
||||
| File | What it is for |
|
||||
|---|---|
|
||||
| `nextcloud-shared-confidential.ics` | A `CLASS:CONFIDENTIAL` task as Nextcloud rewrites it on GET from a **shared** calendar — `DUE`, `STATUS`, `COMPLETED`, `PERCENT-COMPLETE`, `PRIORITY` and `RELATED-TO` already stripped, ETag left untouched. Re-PUTting this destroys the owner's task |
|
||||
| `recurring-master-overrides.ics` | Master plus two `RECURRENCE-ID` overrides in one resource, sharing a UID |
|
||||
| `valarm-unknown-nested.ics` | Unknown properties **inside** a `VALARM` — the case a flat property-per-row model cannot represent |
|
||||
| `unknown-component.ics` | An entirely unknown sub-component |
|
||||
| `unknown-tzid.ics` | A `TZID` no device tzdb knows, with its own `VTIMEZONE` |
|
||||
| `uid-special-chars.ics` | A UID containing `/` and `@` — the href sanitiser must not assume it is filename-safe |
|
||||
| `rrule-due-no-dtstart.ics` | `RRULE` + `DUE` and no `DTSTART`: no well-defined `RECURRENCE-ID`, undefined in RFC 5545, ubiquitous in the wild |
|
||||
| `moz-alarm-props.ics` | `X-MOZ-LASTACK` / `X-MOZ-SNOOZE-TIME`. Dropping them causes documented Thunderbird alarm storms |
|
||||
| `apple-sort-order.ics` | `X-APPLE-SORT-ORDER` — preserve, never interpret |
|
||||
| `floating-time.ics` | `DTSTART` with neither `Z` nor `TZID`. Readable, not reproducible: it must round-trip from the residue, not be rewritten as a guess |
|
||||
| `completion-model-a.ics` | Completion as a `RECURRENCE-ID` override, master stays open — jtx Board, Thunderbird. **The model we write** |
|
||||
| `completion-model-b.ics` | Master's `DUE` advanced in place, completion cleared — tasks.org, Evolution, Nextcloud in practice |
|
||||
| `completion-model-c.ics` | `STATUS:COMPLETED` on the master, killing the series — Nextcloud Tasks ≤ 0.17. **Always a bug**; we read it, we never write it |
|
||||
| `completion-model-d.ics` | The completed occurrence detached as a new task with a new UID — OpenTasks |
|
||||
| `quoted-param-colon.ics` | A quoted parameter value containing a colon and a comma. The value separator is the first colon *outside* quotes |
|
||||
| `mixed-value-types.ics` | `DTSTART;VALUE=DATE` with a timed `DUE`. One `is_all_day` flag cannot author both, so the odd one out must stay in the residue rather than be flattened to a date |
|
||||
| `malformed-scalars.ics` | `PRIORITY:11`, `PERCENT-COMPLETE:150`, `SEQUENCE:x`, `STATUS:X-DEFERRED`, `CLASS:X-INTERNAL` — values no spec allows. Clamping or defaulting any of them is a silent rewrite of somebody's data |
|
||||
| `two-timezones.ics` | `DTSTART` and `DUE` in different zones. One `timezone` column cannot author both |
|
||||
| `folded-utf8.ics` | A line folded mid-emoji-adjacent, to prove folding counts octets and splits on character boundaries |
|
||||
@@ -0,0 +1,11 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Apple Inc.//macOS 15.3//EN
|
||||
BEGIN:VTODO
|
||||
UID:apple-sort-001
|
||||
DTSTAMP:20260901T120000Z
|
||||
SUMMARY:Buy milk
|
||||
X-APPLE-SORT-ORDER:734829163
|
||||
PRIORITY:3
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,22 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//jtx Board//EN
|
||||
BEGIN:VTODO
|
||||
UID:model-a-001
|
||||
DTSTAMP:20260901T120000Z
|
||||
SUMMARY:Take out the bins
|
||||
DTSTART;VALUE=DATE:20260901
|
||||
RRULE:FREQ=WEEKLY;BYDAY=TU
|
||||
STATUS:NEEDS-ACTION
|
||||
END:VTODO
|
||||
BEGIN:VTODO
|
||||
UID:model-a-001
|
||||
RECURRENCE-ID;VALUE=DATE:20260901
|
||||
DTSTAMP:20260901T190000Z
|
||||
SUMMARY:Take out the bins
|
||||
DTSTART;VALUE=DATE:20260901
|
||||
STATUS:COMPLETED
|
||||
COMPLETED:20260901T190000Z
|
||||
PERCENT-COMPLETE:100
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,12 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//tasks.org//EN
|
||||
BEGIN:VTODO
|
||||
UID:model-b-001
|
||||
DTSTAMP:20260908T190000Z
|
||||
SUMMARY:Take out the bins
|
||||
DUE;VALUE=DATE:20260908
|
||||
RRULE:FREQ=WEEKLY;BYDAY=TU
|
||||
STATUS:NEEDS-ACTION
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,14 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Nextcloud Tasks 0.17//EN
|
||||
BEGIN:VTODO
|
||||
UID:model-c-001
|
||||
DTSTAMP:20260901T190000Z
|
||||
SUMMARY:Take out the bins
|
||||
DUE;VALUE=DATE:20260901
|
||||
RRULE:FREQ=WEEKLY;BYDAY=TU
|
||||
STATUS:COMPLETED
|
||||
COMPLETED:20260901T190000Z
|
||||
PERCENT-COMPLETE:100
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,20 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//dmfs.org/OpenTasks//EN
|
||||
BEGIN:VTODO
|
||||
UID:model-d-001
|
||||
DTSTAMP:20260908T190000Z
|
||||
SUMMARY:Take out the bins
|
||||
DUE;VALUE=DATE:20260908
|
||||
RRULE:FREQ=WEEKLY;BYDAY=TU
|
||||
STATUS:NEEDS-ACTION
|
||||
END:VTODO
|
||||
BEGIN:VTODO
|
||||
UID:model-d-detached-001
|
||||
DTSTAMP:20260901T190000Z
|
||||
SUMMARY:Take out the bins
|
||||
DUE;VALUE=DATE:20260901
|
||||
STATUS:COMPLETED
|
||||
COMPLETED:20260901T190000Z
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,11 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Radicale//NONSGML Radicale Server//EN
|
||||
BEGIN:VTODO
|
||||
UID:floating-001
|
||||
DTSTAMP:20260901T120000Z
|
||||
SUMMARY:Wake up wherever you are
|
||||
DTSTART:20260905T070000
|
||||
DUE:20260905T073000
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,12 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Agendula//Test//EN
|
||||
BEGIN:VTODO
|
||||
UID:folded-utf8-001
|
||||
DTSTAMP:20260901T120000Z
|
||||
SUMMARY:Gießkanne füllen und die Pflanzen im Wintergarten gießen — 🌱🌿🪴
|
||||
jede Woche montags
|
||||
DESCRIPTION:Zeile eins\nZeile zwei mit Komma\, Semikolon\; und Backslash\\ dr
|
||||
in
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,14 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Careless Client//EN
|
||||
BEGIN:VTODO
|
||||
UID:malformed-scalars-001
|
||||
DTSTAMP:20260901T120000Z
|
||||
SUMMARY:Values no spec allows
|
||||
PRIORITY:11
|
||||
PERCENT-COMPLETE:150
|
||||
SEQUENCE:x
|
||||
STATUS:X-DEFERRED
|
||||
CLASS:X-INTERNAL
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,11 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Mixed//EN
|
||||
BEGIN:VTODO
|
||||
UID:mixed-value-001
|
||||
DTSTAMP:20260901T120000Z
|
||||
SUMMARY:Starts on a day, due at a time
|
||||
DTSTART;VALUE=DATE:20260901
|
||||
DUE;TZID=Europe/Berlin:20260901T090000
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,18 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
|
||||
BEGIN:VTODO
|
||||
UID:moz-alarm-001
|
||||
DTSTAMP:20260901T120000Z
|
||||
SUMMARY:Call the dentist
|
||||
DUE;TZID=Europe/Berlin:20260905T100000
|
||||
X-MOZ-LASTACK:20260904T090000Z
|
||||
X-MOZ-SNOOZE-TIME:20260905T093000Z
|
||||
X-MOZ-GENERATION:3
|
||||
BEGIN:VALARM
|
||||
ACTION:DISPLAY
|
||||
DESCRIPTION:Call the dentist
|
||||
TRIGGER;RELATED=END:-PT15M
|
||||
END:VALARM
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,12 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Nextcloud calendar v4.7.0
|
||||
BEGIN:VTODO
|
||||
UID:conf-shared-001
|
||||
DTSTAMP:20260901T120000Z
|
||||
SUMMARY:Quarterly filing
|
||||
CLASS:CONFIDENTIAL
|
||||
CREATED:20260801T090000Z
|
||||
LAST-MODIFIED:20260901T115900Z
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,11 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//SOGo//EN
|
||||
BEGIN:VTODO
|
||||
UID:quoted-param-001
|
||||
DTSTAMP:20260901T120000Z
|
||||
SUMMARY:Review with the team
|
||||
ATTENDEE;CN="Smith, J:r";PARTSTAT=NEEDS-ACTION:mailto:jr@example.com
|
||||
ORGANIZER;CN=Alice:mailto:alice@example.com
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,33 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
|
||||
BEGIN:VTODO
|
||||
UID:series-weekly-001
|
||||
DTSTAMP:20260901T120000Z
|
||||
SUMMARY:Water the plants
|
||||
DTSTART;TZID=Europe/Berlin:20260901T080000
|
||||
DUE;TZID=Europe/Berlin:20260901T090000
|
||||
RRULE:FREQ=WEEKLY;BYDAY=MO
|
||||
STATUS:NEEDS-ACTION
|
||||
END:VTODO
|
||||
BEGIN:VTODO
|
||||
UID:series-weekly-001
|
||||
RECURRENCE-ID;TZID=Europe/Berlin:20260908T080000
|
||||
DTSTAMP:20260908T081500Z
|
||||
SUMMARY:Water the plants
|
||||
DTSTART;TZID=Europe/Berlin:20260908T080000
|
||||
DUE;TZID=Europe/Berlin:20260908T090000
|
||||
STATUS:COMPLETED
|
||||
PERCENT-COMPLETE:100
|
||||
COMPLETED:20260908T081500Z
|
||||
END:VTODO
|
||||
BEGIN:VTODO
|
||||
UID:series-weekly-001
|
||||
RECURRENCE-ID;TZID=Europe/Berlin:20260915T080000
|
||||
DTSTAMP:20260915T070000Z
|
||||
SUMMARY:Water the plants (away — ask neighbour)
|
||||
DTSTART;TZID=Europe/Berlin:20260915T080000
|
||||
DUE;TZID=Europe/Berlin:20260915T090000
|
||||
STATUS:NEEDS-ACTION
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,12 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Nextcloud Tasks//EN
|
||||
BEGIN:VTODO
|
||||
UID:rrule-no-dtstart-001
|
||||
DTSTAMP:20260901T120000Z
|
||||
SUMMARY:Pay rent
|
||||
DUE;VALUE=DATE:20260901
|
||||
RRULE:FREQ=MONTHLY;BYMONTHDAY=1
|
||||
STATUS:NEEDS-ACTION
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,11 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Traveller//EN
|
||||
BEGIN:VTODO
|
||||
UID:two-zones-001
|
||||
DTSTAMP:20260901T120000Z
|
||||
SUMMARY:Leave Berlin, land in New York
|
||||
DTSTART;TZID=Europe/Berlin:20260901T080000
|
||||
DUE;TZID=America/New_York:20260901T140000
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,9 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Example Corp//CalDAV Client//EN
|
||||
BEGIN:VTODO
|
||||
UID:20260901T120000Z/task@example.com
|
||||
DTSTAMP:20260901T120000Z
|
||||
SUMMARY:UID that is not filename-safe
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,13 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Some Other Client//EN
|
||||
BEGIN:VTODO
|
||||
UID:unknown-comp-001
|
||||
DTSTAMP:20260901T120000Z
|
||||
SUMMARY:Task with a component we have never seen
|
||||
BEGIN:X-VENDOR-METADATA
|
||||
X-VENDOR-KEY:project-alpha
|
||||
X-VENDOR-WEIGHT:7
|
||||
END:X-VENDOR-METADATA
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,19 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Legacy Groupware//EN
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Custom/Company-HQ
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19700101T000000
|
||||
TZOFFSETFROM:+0130
|
||||
TZOFFSETTO:+0130
|
||||
TZNAME:HQ
|
||||
END:STANDARD
|
||||
END:VTIMEZONE
|
||||
BEGIN:VTODO
|
||||
UID:unknown-tzid-001
|
||||
DTSTAMP:20260901T120000Z
|
||||
SUMMARY:Board pack due
|
||||
DUE;TZID=Custom/Company-HQ:20260910T170000
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,18 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Apple Inc.//iOS 18.2//EN
|
||||
BEGIN:VTODO
|
||||
UID:alarm-nested-001
|
||||
DTSTAMP:20260901T120000Z
|
||||
SUMMARY:Renew passport
|
||||
DUE;TZID=Europe/Berlin:20261001T170000
|
||||
BEGIN:VALARM
|
||||
ACTION:DISPLAY
|
||||
DESCRIPTION:Reminder
|
||||
TRIGGER;RELATED=START:-PT30M
|
||||
ACKNOWLEDGED:20260901T113000Z
|
||||
X-WR-ALARMUID:8E6C4A1E-0000-4C1B-9B1A-3F5F4C2D9A11
|
||||
X-APPLE-DEFAULT-ALARM:TRUE
|
||||
END:VALARM
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
Reference in New Issue
Block a user