feat(#325): copy events from one calendar into another
This commit is contained in:
1 parent
c6901c9c63
commit
e5d9b4965b
15 files changed
+882
-14
No files matched your search
@@ -30,6 +30,9 @@ import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.ics.CalendarCopyRows
|
||||
import de.jeanlucmakiola.calendula.domain.ics.CopyException
|
||||
import de.jeanlucmakiola.calendula.domain.ics.CopySeries
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes
|
||||
@@ -100,6 +103,13 @@ interface CalendarDataSource {
|
||||
*/
|
||||
fun calendarEvents(calendarIds: Set<Long>, allDayReminderTimeMinutes: Int): List<IcsEvent>
|
||||
|
||||
/**
|
||||
* Every event row of [calendarId] for a calendar copy (#325): the
|
||||
* masters/one-offs [calendarEvents] reads, plus the modified and cancelled
|
||||
* exception rows it leaves out, each with the keys that link it to its series.
|
||||
*/
|
||||
fun calendarCopyRows(calendarId: Long, allDayReminderTimeMinutes: Int): CalendarCopyRows
|
||||
|
||||
/**
|
||||
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
||||
* dedup an `.ics` import so re-importing a backup doesn't double events.
|
||||
@@ -788,12 +798,60 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
.associate { it.id to it.displayName }
|
||||
if (names.isEmpty()) return emptyList()
|
||||
|
||||
val idList = names.keys.joinToString(",")
|
||||
// Masters + one-offs only: no soft-deleted or exception rows.
|
||||
val selection = "${CalendarContract.Events.CALENDAR_ID} IN ($idList) AND " +
|
||||
val selection = "${CalendarContract.Events.CALENDAR_ID} IN (${names.keys.joinToString(",")}) AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||
"${CalendarContract.Events.ORIGINAL_ID} IS NULL AND " +
|
||||
"${CalendarContract.Events.ORIGINAL_SYNC_ID} IS NULL"
|
||||
val events = mutableListOf<IcsEvent>()
|
||||
forEachExportRow(selection, allDayReminderTimeMinutes) { event ->
|
||||
events += event(names[getLong(EventExportProjection.IDX_CALENDAR_ID)])
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
override fun calendarCopyRows(calendarId: Long, allDayReminderTimeMinutes: Int): CalendarCopyRows {
|
||||
val selection = "${CalendarContract.Events.CALENDAR_ID} = $calendarId AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0"
|
||||
val series = mutableListOf<CopySeries>()
|
||||
val exceptions = mutableListOf<CopyException>()
|
||||
forEachExportRow(selection, allDayReminderTimeMinutes) { event ->
|
||||
val color = getIntOrNull(EventExportProjection.IDX_EVENT_COLOR)
|
||||
val originalId = getLongOrNull(EventExportProjection.IDX_ORIGINAL_ID)
|
||||
val originalSyncId = getString(EventExportProjection.IDX_ORIGINAL_SYNC_ID)?.takeIf { it.isNotEmpty() }
|
||||
if (originalId == null && originalSyncId == null) {
|
||||
series += CopySeries(
|
||||
eventId = getLong(EventExportProjection.IDX_ID),
|
||||
syncId = getString(EventExportProjection.IDX_SYNC_ID)?.takeIf { it.isNotEmpty() },
|
||||
event = event(null),
|
||||
color = color,
|
||||
)
|
||||
} else {
|
||||
// An exception without its occurrence time can't name what it replaces.
|
||||
val originalInstance = getLongOrNull(EventExportProjection.IDX_ORIGINAL_INSTANCE_TIME)
|
||||
?: return@forEachExportRow
|
||||
exceptions += CopyException(
|
||||
originalId = originalId,
|
||||
originalSyncId = originalSyncId,
|
||||
originalInstance = originalInstance.toKotlinInstantFromEpochMillis(),
|
||||
event = event(null),
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
return CalendarCopyRows(series, exceptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand each Events row matching [selection], oldest first, to [row] with a
|
||||
* mapper to [IcsEvent] that takes the calendar name to tag it with.
|
||||
* Reminders are read in one batched pass ahead of the rows.
|
||||
*/
|
||||
private fun forEachExportRow(
|
||||
selection: String,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
row: ColumnReader.(event: (calendarName: String?) -> IcsEvent) -> Unit,
|
||||
) {
|
||||
val eventIds = resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Events._ID),
|
||||
@@ -802,7 +860,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
null,
|
||||
)?.use { c -> c.mapRows { getLong(0) } }.orEmpty()
|
||||
val reminders = resolver.reminderMinutesByEvent(eventIds, alertsOnly = false)
|
||||
return resolver.query(
|
||||
resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
EventExportProjection.COLUMNS,
|
||||
selection,
|
||||
@@ -811,14 +869,15 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
)?.use { c ->
|
||||
c.mapRows {
|
||||
val eventId = getLong(EventExportProjection.IDX_ID)
|
||||
val calendarId = getLong(EventExportProjection.IDX_CALENDAR_ID)
|
||||
toIcsEvent(
|
||||
reminderMinutes = reminders[eventId].orEmpty(),
|
||||
calendarName = names[calendarId],
|
||||
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
|
||||
)
|
||||
row { calendarName ->
|
||||
toIcsEvent(
|
||||
reminderMinutes = reminders[eventId].orEmpty(),
|
||||
calendarName = calendarName,
|
||||
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override fun existingUids(calendarId: Long): Set<String> = resolver.query(
|
||||
|
||||
@@ -71,8 +71,27 @@ interface CalendarRepository {
|
||||
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose
|
||||
* UID already exists in the target are skipped (idempotent restore); the
|
||||
* rest are inserted. See [CalendarDataSource.insertImportedEvent].
|
||||
* [onProgress] gets the count of events handled so far after each one; a
|
||||
* cancelled caller stops the import between events.
|
||||
*/
|
||||
suspend fun importEvents(targetCalendarId: Long, events: List<ParsedIcsEvent>): IcsImportSummary
|
||||
suspend fun importEvents(
|
||||
targetCalendarId: Long,
|
||||
events: List<ParsedIcsEvent>,
|
||||
onProgress: (done: Int) -> Unit = {},
|
||||
): IcsImportSummary
|
||||
|
||||
/**
|
||||
* Copy every event of [sourceCalendarId] into [targetCalendarId] (#325),
|
||||
* exceptions included (see
|
||||
* [de.jeanlucmakiola.calendula.domain.ics.planCalendarCopy]). The source is only read. Runs
|
||||
* through [importEvents], so a re-run skips what an earlier one copied.
|
||||
* [onProgress] gets (done, total) once the source is read and after each event.
|
||||
*/
|
||||
suspend fun copyEvents(
|
||||
sourceCalendarId: Long,
|
||||
targetCalendarId: Long,
|
||||
onProgress: (done: Int, total: Int) -> Unit = { _, _ -> },
|
||||
): IcsImportSummary
|
||||
|
||||
/** Create a new event from a validated form; returns the new `Events._ID`. */
|
||||
suspend fun createEvent(form: EventForm): Long
|
||||
|
||||
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.planCalendarCopy
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -30,6 +31,8 @@ import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.time.Instant
|
||||
@@ -250,9 +253,22 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
override suspend fun exportEvents(calendarIds: Set<Long>) =
|
||||
withContext(io) { dataSource.calendarEvents(calendarIds, allDayReminderTimeMinutes()) }
|
||||
|
||||
override suspend fun copyEvents(
|
||||
sourceCalendarId: Long,
|
||||
targetCalendarId: Long,
|
||||
onProgress: (done: Int, total: Int) -> Unit,
|
||||
): IcsImportSummary {
|
||||
val events = withContext(io) {
|
||||
planCalendarCopy(dataSource.calendarCopyRows(sourceCalendarId, allDayReminderTimeMinutes()))
|
||||
}
|
||||
onProgress(0, events.size)
|
||||
return importEvents(targetCalendarId, events) { done -> onProgress(done, events.size) }
|
||||
}
|
||||
|
||||
override suspend fun importEvents(
|
||||
targetCalendarId: Long,
|
||||
events: List<ParsedIcsEvent>,
|
||||
onProgress: (done: Int) -> Unit,
|
||||
): IcsImportSummary = write {
|
||||
val existing = dataSource.existingUids(targetCalendarId)
|
||||
// Both are per-calendar, not per-event: looking them up once keeps a
|
||||
@@ -266,6 +282,8 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
var failed = 0
|
||||
var notAttempted = 0
|
||||
for ((index, event) in events.withIndex()) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
if (index > 0) onProgress(index)
|
||||
// A known UID means the event is already in this calendar — skip,
|
||||
// keeping a restore idempotent (no overwrite this pass).
|
||||
if (event.uid != null && event.uid in existing) {
|
||||
@@ -296,6 +314,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
Log.w(TAG, "Skipped an unimportable event", e)
|
||||
}
|
||||
}
|
||||
if (notAttempted == 0) onProgress(events.size)
|
||||
IcsImportSummary(
|
||||
imported = imported,
|
||||
skippedDuplicate = skipped,
|
||||
|
||||
@@ -156,10 +156,12 @@ internal object EventDetailProjection {
|
||||
* [EventDetailProjection] this reads `UID_2445` (to keep a row's identity across
|
||||
* backups) and `DURATION` (recurring rows carry it instead of DTEND), plus
|
||||
* `EXDATE` so a series exports the occurrences deleted from it. Modified-
|
||||
* occurrence and cancelled-exception rows are filtered out by the query
|
||||
* occurrence and cancelled-exception rows are filtered out by the backup query
|
||||
* (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides aren't exported — a
|
||||
* documented v1 limit (import skips them too). EXDATE is unaffected by that
|
||||
* filter: it lives on the master row, not on an exception of its own.
|
||||
* filter: it lives on the master row, not on an exception of its own. A
|
||||
* calendar copy (#325) reads the exception rows too, through the trailing
|
||||
* colour and series-link columns.
|
||||
*/
|
||||
internal object EventExportProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
@@ -178,6 +180,11 @@ internal object EventExportProjection {
|
||||
CalendarContract.Events.STATUS,
|
||||
CalendarContract.Events.AVAILABILITY,
|
||||
CalendarContract.Events.CALENDAR_ID,
|
||||
CalendarContract.Events.EVENT_COLOR,
|
||||
CalendarContract.Events._SYNC_ID,
|
||||
CalendarContract.Events.ORIGINAL_ID,
|
||||
CalendarContract.Events.ORIGINAL_SYNC_ID,
|
||||
CalendarContract.Events.ORIGINAL_INSTANCE_TIME,
|
||||
)
|
||||
|
||||
const val IDX_ID = 0
|
||||
@@ -195,6 +202,11 @@ internal object EventExportProjection {
|
||||
const val IDX_STATUS = 12
|
||||
const val IDX_AVAILABILITY = 13
|
||||
const val IDX_CALENDAR_ID = 14
|
||||
const val IDX_EVENT_COLOR = 15
|
||||
const val IDX_SYNC_ID = 16
|
||||
const val IDX_ORIGINAL_ID = 17
|
||||
const val IDX_ORIGINAL_SYNC_ID = 18
|
||||
const val IDX_ORIGINAL_INSTANCE_TIME = 19
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* A master or one-off event of the calendar being copied (#325), with the
|
||||
* provider keys its exception rows point back through. [color] is the event's
|
||||
* own colour, null when it shows its calendar's.
|
||||
*/
|
||||
data class CopySeries(
|
||||
val eventId: Long,
|
||||
val syncId: String?,
|
||||
val event: IcsEvent,
|
||||
val color: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* An exception row of the calendar being copied: a modified or cancelled
|
||||
* occurrence of the series named by [originalId] / [originalSyncId], standing in
|
||||
* for the occurrence that would have started at [originalInstance].
|
||||
*/
|
||||
data class CopyException(
|
||||
val originalId: Long?,
|
||||
val originalSyncId: String?,
|
||||
val originalInstance: Instant,
|
||||
val event: IcsEvent,
|
||||
val color: Int? = null,
|
||||
) {
|
||||
val isCancelled: Boolean get() = event.status == EventStatus.Cancelled
|
||||
}
|
||||
|
||||
/** Everything a calendar copy reads from its source: series/one-offs and their exception rows. */
|
||||
data class CalendarCopyRows(
|
||||
val series: List<CopySeries>,
|
||||
val exceptions: List<CopyException>,
|
||||
)
|
||||
|
||||
/**
|
||||
* The events a calendar copy inserts into its target, in the shape
|
||||
* `importEvents` takes.
|
||||
*
|
||||
* A copied row has no `_sync_id`, and an exception row only attaches to its
|
||||
* series through one, so overrides can't be written as exception rows. Every
|
||||
* exception therefore excludes its occurrence from the copied series (`EXDATE`),
|
||||
* and a modified one comes along as a standalone event at its own times — what
|
||||
* Calendula itself writes for "only this event" on such a series. Its UID is
|
||||
* derived from the series' UID and the occurrence, so a re-run skips it like
|
||||
* any other event.
|
||||
*
|
||||
* Standalone occurrences come first: a run cut short then leaves at worst an
|
||||
* occurrence missing its series, never a series missing an occurrence.
|
||||
* Attendees and organizer are not part of either input; the copy is the
|
||||
* user's own event.
|
||||
*/
|
||||
fun planCalendarCopy(rows: CalendarCopyRows): List<ParsedIcsEvent> {
|
||||
val (series, exceptions) = rows
|
||||
val byId = series.associateBy { it.eventId }
|
||||
val bySyncId = series.filter { !it.syncId.isNullOrEmpty() }.associateBy { it.syncId }
|
||||
val excluded = mutableMapOf<Long, MutableList<String>>()
|
||||
val detached = mutableListOf<ParsedIcsEvent>()
|
||||
for (exception in exceptions) {
|
||||
val parent = exception.originalId?.let(byId::get)
|
||||
?: exception.originalSyncId?.takeIf { it.isNotEmpty() }?.let(bySyncId::get)
|
||||
if (parent != null) {
|
||||
// An exception to a one-off overrides nothing a copy could carry.
|
||||
if (parent.event.recurrenceRule.isNullOrBlank()) continue
|
||||
excluded.getOrPut(parent.eventId) { mutableListOf() } +=
|
||||
exdateStamp(exception.originalInstance, parent.event.isAllDay)
|
||||
}
|
||||
if (exception.isCancelled) continue
|
||||
val baseUid = parent?.event?.uid ?: exception.event.uid
|
||||
detached += exception.event
|
||||
.copy(
|
||||
uid = "${exception.originalInstance.toEpochMilliseconds()}-$baseUid",
|
||||
recurrenceRule = null,
|
||||
exDates = emptyList(),
|
||||
)
|
||||
.toParsedIcsEvent(exception.color)
|
||||
}
|
||||
return detached + series.map { s ->
|
||||
val extra = excluded[s.eventId].orEmpty()
|
||||
val event = if (extra.isEmpty()) s.event else s.event.copy(exDates = (s.event.exDates + extra).distinct())
|
||||
event.toParsedIcsEvent(s.color)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [IcsEvent] read back as if [IcsWriter] had written it and [IcsParser] had read
|
||||
* it, without the text in between. Unlike that round trip it keeps a one-off's
|
||||
* own time zone (the writer pins it to UTC) and the event's colour (not part of
|
||||
* the file format).
|
||||
*/
|
||||
fun IcsEvent.toParsedIcsEvent(color: Int? = null): ParsedIcsEvent = ParsedIcsEvent(
|
||||
uid = uid,
|
||||
summary = summary,
|
||||
start = start,
|
||||
// RFC 5545 §3.6.1: an all-day event lasts at least its one day; zero-length
|
||||
// it expands into no instance at all.
|
||||
end = if (isAllDay && end <= start) start + 1.days else end,
|
||||
isAllDay = isAllDay,
|
||||
zoneId = zoneId,
|
||||
recurrenceRule = recurrenceRule?.takeIf { it.isNotBlank() }?.removePrefix("RRULE:"),
|
||||
location = location,
|
||||
description = description,
|
||||
// The writer emits only lead times; a negative one would be an alarm after the start.
|
||||
reminderMinutes = reminderMinutes.filter { it >= 0 }.distinct(),
|
||||
status = status,
|
||||
availability = availability,
|
||||
color = color,
|
||||
exDates = if (recurrenceRule.isNullOrBlank()) emptyList() else exDates.filter { it.isNotBlank() }.distinct(),
|
||||
)
|
||||
|
||||
/** An occurrence as an `EXDATE` entry in the provider's shape: the UTC day for an all-day series, else a UTC stamp. */
|
||||
internal fun exdateStamp(occurrence: Instant, isAllDay: Boolean): String =
|
||||
if (isAllDay) occurrence.toLocalDateTime(TimeZone.UTC).date.toBasic() else occurrence.toUtcBasic()
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.FileDownload
|
||||
import androidx.compose.material.icons.filled.FileUpload
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
@@ -89,8 +90,11 @@ fun BackupScreen(
|
||||
onImport: (Uri) -> Unit,
|
||||
visible: Boolean = true,
|
||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||
copyViewModel: CopyEventsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.backupState.collectAsStateWithLifecycle()
|
||||
val copyRun by copyViewModel.run.collectAsStateWithLifecycle()
|
||||
var showCopy by rememberSaveable { mutableStateOf(false) }
|
||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -158,6 +162,9 @@ fun BackupScreen(
|
||||
is BackupUiState.Ready -> BackupContent(
|
||||
exportable = s.exportable,
|
||||
canImport = s.canImport,
|
||||
canCopy = s.canCopy,
|
||||
copyRun = copyRun,
|
||||
onCopy = { showCopy = true },
|
||||
autoBackup = autoBackup,
|
||||
viewModel = viewModel,
|
||||
onRestore = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||
@@ -187,6 +194,23 @@ fun BackupScreen(
|
||||
onDismiss = { showExportPicker = false },
|
||||
)
|
||||
}
|
||||
if (showCopy) {
|
||||
val calendars by copyViewModel.calendars.collectAsStateWithLifecycle()
|
||||
val selection by copyViewModel.selection.collectAsStateWithLifecycle()
|
||||
CopyEventsSheet(
|
||||
calendars = calendars,
|
||||
selection = selection,
|
||||
run = copyRun,
|
||||
onSelectSource = copyViewModel::setSource,
|
||||
onSelectTarget = copyViewModel::setTarget,
|
||||
onStart = copyViewModel::start,
|
||||
onCancel = copyViewModel::cancel,
|
||||
onDismiss = {
|
||||
showCopy = false
|
||||
copyViewModel.clearFinished()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showBackupCalendars) {
|
||||
AutoBackupCalendarPicker(
|
||||
calendars = ready.exportable,
|
||||
@@ -249,6 +273,9 @@ private fun defaultBackupName(): String = "calendula-backup-${LocalDate.now()}.i
|
||||
private fun BackupContent(
|
||||
exportable: List<CalendarSource>,
|
||||
canImport: Boolean,
|
||||
canCopy: Boolean,
|
||||
copyRun: CopyRunState,
|
||||
onCopy: () -> Unit,
|
||||
autoBackup: AutoBackupUiState,
|
||||
viewModel: CalendarsViewModel,
|
||||
onRestore: () -> Unit,
|
||||
@@ -296,6 +323,16 @@ private fun BackupContent(
|
||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||
onClick = onRestoreClick,
|
||||
)
|
||||
if (canCopy) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_copy_action),
|
||||
summary = (copyRun as? CopyRunState.Running)?.let { copyProgressText(it) }
|
||||
?: stringResource(R.string.calendars_copy_summary),
|
||||
position = Position.Middle,
|
||||
leading = { LeadingAvatar(Icons.Default.ContentCopy) },
|
||||
onClick = onCopy,
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup),
|
||||
summary = stringResource(R.string.calendars_auto_backup_hint),
|
||||
|
||||
@@ -24,6 +24,8 @@ sealed interface BackupUiState {
|
||||
data class Ready(
|
||||
val exportable: List<CalendarSource>,
|
||||
val canImport: Boolean,
|
||||
/** Some calendar can be copied into a different one (#325). */
|
||||
val canCopy: Boolean = false,
|
||||
) : BackupUiState
|
||||
}
|
||||
|
||||
@@ -50,5 +52,17 @@ fun backupUiState(calendars: List<CalendarSource>): BackupUiState {
|
||||
calendarListFailure(calendars) ?: FailureReason.NoImportTarget,
|
||||
)
|
||||
}
|
||||
return BackupUiState.Ready(exportable = exportable, canImport = canImport)
|
||||
return BackupUiState.Ready(
|
||||
exportable = exportable,
|
||||
canImport = canImport,
|
||||
canCopy = exportable.any { copyTargets(calendars, it.id).isNotEmpty() },
|
||||
)
|
||||
}
|
||||
|
||||
/** Calendars a copy can read from: any a backup can (#325), synced and read-only ones included. */
|
||||
fun copySources(calendars: List<CalendarSource>): List<CalendarSource> =
|
||||
calendars.filter { it.isBackupCandidate }
|
||||
|
||||
/** Calendars a copy from [sourceId] can write into: the import targets, minus the source itself. */
|
||||
fun copyTargets(calendars: List<CalendarSource>, sourceId: Long?): List<CalendarSource> =
|
||||
calendars.filter { it.isEventTarget && it.id != sourceId }
|
||||
@@ -0,0 +1,221 @@
|
||||
package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.LinearWavyProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
||||
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/**
|
||||
* Copy every event of one calendar into another (#325): pick the source and the
|
||||
* target, copy, watch it run. The run lives in [CopyEventsViewModel], so this
|
||||
* sheet can be closed and reopened mid-copy.
|
||||
*/
|
||||
@Composable
|
||||
fun CopyEventsSheet(
|
||||
calendars: List<CalendarSource>,
|
||||
selection: CopySelection,
|
||||
run: CopyRunState,
|
||||
onSelectSource: (Long) -> Unit,
|
||||
onSelectTarget: (Long) -> Unit,
|
||||
onStart: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sources = copySources(calendars)
|
||||
val source = sources.firstOrNull { it.id == selection.sourceId }
|
||||
val targets = copyTargets(calendars, source?.id)
|
||||
val target = targets.firstOrNull { it.id == selection.targetId }
|
||||
val running = run is CopyRunState.Running
|
||||
var picking by rememberSaveable { mutableStateOf<CopyEnd?>(null) }
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.calendars_copy_action),
|
||||
onDismiss = onDismiss,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
GroupedHint(stringResource(R.string.calendars_copy_hint), style = MaterialTheme.typography.bodySmall)
|
||||
CopyEndRow(
|
||||
label = stringResource(R.string.calendars_copy_from),
|
||||
calendar = source,
|
||||
position = Position.Top,
|
||||
onClick = if (running) null else ({ picking = CopyEnd.Source }),
|
||||
)
|
||||
CopyEndRow(
|
||||
label = stringResource(R.string.calendars_copy_to),
|
||||
calendar = target,
|
||||
position = Position.Bottom,
|
||||
onClick = if (running || source == null) null else ({ picking = CopyEnd.Target }),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
when (run) {
|
||||
is CopyRunState.Running -> CopyProgress(run, onCancel)
|
||||
is CopyRunState.Done -> CopySummary(run.summary)
|
||||
CopyRunState.Cancelled -> CopyNote(stringResource(R.string.calendars_copy_cancelled))
|
||||
CopyRunState.Failed -> CopyNote(
|
||||
stringResource(R.string.calendars_copy_failed),
|
||||
error = true,
|
||||
)
|
||||
CopyRunState.Idle -> Unit
|
||||
}
|
||||
if (!running) {
|
||||
Button(
|
||||
onClick = onStart,
|
||||
enabled = source != null && target != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.calendars_copy_start))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (picking) {
|
||||
CopyEnd.Source -> FullScreenPicker(
|
||||
title = stringResource(R.string.calendars_copy_from_title),
|
||||
onDismiss = { picking = null },
|
||||
predictiveBack = true,
|
||||
) {
|
||||
CalendarPickerGroups(
|
||||
calendars = sources,
|
||||
selectedId = source?.id,
|
||||
onSelect = {
|
||||
onSelectSource(it)
|
||||
picking = null
|
||||
},
|
||||
)
|
||||
}
|
||||
CopyEnd.Target -> FullScreenPicker(
|
||||
title = stringResource(R.string.calendars_copy_to_title),
|
||||
onDismiss = { picking = null },
|
||||
predictiveBack = true,
|
||||
) {
|
||||
CalendarPickerGroups(
|
||||
calendars = targets,
|
||||
selectedId = target?.id,
|
||||
onSelect = {
|
||||
onSelectTarget(it)
|
||||
picking = null
|
||||
},
|
||||
)
|
||||
}
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private enum class CopyEnd { Source, Target }
|
||||
|
||||
@Composable
|
||||
private fun CopyEndRow(
|
||||
label: String,
|
||||
calendar: CalendarSource?,
|
||||
position: Position,
|
||||
onClick: (() -> Unit)?,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = label,
|
||||
summary = calendar?.displayName?.orUnnamed() ?: stringResource(R.string.calendars_copy_choose),
|
||||
position = position,
|
||||
dimmed = onClick == null,
|
||||
leading = calendar?.let { { CalendarColorChip(it.color) } },
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun CopyProgress(run: CopyRunState.Running, onCancel: () -> Unit) {
|
||||
val total = run.total
|
||||
if (total == null || total == 0) {
|
||||
LinearWavyProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
} else {
|
||||
LinearWavyProgressIndicator(
|
||||
progress = { run.done.toFloat() / total },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = copyProgressText(run),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.dialog_cancel))
|
||||
}
|
||||
}
|
||||
|
||||
/** "Reading events…" until the source is read, then "120 of 2,400 events". */
|
||||
@Composable
|
||||
fun copyProgressText(run: CopyRunState.Running): String {
|
||||
val total = run.total ?: return stringResource(R.string.calendars_copy_reading)
|
||||
return pluralStringResource(R.plurals.calendars_copy_progress, total, run.done, total)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CopySummary(summary: IcsImportSummary) {
|
||||
Text(
|
||||
stringResource(R.string.calendars_copy_done),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||
CopyCount(stringResource(R.string.import_done_added_label), summary.imported)
|
||||
CopyCount(stringResource(R.string.import_done_skipped_label), summary.skippedDuplicate)
|
||||
if (summary.failed > 0) CopyCount(stringResource(R.string.import_done_failed_label), summary.failed)
|
||||
}
|
||||
if (summary.skippedDuplicate > 0) CopyNote(stringResource(R.string.import_done_dedup_note))
|
||||
if (summary.failed > 0) CopyNote(stringResource(R.string.import_done_failed_note))
|
||||
if (summary.notAttempted > 0) {
|
||||
CopyNote(
|
||||
pluralStringResource(R.plurals.calendars_copy_stopped, summary.notAttempted, summary.notAttempted),
|
||||
error = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CopyCount(label: String, count: Int) {
|
||||
Column {
|
||||
Text(count.toString(), style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CopyNote(text: String, error: Boolean = false) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Copy events from one calendar into another (#325). Lives as long as the
|
||||
* activity, so a long copy keeps running across rotation and when the Backup
|
||||
* screen is closed; [cancel] stops it between events.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CopyEventsViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
@IoDispatcher io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
val calendars: StateFlow<List<CalendarSource>> =
|
||||
repository.calendars()
|
||||
.catch { emit(emptyList()) }
|
||||
.flowOn(io)
|
||||
.stateWhileSubscribed(scope = viewModelScope, initialValue = emptyList())
|
||||
|
||||
private val _selection = MutableStateFlow(CopySelection())
|
||||
val selection: StateFlow<CopySelection> = _selection.asStateFlow()
|
||||
|
||||
private val _run = MutableStateFlow<CopyRunState>(CopyRunState.Idle)
|
||||
val run: StateFlow<CopyRunState> = _run.asStateFlow()
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
fun setSource(id: Long) {
|
||||
if (job?.isActive == true) return
|
||||
_selection.value = _selection.value.withSource(id)
|
||||
clearFinished()
|
||||
}
|
||||
|
||||
fun setTarget(id: Long) {
|
||||
if (job?.isActive == true) return
|
||||
_selection.value = _selection.value.copy(targetId = id)
|
||||
clearFinished()
|
||||
}
|
||||
|
||||
fun start() {
|
||||
val source = _selection.value.sourceId ?: return
|
||||
val target = _selection.value.targetId ?: return
|
||||
if (job?.isActive == true) return
|
||||
_run.value = CopyRunState.Running(done = 0, total = null)
|
||||
job = viewModelScope.launch {
|
||||
try {
|
||||
val summary = repository.copyEvents(source, target) { done, total ->
|
||||
_run.value = CopyRunState.Running(done, total)
|
||||
}
|
||||
_run.value = CopyRunState.Done(summary)
|
||||
} catch (e: CancellationException) {
|
||||
_run.value = CopyRunState.Cancelled
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
_run.value = CopyRunState.Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
job?.cancel()
|
||||
}
|
||||
|
||||
/** Back to idle once a finished run's outcome has been seen. */
|
||||
fun clearFinished() {
|
||||
if (job?.isActive != true) _run.value = CopyRunState.Idle
|
||||
}
|
||||
}
|
||||
|
||||
/** The calendars a copy runs between. */
|
||||
data class CopySelection(val sourceId: Long? = null, val targetId: Long? = null) {
|
||||
/** A target equal to the new source is dropped: a calendar is never copied into itself. */
|
||||
fun withSource(id: Long): CopySelection =
|
||||
CopySelection(sourceId = id, targetId = targetId?.takeIf { it != id })
|
||||
}
|
||||
|
||||
sealed interface CopyRunState {
|
||||
data object Idle : CopyRunState
|
||||
|
||||
/** [total] is null while the source calendar is still being read. */
|
||||
data class Running(val done: Int, val total: Int?) : CopyRunState
|
||||
|
||||
data class Done(val summary: IcsImportSummary) : CopyRunState
|
||||
data object Cancelled : CopyRunState
|
||||
data object Failed : CopyRunState
|
||||
}
|
||||
@@ -889,6 +889,27 @@
|
||||
<string name="calendars_restore_action">Restore from .ics file</string>
|
||||
<string name="calendars_restore_hint">Import events from a backup or another calendar app.</string>
|
||||
<string name="calendars_restore_unavailable">No calendar can take the imported events yet.</string>
|
||||
<string name="calendars_copy_action">Copy events</string>
|
||||
<string name="calendars_copy_summary">Copy one calendar\'s events into another, for example to move off Google.</string>
|
||||
<string name="calendars_copy_hint">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.</string>
|
||||
<string name="calendars_copy_from">From</string>
|
||||
<string name="calendars_copy_to">To</string>
|
||||
<string name="calendars_copy_choose">Choose a calendar</string>
|
||||
<string name="calendars_copy_from_title">Copy from</string>
|
||||
<string name="calendars_copy_to_title">Copy into</string>
|
||||
<string name="calendars_copy_start">Copy</string>
|
||||
<string name="calendars_copy_reading">Reading events…</string>
|
||||
<plurals name="calendars_copy_progress">
|
||||
<item quantity="one">%1$d of %2$d event</item>
|
||||
<item quantity="other">%1$d of %2$d events</item>
|
||||
</plurals>
|
||||
<string name="calendars_copy_done">Copy complete</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>
|
||||
<plurals name="calendars_copy_stopped">
|
||||
<item quantity="one">The target calendar stopped accepting events part way through. %d event was never copied.</item>
|
||||
<item quantity="other">The target calendar stopped accepting events part way through. %d events were never copied.</item>
|
||||
</plurals>
|
||||
<string name="calendars_auto_backup">Automatic backup</string>
|
||||
<string name="calendars_auto_backup_hint">Periodically export your calendars to a folder as .ics files.</string>
|
||||
<string name="calendars_auto_backup_folder">Backup folder</string>
|
||||
|
||||
+36
@@ -961,6 +961,42 @@ class CalendarRepositoryImplTest {
|
||||
assertThat(fake.lastCalendarEventsIds).containsExactly(2L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copyEvents reports progress and skips what an earlier run copied`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
fun icsEvent(uid: String) = de.jeanlucmakiola.calendula.domain.ics.IcsEvent(
|
||||
uid = uid,
|
||||
summary = uid,
|
||||
start = Instant.fromEpochMilliseconds(1_000_000_000L),
|
||||
end = Instant.fromEpochMilliseconds(1_000_003_600L),
|
||||
isAllDay = false,
|
||||
zoneId = "UTC",
|
||||
)
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
existingUidsResult = setOf("a")
|
||||
calendarCopyRowsResult = de.jeanlucmakiola.calendula.domain.ics.CalendarCopyRows(
|
||||
series = listOf(
|
||||
de.jeanlucmakiola.calendula.domain.ics.CopySeries(1, null, icsEvent("a")),
|
||||
de.jeanlucmakiola.calendula.domain.ics.CopySeries(2, null, icsEvent("b")),
|
||||
),
|
||||
exceptions = emptyList(),
|
||||
)
|
||||
}
|
||||
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
|
||||
val progress = mutableListOf<Pair<Int, Int>>()
|
||||
|
||||
val summary = repo.copyEvents(sourceCalendarId = 1L, targetCalendarId = 3L) { done, total ->
|
||||
progress += done to total
|
||||
}
|
||||
|
||||
assertThat(summary.imported).isEqualTo(1)
|
||||
assertThat(summary.skippedDuplicate).isEqualTo(1)
|
||||
assertThat(fake.importedEvents.map { it.first.uid to it.second }).containsExactly("b" to 3L)
|
||||
assertThat(progress.first()).isEqualTo(0 to 2)
|
||||
assertThat(progress.last()).isEqualTo(2 to 2)
|
||||
}
|
||||
|
||||
private fun parsedEvent(uid: String?, summary: String = "E") =
|
||||
de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
|
||||
uid = uid,
|
||||
|
||||
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.ics.CalendarCopyRows
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import java.time.ZoneId
|
||||
@@ -89,6 +90,11 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
return calendarEventsResult
|
||||
}
|
||||
|
||||
var calendarCopyRowsResult: CalendarCopyRows = CalendarCopyRows(emptyList(), emptyList())
|
||||
|
||||
override fun calendarCopyRows(calendarId: Long, allDayReminderTimeMinutes: Int): CalendarCopyRows =
|
||||
calendarCopyRowsResult
|
||||
|
||||
override fun existingUids(calendarId: Long): Set<String> = existingUidsResult
|
||||
|
||||
/** (event, targetCalendarId) pairs passed to [insertImportedEvent]. */
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** What a calendar copy (#325) inserts for a source calendar's rows. */
|
||||
class CalendarCopyTest {
|
||||
|
||||
private val start = Instant.parse("2026-01-05T09:00:00Z")
|
||||
private val week = 7 * 24 * 3_600_000L
|
||||
|
||||
private fun event(
|
||||
uid: String,
|
||||
rrule: String? = null,
|
||||
allDay: Boolean = false,
|
||||
at: Instant = start,
|
||||
status: EventStatus = EventStatus.Confirmed,
|
||||
exDates: List<String> = emptyList(),
|
||||
reminders: List<Int> = emptyList(),
|
||||
) = IcsEvent(
|
||||
uid = uid,
|
||||
summary = uid,
|
||||
start = at,
|
||||
end = Instant.fromEpochMilliseconds(at.toEpochMilliseconds() + 3_600_000L),
|
||||
isAllDay = allDay,
|
||||
zoneId = "Europe/Berlin",
|
||||
recurrenceRule = rrule,
|
||||
exDates = exDates,
|
||||
reminderMinutes = reminders,
|
||||
status = status,
|
||||
)
|
||||
|
||||
private fun occurrence(n: Int) = Instant.fromEpochMilliseconds(start.toEpochMilliseconds() + n * week)
|
||||
|
||||
@Test
|
||||
fun `a plain event copies as itself, keeping its zone and colour`() {
|
||||
val plan = planCalendarCopy(
|
||||
CalendarCopyRows(
|
||||
series = listOf(CopySeries(1, null, event("a", reminders = listOf(10, -5, 10)), color = 0xFF112233.toInt())),
|
||||
exceptions = emptyList(),
|
||||
),
|
||||
)
|
||||
|
||||
val copied = plan.single()
|
||||
assertThat(copied.uid).isEqualTo("a")
|
||||
assertThat(copied.zoneId).isEqualTo("Europe/Berlin")
|
||||
assertThat(copied.color).isEqualTo(0xFF112233.toInt())
|
||||
assertThat(copied.reminderMinutes).containsExactly(10)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cancelled occurrence becomes an EXDATE on the series`() {
|
||||
val plan = planCalendarCopy(
|
||||
CalendarCopyRows(
|
||||
series = listOf(CopySeries(1, "sync-1", event("s", rrule = "FREQ=WEEKLY", exDates = listOf("20260112T080000Z")))),
|
||||
exceptions = listOf(
|
||||
CopyException(
|
||||
originalId = null,
|
||||
originalSyncId = "sync-1",
|
||||
originalInstance = occurrence(2),
|
||||
event = event("s", at = occurrence(2), status = EventStatus.Cancelled),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val series = plan.single()
|
||||
assertThat(series.exDates).containsExactly("20260112T080000Z", "20260119T090000Z").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a modified occurrence is excluded from the series and copied on its own, first`() {
|
||||
val moved = Instant.fromEpochMilliseconds(occurrence(1).toEpochMilliseconds() + 3_600_000L)
|
||||
val plan = planCalendarCopy(
|
||||
CalendarCopyRows(
|
||||
series = listOf(CopySeries(1, null, event("s", rrule = "FREQ=WEEKLY"))),
|
||||
exceptions = listOf(
|
||||
CopyException(
|
||||
originalId = 1,
|
||||
originalSyncId = null,
|
||||
originalInstance = occurrence(1),
|
||||
event = event("s", rrule = "FREQ=WEEKLY", at = moved),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(plan).hasSize(2)
|
||||
val detached = plan[0]
|
||||
assertThat(detached.uid).isEqualTo("${occurrence(1).toEpochMilliseconds()}-s")
|
||||
assertThat(detached.recurrenceRule).isNull()
|
||||
assertThat(detached.start).isEqualTo(moved)
|
||||
assertThat(plan[1].exDates).containsExactly("20260112T090000Z")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day series excludes the occurrence by its day`() {
|
||||
val day = Instant.parse("2026-01-05T00:00:00Z")
|
||||
val plan = planCalendarCopy(
|
||||
CalendarCopyRows(
|
||||
series = listOf(CopySeries(1, null, event("s", rrule = "FREQ=DAILY", allDay = true, at = day))),
|
||||
exceptions = listOf(
|
||||
CopyException(
|
||||
originalId = 1,
|
||||
originalSyncId = null,
|
||||
originalInstance = Instant.parse("2026-01-07T00:00:00Z"),
|
||||
event = event("s", allDay = true, status = EventStatus.Cancelled),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(plan.single().exDates).containsExactly("20260107")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an orphaned modified occurrence still copies, an orphaned cancellation does not`() {
|
||||
val plan = planCalendarCopy(
|
||||
CalendarCopyRows(
|
||||
series = emptyList(),
|
||||
exceptions = listOf(
|
||||
CopyException(5, null, occurrence(1), event("x")),
|
||||
CopyException(5, null, occurrence(2), event("y", status = EventStatus.Cancelled)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(plan.map { it.summary }).containsExactly("x")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the plan is stable, so a re-run dedups every event`() {
|
||||
val rows = CalendarCopyRows(
|
||||
series = listOf(CopySeries(1, null, event("s", rrule = "FREQ=WEEKLY"))),
|
||||
exceptions = listOf(CopyException(1, null, occurrence(1), event("s"))),
|
||||
)
|
||||
|
||||
assertThat(planCalendarCopy(rows).map { it.uid }).isEqualTo(planCalendarCopy(rows).map { it.uid })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero-length all-day event lasts its day`() {
|
||||
val day = Instant.parse("2026-01-05T00:00:00Z")
|
||||
val parsed = event("a", allDay = true, at = day).copy(end = day).toParsedIcsEvent()
|
||||
|
||||
assertThat(parsed.end).isEqualTo(Instant.parse("2026-01-06T00:00:00Z"))
|
||||
}
|
||||
}
|
||||
@@ -97,4 +97,40 @@ class BackupUiStateTest {
|
||||
assertThat(state.exportable.map { it.id }).containsExactly(1L, 2L)
|
||||
assertThat(state.canImport).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read-only calendar can be copied into a writable one`() {
|
||||
val readOnly = cal(1, writable = false)
|
||||
val local = cal(2, local = true)
|
||||
|
||||
val state = ready(backupUiState(listOf(readOnly, local)))
|
||||
|
||||
assertThat(state.canCopy).isTrue()
|
||||
assertThat(copySources(listOf(readOnly, local)).map { it.id }).containsExactly(1L, 2L)
|
||||
assertThat(copyTargets(listOf(readOnly, local), 1L).map { it.id }).containsExactly(2L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single writable calendar has nothing to copy into`() {
|
||||
val only = cal(1, local = true)
|
||||
|
||||
assertThat(ready(backupUiState(listOf(only))).canCopy).isFalse()
|
||||
assertThat(copyTargets(listOf(only), 1L)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a copy never targets a managed or switched-off calendar`() {
|
||||
val source = cal(1)
|
||||
val calendars = listOf(source, cal(2, managed = true), cal(3, visible = false))
|
||||
|
||||
assertThat(copyTargets(calendars, source.id)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `choosing the target as source drops it`() {
|
||||
assertThat(CopySelection(sourceId = 1, targetId = 2).withSource(2))
|
||||
.isEqualTo(CopySelection(sourceId = 2, targetId = null))
|
||||
assertThat(CopySelection(sourceId = 1, targetId = 2).withSource(3))
|
||||
.isEqualTo(CopySelection(sourceId = 3, targetId = 2))
|
||||
}
|
||||
}
|
||||
@@ -495,6 +495,20 @@ on whether an all-day exclusion is `yyyyMMdd` or a padded midnight stamp, so
|
||||
`exportExDates` drops the time part on the way out. Without this, an in-app
|
||||
backup→restore resurrected every occurrence the user had deleted.
|
||||
|
||||
**Copying a calendar reads its exceptions too.** Backup & restore → Copy events
|
||||
(#325) reads every row of the source through `calendarCopyRows` — masters,
|
||||
one-offs *and* the exception rows the backup query leaves out — and
|
||||
`planCalendarCopy` turns them into the `ParsedIcsEvent`s that the normal
|
||||
`importEvents` inserts, so the UID dedup makes a re-run skip what landed before.
|
||||
The copy has no `_sync_id`, and an exception row only attaches through one, so
|
||||
overrides are written the way Calendula writes "only this event" on such a
|
||||
series: each exception adds its occurrence to the series' `EXDATE`, and a
|
||||
modified one becomes a standalone event with a UID derived from the series UID
|
||||
and the occurrence. The rows skip the `.ics` text: the writer pins a timed
|
||||
one-off to UTC and carries no colour, the direct mapping keeps both. Attendees
|
||||
and organizer are never read. The backup keeps excluding exceptions; a restore
|
||||
could not place a `RECURRENCE-ID` either.
|
||||
|
||||
That re-encode is sampled where the reminder will actually *fire*, not at
|
||||
`DTSTART` (`importedAllDayReminderDate`). An imported series' `DTSTART` is only an
|
||||
anchor and is routinely ancient — Fossify writes a year-less contact birthday at
|
||||
|
||||
Reference in new issue
Block a user