fix(import): make .ics import survive events the provider rejects (#225)

importEvents inserted every event in one unguarded loop, so a single row
the provider refused — it throws on a malformed RRULE straight out of
insert — aborted the batch and left the user with "couldn't read this
file" and nothing imported. Each event is now isolated and rejects are
counted into IcsImportSummary.failed and shown. sanitizeRrule drops empty
and malformed rule parts before the write.

Alongside that, several things a foreign file carries that we dropped:
VTODO components (silently lost, now imported as events), EXDATE, the
CATEGORIES calendar name, and colours — X-FOSSIFY-EVENT-COLOR / COLOR /
the category colour plus the X-SMT-* legacy spellings, snapped in Oklab
to the nearest key a palette account publishes since those reject a raw
EVENT_COLOR.

Two correctness fixes on our side: an all-day event may no longer end at
or before it starts (the provider expands a zero-length series into no
instances, so it just disappears), and imported all-day reminders now go
through the same encoding as hand-created ones instead of firing at UTC
midnight. A VALARM trigger pointing after the start is read as a time of
day rather than clamped to zero.

Note for later: their all-day DTEND is RFC-correct — endTS anchors at
noon of the last day and the exporter's +12h rounds it to the following
midnight. Do not "fix" it by sniffing PRODID; the holiday files bundled
in Fossify are conformant and name Fossify in theirs. One is kept as a
fixture.

Refs #225
This commit is contained in:
2026-08-19 20:17:51 +02:00
parent d03985eada
commit 2ed18e9938
19 changed files with 1271 additions and 84 deletions

View File

@@ -346,6 +346,53 @@ a latch that disables its own scheduling once a real broadcast arrives — canno
be copied, because our failure mode includes a broadcast that arrives with no
alert row behind it.
## Importing foreign `.ics`
`IcsParser` is deliberately liberal, because the files it is handed were written
by other people's calendars. What that costs us, learned from reading the Simple
Calendar / Fossify exporter (#225):
**An all-day event may never reach the provider zero days long.** RFC 5545
§3.6.1 gives a `DATE`-valued `DTSTART` with no `DTEND` a one-day duration, and a
`DTEND` equal to `DTSTART` is meaningless — but the failure is silent rather than
loud: the provider expands a zero-length series into no instances at all, so the
event is simply not there, with nothing logged. `resolveEnd` floors the span at a
day and `buildImportedEventValues` floors an all-day `DURATION` at `P1D`.
Resist the urge to correct a producer's all-day `DTEND` by sniffing its `PRODID`.
Fossify's looks like it needs it and does not: `Event.endTS` anchors an all-day
event at *noon* of its last day (`EventActivity.getStartEndTimes`), or at the
exclusive midnight when the row came from CalDAV, and the exporter's
`+ TWELVE_HOURS` rounds either to the following midnight — correct on both paths.
Shifting it would push every imported all-day event out by a day. The holiday
files bundled inside Fossify are a second trap: their `PRODID` reads
`Fossify Calendar Holiday Generator` and their content is plain conformant
iCalendar. One is kept as a fixture in `app/src/test/resources/ics`.
**One bad event must not cost the file.** The provider validates `RRULE` through
`EventRecurrence.parse`, which *throws* — out of `insert`, not out of a later
read. `sanitizeRrule` drops empty and malformed parts before the write, and
`CalendarRepositoryImpl.importEvents` isolates each event, counting rejects into
`IcsImportSummary.failed` rather than unwinding the batch and reporting only
"couldn't read this file".
Smaller dialect handling: `VTODO` components import as events (Calendula models
no tasks; dropping them silently lost half of some exports), `CATEGORIES` stands
in for the `X-WR-CALNAME` Fossify never writes, bare `EXDATE` day codes on a
timed series are resolved against the series' own time of day, and a `VALARM`
trigger pointing *after* the start — which is how that family encodes "on the day
at 09:00" — is read as zero days before rather than clamped to a lead time of
zero, so all-day reminders fire at the hour the user's own setting names instead
of at UTC midnight.
Colour arrives as a raw ARGB from an app with no idea which account it is landing
in. A calendar whose account publishes a palette rejects a raw `EVENT_COLOR`, so
`buildImportedEventValues` snaps the imported colour to the nearest published key
(`nearestTo`, measured in Oklab) and writes the raw value only where there is no
palette. The single-event review form deliberately keeps no colour at all: its
first question is which calendar to use, and answering it clears the colour
anyway.
## Testing
JUnit 5 + Truth + Turbine on the JVM. The seams that make it work: