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 16276e450d
commit c99e802217
14 changed files with 513 additions and 103 deletions

View File

@@ -26,7 +26,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 - **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 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 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.** - **Birthdays and anniversaries imported from Fossify Calendar now show up.**
Fossify writes the ones it mirrors from your contacts as events that start and 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 end on the same day, which the calendar read as lasting no time at all: they

View File

@@ -70,6 +70,34 @@ internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): Local
return LocalDate.of(today.year, month, day) 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 * Recover the semantic whole-day lead time from a raw all-day reminder
* [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the * [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the

View File

@@ -85,6 +85,15 @@ interface CalendarDataSource {
*/ */
fun eventColorPalette(calendarId: Long): List<EventColorOption> fun eventColorPalette(calendarId: Long): List<EventColorOption>
/**
* 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<EventColorOption>
/** /**
* Every master/one-off event of the writable local calendars, mapped for a * Every master/one-off event of the writable local calendars, mapped for a
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception * 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 * Insert a parsed `.ics` event into [calendarId], preserving its UID (or
* minting one when absent); returns the new `Events._ID`. * minting one when absent); returns the new `Events._ID`.
* *
* [colorPalette] is the target account's published event colours, looked up * [colorPalette] is the target account's published event colours
* once per import; [allDayReminderTimeMinutes] is the user's preferred * ([publishedEventColors]), looked up once per import;
* all-day firing time, applied exactly as a hand-created event's is. * [allDayReminderTimeMinutes] is the user's preferred all-day firing time,
* applied exactly as a hand-created event's is.
*/ */
fun insertImportedEvent( fun insertImportedEvent(
event: ParsedIcsEvent, event: ParsedIcsEvent,
@@ -732,7 +742,10 @@ class AndroidCalendarDataSource @Inject constructor(
} }
} }
override fun eventColorPalette(calendarId: Long): List<EventColorOption> { override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
publishedEventColors(calendarId).curatedForPicker()
override fun publishedEventColors(calendarId: Long): List<EventColorOption> {
val account = calendarAccount(calendarId) ?: return emptyList() val account = calendarAccount(calendarId) ?: return emptyList()
return resolver.query( return resolver.query(
CalendarContract.Colors.CONTENT_URI, CalendarContract.Colors.CONTENT_URI,
@@ -750,7 +763,6 @@ class AndroidCalendarDataSource @Inject constructor(
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) } c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
} }
?.filter { it.key.isNotEmpty() } ?.filter { it.key.isNotEmpty() }
?.curatedForPicker()
?: emptyList() ?: emptyList()
} }
@@ -830,16 +842,27 @@ class AndroidCalendarDataSource @Inject constructor(
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues()) val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
?: throw WriteFailedException("import event into calendar id=$calendarId") ?: throw WriteFailedException("import event into calendar id=$calendarId")
val eventId = ContentUris.parseId(uri) val eventId = ContentUris.parseId(uri)
val startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds()) val zone = ZoneId.systemDefault()
.atZone(ZoneOffset.UTC).toLocalDate() // 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 -> event.semanticReminderMinutes().forEach { minutes ->
// An all-day reminder is stored the same way a hand-created one is, val providerMinutes = if (reminderDate != null) {
// so it fires at the time the user picked rather than at UTC midnight.
val providerMinutes = if (event.isAllDay) {
toProviderAllDayMinutes( toProviderAllDayMinutes(
semanticMinutes = minutes, semanticMinutes = minutes,
startDate = startDate, startDate = reminderDate,
zone = ZoneId.systemDefault(), zone = zone,
timeOfDayMinutes = allDayReminderTimeMinutes, timeOfDayMinutes = allDayReminderTimeMinutes,
) )
} else { } else {

View File

@@ -220,8 +220,10 @@ class CalendarRepositoryImpl @Inject constructor(
): IcsImportSummary = withContext(io) { ): IcsImportSummary = withContext(io) {
val existing = dataSource.existingUids(targetCalendarId) val existing = dataSource.existingUids(targetCalendarId)
// Both are per-calendar, not per-event: looking them up once keeps a // Both are per-calendar, not per-event: looking them up once keeps a
// thousand-event restore to two extra queries. // thousand-event restore to two extra queries. The palette is the
val palette = dataSource.eventColorPalette(targetCalendarId) // 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() val allDayMinutes = allDayReminderTimeMinutes()
var imported = 0 var imported = 0
var skipped = 0 var skipped = 0

View File

@@ -186,6 +186,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
var status = EventStatus.Confirmed var status = EventStatus.Confirmed
var availability = Availability.Busy var availability = Availability.Busy
var calendarName = fileCalendarName var calendarName = fileCalendarName
var category: String? = null
var eventColor: Int? = null var eventColor: Int? = null
var calendarColor: Int? = null var calendarColor: Int? = null
val reminders = mutableListOf<Int>() val reminders = mutableListOf<Int>()
@@ -223,10 +224,10 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
"X-CALENDULA-CALENDAR" -> "X-CALENDULA-CALENDAR" ->
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName } calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
// Fossify names the source calendar per event; it never writes // Fossify names the source calendar per event; it never writes
// an X-WR-CALNAME, so this is the only calendar label its files carry. // an X-WR-CALNAME, so this is the only calendar label its files
"CATEGORIES" -> calendarName = unescapeText(line.value).trim() // carry. Elsewhere CATEGORIES is a plain tag list, so it only
.takeIf { it.isNotEmpty() && !it.equals("null", true) } // stands in where nothing named the calendar outright.
?: calendarName "CATEGORIES" -> category = singleCategory(line.value) ?: category
// Per-event colour, most specific encoding first (see parseIcsColorValue). // Per-event colour, most specific encoding first (see parseIcsColorValue).
"X-FOSSIFY-EVENT-COLOR", "X-SMT-EVENT-COLOR" -> "X-FOSSIFY-EVENT-COLOR", "X-SMT-EVENT-COLOR" ->
eventColor = parseIcsColorValue(line.value) ?: eventColor eventColor = parseIcsColorValue(line.value) ?: eventColor
@@ -247,10 +248,8 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
warnings.add(IcsParseWarning.EventWithoutStartSkipped) warnings.add(IcsParseWarning.EventWithoutStartSkipped)
return null return null
} }
val cleanRrule = sanitizeRrule(rrule) val recurrence = sanitizeRrule(rrule)
if (rrule != null && cleanRrule != rrule.removePrefix("RRULE:").trim()) { if (recurrence.repaired) warnings.add(IcsParseWarning.RecurrenceRuleRepaired)
warnings.add(IcsParseWarning.RecurrenceRuleRepaired)
}
val end = resolveEnd(start, dtEnd, duration) val end = resolveEnd(start, dtEnd, duration)
return ParsedIcsEvent( return ParsedIcsEvent(
uid = uid, uid = uid,
@@ -259,15 +258,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
end = end, end = end,
isAllDay = start.isAllDay, isAllDay = start.isAllDay,
zoneId = start.zoneId, zoneId = start.zoneId,
recurrenceRule = cleanRrule, recurrenceRule = recurrence.rule,
location = location, location = location,
description = description, description = description,
reminderMinutes = reminders.distinct(), reminderMinutes = reminders.distinct(),
status = status, status = status,
availability = availability, availability = availability,
calendarName = calendarName, calendarName = calendarName ?: category,
color = eventColor ?: calendarColor, color = eventColor ?: calendarColor,
exDates = if (cleanRrule == null) emptyList() else normalizeExDates(exDateLines, start), exDates = if (recurrence.rule == null) {
emptyList()
} else {
normalizeExDates(exDateLines, start)
},
isTask = isTask, 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. * 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 * Such a value is resolved against the series' own time of day, which is
* what it meant — its repetitions never move within the day. * 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<IcsContentLine>, start: IcsDateTime): List<String> { private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> {
if (lines.isEmpty()) return emptyList() if (lines.isEmpty()) return emptyList()
@@ -310,17 +318,12 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
return lines return lines
.flatMap { line -> line.value.split(',').map { line to it.trim() } } .flatMap { line -> line.value.split(',').map { line to it.trim() } }
.mapNotNull { (line, token) -> .mapNotNull { (line, token) ->
if (token.isEmpty()) return@mapNotNull null when {
if (token.contains('T')) { token.isEmpty() -> null
val instant = parseExDateTime(token, line, startZone) ?: return@mapNotNull null start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.let(::dayCode)
if (start.isAllDay) utcDayCode(instant) else utcStamp(instant) token.contains('T') -> parseExDateTime(token, line, startZone)?.let(::utcStamp)
} else { else -> parseBasicDate(token)
val date = parseBasicDate(token) ?: return@mapNotNull null ?.let { utcStamp(LocalDateTime(it, startTime).toInstant(startZone)) }
if (start.isAllDay) {
utcDayCode(date.atStartOfDayIn(TimeZone.UTC))
} else {
utcStamp(LocalDateTime(date, startTime).toInstant(startZone))
}
} }
} }
.distinct() .distinct()
@@ -391,14 +394,31 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
fun Instant.plusDays(days: Int): Instant = fun Instant.plusDays(days: Int): Instant =
Instant.fromEpochMilliseconds(toEpochMilliseconds() + days * DAY_MILLIS) Instant.fromEpochMilliseconds(toEpochMilliseconds() + days * DAY_MILLIS)
fun utcDayCode(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) { fun dayCode(date: LocalDate): String =
"%04d%02d%02d".format(year, month.number, day) "%04d%02d%02d".format(date.year, date.month.number, date.day)
}
fun utcStamp(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) { fun utcStamp(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) {
"%04d%02d%02dT%02d%02d%02dZ".format(year, month.number, day, hour, minute, second) "%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:<component>` at/after [from], or list end. */ /** Index of the matching `END:<component>` at/after [from], or list end. */
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int { fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
var i = from var i = from

View File

@@ -4,15 +4,50 @@ private val VALID_FREQ = setOf(
"SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY", "SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY",
) )
/** RRULE parts whose value is a comma-separated list, so empty items can be dropped. */ private val VALID_WEEKDAYS = setOf("SU", "MO", "TU", "WE", "TH", "FR", "SA")
private val LIST_PARTS = setOf(
"BYSECOND", "BYMINUTE", "BYHOUR", "BYDAY", "BYMONTHDAY", "BYYEARDAY",
"BYWEEKNO", "BYMONTH", "BYSETPOS",
)
/** /**
* Make a foreign RRULE safe to hand `CalendarContract`, or return null when it * What `EventRecurrence` accepts in each numeric list part: a value range, plus
* can't be salvaged. * 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 * The provider validates every RRULE through `EventRecurrence.parse`, which
* **throws** on a malformed rule — and the throw surfaces from `insert`, not * **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 * (`Parser.getByDay` interpolates an empty day string unconditionally), which is
* the state every weekly event it imported from elsewhere is left in. * 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 * That parser has a fixed table of parts and throws on any name outside it, so
* missing or unrecognised `FREQ` — without which there is no rule at all — * this is a whitelist: an unrecognised part (RFC 7529 `RSCALE`, a vendor `X-`
* returns null. * 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() 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 var freqSeen = false
val parts = rule.split(';').mapNotNull { part -> var repaired = false
val parts = mutableListOf<String>()
/** 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 key = part.substringBefore('=', "").trim().uppercase()
val value = if ('=' in part) part.substringAfter('=').trim() else "" val value = if ('=' in part) part.substringAfter('=').trim() else ""
if (key.isEmpty() || value.isEmpty()) return@mapNotNull null // A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
when { if (key.isEmpty() && value.isEmpty()) continue
key == "FREQ" -> { val kept = when {
if (value.uppercase() !in VALID_FREQ) return null key.isEmpty() || value.isEmpty() -> null
freqSeen = true key == "FREQ" ->
"FREQ=${value.uppercase()}" value.uppercase().takeIf { it in VALID_FREQ }
} ?.also { freqSeen = true }
// A non-positive or non-numeric INTERVAL is rejected by the provider. ?.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 == "INTERVAL" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "INTERVAL=$it" }
key in LIST_PARTS -> value.split(',') key == "COUNT" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "COUNT=$it" }
.map { it.trim() } key == "UNTIL" ->
.filter { it.isNotEmpty() } value.uppercase().takeIf { UNTIL_SHAPE.matches(it) }?.let { "UNTIL=$it" }
.takeIf { it.isNotEmpty() } key == "WKST" ->
?.let { "$key=${it.joinToString(",")}" } value.uppercase().takeIf { it in VALID_WEEKDAYS }?.let { "WKST=$it" }
else -> "$key=$value" 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)
} }

View File

@@ -44,9 +44,9 @@ sealed interface ImportUiState {
val warnings: Set<IcsParseWarning>, val warnings: Set<IcsParseWarning>,
val calendars: List<CalendarSource>, val calendars: List<CalendarSource>,
/** /**
* The calendar the file says its events came from, when it names exactly * The calendar the file says its events came from, when every one of
* one — used to preselect a matching target. Null when the file names * them names the same — used to preselect a matching target (see
* none or its events disagree. * [fileCalendarName]).
*/ */
val fileCalendarName: String? = null, val fileCalendarName: String? = null,
) : ImportUiState ) : ImportUiState
@@ -98,10 +98,7 @@ class ImportViewModel @Inject constructor(
warnings = parsed.warnings, warnings = parsed.warnings,
calendars = repository.calendars().first() calendars = repository.calendars().first()
.filter { it.isEventTarget }, .filter { it.isEventTarget },
fileCalendarName = parsed.events fileCalendarName = fileCalendarName(parsed.events),
.mapNotNull { it.calendarName }
.distinct()
.singleOrNull(),
) )
} }
} }
@@ -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<ParsedIcsEvent>): String? =
events.map { it.calendarName }.distinct().singleOrNull()
/** /**
* The target calendar to preselect for a bulk import. * The target calendar to preselect for a bulk import.
* *

View File

@@ -143,6 +143,47 @@ class AllDayReminderEncodingTest {
assertThat(fireFromNext).isEqualTo(LocalTime.of(9, 0)) // correct 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 @Test
fun `a winter-anchored offset drifts one hour on a summer occurrence`() { fun `a winter-anchored offset drifts one hour on a summer occurrence`() {
// Known limitation: one fixed MINUTES per series can't track DST. An // Known limitation: one fixed MINUTES per series can't track DST. An

View File

@@ -828,7 +828,7 @@ class CalendarRepositoryImplTest {
) = runTest { ) = runTest {
var lookups = 0 var lookups = 0
val fake = FakeCalendarDataSource().apply { val fake = FakeCalendarDataSource().apply {
eventColorPaletteResult = { lookups++; emptyList() } publishedEventColorsResult = { lookups++; emptyList() }
} }
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined) val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
@@ -837,6 +837,26 @@ class CalendarRepositoryImplTest {
assertThat(lookups).isEqualTo(1) 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 @Test
fun `exportEvents forwards the chosen calendar-id subset to the data source`( fun `exportEvents forwards the chosen calendar-id subset to the data source`(
@TempDir tempDir: Path, @TempDir tempDir: Path,

View File

@@ -23,6 +23,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var searchResult: (String) -> List<SearchCandidate> = { _ -> emptyList() } var searchResult: (String) -> List<SearchCandidate> = { _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null } var eventDetailResult: (Long) -> EventDetail? = { null }
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() } var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
var publishedEventColorsResult: (Long) -> List<EventColorOption> = { emptyList() }
var exportableEventsResult: List<IcsEvent> = emptyList() var exportableEventsResult: List<IcsEvent> = emptyList()
/** The [calendarIds] the last [exportableEvents] call received (null = all). */ /** The [calendarIds] the last [exportableEvents] call received (null = all). */
var lastExportableEventsCalendarIds: Set<Long>? = null var lastExportableEventsCalendarIds: Set<Long>? = null
@@ -81,6 +82,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
eventDetailResult(eventId) eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List<EventColorOption> = override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
eventColorPaletteResult(calendarId) eventColorPaletteResult(calendarId)
override fun publishedEventColors(calendarId: Long): List<EventColorOption> =
publishedEventColorsResult(calendarId)
override fun exportableEvents( override fun exportableEvents(
calendarIds: Set<Long>?, calendarIds: Set<Long>?,
allDayReminderTimeMinutes: Int, allDayReminderTimeMinutes: Int,
@@ -97,12 +100,16 @@ internal class FakeCalendarDataSource : CalendarDataSource {
/** Thrown instead of [writeError] for events whose summary is in this set. */ /** Thrown instead of [writeError] for events whose summary is in this set. */
val failingImportSummaries = mutableSetOf<String>() val failingImportSummaries = mutableSetOf<String>()
/** The [colorPalette] the last [insertImportedEvent] call received. */
var lastImportPalette: List<EventColorOption> = emptyList()
override fun insertImportedEvent( override fun insertImportedEvent(
event: ParsedIcsEvent, event: ParsedIcsEvent,
calendarId: Long, calendarId: Long,
allDayReminderTimeMinutes: Int, allDayReminderTimeMinutes: Int,
colorPalette: List<EventColorOption>, colorPalette: List<EventColorOption>,
): Long { ): Long {
lastImportPalette = colorPalette
writeError?.let { throw it } writeError?.let { throw it }
if (event.summary in failingImportSummaries) error("rejected: ${event.summary}") if (event.summary in failingImportSummaries) error("rejected: ${event.summary}")
importedEvents += event to calendarId importedEvents += event to calendarId

View File

@@ -265,6 +265,43 @@ class IcsFossifyImportTest {
assertThat(result.events.single().calendarName).isEqualTo("Birthdays") 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 @Test
fun `a reminder written as a positive trigger is not read as a lead time`() { 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 // 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() 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 @Test
fun `a timed series' bare EXDATE day code is resolved against the series time`() { 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 // They store excluded occurrences as day codes and write them out that

View File

@@ -7,46 +7,126 @@ class IcsRecurrenceTest {
@Test @Test
fun `a well-formed rule is unchanged`() { fun `a well-formed rule is unchanged`() {
assertThat(sanitizeRrule("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE")) val result = sanitizeRrule("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE")
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE")
assertThat(result.rule).isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE")
assertThat(result.repaired).isFalse()
} }
@Test @Test
fun `the RRULE prefix is stripped`() { fun `the RRULE prefix is stripped`() {
assertThat(sanitizeRrule("RRULE:FREQ=DAILY")).isEqualTo("FREQ=DAILY") assertThat(sanitizeRrule("RRULE:FREQ=DAILY").rule).isEqualTo("FREQ=DAILY")
} }
@Test @Test
fun `an empty list part is dropped`() { fun `an empty list part is dropped`() {
assertThat(sanitizeRrule("FREQ=WEEKLY;INTERVAL=1;BYDAY=")) val bareByDay = sanitizeRrule("FREQ=WEEKLY;INTERVAL=1;BYDAY=")
.isEqualTo("FREQ=WEEKLY;INTERVAL=1") assertThat(bareByDay.rule).isEqualTo("FREQ=WEEKLY;INTERVAL=1")
assertThat(sanitizeRrule("FREQ=WEEKLY;BYDAY=MO,,WE;BYMONTH=")) assertThat(bareByDay.repaired).isTrue()
assertThat(sanitizeRrule("FREQ=WEEKLY;BYDAY=MO,,WE;BYMONTH=").rule)
.isEqualTo("FREQ=WEEKLY;BYDAY=MO,WE") .isEqualTo("FREQ=WEEKLY;BYDAY=MO,WE")
} }
@Test @Test
fun `a nonsensical INTERVAL is dropped, not fatal`() { fun `a nonsensical INTERVAL or COUNT is dropped, not fatal`() {
assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=0")).isEqualTo("FREQ=DAILY") assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=0").rule).isEqualTo("FREQ=DAILY")
assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=every")).isEqualTo("FREQ=DAILY") assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=every").rule).isEqualTo("FREQ=DAILY")
assertThat(sanitizeRrule("FREQ=DAILY;COUNT=0").rule).isEqualTo("FREQ=DAILY")
} }
@Test @Test
fun `a rule without a usable FREQ is unsalvageable`() { fun `a rule without a usable FREQ is unsalvageable`() {
assertThat(sanitizeRrule("INTERVAL=1;BYDAY=MO")).isNull() assertThat(sanitizeRrule("INTERVAL=1;BYDAY=MO").rule).isNull()
assertThat(sanitizeRrule("FREQ=FORTNIGHTLY;INTERVAL=1")).isNull() assertThat(sanitizeRrule("FREQ=FORTNIGHTLY;INTERVAL=1").rule).isNull()
assertThat(sanitizeRrule("FREQ=;INTERVAL=1")).isNull() assertThat(sanitizeRrule("FREQ=;INTERVAL=1").rule).isNull()
assertThat(sanitizeRrule("")).isNull() assertThat(sanitizeRrule("").rule).isNull()
assertThat(sanitizeRrule(null)).isNull() assertThat(sanitizeRrule(null).rule).isNull()
} }
@Test @Test
fun `FREQ is normalised to upper case`() { fun `no rule at all is not a repair`() {
assertThat(sanitizeRrule("freq=daily;count=3")).isEqualTo("FREQ=DAILY;COUNT=3") assertThat(sanitizeRrule(null).repaired).isFalse()
assertThat(sanitizeRrule("").repaired).isFalse()
assertThat(sanitizeRrule("FREQ=FORTNIGHTLY").repaired).isTrue()
} }
@Test @Test
fun `unknown parts pass through untouched`() { fun `normalising is not repairing`() {
assertThat(sanitizeRrule("FREQ=YEARLY;WKST=SU;X-THING=7")) // Case and a trailing separator change the text but lose nothing, and
.isEqualTo("FREQ=YEARLY;WKST=SU;X-THING=7") // 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")
} }
} }

View File

@@ -2,7 +2,9 @@ package de.jeanlucmakiola.calendula.ui.imports
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import kotlin.time.Instant
/** Which calendar a bulk import lands on before the user touches the picker. */ /** Which calendar a bulk import lands on before the user touches the picker. */
class ImportTargetTest { class ImportTargetTest {
@@ -52,4 +54,36 @@ class ImportTargetTest {
fun `an empty list preselects nothing`() { fun `an empty list preselects nothing`() {
assertThat(defaultImportTarget(emptyList(), "Birthdays")).isNull() 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()
}
} }

View File

@@ -377,19 +377,28 @@ conformant one.
**One bad event must not cost the file.** The provider validates `RRULE` through **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 `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 `CalendarRepositoryImpl.importEvents` isolates each event, counting rejects into
`IcsImportSummary.failed` rather than unwinding the batch and reporting only `IcsImportSummary.failed` rather than unwinding the batch and reporting only
"couldn't read this file". "couldn't read this file".
Smaller dialect handling: `VTODO` components import as events (Calendula models Smaller dialect handling: `VTODO` components import as events (Calendula models
no tasks; dropping them silently lost half of some exports), `CATEGORIES` stands no tasks; dropping them silently lost half of some exports), a single-valued
in for the `X-WR-CALNAME` Fossify never writes and preselects a target calendar `CATEGORIES` stands in for the `X-WR-CALNAME` Fossify never writes and preselects
of that name, bare `EXDATE` day codes on a timed series are resolved against the 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 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* to `DTSTART`'s zone, not the device's) while an all-day `EXDATE` keeps the day it
the start — which is how that family encodes "on the day at 09:00" — is read as spells out rather than the day its resolved instant lands on in UTC, and a
zero days before rather than clamped to a lead time of zero. `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 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 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 `fromProviderAllDayMinutes` first, and the importing device re-encodes it against
its own setting. 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 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 in. A calendar whose account publishes a palette rejects a raw `EVENT_COLOR`, so
`buildImportedEventValues` snaps the imported colour to the nearest published key `buildImportedEventValues` snaps the imported colour to the nearest published key
(`nearestTo`, measured in Oklab) and writes the raw value only where there is no (`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 first question is which calendar to use, and answering it clears the colour
anyway. anyway.