From 06909487e32f78c2ce5b9dce70407fe7bc015fb4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 31 Aug 2026 19:11:41 +0200 Subject: [PATCH] fix(ics): export a series' deleted occurrences (#225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import learned to read EXDATE, but the export side never wrote it: an in-app backup and restore brought back every occurrence the user had deleted. EXDATE lives on the master row, so the export query's ORIGINAL_ID IS NULL filter never hid it — it just wasn't in the projection. An all-day series' exclusions are written VALUE=DATE, since 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 the time part is dropped on the way out. --- CHANGELOG.md | 4 ++ .../data/calendar/CalendarRepositoryImpl.kt | 1 - .../data/calendar/IcsExportMapper.kt | 21 ++++++ .../calendula/data/calendar/Projections.kt | 20 +++--- .../calendula/domain/ics/IcsEvent.kt | 6 ++ .../calendula/domain/ics/IcsWriter.kt | 15 +++++ .../data/calendar/IcsExportMapperTest.kt | 66 +++++++++++++++++++ .../calendula/domain/ics/IcsParserTest.kt | 32 +++++++++ .../calendula/domain/ics/IcsWriterTest.kt | 51 ++++++++++++++ 9 files changed, 207 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6f96a3..8e6eb2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 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 3ef334b..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 @@ -308,7 +308,6 @@ class CalendarRepositoryImpl @Inject constructor( dataSource.deleteEventFromOccurrence(eventId, beginMillis) } - private companion object { const val TAG = "CalendarRepository" } 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 6ec0157..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 @@ -56,6 +56,11 @@ internal fun ColumnReader.toIcsEvent( 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 = if (isAllDay) { @@ -72,3 +77,19 @@ internal fun ColumnReader.toIcsEvent( ) } +/** + * 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/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/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/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt index 71a3f04..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 @@ -102,4 +102,70 @@ class IcsExportMapperTest { 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/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/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() + } }