From e5d9b4965bc538b86ab4fc73618515e2c8ff64b9 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 13 Nov 2026 17:06:11 +0100 Subject: [PATCH] feat(#325): copy events from one calendar into another --- .../data/calendar/CalendarDataSource.kt | 79 ++++++- .../data/calendar/CalendarRepository.kt | 21 +- .../data/calendar/CalendarRepositoryImpl.kt | 19 ++ .../calendula/data/calendar/Projections.kt | 16 +- .../calendula/domain/ics/CalendarCopy.kt | 119 ++++++++++ .../calendula/ui/calendars/BackupScreen.kt | 37 +++ .../calendula/ui/calendars/BackupUiState.kt | 16 +- .../calendula/ui/calendars/CopyEventsSheet.kt | 221 ++++++++++++++++++ .../ui/calendars/CopyEventsViewModel.kt | 105 +++++++++ app/src/main/res/values/strings.xml | 21 ++ .../calendar/CalendarRepositoryImplTest.kt | 36 +++ .../data/calendar/FakeCalendarDataSource.kt | 6 + .../calendula/domain/ics/CalendarCopyTest.kt | 150 ++++++++++++ .../ui/calendars/BackupUiStateTest.kt | 36 +++ docs/ARCHITECTURE.md | 14 ++ 15 files changed, 882 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/CalendarCopy.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CopyEventsSheet.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CopyEventsViewModel.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/CalendarCopyTest.kt 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 fc8c0b75..870e53ba 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 @@ -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, allDayReminderTimeMinutes: Int): List + /** + * 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() + 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() + val exceptions = mutableListOf() + 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 = resolver.query( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt index 45f08aa8..fa950bd3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt @@ -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): IcsImportSummary + suspend fun importEvents( + targetCalendarId: Long, + events: List, + 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 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 0f9c9bc4..e4c6bea4 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 @@ -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) = 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, + 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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index 5775d1a4..37f4bd7e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -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 = 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 } /** 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 new file mode 100644 index 00000000..bc8facd4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/CalendarCopy.kt @@ -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, + val exceptions: List, +) + +/** + * 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 { + val (series, exceptions) = rows + val byId = series.associateBy { it.eventId } + val bySyncId = series.filter { !it.syncId.isNullOrEmpty() }.associateBy { it.syncId } + val excluded = mutableMapOf>() + val detached = mutableListOf() + 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() 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 0c738b42..a0feb927 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 @@ -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, 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), 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 13daf24b..c4de4824 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 @@ -24,6 +24,8 @@ sealed interface BackupUiState { data class Ready( val exportable: List, 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): 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): List = + calendars.filter { it.isBackupCandidate } + +/** Calendars a copy from [sourceId] can write into: the import targets, minus the source itself. */ +fun copyTargets(calendars: List, sourceId: Long?): List = + calendars.filter { it.isEventTarget && it.id != sourceId } 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 new file mode 100644 index 00000000..a7fbcb25 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CopyEventsSheet.kt @@ -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, + 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(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, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CopyEventsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CopyEventsViewModel.kt new file mode 100644 index 00000000..97fb06d2 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CopyEventsViewModel.kt @@ -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> = + repository.calendars() + .catch { emit(emptyList()) } + .flowOn(io) + .stateWhileSubscribed(scope = viewModelScope, initialValue = emptyList()) + + private val _selection = MutableStateFlow(CopySelection()) + val selection: StateFlow = _selection.asStateFlow() + + private val _run = MutableStateFlow(CopyRunState.Idle) + val run: StateFlow = _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 +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eac1f5a3..4a55e627 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -889,6 +889,27 @@ Restore from .ics file Import events from a backup or another calendar app. 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. + From + To + Choose a calendar + Copy from + Copy into + Copy + Reading events… + + %1$d of %2$d event + %1$d of %2$d events + + Copy complete + Copy cancelled. Run it again to finish: events that were already copied are skipped. + Couldn\'t copy the events. + + The target calendar stopped accepting events part way through. %d event was never copied. + The target calendar stopped accepting events part way through. %d events were never copied. + Automatic backup Periodically export your calendars to a folder as .ics files. Backup folder 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 119cfc65..eff60c52 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 @@ -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>() + + 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, diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt index 3921fca1..5ba0bf12 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt @@ -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 = existingUidsResult /** (event, targetCalendarId) pairs passed to [insertImportedEvent]. */ diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/CalendarCopyTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/CalendarCopyTest.kt new file mode 100644 index 00000000..c1036b10 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/CalendarCopyTest.kt @@ -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 = emptyList(), + reminders: List = 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")) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiStateTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiStateTest.kt index 196125ca..99106a76 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiStateTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiStateTest.kt @@ -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)) + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5214f44b..9a11451d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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