fix(#515, #325): review round 1 - multi-line RDATE, accepted occurrences of declined series copied, partial copies say so
This commit is contained in:
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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user