Compare commits

..

1 Commits

Author SHA1 Message Date
27a48b9eb4 fix: move EXDATE with the series when its times change (#248)
Dropping a single occurrence on a calendar with no _sync_id records it in
the master row's EXDATE (the #47 fix; synced calendars use a cancelled
exception instead and are unaffected). An EXDATE stamp is an absolute
instant — it excludes an occurrence only while the series keeps generating
one at exactly that instant — and nothing ever rewrote it when the anchor
moved.

buildEventUpdateValues shifts DTSTART on any time change and rewrites
RRULE/DURATION/zone/all-day, so every occurrence regenerates elsewhere
while the stamps stay behind matching nothing: the occurrence the user
deleted comes back. updateEventFromOccurrence is worse — it splits the
series by inserting a fresh event built from the form, which carries no
EXDATE at all, so every exclusion in the tail is lost.

Shift each stamp by the same wall-clock delta the anchor takes, re-resolved
in the event's (possibly new) zone. Reusing the anchor's rule is what keeps
an exclusion pinned to its occurrence when a DST boundary sits between the
two, or the zone itself changed; a millisecond delta would bake in the
offset that happened to apply at the edited occurrence. Parsing reads
all-day-ness from the old form and writing takes it from the new one, so
the VALUE=DATE and date-time forms convert into each other when the event
switches — the wrong form matches no occurrence and loses the exclusion
just as surely as the wrong instant.

Stamps in a shape Calendula never writes (a TZID-parameterised or floating
one from a sync adapter) are left exactly as they are, whole list included:
a stale stamp excludes nothing, but a mangled one could exclude the wrong
occurrence.

For the split, the parent's stamps past the cutoff are re-timed onto the
new series. The one *at* the split point is dropped: it names the
occurrence being edited, which exists by definition, and honouring a stale
exclusion for it would swallow the edit whole. The parent keeps its full
list — stamps past the truncation are inert once it stops generating those
occurrences. The write repeats the whole time/recurrence set for the reason
#47 documents (an EXDATE-only update is not read as a recurrence change),
and a failure rolls the new series back before the parent is truncated, so
the split fails whole rather than landing with the exclusions dropped.

Dropping the recurrence now clears EXDATE with the RRULE. Dormant rather
than harmless: adding a recurrence back later would punch the old holes
into the new one.

This reaches every calendar type through the "all events" path, including
synced ones, where an upstream EXDATE must move with the series for the
same reason — and reaches a calendar move too, which copies EXDATE verbatim
and then applies the field edits as a normal series update.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:40:44 +02:00
6 changed files with 408 additions and 312 deletions

View File

