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

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