From 2218c11d3f09bfc9b2d8a364b98372ec1b848297 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 12 Jul 2026 10:36:35 +0200 Subject: [PATCH] fix: cancel only the tapped occurrence on single-instance delete (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Delete only this event" on a recurring series wrote a cancelled exception carrying just ORIGINAL_INSTANCE_TIME + STATUS_CANCELED. Without DTSTART + DURATION the provider clones the master *with its RRULE intact* and cancels the whole clone, so every other occurrence vanished, the target survived as a "cancelled" ghost, and re-deleting toggled the series back — exactly the reported corruption. Anchor the exception as a single instance (DTSTART + DURATION + zone + all-day, read from the series row) so the provider clears the inherited RRULE and cancels only that occurrence — the same discipline the edit path already documents (Codeberg #16). Also filter STATUS_CANCELED out of the instances grid query so the cancelled occurrence disappears instead of lingering as a tappable ghost (NULL status is kept — a normal event). Extracts the exception ContentValues into a pure buildOccurrenceCancelValues helper with JVM tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data/calendar/CalendarDataSource.kt | 30 ++++++++++---- .../data/calendar/EventWriteMapper.kt | 27 +++++++++++++ .../data/calendar/EventWriteMapperTest.kt | 40 +++++++++++++++++++ 3 files changed, 90 insertions(+), 7 deletions(-) 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 eeb7821..632557e 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 @@ -492,7 +492,13 @@ class AndroidCalendarDataSource @Inject constructor( return resolver.query( uri, InstanceProjection.COLUMNS, - null, null, + // Hide cancelled occurrences: "delete only this event" writes a + // cancelled exception for the one instance (#47). A NULL status is a + // normal, un-cancelled event, so it must survive the filter — a bare + // `!= CANCELED` would drop it (NULL != 2 is NULL, not true). + "${CalendarContract.Instances.STATUS} IS NULL OR " + + "${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}", + null, CalendarContract.Instances.BEGIN + " ASC", )?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList() } @@ -1183,15 +1189,25 @@ class AndroidCalendarDataSource @Inject constructor( override fun deleteOccurrence(eventId: Long, beginMillis: Long) { // A cancelled exception row hides exactly this occurrence; the sync - // adapter turns it into an EXDATE/cancelled VEVENT upstream. - val values = ContentValues().apply { - put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, beginMillis) - put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED) - } + // adapter turns it into an EXDATE/cancelled VEVENT upstream. It has to + // carry the full time set (DTSTART + DURATION + zone), not just STATUS: + // the provider only demotes the cloned exception to a single instance — + // clearing the inherited RRULE — when it can derive that instance from + // those columns. A STATUS-only cancel left the RRULE standing and + // cancelled the whole series, wiping every other occurrence (#47), the + // same trap the edit path documents (Codeberg #16). + val row = querySeriesRow(eventId) + val values = buildOccurrenceCancelValues( + originalInstanceMillis = beginMillis, + dtStartMillis = beginMillis, + duration = row.duration, + timezone = row.timezone, + allDay = row.allDay, + ) val uri = ContentUris.withAppendedId( CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId, ) - resolver.insert(uri, values) + resolver.insert(uri, values.toContentValues()) ?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis") } 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 dc830aa..e310c48 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 @@ -182,6 +182,33 @@ internal fun buildOccurrenceExceptionValues( putAll(eventColorColumns(form.colorKey, form.color)) } +/** + * Column values for a *cancelled*-occurrence exception row ("delete only this + * event"): inserting them at `Events.CONTENT_EXCEPTION_URI/` makes the + * provider clone the series row and cancel exactly this one instance. + * + * As with [buildOccurrenceExceptionValues], the occurrence must be anchored with + * DTSTART + DURATION so the provider derives a single instance and clears the + * inherited RRULE. A STATUS-only cancel skips that: the clone keeps the RRULE, so + * the *whole series* is cancelled and every other occurrence disappears + * (Codeberg #47). The occurrence's length/zone come straight from the series row + * — cancelling never changes them. + */ +internal fun buildOccurrenceCancelValues( + originalInstanceMillis: Long, + dtStartMillis: Long, + duration: String?, + timezone: String?, + allDay: Int, +): Map = buildMap { + put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, originalInstanceMillis) + put(CalendarContract.Events.DTSTART, dtStartMillis) + put(CalendarContract.Events.DURATION, duration) + put(CalendarContract.Events.EVENT_TIMEZONE, timezone) + put(CalendarContract.Events.ALL_DAY, allDay) + put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED) +} + /** * The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A * [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index f1be42b..252ca37 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -220,6 +220,46 @@ class EventWriteMapperTest { assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null) } + // --- buildOccurrenceCancelValues ("delete only this event") --- + + @Test + fun `occurrence cancel anchors a single instance and cancels only it`() { + val values = buildOccurrenceCancelValues( + originalInstanceMillis = 1_700_000_000_000L, + dtStartMillis = 1_700_000_000_000L, + duration = "P3600S", + timezone = "Europe/Berlin", + allDay = 0, + ) + assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME]) + .isEqualTo(1_700_000_000_000L) + // DTSTART + DURATION make the provider derive a single instance and drop + // the inherited RRULE, so only this occurrence is cancelled — not the + // whole series (#47). DTEND is never sent (the provider rejects it). + assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_700_000_000_000L) + assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P3600S") + assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin") + assertThat(values[CalendarContract.Events.STATUS]) + .isEqualTo(CalendarContract.Events.STATUS_CANCELED) + assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND) + assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE) + } + + @Test + fun `all-day occurrence cancel keeps the all-day flag and utc zone`() { + val values = buildOccurrenceCancelValues( + originalInstanceMillis = 1_700_000_000_000L, + dtStartMillis = 1_700_000_000_000L, + duration = "P1D", + timezone = "UTC", + allDay = 1, + ) + assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1) + assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC") + assertThat(values[CalendarContract.Events.STATUS]) + .isEqualTo(CalendarContract.Events.STATUS_CANCELED) + } + // --- per-event colour --- @Test -- 2.49.1