diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt index ff56ba3c..8b08590c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt @@ -41,8 +41,7 @@ internal fun ColumnReader.toIcsEvent( start = dtStart.toKotlinInstantFromEpochMillis(), end = end.toKotlinInstantFromEpochMillis(), isAllDay = isAllDay, - zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() } - ?: "UTC", + zoneId = timezone?.takeIf { it.isNotBlank() } ?: "UTC", recurrenceRule = rrule, exDates = if (rrule == null && rdates.isEmpty()) { emptyList() @@ -121,11 +120,13 @@ internal fun exportRecurrenceDates( eventTimezone: String? = null, ): List { if (rdate.isNullOrBlank()) return emptyList() - // The zone prefix may hold a '/' of its own (Europe/Berlin), so only the stamps lose their period end. - val stamps = rdate.withoutExdateParameters() - val prefix = rdate.dropLast(stamps.length) - val starts = prefix + stamps.split(',').joinToString(",") { it.substringBefore('/') } - return exportExDates(starts, isAllDay, eventTimezone).filter { RECURRENCE_DATE.matches(it) } + // Several RDATE properties are stored one per line, each with its own zone prefix. + return rdate.lines().filter { it.isNotBlank() }.flatMap { line -> + // The zone prefix may hold a '/' of its own (Europe/Berlin), so only the stamps lose their period end. + val stamps = line.withoutExdateParameters() + val starts = line.dropLast(stamps.length) + stamps.split(',').joinToString(",") { it.substringBefore('/') } + exportExDates(starts, isAllDay, eventTimezone) + }.filter { RECURRENCE_DATE.matches(it) }.distinct() } private val RECURRENCE_DATE = Regex("""\d{8}(T\d{6}Z)?""") 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 36dfb060..98abc64b 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 @@ -19,13 +19,17 @@ fun planCalendarCopy( exdateStamp: (occurrenceMillis: Long, isAllDay: Boolean) -> String, ): List { val live = CalendarRows( - series = rows.series.filterNot { it.event.status == EventStatus.Cancelled || it.declined }, + series = rows.series.filterNot { it.event.status == EventStatus.Cancelled }, // 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() } + // A declined series still hands its accepted occurrences on before it goes. + val declined = rows.series.filter { it.declined }.map { it.event.uid }.toSet() + return flattenOverrides(live, exdateStamp) + .filterNot { it.overrideOf == null && it.uid in declined } + .map { it.toParsedIcsEvent() } } /** 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 f3deed61..1cf5e806 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 @@ -89,12 +89,6 @@ 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. - */ /** Extra occurrences: the same UTC stamps / `VALUE=DATE` days as [appendExDates]. */ private fun MutableList.appendRecurrenceDates(event: IcsEvent) { val stamps = event.recurrenceDates.filter { it.isNotBlank() }.distinct() @@ -103,6 +97,12 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) { add(prefix + stamps.joinToString(",")) } + /** + * 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.appendExDates(event: IcsEvent) { if (!event.recurs) return val stamps = event.exDates.filter { it.isNotBlank() }.distinct() 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 adf6b33e..b4382b14 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 @@ -399,7 +399,11 @@ private fun CopyEventsRow(copyRun: CopyRunState, position: Position, onClick: () 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, + when { + copyRun.stopped -> R.string.calendars_copy_stopped_title + copyRun.summary.failed > 0 -> R.string.calendars_copy_partial + else -> R.string.calendars_copy_done + }, ) CopyRunState.Cancelled -> stringResource(R.string.calendars_copy_cancelled) CopyRunState.Failed -> stringResource(R.string.calendars_copy_failed) 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 71e6450e..720f8ffb 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 @@ -53,11 +53,12 @@ fun CopyEventsSheet( onDismiss: () -> Unit, ) { val list = (calendars as? CopyCalendars.Ready)?.calendars.orEmpty() + val running = run is CopyRunState.Running val sources = remember(list) { copySources(list) } - val source = sources.firstOrNull { it.id == selection.sourceId } + // A running copy keeps showing its source even if it stopped qualifying meanwhile. + val source = (if (running) list else sources).firstOrNull { it.id == selection.sourceId } 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) } FullScreenPicker( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9c8cf4a8..791612c5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -905,6 +905,7 @@ Copy complete Copy stopped + Copy finished, some events couldn\'t be added No calendar can be copied into another one right now. Copy cancelled. Run it again to finish: events that were already copied are skipped. Couldn\'t copy the events. diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt index fbd18068..01a2fc6d 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt @@ -306,4 +306,10 @@ class IcsExportMapperTest { assertThat(exportRecurrenceDates("20260110T000000Z,20260111,garbage", isAllDay = true)) .containsExactly("20260110", "20260111").inOrder() } + + @Test + fun `an RDATE stored over several lines keeps every line's dates`() { + assertThat(exportRecurrenceDates("Europe/Berlin;20260110T100000\nAmerica/New_York;20260112T090000", isAllDay = false)) + .containsExactly("20260110T090000Z", "20260112T140000Z").inOrder() + } } 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 5064de0a..8f7c9e68 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 @@ -212,10 +212,14 @@ class CalendarRowsTest { } @Test - fun `a copy leaves declined invitations out`() { - val rows = CalendarRows(listOf(series("d").copy(declined = true), series("k", id = 2)), emptyList()) + fun `a copy leaves declined invitations out but keeps an occurrence accepted on its own`() { + val rows = CalendarRows( + listOf(series("d").copy(declined = true), series("k", id = 2)), + listOf(OverrideRow(1, 1, null, occurrence(1), event("accepted"))), + ) - assertThat(planCalendarCopy(rows, ::formatExdateStamp).map { it.uid }).containsExactly("k") + val plan = planCalendarCopy(rows, ::formatExdateStamp) + assertThat(plan.map { it.summary }).containsExactly("accepted", "k") } @Test diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bced3eec..cc6ff22e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -521,7 +521,8 @@ as before. **`RDATE` travels like `EXDATE` (#515).** A series can repeat by listed dates alone. The provider stores `RDATE` in the same `;` form as `EXDATE`, so `exportRecurrenceDates` resolves it the same way to UTC stamps (or -bare days for an all-day series); a `PERIOD` entry keeps its start. `IcsWriter` +bare days for an all-day series), line by line where several `RDATE`s were +stored; a `PERIOD` entry keeps its start. `IcsWriter` writes it as `RDATE:` / `RDATE;VALUE=DATE:`, anchors such a series to its zone like an RRULE series, and writes its `EXDATE` too; `IcsParser` reads `RDATE` through the same normalisation as `EXDATE`, so a foreign `TZID` or floating