fix(ics): export a series' deleted occurrences (#225)

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.
This commit is contained in:
2026-08-31 19:11:41 +02:00
parent 0b7f960511
commit 06909487e3
9 changed files with 207 additions and 9 deletions

View File

@@ -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

View File

@@ -308,7 +308,6 @@ class CalendarRepositoryImpl @Inject constructor(
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
}
private companion object {
const val TAG = "CalendarRepository"
}

View File

@@ -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<String> = exdate
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?.map { if (isAllDay) it.substringBefore('T') else it }
?.distinct()
.orEmpty()

View File

@@ -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<String> = 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
}
/**

View File

@@ -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<String> = emptyList(),
val location: String? = null,
val description: String? = null,
/** Reminder lead times in minutes before start (raw provider offsets). */

View File

@@ -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<String>.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<String>.appendAlarm(minutes: Int, summary: String) {
add("BEGIN:VALARM")
add("ACTION:DISPLAY")

View File

@@ -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()
}
}

View File

@@ -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")
}
}

View File

@@ -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()
}
}