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 cbc82e53..6ee39d62 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 @@ -798,27 +798,30 @@ class AndroidCalendarDataSource @Inject constructor( it != calendar?.color || !getString(EventExportProjection.IDX_EVENT_COLOR_KEY).isNullOrEmpty() } val mapped = event(calendar?.displayName).copy(color = color) + val declined = getIntOrNull(EventExportProjection.IDX_SELF_ATTENDEE_STATUS) == + CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED val originalId = getLongOrNull(EventExportProjection.IDX_ORIGINAL_ID) val originalSyncId = getString(EventExportProjection.IDX_ORIGINAL_SYNC_ID)?.takeIf { it.isNotEmpty() } - val originalInstance = getLongOrNull(EventExportProjection.IDX_ORIGINAL_INSTANCE_TIME) - if (originalId == null && originalSyncId == null && originalInstance == null) { + // Without a link the provider shows the row as an event of its own. + if (originalId == null && originalSyncId == null) { series += MasterRow( eventId = getLong(EventExportProjection.IDX_ID), calendarId = calendarId, syncId = getString(EventExportProjection.IDX_SYNC_ID)?.takeIf { it.isNotEmpty() }, event = mapped, - declined = getIntOrNull(EventExportProjection.IDX_SELF_ATTENDEE_STATUS) == - CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED, + declined = declined, ) } else { // An exception without its occurrence time can't name what it replaces. - if (originalInstance == null) return@forEachExportRow + val originalInstance = getLongOrNull(EventExportProjection.IDX_ORIGINAL_INSTANCE_TIME) + ?: return@forEachExportRow overrides += OverrideRow( calendarId = calendarId, originalId = originalId, originalSyncId = originalSyncId, originalInstance = originalInstance.toKotlinInstantFromEpochMillis(), event = mapped, + declined = declined, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt index 9d85a350..d2aac8a9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt @@ -295,9 +295,9 @@ class CalendarRepositoryImpl @Inject constructor( var skipped = 0 var failed = 0 var notAttempted = 0 - // Series whose standalone occurrence didn't land: inserted, they would - // exclude it for good, since a re-run skips it next to its series. - val held = mutableSetOf() + // Occurrences whose standalone copy didn't land, by series UID: the series + // keeps them instead of excluding them for good. + val unexcluded = mutableMapOf>() for ((index, event) in events.withIndex()) { if (index > 0) onProgress(index) // A known UID means the event is already in this calendar — skip, @@ -306,12 +306,8 @@ class CalendarRepositoryImpl @Inject constructor( skipped++ continue } - if (event.uid != null && event.uid in held) { - failed++ - continue - } try { - dataSource.insertImportedEvent(event, targetCalendarId, allDayMinutes, palette) + dataSource.insertImportedEvent(event.keeping(unexcluded), targetCalendarId, allDayMinutes, palette) imported++ } catch (e: CancellationException) { throw e @@ -331,7 +327,9 @@ class CalendarRepositoryImpl @Inject constructor( // rest of the file — foreign exports do carry rows it rejects // outright (a malformed RRULE throws straight out of insert). failed++ - event.overrideOf?.let(held::add) + if (event.overrideOf != null && event.overrideAt != null) { + unexcluded.getOrPut(event.overrideOf) { mutableListOf() } += event.overrideAt + } Log.w(TAG, "Skipped an unimportable event", e) } } @@ -424,3 +422,10 @@ private fun Flow.reQuery(block: suspend () -> T): Flow = flow { */ private fun ParsedIcsEvent.isAlreadyIn(existing: Set): Boolean = (uid != null && uid in existing) || (overrideOf != null && overrideOf in existing) + +/** This series without the exclusions of [unexcluded] occurrences whose standalone copy failed. */ +private fun ParsedIcsEvent.keeping(unexcluded: Map>): ParsedIcsEvent { + val kept = uid?.let(unexcluded::get) ?: return this + val stamps = kept.map { formatExdateStamp(it.toEpochMilliseconds(), isAllDay) }.toSet() + return copy(exDates = exDates.filterNot { it in stamps }) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/CalendarCopy.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/CalendarCopy.kt index a03c260c..502e90d8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/CalendarCopy.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/CalendarCopy.kt @@ -7,7 +7,7 @@ import kotlin.time.Duration.Companion.days * The events a calendar copy (#325) inserts into its target, in the shape * `importEvents` takes: the source's rows flattened as [flattenOverrides] does * for a backup, minus cancelled series and one-offs, which would arrive live - * (an insert can't be cancelled), and declined invitations. Attendees and organizer are never read. + * (an insert can't be cancelled), and declined invitations and occurrences. Attendees and organizer are never read. * * Idempotent per snapshot: `importEvents` skips a UID the target already holds, * and an [IcsEvent.overrideOf] occurrence whose series it holds, so a re-run @@ -18,7 +18,13 @@ fun planCalendarCopy( rows: CalendarRows, exdateStamp: (occurrenceMillis: Long, isAllDay: Boolean) -> String, ): List { - val live = rows.copy(series = rows.series.filterNot { it.event.status == EventStatus.Cancelled || it.declined }) + val live = CalendarRows( + series = rows.series.filterNot { it.event.status == EventStatus.Cancelled || it.declined }, + // A declined occurrence is as good as deleted here. + overrides = rows.overrides.map { + if (it.declined) it.copy(event = it.event.copy(status = EventStatus.Cancelled)) else it + }, + ) return flattenOverrides(live, exdateStamp).map { it.toParsedIcsEvent() } } @@ -48,5 +54,6 @@ fun IcsEvent.toParsedIcsEvent(): ParsedIcsEvent = ParsedIcsEvent( accessLevel = accessLevel, color = color, overrideOf = overrideOf, + overrideAt = overrideAt, exDates = if (recurs) exDates.filter { it.isNotBlank() }.distinct() else emptyList(), ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/CalendarRows.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/CalendarRows.kt index fa5d4810..56ab2cbb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/CalendarRows.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/CalendarRows.kt @@ -27,6 +27,8 @@ data class OverrideRow( val originalSyncId: String?, val originalInstance: Instant, val event: IcsEvent, + /** The user declined this occurrence. */ + val declined: Boolean = false, ) { val isCancelled: Boolean get() = event.status == EventStatus.Cancelled } @@ -81,6 +83,7 @@ fun flattenOverrides( recurrenceDates = null, exDates = emptyList(), overrideOf = parent.event.uid, + overrideAt = override.originalInstance, ) } // Two exception rows for one occurrence would derive the same UID; the same diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsEvent.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsEvent.kt index a24f8673..9cb23a55 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsEvent.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsEvent.kt @@ -48,6 +48,8 @@ data class IcsEvent( * UID of the series it replaces an occurrence of, as `X-CALENDULA-OVERRIDE-OF`. */ val overrideOf: String? = null, + /** The occurrence of [overrideOf] this event replaces, as `X-CALENDULA-OVERRIDE-AT`. */ + val overrideAt: Instant? = null, ) /** Whether this is a series: by rule, or by listed dates alone. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt index 0a48e44f..b4e15bcf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt @@ -52,6 +52,8 @@ data class ParsedIcsEvent( * already is, since the series there carries its own override. */ val overrideOf: String? = null, + /** The occurrence of [overrideOf] this event replaces (`X-CALENDULA-OVERRIDE-AT`). */ + val overrideAt: Instant? = null, /** The provider's raw `RDATE`, only set by a calendar copy. */ val recurrenceDates: String? = null, val accessLevel: AccessLevel = AccessLevel.Default, @@ -224,6 +226,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault val exDateLines = mutableListOf() var skipAsOverride = false var overrideOf: String? = null + var overrideAt: Instant? = null var i = 0 while (i < body.size) { @@ -253,6 +256,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault "RECURRENCE-ID" -> skipAsOverride = true "ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored) "X-CALENDULA-OVERRIDE-OF" -> overrideOf = unescapeText(line.value).trim().ifEmpty { null } + "X-CALENDULA-OVERRIDE-AT" -> overrideAt = parseBasicDateTime(line.value.trim())?.toInstant(TimeZone.UTC) "X-CALENDULA-CALENDAR" -> calendarName = unescapeText(line.value).trim().ifEmpty { calendarName } // Fossify names the source calendar per event; it never writes @@ -313,6 +317,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault }, isTask = isTask, overrideOf = overrideOf, + overrideAt = overrideAt, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt index 833c0c22..a0a039be 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt @@ -58,6 +58,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) { ?.let { add("X-CALENDULA-CALENDAR:${escapeText(it)}") } event.overrideOf?.takeIf { it.isNotBlank() } ?.let { add("X-CALENDULA-OVERRIDE-OF:${escapeText(it)}") } + event.overrideAt?.let { add("X-CALENDULA-OVERRIDE-AT:${it.toUtcBasic()}") } event.reminderMinutes.filter { it >= 0 }.distinct().forEach { minutes -> appendAlarm(minutes, event.summary) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt index 4e68c341..3aad783d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt @@ -305,6 +305,7 @@ private fun BackupContent( leading = { LeadingAvatar(Icons.Default.FileUpload) }, onClick = onRestoreClick, ) + if (copyRun != CopyRunState.Idle) CopyEventsRow(copyRun, Position.Alone, onCopy) return } @@ -324,23 +325,7 @@ private fun BackupContent( onClick = onRestoreClick, ) // A running or finished copy keeps its row, the way back to its sheet. - if (canCopy || copyRun != CopyRunState.Idle) { - GroupedRow( - title = stringResource(R.string.calendars_copy_action), - summary = when (copyRun) { - is CopyRunState.Running -> copyProgressText(copyRun) - is CopyRunState.Done -> stringResource( - if (copyRun.stopped) R.string.calendars_copy_stopped_title else R.string.calendars_copy_done, - ) - CopyRunState.Cancelled -> stringResource(R.string.calendars_copy_cancelled) - CopyRunState.Failed -> stringResource(R.string.calendars_copy_failed) - CopyRunState.Idle -> stringResource(R.string.calendars_copy_summary) - }, - position = Position.Middle, - leading = { LeadingAvatar(Icons.Default.ContentCopy) }, - onClick = onCopy, - ) - } + if (canCopy || copyRun != CopyRunState.Idle) CopyEventsRow(copyRun, Position.Middle, onCopy) GroupedRow( title = stringResource(R.string.calendars_auto_backup), summary = stringResource(R.string.calendars_auto_backup_hint), @@ -406,6 +391,25 @@ private fun BackupContent( } } +@Composable +private fun CopyEventsRow(copyRun: CopyRunState, position: Position, onClick: () -> Unit) { + GroupedRow( + title = stringResource(R.string.calendars_copy_action), + summary = when (copyRun) { + is CopyRunState.Running -> copyProgressText(copyRun) + is CopyRunState.Done -> stringResource( + if (copyRun.stopped) R.string.calendars_copy_stopped_title else R.string.calendars_copy_done, + ) + CopyRunState.Cancelled -> stringResource(R.string.calendars_copy_cancelled) + CopyRunState.Failed -> stringResource(R.string.calendars_copy_failed) + CopyRunState.Idle -> stringResource(R.string.calendars_copy_summary) + }, + position = position, + leading = { LeadingAvatar(Icons.Default.ContentCopy) }, + onClick = onClick, + ) +} + /** * Choose which calendars to include in a one-time `.ics` export. Starts on the * calendars a backup covers by default, or on all of them when none is; the Export action opens the SAF save dialog and hands back diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiState.kt index f34c1619..55a70958 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiState.kt @@ -65,8 +65,9 @@ fun backupUiState(calendars: List): BackupUiState { */ fun copySources(calendars: List): List { val targets = calendars.filter { it.isEventTarget } + val onlyTarget = targets.singleOrNull()?.id if (targets.isEmpty()) return emptyList() - return calendars.filter { source -> source.isBackupCandidate && targets.any { it.id != source.id } } + return calendars.filter { it.isBackupCandidate && it.id != onlyTarget } } /** Calendars a copy from [sourceId] can write into: the import targets, minus the source itself. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CopyEventsSheet.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CopyEventsSheet.kt index 3f41aaea..71e6450e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CopyEventsSheet.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CopyEventsSheet.kt @@ -16,6 +16,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -52,9 +53,9 @@ fun CopyEventsSheet( onDismiss: () -> Unit, ) { val list = (calendars as? CopyCalendars.Ready)?.calendars.orEmpty() - val sources = copySources(list) + val sources = remember(list) { copySources(list) } val source = sources.firstOrNull { it.id == selection.sourceId } - val targets = copyTargets(list, source?.id) + val targets = remember(list, source?.id) { copyTargets(list, source?.id) } val target = targets.firstOrNull { it.id == selection.targetId } val running = run is CopyRunState.Running var picking by rememberSaveable { mutableStateOf(null) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c40971b5..9c8cf4a8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -891,7 +891,7 @@ No calendar can take the imported events yet. Copy events Copy one calendar\'s events into another, for example to move off Google. - Copies every event, including repeating ones with the occurrences you changed or deleted. Guests and invitations stay behind, and the original calendar is left as it is. + Copies your events, including repeating ones with the occurrences you changed or deleted. Cancelled events and invitations you declined are left out, guests stay behind, and the original calendar is left as it is. From To Choose a calendar diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt index 6f81698a..0dd24eb1 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt @@ -1047,19 +1047,23 @@ class CalendarRepositoryImplTest { } @Test - fun `a series waits for a standalone occurrence that didn't land`( + fun `a series keeps an occurrence whose standalone copy didn't land`( @TempDir tempDir: Path, ) = runTest { val fake = FakeCalendarDataSource().apply { failingImportSummaries += "moved" } val repo = testRepository(fake, tempDir, Dispatchers.Unconfined) + val at = Instant.parse("2026-01-12T09:00:00Z") val summary = repo.importEvents( 3L, - listOf(parsedEvent("1-s", summary = "moved").copy(overrideOf = "s"), parsedEvent("s"), parsedEvent("t")), + listOf( + parsedEvent("1-s", summary = "moved").copy(overrideOf = "s", overrideAt = at), + parsedEvent("s").copy(recurrenceRule = "FREQ=WEEKLY", exDates = listOf("20260112T090000Z", "20260119T090000Z")), + ), ) - assertThat(fake.importedEvents.map { it.first.uid }).containsExactly("t") - assertThat(summary.failed).isEqualTo(2) + assertThat(summary.failed).isEqualTo(1) + assertThat(fake.importedEvents.single().first.exDates).containsExactly("20260119T090000Z") } @Test diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/CalendarRowsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/CalendarRowsTest.kt index 11dab970..d6fb3d40 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/CalendarRowsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/CalendarRowsTest.kt @@ -181,9 +181,11 @@ class CalendarRowsTest { @Test fun `the override link survives the file`() { - val text = IcsWriter().writeCalendar(listOf(event("d").copy(overrideOf = "s;1")), start) + val text = IcsWriter().writeCalendar(listOf(event("d").copy(overrideOf = "s;1", overrideAt = occurrence(1))), start) - assertThat(IcsParser().parse(text).events.single().overrideOf).isEqualTo("s;1") + val parsed = IcsParser().parse(text).events.single() + assertThat(parsed.overrideOf).isEqualTo("s;1") + assertThat(parsed.overrideAt).isEqualTo(occurrence(1)) } @Test @@ -215,4 +217,16 @@ class CalendarRowsTest { assertThat(planCalendarCopy(rows, ::formatExdateStamp).map { it.uid }).containsExactly("k") } + + @Test + fun `a copy treats a declined occurrence as deleted`() { + val rows = CalendarRows( + listOf(series("s")), + listOf(OverrideRow(1, 1, null, occurrence(1), event("x"), declined = true)), + ) + + val plan = planCalendarCopy(rows, ::formatExdateStamp) + assertThat(plan.map { it.uid }).containsExactly("s") + assertThat(plan.single().exDates).containsExactly("20260112T090000Z") + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f5a319ba..da369688 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -507,10 +507,10 @@ times. Its UID is derived from the series UID and the occurrence, so it is the same in every backup, and it carries the series UID as `X-CALENDULA-OVERRIDE-OF`. `importEvents` skips such an occurrence wherever its series already is — that series there has the override itself — so restoring into the calendar a backup -came from adds nothing twice, while a restore into an empty one gets both. If -the standalone occurrence fails to insert, its series is held back too (counted -as not added), since inserted with the occurrence excluded it would make every -re-run skip the occurrence for good. +came from adds nothing twice, while a restore into an empty one gets both. The +standalone event also names its occurrence (`X-CALENDULA-OVERRIDE-AT`); if it +fails to insert, the series is inserted without that exclusion, so the +occurrence stays at its original time rather than vanishing for good. Exceptions whose series isn't among the rows are left out, a cancelled series' moved occurrences stay cancelled, and sync ids link only within their own calendar. A file can't carry `RDATE`, so a backup treats a series that @@ -522,7 +522,7 @@ flattened rows straight into `importEvents`, so the UID dedup makes a re-run ski what landed before. It skips the `.ics` text: the writer pins a timed one-off to UTC and carries no colour, visibility or `RDATE`; the direct mapping keeps them. Attendees and organizer are never read, and cancelled events and declined -invitations are left out (an insert can't be cancelled; it would arrive live). A re-run is idempotent per +invitations are left out (a declined occurrence counts as deleted) (an insert can't be cancelled; it would arrive live). A re-run is idempotent per snapshot: a series already in the target keeps its overrides, so an occurrence changed since keeps its earlier copy rather than gaining a second one.