From cb9916ded0a0ea0739e1e1a43a8a2443364eb2bc Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 31 Aug 2026 18:07:19 +0200 Subject: [PATCH] fix(ics): harden the import against foreign files (#225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 3 +- .../data/calendar/AllDayReminderEncoding.kt | 28 ++++ .../data/calendar/CalendarDataSource.kt | 47 +++++-- .../data/calendar/CalendarRepositoryImpl.kt | 6 +- .../calendula/domain/ics/IcsParser.kt | 70 ++++++---- .../calendula/domain/ics/IcsRecurrence.kt | 122 ++++++++++++++---- .../calendula/ui/imports/ImportViewModel.kt | 22 +++- .../calendar/AllDayReminderEncodingTest.kt | 41 ++++++ .../calendar/CalendarRepositoryImplTest.kt | 22 +++- .../data/calendar/FakeCalendarDataSource.kt | 7 + .../domain/ics/IcsFossifyImportTest.kt | 59 +++++++++ .../calendula/domain/ics/IcsRecurrenceTest.kt | 118 ++++++++++++++--- .../calendula/ui/imports/ImportTargetTest.kt | 34 +++++ docs/ARCHITECTURE.md | 37 ++++-- 14 files changed, 513 insertions(+), 103 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 855328a..08135e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **All-day reminders survive a backup and an import.** They fire at the hour you chose, not at midnight UTC — the same rule the app already applied to all-day events you create yourself — and an export no longer leaves them out of the - file entirely ([#225]). + file entirely. An imported yearly birthday fires at the right hour too, even + though the file dates it back to the year of birth ([#225]). - **Birthdays and anniversaries imported from Fossify Calendar now show up.** Fossify writes the ones it mirrors from your contacts as events that start and end on the same day, which the calendar read as lasting no time at all: they diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncoding.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncoding.kt index 94021e6..6aa6e1d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncoding.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncoding.kt @@ -70,6 +70,34 @@ internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): Local return LocalDate.of(today.year, month, day) } +/** + * The date an **imported** all-day event's reminder offset is sampled at. + * + * A one-off fires once, on its own date. A series' `DTSTART` is only an anchor + * and may be ancient — Fossify writes a year-less contact birthday at 1970, a + * year whose rules predate DST across most of Europe, and a real birth year is + * usually older still — so sampling there skews every modern occurrence by the + * offset delta (the same trap [nextYearlyOccurrence] exists for). A yearly + * series is therefore sampled at its next occurrence of the anchor's month/day, + * which shares the season; any other series at [today], which its upcoming + * occurrences are near. + * + * The decode side ([fromProviderAllDayMinutes]) only asks which local *day* the + * encoded instant falls on, so it still reads the lead time back correctly from + * the anchor's own date. + */ +internal fun importedAllDayReminderDate( + startDate: LocalDate, + recurrenceRule: String?, + today: LocalDate, +): LocalDate = when { + recurrenceRule.isNullOrBlank() -> startDate + recurrenceRule.contains("FREQ=YEARLY", ignoreCase = true) -> + nextYearlyOccurrence(startDate.monthValue, startDate.dayOfMonth, today) + startDate.isBefore(today) -> today + else -> startDate +} + /** * Recover the semantic whole-day lead time from a raw all-day reminder * [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 2c4f4f4..d8dad0a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -85,6 +85,15 @@ interface CalendarDataSource { */ fun eventColorPalette(calendarId: Long): List + /** + * The same palette **uncurated** — every key the account publishes, in + * provider order. Curation is a display concession (it folds look-alikes and + * drops the neutrals outright from an oversized palette), so matching a + * colour that came from outside the account has to run against the full set: + * those keys are all the calendar accepts. See [eventColorPalette]. + */ + fun publishedEventColors(calendarId: Long): List + /** * Every master/one-off event of the writable local calendars, mapped for a * whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception @@ -106,9 +115,10 @@ interface CalendarDataSource { * Insert a parsed `.ics` event into [calendarId], preserving its UID (or * minting one when absent); returns the new `Events._ID`. * - * [colorPalette] is the target account's published event colours, looked up - * once per import; [allDayReminderTimeMinutes] is the user's preferred - * all-day firing time, applied exactly as a hand-created event's is. + * [colorPalette] is the target account's published event colours + * ([publishedEventColors]), looked up once per import; + * [allDayReminderTimeMinutes] is the user's preferred all-day firing time, + * applied exactly as a hand-created event's is. */ fun insertImportedEvent( event: ParsedIcsEvent, @@ -732,7 +742,10 @@ class AndroidCalendarDataSource @Inject constructor( } } - override fun eventColorPalette(calendarId: Long): List { + override fun eventColorPalette(calendarId: Long): List = + publishedEventColors(calendarId).curatedForPicker() + + override fun publishedEventColors(calendarId: Long): List { val account = calendarAccount(calendarId) ?: return emptyList() return resolver.query( CalendarContract.Colors.CONTENT_URI, @@ -750,7 +763,6 @@ class AndroidCalendarDataSource @Inject constructor( c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) } } ?.filter { it.key.isNotEmpty() } - ?.curatedForPicker() ?: emptyList() } @@ -830,16 +842,27 @@ class AndroidCalendarDataSource @Inject constructor( val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues()) ?: throw WriteFailedException("import event into calendar id=$calendarId") val eventId = ContentUris.parseId(uri) - val startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds()) - .atZone(ZoneOffset.UTC).toLocalDate() + val zone = ZoneId.systemDefault() + // An all-day reminder is stored the same way a hand-created one is, so it + // fires at the time the user picked rather than at UTC midnight — sampled + // where it will actually fire, not at an ancient recurrence anchor + // (see [importedAllDayReminderDate]). + val reminderDate = if (event.isAllDay) { + importedAllDayReminderDate( + startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds()) + .atZone(ZoneOffset.UTC).toLocalDate(), + recurrenceRule = event.recurrenceRule, + today = LocalDate.now(zone), + ) + } else { + null + } event.semanticReminderMinutes().forEach { minutes -> - // An all-day reminder is stored the same way a hand-created one is, - // so it fires at the time the user picked rather than at UTC midnight. - val providerMinutes = if (event.isAllDay) { + val providerMinutes = if (reminderDate != null) { toProviderAllDayMinutes( semanticMinutes = minutes, - startDate = startDate, - zone = ZoneId.systemDefault(), + startDate = reminderDate, + zone = zone, timeOfDayMinutes = allDayReminderTimeMinutes, ) } else { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt index 5d36358..3ef334b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt @@ -220,8 +220,10 @@ class CalendarRepositoryImpl @Inject constructor( ): IcsImportSummary = withContext(io) { val existing = dataSource.existingUids(targetCalendarId) // Both are per-calendar, not per-event: looking them up once keeps a - // thousand-event restore to two extra queries. - val palette = dataSource.eventColorPalette(targetCalendarId) + // thousand-event restore to two extra queries. The palette is the + // uncurated one — an imported colour is matched against every key the + // account accepts, not the subset the picker shows. + val palette = dataSource.publishedEventColors(targetCalendarId) val allDayMinutes = allDayReminderTimeMinutes() var imported = 0 var skipped = 0 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt index 84d6940..4310f54 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt @@ -186,6 +186,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault var status = EventStatus.Confirmed var availability = Availability.Busy var calendarName = fileCalendarName + var category: String? = null var eventColor: Int? = null var calendarColor: Int? = null val reminders = mutableListOf() @@ -223,10 +224,10 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault "X-CALENDULA-CALENDAR" -> calendarName = unescapeText(line.value).trim().ifEmpty { calendarName } // Fossify names the source calendar per event; it never writes - // an X-WR-CALNAME, so this is the only calendar label its files carry. - "CATEGORIES" -> calendarName = unescapeText(line.value).trim() - .takeIf { it.isNotEmpty() && !it.equals("null", true) } - ?: calendarName + // an X-WR-CALNAME, so this is the only calendar label its files + // carry. Elsewhere CATEGORIES is a plain tag list, so it only + // stands in where nothing named the calendar outright. + "CATEGORIES" -> category = singleCategory(line.value) ?: category // Per-event colour, most specific encoding first (see parseIcsColorValue). "X-FOSSIFY-EVENT-COLOR", "X-SMT-EVENT-COLOR" -> eventColor = parseIcsColorValue(line.value) ?: eventColor @@ -247,10 +248,8 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault warnings.add(IcsParseWarning.EventWithoutStartSkipped) return null } - val cleanRrule = sanitizeRrule(rrule) - if (rrule != null && cleanRrule != rrule.removePrefix("RRULE:").trim()) { - warnings.add(IcsParseWarning.RecurrenceRuleRepaired) - } + val recurrence = sanitizeRrule(rrule) + if (recurrence.repaired) warnings.add(IcsParseWarning.RecurrenceRuleRepaired) val end = resolveEnd(start, dtEnd, duration) return ParsedIcsEvent( uid = uid, @@ -259,15 +258,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault end = end, isAllDay = start.isAllDay, zoneId = start.zoneId, - recurrenceRule = cleanRrule, + recurrenceRule = recurrence.rule, location = location, description = description, reminderMinutes = reminders.distinct(), status = status, availability = availability, - calendarName = calendarName, + calendarName = calendarName ?: category, color = eventColor ?: calendarColor, - exDates = if (cleanRrule == null) emptyList() else normalizeExDates(exDateLines, start), + exDates = if (recurrence.rule == null) { + emptyList() + } else { + normalizeExDates(exDateLines, start) + }, isTask = isTask, ) } @@ -302,6 +305,11 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault * out that way even for timed series, where the property is then ambiguous. * Such a value is resolved against the series' own time of day, which is * what it meant — its repetitions never move within the day. + * + * An all-day exclusion names a calendar day, so a zone-qualified DATE-TIME + * still means the day it spells out; resolving it to an instant first and + * reading *that* back in UTC would roll a midnight east of UTC onto the day + * before and exclude an occurrence that doesn't exist. */ private fun normalizeExDates(lines: List, start: IcsDateTime): List { if (lines.isEmpty()) return emptyList() @@ -310,17 +318,12 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault return lines .flatMap { line -> line.value.split(',').map { line to it.trim() } } .mapNotNull { (line, token) -> - if (token.isEmpty()) return@mapNotNull null - if (token.contains('T')) { - val instant = parseExDateTime(token, line, startZone) ?: return@mapNotNull null - if (start.isAllDay) utcDayCode(instant) else utcStamp(instant) - } else { - val date = parseBasicDate(token) ?: return@mapNotNull null - if (start.isAllDay) { - utcDayCode(date.atStartOfDayIn(TimeZone.UTC)) - } else { - utcStamp(LocalDateTime(date, startTime).toInstant(startZone)) - } + when { + token.isEmpty() -> null + start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.let(::dayCode) + token.contains('T') -> parseExDateTime(token, line, startZone)?.let(::utcStamp) + else -> parseBasicDate(token) + ?.let { utcStamp(LocalDateTime(it, startTime).toInstant(startZone)) } } } .distinct() @@ -391,14 +394,31 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault fun Instant.plusDays(days: Int): Instant = Instant.fromEpochMilliseconds(toEpochMilliseconds() + days * DAY_MILLIS) - fun utcDayCode(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) { - "%04d%02d%02d".format(year, month.number, day) - } + fun dayCode(date: LocalDate): String = + "%04d%02d%02d".format(date.year, date.month.number, date.day) fun utcStamp(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) { "%04d%02d%02dT%02d%02d%02dZ".format(year, month.number, day, hour, minute, second) } + /** + * A single-valued `CATEGORIES`, which is what the Fossify family writes + * for the source calendar's name. A multi-item list is a tag list and + * names no calendar, so it is ignored. + */ + fun singleCategory(raw: String): String? { + var escaped = false + for (c in raw) { + when { + escaped -> escaped = false + c == '\\' -> escaped = true + c == ',' -> return null + } + } + return unescapeText(raw).trim() + .takeIf { it.isNotEmpty() && !it.equals("null", true) } + } + /** Index of the matching `END:` at/after [from], or list end. */ fun indexOfEnd(lines: List, from: Int, component: String): Int { var i = from diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt index 5bec1d9..abd659c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt @@ -4,15 +4,50 @@ private val VALID_FREQ = setOf( "SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY", ) -/** RRULE parts whose value is a comma-separated list, so empty items can be dropped. */ -private val LIST_PARTS = setOf( - "BYSECOND", "BYMINUTE", "BYHOUR", "BYDAY", "BYMONTHDAY", "BYYEARDAY", - "BYWEEKNO", "BYMONTH", "BYSETPOS", -) +private val VALID_WEEKDAYS = setOf("SU", "MO", "TU", "WE", "TH", "FR", "SA") /** - * Make a foreign RRULE safe to hand `CalendarContract`, or return null when it - * can't be salvaged. + * What `EventRecurrence` accepts in each numeric list part: a value range, plus + * whether `0` is legal. Its `parseNumberList` throws on a non-number, on an item + * outside the range *and* on a zero in the by-position parts, so checking only + * that an item is a number would still hand the provider a rule it rejects. + * + * `BYSETPOS` is the one part it leaves unbounded; `0` is dropped from it anyway + * because RFC 5545 §3.3.10 gives it no meaning. + */ +private val NUMERIC_LIST_PARTS = mapOf( + "BYSECOND" to NumericPart(0..59, zeroAllowed = true), + "BYMINUTE" to NumericPart(0..59, zeroAllowed = true), + "BYHOUR" to NumericPart(0..23, zeroAllowed = true), + "BYMONTHDAY" to NumericPart(-31..31, zeroAllowed = false), + "BYYEARDAY" to NumericPart(-366..366, zeroAllowed = false), + "BYWEEKNO" to NumericPart(-53..53, zeroAllowed = false), + "BYMONTH" to NumericPart(1..12, zeroAllowed = false), + "BYSETPOS" to NumericPart(Int.MIN_VALUE..Int.MAX_VALUE, zeroAllowed = false), +) + +private class NumericPart(private val range: IntRange, private val zeroAllowed: Boolean) { + fun accepts(item: String): Boolean { + val n = item.toIntOrNull() ?: return false + return n in range && (zeroAllowed || n != 0) + } +} + +/** `UNTIL` in RFC 5545 basic format; anything else fails at expansion time. */ +private val UNTIL_SHAPE = Regex("""\d{8}(T\d{6}Z?)?""") + +/** + * A foreign RRULE made safe to hand `CalendarContract`. + * + * [rule] is what survived, or null when there is nothing left to salvage. + * [repaired] is true only when a part was actually dropped — pure normalisation + * (case, a stray separator) doesn't count, so a conformant file is never + * reported to the user as faulty. + */ +data class SanitizedRrule(val rule: String?, val repaired: Boolean) + +/** + * Salvage a foreign RRULE. * * The provider validates every RRULE through `EventRecurrence.parse`, which * **throws** on a malformed rule — and the throw surfaces from `insert`, not @@ -22,34 +57,65 @@ private val LIST_PARTS = setOf( * (`Parser.getByDay` interpolates an empty day string unconditionally), which is * the state every weekly event it imported from elsewhere is left in. * - * Empty and malformed parts are dropped rather than failing the rule; only a - * missing or unrecognised `FREQ` — without which there is no rule at all — - * returns null. + * That parser has a fixed table of parts and throws on any name outside it, so + * this is a whitelist: an unrecognised part (RFC 7529 `RSCALE`, a vendor `X-` + * extension) and an unusable value are dropped rather than passed on to fail the + * whole event. Only a missing or unrecognised `FREQ` — without which there is no + * rule at all — gives up entirely. */ -fun sanitizeRrule(raw: String?): String? { +fun sanitizeRrule(raw: String?): SanitizedRrule { val rule = raw?.trim()?.removePrefix("RRULE:")?.trim().orEmpty() - if (rule.isEmpty()) return null + if (rule.isEmpty()) return SanitizedRrule(rule = null, repaired = false) var freqSeen = false - val parts = rule.split(';').mapNotNull { part -> + var repaired = false + val parts = mutableListOf() + + /** The items [valid] accepts, uppercased and comma-joined; null if none survive. */ + fun keepItems(value: String, valid: (String) -> Boolean): String? { + val items = value.split(',').map { it.trim().uppercase() } + val kept = items.filter(valid) + if (kept.size != items.size) repaired = true + return kept.takeIf { it.isNotEmpty() }?.joinToString(",") + } + + for (part in rule.split(';')) { val key = part.substringBefore('=', "").trim().uppercase() val value = if ('=' in part) part.substringAfter('=').trim() else "" - if (key.isEmpty() || value.isEmpty()) return@mapNotNull null - when { - key == "FREQ" -> { - if (value.uppercase() !in VALID_FREQ) return null - freqSeen = true - "FREQ=${value.uppercase()}" - } - // A non-positive or non-numeric INTERVAL is rejected by the provider. + // A stray separator ("FREQ=DAILY;") normalises away; nothing was lost. + if (key.isEmpty() && value.isEmpty()) continue + val kept = when { + key.isEmpty() || value.isEmpty() -> null + key == "FREQ" -> + value.uppercase().takeIf { it in VALID_FREQ } + ?.also { freqSeen = true } + ?.let { "FREQ=$it" } + // A non-positive or non-numeric INTERVAL/COUNT is rejected by the + // provider, and a COUNT of zero would expand to no occurrences. key == "INTERVAL" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "INTERVAL=$it" } - key in LIST_PARTS -> value.split(',') - .map { it.trim() } - .filter { it.isNotEmpty() } - .takeIf { it.isNotEmpty() } - ?.let { "$key=${it.joinToString(",")}" } - else -> "$key=$value" + key == "COUNT" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "COUNT=$it" } + key == "UNTIL" -> + value.uppercase().takeIf { UNTIL_SHAPE.matches(it) }?.let { "UNTIL=$it" } + key == "WKST" -> + value.uppercase().takeIf { it in VALID_WEEKDAYS }?.let { "WKST=$it" } + key == "BYDAY" -> keepItems(value, ::isWeekdayItem)?.let { "BYDAY=$it" } + key in NUMERIC_LIST_PARTS -> + keepItems(value, NUMERIC_LIST_PARTS.getValue(key)::accepts)?.let { "$key=$it" } + else -> null } + if (kept == null) repaired = true else parts += kept + } + return if (freqSeen) { + SanitizedRrule(rule = parts.joinToString(";"), repaired = repaired) + } else { + SanitizedRrule(rule = null, repaired = true) } - return if (freqSeen) parts.joinToString(";") else null +} + +/** A `BYDAY` item: a two-letter weekday, optionally preceded by an ordinal. */ +private fun isWeekdayItem(item: String): Boolean { + if (item.length < 2) return false + val ordinal = item.dropLast(2) + return item.takeLast(2) in VALID_WEEKDAYS && + (ordinal.isEmpty() || ordinal.toIntOrNull() != null) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt index aff5bd0..46a18a2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt @@ -44,9 +44,9 @@ sealed interface ImportUiState { val warnings: Set, val calendars: List, /** - * The calendar the file says its events came from, when it names exactly - * one — used to preselect a matching target. Null when the file names - * none or its events disagree. + * The calendar the file says its events came from, when every one of + * them names the same — used to preselect a matching target (see + * [fileCalendarName]). */ val fileCalendarName: String? = null, ) : ImportUiState @@ -98,10 +98,7 @@ class ImportViewModel @Inject constructor( warnings = parsed.warnings, calendars = repository.calendars().first() .filter { it.isEventTarget }, - fileCalendarName = parsed.events - .mapNotNull { it.calendarName } - .distinct() - .singleOrNull(), + fileCalendarName = fileCalendarName(parsed.events), ) } } @@ -124,6 +121,17 @@ class ImportViewModel @Inject constructor( } } +/** + * The one calendar [events] all came from, or null. + * + * Every event has to agree, unnamed ones included: a foreign file where a single + * `VEVENT` carries a `CATEGORIES` tag and the rest carry nothing names no + * calendar — it just has one tagged event, and letting it pick the target would + * land the whole file wherever that tag happens to match. + */ +internal fun fileCalendarName(events: List): String? = + events.map { it.calendarName }.distinct().singleOrNull() + /** * The target calendar to preselect for a bulk import. * diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt index 0dd4cc4..4baaec6 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt @@ -143,6 +143,47 @@ class AllDayReminderEncodingTest { assertThat(fireFromNext).isEqualTo(LocalTime.of(9, 0)) // correct } + @Test + fun `an imported yearly series samples at its next occurrence, not its anchor`() { + // Fossify anchors a year-less contact birthday at 1970, when Berlin had + // no DST; sampling there fires a modern June occurrence at 10:00 instead + // of the 09:00 the user picked (Codeberg #225). + val anchor = LocalDate.of(1970, 6, 3) + val today = LocalDate.of(2026, 6, 1) + + val sampled = importedAllDayReminderDate(anchor, "FREQ=YEARLY;BYMONTH=6", today) + assertThat(sampled).isEqualTo(LocalDate.of(2026, 6, 3)) + + val raw = toProviderAllDayMinutes(0, sampled, berlin, nineAm) + val fire = actualFire(raw, LocalDate.of(2027, 6, 3)) + .let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime() + assertThat(fire).isEqualTo(LocalTime.of(9, 0)) + } + + @Test + fun `the lead time still reads back from the anchor's own date`() { + // The offset is sampled at the next occurrence but decoded against + // DTSTART, so the two must still agree on the whole-day lead time. + val anchor = LocalDate.of(1970, 6, 3) + val sampled = importedAllDayReminderDate(anchor, "FREQ=YEARLY", LocalDate.of(2026, 6, 1)) + + for (semantic in listOf(0, 1_440, 2_880)) { + val raw = toProviderAllDayMinutes(semantic, sampled, berlin, nineAm) + assertThat(fromProviderAllDayMinutes(raw, anchor, berlin, nineAm)).isEqualTo(semantic) + } + } + + @Test + fun `a one-off import samples at its own date, a stale series at today`() { + val past = LocalDate.of(1970, 6, 3) + val today = LocalDate.of(2026, 6, 1) + val future = LocalDate.of(2027, 3, 4) + + assertThat(importedAllDayReminderDate(past, null, today)).isEqualTo(past) + assertThat(importedAllDayReminderDate(past, "FREQ=WEEKLY;BYDAY=MO", today)).isEqualTo(today) + assertThat(importedAllDayReminderDate(future, "FREQ=WEEKLY", today)).isEqualTo(future) + } + @Test fun `a winter-anchored offset drifts one hour on a summer occurrence`() { // Known limitation: one fixed MINUTES per series can't track DST. An diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt index 25fe582..cd8d03e 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt @@ -828,7 +828,7 @@ class CalendarRepositoryImplTest { ) = runTest { var lookups = 0 val fake = FakeCalendarDataSource().apply { - eventColorPaletteResult = { lookups++; emptyList() } + publishedEventColorsResult = { lookups++; emptyList() } } val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined) @@ -837,6 +837,26 @@ class CalendarRepositoryImplTest { assertThat(lookups).isEqualTo(1) } + @Test + fun `importEvents matches colours against the uncurated palette`( + @TempDir tempDir: Path, + ) = runTest { + // Curation is a picker concession — it folds look-alikes and drops the + // neutrals from an oversized palette — but every published key is one + // the calendar accepts, so an imported colour matches against all of + // them (Codeberg #225). + val published = listOf(EventColorOption("1", 0xFF808080.toInt())) + val fake = FakeCalendarDataSource().apply { + publishedEventColorsResult = { published } + eventColorPaletteResult = { emptyList() } + } + val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined) + + repo.importEvents(3L, listOf(parsedEvent("a@x"))) + + assertThat(fake.lastImportPalette).isEqualTo(published) + } + @Test fun `exportEvents forwards the chosen calendar-id subset to the data source`( @TempDir tempDir: Path, diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt index c87a925..676200b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt @@ -23,6 +23,7 @@ internal class FakeCalendarDataSource : CalendarDataSource { var searchResult: (String) -> List = { _ -> emptyList() } var eventDetailResult: (Long) -> EventDetail? = { null } var eventColorPaletteResult: (Long) -> List = { emptyList() } + var publishedEventColorsResult: (Long) -> List = { emptyList() } var exportableEventsResult: List = emptyList() /** The [calendarIds] the last [exportableEvents] call received (null = all). */ var lastExportableEventsCalendarIds: Set? = null @@ -81,6 +82,8 @@ internal class FakeCalendarDataSource : CalendarDataSource { eventDetailResult(eventId) override fun eventColorPalette(calendarId: Long): List = eventColorPaletteResult(calendarId) + override fun publishedEventColors(calendarId: Long): List = + publishedEventColorsResult(calendarId) override fun exportableEvents( calendarIds: Set?, allDayReminderTimeMinutes: Int, @@ -97,12 +100,16 @@ internal class FakeCalendarDataSource : CalendarDataSource { /** Thrown instead of [writeError] for events whose summary is in this set. */ val failingImportSummaries = mutableSetOf() + /** The [colorPalette] the last [insertImportedEvent] call received. */ + var lastImportPalette: List = emptyList() + override fun insertImportedEvent( event: ParsedIcsEvent, calendarId: Long, allDayReminderTimeMinutes: Int, colorPalette: List, ): Long { + lastImportPalette = colorPalette writeError?.let { throw it } if (event.summary in failingImportSummaries) error("rejected: ${event.summary}") importedEvents += event to calendarId diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsFossifyImportTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsFossifyImportTest.kt index 29ea034..070f25e 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsFossifyImportTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsFossifyImportTest.kt @@ -265,6 +265,43 @@ class IcsFossifyImportTest { assertThat(result.events.single().calendarName).isEqualTo("Birthdays") } + @Test + fun `CATEGORIES does not outrank a property that names the calendar outright`() { + // Elsewhere CATEGORIES is a tag list, so it only stands in where nothing + // else named the calendar — X-WR-CALNAME and our own label win. + val result = IcsParser().parse( + listOf( + "BEGIN:VCALENDAR", + "X-WR-CALNAME:Work", + "BEGIN:VEVENT", + "SUMMARY:Standup", + "CATEGORIES:Holiday", + "DTSTART:20260819T080000Z", + "DTEND:20260819T090000Z", + "END:VEVENT", + "END:VCALENDAR", + ).joinToString("\r\n"), + ) + + assertThat(result.events.single().calendarName).isEqualTo("Work") + } + + @Test + fun `a multi-valued CATEGORIES is a tag list, not a calendar name`() { + val result = parser.parse( + fossify( + "BEGIN:VEVENT", + "SUMMARY:Standup", + "CATEGORIES:Meeting,Personal", + "DTSTART:20260819T080000Z", + "DTEND:20260819T090000Z", + "END:VEVENT", + ), + ) + + assertThat(result.events.single().calendarName).isNull() + } + @Test fun `a reminder written as a positive trigger is not read as a lead time`() { // The exporter flips the sign for reminders stored below -1 @@ -369,6 +406,28 @@ class IcsFossifyImportTest { assertThat(result.events.single().exDates).containsExactly("20260820", "20260822").inOrder() } + @Test + fun `an all-day EXDATE keeps the day it spells out, whatever zone it carries`() { + // A midnight east of UTC resolves to an instant on the previous day; + // reading that back in UTC would exclude a day that isn't an occurrence + // and leave the one the user deleted in place. + val result = parser.parse( + fossify( + "BEGIN:VEVENT", + "SUMMARY:Standup", + "DTSTART;VALUE=DATE:20260819", + "DTEND;VALUE=DATE:20260820", + "RRULE:FREQ=DAILY", + "EXDATE;TZID=Europe/Berlin:20260821T000000", + "EXDATE;VALUE=DATE:20260823", + "END:VEVENT", + ), + ) + + assertThat(result.events.single().exDates) + .containsExactly("20260821", "20260823").inOrder() + } + @Test fun `a timed series' bare EXDATE day code is resolved against the series time`() { // They store excluded occurrences as day codes and write them out that diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrenceTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrenceTest.kt index 93ea9c1..1d196fd 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrenceTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrenceTest.kt @@ -7,46 +7,126 @@ class IcsRecurrenceTest { @Test fun `a well-formed rule is unchanged`() { - assertThat(sanitizeRrule("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE")) - .isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE") + val result = sanitizeRrule("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE") + + assertThat(result.rule).isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE") + assertThat(result.repaired).isFalse() } @Test fun `the RRULE prefix is stripped`() { - assertThat(sanitizeRrule("RRULE:FREQ=DAILY")).isEqualTo("FREQ=DAILY") + assertThat(sanitizeRrule("RRULE:FREQ=DAILY").rule).isEqualTo("FREQ=DAILY") } @Test fun `an empty list part is dropped`() { - assertThat(sanitizeRrule("FREQ=WEEKLY;INTERVAL=1;BYDAY=")) - .isEqualTo("FREQ=WEEKLY;INTERVAL=1") - assertThat(sanitizeRrule("FREQ=WEEKLY;BYDAY=MO,,WE;BYMONTH=")) + val bareByDay = sanitizeRrule("FREQ=WEEKLY;INTERVAL=1;BYDAY=") + assertThat(bareByDay.rule).isEqualTo("FREQ=WEEKLY;INTERVAL=1") + assertThat(bareByDay.repaired).isTrue() + + assertThat(sanitizeRrule("FREQ=WEEKLY;BYDAY=MO,,WE;BYMONTH=").rule) .isEqualTo("FREQ=WEEKLY;BYDAY=MO,WE") } @Test - fun `a nonsensical INTERVAL is dropped, not fatal`() { - assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=0")).isEqualTo("FREQ=DAILY") - assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=every")).isEqualTo("FREQ=DAILY") + fun `a nonsensical INTERVAL or COUNT is dropped, not fatal`() { + assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=0").rule).isEqualTo("FREQ=DAILY") + assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=every").rule).isEqualTo("FREQ=DAILY") + assertThat(sanitizeRrule("FREQ=DAILY;COUNT=0").rule).isEqualTo("FREQ=DAILY") } @Test fun `a rule without a usable FREQ is unsalvageable`() { - assertThat(sanitizeRrule("INTERVAL=1;BYDAY=MO")).isNull() - assertThat(sanitizeRrule("FREQ=FORTNIGHTLY;INTERVAL=1")).isNull() - assertThat(sanitizeRrule("FREQ=;INTERVAL=1")).isNull() - assertThat(sanitizeRrule("")).isNull() - assertThat(sanitizeRrule(null)).isNull() + assertThat(sanitizeRrule("INTERVAL=1;BYDAY=MO").rule).isNull() + assertThat(sanitizeRrule("FREQ=FORTNIGHTLY;INTERVAL=1").rule).isNull() + assertThat(sanitizeRrule("FREQ=;INTERVAL=1").rule).isNull() + assertThat(sanitizeRrule("").rule).isNull() + assertThat(sanitizeRrule(null).rule).isNull() } @Test - fun `FREQ is normalised to upper case`() { - assertThat(sanitizeRrule("freq=daily;count=3")).isEqualTo("FREQ=DAILY;COUNT=3") + fun `no rule at all is not a repair`() { + assertThat(sanitizeRrule(null).repaired).isFalse() + assertThat(sanitizeRrule("").repaired).isFalse() + assertThat(sanitizeRrule("FREQ=FORTNIGHTLY").repaired).isTrue() } @Test - fun `unknown parts pass through untouched`() { - assertThat(sanitizeRrule("FREQ=YEARLY;WKST=SU;X-THING=7")) - .isEqualTo("FREQ=YEARLY;WKST=SU;X-THING=7") + fun `normalising is not repairing`() { + // Case and a trailing separator change the text but lose nothing, and + // telling the user their file was faulty over either is crying wolf. + val lowercase = sanitizeRrule("freq=daily;count=3") + assertThat(lowercase.rule).isEqualTo("FREQ=DAILY;COUNT=3") + assertThat(lowercase.repaired).isFalse() + + val trailing = sanitizeRrule("FREQ=DAILY;") + assertThat(trailing.rule).isEqualTo("FREQ=DAILY") + assertThat(trailing.repaired).isFalse() + } + + @Test + fun `parts the provider's parser has no entry for are dropped`() { + // EventRecurrence.parse throws on any part name outside its own table, + // so passing one on would cost the whole event (Codeberg #225). + val result = sanitizeRrule("FREQ=MONTHLY;RSCALE=GREGORIAN;BYMONTHDAY=15") + + assertThat(result.rule).isEqualTo("FREQ=MONTHLY;BYMONTHDAY=15") + assertThat(result.repaired).isTrue() + assertThat(sanitizeRrule("FREQ=YEARLY;X-THING=7").rule).isEqualTo("FREQ=YEARLY") + } + + @Test + fun `WKST survives, a nonsense weekday does not`() { + assertThat(sanitizeRrule("FREQ=WEEKLY;WKST=SU").rule).isEqualTo("FREQ=WEEKLY;WKST=SU") + assertThat(sanitizeRrule("FREQ=WEEKLY;WKST=XX").rule).isEqualTo("FREQ=WEEKLY") + } + + @Test + fun `a BYDAY item that isn't a weekday is dropped`() { + assertThat(sanitizeRrule("FREQ=MONTHLY;BYDAY=-1FR,1,MO").rule) + .isEqualTo("FREQ=MONTHLY;BYDAY=-1FR,MO") + assertThat(sanitizeRrule("FREQ=WEEKLY;BYDAY=mo,tu").rule) + .isEqualTo("FREQ=WEEKLY;BYDAY=MO,TU") + } + + @Test + fun `a zero is dropped from the by-position parts only`() { + assertThat(sanitizeRrule("FREQ=MONTHLY;BYMONTHDAY=0,15").rule) + .isEqualTo("FREQ=MONTHLY;BYMONTHDAY=15") + assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=0,9").rule) + .isEqualTo("FREQ=DAILY;BYHOUR=0,9") + } + + @Test + fun `an out-of-range numeric item is dropped`() { + // EventRecurrence.parseNumberList range-checks every one of these and + // throws out of insert, so being a number isn't enough (Codeberg #225). + assertThat(sanitizeRrule("FREQ=MONTHLY;BYMONTHDAY=15,32").rule) + .isEqualTo("FREQ=MONTHLY;BYMONTHDAY=15") + assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=25").rule).isEqualTo("FREQ=DAILY") + assertThat(sanitizeRrule("FREQ=YEARLY;BYMONTH=13").rule).isEqualTo("FREQ=YEARLY") + assertThat(sanitizeRrule("FREQ=YEARLY;BYWEEKNO=60").rule).isEqualTo("FREQ=YEARLY") + assertThat(sanitizeRrule("FREQ=YEARLY;BYYEARDAY=400").rule).isEqualTo("FREQ=YEARLY") + assertThat(sanitizeRrule("FREQ=DAILY;BYSECOND=60").rule).isEqualTo("FREQ=DAILY") + assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=25").repaired).isTrue() + } + + @Test + fun `the edges of each range survive`() { + assertThat(sanitizeRrule("FREQ=MONTHLY;BYMONTHDAY=-31,31").repaired).isFalse() + assertThat(sanitizeRrule("FREQ=YEARLY;BYYEARDAY=-366,366;BYWEEKNO=-53,53").repaired) + .isFalse() + assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=23;BYMINUTE=59;BYSECOND=59").repaired) + .isFalse() + // BYSETPOS is the one part the provider leaves unbounded. + assertThat(sanitizeRrule("FREQ=MONTHLY;BYDAY=MO;BYSETPOS=-1,400").rule) + .isEqualTo("FREQ=MONTHLY;BYDAY=MO;BYSETPOS=-1,400") + } + + @Test + fun `an UNTIL that isn't a timestamp is dropped`() { + assertThat(sanitizeRrule("FREQ=DAILY;UNTIL=20260819T235959Z").rule) + .isEqualTo("FREQ=DAILY;UNTIL=20260819T235959Z") + assertThat(sanitizeRrule("FREQ=DAILY;UNTIL=2026-08-19").rule).isEqualTo("FREQ=DAILY") } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/imports/ImportTargetTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/imports/ImportTargetTest.kt index 9092452..cbb1a5b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/imports/ImportTargetTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/imports/ImportTargetTest.kt @@ -2,7 +2,9 @@ package de.jeanlucmakiola.calendula.ui.imports import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import org.junit.jupiter.api.Test +import kotlin.time.Instant /** Which calendar a bulk import lands on before the user touches the picker. */ class ImportTargetTest { @@ -52,4 +54,36 @@ class ImportTargetTest { fun `an empty list preselects nothing`() { assertThat(defaultImportTarget(emptyList(), "Birthdays")).isNull() } + + private fun event(calendarName: String?) = ParsedIcsEvent( + uid = "u@x", + summary = "E", + start = Instant.fromEpochMilliseconds(0L), + end = Instant.fromEpochMilliseconds(3_600_000L), + isAllDay = false, + zoneId = "UTC", + calendarName = calendarName, + ) + + @Test + fun `a file whose events all name one calendar names it`() { + assertThat(fileCalendarName(List(3) { event("Birthdays") })).isEqualTo("Birthdays") + } + + @Test + fun `disagreeing events name none`() { + assertThat(fileCalendarName(listOf(event("Birthdays"), event("Anniversaries")))).isNull() + } + + @Test + fun `one tagged event among unnamed ones names none`() { + // CATEGORIES is a tag list everywhere but the Fossify family, so a lone + // tagged event must not decide where the whole file lands. + assertThat(fileCalendarName(listOf(event("Holiday")) + List(9) { event(null) })).isNull() + } + + @Test + fun `a file that names nothing names none`() { + assertThat(fileCalendarName(List(3) { event(null) })).isNull() + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f7d35d9..4c86337 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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.