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:
@@ -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
|
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
|
ends where it starts, or carries no end at all, is now a one-day event
|
||||||
([#225]).
|
([#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
|
### Added
|
||||||
- **More of an imported `.ics` survives the trip**: tasks come across as events
|
- **More of an imported `.ics` survives the trip**: tasks come across as events
|
||||||
|
|||||||
@@ -308,7 +308,6 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "CalendarRepository"
|
const val TAG = "CalendarRepository"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ internal fun ColumnReader.toIcsEvent(
|
|||||||
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
|
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
|
||||||
?: "UTC",
|
?: "UTC",
|
||||||
recurrenceRule = rrule,
|
recurrenceRule = rrule,
|
||||||
|
exDates = if (rrule == null) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
exportExDates(getString(EventExportProjection.IDX_EXDATE), isAllDay)
|
||||||
|
},
|
||||||
location = getString(EventExportProjection.IDX_LOCATION),
|
location = getString(EventExportProjection.IDX_LOCATION),
|
||||||
description = getString(EventExportProjection.IDX_DESCRIPTION),
|
description = getString(EventExportProjection.IDX_DESCRIPTION),
|
||||||
reminderMinutes = if (isAllDay) {
|
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()
|
||||||
|
|||||||
@@ -121,10 +121,12 @@ internal object EventDetailProjection {
|
|||||||
/**
|
/**
|
||||||
* Master/one-off Events rows for a whole-calendar backup. Unlike
|
* Master/one-off Events rows for a whole-calendar backup. Unlike
|
||||||
* [EventDetailProjection] this reads `UID_2445` (to keep a row's identity across
|
* [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
|
* occurrence and cancelled-exception rows are filtered out by the query
|
||||||
* (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides and EXDATEs aren't
|
* (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides aren't exported — a
|
||||||
* exported yet — a documented v1 limit (import skips them too).
|
* 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 {
|
internal object EventExportProjection {
|
||||||
val COLUMNS: Array<String> = arrayOf(
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
@@ -137,6 +139,7 @@ internal object EventExportProjection {
|
|||||||
CalendarContract.Events.ALL_DAY,
|
CalendarContract.Events.ALL_DAY,
|
||||||
CalendarContract.Events.EVENT_TIMEZONE,
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
CalendarContract.Events.RRULE,
|
CalendarContract.Events.RRULE,
|
||||||
|
CalendarContract.Events.EXDATE,
|
||||||
CalendarContract.Events.EVENT_LOCATION,
|
CalendarContract.Events.EVENT_LOCATION,
|
||||||
CalendarContract.Events.DESCRIPTION,
|
CalendarContract.Events.DESCRIPTION,
|
||||||
CalendarContract.Events.STATUS,
|
CalendarContract.Events.STATUS,
|
||||||
@@ -153,11 +156,12 @@ internal object EventExportProjection {
|
|||||||
const val IDX_ALL_DAY = 6
|
const val IDX_ALL_DAY = 6
|
||||||
const val IDX_EVENT_TIMEZONE = 7
|
const val IDX_EVENT_TIMEZONE = 7
|
||||||
const val IDX_RRULE = 8
|
const val IDX_RRULE = 8
|
||||||
const val IDX_LOCATION = 9
|
const val IDX_EXDATE = 9
|
||||||
const val IDX_DESCRIPTION = 10
|
const val IDX_LOCATION = 10
|
||||||
const val IDX_STATUS = 11
|
const val IDX_DESCRIPTION = 11
|
||||||
const val IDX_AVAILABILITY = 12
|
const val IDX_STATUS = 12
|
||||||
const val IDX_CALENDAR_ID = 13
|
const val IDX_AVAILABILITY = 13
|
||||||
|
const val IDX_CALENDAR_ID = 14
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ data class IcsEvent(
|
|||||||
val zoneId: String,
|
val zoneId: String,
|
||||||
/** Bare RRULE value (no `RRULE:` prefix), or null for a one-off event. */
|
/** Bare RRULE value (no `RRULE:` prefix), or null for a one-off event. */
|
||||||
val recurrenceRule: String? = null,
|
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 location: String? = null,
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
/** Reminder lead times in minutes before start (raw provider offsets). */
|
/** Reminder lead times in minutes before start (raw provider offsets). */
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
|||||||
appendTimes(event)
|
appendTimes(event)
|
||||||
event.recurrenceRule?.takeIf { it.isNotBlank() }
|
event.recurrenceRule?.takeIf { it.isNotBlank() }
|
||||||
?.let { add("RRULE:${it.removePrefix("RRULE:")}") }
|
?.let { add("RRULE:${it.removePrefix("RRULE:")}") }
|
||||||
|
appendExDates(event)
|
||||||
event.location?.takeIf { it.isNotBlank() }
|
event.location?.takeIf { it.isNotBlank() }
|
||||||
?.let { add("LOCATION:${escapeText(it)}") }
|
?.let { add("LOCATION:${escapeText(it)}") }
|
||||||
event.description?.takeIf { it.isNotBlank() }
|
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) {
|
private fun MutableList<String>.appendAlarm(minutes: Int, summary: String) {
|
||||||
add("BEGIN:VALARM")
|
add("BEGIN:VALARM")
|
||||||
add("ACTION:DISPLAY")
|
add("ACTION:DISPLAY")
|
||||||
|
|||||||
@@ -102,4 +102,70 @@ class IcsExportMapperTest {
|
|||||||
const val NINE_AM = 9 * 60
|
const val NINE_AM = 9 * 60
|
||||||
val BERLIN: ZoneId = ZoneId.of("Europe/Berlin")
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,4 +165,36 @@ class IcsParserTest {
|
|||||||
assertThat(result.events.map { it.uid }).containsExactly("good")
|
assertThat(result.events.map { it.uid }).containsExactly("good")
|
||||||
assertThat(result.warnings).contains(IcsParseWarning.EventWithoutStartSkipped)
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,4 +149,55 @@ class IcsWriterTest {
|
|||||||
// Stable across calls — a re-export of the same row yields the same UID.
|
// Stable across calls — a re-export of the same row yields the same UID.
|
||||||
assertThat(deriveIcsUid(null, 7, 1000)).isEqualTo(deriveIcsUid(null, 7, 1000))
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user