fix(ics): harden the import against foreign files (#225)

Review pass over the Fossify import work:

- sanitizeRrule whitelists the parts EventRecurrence actually parses, and
  reports whether it dropped anything — normalising case or a stray ";" no
  longer warns the user about a faulty repeat rule.
- CATEGORIES only stands in for a calendar name when it is single-valued and
  every event agrees; elsewhere it is a tag list and must not pick the target.
- An all-day EXDATE keeps the day it spells out instead of the day its instant
  lands on in UTC.
- An imported all-day reminder is sampled where it will fire, not at a
  recurrence anchor that Fossify dates to 1970.
- Imported colours match against every published key, not the picker's
  curated subset.
This commit is contained in:
2026-08-31 18:07:19 +02:00
parent a2837f93ad
commit 7e7356e4c3
14 changed files with 513 additions and 103 deletions

View File

@@ -377,19 +377,28 @@ conformant one.
**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
read. That parser works off a fixed table of part names and throws on anything
outside it, so `sanitizeRrule` is a **whitelist**: unrecognised parts (RFC 7529
`RSCALE`, vendor `X-` extensions) and unusable values go, and only a missing
`FREQ` gives up on the rule. It reports whether it actually *dropped* something,
separately from the rule text it returns — case and a trailing `;` normalise away
and must not raise the "faulty repeat rule" warning at the user. Behind all that,
`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 and preselects a target calendar
of that name, bare `EXDATE` day codes on a timed series are resolved against the
no tasks; dropping them silently lost half of some exports), a single-valued
`CATEGORIES` stands in for the `X-WR-CALNAME` Fossify never writes and preselects
a target calendar of that name — as a *fallback* only, since everywhere else the
property is a tag list and must not outrank something that names the calendar
outright — bare `EXDATE` day codes on a timed series are resolved against the
series' own time of day (as is a floating `EXDATE` DATE-TIME — RFC 5545 ties it
to `DTSTART`'s zone, not the device's), 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.
to `DTSTART`'s zone, not the device's) while an all-day `EXDATE` keeps the day it
spells out rather than the day its resolved instant lands on in UTC, 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.
All-day reminder offsets are **whole days, rounded up** in both directions. A
file's raw offset is `days × 1440 timeOfDay`, so rounding to the nearest day
@@ -401,11 +410,23 @@ after the event — so `toIcsEvent` decodes it back through
`fromProviderAllDayMinutes` first, and the importing device re-encodes it against
its own setting.
That re-encode is sampled where the reminder will actually *fire*, not at
`DTSTART` (`importedAllDayReminderDate`). An imported series' `DTSTART` is only an
anchor and is routinely ancient — Fossify writes a year-less contact birthday at
1970, and a real birth year is usually older still — from a year whose timezone
rules predate DST across most of Europe. Sampling the UTC offset there skews every
modern occurrence by the delta, the same trap `nextYearlyOccurrence` exists for on
the managed special-dates path. Decoding is unaffected and stays anchored at
`DTSTART`: it only asks which local *day* the encoded instant falls on.
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
palette. The match runs against `publishedEventColors`, **not** the curated
`eventColorPalette`: curation is a picker concession that folds look-alikes and
drops the neutrals outright from an oversized palette, but every published key is
one the calendar accepts. 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.