diff --git a/.gitattributes b/.gitattributes index 9ac4914..9e860d9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,4 @@ *.jpg binary *.gif binary *.webp binary +app/src/test/resources/ics/*.ics -text diff --git a/CHANGELOG.md b/CHANGELOG.md index 37658c0..8e6eb2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 saves the edit as its own event instead, which is what you see either way. A save that does fail also says so for longer, rather than flashing past ([#234]). +- **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]). +- **All-day reminders survive a backup and an import.** They fire at the hour you + chose, not at midnight UTC — the same rule the app already applied to all-day + events you create yourself — and an export no longer leaves them out of the + file entirely. An imported yearly birthday fires at the right hour too, even + though the file dates it back to the year of birth ([#225]). +- **Birthdays and anniversaries imported from Fossify Calendar now show up.** + Fossify writes the ones it mirrors from your contacts as events that start and + end on the same day, which the calendar read as lasting no time at all: they + imported without complaint and then appeared nowhere. Any all-day event that + ends where it starts, or carries no end at all, is now a one-day event + ([#225]). +- **A backup no longer brings back occurrences you had deleted.** Remove a single + occurrence of a repeating event, back up, restore — and it was there again, + because the removals never made it into the file. They travel with the series + now, so a restored calendar looks the way you left it ([#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, a calendar named like the one the file came from is preselected, + 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.3] — 2026-08-22 @@ -1491,5 +1519,6 @@ 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 [#228]: https://codeberg.org/jlmakiola/calendula/issues/228 [#234]: https://codeberg.org/jlmakiola/calendula/issues/234 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncoding.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncoding.kt index 94021e6..6aa6e1d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncoding.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncoding.kt @@ -70,6 +70,34 @@ internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): Local return LocalDate.of(today.year, month, day) } +/** + * The date an **imported** all-day event's reminder offset is sampled at. + * + * A one-off fires once, on its own date. A series' `DTSTART` is only an anchor + * and may be ancient — Fossify writes a year-less contact birthday at 1970, a + * year whose rules predate DST across most of Europe, and a real birth year is + * usually older still — so sampling there skews every modern occurrence by the + * offset delta (the same trap [nextYearlyOccurrence] exists for). A yearly + * series is therefore sampled at its next occurrence of the anchor's month/day, + * which shares the season; any other series at [today], which its upcoming + * occurrences are near. + * + * The decode side ([fromProviderAllDayMinutes]) only asks which local *day* the + * encoded instant falls on, so it still reads the lead time back correctly from + * the anchor's own date. + */ +internal fun importedAllDayReminderDate( + startDate: LocalDate, + recurrenceRule: String?, + today: LocalDate, +): LocalDate = when { + recurrenceRule.isNullOrBlank() -> startDate + recurrenceRule.contains("FREQ=YEARLY", ignoreCase = true) -> + nextYearlyOccurrence(startDate.monthValue, startDate.dayOfMonth, today) + startDate.isBefore(today) -> today + else -> startDate +} + /** * Recover the semantic whole-day lead time from a raw all-day reminder * [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 503bc6e..422cdc5 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 @@ -84,14 +85,25 @@ interface CalendarDataSource { */ fun eventColorPalette(calendarId: Long): List + /** + * The same palette **uncurated** — every key the account publishes, in + * provider order. Curation is a display concession (it folds look-alikes and + * drops the neutrals outright from an oversized palette), so matching a + * colour that came from outside the account has to run against the full set: + * those keys are all the calendar accepts. See [eventColorPalette]. + */ + fun publishedEventColors(calendarId: Long): List + /** * Every master/one-off event of the writable local calendars, mapped for a * whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception * rows are excluded (see [EventExportProjection]). When [calendarIds] is * given, only those calendars are exported (still intersected with the * eligible set); `null` exports every eligible calendar. + * [allDayReminderTimeMinutes]: needed to write all-day reminders as whole-day + * lead times rather than raw provider offsets (see [toIcsEvent]). */ - fun exportableEvents(calendarIds: Set? = null): List + fun exportableEvents(calendarIds: Set?, allDayReminderTimeMinutes: Int): List /** * The non-empty `Events.UID_2445` values present in [calendarId] — used to @@ -101,10 +113,19 @@ 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 + * ([publishedEventColors]), looked up once per import; + * [allDayReminderTimeMinutes] is the user's preferred all-day firing time, + * applied exactly as a hand-created event's is. */ - fun insertImportedEvent(event: ParsedIcsEvent, 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; @@ -726,7 +747,10 @@ class AndroidCalendarDataSource @Inject constructor( } } - override fun eventColorPalette(calendarId: Long): List { + override fun eventColorPalette(calendarId: Long): List = + publishedEventColors(calendarId).curatedForPicker() + + override fun publishedEventColors(calendarId: Long): List { val account = calendarAccount(calendarId) ?: return emptyList() return resolver.query( CalendarContract.Colors.CONTENT_URI, @@ -744,11 +768,13 @@ class AndroidCalendarDataSource @Inject constructor( c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) } } ?.filter { it.key.isNotEmpty() } - ?.curatedForPicker() ?: emptyList() } - override fun exportableEvents(calendarIds: Set?): List { + override fun exportableEvents( + calendarIds: Set?, + allDayReminderTimeMinutes: Int, + ): List { // Only the local calendars the app owns and can write — synced calendars // already have a backup (their server). Exclude the managed special-dates // mirror calendars: their events are derived from contacts, not authored @@ -783,6 +809,7 @@ class AndroidCalendarDataSource @Inject constructor( reader.toIcsEvent( reminderMinutes = queryReminders(eventId).map { it.minutes }, calendarName = names[calendarId], + allDayReminderTimeMinutes = allDayReminderTimeMinutes, ) } } ?: emptyList() @@ -791,55 +818,64 @@ class AndroidCalendarDataSource @Inject constructor( override fun existingUids(calendarId: Long): Set = resolver.query( CalendarContract.Events.CONTENT_URI, arrayOf(CalendarContract.Events.UID_2445), + // DELETED rows linger until a sync adapter purges them; counting those + // as present would make a re-import skip everything the user has since + // deleted, reporting "all duplicates" and importing nothing. "${CalendarContract.Events.CALENDAR_ID} = ? AND " + - "${CalendarContract.Events.UID_2445} IS NOT NULL", + "${CalendarContract.Events.UID_2445} IS NOT NULL AND " + + "${CalendarContract.Events.DELETED} = 0", arrayOf(calendarId.toString()), null, )?.use { c -> 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 zone = ZoneId.systemDefault() + // An all-day reminder is stored the same way a hand-created one is, so it + // fires at the time the user picked rather than at UTC midnight — sampled + // where it will actually fire, not at an ancient recurrence anchor + // (see [importedAllDayReminderDate]). + val reminderDate = if (event.isAllDay) { + importedAllDayReminderDate( + startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds()) + .atZone(ZoneOffset.UTC).toLocalDate(), + recurrenceRule = event.recurrenceRule, + today = LocalDate.now(zone), + ) + } else { + null + } + event.semanticReminderMinutes().forEach { minutes -> + val providerMinutes = if (reminderDate != null) { + toProviderAllDayMinutes( + semanticMinutes = minutes, + startDate = reminderDate, + zone = zone, + 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) { @@ -849,18 +885,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..0af9826 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 @@ -210,26 +212,43 @@ class CalendarRepositoryImpl @Inject constructor( } override suspend fun exportEvents(calendarIds: Set?) = - withContext(io) { dataSource.exportableEvents(calendarIds) } + withContext(io) { dataSource.exportableEvents(calendarIds, allDayReminderTimeMinutes()) } override suspend fun importEvents( targetCalendarId: Long, 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. The palette is the + // uncurated one — an imported colour is matched against every key the + // account accepts, not the subset the picker shows. + val palette = dataSource.publishedEventColors(targetCalendarId) + val allDayMinutes = allDayReminderTimeMinutes() var imported = 0 var skipped = 0 + 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 +307,10 @@ 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 8380fba..3696b5c 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 @@ -531,3 +535,73 @@ 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 "Importing foreign .ics" in + * docs/ARCHITECTURE.md). + */ +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/data/calendar/IcsExportMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt index ce8befa..90a1a3a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt @@ -5,6 +5,9 @@ import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.ics.IcsEvent import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset /** * Map one Events row (read through [EventExportProjection]) into an [IcsEvent] @@ -12,14 +15,23 @@ import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis * [calendarName] the display name of its calendar (emitted as * `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with * MapColumnReader. + * + * An all-day row's raw offset has the firing time of day encoded into it and is + * normally *negative* (see [AllDayReminderEncoding]); written out literally it + * would be dropped as a trigger that fires after the event, losing the reminder. + * [allDayReminderTimeMinutes] and [zone] decode it back to the whole-day lead + * time the file should carry, which an import re-encodes for its own device. */ internal fun ColumnReader.toIcsEvent( reminderMinutes: List, calendarName: String?, + allDayReminderTimeMinutes: Int, + zone: ZoneId = ZoneId.systemDefault(), ): IcsEvent { val eventId = getLong(EventExportProjection.IDX_ID) val dtStart = getLong(EventExportProjection.IDX_DTSTART) val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() } + val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0 // Recurring rows store DURATION instead of DTEND; reconstruct the end from it // so the writer can render DTEND. A missing/blank both means a zero-length event. @@ -40,16 +52,44 @@ internal fun ColumnReader.toIcsEvent( summary = getString(EventExportProjection.IDX_TITLE).orEmpty(), start = dtStart.toKotlinInstantFromEpochMillis(), end = end.toKotlinInstantFromEpochMillis(), - isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0, + isAllDay = isAllDay, zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() } ?: "UTC", recurrenceRule = rrule, + exDates = if (rrule == null) { + emptyList() + } else { + exportExDates(getString(EventExportProjection.IDX_EXDATE), isAllDay) + }, location = getString(EventExportProjection.IDX_LOCATION), description = getString(EventExportProjection.IDX_DESCRIPTION), - reminderMinutes = reminderMinutes, + reminderMinutes = if (isAllDay) { + val startDate = Instant.ofEpochMilli(dtStart).atZone(ZoneOffset.UTC).toLocalDate() + reminderMinutes.map { + fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes) + } + } else { + reminderMinutes + }, status = status, availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)), calendarName = calendarName, ) } +/** + * The row's `EXDATE` as the writer wants it: one stamp per entry, empties + * dropped. + * + * An all-day exclusion names a calendar day, and sync adapters are inconsistent + * about whether they write it as a bare `yyyyMMdd` or pad it to a midnight + * stamp; the time part is dropped so the exported value is the day either way. + * Calendula's own writes ([buildOccurrenceExdateValues]) are already bare. + */ +internal fun exportExDates(exdate: String?, isAllDay: Boolean): List = exdate + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.map { if (isAllDay) it.substringBefore('T') else it } + ?.distinct() + .orEmpty() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index 264c5ac..b535aee 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -121,10 +121,12 @@ internal object EventDetailProjection { /** * Master/one-off Events rows for a whole-calendar backup. Unlike * [EventDetailProjection] this reads `UID_2445` (to keep a row's identity across - * backups) and `DURATION` (recurring rows carry it instead of DTEND). Modified- + * backups) and `DURATION` (recurring rows carry it instead of DTEND), plus + * `EXDATE` so a series exports the occurrences deleted from it. Modified- * occurrence and cancelled-exception rows are filtered out by the query - * (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides and EXDATEs aren't - * exported yet — a documented v1 limit (import skips them too). + * (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides aren't exported — a + * documented v1 limit (import skips them too). EXDATE is unaffected by that + * filter: it lives on the master row, not on an exception of its own. */ internal object EventExportProjection { val COLUMNS: Array = arrayOf( @@ -137,6 +139,7 @@ internal object EventExportProjection { CalendarContract.Events.ALL_DAY, CalendarContract.Events.EVENT_TIMEZONE, CalendarContract.Events.RRULE, + CalendarContract.Events.EXDATE, CalendarContract.Events.EVENT_LOCATION, CalendarContract.Events.DESCRIPTION, CalendarContract.Events.STATUS, @@ -153,11 +156,12 @@ internal object EventExportProjection { const val IDX_ALL_DAY = 6 const val IDX_EVENT_TIMEZONE = 7 const val IDX_RRULE = 8 - const val IDX_LOCATION = 9 - const val IDX_DESCRIPTION = 10 - const val IDX_STATUS = 11 - const val IDX_AVAILABILITY = 12 - const val IDX_CALENDAR_ID = 13 + const val IDX_EXDATE = 9 + const val IDX_LOCATION = 10 + const val IDX_DESCRIPTION = 11 + const val IDX_STATUS = 12 + const val IDX_AVAILABILITY = 13 + const val IDX_CALENDAR_ID = 14 } /** 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/IcsEvent.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsEvent.kt index 6e82538..19da17b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsEvent.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsEvent.kt @@ -22,6 +22,12 @@ data class IcsEvent( val zoneId: String, /** Bare RRULE value (no `RRULE:` prefix), or null for a one-off event. */ val recurrenceRule: String? = null, + /** + * Occurrences deleted from the series, in the provider's `EXDATE` shape — + * `yyyyMMdd` for an all-day series, a UTC `yyyyMMddTHHmmssZ` stamp + * otherwise. Empty for a one-off event. + */ + val exDates: List = emptyList(), val location: String? = null, val description: String? = null, /** Reminder lead times in minutes before start (raw provider offsets). */ 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..c49c5ff 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.ceil 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,45 @@ 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, and a trigger that fires + * *after* the start models no lead time at all — it is dropped rather than + * clamped, which would invent a reminder at the start the file never asked for + * (a legal `TRIGGER:PT30M` follow-up alarm is the case). All-day reminders are + * whole days before the event, fired at the user's configured time of day (see + * `AllDayReminderEncoding`), so a raw offset counts the whole days its trigger + * lands *earlier than* the event's UTC midnight — i.e. it rounds **up**. + * + * Rounding to the nearest day instead would lose a day for every producer whose + * all-day notifications fire after noon: a file's raw offset is + * `days × 1440 − timeOfDay`, so "1 day before at 18:00" arrives as + * `TRIGGER:-PT6H` (raw 360) and would read as "on the day". Rounding up also + * lands Fossify's encoding correctly — it writes "on the day at 09:00" as a + * *positive* `TRIGGER:P0DT9H0M0S`, nine hours after the UTC midnight, which + * ceils to zero days before. + */ +fun ParsedIcsEvent.semanticReminderMinutes(): List = reminderMinutes + .mapNotNull { raw -> + if (isAllDay) { + ceil(raw.toDouble() / MINUTES_PER_DAY).toInt().coerceAtLeast(0) * MINUTES_PER_DAY + } else { + raw.takeIf { it >= 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 +93,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 +106,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 +124,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,42 +134,55 @@ 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 = "" var dtStart: IcsDateTime? = null var dtEnd: IcsDateTime? = null + var due: IcsDateTime? = null var duration: String? = null var rrule: String? = null var location: String? = null @@ -112,7 +190,11 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault var status = EventStatus.Confirmed var availability = Availability.Busy var calendarName = fileCalendarName + var category: String? = null + var eventColor: Int? = null + var calendarColor: Int? = null val reminders = mutableListOf() + val exDateLines = mutableListOf() var skipAsOverride = false var i = 0 @@ -129,9 +211,11 @@ 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) + "DUE" -> if (isTask) due = 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 +226,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. Elsewhere CATEGORIES is a plain tag list, so it only + // stands in where nothing named the calendar outright. + "CATEGORIES" -> category = singleCategory(line.value) ?: category + // Per-event colour, most specific encoding first (see parseIcsColorValue). + "X-FOSSIFY-EVENT-COLOR", "X-SMT-EVENT-COLOR" -> + eventColor = parseIcsColorValue(line.value) ?: eventColor + "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++ } @@ -150,37 +247,125 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault warnings.add(IcsParseWarning.ModifiedOccurrenceSkipped) return null } + // Most producers give a VTODO only a DUE, which is then the moment the + // task sits at. One that carries a DTSTART too means the span between + // them, so DUE stands in for the end it has no DTEND for. Resolved after + // the sweep rather than inside it: property order isn't guaranteed, and + // a DUE ahead of the DTSTART would otherwise be taken for the start. + if (isTask && due != null) { + if (dtStart == null) dtStart = due else if (dtEnd == null) dtEnd = due + } val start = dtStart ?: run { warnings.add(IcsParseWarning.EventWithoutStartSkipped) return null } - val end = dtEnd - ?: duration?.let { - start.copy( - instant = Instant.fromEpochMilliseconds( - start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it), - ), - ) - } - ?: start + val recurrence = sanitizeRrule(rrule) + if (recurrence.repaired) 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 = recurrence.rule, location = location, description = description, reminderMinutes = reminders.distinct(), status = status, availability = availability, - calendarName = calendarName, + calendarName = calendarName ?: category, + color = eventColor ?: calendarColor, + exDates = if (recurrence.rule == 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. + * + * An all-day exclusion names a calendar day, so a zone-qualified DATE-TIME + * still means the day it spells out; resolving it to an instant first and + * reading *that* back in UTC would roll a midnight east of UTC onto the day + * before and exclude an occurrence that doesn't exist. + */ + private fun normalizeExDates(lines: List, start: IcsDateTime): List { + if (lines.isEmpty()) return emptyList() + 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) -> + when { + token.isEmpty() -> null + start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.let(::dayCode) + token.contains('T') -> parseExDateTime(token, line, startZone)?.let(::utcStamp) + else -> parseBasicDate(token) + ?.let { utcStamp(LocalDateTime(it, startTime).toInstant(startZone)) } + } + } + .distinct() + } + + /** + * [startZone] is the series' own zone, which a floating value (no `Z`, no + * `TZID`) has to be read in — RFC 5545 ties `EXDATE` to `DTSTART`'s form, + * and reading it in the device's zone instead would put the exclusion on an + * instant no occurrence has. + */ + private fun parseExDateTime( + token: String, + line: IcsContentLine, + startZone: TimeZone, + ): 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() } + ?: startZone + } + 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 +374,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 +402,34 @@ 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 dayCode(date: LocalDate): String = + "%04d%02d%02d".format(date.year, date.month.number, date.day) + + fun utcStamp(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) { + "%04d%02d%02dT%02d%02d%02dZ".format(year, month.number, day, hour, minute, second) + } + + /** + * A single-valued `CATEGORIES`, which is what the Fossify family writes + * for the source calendar's name. A multi-item list is a tag list and + * names no calendar, so it is ignored. + */ + fun singleCategory(raw: String): String? { + var escaped = false + for (c in raw) { + when { + escaped -> escaped = false + c == '\\' -> escaped = true + c == ',' -> return null + } + } + return unescapeText(raw).trim() + .takeIf { it.isNotEmpty() && !it.equals("null", true) } + } + /** Index of the matching `END:` at/after [from], or list end. */ fun indexOfEnd(lines: List, from: Int, component: String): Int { var i = from diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt new file mode 100644 index 0000000..7f67573 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt @@ -0,0 +1,132 @@ +package de.jeanlucmakiola.calendula.domain.ics + +private val VALID_FREQ = setOf( + "SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY", +) + +private val VALID_WEEKDAYS = setOf("SU", "MO", "TU", "WE", "TH", "FR", "SA") + +/** + * What `EventRecurrence` accepts in each numeric list part: a value range, plus + * whether `0` is legal. Its `parseNumberList` throws on a non-number, on an item + * outside the range *and* on a zero in the by-position parts, so checking only + * that an item is a number would still hand the provider a rule it rejects. + * + * `BYSETPOS` is the one part it leaves unbounded; `0` is dropped from it anyway + * because RFC 5545 §3.3.10 gives it no meaning. + */ +private val NUMERIC_LIST_PARTS = mapOf( + "BYSECOND" to NumericPart(0..59, zeroAllowed = true), + "BYMINUTE" to NumericPart(0..59, zeroAllowed = true), + "BYHOUR" to NumericPart(0..23, zeroAllowed = true), + "BYMONTHDAY" to NumericPart(-31..31, zeroAllowed = false), + "BYYEARDAY" to NumericPart(-366..366, zeroAllowed = false), + "BYWEEKNO" to NumericPart(-53..53, zeroAllowed = false), + "BYMONTH" to NumericPart(1..12, zeroAllowed = false), + "BYSETPOS" to NumericPart(Int.MIN_VALUE..Int.MAX_VALUE, zeroAllowed = false), +) + +private class NumericPart(private val range: IntRange, private val zeroAllowed: Boolean) { + fun accepts(item: String): Boolean { + val n = item.toIntOrNull() ?: return false + return n in range && (zeroAllowed || n != 0) + } +} + +/** `UNTIL` in RFC 5545 basic format; anything else fails at expansion time. */ +private val UNTIL_SHAPE = Regex("""\d{8}(T\d{6}Z?)?""") + +/** + * A foreign RRULE made safe to hand `CalendarContract`. + * + * [rule] is what survived, or null when there is nothing left to salvage. + * [repaired] is true only when a part was actually dropped — pure normalisation + * (case, a stray separator) doesn't count, so a conformant file is never + * reported to the user as faulty. + */ +data class SanitizedRrule(val rule: String?, val repaired: Boolean) + +/** + * Salvage a foreign RRULE. + * + * The provider validates every RRULE through `EventRecurrence.parse`, which + * **throws** on a malformed rule — and the throw surfaces from `insert`, not + * 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. + * + * That parser has a fixed table of parts and throws on any name outside it, so + * this is a whitelist: an unrecognised part (RFC 7529 `RSCALE`, a vendor `X-` + * extension) and an unusable value are dropped rather than passed on to fail the + * whole event. Only a missing or unrecognised `FREQ` — without which there is no + * rule at all — gives up entirely. + */ +fun sanitizeRrule(raw: String?): SanitizedRrule { + val rule = raw?.trim()?.removePrefix("RRULE:")?.trim().orEmpty() + if (rule.isEmpty()) return SanitizedRrule(rule = null, repaired = false) + + var freqSeen = false + var repaired = false + val parts = mutableListOf() + + /** The items [valid] accepts, uppercased and comma-joined; null if none survive. */ + fun keepItems(value: String, valid: (String) -> Boolean): String? { + val items = value.split(',').map { it.trim().uppercase() } + val kept = items.filter(valid) + if (kept.size != items.size) repaired = true + return kept.takeIf { it.isNotEmpty() }?.joinToString(",") + } + + for (part in rule.split(';')) { + // A stray separator ("FREQ=DAILY;") normalises away; nothing was lost. + // A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part + // being dropped, so it falls through and marks the rule repaired. + if (part.isBlank()) continue + val key = part.substringBefore('=', "").trim().uppercase() + val value = if ('=' in part) part.substringAfter('=').trim() else "" + val kept = when { + key.isEmpty() || value.isEmpty() -> null + key == "FREQ" -> + value.uppercase().takeIf { it in VALID_FREQ } + ?.also { freqSeen = true } + ?.let { "FREQ=$it" } + // A non-positive or non-numeric INTERVAL/COUNT is rejected by the + // provider, and a COUNT of zero would expand to no occurrences. + key == "INTERVAL" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "INTERVAL=$it" } + key == "COUNT" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "COUNT=$it" } + key == "UNTIL" -> + value.uppercase().takeIf { UNTIL_SHAPE.matches(it) }?.let { "UNTIL=$it" } + key == "WKST" -> + value.uppercase().takeIf { it in VALID_WEEKDAYS }?.let { "WKST=$it" } + key == "BYDAY" -> keepItems(value, ::isWeekdayItem)?.let { "BYDAY=$it" } + key in NUMERIC_LIST_PARTS -> + keepItems(value, NUMERIC_LIST_PARTS.getValue(key)::accepts)?.let { "$key=$it" } + else -> null + } + if (kept == null) repaired = true else parts += kept + } + // `EventRecurrence.parse` ends with *two* global checks, not one: a missing + // FREQ throws, and so does an UNTIL that arrives together with a COUNT. A + // rule carrying both is malformed per RFC 5545 §3.3.10 but real producers + // emit it, and left alone it would throw straight out of `insert` — the very + // failure this function exists to prevent. UNTIL is kept: it bounds the + // series at an absolute date, so a stale COUNT can't over-generate past it. + if (parts.any { it.startsWith("UNTIL=") }) { + if (parts.removeAll { it.startsWith("COUNT=") }) repaired = true + } + return if (freqSeen) { + SanitizedRrule(rule = parts.joinToString(";"), repaired = repaired) + } else { + SanitizedRrule(rule = null, repaired = true) + } +} + +/** A `BYDAY` item: a two-letter weekday, optionally preceded by an ordinal. */ +private fun isWeekdayItem(item: String): Boolean { + if (item.length < 2) return false + val ordinal = item.dropLast(2) + return item.takeLast(2) in VALID_WEEKDAYS && + (ordinal.isEmpty() || ordinal.toIntOrNull() != null) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt index 8d3e604..c26dd9c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt @@ -46,6 +46,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) { appendTimes(event) event.recurrenceRule?.takeIf { it.isNotBlank() } ?.let { add("RRULE:${it.removePrefix("RRULE:")}") } + appendExDates(event) event.location?.takeIf { it.isNotBlank() } ?.let { add("LOCATION:${escapeText(it)}") } event.description?.takeIf { it.isNotBlank() } @@ -83,6 +84,20 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) { } } + /** + * The series' deleted occurrences. RFC 5545 ties `EXDATE`'s value type to + * `DTSTART`'s, so an all-day series needs the explicit `VALUE=DATE` — without + * it the bare day codes read as an invalid DATE-TIME. Only written for a + * recurring event: an exclusion names an occurrence, and a one-off has none. + */ + private fun MutableList.appendExDates(event: IcsEvent) { + if (event.recurrenceRule.isNullOrBlank()) return + val stamps = event.exDates.filter { it.isNotBlank() }.distinct() + if (stamps.isEmpty()) return + val prefix = if (event.isAllDay) "EXDATE;VALUE=DATE:" else "EXDATE:" + add(prefix + stamps.joinToString(",")) + } + private fun MutableList.appendAlarm(minutes: Int, summary: String) { add("BEGIN:VALARM") add("ACTION:DISPLAY") 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..8e01a6c 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 @@ -93,15 +93,11 @@ fun ImportScreen( } // Hoisted target calendar so the always-visible top-bar Import action can - // read it without the user scrolling to a bottom button. Defaults to the - // first *local* calendar — the first row the picker shows ("Your calendars" - // group leads) — so the pre-selection lines up with the top of the list; - // falls back to the first calendar if there are no local ones. Re-defaults - // when the "many" list first arrives (keyed on it), then holds the pick. + // read it without the user scrolling to a bottom button (see + // [defaultImportTarget] for the choice). Re-defaults when the "many" list + // first arrives (keyed on it), then holds the pick. val many = state as? ImportUiState.Many - val defaultTarget = many?.calendars?.let { cals -> - (cals.firstOrNull { it.isLocal } ?: cals.firstOrNull())?.id - } + val defaultTarget = many?.let { defaultImportTarget(it.calendars, it.fileCalendarName) } var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) } Scaffold( @@ -252,6 +248,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 +295,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 +319,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 +361,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/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt index 723132b..46a18a2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt @@ -43,6 +43,12 @@ sealed interface ImportUiState { val events: List, val warnings: Set, val calendars: List, + /** + * The calendar the file says its events came from, when every one of + * them names the same — used to preselect a matching target (see + * [fileCalendarName]). + */ + val fileCalendarName: String? = null, ) : ImportUiState data class Done(val summary: IcsImportSummary) : ImportUiState @@ -92,6 +98,7 @@ class ImportViewModel @Inject constructor( warnings = parsed.warnings, calendars = repository.calendars().first() .filter { it.isEventTarget }, + fileCalendarName = fileCalendarName(parsed.events), ) } } @@ -113,3 +120,33 @@ class ImportViewModel @Inject constructor( } } } + +/** + * The one calendar [events] all came from, or null. + * + * Every event has to agree, unnamed ones included: a foreign file where a single + * `VEVENT` carries a `CATEGORIES` tag and the rest carry nothing names no + * calendar — it just has one tagged event, and letting it pick the target would + * land the whole file wherever that tag happens to match. + */ +internal fun fileCalendarName(events: List): String? = + events.map { it.calendarName }.distinct().singleOrNull() + +/** + * The target calendar to preselect for a bulk import. + * + * A calendar named like the one the file came from wins — restoring "Birthdays" + * onto the Birthdays calendar is what the user means. Otherwise the first + * *local* calendar, which is the first row the picker shows ("Your calendars" + * leads), so the pre-selection lines up with the top of the list; failing that + * the first calendar of any kind. + */ +internal fun defaultImportTarget( + calendars: List, + fileCalendarName: String?, +): Long? { + val named = fileCalendarName?.let { name -> + calendars.firstOrNull { it.displayName.equals(name, ignoreCase = true) } + } + return (named ?: calendars.firstOrNull { it.isLocal } ?: calendars.firstOrNull())?.id +} 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/AllDayReminderEncodingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt index 0dd4cc4..4baaec6 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt @@ -143,6 +143,47 @@ class AllDayReminderEncodingTest { assertThat(fireFromNext).isEqualTo(LocalTime.of(9, 0)) // correct } + @Test + fun `an imported yearly series samples at its next occurrence, not its anchor`() { + // Fossify anchors a year-less contact birthday at 1970, when Berlin had + // no DST; sampling there fires a modern June occurrence at 10:00 instead + // of the 09:00 the user picked (Codeberg #225). + val anchor = LocalDate.of(1970, 6, 3) + val today = LocalDate.of(2026, 6, 1) + + val sampled = importedAllDayReminderDate(anchor, "FREQ=YEARLY;BYMONTH=6", today) + assertThat(sampled).isEqualTo(LocalDate.of(2026, 6, 3)) + + val raw = toProviderAllDayMinutes(0, sampled, berlin, nineAm) + val fire = actualFire(raw, LocalDate.of(2027, 6, 3)) + .let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime() + assertThat(fire).isEqualTo(LocalTime.of(9, 0)) + } + + @Test + fun `the lead time still reads back from the anchor's own date`() { + // The offset is sampled at the next occurrence but decoded against + // DTSTART, so the two must still agree on the whole-day lead time. + val anchor = LocalDate.of(1970, 6, 3) + val sampled = importedAllDayReminderDate(anchor, "FREQ=YEARLY", LocalDate.of(2026, 6, 1)) + + for (semantic in listOf(0, 1_440, 2_880)) { + val raw = toProviderAllDayMinutes(semantic, sampled, berlin, nineAm) + assertThat(fromProviderAllDayMinutes(raw, anchor, berlin, nineAm)).isEqualTo(semantic) + } + } + + @Test + fun `a one-off import samples at its own date, a stale series at today`() { + val past = LocalDate.of(1970, 6, 3) + val today = LocalDate.of(2026, 6, 1) + val future = LocalDate.of(2027, 3, 4) + + assertThat(importedAllDayReminderDate(past, null, today)).isEqualTo(past) + assertThat(importedAllDayReminderDate(past, "FREQ=WEEKLY;BYDAY=MO", today)).isEqualTo(today) + assertThat(importedAllDayReminderDate(future, "FREQ=WEEKLY", today)).isEqualTo(future) + } + @Test fun `a winter-anchored offset drifts one hour on a summer occurrence`() { // Known limitation: one fixed MINUTES per series can't track DST. An diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt index 1e022a6..cd8d03e 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt @@ -801,6 +801,62 @@ 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 { + publishedEventColorsResult = { 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 `importEvents matches colours against the uncurated palette`( + @TempDir tempDir: Path, + ) = runTest { + // Curation is a picker concession — it folds look-alikes and drops the + // neutrals from an oversized palette — but every published key is one + // the calendar accepts, so an imported colour matches against all of + // them (Codeberg #225). + val published = listOf(EventColorOption("1", 0xFF808080.toInt())) + val fake = FakeCalendarDataSource().apply { + publishedEventColorsResult = { published } + eventColorPaletteResult = { emptyList() } + } + val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined) + + repo.importEvents(3L, listOf(parsedEvent("a@x"))) + + assertThat(fake.lastImportPalette).isEqualTo(published) + } + @Test fun `exportEvents forwards the chosen calendar-id subset to the data source`( @TempDir tempDir: Path, @@ -825,9 +881,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..676200b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt @@ -23,6 +23,7 @@ internal class FakeCalendarDataSource : CalendarDataSource { var searchResult: (String) -> List = { _ -> emptyList() } var eventDetailResult: (Long) -> EventDetail? = { null } var eventColorPaletteResult: (Long) -> List = { emptyList() } + var publishedEventColorsResult: (Long) -> List = { emptyList() } var exportableEventsResult: List = emptyList() /** The [calendarIds] the last [exportableEvents] call received (null = all). */ var lastExportableEventsCalendarIds: Set? = null @@ -81,7 +82,12 @@ internal class FakeCalendarDataSource : CalendarDataSource { eventDetailResult(eventId) override fun eventColorPalette(calendarId: Long): List = eventColorPaletteResult(calendarId) - override fun exportableEvents(calendarIds: Set?): List { + override fun publishedEventColors(calendarId: Long): List = + publishedEventColorsResult(calendarId) + override fun exportableEvents( + calendarIds: Set?, + allDayReminderTimeMinutes: Int, + ): List { lastExportableEventsCalendarIds = calendarIds return exportableEventsResult } @@ -91,8 +97,21 @@ 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() + + /** The [colorPalette] the last [insertImportedEvent] call received. */ + var lastImportPalette: List = emptyList() + + override fun insertImportedEvent( + event: ParsedIcsEvent, + calendarId: Long, + allDayReminderTimeMinutes: Int, + colorPalette: List, + ): Long { + lastImportPalette = colorPalette writeError?.let { throw it } + if (event.summary in failingImportSummaries) error("rejected: ${event.summary}") importedEvents += event to calendarId return nextInsertId } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt index 552e1d9..72f2873 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt @@ -4,6 +4,7 @@ import android.provider.CalendarContract import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.EventStatus import org.junit.jupiter.api.Test +import java.time.ZoneId class IcsExportMapperTest { @@ -20,7 +21,11 @@ class IcsExportMapperTest { EventExportProjection.IDX_AVAILABILITY to CalendarContract.Events.AVAILABILITY_BUSY, ) - val event = reader.toIcsEvent(reminderMinutes = listOf(10), calendarName = "Personal") + val event = reader.toIcsEvent( + reminderMinutes = listOf(10), + calendarName = "Personal", + allDayReminderTimeMinutes = NINE_AM, + ) assertThat(event.uid).isEqualTo("abc@host") assertThat(event.summary).isEqualTo("Standup") @@ -47,7 +52,11 @@ class IcsExportMapperTest { EventExportProjection.IDX_EVENT_TIMEZONE to "UTC", ) - val event = reader.toIcsEvent(reminderMinutes = emptyList(), calendarName = null) + val event = reader.toIcsEvent( + reminderMinutes = emptyList(), + calendarName = null, + allDayReminderTimeMinutes = NINE_AM, + ) assertThat(event.uid).isEqualTo("7-1000000@calendula") assertThat(event.recurrenceRule).isEqualTo("FREQ=WEEKLY") @@ -65,6 +74,98 @@ class IcsExportMapperTest { EventExportProjection.IDX_EVENT_TIMEZONE to "UTC", ) - assertThat(reader.toIcsEvent(emptyList(), null).isAllDay).isTrue() + assertThat(reader.toIcsEvent(emptyList(), null, NINE_AM).isAllDay).isTrue() + } + + @Test + fun `an all-day reminder is exported as its whole-day lead time`() { + // The raw offset encodes the firing time and is normally negative; left + // raw it would be written as a trigger after the event and dropped, so + // the backup would come back with no reminder at all. + val reader = MapColumnReader( + EventExportProjection.IDX_ID to 1L, + EventExportProjection.IDX_TITLE to "Anna Birthday", + EventExportProjection.IDX_DTSTART to 1_782_864_000_000L, // 2026-07-01 UTC, CEST + EventExportProjection.IDX_DTEND to 1_782_950_400_000L, + EventExportProjection.IDX_ALL_DAY to 1, + EventExportProjection.IDX_EVENT_TIMEZONE to "UTC", + ) + + val onTheDay = reader.toIcsEvent(listOf(-420), null, NINE_AM, BERLIN) + val dayBefore = reader.toIcsEvent(listOf(1_020), null, NINE_AM, BERLIN) + + assertThat(onTheDay.reminderMinutes).containsExactly(0) + assertThat(dayBefore.reminderMinutes).containsExactly(1_440) + } + + private companion object { + const val NINE_AM = 9 * 60 + val BERLIN: ZoneId = ZoneId.of("Europe/Berlin") + } + + @Test + fun `a recurring row carries its EXDATE exclusions`() { + val reader = MapColumnReader( + EventExportProjection.IDX_ID to 7L, + EventExportProjection.IDX_TITLE to "Weekly", + EventExportProjection.IDX_DTSTART to 1_000_000L, + EventExportProjection.IDX_DURATION to "P3600S", + EventExportProjection.IDX_ALL_DAY to 0, + EventExportProjection.IDX_RRULE to "FREQ=WEEKLY", + EventExportProjection.IDX_EXDATE to "20260901T080000Z,20260908T080000Z", + EventExportProjection.IDX_EVENT_TIMEZONE to "UTC", + ) + + val event = reader.toIcsEvent( + reminderMinutes = emptyList(), + calendarName = null, + allDayReminderTimeMinutes = NINE_AM, + ) + + assertThat(event.exDates) + .containsExactly("20260901T080000Z", "20260908T080000Z").inOrder() + } + + @Test + fun `an all-day EXDATE keeps only the day, however the adapter padded it`() { + val reader = MapColumnReader( + EventExportProjection.IDX_ID to 8L, + EventExportProjection.IDX_TITLE to "Holiday", + EventExportProjection.IDX_DTSTART to 0L, + EventExportProjection.IDX_DURATION to "P1D", + EventExportProjection.IDX_ALL_DAY to 1, + EventExportProjection.IDX_RRULE to "FREQ=YEARLY", + EventExportProjection.IDX_EXDATE to "20260901,20270901T000000Z", + EventExportProjection.IDX_EVENT_TIMEZONE to "UTC", + ) + + val event = reader.toIcsEvent( + reminderMinutes = emptyList(), + calendarName = null, + allDayReminderTimeMinutes = NINE_AM, + ) + + assertThat(event.exDates).containsExactly("20260901", "20270901").inOrder() + } + + @Test + fun `a one-off row exports no exclusions even if the column is set`() { + val reader = MapColumnReader( + EventExportProjection.IDX_ID to 9L, + EventExportProjection.IDX_TITLE to "Standup", + EventExportProjection.IDX_DTSTART to 1_000_000L, + EventExportProjection.IDX_DTEND to 1_900_000L, + EventExportProjection.IDX_ALL_DAY to 0, + EventExportProjection.IDX_EXDATE to "20260901T080000Z", + EventExportProjection.IDX_EVENT_TIMEZONE to "UTC", + ) + + val event = reader.toIcsEvent( + reminderMinutes = emptyList(), + calendarName = null, + allDayReminderTimeMinutes = NINE_AM, + ) + + assertThat(event.exDates).isEmpty() } } 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..f7bc50b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsFossifyImportTest.kt @@ -0,0 +1,586 @@ +package de.jeanlucmakiola.calendula.domain.ics + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.TimeZone +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +/** + * 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. + * + * Their all-day `DTEND` is *usually* RFC-correct and must not be "corrected": + * `Event.endTS` anchors a UI-created 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. Shifting those by a day would corrupt them. + * + * The exception — and the whole of #225 — is events mirrored from Contacts. + * `MainActivity` builds those with `startTS = endTS = timestamp`, so `+ 12h` + * lands back on the starting day and they export zero length. + */ +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 `contact-mirrored birthdays export zero length and must not vanish`() { + // The reported failure (#225). Fossify mirrors Contacts birthdays and + // anniversaries with startTS == endTS, so the exporter's +12h rounds + // back to the starting day: DTSTART == DTEND. Read literally these are + // yearly series of zero-length occurrences, which the provider expands + // into nothing at all — the birthdays import "successfully" and are + // then nowhere to be seen. + val text = checkNotNull( + javaClass.classLoader?.getResourceAsStream("ics/fossify-contact-birthdays.ics"), + ).use { it.readBytes().toString(Charsets.UTF_8) } + + val result = parser.parse(text) + + assertThat(result.events).hasSize(3) + result.events.forEach { + assertThat(it.isAllDay).isTrue() + assertThat(days(it)).isEqualTo(1) + assertThat(it.recurrenceRule).startsWith("FREQ=YEARLY") + // Their all-day reminder encoding: "on the day", not midnight UTC. + assertThat(it.semanticReminderMinutes()).containsExactly(0) + } + } + + @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 `a task with both DTSTART and DUE spans between them`() { + val result = parser.parse( + fossify( + "BEGIN:VTODO", + "SUMMARY:Write report", + "DTSTART:20260901T090000Z", + "DUE:20260901T170000Z", + "END:VTODO", + ), + ) + + val task = result.events.single() + assertThat(task.start).isEqualTo(Instant.parse("2026-09-01T09:00:00Z")) + assertThat(task.end).isEqualTo(Instant.parse("2026-09-01T17:00:00Z")) + } + + @Test + fun `a DUE ahead of the DTSTART is still the end`() { + // Property order isn't guaranteed; DUE must not be taken for the start. + val result = parser.parse( + fossify( + "BEGIN:VTODO", + "SUMMARY:Write report", + "DUE:20260901T170000Z", + "DTSTART:20260901T090000Z", + "END:VTODO", + ), + ) + + val task = result.events.single() + assertThat(task.start).isEqualTo(Instant.parse("2026-09-01T09:00:00Z")) + assertThat(task.end).isEqualTo(Instant.parse("2026-09-01T17:00:00Z")) + } + + @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 `CATEGORIES does not outrank a property that names the calendar outright`() { + // Elsewhere CATEGORIES is a tag list, so it only stands in where nothing + // else named the calendar — X-WR-CALNAME and our own label win. + val result = IcsParser().parse( + listOf( + "BEGIN:VCALENDAR", + "X-WR-CALNAME:Work", + "BEGIN:VEVENT", + "SUMMARY:Standup", + "CATEGORIES:Holiday", + "DTSTART:20260819T080000Z", + "DTEND:20260819T090000Z", + "END:VEVENT", + "END:VCALENDAR", + ).joinToString("\r\n"), + ) + + assertThat(result.events.single().calendarName).isEqualTo("Work") + } + + @Test + fun `a multi-valued CATEGORIES is a tag list, not a calendar name`() { + val result = parser.parse( + fossify( + "BEGIN:VEVENT", + "SUMMARY:Standup", + "CATEGORIES:Meeting,Personal", + "DTSTART:20260819T080000Z", + "DTEND:20260819T090000Z", + "END:VEVENT", + ), + ) + + assertThat(result.events.single().calendarName).isNull() + } + + @Test + fun `a reminder written as a positive trigger is not read as a lead time`() { + // The exporter flips the sign for reminders stored below -1 + // (`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 whose firing time is after noon keeps its full day`() { + // The raw offset is `days * 1440 - timeOfDay`, so an 18:00 notification + // time puts "1 day before" only six hours ahead of the UTC midnight. + // Rounding to the nearest day would read that as "on the day". + val result = parser.parse( + fossify( + "BEGIN:VEVENT", + "SUMMARY:Anna Birthday", + "DTSTART;VALUE=DATE:20260819", + "DTEND;VALUE=DATE:20260820", + "BEGIN:VALARM", + "ACTION:DISPLAY", + "TRIGGER:-P0DT6H0M0S", + "END:VALARM", + "END:VEVENT", + ), + ) + + assertThat(result.events.single().semanticReminderMinutes()).containsExactly(1440) + } + + @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 `a timed trigger that fires after the start is dropped, not clamped`() { + // A legal RFC 5545 follow-up alarm. Modelling it as a lead time of zero + // would invent a notification at the start the file never asked for. + val result = parser.parse( + fossify( + "BEGIN:VEVENT", + "SUMMARY:Standup", + "DTSTART:20260819T080000Z", + "DTEND:20260819T081500Z", + "BEGIN:VALARM", + "ACTION:DISPLAY", + "TRIGGER:PT30M", + "END:VALARM", + "END:VEVENT", + ), + ) + + assertThat(result.events.single().semanticReminderMinutes()).isEmpty() + } + + @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 `an all-day EXDATE keeps the day it spells out, whatever zone it carries`() { + // A midnight east of UTC resolves to an instant on the previous day; + // reading that back in UTC would exclude a day that isn't an occurrence + // and leave the one the user deleted in place. + val result = parser.parse( + fossify( + "BEGIN:VEVENT", + "SUMMARY:Standup", + "DTSTART;VALUE=DATE:20260819", + "DTEND;VALUE=DATE:20260820", + "RRULE:FREQ=DAILY", + "EXDATE;TZID=Europe/Berlin:20260821T000000", + "EXDATE;VALUE=DATE:20260823", + "END:VEVENT", + ), + ) + + assertThat(result.events.single().exDates) + .containsExactly("20260821", "20260823").inOrder() + } + + @Test + fun `a timed series' bare EXDATE day code is resolved against the series time`() { + // They store excluded occurrences as day codes and write them out that + // 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 `a floating EXDATE is read in the series' zone, not the device's`() { + // RFC 5545 ties EXDATE to DTSTART's form: with no Z and no TZID the + // value is New York wall time. Reading it in the device's Berlin zone + // would land on an instant no occurrence has, and the excluded + // occurrence would come back. + val result = parser.parse( + fossify( + "BEGIN:VEVENT", + "SUMMARY:Standup", + "DTSTART;TZID=America/New_York:20260819T100000", + "DTEND;TZID=America/New_York:20260819T103000", + "RRULE:FREQ=WEEKLY", + "EXDATE:20260826T100000", + "END:VEVENT", + ), + ) + + assertThat(result.events.single().exDates).containsExactly("20260826T140000Z") + } + + @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/IcsParserTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsParserTest.kt index abdc710..851d427 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsParserTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsParserTest.kt @@ -165,4 +165,36 @@ class IcsParserTest { assertThat(result.events.map { it.uid }).containsExactly("good") assertThat(result.warnings).contains(IcsParseWarning.EventWithoutStartSkipped) } + + @Test + fun `round-trips a timed series' deleted occurrences`() { + val event = IcsEvent( + uid = "u20@calendula", + summary = "Weekly", + start = instantUtc(2026, 6, 18, 13, 0), + end = instantUtc(2026, 6, 18, 13, 30), + isAllDay = false, + zoneId = "UTC", + recurrenceRule = "FREQ=WEEKLY", + exDates = listOf("20260625T130000Z"), + ) + + assertThat(roundTrip(event).exDates).containsExactly("20260625T130000Z") + } + + @Test + fun `round-trips an all-day series' deleted occurrences`() { + val event = IcsEvent( + uid = "u21@calendula", + summary = "Holiday", + start = LocalDate(2026, 6, 18).atStartOfDayIn(TimeZone.UTC), + end = LocalDate(2026, 6, 19).atStartOfDayIn(TimeZone.UTC), + isAllDay = true, + zoneId = "UTC", + recurrenceRule = "FREQ=YEARLY", + exDates = listOf("20270618"), + ) + + assertThat(roundTrip(event).exDates).containsExactly("20270618") + } } 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..6284708 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrenceTest.kt @@ -0,0 +1,166 @@ +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`() { + val result = sanitizeRrule("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE") + + assertThat(result.rule).isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE") + assertThat(result.repaired).isFalse() + } + + @Test + fun `the RRULE prefix is stripped`() { + assertThat(sanitizeRrule("RRULE:FREQ=DAILY").rule).isEqualTo("FREQ=DAILY") + } + + @Test + fun `an empty list part is dropped`() { + val bareByDay = sanitizeRrule("FREQ=WEEKLY;INTERVAL=1;BYDAY=") + assertThat(bareByDay.rule).isEqualTo("FREQ=WEEKLY;INTERVAL=1") + assertThat(bareByDay.repaired).isTrue() + + assertThat(sanitizeRrule("FREQ=WEEKLY;BYDAY=MO,,WE;BYMONTH=").rule) + .isEqualTo("FREQ=WEEKLY;BYDAY=MO,WE") + } + + @Test + fun `a nonsensical INTERVAL or COUNT is dropped, not fatal`() { + assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=0").rule).isEqualTo("FREQ=DAILY") + assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=every").rule).isEqualTo("FREQ=DAILY") + assertThat(sanitizeRrule("FREQ=DAILY;COUNT=0").rule).isEqualTo("FREQ=DAILY") + } + + @Test + fun `a rule without a usable FREQ is unsalvageable`() { + assertThat(sanitizeRrule("INTERVAL=1;BYDAY=MO").rule).isNull() + assertThat(sanitizeRrule("FREQ=FORTNIGHTLY;INTERVAL=1").rule).isNull() + assertThat(sanitizeRrule("FREQ=;INTERVAL=1").rule).isNull() + assertThat(sanitizeRrule("").rule).isNull() + assertThat(sanitizeRrule(null).rule).isNull() + } + + @Test + fun `no rule at all is not a repair`() { + assertThat(sanitizeRrule(null).repaired).isFalse() + assertThat(sanitizeRrule("").repaired).isFalse() + assertThat(sanitizeRrule("FREQ=FORTNIGHTLY").repaired).isTrue() + } + + @Test + fun `normalising is not repairing`() { + // Case and a trailing separator change the text but lose nothing, and + // telling the user their file was faulty over either is crying wolf. + val lowercase = sanitizeRrule("freq=daily;count=3") + assertThat(lowercase.rule).isEqualTo("FREQ=DAILY;COUNT=3") + assertThat(lowercase.repaired).isFalse() + + val trailing = sanitizeRrule("FREQ=DAILY;") + assertThat(trailing.rule).isEqualTo("FREQ=DAILY") + assertThat(trailing.repaired).isFalse() + } + + @Test + fun `parts the provider's parser has no entry for are dropped`() { + // EventRecurrence.parse throws on any part name outside its own table, + // so passing one on would cost the whole event (Codeberg #225). + val result = sanitizeRrule("FREQ=MONTHLY;RSCALE=GREGORIAN;BYMONTHDAY=15") + + assertThat(result.rule).isEqualTo("FREQ=MONTHLY;BYMONTHDAY=15") + assertThat(result.repaired).isTrue() + assertThat(sanitizeRrule("FREQ=YEARLY;X-THING=7").rule).isEqualTo("FREQ=YEARLY") + } + + @Test + fun `WKST survives, a nonsense weekday does not`() { + assertThat(sanitizeRrule("FREQ=WEEKLY;WKST=SU").rule).isEqualTo("FREQ=WEEKLY;WKST=SU") + assertThat(sanitizeRrule("FREQ=WEEKLY;WKST=XX").rule).isEqualTo("FREQ=WEEKLY") + } + + @Test + fun `a BYDAY item that isn't a weekday is dropped`() { + assertThat(sanitizeRrule("FREQ=MONTHLY;BYDAY=-1FR,1,MO").rule) + .isEqualTo("FREQ=MONTHLY;BYDAY=-1FR,MO") + assertThat(sanitizeRrule("FREQ=WEEKLY;BYDAY=mo,tu").rule) + .isEqualTo("FREQ=WEEKLY;BYDAY=MO,TU") + } + + @Test + fun `a zero is dropped from the by-position parts only`() { + assertThat(sanitizeRrule("FREQ=MONTHLY;BYMONTHDAY=0,15").rule) + .isEqualTo("FREQ=MONTHLY;BYMONTHDAY=15") + assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=0,9").rule) + .isEqualTo("FREQ=DAILY;BYHOUR=0,9") + } + + @Test + fun `an out-of-range numeric item is dropped`() { + // EventRecurrence.parseNumberList range-checks every one of these and + // throws out of insert, so being a number isn't enough (Codeberg #225). + assertThat(sanitizeRrule("FREQ=MONTHLY;BYMONTHDAY=15,32").rule) + .isEqualTo("FREQ=MONTHLY;BYMONTHDAY=15") + assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=25").rule).isEqualTo("FREQ=DAILY") + assertThat(sanitizeRrule("FREQ=YEARLY;BYMONTH=13").rule).isEqualTo("FREQ=YEARLY") + assertThat(sanitizeRrule("FREQ=YEARLY;BYWEEKNO=60").rule).isEqualTo("FREQ=YEARLY") + assertThat(sanitizeRrule("FREQ=YEARLY;BYYEARDAY=400").rule).isEqualTo("FREQ=YEARLY") + assertThat(sanitizeRrule("FREQ=DAILY;BYSECOND=60").rule).isEqualTo("FREQ=DAILY") + assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=25").repaired).isTrue() + } + + @Test + fun `the edges of each range survive`() { + assertThat(sanitizeRrule("FREQ=MONTHLY;BYMONTHDAY=-31,31").repaired).isFalse() + assertThat(sanitizeRrule("FREQ=YEARLY;BYYEARDAY=-366,366;BYWEEKNO=-53,53").repaired) + .isFalse() + assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=23;BYMINUTE=59;BYSECOND=59").repaired) + .isFalse() + // BYSETPOS is the one part the provider leaves unbounded. + assertThat(sanitizeRrule("FREQ=MONTHLY;BYDAY=MO;BYSETPOS=-1,400").rule) + .isEqualTo("FREQ=MONTHLY;BYDAY=MO;BYSETPOS=-1,400") + } + + @Test + fun `an UNTIL that isn't a timestamp is dropped`() { + assertThat(sanitizeRrule("FREQ=DAILY;UNTIL=20260819T235959Z").rule) + .isEqualTo("FREQ=DAILY;UNTIL=20260819T235959Z") + assertThat(sanitizeRrule("FREQ=DAILY;UNTIL=2026-08-19").rule).isEqualTo("FREQ=DAILY") + } + + @Test + fun `UNTIL and COUNT together keep UNTIL`() { + // EventRecurrence.parse throws outright on a rule carrying both. + val both = sanitizeRrule("FREQ=WEEKLY;COUNT=10;UNTIL=20260101T000000Z") + + assertThat(both.rule).isEqualTo("FREQ=WEEKLY;UNTIL=20260101T000000Z") + assertThat(both.repaired).isTrue() + } + + @Test + fun `a COUNT the UNTIL check never sees is left alone`() { + // An UNTIL dropped for its shape doesn't take the COUNT down with it. + val staleUntil = sanitizeRrule("FREQ=WEEKLY;COUNT=10;UNTIL=2026-01-01") + + assertThat(staleUntil.rule).isEqualTo("FREQ=WEEKLY;COUNT=10") + assertThat(staleUntil.repaired).isTrue() + } + + @Test + fun `a part without a value separator is reported as repaired`() { + val noEquals = sanitizeRrule("FREQ=WEEKLY;BYDAY") + + assertThat(noEquals.rule).isEqualTo("FREQ=WEEKLY") + assertThat(noEquals.repaired).isTrue() + } + + @Test + fun `a trailing separator is normalisation, not a repair`() { + val trailing = sanitizeRrule("FREQ=DAILY;") + + assertThat(trailing.rule).isEqualTo("FREQ=DAILY") + assertThat(trailing.repaired).isFalse() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriterTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriterTest.kt index 89b06e7..bc92cfb 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriterTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriterTest.kt @@ -149,4 +149,55 @@ class IcsWriterTest { // Stable across calls — a re-export of the same row yields the same UID. assertThat(deriveIcsUid(null, 7, 1000)).isEqualTo(deriveIcsUid(null, 7, 1000)) } + + @Test + fun `a timed series writes its exclusions as UTC stamps`() { + val event = IcsEvent( + uid = "u9@calendula", + summary = "Weekly", + start = instantUtc(2026, 6, 18, 13, 0), + end = instantUtc(2026, 6, 18, 13, 30), + isAllDay = false, + zoneId = "UTC", + recurrenceRule = "FREQ=WEEKLY", + exDates = listOf("20260625T130000Z", "20260702T130000Z"), + ) + + assertThat(lines(listOf(event))) + .contains("EXDATE:20260625T130000Z,20260702T130000Z") + } + + @Test + fun `an all-day series marks its exclusions VALUE=DATE`() { + val start = LocalDate(2026, 6, 18).atStartOfDayIn(TimeZone.UTC) + val event = IcsEvent( + uid = "u10@calendula", + summary = "Holiday", + start = start, + end = LocalDate(2026, 6, 19).atStartOfDayIn(TimeZone.UTC), + isAllDay = true, + zoneId = "UTC", + recurrenceRule = "FREQ=YEARLY", + exDates = listOf("20270618"), + ) + + // RFC 5545 ties EXDATE's value type to DTSTART's; a bare day code + // without VALUE=DATE reads as a malformed DATE-TIME. + assertThat(lines(listOf(event))).contains("EXDATE;VALUE=DATE:20270618") + } + + @Test + fun `a one-off event never writes an EXDATE`() { + val event = IcsEvent( + uid = "u11@calendula", + summary = "Standup", + start = instantUtc(2026, 6, 18, 13, 0), + end = instantUtc(2026, 6, 18, 13, 30), + isAllDay = false, + zoneId = "UTC", + exDates = listOf("20260625T130000Z"), + ) + + assertThat(lines(listOf(event)).none { it.startsWith("EXDATE") }).isTrue() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/imports/ImportTargetTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/imports/ImportTargetTest.kt new file mode 100644 index 0000000..cbb1a5b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/imports/ImportTargetTest.kt @@ -0,0 +1,89 @@ +package de.jeanlucmakiola.calendula.ui.imports + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +/** Which calendar a bulk import lands on before the user touches the picker. */ +class ImportTargetTest { + + private fun cal(id: Long, name: String, local: Boolean = false) = CalendarSource( + id = id, + displayName = name, + accountName = "acc@local", + accountType = if (local) "LOCAL" else "com.google", + color = 0, + isVisibleInSystem = true, + isLocal = local, + ) + + private val calendars = listOf( + cal(1L, "Work"), + cal(2L, "Personal", local = true), + cal(3L, "Birthdays", local = true), + ) + + @Test + fun `a calendar named like the file's wins`() { + assertThat(defaultImportTarget(calendars, "Birthdays")).isEqualTo(3L) + } + + @Test + fun `the name match ignores case`() { + assertThat(defaultImportTarget(calendars, "birthdays")).isEqualTo(3L) + } + + @Test + fun `an unmatched name falls back to the first local calendar`() { + assertThat(defaultImportTarget(calendars, "Holidays")).isEqualTo(2L) + } + + @Test + fun `no name at all falls back to the first local calendar`() { + assertThat(defaultImportTarget(calendars, null)).isEqualTo(2L) + } + + @Test + fun `with no local calendars the first of any kind is taken`() { + assertThat(defaultImportTarget(listOf(cal(1L, "Work")), null)).isEqualTo(1L) + } + + @Test + fun `an empty list preselects nothing`() { + assertThat(defaultImportTarget(emptyList(), "Birthdays")).isNull() + } + + private fun event(calendarName: String?) = ParsedIcsEvent( + uid = "u@x", + summary = "E", + start = Instant.fromEpochMilliseconds(0L), + end = Instant.fromEpochMilliseconds(3_600_000L), + isAllDay = false, + zoneId = "UTC", + calendarName = calendarName, + ) + + @Test + fun `a file whose events all name one calendar names it`() { + assertThat(fileCalendarName(List(3) { event("Birthdays") })).isEqualTo("Birthdays") + } + + @Test + fun `disagreeing events name none`() { + assertThat(fileCalendarName(listOf(event("Birthdays"), event("Anniversaries")))).isNull() + } + + @Test + fun `one tagged event among unnamed ones names none`() { + // CATEGORIES is a tag list everywhere but the Fossify family, so a lone + // tagged event must not decide where the whole file lands. + assertThat(fileCalendarName(listOf(event("Holiday")) + List(9) { event(null) })).isNull() + } + + @Test + fun `a file that names nothing names none`() { + assertThat(fileCalendarName(List(3) { event(null) })).isNull() + } +} diff --git a/app/src/test/resources/ics/fossify-contact-birthdays.ics b/app/src/test/resources/ics/fossify-contact-birthdays.ics new file mode 100644 index 0000000..6c9a7b8 --- /dev/null +++ b/app/src/test/resources/ics/fossify-contact-birthdays.ics @@ -0,0 +1,64 @@ +BEGIN:VCALENDAR +PRODID:-//Fossify//NONSGML Event Calendar//EN +VERSION:2.0 +BEGIN:VEVENT +SUMMARY:Anna Schmidt +UID:101 +X-FOSSIFY-CATEGORY-COLOR:-1155931 +CATEGORIES:Birthdays +LAST-MODIFIED:20250615T150640Z +TRANSP:TRANSPARENT +DTSTART;VALUE=DATE:19900412 +DTEND;VALUE=DATE:19900412 +X-FOSSIFY-MISSING-YEAR:0 +DTSTAMP:20260819T120000Z +CLASS:PUBLIC +STATUS:CONFIRMED +RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4 +BEGIN:VALARM +DESCRIPTION:Reminder +ACTION:DISPLAY +TRIGGER:P0DT9H0M0S +END:VALARM +END:VEVENT +BEGIN:VEVENT +SUMMARY:Opa +UID:102 +X-FOSSIFY-CATEGORY-COLOR:-1155931 +CATEGORIES:Birthdays +LAST-MODIFIED:20250615T150640Z +TRANSP:TRANSPARENT +DTSTART;VALUE=DATE:19700603 +DTEND;VALUE=DATE:19700603 +X-FOSSIFY-MISSING-YEAR:1 +DTSTAMP:20260819T120000Z +CLASS:PUBLIC +STATUS:CONFIRMED +RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=6 +BEGIN:VALARM +DESCRIPTION:Reminder +ACTION:DISPLAY +TRIGGER:P0DT9H0M0S +END:VALARM +END:VEVENT +BEGIN:VEVENT +SUMMARY:Hochzeitstag +UID:103 +X-FOSSIFY-CATEGORY-COLOR:-1155931 +CATEGORIES:Anniversaries +LAST-MODIFIED:20250615T150640Z +TRANSP:TRANSPARENT +DTSTART;VALUE=DATE:20050917 +DTEND;VALUE=DATE:20050917 +X-FOSSIFY-MISSING-YEAR:0 +DTSTAMP:20260819T120000Z +CLASS:PUBLIC +STATUS:CONFIRMED +RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=9 +BEGIN:VALARM +DESCRIPTION:Reminder +ACTION:DISPLAY +TRIGGER:P0DT9H0M0S +END:VALARM +END:VEVENT +END:VCALENDAR 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..310ece3 --- /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..780f683 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -346,6 +346,101 @@ 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.** This is what +#225 was: Fossify mirrors Contacts birthdays and anniversaries with +`startTS = endTS` (`MainActivity`), so its exporter's `dayCode(endTS + 12h)` +rounds back to the starting day and writes `DTEND == DTSTART`. Taken literally +that is a yearly series of zero-length occurrences, which the provider expands +into no instances at all — the import reports success and the birthdays are +nowhere. Nothing is logged, because nothing failed. `resolveEnd` floors an +all-day span at a day (also RFC 5545 §3.6.1, which gives a `DATE` `DTSTART` with +no `DTEND` a one-day duration) and `buildImportedEventValues` floors an all-day +`DURATION` at `P1D`. + +Do **not** generalise that into correcting the producer's `DTEND` by sniffing its +`PRODID`. The same exporter is right everywhere else: `Event.endTS` anchors a +UI-created all-day event at *noon* of its last day +(`EventActivity.getStartEndTimes`), or at the exclusive midnight when the row +came from CalDAV, and `+ TWELVE_HOURS` rounds either to the following midnight. +Shifting those would push every one of them 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. Both that file and a contact-birthday export are kept as fixtures in +`app/src/test/resources/ics`. Clamp the degenerate case; never rewrite the +conformant one. + +**One bad event must not cost the file.** The provider validates `RRULE` through +`EventRecurrence.parse`, which *throws* — out of `insert`, not out of a later +read. That parser works off a fixed table of part names and throws on anything +outside it, so `sanitizeRrule` is a **whitelist**: unrecognised parts (RFC 7529 +`RSCALE`, vendor `X-` extensions) and unusable values go, and only a missing +`FREQ` gives up on the rule. It reports whether it actually *dropped* something, +separately from the rule text it returns — case and a trailing `;` normalise away +and must not raise the "faulty repeat rule" warning at the user. Behind all that, +`CalendarRepositoryImpl.importEvents` isolates each event, counting rejects into +`IcsImportSummary.failed` rather than unwinding the batch and reporting only +"couldn't read this file". + +Smaller dialect handling: `VTODO` components import as events (Calendula models +no tasks; dropping them silently lost half of some exports), a single-valued +`CATEGORIES` stands in for the `X-WR-CALNAME` Fossify never writes and preselects +a target calendar of that name — as a *fallback* only, since everywhere else the +property is a tag list and must not outrank something that names the calendar +outright — bare `EXDATE` day codes on a timed series are resolved against the +series' own time of day (as is a floating `EXDATE` DATE-TIME — RFC 5545 ties it +to `DTSTART`'s zone, not the device's) while an all-day `EXDATE` keeps the day it +spells out rather than the day its resolved instant lands on in UTC, and an +all-day `VALARM` trigger pointing *after* the start — which is how that family +encodes "on the day at 09:00" — is read as zero days before. On a *timed* event +the same shape means a follow-up alarm and is dropped: clamping it to zero would +invent a reminder at the start that the file never asked for. + +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 +would drop a day for every producer whose all-day notifications fire after noon +("1 day before at 18:00" arrives as `TRIGGER:-PT6H`). The export side is the +mirror image: an all-day row's raw provider `MINUTES` has the firing time encoded +into it and is normally *negative*, which the writer used to discard as a trigger +after the event — so `toIcsEvent` decodes it back through +`fromProviderAllDayMinutes` first, and the importing device re-encodes it against +its own setting. + +**A backup carries the series' deleted occurrences.** `EXDATE` lives on the +master row, so the export query's `ORIGINAL_ID IS NULL` filter — which does drop +`RECURRENCE-ID` overrides — never hid it; it simply wasn't read. It is now part +of `EventExportProjection`, and `IcsWriter` marks an all-day series' exclusions +`VALUE=DATE`, because RFC 5545 ties `EXDATE`'s value type to `DTSTART`'s and a +bare day code without it reads as a malformed DATE-TIME. Sync adapters disagree +on whether an all-day exclusion is `yyyyMMdd` or a padded midnight stamp, so +`exportExDates` drops the time part on the way out. Without this, an in-app +backup→restore resurrected every occurrence the user had deleted. + +That re-encode is sampled where the reminder will actually *fire*, not at +`DTSTART` (`importedAllDayReminderDate`). An imported series' `DTSTART` is only an +anchor and is routinely ancient — Fossify writes a year-less contact birthday at +1970, and a real birth year is usually older still — from a year whose timezone +rules predate DST across most of Europe. Sampling the UTC offset there skews every +modern occurrence by the delta, the same trap `nextYearlyOccurrence` exists for on +the managed special-dates path. Decoding is unaffected and stays anchored at +`DTSTART`: it only asks which local *day* the encoded instant falls on. + +Colour arrives as a raw ARGB from an app with no idea which account it is landing +in. A calendar whose account publishes a palette rejects a raw `EVENT_COLOR`, so +`buildImportedEventValues` snaps the imported colour to the nearest published key +(`nearestTo`, measured in Oklab) and writes the raw value only where there is no +palette. The match runs against `publishedEventColors`, **not** the curated +`eventColorPalette`: curation is a picker concession that folds look-alikes and +drops the neutrals outright from an oversized palette, but every published key is +one the calendar accepts. The single-event review form deliberately keeps no colour at all: its +first question is which calendar to use, and answering it clears the colour +anyway. + ## Testing JUnit 5 + Truth + Turbine on the JVM. The seams that make it work: