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
+32
View File
@@ -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 |