fix(#515, #325): review round 1 - multi-line RDATE, accepted occurrences of declined series copied, partial copies say so

This commit is contained in:
makiolaj committed 2026-11-14 03:01:37 +01:00
1 parent 30af150197
commit 028d8d0e1e
9 files changed
+44 -22

No files matched your search

@@ -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<String> {
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)?""")
@@ -19,13 +19,17 @@ fun planCalendarCopy(
exdateStamp: (occurrenceMillis: Long, isAllDay: Boolean) -> String,
): List<ParsedIcsEvent> {
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() }
}
/**
@@ -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<String>.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<String>.appendExDates(event: IcsEvent) {
if (!event.recurs) return
val stamps = event.exDates.filter { it.isNotBlank() }.distinct()
@@ -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)
@@ -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<CopyEnd?>(null) }
FullScreenPicker(
+1
View File
@@ -905,6 +905,7 @@
</plurals>
<string name="calendars_copy_done">Copy complete</string>
<string name="calendars_copy_stopped_title">Copy stopped</string>
<string name="calendars_copy_partial">Copy finished, some events couldn\'t be added</string>
<string name="calendars_copy_none">No calendar can be copied into another one right now.</string>
<string name="calendars_copy_cancelled">Copy cancelled. Run it again to finish: events that were already copied are skipped.</string>
<string name="calendars_copy_failed">Couldn\'t copy the events.</string>
@@ -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()
}
}
@@ -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
+2 -1
View File
@@ -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 `<zone>;<stamps>` 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