@@ -8,16 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Fixed ### Fixed
- **"Only this event" now actually saves your edit.** On some calendars — - **A deleted occurrence no longer comes back when the series is re-timed.**
including Google ones that still show as on-device, and any local calendar — Delete a single occurrence of a repeating event, then change the time of the
editing a single occurrence of a repeating event did nothing at all: the scope whole series, and the occurrence you had removed reappeared. Doing it the
dialog closed, the edit screen stayed put, and saving again just repeated it. other way round — "This and all following events" — lost every removal in the
Android can only attach a single-occurrence change to its series once the part of the series being changed. Removals now travel with the event: they
calendar has been synced at least once, so on those calendars the change had keep their place in the series across a time change, a move to a different
nowhere to go. Calendula now removes that one occurrence from the series and day, a timezone change and a switch to or from an all-day event, and they
saves the edit as its own event instead, which is what you see either way. A carry over when a series is split. Repeating events on your device's own
save that does fail also says so for longer, rather than flashing past calendars are affected, including the birthday and anniversary calendars
([#234]). Calendula creates from your contacts ([#248]).
## [2.19.3] — 2026-08-22 ## [2.19.3] — 2026-08-22
@@ -1483,4 +1483,4 @@ automatically, with zero telemetry and no internet permission.
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192 [#192]: https://codeberg.org/jlmakiola/calendula/issues/192
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196 [#196]: https://codeberg.org/jlmakiola/calendula/issues/196
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214 [#214]: https://codeberg.org/jlmakiola/calendula/issues/214
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234 [#248]: https://codeberg.org/jlmakiola/calendula/issues/248

View File

@@ -232,15 +232,10 @@ interface CalendarDataSource {
): Long ): Long
/** /**
* Change a single occurrence of a recurring event at [beginMillis] (the * Change a single occurrence of a recurring event by inserting a
* occurrence's `Instances.BEGIN`) to [form]'s values; returns the * modified-occurrence exception at [beginMillis] (the occurrence's
* `Events._ID` of the row now holding them. * `Instances.BEGIN`) carrying [form]'s values; returns the exception
* * row's `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
* A series with a `_sync_id` gets a modified-occurrence exception. One
* without gets the occurrence excluded from the parent via EXDATE plus a
* standalone event carrying the edits — an exception cannot link to its
* parent there (Codeberg #234, the same constraint as [deleteOccurrence]).
* [allDayReminderTimeMinutes]: see [insertEvent].
*/ */
fun updateOccurrence( fun updateOccurrence(
eventId: Long, eventId: Long,
@@ -981,10 +976,12 @@ class AndroidCalendarDataSource @Inject constructor(
updated: EventForm, updated: EventForm,
allDayReminderTimeMinutes: Int, allDayReminderTimeMinutes: Int,
) { ) {
val row = querySeriesRow(eventId)
val values = buildEventUpdateValues( val values = buildEventUpdateValues(
original = original, original = original,
updated = updated, updated = updated,
seriesDtStartMillis = querySeriesRow(eventId).dtStartMillis, seriesDtStartMillis = row.dtStartMillis,
seriesExdate = row.exdate,
zone = ZoneId.systemDefault(), zone = ZoneId.systemDefault(),
) )
if (values.isNotEmpty()) { if (values.isNotEmpty()) {
@@ -1199,13 +1196,6 @@ class AndroidCalendarDataSource @Inject constructor(
form: EventForm, form: EventForm,
allDayReminderTimeMinutes: Int, allDayReminderTimeMinutes: Int,
): Long { ): Long {
val row = querySeriesRow(eventId)
// Stricter than deleteOccurrence's bare _sync_id check: EXDATE only means
// something on a row that recurs, so a non-recurring one keeps the
// exception path rather than getting a recurrence set written onto it.
if (row.syncId == null && !row.rrule.isNullOrBlank()) {
return detachOccurrence(eventId, beginMillis, row, form, allDayReminderTimeMinutes)
}
// The provider clones the series row and applies these values on top. // The provider clones the series row and applies these values on top.
val values = buildOccurrenceExceptionValues( val values = buildOccurrenceExceptionValues(
form = form, form = form,
@@ -1224,89 +1214,6 @@ class AndroidCalendarDataSource @Inject constructor(
return exceptionId return exceptionId
} }
/**
* "Edit only this event" on a series with **no `_sync_id`**: drop the
* occurrence from the parent with EXDATE and insert the edited values as a
* standalone event on the same calendar.
*
* A modified exception attaches to its parent only through `ORIGINAL_SYNC_ID`,
* exactly like the cancelled one [deleteOccurrence] documents; with no
* `_sync_id` the link never forms and the edit is lost (Codeberg #234).
* EXDATE plus a standalone row needs no link — what a detached instance
* degrades to without a `RECURRENCE-ID` to carry it.
*
* The detached row keeps no stored link back to its series, so: it no longer
* travels with it ([moveEvent] copies the master and its `ORIGINAL_ID`
* children, and this is neither); its EXDATE hole is an absolute instant, so
* re-timing the whole series brings the occurrence back beside the copy (a
* #47 delete resurrects the same way); and it is built from the form, not
* cloned, so `ORGANIZER`, `STATUS` and the attendee rows [reconcileAttendees]
* preserves are dropped — the same limitation as [moveEvent].
*
* Insert first, so a failure leaves the series untouched
* ([updateEventFromOccurrence]'s discipline); roll the new row back if the
* EXDATE update then fails, since it would be a visible duplicate. The
* reverse order risks the worse outcome — an excluded occurrence with no
* replacement, i.e. an edit that quietly deletes.
*/
private fun detachOccurrence(
eventId: Long,
beginMillis: Long,
row: SeriesRow,
form: EventForm,
allDayReminderTimeMinutes: Int,
): Long {
// Already detached (or deleted) from a stale screen still pointing at the
// parent: the EXDATE merge would fold the repeat away and still report a
// changed row, quietly leaving a *second* standalone copy.
if (exdateContains(row.exdate, beginMillis, isAllDay = row.allDay != 0)) {
throw NoSuchEventException(eventId)
}
// Reminders, guests and colour come along like any new event, and so does
// a fresh UID — the detached row is a separate event now, and sharing the
// parent's would collide with it in .ics restore dedup.
val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes)
val values = buildOccurrenceExdateValues(
existingExdate = row.exdate,
occurrenceMillis = beginMillis,
dtStartMillis = row.dtStartMillis,
rrule = row.rrule,
duration = row.duration,
timezone = row.timezone,
allDay = row.allDay,
)
// Rows touched, not occurrences excluded — 1 whenever the series row still
// exists. It catches the row disappearing under us, not an EXDATE the
// provider's expansion fails to match.
val updatedRows = try {
resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(), null, null,
)
} catch (t: Throwable) {
rollBackDetached(detachedId)
throw t
}
if (updatedRows == 0) {
rollBackDetached(detachedId)
throw WriteFailedException(
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
)
}
return detachedId
}
/**
* Undo the standalone row [detachOccurrence] inserted before its EXDATE
* update failed. Best effort: the caller is already throwing, and the worst
* case is the duplicate we were avoiding — never a lost occurrence.
*/
private fun rollBackDetached(detachedId: Long) {
runCatching { deleteEvent(detachedId) }.onFailure {
Log.w(TAG, "Failed to roll back detached occurrence $detachedId", it)
}
}
override fun updateEventFromOccurrence( override fun updateEventFromOccurrence(
eventId: Long, eventId: Long,
beginMillis: Long, beginMillis: Long,
@@ -1323,10 +1230,66 @@ class AndroidCalendarDataSource @Inject constructor(
} }
// Insert the new series first: if it fails, the original is untouched. // Insert the new series first: if it fails, the original is untouched.
val newEventId = insertEvent(updated, allDayReminderTimeMinutes) val newEventId = insertEvent(updated, allDayReminderTimeMinutes)
carrySplitExdate(newEventId, row, beginMillis, original, updated)
truncateSeries(eventId, row, beginMillis) truncateSeries(eventId, row, beginMillis)
return newEventId return newEventId
} }
/**
* Carry the [parent]'s exclusions for occurrences past [beginMillis] onto the
* series [newEventId] that now owns them, re-timed by the shift the split
* applied ([shiftedExdate]/[exdateAfter]). [insertEvent] builds the new row
* from the form, which knows nothing about them, so without this every
* occurrence the user had deleted from the tail of the series comes back.
*
* The whole time/recurrence set rides along with EXDATE for the reason
* [buildOccurrenceExdateValues] documents: on its own the provider does not
* read an EXDATE write as a recurrence change, and leaves the instances it
* expanded on insert standing.
*
* A failure rolls the new series back and throws, so the split fails whole
* rather than landing with the exclusions quietly dropped — the parent is
* still untruncated at this point, so the event is left exactly as it was.
*/
private fun carrySplitExdate(
newEventId: Long,
parent: SeriesRow,
beginMillis: Long,
original: EventForm,
updated: EventForm,
) {
// Dropping the recurrence in the same save leaves a one-off tail, and an
// exclusion means nothing on a row that doesn't recur.
if (updated.rrule.isNullOrBlank()) return
val carried = shiftedExdate(
existingExdate = exdateAfter(parent.exdate, beginMillis, original.isAllDay),
original = original,
updated = updated,
zone = ZoneId.systemDefault(),
) ?: return
val row = querySeriesRow(newEventId)
val values = ContentValues().apply {
put(CalendarContract.Events.EXDATE, carried)
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
put(CalendarContract.Events.RRULE, row.rrule)
put(CalendarContract.Events.DURATION, row.duration)
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
put(CalendarContract.Events.ALL_DAY, row.allDay)
}
try {
val rows = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, newEventId),
values, null, null,
)
if (rows == 0) {
throw WriteFailedException("carry exdate onto split series id=$newEventId")
}
} catch (t: Throwable) {
runCatching { deleteEvent(newEventId) }
throw t
}
}
override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) { override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) {
val row = querySeriesRow(eventId) val row = querySeriesRow(eventId)
// From the first occurrence on = the whole series; also the fallback // From the first occurrence on = the whole series; also the fallback

View File

@@ -10,6 +10,9 @@ import java.time.Duration
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.ResolverStyle
import java.time.LocalDate as JavaLocalDate
import java.time.LocalDateTime as JavaLocalDateTime import java.time.LocalDateTime as JavaLocalDateTime
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */ /** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
@@ -134,18 +137,21 @@ internal fun buildEventInsertValues(
* *
* Time fields travel together (the provider validates them as a unit): * Time fields travel together (the provider validates them as a unit):
* - unchanged times, all-day flag and rrule → no time columns at all; * - unchanged times, all-day flag and rrule → no time columns at all;
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared; * - non-recurring result → DTSTART/DTEND, DURATION, RRULE and EXDATE cleared;
* - recurring result → the *series* DTSTART moves by the same **wall-clock** * - recurring result → the *series* DTSTART moves by the same **wall-clock**
* shift the user applied to the displayed occurrence and is re-resolved in the * shift the user applied to the displayed occurrence and is re-resolved in the
* event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION * event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION
* replaces DTEND, RRULE is written. This keeps past occurrences intact when * replaces DTEND, RRULE is written, and the row's exclusions
* someone edits a later occurrence's time, and keeps the anchor's time-of-day * ([seriesExdate], the current EXDATE) travel with the anchor. This keeps past
* stable across a DST boundary or a zone change between the two. * occurrences intact when someone edits a later occurrence's time, and keeps
* the anchor's time-of-day stable across a DST boundary or a zone change
* between the two.
*/ */
internal fun buildEventUpdateValues( internal fun buildEventUpdateValues(
original: EventForm, original: EventForm,
updated: EventForm, updated: EventForm,
seriesDtStartMillis: Long, seriesDtStartMillis: Long,
seriesExdate: String?,
zone: ZoneId, zone: ZoneId,
): Map<String, Any?> = buildMap { ): Map<String, Any?> = buildMap {
if (updated.title.trim() != original.title.trim()) { if (updated.title.trim() != original.title.trim()) {
@@ -185,6 +191,10 @@ internal fun buildEventUpdateValues(
put(CalendarContract.Events.DTEND, newTimes.dtEndMillis) put(CalendarContract.Events.DTEND, newTimes.dtEndMillis)
put(CalendarContract.Events.RRULE, null) put(CalendarContract.Events.RRULE, null)
put(CalendarContract.Events.DURATION, null) put(CalendarContract.Events.DURATION, null)
// The exclusions named occurrences of a series that no longer exists.
// Left behind they are dormant rather than harmless: adding a recurrence
// back later would punch the old holes into the new one.
put(CalendarContract.Events.EXDATE, null)
} else { } else {
// Move the series anchor by the *wall-clock* shift the user applied to the // Move the series anchor by the *wall-clock* shift the user applied to the
// displayed occurrence, then re-resolve it in the event's (possibly new) // displayed occurrence, then re-resolve it in the event's (possibly new)
@@ -206,6 +216,14 @@ internal fun buildEventUpdateValues(
put(CalendarContract.Events.DTEND, null) put(CalendarContract.Events.DTEND, null)
put(CalendarContract.Events.RRULE, updated.rrule) put(CalendarContract.Events.RRULE, updated.rrule)
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay)) put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
// An EXDATE stamp is an absolute instant, so it excludes an occurrence
// only while the series keeps generating one at exactly that instant. The
// anchor has just moved, so every occurrence regenerates elsewhere and a
// stamp left behind matches none of them — the occurrence the user deleted
// comes back. Move the stamps the same way the anchor moved.
shiftedExdate(seriesExdate, original, updated, zone)
?.takeIf { it != seriesExdate }
?.let { put(CalendarContract.Events.EXDATE, it) }
} }
} }
@@ -243,21 +261,6 @@ internal fun buildOccurrenceExceptionValues(
putAll(eventColorColumns(form.colorKey, form.color)) putAll(eventColorColumns(form.colorKey, form.color))
} }
/**
* The form as a **detached occurrence**: the same edited values, with the
* series rule dropped so [buildEventInsertValues] writes a standalone one-off
* row (DTSTART + DTEND, no RRULE/DURATION) at the occurrence's own times.
*
* The "edit only this event" shape for a series with **no `_sync_id`**, where an
* exception row can't attach to its parent at all (Codeberg #234).
*
* The exception path gets the rule dropped for free — the provider clears the
* RRULE it cloned when an exception carries DTSTART + DURATION
* ([buildOccurrenceExceptionValues]). Here nothing is cloned, so it is stripped
* by hand; leaving it on would insert a second *series* overlapping the first.
*/
internal fun EventForm.toDetachedOccurrence(): EventForm = copy(rrule = null)
/** /**
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it * Raw provider snapshot of a master/one-off Events row, enough to re-insert it
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID` * verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
@@ -435,11 +438,7 @@ internal fun buildOccurrenceExdateValues(
allDay: Int, allDay: Int,
): Map<String, Any?> { ): Map<String, Any?> {
val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0) val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0)
val existing = existingExdate?.split(',') val merged = (exdateStamps(existingExdate) + stamp).distinct().joinToString(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
val merged = (existing + stamp).distinct().joinToString(",")
return mapOf( return mapOf(
CalendarContract.Events.EXDATE to merged, CalendarContract.Events.EXDATE to merged,
CalendarContract.Events.DTSTART to dtStartMillis, CalendarContract.Events.DTSTART to dtStartMillis,
@@ -451,38 +450,121 @@ internal fun buildOccurrenceExdateValues(
} }
/** /**
* Whether [existingExdate] already excludes the occurrence at [occurrenceMillis] * [existingExdate] with every stamp re-timed by the same **wall-clock** shift the
* — i.e. it has already been dropped from the series, deleted or detached. * series anchor takes from [original] to [updated] — the exclusions' half of the
* anchor move [buildEventUpdateValues] performs, and what carries them onto the
* new series when "this and following" splits one.
* *
* Guards the detach path against running twice from a stale screen: the EXDATE * A stamp is an absolute instant, so it keeps naming its occurrence only if it
* merge folds the repeat away silently and the update still reports one row * moves exactly as the occurrence does: shifted in wall clock and re-resolved in
* changed, so a second save would leave a second standalone copy. * the event's (possibly new) zone. A millisecond delta would instead bake in the
* offset that happened to apply at the edited occurrence — an hour off for any
* exclusion on the far side of a DST boundary. All-day-ness is read from
* [original] and written from [updated], so the `VALUE=DATE` and date-time forms
* convert into each other when the event switches.
*
* Null when there is nothing to carry: no stamps, or a stamp in a form Calendula
* never writes (a `TZID=`-parameterised or floating one from a sync adapter). Those
* are left exactly as they are rather than guessed at — a stale stamp excludes
* nothing, but a mangled one could exclude the wrong occurrence.
*/ */
internal fun exdateContains( internal fun shiftedExdate(
existingExdate: String?, existingExdate: String?,
occurrenceMillis: Long, original: EventForm,
isAllDay: Boolean, updated: EventForm,
): Boolean { zone: ZoneId,
val stamp = formatExdateStamp(occurrenceMillis, isAllDay) ): String? {
return existingExdate?.split(',')?.any { it.trim() == stamp } == true val stamps = exdateStamps(existingExdate)
if (stamps.isEmpty()) return null
val fromZone = original.writeZone(zone)
val toZone = updated.writeZone(zone)
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
return stamps
.map { stamp ->
val local = parseExdateStamp(stamp, original.isAllDay, fromZone) ?: return null
formatExdateStamp(local.plus(wallClockShift), updated.isAllDay, toZone)
}
.distinct()
.joinToString(",")
} }
/**
* The stamps of [existingExdate] naming occurrences after [beginMillis] — the ones
* that belong to the *new* series once "this and following" splits a recurring
* event there. The parent keeps the full list; its stamps past the split point are
* simply inert once it stops generating those occurrences.
*
* The occurrence at [beginMillis] itself is never carried. It is the one the user
* is editing, so it exists by definition, and honouring a stale exclusion for it
* would swallow the edit whole.
*
* Null when nothing qualifies, or when a stamp can't be read (see [shiftedExdate]).
*/
internal fun exdateAfter(existingExdate: String?, beginMillis: Long, isAllDay: Boolean): String? {
val stamps = exdateStamps(existingExdate)
if (stamps.isEmpty()) return null
return stamps
.filter { stamp ->
val utc = parseExdateStamp(stamp, isAllDay, ZoneOffset.UTC) ?: return null
utc.toInstant(ZoneOffset.UTC).toEpochMilli() > beginMillis
}
.takeIf { it.isNotEmpty() }
?.joinToString(",")
}
/** The individual stamps of an EXDATE column value; it is a comma-separated list. */
private fun exdateStamps(exdate: String?): List<String> = exdate
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
/** /**
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms * One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date * are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
* reads off the UTC calendar day. * reads off the UTC calendar day.
*/ */
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String { private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String =
val utc = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC) formatExdateStamp(
return if (isAllDay) { local = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC).toLocalDateTime(),
"%04d%02d%02d".format(utc.year, utc.monthValue, utc.dayOfMonth) isAllDay = isAllDay,
} else { zone = ZoneOffset.UTC,
"%04d%02d%02dT%02d%02d%02dZ".format(
utc.year, utc.monthValue, utc.dayOfMonth,
utc.hour, utc.minute, utc.second,
) )
/**
* One EXDATE entry for the occurrence whose wall clock in [zone] is [local]. An
* all-day entry keeps only the date (its time-of-day is the anchor's, not the
* occurrence's); a timed one is resolved in [zone] and written as a UTC instant.
*/
private fun formatExdateStamp(local: JavaLocalDateTime, isAllDay: Boolean, zone: ZoneId): String =
if (isAllDay) {
local.toLocalDate().format(ALL_DAY_EXDATE)
} else {
local.atZone(zone).withZoneSameInstant(ZoneOffset.UTC).format(TIMED_EXDATE)
} }
}
/**
* [stamp] as the wall clock it names in [zone] — the inverse of
* [formatExdateStamp]. Null for anything but the two forms Calendula writes, so a
* caller can tell "not ours, leave it alone" from a value it may safely re-time.
*/
private fun parseExdateStamp(stamp: String, isAllDay: Boolean, zone: ZoneId): JavaLocalDateTime? =
runCatching {
if (isAllDay) {
JavaLocalDate.parse(stamp, ALL_DAY_EXDATE).atStartOfDay()
} else {
JavaLocalDateTime.parse(stamp, TIMED_EXDATE)
.atZone(ZoneOffset.UTC).withZoneSameInstant(zone).toLocalDateTime()
}
}.getOrNull()
/** `VALUE=DATE` EXDATE form, for an all-day series. */
private val ALL_DAY_EXDATE: DateTimeFormatter =
DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT)
/** UTC date-time EXDATE form, for a timed series. */
private val TIMED_EXDATE: DateTimeFormatter =
DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss'Z'").withResolverStyle(ResolverStyle.STRICT)
/** /**
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A * The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A

View File

@@ -64,7 +64,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -265,16 +264,13 @@ fun EventEditScreen(
viewModel.reset() viewModel.reset()
onSaved() onSaved()
} }
// A failed save leaves the form looking unchanged, so the snackbar is
// the only sign anything happened — long rather than the default
// flash (Codeberg #234: it read as "nothing happens at all").
SaveUiState.Failed -> { SaveUiState.Failed -> {
viewModel.consumeSaveResult() viewModel.consumeSaveResult()
snackbarHostState.showSnackbar(saveFailedMessage, duration = SnackbarDuration.Long) snackbarHostState.showSnackbar(saveFailedMessage)
} }
SaveUiState.NeedsPermission -> { SaveUiState.NeedsPermission -> {
viewModel.consumeSaveResult() viewModel.consumeSaveResult()
snackbarHostState.showSnackbar(writeDeniedMessage, duration = SnackbarDuration.Long) snackbarHostState.showSnackbar(writeDeniedMessage)
} }
// AwaitingScope/AwaitingConflict/Gone render as dialogs below. // AwaitingScope/AwaitingConflict/Gone render as dialogs below.
else -> Unit else -> Unit

View File

@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.ui.edit package de.jeanlucmakiola.calendula.ui.edit
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -57,8 +56,6 @@ import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
private const val TAG = "EventEdit"
/** /**
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources * Where a prefilled [EventEditViewModel.openImported] form came from. The sources
* want different reminder handling (#49), and differ in whether they own the * want different reminder handling (#49), and differ in whether they own the
@@ -754,15 +751,7 @@ class EventEditViewModel @Inject constructor(
throw e throw e
} catch (e: SecurityException) { } catch (e: SecurityException) {
SaveUiState.NeedsPermission SaveUiState.NeedsPermission
} catch (e: NoSuchEventException) {
// The event or occurrence is already gone: the same answer the
// pre-check gives, and better than a bare "couldn't save".
SaveUiState.Gone
} catch (e: Exception) { } catch (e: Exception) {
// The user only gets a generic snackbar, so without this a failed
// write leaves nothing to report (Codeberg #234). Scope and event
// id only — never the form's content.
Log.w(TAG, "Save failed (scope=$scope, eventId=${target?.eventId})", e)
SaveUiState.Failed SaveUiState.Failed
} }
} }

View File

@@ -128,7 +128,8 @@ class EventWriteMapperTest {
original: EventForm, original: EventForm,
updated: EventForm, updated: EventForm,
series: Long = seriesStart, series: Long = seriesStart,
): Map<String, Any?> = buildEventUpdateValues(original, updated, series, berlin) exdate: String? = null,
): Map<String, Any?> = buildEventUpdateValues(original, updated, series, exdate, berlin)
/** The instant [local] names in [zoneId], as the provider would store it. */ /** The instant [local] names in [zoneId], as the provider would store it. */
private fun instantAt(local: String, zoneId: String): Long = private fun instantAt(local: String, zoneId: String): Long =
@@ -558,172 +559,237 @@ class EventWriteMapperTest {
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1) assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
} }
// --- toDetachedOccurrence ("edit only this event", no _sync_id) --- // --- EXDATE follows the series when its times change (Codeberg #248) ---
/** A weekly series whose displayed occurrence runs 15 July 2026, 09:0010:00. */
private fun julySeries(): EventForm = form(
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
).copy(rrule = "FREQ=WEEKLY")
/** [julySeries] pushed to [hour]:00, the shift an "all events" time edit makes. */
private fun EventForm.atHour(hour: Int): EventForm = copy(
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour + 1, 0)),
)
@Test @Test
fun `a detached occurrence drops the series rule and becomes a one-off row`() { fun `a series time edit moves its exclusions with the anchor`() {
val edited = form().copy(title = "Moved", rrule = "FREQ=WEEKLY;BYDAY=TH") // The bug: the stamp stayed at the old instant, which the moved series no
val detached = edited.toDetachedOccurrence() // longer generates, so the occurrence the user deleted came back.
val series = instantAt("2026-07-01T09:00", "Europe/Berlin")
val values = buildEventInsertValues( val original = julySeries()
form = detached, // 8 July 09:00 Berlin (CEST, +2) == 07:00Z; at 10:00 it must read 08:00Z.
uid = "uid@calendula", val values = update(original, original.atHour(10), series, "20260708T070000Z")
times = detached.toWriteTimes(berlin), assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260708T080000Z")
)
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
// A surviving rule would insert a second *series* overlapping the first
// (Codeberg #234's stray duplicate).
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
// A one-off row carries DTEND rather than a duration.
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_170_200_000L)
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
} }
@Test @Test
fun `a detached occurrence carries every edited field onto the new row`() { fun `an exclusion keeps its wall clock across a DST boundary`() {
// Built from the form, not cloned from the parent: a field dropped here // Anchor and edited occurrence are in July (CEST, +2); the excluded
// is an edit silently lost. // occurrence sits in January (CET, +1). Shifting by the millisecond delta
val edited = form(timezone = "America/New_York").copy( // measured at the edited occurrence would leave it an hour off.
title = " Standup ", val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
location = "Room 2", val original = julySeries()
description = "notes", // 14 January 09:00 Berlin == 08:00Z; at 10:00 it must read 09:00Z.
reminders = listOf(10), val values = update(original, original.atHour(10), series, "20260114T080000Z")
availability = Availability.Free, assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260114T090000Z")
accessLevel = AccessLevel.Private,
rrule = "FREQ=DAILY",
)
val detached = edited.toDetachedOccurrence()
val values = buildEventInsertValues(
form = detached,
uid = "uid@calendula",
times = detached.toWriteTimes(berlin),
)
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Standup")
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Room 2")
assertThat(values[CalendarContract.Events.DESCRIPTION]).isEqualTo("notes")
assertThat(values[CalendarContract.Events.AVAILABILITY])
.isEqualTo(CalendarContract.Events.AVAILABILITY_FREE)
assertThat(values[CalendarContract.Events.ACCESS_LEVEL])
.isEqualTo(CalendarContract.Events.ACCESS_PRIVATE)
// The pinned zone survives — never re-anchored to the device.
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York")
assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("uid@calendula")
// Reminders aren't columns; the insert path seeds them from the form.
assertThat(detached.reminders).containsExactly(10)
} }
@Test @Test
fun `a detached all-day occurrence stays on UTC midnights`() { fun `pinning a series to another zone re-resolves its exclusions there`() {
val edited = form( val series = instantAt("2026-07-01T09:00", "Europe/Berlin")
val original = julySeries()
val values = update(
original,
original.copy(timezone = "Asia/Tokyo"),
series,
"20260716T070000Z",
)
// The exclusion still reads 09:00 — now 09:00 in Tokyo (+9) == 00:00Z.
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260716T000000Z")
}
@Test
fun `an all-day series move shifts its exclusions by whole days`() {
val series = instantAt("2026-07-01T00:00", "UTC")
val original = form(
isAllDay = true, isAllDay = true,
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)), start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)), end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(title = "Birthday", rrule = "FREQ=YEARLY") ).copy(rrule = "FREQ=WEEKLY")
val detached = edited.toDetachedOccurrence() val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
val values = buildEventInsertValues( end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
form = detached,
uid = "uid@calendula",
times = detached.toWriteTimes(berlin),
) )
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC") assertThat(update(original, moved, series, "20260722")[CalendarContract.Events.EXDATE])
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_136_000_000L) .isEqualTo("20260724")
// Exclusive DTEND — the next UTC midnight.
assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_222_400_000L)
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
} }
@Test @Test
fun `the detached row lands exactly where the parent's exdate removes it`() { fun `switching a series to all-day rewrites its exclusions as dates`() {
// The two halves must agree on the instant, or the user sees the // The two forms aren't interchangeable: a date-time stamp on an all-day
// occurrence twice or not at all. // series matches no occurrence, so the exclusion would be lost.
val edited = form().copy(title = "Renamed", rrule = "FREQ=WEEKLY") val series = instantAt("2026-07-01T09:00", "Europe/Berlin")
val occurrenceMillis = 1_781_164_800_000L val original = julySeries()
val values = update(original, original.copy(isAllDay = true), series, "20260722T070000Z")
val parent = buildOccurrenceExdateValues( assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260722")
existingExdate = null,
occurrenceMillis = occurrenceMillis,
dtStartMillis = 1_780_560_000_000L,
rrule = "FREQ=WEEKLY",
duration = "P5400S",
timezone = "Europe/Berlin",
allDay = 0,
)
val detached = edited.toDetachedOccurrence()
val inserted = buildEventInsertValues(
form = detached,
uid = "uid@calendula",
times = detached.toWriteTimes(berlin),
)
assertThat(parent[CalendarContract.Events.EXDATE]).isEqualTo("20260611T080000Z")
assertThat(inserted[CalendarContract.Events.DTSTART]).isEqualTo(occurrenceMillis)
// The parent keeps its own anchor and rule — only this occurrence leaves.
assertThat(parent[CalendarContract.Events.DTSTART]).isEqualTo(1_780_560_000_000L)
assertThat(parent[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
} }
@Test @Test
fun `a detached all-day occurrence matches its date-only exdate stamp`() { fun `switching a series back to timed rewrites its exclusions as instants`() {
val parent = buildOccurrenceExdateValues( val series = instantAt("2026-07-01T00:00", "UTC")
existingExdate = null, val original = form(
occurrenceMillis = 1_781_136_000_000L, // 2026-06-11T00:00:00Z
dtStartMillis = 1_749_600_000_000L,
rrule = "FREQ=YEARLY",
duration = "P1D",
timezone = "UTC",
allDay = 1,
)
val detached = form(
isAllDay = true, isAllDay = true,
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)), start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)), end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=YEARLY").toDetachedOccurrence() ).copy(rrule = "FREQ=WEEKLY")
val inserted = buildEventInsertValues( val timed = original.copy(
form = detached, isAllDay = false,
uid = "uid@calendula", start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
times = detached.toWriteTimes(berlin), end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
) )
assertThat(parent[CalendarContract.Events.EXDATE]).isEqualTo("20260611") // The excluded day gains the new 09:00 Berlin time-of-day == 07:00Z.
assertThat(inserted[CalendarContract.Events.DTSTART]).isEqualTo(1_781_136_000_000L) assertThat(update(original, timed, series, "20260722")[CalendarContract.Events.EXDATE])
} .isEqualTo("20260722T070000Z")
// --- exdateContains (guards a second detach of the same occurrence) ---
@Test
fun `an occurrence already excluded is recognised, timed and all-day`() {
// Detaching twice would leave a second standalone copy.
assertThat(
exdateContains("20260611T080000Z", 1_781_164_800_000L, isAllDay = false),
).isTrue()
assertThat(
exdateContains("20260611", 1_781_136_000_000L, isAllDay = true),
).isTrue()
} }
@Test @Test
fun `an occurrence not in the exdate list is not mistaken for an excluded one`() { fun `a text-only edit leaves the exclusions alone`() {
assertThat(exdateContains(null, 1_781_164_800_000L, isAllDay = false)).isFalse() val original = julySeries()
assertThat(exdateContains("", 1_781_164_800_000L, isAllDay = false)).isFalse() val values = update(original, original.copy(title = "Renamed"), exdate = "20260722T070000Z")
// A neighbouring occurrence must not match — the guard is per-instant. assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
assertThat(
exdateContains("20260610T080000Z", 1_781_164_800_000L, isAllDay = false),
).isFalse()
} }
@Test @Test
fun `an exclusion is found anywhere in a multi-entry exdate list`() { fun `changing only the rule rewrites no exclusions`() {
// Whitespace after a comma is legal in the stored column. // The times are untouched, so every surviving occurrence keeps its instant
// and the stamps still name the right ones.
val original = julySeries()
val values = update(original, original.copy(rrule = "FREQ=DAILY"), exdate = "20260722T070000Z")
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=DAILY")
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
}
@Test
fun `an exdate form we do not write is left untouched`() {
// A sync adapter may store a TZID-parameterised or floating stamp. A stale
// stamp excludes nothing; a mangled one could exclude the wrong occurrence.
val original = julySeries()
val values = update(
original,
original.atHour(10),
instantAt("2026-07-01T09:00", "Europe/Berlin"),
"TZID=Europe/Berlin;20260722T090000",
)
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
}
@Test
fun `removing the recurrence clears the exclusions with it`() {
// Dormant, not harmless: adding a recurrence back later would punch the old
// holes into the new one.
val original = julySeries()
val values = update(original, original.copy(rrule = null), exdate = "20260722T070000Z")
assertThat(values).containsEntry(CalendarContract.Events.EXDATE, null)
}
@Test
fun `undoing a series move lands the exclusions back where they started`() {
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
val original = julySeries()
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30)),
)
val exdate = "20260722T070000Z"
val forward = update(original, moved, series, exdate)
val movedExdate = forward[CalendarContract.Events.EXDATE] as String
assertThat(movedExdate).isNotEqualTo(exdate)
val back = update(
moved,
original,
forward[CalendarContract.Events.DTSTART] as Long,
movedExdate,
)
assertThat(back[CalendarContract.Events.EXDATE]).isEqualTo(exdate)
}
@Test
fun `a series with no exclusions writes no exdate column`() {
val series = instantAt("2026-07-01T09:00", "Europe/Berlin")
val original = julySeries()
assertThat(update(original, original.atHour(10), series, exdate = null))
.doesNotContainKey(CalendarContract.Events.EXDATE)
}
// --- exdateAfter (the exclusions a "this and following" split inherits) ---
@Test
fun `a split carries only the exclusions past the split point`() {
// The occurrence at the split point is the one being edited, so it exists
// by definition — a stale exclusion for it would swallow the edit whole.
assertThat( assertThat(
exdateContains( exdateAfter(
"20260604T080000Z, 20260611T080000Z,20260618T080000Z", existingExdate = "20260708T080000Z,20260715T080000Z,20260722T080000Z",
1_781_164_800_000L, beginMillis = instantAt("2026-07-15T08:00", "UTC"),
isAllDay = false, isAllDay = false,
), ),
).isTrue() ).isEqualTo("20260722T080000Z")
}
@Test
fun `a split carries nothing when every exclusion is behind it`() {
assertThat(
exdateAfter("20260708T080000Z", instantAt("2026-07-15T08:00", "UTC"), isAllDay = false),
).isNull()
assertThat(exdateAfter(null, 0L, isAllDay = false)).isNull()
}
@Test
fun `all-day exclusions split on their UTC date`() {
assertThat(
exdateAfter("20260708,20260722", instantAt("2026-07-15T00:00", "UTC"), isAllDay = true),
).isEqualTo("20260722")
}
@Test
fun `an unreadable exclusion carries nothing across a split`() {
assertThat(
exdateAfter(
"20260722T080000Z,TZID=Europe/Berlin;20260729T100000",
instantAt("2026-07-15T08:00", "UTC"),
isAllDay = false,
),
).isNull()
}
@Test
fun `split exclusions move by the same shift as the new series start`() {
// What the split path composes: filter to the tail, then re-time it by the
// shift the user applied to the occurrence they split at.
val original = julySeries()
val carried = shiftedExdate(
existingExdate = exdateAfter(
"20260716T070000Z",
instantAt("2026-07-15T07:00", "UTC"),
isAllDay = false,
),
original = original,
updated = original.atHour(11),
zone = berlin,
)
// 16 July 09:00 Berlin, pushed two hours, is 11:00 Berlin == 09:00Z.
assertThat(carried).isEqualTo("20260716T090000Z")
}
@Test
fun `nothing to shift yields no exdate`() {
assertThat(shiftedExdate(null, julySeries(), julySeries().atHour(10), berlin)).isNull()
assertThat(shiftedExdate(" ", julySeries(), julySeries().atHour(10), berlin)).isNull()
} }
// --- per-event colour --- // --- per-event colour ---