feat(#325): copy events from one calendar into another

This commit is contained in:
makiolaj committed 2026-11-13 22:28:12 +01:00
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
}
+21
View File
@@ -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>
@@ -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))
}
}
+14
View File
@@ -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