diff --git a/CHANGELOG.md b/CHANGELOG.md index c589652..30a32f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **A failed import no longer costs you the whole file.** One event the calendar + refuses used to abort the entire import with nothing on screen but "couldn't + read this file". Events are now added one at a time: anything rejected is + counted and shown, and the rest still arrive. A faulty repeat rule in the file + is repaired instead of being handed on ([#225]). +- **Imported reminders fire at the hour you chose**, not at midnight UTC — the + same rule the app already applied to all-day events you create yourself + ([#225]). +- **An all-day event with no end date, or one ending the day it starts, imports + as a one-day event** instead of a zero-length one that never appears ([#225]). + +### Added +- **More of an imported `.ics` survives the trip**: tasks come across as events + rather than being dropped in silence, deleted occurrences of a repeating event + stay deleted, the file's own calendar name is picked up, and event colours are + carried over — matched to the closest colour your calendar's account offers, so + a migrated calendar still reads at a glance ([#225]). + ## [2.19.2] — 2026-08-17 ### Changed @@ -1455,3 +1474,4 @@ automatically, with zero telemetry and no internet permission. [#192]: https://codeberg.org/jlmakiola/calendula/issues/192 [#196]: https://codeberg.org/jlmakiola/calendula/issues/196 [#214]: https://codeberg.org/jlmakiola/calendula/issues/214 +[#225]: https://codeberg.org/jlmakiola/calendula/issues/225 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 1b8507f..bf7e7f4 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 @@ -35,6 +35,7 @@ import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.domain.ics.IcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent +import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt import kotlinx.datetime.toJavaLocalDate import java.time.Instant @@ -101,10 +102,18 @@ interface CalendarDataSource { /** * Insert a parsed `.ics` event into [calendarId], preserving its UID (or - * minting one when absent); returns the new `Events._ID`. Reminders are - * written as the file's raw lead minutes (METHOD_ALERT). + * 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. */ - fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long + fun insertImportedEvent( + event: ParsedIcsEvent, + calendarId: Long, + allDayReminderTimeMinutes: Int, + colorPalette: List, + ): Long /** * Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns; @@ -794,47 +803,41 @@ class AndroidCalendarDataSource @Inject constructor( buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) } } ?: emptySet() - override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long { - val startMillis = event.start.toEpochMillis() - val endMillis = event.end.toEpochMillis() - val values = ContentValues().apply { - put(CalendarContract.Events.CALENDAR_ID, calendarId) + override fun insertImportedEvent( + event: ParsedIcsEvent, + calendarId: Long, + allDayReminderTimeMinutes: Int, + colorPalette: List, + ): Long { + val values = buildImportedEventValues( + event = event, + calendarId = calendarId, // Preserve the file's UID so a re-import dedups against it; mint one // only when the source event carried none. - put( - CalendarContract.Events.UID_2445, - event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula", - ) - put(CalendarContract.Events.TITLE, event.summary.trim()) - put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0) - put(CalendarContract.Events.DTSTART, startMillis) - if (event.recurrenceRule == null) { - put(CalendarContract.Events.DTEND, endMillis) - } else { - put(CalendarContract.Events.RRULE, event.recurrenceRule) - put( - CalendarContract.Events.DURATION, - importDuration(startMillis, endMillis, event.isAllDay), - ) - } - // All-day rows live at UTC midnights (the file already encodes them so); - // timed rows keep the event's own zone. - put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId) - put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue()) - put(CalendarContract.Events.STATUS, event.status.toProviderStatus()) - event.location?.trim()?.takeIf { it.isNotEmpty() } - ?.let { put(CalendarContract.Events.EVENT_LOCATION, it) } - event.description?.trim()?.takeIf { it.isNotEmpty() } - ?.let { put(CalendarContract.Events.DESCRIPTION, it) } - } - val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values) + uid = event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula", + palette = colorPalette, + ) + val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues()) ?: throw WriteFailedException("import event into calendar id=$calendarId") val eventId = ContentUris.parseId(uri) - // Raw lead minutes straight from the file's VALARMs (best-effort, like insertEvent). - event.reminderMinutes.distinct().filter { it >= 0 }.forEach { minutes -> + val startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds()) + .atZone(ZoneOffset.UTC).toLocalDate() + 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) { + toProviderAllDayMinutes( + semanticMinutes = minutes, + startDate = startDate, + zone = ZoneId.systemDefault(), + timeOfDayMinutes = allDayReminderTimeMinutes, + ) + } else { + minutes + } val reminder = ContentValues().apply { put(CalendarContract.Reminders.EVENT_ID, eventId) - put(CalendarContract.Reminders.MINUTES, minutes) + put(CalendarContract.Reminders.MINUTES, providerMinutes) put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT) } if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) { @@ -844,18 +847,6 @@ class AndroidCalendarDataSource @Inject constructor( return eventId } - /** Provider DURATION for an imported recurring row: whole days / seconds. */ - private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String { - val span = (endMillis - startMillis).coerceAtLeast(0) - return if (isAllDay) "P${span / 86_400_000L}D" else "P${span / 1_000L}S" - } - - private fun EventStatus.toProviderStatus(): Int = when (this) { - EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED - EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE - EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED - } - /** The account a calendar belongs to, for scoping a `Colors` lookup. */ private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query( ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId), 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 f7b677a..2e47430 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 @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.data.calendar +import android.util.Log import de.jeanlucmakiola.floret.time.toEpochMillis import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs @@ -26,6 +27,7 @@ import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.withContext import java.util.concurrent.atomic.AtomicLong import kotlin.time.Instant @@ -217,19 +219,34 @@ class CalendarRepositoryImpl @Inject constructor( events: List, ): 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) + val allDayMinutes = allDayReminderTimeMinutes() var imported = 0 var skipped = 0 + var failed = 0 for (event in events) { // A known UID means the event is already in this calendar — skip, // keeping a restore idempotent (no overwrite this pass). if (event.uid != null && event.uid in existing) { skipped++ - } else { - dataSource.insertImportedEvent(event, targetCalendarId) + continue + } + try { + dataSource.insertImportedEvent(event, targetCalendarId, allDayMinutes, palette) imported++ + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // One event the provider won't take must not cost the user the + // rest of the file — foreign exports do carry rows it rejects + // outright (a malformed RRULE throws straight out of insert). + failed++ + Log.w(TAG, "Skipped an unimportable event", e) } } - IcsImportSummary(imported = imported, skippedDuplicate = skipped) + IcsImportSummary(imported = imported, skippedDuplicate = skipped, failed = failed) } override suspend fun createEvent(form: EventForm): Long = withContext(io) { @@ -288,6 +305,11 @@ class CalendarRepositoryImpl @Inject constructor( ) = withContext(io) { dataSource.deleteEventFromOccurrence(eventId, beginMillis) } + + + private companion object { + const val TAG = "CalendarRepository" + } } private fun Flow.reQuery(block: suspend () -> T): Flow = flow { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index 8e64db5..630018b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -3,7 +3,11 @@ package de.jeanlucmakiola.calendula.data.calendar import android.provider.CalendarContract import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability +import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm +import de.jeanlucmakiola.calendula.domain.EventStatus +import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent +import de.jeanlucmakiola.calendula.domain.nearestTo import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDateTime import java.time.Duration @@ -499,3 +503,72 @@ internal fun AccessLevel.toProviderValue(): Int = when (this) { AccessLevel.Private -> CalendarContract.Events.ACCESS_PRIVATE AccessLevel.Public -> CalendarContract.Events.ACCESS_PUBLIC } + +/** + * The `Events` row for a `.ics` event being imported into [calendarId]. + * + * A recurring row carries RRULE + DURATION (and any EXDATE) with no DTEND; a + * one-off carries DTEND. All-day rows live at UTC midnights, exactly as the + * parser hands them over. + * + * [palette] is the target account's published event colours. A calendar that + * publishes one rejects a raw `EVENT_COLOR`, so an imported colour — which is + * an arbitrary ARGB from a foreign app — is snapped to the nearest key it does + * accept; accounts with no palette take the raw value (see [eventColorColumns]). + */ +internal fun buildImportedEventValues( + event: ParsedIcsEvent, + calendarId: Long, + uid: String, + palette: List, +): Map = buildMap { + val startMillis = event.start.toEpochMilliseconds() + val endMillis = event.end.toEpochMilliseconds() + put(CalendarContract.Events.CALENDAR_ID, calendarId) + put(CalendarContract.Events.UID_2445, uid) + put(CalendarContract.Events.TITLE, event.summary.trim()) + put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0) + put(CalendarContract.Events.DTSTART, startMillis) + if (event.recurrenceRule == null) { + put(CalendarContract.Events.DTEND, endMillis) + } else { + put(CalendarContract.Events.RRULE, event.recurrenceRule) + put( + CalendarContract.Events.DURATION, + importDuration(startMillis, endMillis, event.isAllDay), + ) + event.exDates.takeIf { it.isNotEmpty() } + ?.let { put(CalendarContract.Events.EXDATE, it.joinToString(",")) } + } + // All-day rows live at UTC midnights (the file already encodes them so); + // timed rows keep the event's own zone. + put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId) + put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue()) + put(CalendarContract.Events.STATUS, event.status.toProviderStatus()) + event.location?.trim()?.takeIf { it.isNotEmpty() } + ?.let { put(CalendarContract.Events.EVENT_LOCATION, it) } + event.description?.trim()?.takeIf { it.isNotEmpty() } + ?.let { put(CalendarContract.Events.DESCRIPTION, it) } + event.color?.let { color -> + val key = palette.nearestTo(color)?.key + putAll(eventColorColumns(colorKey = key, color = if (key == null) color else null)) + } +} + +/** + * Provider `DURATION` for an imported recurring row: whole days for an all-day + * series, seconds otherwise. An all-day series is never shorter than a day — + * the provider expands a zero-length one into no instances at all, so the + * series would vanish (which is what a literal read of the Fossify family's + * inclusive DTEND used to produce; see `IcsQuirks`). + */ +private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String { + val span = (endMillis - startMillis).coerceAtLeast(0) + return if (isAllDay) "P${(span / 86_400_000L).coerceAtLeast(1)}D" else "P${span / 1_000L}S" +} + +private fun EventStatus.toProviderStatus(): Int = when (this) { + EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED + EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE + EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt index 9b44fde..8365639 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt @@ -110,3 +110,17 @@ private const val CURATION_TRIGGER_SIZE = 36 * apart that two swatches never read as the same colour in the grid. */ private const val MIN_DISTANCE = 0.025f + +/** + * The palette entry closest to [argb], or null when the account publishes no + * palette (its calendars then take a raw `EVENT_COLOR` instead). + * + * Used to land a colour that came from outside the account — an imported `.ics` + * carries an arbitrary ARGB, while a palette calendar only accepts one of its + * own keys. Distance is measured in Oklab, so the match is the one that looks + * closest rather than the one that is closest in RGB coordinates. + */ +fun List.nearestTo(argb: Int): EventColorOption? { + val target = oklchOf(argb) + return minByOrNull { oklchOf(it.argb).distanceTo(target) } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsColor.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsColor.kt new file mode 100644 index 0000000..d6ddc63 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsColor.kt @@ -0,0 +1,80 @@ +package de.jeanlucmakiola.calendula.domain.ics + +/** Opaque alpha, forced onto any colour that arrives without one. */ +private const val OPAQUE_ALPHA = 0xFF000000.toInt() + +/** + * Resolve a colour value out of a foreign `.ics` to an opaque ARGB int. + * + * Three encodings are in the wild and all appear in the files Calendula is + * asked to import: + * - a CSS3 colour name, which is what RFC 7986 `COLOR` is defined as + * (`COLOR:tomato`); + * - a raw signed Android colour int, which the Simple Calendar / Fossify + * family writes in its `X-…-COLOR` extensions (`X-FOSSIFY-EVENT-COLOR:-2818048`); + * - `#rrggbb` / `#aarrggbb` hex, used by assorted other producers. + * + * Anything else — including the literal `null` Fossify emits when its calendar + * lookup misses — resolves to null. + */ +fun parseIcsColorValue(raw: String?): Int? { + val value = raw?.trim().orEmpty() + if (value.isEmpty()) return null + + if (value.startsWith("#")) { + val hex = value.substring(1) + val parsed = hex.toLongOrNull(16)?.toInt() ?: return null + return when (hex.length) { + 6 -> parsed or OPAQUE_ALPHA + 8 -> parsed + else -> null + } + } + value.toIntOrNull()?.let { return if (it ushr 24 == 0) it or OPAQUE_ALPHA else it } + return cssColors[value.lowercase()] +} + +/** The CSS3 extended colour keywords, the value space of RFC 7986 `COLOR`. */ +private val cssColors: Map by lazy { + CSS3_TABLE.split(' ').associate { entry -> + val name = entry.substringBefore('=') + name to (entry.substringAfter('=').toInt(16) or OPAQUE_ALPHA) + } +} + +private const val CSS3_TABLE = + "aliceblue=f0f8ff antiquewhite=faebd7 aqua=00ffff aquamarine=7fffd4 " + + "azure=f0ffff beige=f5f5dc bisque=ffe4c4 black=000000 blanchedalmond=ffebcd " + + "blue=0000ff blueviolet=8a2be2 brown=a52a2a burlywood=deb887 cadetblue=5f9ea0 " + + "chartreuse=7fff00 chocolate=d2691e coral=ff7f50 cornflowerblue=6495ed " + + "cornsilk=fff8dc crimson=dc143c cyan=00ffff darkblue=00008b darkcyan=008b8b " + + "darkgoldenrod=b8860b darkgray=a9a9a9 darkgreen=006400 darkgrey=a9a9a9 " + + "darkkhaki=bdb76b darkmagenta=8b008b darkolivegreen=556b2f darkorange=ff8c00 " + + "darkorchid=9932cc darkred=8b0000 darksalmon=e9967a darkseagreen=8fbc8f " + + "darkslateblue=483d8b darkslategray=2f4f4f darkslategrey=2f4f4f darkturquoise=00ced1 " + + "darkviolet=9400d3 deeppink=ff1493 deepskyblue=00bfff dimgray=696969 " + + "dimgrey=696969 dodgerblue=1e90ff firebrick=b22222 floralwhite=fffaf0 " + + "forestgreen=228b22 fuchsia=ff00ff gainsboro=dcdcdc ghostwhite=f8f8ff " + + "gold=ffd700 goldenrod=daa520 gray=808080 green=008000 greenyellow=adff2f " + + "grey=808080 honeydew=f0fff0 hotpink=ff69b4 indianred=cd5c5c indigo=4b0082 " + + "ivory=fffff0 khaki=f0e68c lavender=e6e6fa lavenderblush=fff0f5 lawngreen=7cfc00 " + + "lemonchiffon=fffacd lightblue=add8e6 lightcoral=f08080 lightcyan=e0ffff " + + "lightgoldenrodyellow=fafad2 lightgray=d3d3d3 lightgreen=90ee90 lightgrey=d3d3d3 " + + "lightpink=ffb6c1 lightsalmon=ffa07a lightseagreen=20b2aa lightskyblue=87cefa " + + "lightslategray=778899 lightslategrey=778899 lightsteelblue=b0c4de " + + "lightyellow=ffffe0 lime=00ff00 limegreen=32cd32 linen=faf0e6 magenta=ff00ff " + + "maroon=800000 mediumaquamarine=66cdaa mediumblue=0000cd mediumorchid=ba55d3 " + + "mediumpurple=9370db mediumseagreen=3cb371 mediumslateblue=7b68ee " + + "mediumspringgreen=00fa9a mediumturquoise=48d1cc mediumvioletred=c71585 " + + "midnightblue=191970 mintcream=f5fffa mistyrose=ffe4e1 moccasin=ffe4b5 " + + "navajowhite=ffdead navy=000080 oldlace=fdf5e6 olive=808000 olivedrab=6b8e23 " + + "orange=ffa500 orangered=ff4500 orchid=da70d6 palegoldenrod=eee8aa " + + "palegreen=98fb98 paleturquoise=afeeee palevioletred=db7093 papayawhip=ffefd5 " + + "peachpuff=ffdab9 peru=cd853f pink=ffc0cb plum=dda0dd powderblue=b0e0e6 " + + "purple=800080 rebeccapurple=663399 red=ff0000 rosybrown=bc8f8f royalblue=4169e1 " + + "saddlebrown=8b4513 salmon=fa8072 sandybrown=f4a460 seagreen=2e8b57 " + + "seashell=fff5ee sienna=a0522d silver=c0c0c0 skyblue=87ceeb slateblue=6a5acd " + + "slategray=708090 slategrey=708090 snow=fffafa springgreen=00ff7f " + + "steelblue=4682b4 tan=d2b48c teal=008080 thistle=d8bfd8 tomato=ff6347 " + + "turquoise=40e0d0 violet=ee82ee wheat=f5deb3 white=ffffff whitesmoke=f5f5f5 " + + "yellow=ffff00 yellowgreen=9acd32" 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 dc7e7e3..f35fe83 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 @@ -7,14 +7,25 @@ import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.number import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.math.roundToInt import kotlin.time.Instant +/** Milliseconds in a calendar day; all-day times are UTC midnights, so this is exact. */ +private const val DAY_MILLIS = 86_400_000L +private const val MINUTES_PER_DAY = 1_440 + /** * A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent], * but [uid] is nullable (an incoming event may carry none; the insert layer * then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror * how the writer encoded them. + * + * [reminderMinutes] holds raw lead times as the file's `VALARM`s expressed + * them — minutes *before* start, so a negative entry means "after start". + * [semanticReminderMinutes] turns those into the lead times Calendula models. */ data class ParsedIcsEvent( val uid: String?, @@ -30,8 +41,36 @@ data class ParsedIcsEvent( val status: EventStatus = EventStatus.Confirmed, val availability: Availability = Availability.Busy, val calendarName: String? = null, + /** Opaque ARGB the source file gave this event, if any (see [parseIcsColorValue]). */ + val color: Int? = null, + /** Excluded occurrences, already in the provider's `EXDATE` shape. */ + val exDates: List = emptyList(), + /** True when this came from a `VTODO` rather than a `VEVENT`. */ + val isTask: Boolean = false, ) +/** + * [reminderMinutes] mapped onto Calendula's model: a lead time in minutes before + * the event, never negative. + * + * For a timed event the raw lead time already is that. All-day reminders are + * whole days before the event, fired at the user's configured time of day (see + * `AllDayReminderEncoding`), so a raw offset is rounded to the nearest day — + * which is also what makes Fossify's all-day encoding land correctly: it writes + * "on the day at 09:00" as a *positive* `TRIGGER:P0DT9H0M0S`, i.e. nine hours + * after the UTC midnight, and that rounds to zero days before. + */ +fun ParsedIcsEvent.semanticReminderMinutes(): List = reminderMinutes + .map { raw -> + if (isAllDay) { + (raw.toDouble() / MINUTES_PER_DAY).roundToInt().coerceAtLeast(0) * MINUTES_PER_DAY + } else { + raw.coerceAtLeast(0) + } + } + .distinct() + .sorted() + /** Things the parser dropped rather than failing — surfaced in the import report. */ enum class IcsParseWarning { /** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */ @@ -45,6 +84,12 @@ enum class IcsParseWarning { /** A `TZID` couldn't be resolved against the device tz database (used local zone). */ UnknownTimezone, + + /** `VTODO` components were imported as events — Calendula has no task model. */ + TasksImportedAsEvents, + + /** A malformed `RRULE` was repaired or dropped (see [sanitizeRrule]). */ + RecurrenceRuleRepaired, } data class IcsParseResult( @@ -52,8 +97,16 @@ data class IcsParseResult( val warnings: Set, ) -/** Outcome of a bulk `.ics` import into one calendar. */ -data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int) +/** + * Outcome of a bulk `.ics` import into one calendar. [failed] counts events the + * provider rejected — the import continues past them, so a single unusable + * event can't cost the user the rest of the file. + */ +data class IcsImportSummary( + val imported: Int, + val skippedDuplicate: Int, + val failed: Int = 0, +) /** * Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and @@ -62,6 +115,9 @@ data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int) * (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings] * rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is * resolved against the OS tz database instead ([deviceZone] is the fallback). + * + * `VTODO` components are read as all-day/timed events: Calendula models no + * tasks, and dropping them would silently lose half of some exports. */ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) { @@ -69,37 +125,49 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault val lines = unfoldLines(text) val events = mutableListOf() val warnings = mutableSetOf() - var calendarName: String? = null + // Scanned up front rather than in document order: an X-WR-CALNAME after + // the first VEVENT still names the calendar every event came from. + val calendarName = lines.asSequence() + .mapNotNull(::parseContentLine) + .firstOrNull { it.name == "X-WR-CALNAME" } + ?.let { unescapeText(it.value).trim().ifEmpty { null } } var i = 0 while (i < lines.size) { val line = parseContentLine(lines[i]) if (line == null) { i++; continue } + val component = when { + line.isBegin("VEVENT") -> "VEVENT" + line.isBegin("VTODO") -> "VTODO" + else -> null + } when { - line.isBegin("VEVENT") -> { - val end = indexOfEnd(lines, i + 1, "VEVENT") - parseVevent(lines.subList(i + 1, end), calendarName, warnings) - ?.let(events::add) + component != null -> { + val end = indexOfEnd(lines, i + 1, component) + parseComponent( + body = lines.subList(i + 1, end), + fileCalendarName = calendarName, + warnings = warnings, + isTask = component == "VTODO", + )?.let(events::add) i = end + 1 } line.isBegin("VTIMEZONE") -> { // Skipped wholesale; TZIDs resolve against the OS tz database. i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1 } - line.name == "X-WR-CALNAME" -> { - calendarName = unescapeText(line.value).trim().ifEmpty { null } - i++ - } else -> i++ } } + if (events.any { it.isTask }) warnings.add(IcsParseWarning.TasksImportedAsEvents) return IcsParseResult(events, warnings) } - private fun parseVevent( + private fun parseComponent( body: List, fileCalendarName: String?, warnings: MutableSet, + isTask: Boolean, ): ParsedIcsEvent? { var uid: String? = null var summary = "" @@ -112,7 +180,10 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault var status = EventStatus.Confirmed var availability = Availability.Busy var calendarName = fileCalendarName + var eventColor: Int? = null + var calendarColor: Int? = null val reminders = mutableListOf() + val exDateLines = mutableListOf() var skipAsOverride = false var i = 0 @@ -129,9 +200,12 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault "UID" -> uid = line.value.trim().ifEmpty { null } "SUMMARY" -> summary = unescapeText(line.value) "DTSTART" -> dtStart = parseIcsDateTime(line, warnings) + // A VTODO has no DTSTART of its own in most producers' output. + "DUE" -> if (isTask && dtStart == null) dtStart = parseIcsDateTime(line, warnings) "DTEND" -> dtEnd = parseIcsDateTime(line, warnings) "DURATION" -> duration = line.value.trim() "RRULE" -> rrule = line.value.trim().ifEmpty { null } + "EXDATE" -> exDateLines.add(line) "LOCATION" -> location = unescapeText(line.value).ifEmpty { null } "DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null } "STATUS" -> status = mapIcsStatus(line.value) @@ -142,6 +216,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault "ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored) "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 + // Per-event colour, most specific encoding first (see parseIcsColorValue). + "X-FOSSIFY-EVENT-COLOR", "X-SMT-EVENT-COLOR" -> + eventColor = parseIcsColorValue(line.value) ?: eventColor + "COLOR" -> eventColor = eventColor ?: parseIcsColorValue(line.value) + // The *calendar's* colour. Kept as a fallback so a migration that + // folds several source calendars into one keeps them apart visually. + "X-FOSSIFY-CATEGORY-COLOR", "X-SMT-CATEGORY-COLOR", "CATEGORY_COLOR" -> + calendarColor = parseIcsColorValue(line.value) ?: calendarColor } i++ } @@ -154,33 +241,101 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault warnings.add(IcsParseWarning.EventWithoutStartSkipped) return null } - val end = dtEnd - ?: duration?.let { - start.copy( - instant = Instant.fromEpochMilliseconds( - start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it), - ), - ) - } - ?: start + val cleanRrule = sanitizeRrule(rrule) + if (rrule != null && cleanRrule != rrule.removePrefix("RRULE:").trim()) { + warnings.add(IcsParseWarning.RecurrenceRuleRepaired) + } + val end = resolveEnd(start, dtEnd, duration) return ParsedIcsEvent( uid = uid, summary = summary, start = start.instant, - end = end.instant, + end = end, isAllDay = start.isAllDay, zoneId = start.zoneId, - recurrenceRule = rrule, + recurrenceRule = cleanRrule, location = location, description = description, reminderMinutes = reminders.distinct(), status = status, availability = availability, calendarName = calendarName, + color = eventColor ?: calendarColor, + exDates = if (cleanRrule == null) emptyList() else normalizeExDates(exDateLines, start), + isTask = isTask, ) } - /** A VALARM's lead time in minutes before start, or null if not a usable relative trigger. */ + /** + * The event's end instant. + * + * All-day events get two RFC 5545 §3.6.1 rules applied that a literal read + * would miss: a `DATE`-valued `DTSTART` with no `DTEND` lasts one day, and — + * since the provider expands a zero-length all-day series into no instances + * at all — an all-day event may never end at or before it starts. + */ + private fun resolveEnd( + start: IcsDateTime, + dtEnd: IcsDateTime?, + duration: String?, + ): Instant { + val explicit = dtEnd?.instant ?: duration?.let { + Instant.fromEpochMilliseconds( + start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it), + ) + } + if (!start.isAllDay) return explicit ?: start.instant + return explicit?.takeIf { it > start.instant } ?: start.instant.plusDays(1) + } + + /** + * `EXDATE` values in the shape `Events.EXDATE` wants: `yyyyMMdd` for an + * all-day series, a UTC `yyyyMMddTHHmmssZ` stamp otherwise. + * + * Fossify stores its excluded occurrences as bare day codes and writes them + * 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. + */ + private fun normalizeExDates(lines: List, start: IcsDateTime): List { + if (lines.isEmpty()) return emptyList() + val startZone = runCatching { TimeZone.of(start.zoneId) }.getOrNull() ?: deviceZone + val startTime = start.instant.toLocalDateTime(startZone).time + 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) ?: 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)) + } + } + } + .distinct() + } + + private fun parseExDateTime(token: String, line: IcsContentLine): Instant? { + val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null + val zone = when { + token.endsWith("Z") -> TimeZone.UTC + else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() } + ?: deviceZone + } + return ldt.toInstant(zone) + } + + /** + * A `VALARM`'s offset from the event start, in minutes *before* it — so a + * trigger that fires after the start (which is how the Fossify family + * encodes "on the day at 09:00") comes back negative. Null when the trigger + * isn't a usable relative offset. + */ private fun parseAlarmMinutes(body: List): Int? { val trigger = body.asSequence() .mapNotNull { parseContentLine(it) } @@ -189,8 +344,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault // Absolute (DATE-TIME) triggers can't be expressed as a lead time. if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null val millis = parseRfc2445DurationMillis(trigger.value) - // Negative = before start (the normal case) → positive lead minutes. - return (-millis / 60_000L).toInt().coerceAtLeast(0) + return (-millis / 60_000L).toInt() } private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet): IcsDateTime? { @@ -218,6 +372,17 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault fun IcsContentLine.isBegin(component: String) = name == "BEGIN" && value.trim().equals(component, true) + 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 utcStamp(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) { + "%04d%02d%02dT%02d%02d%02dZ".format(year, month.number, day, hour, minute, second) + } + /** 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 new file mode 100644 index 0000000..5bec1d9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt @@ -0,0 +1,55 @@ +package de.jeanlucmakiola.calendula.domain.ics + +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", +) + +/** + * Make a foreign RRULE safe to hand `CalendarContract`, or return null when it + * can't be salvaged. + * + * The provider validates every RRULE through `EventRecurrence.parse`, which + * **throws** on a malformed rule — and the throw surfaces from `insert`, not + * from a later read. One bad rule in an imported file would therefore abort the + * write it rides on. Producers do emit such rules: Fossify's exporter writes a + * bare `;BYDAY=` whenever a weekly event carries no weekday mask + * (`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. + */ +fun sanitizeRrule(raw: String?): String? { + val rule = raw?.trim()?.removePrefix("RRULE:")?.trim().orEmpty() + if (rule.isEmpty()) return null + + var freqSeen = false + val parts = rule.split(';').mapNotNull { part -> + 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. + 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" + } + } + return if (freqSeen) parts.joinToString(";") else null +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt index 3e8ff04..d2609a9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt @@ -13,6 +13,11 @@ import kotlinx.datetime.toLocalDateTime * calendar, exactly like a fresh create — the user confirms the target and * reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day * handling (provider all-day times are UTC midnights with an exclusive end). + * + * [ParsedIcsEvent.color] is deliberately not carried over: picking the calendar + * is the first thing this form asks for, and that clears any colour (a raw ARGB + * is invalid on a calendar whose account publishes a palette). Bulk import, + * where the target is known up front, does keep it. */ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm { val (start, end) = if (isAllDay) { @@ -31,7 +36,7 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm { end = end, location = location.orEmpty(), description = description.orEmpty(), - reminders = reminderMinutes.distinct().sorted(), + reminders = semanticReminderMinutes(), availability = availability, rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt index 56e6602..3789c22 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt @@ -252,6 +252,15 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) { style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface, ) + if (state.summary.failed > 0) { + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.import_done_failed_note), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } if (state.summary.skippedDuplicate > 0) { Spacer(Modifier.height(8.dp)) Text( @@ -290,6 +299,19 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) { onContainer = MaterialTheme.colorScheme.onSurfaceVariant, ) } + if (state.summary.failed > 0) { + ImportStatCard( + count = state.summary.failed, + label = stringResource(R.string.import_done_failed_label), + contentDescription = pluralStringResource( + R.plurals.import_done_failed, + state.summary.failed, + state.summary.failed, + ), + container = MaterialTheme.colorScheme.errorContainer, + onContainer = MaterialTheme.colorScheme.onErrorContainer, + ) + } } Spacer(Modifier.weight(1f)) Button( @@ -301,7 +323,7 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) { } } -/** A big-number tonal tile summarising one import outcome (added / skipped). */ +/** A big-number tonal tile summarising one import outcome (added / skipped / failed). */ @Composable private fun RowScope.ImportStatCard( count: Int, @@ -343,6 +365,9 @@ private fun WarningText(warning: IcsParseWarning) { IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start) IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees) IcsParseWarning.UnknownTimezone -> stringResource(R.string.import_warning_timezone) + IcsParseWarning.TasksImportedAsEvents -> stringResource(R.string.import_warning_tasks) + IcsParseWarning.RecurrenceRuleRepaired -> + stringResource(R.string.import_warning_recurrence_repaired) } Text( text = text, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d8c06b4..474f614 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -693,11 +693,15 @@ Events already in the calendar were skipped. Added Duplicates + Not added + Some events couldn\'t be added and were left out. Close Some changed occurrences of recurring events were skipped. An event without a start time was skipped. Guest lists weren\'t imported. An unknown time zone fell back to your device\'s. + Tasks in this file were added as events. + A faulty repeat rule was repaired. Import Importing %d event @@ -719,6 +723,10 @@ Skipped %d already in this calendar. Skipped %d already in this calendar. + + %d event couldn\'t be added. + %d events couldn\'t be added. + New event Create a new event 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 1e022a6..25fe582 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 @@ -801,6 +801,42 @@ class CalendarRepositoryImplTest { assertThat(fake.importedEvents.map { it.second }).containsExactly(3L, 3L) } + @Test + fun `importEvents carries on past an event the provider rejects`( + @TempDir tempDir: Path, + ) = runTest { + // A foreign export can hold a row the provider throws on outright; that + // must cost the user one event, not the whole file (Codeberg #225). + val fake = FakeCalendarDataSource().apply { failingImportSummaries += "bad" } + val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined) + val events = listOf( + parsedEvent("a@x", summary = "good"), + parsedEvent("b@x", summary = "bad"), + parsedEvent("c@x", summary = "good"), + ) + + val summary = repo.importEvents(targetCalendarId = 3L, events = events) + + assertThat(summary.imported).isEqualTo(2) + assertThat(summary.failed).isEqualTo(1) + assertThat(fake.importedEvents.map { it.first.uid }).containsExactly("a@x", "c@x") + } + + @Test + fun `importEvents looks the target palette up once, not per event`( + @TempDir tempDir: Path, + ) = runTest { + var lookups = 0 + val fake = FakeCalendarDataSource().apply { + eventColorPaletteResult = { lookups++; emptyList() } + } + val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined) + + repo.importEvents(3L, List(5) { parsedEvent("e$it@x") }) + + assertThat(lookups).isEqualTo(1) + } + @Test fun `exportEvents forwards the chosen calendar-id subset to the data source`( @TempDir tempDir: Path, @@ -825,9 +861,10 @@ class CalendarRepositoryImplTest { assertThat(fake.lastExportableEventsCalendarIds).isNull() } - private fun parsedEvent(uid: String?) = de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent( + private fun parsedEvent(uid: String?, summary: String = "E") = + de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent( uid = uid, - summary = "E", + summary = summary, start = Instant.fromEpochMilliseconds(1_000_000_000L), end = Instant.fromEpochMilliseconds(1_000_003_600L), isAllDay = false, 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 a66fd40..22bca92 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 @@ -91,8 +91,17 @@ internal class FakeCalendarDataSource : CalendarDataSource { /** (event, targetCalendarId) pairs passed to [insertImportedEvent]. */ val importedEvents = mutableListOf>() - override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long { + /** Thrown instead of [writeError] for events whose summary is in this set. */ + val failingImportSummaries = mutableSetOf() + + override fun insertImportedEvent( + event: ParsedIcsEvent, + calendarId: Long, + allDayReminderTimeMinutes: Int, + colorPalette: List, + ): Long { writeError?.let { throw it } + if (event.summary in failingImportSummaries) error("rejected: ${event.summary}") importedEvents += event to calendarId return nextInsertId } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ImportedEventValuesTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ImportedEventValuesTest.kt new file mode 100644 index 0000000..3acd9f4 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ImportedEventValuesTest.kt @@ -0,0 +1,119 @@ +package de.jeanlucmakiola.calendula.data.calendar + +import android.provider.CalendarContract +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventColorOption +import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class ImportedEventValuesTest { + + private val dayStart = Instant.parse("2026-08-19T00:00:00Z") + + private fun allDay( + rrule: String? = null, + exDates: List = emptyList(), + color: Int? = null, + days: Int = 1, + ) = ParsedIcsEvent( + uid = "u@x", + summary = "Anna Birthday", + start = dayStart, + end = Instant.fromEpochMilliseconds(dayStart.toEpochMilliseconds() + days * 86_400_000L), + isAllDay = true, + zoneId = "UTC", + recurrenceRule = rrule, + exDates = exDates, + color = color, + ) + + private fun values(event: ParsedIcsEvent, palette: List = emptyList()) = + buildImportedEventValues(event, calendarId = 7L, uid = "u@x", palette = palette) + + @Test + fun `a one-off carries DTEND and no recurrence`() { + val v = values(allDay()) + assertThat(v[CalendarContract.Events.DTEND]) + .isEqualTo(dayStart.toEpochMilliseconds() + 86_400_000L) + assertThat(v).doesNotContainKey(CalendarContract.Events.DURATION) + assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(1) + assertThat(v[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC") + } + + @Test + fun `a recurring row carries DURATION instead of DTEND`() { + val v = values(allDay(rrule = "FREQ=YEARLY;INTERVAL=1")) + assertThat(v).doesNotContainKey(CalendarContract.Events.DTEND) + assertThat(v[CalendarContract.Events.RRULE]).isEqualTo("FREQ=YEARLY;INTERVAL=1") + assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D") + } + + @Test + fun `an all-day series is never zero days long`() { + // A degenerate span would expand into no instances at all — the series + // would simply not exist (Codeberg #225). + val v = values(allDay(rrule = "FREQ=YEARLY;INTERVAL=1", days = 0)) + assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D") + } + + @Test + fun `EXDATEs ride along with the recurrence`() { + val v = values(allDay(rrule = "FREQ=DAILY", exDates = listOf("20260820", "20260822"))) + assertThat(v[CalendarContract.Events.EXDATE]).isEqualTo("20260820,20260822") + } + + @Test + fun `EXDATEs are not written without a recurrence to exclude from`() { + val v = values(allDay(exDates = listOf("20260820"))) + assertThat(v).doesNotContainKey(CalendarContract.Events.EXDATE) + } + + @Test + fun `an imported colour is written raw when the account publishes no palette`() { + val v = values(allDay(color = -2818048)) + assertThat(v[CalendarContract.Events.EVENT_COLOR]).isEqualTo(-2818048) + assertThat(v[CalendarContract.Events.EVENT_COLOR_KEY]).isNull() + } + + @Test + fun `an imported colour snaps to the nearest published palette key`() { + val palette = listOf( + EventColorOption(key = "1", argb = 0xFF0000FF.toInt()), // blue + EventColorOption(key = "2", argb = 0xFFFF4500.toInt()), // orangered + EventColorOption(key = "3", argb = 0xFF008000.toInt()), // green + ) + // Tomato — visually an orangered, nothing like the blue or the green. + val v = values(allDay(color = 0xFFFF6347.toInt()), palette) + + assertThat(v[CalendarContract.Events.EVENT_COLOR_KEY]).isEqualTo("2") + // A raw colour alongside a key is what a palette calendar rejects. + assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR) + } + + @Test + fun `an event with no colour touches neither colour column`() { + val v = values(allDay()) + assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR) + assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY) + } + + @Test + fun `a timed event keeps its own zone and a seconds duration`() { + val start = Instant.parse("2026-08-19T08:00:00Z") + val event = ParsedIcsEvent( + uid = "u@x", + summary = "Standup", + start = start, + end = Instant.parse("2026-08-19T08:15:00Z"), + isAllDay = false, + zoneId = "Europe/Berlin", + recurrenceRule = "FREQ=WEEKLY", + ) + + val v = values(event) + assertThat(v[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin") + assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P900S") + assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(0) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsColorTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsColorTest.kt new file mode 100644 index 0000000..0df9328 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsColorTest.kt @@ -0,0 +1,40 @@ +package de.jeanlucmakiola.calendula.domain.ics + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class IcsColorTest { + + @Test + fun `a raw Android colour int round-trips`() { + assertThat(parseIcsColorValue("-2818048")).isEqualTo(-2818048) + } + + @Test + fun `a CSS3 name resolves opaque`() { + assertThat(parseIcsColorValue("tomato")).isEqualTo(0xFFFF6347.toInt()) + assertThat(parseIcsColorValue("REBECCAPURPLE")).isEqualTo(0xFF663399.toInt()) + } + + @Test + fun `hex forms are accepted with and without alpha`() { + assertThat(parseIcsColorValue("#ff6347")).isEqualTo(0xFFFF6347.toInt()) + assertThat(parseIcsColorValue("#80ff6347")).isEqualTo(0x80FF6347.toInt()) + } + + @Test + fun `a colour with no alpha byte is made opaque`() { + // 0x00FF6347 as a decimal int — an RGB triple that lost its alpha. + assertThat(parseIcsColorValue("16737095")).isEqualTo(0xFFFF6347.toInt()) + } + + @Test + fun `unusable values resolve to null`() { + assertThat(parseIcsColorValue(null)).isNull() + assertThat(parseIcsColorValue("")).isNull() + // Fossify interpolates a nullable calendar straight into the property. + assertThat(parseIcsColorValue("null")).isNull() + assertThat(parseIcsColorValue("chartreusey")).isNull() + assertThat(parseIcsColorValue("#abc")).isNull() + } +} 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 new file mode 100644 index 0000000..ce33cec --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsFossifyImportTest.kt @@ -0,0 +1,400 @@ +package de.jeanlucmakiola.calendula.domain.ics + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.TimeZone +import org.junit.jupiter.api.Test + +/** + * Reading the Simple Calendar / Fossify export dialect (Codeberg #225). + * + * Every fixture is shaped as `IcsExporter.writeEvent` emits it — property order, + * the `X-FOSSIFY-*` extensions and the `P0DT1H5M0S` duration spelling included. + * + * Note what is *not* asserted here: their all-day `DTEND` is RFC-correct. + * `Event.endTS` anchors an all-day event at noon of its last day + * (`EventActivity.getStartEndTimes`), or at the exclusive midnight for a + * CalDAV-sourced one, and the exporter's `+ TWELVE_HOURS` rounds either to the + * following midnight. Treating it as inclusive would push every imported all-day + * event out by a day. + */ +class IcsFossifyImportTest { + + private val parser = IcsParser(TimeZone.of("Europe/Berlin")) + + private fun fossify(vararg body: String) = ( + listOf( + "BEGIN:VCALENDAR", + "PRODID:-//Fossify//NONSGML Event Calendar//EN", + "VERSION:2.0", + ) + body + listOf("END:VCALENDAR") + ).joinToString("\r\n") + + private fun days(event: ParsedIcsEvent): Long = + (event.end - event.start).inWholeMilliseconds / 86_400_000L + + @Test + fun `an all-day event keeps the exact span the file gives it`() { + val result = parser.parse( + fossify( + "BEGIN:VEVENT", + "SUMMARY:Anna Birthday", + "UID:abc123", + "X-FOSSIFY-CATEGORY-COLOR:-1155931", + "DTSTART;VALUE=DATE:19900412", + "DTEND;VALUE=DATE:19900413", + "X-FOSSIFY-MISSING-YEAR:0", + "RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4", + "END:VEVENT", + ), + ) + + val event = result.events.single() + assertThat(event.isAllDay).isTrue() + assertThat(days(event)).isEqualTo(1) + } + + @Test + fun `a real Fossify holiday file imports unchanged`() { + // Shipped inside Fossify Calendar (assets/holidays/AT/public.ics). Its + // PRODID names Fossify, so any producer-sniffing shortcut would corrupt + // it — the file is plain, conformant iCalendar. + val text = checkNotNull( + javaClass.classLoader?.getResourceAsStream("ics/fossify-holidays-at.ics"), + ).use { it.readBytes().toString(Charsets.UTF_8) } + + val result = parser.parse(text) + + assertThat(result.events.map { it.summary }) + .containsExactly("Neujahr", "Heilige Drei Könige").inOrder() + assertThat(result.events.map { days(it) }).containsExactly(1L, 1L) + assertThat(result.events.map { it.recurrenceRule }).containsExactly("FREQ=YEARLY", "FREQ=YEARLY") + } + + @Test + fun `an all-day event with no DTEND lasts one day`() { + // RFC 5545 3.6.1, and the span the provider needs to expand a series. + val result = parser.parse( + fossify( + "BEGIN:VEVENT", + "SUMMARY:Holiday", + "DTSTART;VALUE=DATE:20260819", + "END:VEVENT", + ), + ) + + assertThat(days(result.events.single())).isEqualTo(1) + } + + @Test + fun `a zero-length all-day event is widened rather than left to vanish`() { + val result = parser.parse( + fossify( + "BEGIN:VEVENT", + "SUMMARY:Holiday", + "DTSTART;VALUE=DATE:20260819", + "DTEND;VALUE=DATE:20260819", + "END:VEVENT", + ), + ) + + assertThat(days(result.events.single())).isEqualTo(1) + } + + @Test + fun `a malformed repeat rule is repaired rather than passed to the provider`() { + val result = parser.parse( + fossify( + "BEGIN:VEVENT", + "SUMMARY:Standup", + "DTSTART:20260819T080000Z", + "DTEND:20260819T081500Z", + "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=", + "END:VEVENT", + ), + ) + + assertThat(result.events.single().recurrenceRule).isEqualTo("FREQ=WEEKLY;INTERVAL=1") + assertThat(result.warnings).contains(IcsParseWarning.RecurrenceRuleRepaired) + } + + @Test + fun `a task is imported as an event and reported`() { + val result = parser.parse( + fossify( + "BEGIN:VTODO", + "SUMMARY:Pay rent", + "DTSTART;VALUE=DATE:20260901", + "END:VTODO", + ), + ) + + val task = result.events.single() + assertThat(task.isTask).isTrue() + assertThat(task.summary).isEqualTo("Pay rent") + assertThat(days(task)).isEqualTo(1) + assertThat(result.warnings).contains(IcsParseWarning.TasksImportedAsEvents) + } + + @Test + fun `a task dated only by DUE still imports`() { + val result = parser.parse( + fossify( + "BEGIN:VTODO", + "SUMMARY:File taxes", + "DUE;VALUE=DATE:20260901", + "END:VTODO", + ), + ) + + assertThat(result.events.single().summary).isEqualTo("File taxes") + } + + @Test + fun `the per-event colour wins over the calendar colour`() { + val result = parser.parse( + fossify( + "BEGIN:VEVENT", + "SUMMARY:Holiday", + "X-FOSSIFY-CATEGORY-COLOR:-1155931", + "COLOR:tomato", + "X-FOSSIFY-EVENT-COLOR:-2818048", + "DTSTART:20260819T080000Z", + "DTEND:20260819T090000Z", + "END:VEVENT", + ), + ) + + assertThat(result.events.single().color).isEqualTo(-2818048) + } + + @Test + fun `the calendar colour stands in when the event has none of its own`() { + val result = parser.parse( + fossify( + "BEGIN:VEVENT", + "SUMMARY:Holiday", + "X-FOSSIFY-CATEGORY-COLOR:-1155931", + "DTSTART:20260819T080000Z", + "DTEND:20260819T090000Z", + "END:VEVENT", + ), + ) + + assertThat(result.events.single().color).isEqualTo(-1155931) + } + + @Test + fun `the legacy Simple Calendar colour spellings are read too`() { + val result = parser.parse( + listOf( + "BEGIN:VCALENDAR", + "PRODID:-//Simple Mobile Tools//NONSGML Event Calendar//EN", + "BEGIN:VEVENT", + "SUMMARY:Holiday", + "X-SMT-CATEGORY-COLOR:-1155931", + "DTSTART:20260819T080000Z", + "DTEND:20260819T090000Z", + "END:VEVENT", + "END:VCALENDAR", + ).joinToString("\r\n"), + ) + + assertThat(result.events.single().color).isEqualTo(-1155931) + } + + @Test + fun `the literal null Fossify writes for a missing calendar is not a colour`() { + // Both properties interpolate a nullable calendar straight into the value. + val result = parser.parse( + fossify( + "BEGIN:VEVENT", + "SUMMARY:Holiday", + "X-FOSSIFY-CATEGORY-COLOR:null", + "CATEGORIES:null", + "DTSTART:20260819T080000Z", + "DTEND:20260819T090000Z", + "END:VEVENT", + ), + ) + + val event = result.events.single() + assertThat(event.color).isNull() + assertThat(event.calendarName).isNull() + } + + @Test + fun `CATEGORIES names the source calendar`() { + val result = parser.parse( + fossify( + "BEGIN:VEVENT", + "SUMMARY:Anna Birthday", + "CATEGORIES:Birthdays", + "DTSTART;VALUE=DATE:20260819", + "DTEND;VALUE=DATE:20260820", + "END:VEVENT", + ), + ) + + assertThat(result.events.single().calendarName).isEqualTo("Birthdays") + } + + @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 + // (`sign = if (reminder.minutes < -1) "" else "-"`), so a trigger can + // point after the start. On an all-day event that means a time of day. + val result = parser.parse( + fossify( + "BEGIN:VEVENT", + "SUMMARY:Anna Birthday", + "DTSTART;VALUE=DATE:20260819", + "DTEND;VALUE=DATE:20260820", + "BEGIN:VALARM", + "ACTION:DISPLAY", + "TRIGGER:P0DT9H0M0S", + "END:VALARM", + "END:VEVENT", + ), + ) + + val event = result.events.single() + assertThat(event.reminderMinutes).containsExactly(-540) + assertThat(event.semanticReminderMinutes()).containsExactly(0) + } + + @Test + fun `an all-day reminder rounds to whole days before`() { + val result = parser.parse( + fossify( + "BEGIN:VEVENT", + "SUMMARY:Anna Birthday", + "DTSTART;VALUE=DATE:20260819", + "DTEND;VALUE=DATE:20260820", + "BEGIN:VALARM", + "ACTION:DISPLAY", + // 15h before midnight == "1 day before at 09:00". + "TRIGGER:-P0DT15H0M0S", + "END:VALARM", + "END:VEVENT", + ), + ) + + assertThat(result.events.single().semanticReminderMinutes()).containsExactly(1440) + } + + @Test + fun `a timed reminder keeps its exact lead time`() { + // Their P0DT1H5M0S duration spelling, from Parser.getDurationCode. + val result = parser.parse( + fossify( + "BEGIN:VEVENT", + "SUMMARY:Standup", + "DTSTART:20260819T080000Z", + "DTEND:20260819T081500Z", + "BEGIN:VALARM", + "ACTION:DISPLAY", + "TRIGGER:-P0DT0H10M0S", + "END:VALARM", + "END:VEVENT", + ), + ) + + assertThat(result.events.single().semanticReminderMinutes()).containsExactly(10) + } + + @Test + fun `an all-day series' EXDATE day codes carry over`() { + val result = parser.parse( + fossify( + "BEGIN:VEVENT", + "SUMMARY:Standup", + "DTSTART;VALUE=DATE:20260819", + "DTEND;VALUE=DATE:20260820", + "RRULE:FREQ=DAILY;INTERVAL=1", + "EXDATE:20260820", + "EXDATE:20260822", + "END:VEVENT", + ), + ) + + assertThat(result.events.single().exDates).containsExactly("20260820", "20260822").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 + // way even for a timed series, where the property alone is ambiguous. + val result = parser.parse( + fossify( + "BEGIN:VEVENT", + "SUMMARY:Gym", + "DTSTART:20260819T170000Z", + "DTEND:20260819T180000Z", + "RRULE:FREQ=DAILY;INTERVAL=1", + "EXDATE:20260820", + "END:VEVENT", + ), + ) + + assertThat(result.events.single().exDates).containsExactly("20260820T170000Z") + } + + @Test + fun `EXDATE is dropped along with an unusable recurrence rule`() { + val result = parser.parse( + fossify( + "BEGIN:VEVENT", + "SUMMARY:Gym", + "DTSTART:20260819T170000Z", + "DTEND:20260819T180000Z", + "RRULE:INTERVAL=1", + "EXDATE:20260820", + "END:VEVENT", + ), + ) + + val event = result.events.single() + assertThat(event.recurrenceRule).isNull() + assertThat(event.exDates).isEmpty() + } + + @Test + fun `a whole export imports every component`() { + val result = parser.parse( + fossify( + "BEGIN:VEVENT", + "SUMMARY:Anna Birthday", + "UID:abc123", + "X-FOSSIFY-CATEGORY-COLOR:-1155931", + "CATEGORIES:Birthdays", + "LAST-MODIFIED:20260101T120000Z", + "TRANSP:TRANSPARENT", + "DTSTART;VALUE=DATE:19900412", + "DTEND;VALUE=DATE:19900413", + "X-FOSSIFY-MISSING-YEAR:0", + "DTSTAMP:20260819T100000Z", + "CLASS:PUBLIC", + "STATUS:CONFIRMED", + "RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4", + "END:VEVENT", + "BEGIN:VEVENT", + "SUMMARY:Standup", + "UID:def456", + "DTSTART:20260819T080000Z", + "DTEND:20260819T081500Z", + "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU", + "END:VEVENT", + "BEGIN:VTODO", + "SUMMARY:Pay rent", + "UID:ghi789", + "DTSTART;VALUE=DATE:20260901", + "END:VTODO", + ), + ) + + assertThat(result.events.map { it.summary }) + .containsExactly("Anna Birthday", "Standup", "Pay rent").inOrder() + // Nothing may reach the provider as a zero-length all-day row. + assertThat(result.events.none { it.isAllDay && days(it) == 0L }).isTrue() + } +} 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 new file mode 100644 index 0000000..93ea9c1 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrenceTest.kt @@ -0,0 +1,52 @@ +package de.jeanlucmakiola.calendula.domain.ics + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +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") + } + + @Test + fun `the RRULE prefix is stripped`() { + assertThat(sanitizeRrule("RRULE:FREQ=DAILY")).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=")) + .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") + } + + @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() + } + + @Test + fun `FREQ is normalised to upper case`() { + assertThat(sanitizeRrule("freq=daily;count=3")).isEqualTo("FREQ=DAILY;COUNT=3") + } + + @Test + fun `unknown parts pass through untouched`() { + assertThat(sanitizeRrule("FREQ=YEARLY;WKST=SU;X-THING=7")) + .isEqualTo("FREQ=YEARLY;WKST=SU;X-THING=7") + } +} diff --git a/app/src/test/resources/ics/fossify-holidays-at.ics b/app/src/test/resources/ics/fossify-holidays-at.ics new file mode 100644 index 0000000..dd1f1f4 --- /dev/null +++ b/app/src/test/resources/ics/fossify-holidays-at.ics @@ -0,0 +1,25 @@ +BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +PRODID:Fossify Calendar Holiday Generator +METHOD:PUBLISH +X-PUBLISHED-TTL:PT1H +BEGIN:VEVENT +UID:fossify_c10f9ad245dc2e57fb3652abee6ed8a06744f0bd +SUMMARY:Neujahr +DTSTAMP:20260526T132315Z +DTSTART;VALUE=DATE:19800101 +DTEND;VALUE=DATE:19800102 +STATUS:CONFIRMED +RRULE:FREQ=YEARLY +END:VEVENT +BEGIN:VEVENT +UID:fossify_8a60ccf65b65265f768dd33a0b7aefc7620e3ee2 +SUMMARY:Heilige Drei Könige +DTSTAMP:20260526T132315Z +DTSTART;VALUE=DATE:19800106 +DTEND;VALUE=DATE:19800107 +STATUS:CONFIRMED +RRULE:FREQ=YEARLY +END:VEVENT +END:VCALENDAR diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d013789..8f7b2c0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -346,6 +346,53 @@ a latch that disables its own scheduling once a real broadcast arrives — canno be copied, because our failure mode includes a broadcast that arrives with no alert row behind it. +## Importing foreign `.ics` + +`IcsParser` is deliberately liberal, because the files it is handed were written +by other people's calendars. What that costs us, learned from reading the Simple +Calendar / Fossify exporter (#225): + +**An all-day event may never reach the provider zero days long.** RFC 5545 +§3.6.1 gives a `DATE`-valued `DTSTART` with no `DTEND` a one-day duration, and a +`DTEND` equal to `DTSTART` is meaningless — but the failure is silent rather than +loud: the provider expands a zero-length series into no instances at all, so the +event is simply not there, with nothing logged. `resolveEnd` floors the span at a +day and `buildImportedEventValues` floors an all-day `DURATION` at `P1D`. + +Resist the urge to correct a producer's all-day `DTEND` by sniffing its `PRODID`. +Fossify's looks like it needs it and does not: `Event.endTS` anchors an all-day +event at *noon* of its last day (`EventActivity.getStartEndTimes`), or at the +exclusive midnight when the row came from CalDAV, and the exporter's +`+ TWELVE_HOURS` rounds either to the following midnight — correct on both paths. +Shifting it would push every imported all-day event out by a day. The holiday +files bundled inside Fossify are a second trap: their `PRODID` reads +`Fossify Calendar Holiday Generator` and their content is plain conformant +iCalendar. One is kept as a fixture in `app/src/test/resources/ics`. + +**One bad event must not cost the file.** The provider validates `RRULE` through +`EventRecurrence.parse`, which *throws* — out of `insert`, not out of a later +read. `sanitizeRrule` drops empty and malformed parts before the write, and +`CalendarRepositoryImpl.importEvents` isolates each event, counting rejects into +`IcsImportSummary.failed` rather than unwinding the batch and reporting only +"couldn't read this file". + +Smaller dialect handling: `VTODO` components import as events (Calendula models +no tasks; dropping them silently lost half of some exports), `CATEGORIES` stands +in for the `X-WR-CALNAME` Fossify never writes, bare `EXDATE` day codes on a +timed series are resolved against the series' own time of day, and a `VALARM` +trigger pointing *after* the start — which is how that family encodes "on the day +at 09:00" — is read as zero days before rather than clamped to a lead time of +zero, so all-day reminders fire at the hour the user's own setting names instead +of at UTC midnight. + +Colour arrives as a raw ARGB from an app with no idea which account it is landing +in. A calendar whose account publishes a palette rejects a raw `EVENT_COLOR`, so +`buildImportedEventValues` snaps the imported colour to the nearest published key +(`nearestTo`, measured in Oklab) and writes the raw value only where there is no +palette. The single-event review form deliberately keeps no colour at all: its +first question is which calendar to use, and answering it clears the colour +anyway. + ## Testing JUnit 5 + Truth + Turbine on the JVM. The seams that make it work: