release: v2.7.0 — ICS export & import #7
@@ -18,8 +18,10 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
@@ -57,6 +59,19 @@ interface CalendarDataSource {
|
|||||||
*/
|
*/
|
||||||
fun exportableEvents(): List<IcsEvent>
|
fun exportableEvents(): List<IcsEvent>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
fun existingUids(calendarId: Long): Set<String>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a parsed `.ics` event into [calendarId], preserving its UID (or
|
||||||
|
* minting one when absent); returns the new `Events._ID`. Reminders are
|
||||||
|
* written as the file's raw lead minutes (METHOD_ALERT).
|
||||||
|
*/
|
||||||
|
fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
||||||
* returns its `Calendars._ID`. Inserted through the sync-adapter URI so the
|
* returns its `Calendars._ID`. Inserted through the sync-adapter URI so the
|
||||||
@@ -307,6 +322,79 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun existingUids(calendarId: Long): Set<String> = resolver.query(
|
||||||
|
CalendarContract.Events.CONTENT_URI,
|
||||||
|
arrayOf(CalendarContract.Events.UID_2445),
|
||||||
|
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||||
|
"${CalendarContract.Events.UID_2445} IS NOT NULL",
|
||||||
|
arrayOf(calendarId.toString()),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) }
|
||||||
|
} ?: emptySet()
|
||||||
|
|
||||||
|
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
|
||||||
|
val startMillis = event.start.toEpochMillis()
|
||||||
|
val endMillis = event.end.toEpochMillis()
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
||||||
|
// Preserve the file's UID so a re-import dedups against it; mint one
|
||||||
|
// only when the source event carried none.
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.UID_2445,
|
||||||
|
event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
|
||||||
|
)
|
||||||
|
put(CalendarContract.Events.TITLE, event.summary.trim())
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.DTSTART, startMillis)
|
||||||
|
if (event.recurrenceRule == null) {
|
||||||
|
put(CalendarContract.Events.DTEND, endMillis)
|
||||||
|
} else {
|
||||||
|
put(CalendarContract.Events.RRULE, event.recurrenceRule)
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
|
importDuration(startMillis, endMillis, event.isAllDay),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// All-day rows live at UTC midnights (the file already encodes them so);
|
||||||
|
// timed rows keep the event's own zone.
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
|
||||||
|
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
|
||||||
|
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
|
||||||
|
event.location?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||||
|
event.description?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||||
|
}
|
||||||
|
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||||
|
?: throw WriteFailedException("import event into calendar id=$calendarId")
|
||||||
|
val eventId = ContentUris.parseId(uri)
|
||||||
|
// Raw lead minutes straight from the file's VALARMs (best-effort, like insertEvent).
|
||||||
|
event.reminderMinutes.distinct().filter { it >= 0 }.forEach { minutes ->
|
||||||
|
val reminder = ContentValues().apply {
|
||||||
|
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||||
|
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||||
|
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||||
|
}
|
||||||
|
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||||
|
Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provider DURATION for an imported recurring row: whole days / seconds. */
|
||||||
|
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
|
||||||
|
val span = (endMillis - startMillis).coerceAtLeast(0)
|
||||||
|
return if (isAllDay) "P${span / 86_400_000L}D" else "P${span / 1_000L}S"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun EventStatus.toProviderStatus(): Int = when (this) {
|
||||||
|
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
|
||||||
|
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
|
||||||
|
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
|
||||||
|
}
|
||||||
|
|
||||||
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
|
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
|
||||||
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
||||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -35,6 +37,13 @@ interface CalendarRepository {
|
|||||||
*/
|
*/
|
||||||
suspend fun exportEvents(): List<IcsEvent>
|
suspend fun exportEvents(): List<IcsEvent>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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].
|
||||||
|
*/
|
||||||
|
suspend fun importEvents(targetCalendarId: Long, events: List<ParsedIcsEvent>): IcsImportSummary
|
||||||
|
|
||||||
/** Create a new event from a validated form; returns the new `Events._ID`. */
|
/** Create a new event from a validated form; returns the new `Events._ID`. */
|
||||||
suspend fun createEvent(form: EventForm): Long
|
suspend fun createEvent(form: EventForm): Long
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
@@ -101,6 +103,26 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun exportEvents() = withContext(io) { dataSource.exportableEvents() }
|
override suspend fun exportEvents() = withContext(io) { dataSource.exportableEvents() }
|
||||||
|
|
||||||
|
override suspend fun importEvents(
|
||||||
|
targetCalendarId: Long,
|
||||||
|
events: List<ParsedIcsEvent>,
|
||||||
|
): IcsImportSummary = withContext(io) {
|
||||||
|
val existing = dataSource.existingUids(targetCalendarId)
|
||||||
|
var imported = 0
|
||||||
|
var skipped = 0
|
||||||
|
for (event in events) {
|
||||||
|
// 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) {
|
||||||
|
skipped++
|
||||||
|
} else {
|
||||||
|
dataSource.insertImportedEvent(event, targetCalendarId)
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IcsImportSummary(imported = imported, skippedDuplicate = skipped)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
||||||
dataSource.insertEvent(form, allDayReminderTimeMinutes())
|
dataSource.insertEvent(form, allDayReminderTimeMinutes())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
|
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
|
||||||
@@ -51,41 +52,3 @@ internal fun ColumnReader.toIcsEvent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Android's calendar provider (and Calendula's own writes) use the non-standard
|
|
||||||
// single-unit forms P<n>S / P<n>D / P<n>W — seconds without the RFC-required
|
|
||||||
// leading T. Matched first; anything else falls through to the general grammar.
|
|
||||||
private val DURATION_SINGLE_UNIT = Regex("""([+-]?)P(\d+)([WDS])""")
|
|
||||||
private val DURATION_GENERAL =
|
|
||||||
Regex("""([+-]?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?""")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Milliseconds of a DURATION (`P1D`, `P3600S`, `PT1H30M`, `P1W`, …). Calendula
|
|
||||||
* only ever writes `P<days>D` / `P<seconds>S`, but the parser also handles the
|
|
||||||
* general RFC 5545 grammar so a foreign row on a local calendar still
|
|
||||||
* round-trips. Unparseable input is treated as zero.
|
|
||||||
*/
|
|
||||||
internal fun parseRfc2445DurationMillis(duration: String?): Long {
|
|
||||||
if (duration.isNullOrBlank()) return 0L
|
|
||||||
val s = duration.trim()
|
|
||||||
|
|
||||||
DURATION_SINGLE_UNIT.matchEntire(s)?.let { m ->
|
|
||||||
val unitSeconds = when (m.groupValues[3]) {
|
|
||||||
"W" -> 7L * 24 * 60 * 60
|
|
||||||
"D" -> 24L * 60 * 60
|
|
||||||
else -> 1L // S
|
|
||||||
}
|
|
||||||
return m.signum() * m.groupValues[2].toLong() * unitSeconds * 1_000L
|
|
||||||
}
|
|
||||||
|
|
||||||
val m = DURATION_GENERAL.matchEntire(s) ?: return 0L
|
|
||||||
val weeks = m.groupValues[2].toLongOrNull() ?: 0L
|
|
||||||
val days = m.groupValues[3].toLongOrNull() ?: 0L
|
|
||||||
val hours = m.groupValues[4].toLongOrNull() ?: 0L
|
|
||||||
val minutes = m.groupValues[5].toLongOrNull() ?: 0L
|
|
||||||
val seconds = m.groupValues[6].toLongOrNull() ?: 0L
|
|
||||||
val totalSeconds = ((((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 + seconds)
|
|
||||||
return m.signum() * totalSeconds * 1_000L
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sign carried by group 1 (`-` → -1, otherwise +1) of a duration match. */
|
|
||||||
private fun MatchResult.signum(): Long = if (groupValues[1] == "-") -1L else 1L
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.ics
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android IO edge of `.ics` import: reads the text of a received/opened
|
||||||
|
* document [Uri]. Parsing is the pure `domain.ics.IcsParser`; this class only
|
||||||
|
* pulls bytes off the ContentResolver. Returns null on any read failure.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class IcsImporter @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
fun readText(uri: Uri): String? = runCatching {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { it.readBytes().toString(Charsets.UTF_8) }
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
// Android's calendar provider (and Calendula's own writes) use the non-standard
|
||||||
|
// single-unit forms P<n>S / P<n>D / P<n>W — seconds without the RFC-required
|
||||||
|
// leading T. Matched first; anything else falls through to the general grammar.
|
||||||
|
private val DURATION_SINGLE_UNIT = Regex("""([+-]?)P(\d+)([WDS])""")
|
||||||
|
private val DURATION_GENERAL =
|
||||||
|
Regex("""([+-]?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?""")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Milliseconds of a DURATION (`P1D`, `P3600S`, `PT1H30M`, `-PT15M`, `P1W`, …),
|
||||||
|
* sign-aware. Calendula writes `P<days>D` / `P<seconds>S`, but the parser also
|
||||||
|
* accepts the general RFC 5545 grammar so backup `DURATION` rows and foreign
|
||||||
|
* `VALARM` triggers round-trip. Unparseable input is treated as zero.
|
||||||
|
*/
|
||||||
|
fun parseRfc2445DurationMillis(duration: String?): Long {
|
||||||
|
if (duration.isNullOrBlank()) return 0L
|
||||||
|
val s = duration.trim()
|
||||||
|
|
||||||
|
DURATION_SINGLE_UNIT.matchEntire(s)?.let { m ->
|
||||||
|
val unitSeconds = when (m.groupValues[3]) {
|
||||||
|
"W" -> 7L * 24 * 60 * 60
|
||||||
|
"D" -> 24L * 60 * 60
|
||||||
|
else -> 1L // S
|
||||||
|
}
|
||||||
|
return m.signum() * m.groupValues[2].toLong() * unitSeconds * 1_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
val m = DURATION_GENERAL.matchEntire(s) ?: return 0L
|
||||||
|
val weeks = m.groupValues[2].toLongOrNull() ?: 0L
|
||||||
|
val days = m.groupValues[3].toLongOrNull() ?: 0L
|
||||||
|
val hours = m.groupValues[4].toLongOrNull() ?: 0L
|
||||||
|
val minutes = m.groupValues[5].toLongOrNull() ?: 0L
|
||||||
|
val seconds = m.groupValues[6].toLongOrNull() ?: 0L
|
||||||
|
val totalSeconds = ((((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 + seconds)
|
||||||
|
return m.signum() * totalSeconds * 1_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign carried by group 1 (`-` → -1, otherwise +1) of a duration match. */
|
||||||
|
private fun MatchResult.signum(): Long = if (groupValues[1] == "-") -1L else 1L
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent],
|
||||||
|
* but [uid] is nullable (an incoming event may carry none; the insert layer
|
||||||
|
* then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror
|
||||||
|
* how the writer encoded them.
|
||||||
|
*/
|
||||||
|
data class ParsedIcsEvent(
|
||||||
|
val uid: String?,
|
||||||
|
val summary: String,
|
||||||
|
val start: Instant,
|
||||||
|
val end: Instant,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
val zoneId: String,
|
||||||
|
val recurrenceRule: String? = null,
|
||||||
|
val location: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val reminderMinutes: List<Int> = emptyList(),
|
||||||
|
val status: EventStatus = EventStatus.Confirmed,
|
||||||
|
val availability: Availability = Availability.Busy,
|
||||||
|
val calendarName: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Things the parser dropped rather than failing — surfaced in the import report. */
|
||||||
|
enum class IcsParseWarning {
|
||||||
|
/** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */
|
||||||
|
ModifiedOccurrenceSkipped,
|
||||||
|
|
||||||
|
/** A `VEVENT` with no parseable `DTSTART`. */
|
||||||
|
EventWithoutStartSkipped,
|
||||||
|
|
||||||
|
/** `ATTENDEE` rows were present; Calendula doesn't import attendees. */
|
||||||
|
AttendeesIgnored,
|
||||||
|
|
||||||
|
/** A `TZID` couldn't be resolved against the device tz database (used local zone). */
|
||||||
|
UnknownTimezone,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class IcsParseResult(
|
||||||
|
val events: List<ParsedIcsEvent>,
|
||||||
|
val warnings: Set<IcsParseWarning>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Outcome of a bulk `.ics` import into one calendar. */
|
||||||
|
data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
|
||||||
|
* JVM-testable. Liberal-in/strict-out: unknown properties are ignored, a single
|
||||||
|
* malformed `VEVENT` is skipped (not fatal), and unsupported constructs
|
||||||
|
* (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings]
|
||||||
|
* rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is
|
||||||
|
* resolved against the OS tz database instead ([deviceZone] is the fallback).
|
||||||
|
*/
|
||||||
|
class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) {
|
||||||
|
|
||||||
|
fun parse(text: String): IcsParseResult {
|
||||||
|
val lines = unfoldLines(text)
|
||||||
|
val events = mutableListOf<ParsedIcsEvent>()
|
||||||
|
val warnings = mutableSetOf<IcsParseWarning>()
|
||||||
|
var calendarName: String? = null
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while (i < lines.size) {
|
||||||
|
val line = parseContentLine(lines[i])
|
||||||
|
if (line == null) { i++; continue }
|
||||||
|
when {
|
||||||
|
line.isBegin("VEVENT") -> {
|
||||||
|
val end = indexOfEnd(lines, i + 1, "VEVENT")
|
||||||
|
parseVevent(lines.subList(i + 1, end), calendarName, warnings)
|
||||||
|
?.let(events::add)
|
||||||
|
i = end + 1
|
||||||
|
}
|
||||||
|
line.isBegin("VTIMEZONE") -> {
|
||||||
|
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
||||||
|
i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1
|
||||||
|
}
|
||||||
|
line.name == "X-WR-CALNAME" -> {
|
||||||
|
calendarName = unescapeText(line.value).trim().ifEmpty { null }
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
else -> i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return IcsParseResult(events, warnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseVevent(
|
||||||
|
body: List<String>,
|
||||||
|
fileCalendarName: String?,
|
||||||
|
warnings: MutableSet<IcsParseWarning>,
|
||||||
|
): ParsedIcsEvent? {
|
||||||
|
var uid: String? = null
|
||||||
|
var summary = ""
|
||||||
|
var dtStart: IcsDateTime? = null
|
||||||
|
var dtEnd: IcsDateTime? = null
|
||||||
|
var duration: String? = null
|
||||||
|
var rrule: String? = null
|
||||||
|
var location: String? = null
|
||||||
|
var description: String? = null
|
||||||
|
var status = EventStatus.Confirmed
|
||||||
|
var availability = Availability.Busy
|
||||||
|
var calendarName = fileCalendarName
|
||||||
|
val reminders = mutableListOf<Int>()
|
||||||
|
var skipAsOverride = false
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while (i < body.size) {
|
||||||
|
val line = parseContentLine(body[i])
|
||||||
|
if (line == null) { i++; continue }
|
||||||
|
when (line.name) {
|
||||||
|
"BEGIN" -> if (line.value.trim().equals("VALARM", true)) {
|
||||||
|
val end = indexOfEnd(body, i + 1, "VALARM")
|
||||||
|
parseAlarmMinutes(body.subList(i + 1, end))?.let(reminders::add)
|
||||||
|
i = end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
"UID" -> uid = line.value.trim().ifEmpty { null }
|
||||||
|
"SUMMARY" -> summary = unescapeText(line.value)
|
||||||
|
"DTSTART" -> dtStart = parseIcsDateTime(line, warnings)
|
||||||
|
"DTEND" -> dtEnd = parseIcsDateTime(line, warnings)
|
||||||
|
"DURATION" -> duration = line.value.trim()
|
||||||
|
"RRULE" -> rrule = line.value.trim().ifEmpty { null }
|
||||||
|
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
|
||||||
|
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
|
||||||
|
"STATUS" -> status = mapIcsStatus(line.value)
|
||||||
|
"TRANSP" -> availability =
|
||||||
|
if (line.value.trim().equals("TRANSPARENT", true)) Availability.Free
|
||||||
|
else Availability.Busy
|
||||||
|
"RECURRENCE-ID" -> skipAsOverride = true
|
||||||
|
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
|
||||||
|
"X-CALENDULA-CALENDAR" ->
|
||||||
|
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipAsOverride) {
|
||||||
|
warnings.add(IcsParseWarning.ModifiedOccurrenceSkipped)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val start = dtStart ?: run {
|
||||||
|
warnings.add(IcsParseWarning.EventWithoutStartSkipped)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val end = dtEnd
|
||||||
|
?: duration?.let {
|
||||||
|
start.copy(
|
||||||
|
instant = Instant.fromEpochMilliseconds(
|
||||||
|
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
?: start
|
||||||
|
return ParsedIcsEvent(
|
||||||
|
uid = uid,
|
||||||
|
summary = summary,
|
||||||
|
start = start.instant,
|
||||||
|
end = end.instant,
|
||||||
|
isAllDay = start.isAllDay,
|
||||||
|
zoneId = start.zoneId,
|
||||||
|
recurrenceRule = rrule,
|
||||||
|
location = location,
|
||||||
|
description = description,
|
||||||
|
reminderMinutes = reminders.distinct(),
|
||||||
|
status = status,
|
||||||
|
availability = availability,
|
||||||
|
calendarName = calendarName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A VALARM's lead time in minutes before start, or null if not a usable relative trigger. */
|
||||||
|
private fun parseAlarmMinutes(body: List<String>): Int? {
|
||||||
|
val trigger = body.asSequence()
|
||||||
|
.mapNotNull { parseContentLine(it) }
|
||||||
|
.firstOrNull { it.name == "TRIGGER" }
|
||||||
|
?: return null
|
||||||
|
// Absolute (DATE-TIME) triggers can't be expressed as a lead time.
|
||||||
|
if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null
|
||||||
|
val millis = parseRfc2445DurationMillis(trigger.value)
|
||||||
|
// Negative = before start (the normal case) → positive lead minutes.
|
||||||
|
return (-millis / 60_000L).toInt().coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet<IcsParseWarning>): IcsDateTime? {
|
||||||
|
val raw = line.value.trim()
|
||||||
|
val isDate = line.params["VALUE"].equals("DATE", true) ||
|
||||||
|
(raw.length == 8 && !raw.contains('T'))
|
||||||
|
if (isDate) {
|
||||||
|
val date = parseBasicDate(raw) ?: return null
|
||||||
|
return IcsDateTime(date.atStartOfDayIn(TimeZone.UTC), isAllDay = true, zoneId = "UTC")
|
||||||
|
}
|
||||||
|
val isUtc = raw.endsWith("Z")
|
||||||
|
val ldt = parseBasicDateTime(raw.removeSuffix("Z")) ?: return null
|
||||||
|
if (isUtc) return IcsDateTime(ldt.toInstant(TimeZone.UTC), isAllDay = false, zoneId = "UTC")
|
||||||
|
|
||||||
|
val tzid = line.params["TZID"]
|
||||||
|
val resolved = tzid?.let { id -> runCatching { TimeZone.of(id) }.getOrNull()?.let { id to it } }
|
||||||
|
if (tzid != null && resolved == null) warnings.add(IcsParseWarning.UnknownTimezone)
|
||||||
|
val (zoneId, zone) = resolved ?: (deviceZone.id to deviceZone)
|
||||||
|
return IcsDateTime(ldt.toInstant(zone), isAllDay = false, zoneId = zoneId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class IcsDateTime(val instant: Instant, val isAllDay: Boolean, val zoneId: String)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
fun IcsContentLine.isBegin(component: String) =
|
||||||
|
name == "BEGIN" && value.trim().equals(component, true)
|
||||||
|
|
||||||
|
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
||||||
|
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
||||||
|
var i = from
|
||||||
|
while (i < lines.size) {
|
||||||
|
val line = parseContentLine(lines[i])
|
||||||
|
if (line != null && line.name == "END" &&
|
||||||
|
line.value.trim().equals(component, true)
|
||||||
|
) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return lines.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mapIcsStatus(value: String): EventStatus = when (value.trim().uppercase()) {
|
||||||
|
"TENTATIVE" -> EventStatus.Tentative
|
||||||
|
"CANCELLED" -> EventStatus.Cancelled
|
||||||
|
else -> EventStatus.Confirmed
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseBasicDate(s: String): LocalDate? = runCatching {
|
||||||
|
LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
|
||||||
|
val date = LocalDate(
|
||||||
|
s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt(),
|
||||||
|
)
|
||||||
|
// Format is YYYYMMDD 'T' HHMMSS; seconds optional.
|
||||||
|
val time = LocalTime(
|
||||||
|
s.substring(9, 11).toInt(),
|
||||||
|
s.substring(11, 13).toInt(),
|
||||||
|
if (s.length >= 15) s.substring(13, 15).toInt() else 0,
|
||||||
|
)
|
||||||
|
LocalDateTime(date, time)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,3 +60,96 @@ fun foldLine(line: String): String {
|
|||||||
}
|
}
|
||||||
return out.toString()
|
return out.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse of [escapeText]: turn `\n`/`\N` into newlines and unescape `\\`, `\;`,
|
||||||
|
* `\,`. A backslash before any other character is dropped, keeping the
|
||||||
|
* character (lenient — foreign files escape liberally).
|
||||||
|
*/
|
||||||
|
fun unescapeText(value: String): String = buildString(value.length) {
|
||||||
|
var i = 0
|
||||||
|
while (i < value.length) {
|
||||||
|
val c = value[i]
|
||||||
|
if (c == '\\' && i + 1 < value.length) {
|
||||||
|
when (val next = value[i + 1]) {
|
||||||
|
'n', 'N' -> append('\n')
|
||||||
|
else -> append(next) // \\, \;, \, and any other escaped char
|
||||||
|
}
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
append(c)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse of [foldLine] across a whole document: split into physical lines on
|
||||||
|
* CRLF/LF/CR, then re-join any line that begins with a single space or tab onto
|
||||||
|
* the previous one (RFC 5545 unfolding). Returns the logical content lines.
|
||||||
|
*/
|
||||||
|
fun unfoldLines(text: String): List<String> {
|
||||||
|
val out = mutableListOf<String>()
|
||||||
|
for (physical in text.split("\r\n", "\n", "\r")) {
|
||||||
|
if (physical.isEmpty()) continue
|
||||||
|
val isContinuation = physical[0] == ' ' || physical[0] == '\t'
|
||||||
|
if (isContinuation && out.isNotEmpty()) {
|
||||||
|
out[out.lastIndex] = out.last() + physical.substring(1)
|
||||||
|
} else {
|
||||||
|
out.add(physical)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One unfolded content line split into its property name, parameters and value:
|
||||||
|
* `SUMMARY;LANGUAGE=en:Lunch` → name `SUMMARY`, params `{LANGUAGE=en}`, value
|
||||||
|
* `Lunch`. The value is everything after the first colon that isn't inside a
|
||||||
|
* quoted parameter; param keys are upper-cased, quoted param values unquoted.
|
||||||
|
* Returns null for a line with no colon.
|
||||||
|
*/
|
||||||
|
data class IcsContentLine(val name: String, val params: Map<String, String>, val value: String)
|
||||||
|
|
||||||
|
fun parseContentLine(line: String): IcsContentLine? {
|
||||||
|
var inQuote = false
|
||||||
|
var colon = -1
|
||||||
|
for (i in line.indices) {
|
||||||
|
when (line[i]) {
|
||||||
|
'"' -> inQuote = !inQuote
|
||||||
|
':' -> if (!inQuote) { colon = i; break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (colon < 0) return null
|
||||||
|
val head = splitUnquoted(line.substring(0, colon), ';')
|
||||||
|
val name = head.firstOrNull()?.trim()?.uppercase().orEmpty()
|
||||||
|
if (name.isEmpty()) return null
|
||||||
|
val params = buildMap {
|
||||||
|
for (part in head.drop(1)) {
|
||||||
|
val eq = part.indexOf('=')
|
||||||
|
if (eq > 0) {
|
||||||
|
put(
|
||||||
|
part.substring(0, eq).trim().uppercase(),
|
||||||
|
part.substring(eq + 1).trim().removeSurrounding("\""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return IcsContentLine(name, params, line.substring(colon + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split on [delimiter] except where it falls inside a double-quoted run. */
|
||||||
|
private fun splitUnquoted(text: String, delimiter: Char): List<String> {
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
val current = StringBuilder()
|
||||||
|
var inQuote = false
|
||||||
|
for (c in text) {
|
||||||
|
when {
|
||||||
|
c == '"' -> { inQuote = !inQuote; current.append(c) }
|
||||||
|
c == delimiter && !inQuote -> { parts.add(current.toString()); current.clear() }
|
||||||
|
else -> current.append(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.add(current.toString())
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefill the create form from a single parsed `.ics` event (the "open one
|
||||||
|
* event" path). [calendarId] is left null so the form preselects the last-used
|
||||||
|
* calendar, exactly like a fresh create — the user confirms the target and
|
||||||
|
* reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day
|
||||||
|
* handling (provider all-day times are UTC midnights with an exclusive end).
|
||||||
|
*/
|
||||||
|
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||||
|
val (start, end) = if (isAllDay) {
|
||||||
|
val startDate = this.start.toLocalDateTime(TimeZone.UTC).date
|
||||||
|
val endExclusive = this.end.toLocalDateTime(TimeZone.UTC).date
|
||||||
|
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||||
|
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||||
|
} else {
|
||||||
|
this.start.toLocalDateTime(zone) to this.end.toLocalDateTime(zone)
|
||||||
|
}
|
||||||
|
return EventForm(
|
||||||
|
calendarId = null,
|
||||||
|
title = summary,
|
||||||
|
isAllDay = isAllDay,
|
||||||
|
start = start,
|
||||||
|
end = end,
|
||||||
|
location = location.orEmpty(),
|
||||||
|
description = description.orEmpty(),
|
||||||
|
reminders = reminderMinutes.distinct().sorted(),
|
||||||
|
availability = availability,
|
||||||
|
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -776,14 +776,19 @@ private fun formatWhen(
|
|||||||
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
||||||
|
|
||||||
if (instance.isAllDay) {
|
if (instance.isAllDay) {
|
||||||
// All-day end is the exclusive next midnight; step back to the last
|
// All-day events live at UTC midnights with an exclusive end. Resolve
|
||||||
// covered day so a one-day event reads as a single date.
|
// the covered dates in UTC — not the device zone, which would shift the
|
||||||
val lastLdt = (instance.end - 1.seconds).toJavaLocalDateTime(zid)
|
// midnight boundaries off the intended date (east of UTC pushes the
|
||||||
return if (startLdt.toLocalDate() == lastLdt.toLocalDate()) {
|
// end past the last day; west of UTC pulls the start back) — and step
|
||||||
allDayLabel to dateFull.format(startLdt.toLocalDate())
|
// the end back to the last covered day so a one-day event reads as a
|
||||||
|
// single date.
|
||||||
|
val utc = ZoneId.of("UTC")
|
||||||
|
val startDate = instance.start.toJavaLocalDateTime(utc).toLocalDate()
|
||||||
|
val lastDate = (instance.end - 1.seconds).toJavaLocalDateTime(utc).toLocalDate()
|
||||||
|
return if (startDate == lastDate) {
|
||||||
|
allDayLabel to dateFull.format(startDate)
|
||||||
} else {
|
} else {
|
||||||
allDayLabel to
|
allDayLabel to "${dateMedium.format(startDate)} – ${dateMedium.format(lastDate)}"
|
||||||
"${dateMedium.format(startLdt.toLocalDate())} – ${dateMedium.format(lastLdt.toLocalDate())}"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -445,4 +445,35 @@ class CalendarRepositoryImplTest {
|
|||||||
.containsExactly(EventColorOption("5", 0xFF33B679.toInt()))
|
.containsExactly(EventColorOption("5", 0xFF33B679.toInt()))
|
||||||
assertThat(repo.eventColorPalette(8L)).isEmpty()
|
assertThat(repo.eventColorPalette(8L)).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `importEvents skips events whose UID already exists and inserts the rest`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
existingUidsResult = setOf("dup@x")
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
val events = listOf(
|
||||||
|
parsedEvent("dup@x"), // already present → skipped
|
||||||
|
parsedEvent("new@x"), // inserted
|
||||||
|
parsedEvent(null), // no UID → always inserted
|
||||||
|
)
|
||||||
|
|
||||||
|
val summary = repo.importEvents(targetCalendarId = 3L, events = events)
|
||||||
|
|
||||||
|
assertThat(summary.imported).isEqualTo(2)
|
||||||
|
assertThat(summary.skippedDuplicate).isEqualTo(1)
|
||||||
|
assertThat(fake.importedEvents.map { it.first.uid }).containsExactly("new@x", null)
|
||||||
|
assertThat(fake.importedEvents.map { it.second }).containsExactly(3L, 3L)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parsedEvent(uid: String?) = de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
|
||||||
|
uid = uid,
|
||||||
|
summary = "E",
|
||||||
|
start = Instant.fromEpochMilliseconds(1_000_000_000L),
|
||||||
|
end = Instant.fromEpochMilliseconds(1_000_003_600L),
|
||||||
|
isAllDay = false,
|
||||||
|
zoneId = "UTC",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test-only fake. Tunable via the three `var` properties; `tick()` simulates
|
* Test-only fake. Tunable via the three `var` properties; `tick()` simulates
|
||||||
@@ -18,6 +19,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
var eventDetailResult: (Long) -> EventDetail? = { null }
|
var eventDetailResult: (Long) -> EventDetail? = { null }
|
||||||
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
|
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
|
||||||
var exportableEventsResult: List<IcsEvent> = emptyList()
|
var exportableEventsResult: List<IcsEvent> = emptyList()
|
||||||
|
/** UIDs the target calendar already holds, for import dedup. */
|
||||||
|
var existingUidsResult: Set<String> = emptySet()
|
||||||
/** Set to make the next write call throw. */
|
/** Set to make the next write call throw. */
|
||||||
var writeError: Exception? = null
|
var writeError: Exception? = null
|
||||||
/** Id returned by the next [insertEvent]. */
|
/** Id returned by the next [insertEvent]. */
|
||||||
@@ -53,6 +56,17 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
eventColorPaletteResult(calendarId)
|
eventColorPaletteResult(calendarId)
|
||||||
override fun exportableEvents(): List<IcsEvent> = exportableEventsResult
|
override fun exportableEvents(): List<IcsEvent> = exportableEventsResult
|
||||||
|
|
||||||
|
override fun existingUids(calendarId: Long): Set<String> = existingUidsResult
|
||||||
|
|
||||||
|
/** (event, targetCalendarId) pairs passed to [insertImportedEvent]. */
|
||||||
|
val importedEvents = mutableListOf<Pair<ParsedIcsEvent, Long>>()
|
||||||
|
|
||||||
|
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
|
||||||
|
writeError?.let { throw it }
|
||||||
|
importedEvents += event to calendarId
|
||||||
|
return nextInsertId
|
||||||
|
}
|
||||||
|
|
||||||
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
||||||
writeError?.let { throw it }
|
writeError?.let { throw it }
|
||||||
createdCalendars += CreatedCalendar(displayName, color, description)
|
createdCalendars += CreatedCalendar(displayName, color, description)
|
||||||
|
|||||||
@@ -7,16 +7,6 @@ import org.junit.jupiter.api.Test
|
|||||||
|
|
||||||
class IcsExportMapperTest {
|
class IcsExportMapperTest {
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `parses the duration forms Calendula writes plus general ones`() {
|
|
||||||
assertThat(parseRfc2445DurationMillis("P1D")).isEqualTo(86_400_000L)
|
|
||||||
assertThat(parseRfc2445DurationMillis("P3600S")).isEqualTo(3_600_000L)
|
|
||||||
assertThat(parseRfc2445DurationMillis("PT1H30M")).isEqualTo(5_400_000L)
|
|
||||||
assertThat(parseRfc2445DurationMillis("P1W")).isEqualTo(604_800_000L)
|
|
||||||
assertThat(parseRfc2445DurationMillis(null)).isEqualTo(0L)
|
|
||||||
assertThat(parseRfc2445DurationMillis("nonsense")).isEqualTo(0L)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `timed one-off row maps with its DTEND and kept UID`() {
|
fun `timed one-off row maps with its DTEND and kept UID`() {
|
||||||
val reader = MapColumnReader(
|
val reader = MapColumnReader(
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class IcsDurationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses the single-unit forms Calendula writes plus general ones`() {
|
||||||
|
assertThat(parseRfc2445DurationMillis("P1D")).isEqualTo(86_400_000L)
|
||||||
|
assertThat(parseRfc2445DurationMillis("P3600S")).isEqualTo(3_600_000L)
|
||||||
|
assertThat(parseRfc2445DurationMillis("PT1H30M")).isEqualTo(5_400_000L)
|
||||||
|
assertThat(parseRfc2445DurationMillis("P1W")).isEqualTo(604_800_000L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `is sign-aware for before-start VALARM triggers`() {
|
||||||
|
assertThat(parseRfc2445DurationMillis("-PT15M")).isEqualTo(-900_000L)
|
||||||
|
assertThat(parseRfc2445DurationMillis("PT0M")).isEqualTo(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unparseable input is zero`() {
|
||||||
|
assertThat(parseRfc2445DurationMillis(null)).isEqualTo(0L)
|
||||||
|
assertThat(parseRfc2445DurationMillis("")).isEqualTo(0L)
|
||||||
|
assertThat(parseRfc2445DurationMillis("nonsense")).isEqualTo(0L)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class IcsParserTest {
|
||||||
|
|
||||||
|
private val parser = IcsParser(deviceZone = TimeZone.of("Europe/Berlin"))
|
||||||
|
private val writer = IcsWriter()
|
||||||
|
private val stamp = LocalDateTime(2026, 6, 18, 9, 30, 0).toInstant(TimeZone.UTC)
|
||||||
|
|
||||||
|
private fun roundTrip(event: IcsEvent): ParsedIcsEvent {
|
||||||
|
val text = writer.writeCalendar(listOf(event), stamp)
|
||||||
|
val result = parser.parse(text)
|
||||||
|
assertThat(result.events).hasSize(1)
|
||||||
|
return result.events.single()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun instantUtc(y: Int, mo: Int, d: Int, h: Int, mi: Int): Instant =
|
||||||
|
LocalDateTime(y, mo, d, h, mi, 0).toInstant(TimeZone.UTC)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `round-trips a timed one-off event`() {
|
||||||
|
val event = IcsEvent(
|
||||||
|
uid = "u1@calendula",
|
||||||
|
summary = "Lunch; with, friends",
|
||||||
|
start = instantUtc(2026, 6, 18, 11, 0),
|
||||||
|
end = instantUtc(2026, 6, 18, 12, 0),
|
||||||
|
isAllDay = false,
|
||||||
|
zoneId = "Europe/Berlin",
|
||||||
|
location = "Café",
|
||||||
|
availability = Availability.Free,
|
||||||
|
status = EventStatus.Tentative,
|
||||||
|
)
|
||||||
|
val parsed = roundTrip(event)
|
||||||
|
assertThat(parsed.uid).isEqualTo("u1@calendula")
|
||||||
|
assertThat(parsed.summary).isEqualTo("Lunch; with, friends")
|
||||||
|
assertThat(parsed.start).isEqualTo(event.start)
|
||||||
|
assertThat(parsed.end).isEqualTo(event.end)
|
||||||
|
assertThat(parsed.isAllDay).isFalse()
|
||||||
|
assertThat(parsed.location).isEqualTo("Café")
|
||||||
|
assertThat(parsed.availability).isEqualTo(Availability.Free)
|
||||||
|
assertThat(parsed.status).isEqualTo(EventStatus.Tentative)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `round-trips a recurring TZID event to the same instant`() {
|
||||||
|
val event = IcsEvent(
|
||||||
|
uid = "u2@calendula",
|
||||||
|
summary = "Weekly",
|
||||||
|
start = instantUtc(2026, 6, 18, 13, 0),
|
||||||
|
end = instantUtc(2026, 6, 18, 14, 0),
|
||||||
|
isAllDay = false,
|
||||||
|
zoneId = "Europe/Berlin",
|
||||||
|
recurrenceRule = "FREQ=WEEKLY",
|
||||||
|
)
|
||||||
|
val parsed = roundTrip(event)
|
||||||
|
assertThat(parsed.start).isEqualTo(event.start)
|
||||||
|
assertThat(parsed.end).isEqualTo(event.end)
|
||||||
|
assertThat(parsed.zoneId).isEqualTo("Europe/Berlin")
|
||||||
|
assertThat(parsed.recurrenceRule).isEqualTo("FREQ=WEEKLY")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `round-trips an all-day event`() {
|
||||||
|
val event = IcsEvent(
|
||||||
|
uid = "u3@calendula",
|
||||||
|
summary = "Holiday",
|
||||||
|
start = LocalDate(2026, 6, 18).atStartOfDayIn(TimeZone.UTC),
|
||||||
|
end = LocalDate(2026, 6, 19).atStartOfDayIn(TimeZone.UTC),
|
||||||
|
isAllDay = true,
|
||||||
|
zoneId = "UTC",
|
||||||
|
)
|
||||||
|
val parsed = roundTrip(event)
|
||||||
|
assertThat(parsed.isAllDay).isTrue()
|
||||||
|
assertThat(parsed.start).isEqualTo(event.start)
|
||||||
|
assertThat(parsed.end).isEqualTo(event.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `round-trips reminders as before-start lead minutes`() {
|
||||||
|
val event = IcsEvent(
|
||||||
|
uid = "u4@calendula",
|
||||||
|
summary = "Meeting",
|
||||||
|
start = instantUtc(2026, 6, 18, 13, 0),
|
||||||
|
end = instantUtc(2026, 6, 18, 14, 0),
|
||||||
|
isAllDay = false,
|
||||||
|
zoneId = "UTC",
|
||||||
|
reminderMinutes = listOf(15, 0),
|
||||||
|
)
|
||||||
|
val parsed = roundTrip(event)
|
||||||
|
assertThat(parsed.reminderMinutes).containsExactly(15, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `tolerates folded lines and a missing UID`() {
|
||||||
|
val ics = buildString {
|
||||||
|
append("BEGIN:VCALENDAR\r\n")
|
||||||
|
append("VERSION:2.0\r\n")
|
||||||
|
append("BEGIN:VEVENT\r\n")
|
||||||
|
// Folded DESCRIPTION (continuation line begins with a space).
|
||||||
|
append("DESCRIPTION:This is a long descriptio\r\n n that was folded\r\n")
|
||||||
|
append("SUMMARY:No UID here\r\n")
|
||||||
|
append("DTSTART:20260618T090000Z\r\n")
|
||||||
|
append("DTEND:20260618T100000Z\r\n")
|
||||||
|
append("END:VEVENT\r\n")
|
||||||
|
append("END:VCALENDAR\r\n")
|
||||||
|
}
|
||||||
|
val parsed = parser.parse(ics).events.single()
|
||||||
|
assertThat(parsed.uid).isNull()
|
||||||
|
assertThat(parsed.description).isEqualTo("This is a long description that was folded")
|
||||||
|
assertThat(parsed.summary).isEqualTo("No UID here")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `skips a RECURRENCE-ID override and reports it`() {
|
||||||
|
val ics = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:x\r\n" +
|
||||||
|
"RECURRENCE-ID:20260618T090000Z\r\nDTSTART:20260618T090000Z\r\n" +
|
||||||
|
"SUMMARY:Override\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
|
||||||
|
val result = parser.parse(ics)
|
||||||
|
assertThat(result.events).isEmpty()
|
||||||
|
assertThat(result.warnings).contains(IcsParseWarning.ModifiedOccurrenceSkipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reports ignored attendees but still imports the event`() {
|
||||||
|
val ics = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:y\r\n" +
|
||||||
|
"DTSTART:20260618T090000Z\r\nSUMMARY:Has guests\r\n" +
|
||||||
|
"ATTENDEE;CN=Bob:mailto:bob@example.com\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
|
||||||
|
val result = parser.parse(ics)
|
||||||
|
assertThat(result.events).hasSize(1)
|
||||||
|
assertThat(result.warnings).contains(IcsParseWarning.AttendeesIgnored)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses multiple events and carries the calendar name`() {
|
||||||
|
val events = listOf(
|
||||||
|
IcsEvent("a@c", "One", instantUtc(2026, 6, 18, 9, 0), instantUtc(2026, 6, 18, 10, 0),
|
||||||
|
false, "UTC", calendarName = "Personal"),
|
||||||
|
IcsEvent("b@c", "Two", instantUtc(2026, 6, 19, 9, 0), instantUtc(2026, 6, 19, 10, 0),
|
||||||
|
false, "UTC", calendarName = "Personal"),
|
||||||
|
)
|
||||||
|
val text = writer.writeCalendar(events, stamp)
|
||||||
|
val result = parser.parse(text)
|
||||||
|
assertThat(result.events).hasSize(2)
|
||||||
|
assertThat(result.events.map { it.calendarName }).containsExactly("Personal", "Personal")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a malformed event does not sink the rest of the file`() {
|
||||||
|
// First VEVENT has no DTSTART (skipped); second is valid.
|
||||||
|
val ics = "BEGIN:VCALENDAR\r\n" +
|
||||||
|
"BEGIN:VEVENT\r\nUID:bad\r\nSUMMARY:No start\r\nEND:VEVENT\r\n" +
|
||||||
|
"BEGIN:VEVENT\r\nUID:good\r\nDTSTART:20260618T090000Z\r\nSUMMARY:Fine\r\nEND:VEVENT\r\n" +
|
||||||
|
"END:VCALENDAR\r\n"
|
||||||
|
val result = parser.parse(ics)
|
||||||
|
assertThat(result.events.map { it.uid }).containsExactly("good")
|
||||||
|
assertThat(result.warnings).contains(IcsParseWarning.EventWithoutStartSkipped)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class ParsedIcsFormTest {
|
||||||
|
|
||||||
|
private val berlin = TimeZone.of("Europe/Berlin")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timed event maps to wall-clock form times in the device zone`() {
|
||||||
|
val event = ParsedIcsEvent(
|
||||||
|
uid = "u@x",
|
||||||
|
summary = "Call",
|
||||||
|
start = LocalDateTime(2026, 6, 18, 13, 0, 0).toInstant(TimeZone.UTC),
|
||||||
|
end = LocalDateTime(2026, 6, 18, 14, 0, 0).toInstant(TimeZone.UTC),
|
||||||
|
isAllDay = false,
|
||||||
|
zoneId = "UTC",
|
||||||
|
reminderMinutes = listOf(10, 10, 5),
|
||||||
|
recurrenceRule = "FREQ=WEEKLY",
|
||||||
|
)
|
||||||
|
val form = event.toEventForm(berlin)
|
||||||
|
|
||||||
|
assertThat(form.calendarId).isNull()
|
||||||
|
assertThat(form.title).isEqualTo("Call")
|
||||||
|
// 13:00 UTC == 15:00 Berlin (summer).
|
||||||
|
assertThat(form.start).isEqualTo(LocalDateTime(2026, 6, 18, 15, 0, 0))
|
||||||
|
assertThat(form.end).isEqualTo(LocalDateTime(2026, 6, 18, 16, 0, 0))
|
||||||
|
assertThat(form.reminders).containsExactly(5, 10).inOrder()
|
||||||
|
assertThat(form.rrule).isEqualTo("FREQ=WEEKLY")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day event shows the last covered day, not the exclusive end`() {
|
||||||
|
val event = ParsedIcsEvent(
|
||||||
|
uid = null,
|
||||||
|
summary = "Trip",
|
||||||
|
start = LocalDate(2026, 6, 18).atStartOfDayIn(TimeZone.UTC),
|
||||||
|
end = LocalDate(2026, 6, 20).atStartOfDayIn(TimeZone.UTC), // exclusive
|
||||||
|
isAllDay = true,
|
||||||
|
zoneId = "UTC",
|
||||||
|
)
|
||||||
|
val form = event.toEventForm(berlin)
|
||||||
|
|
||||||
|
assertThat(form.isAllDay).isTrue()
|
||||||
|
assertThat(form.start.date).isEqualTo(LocalDate(2026, 6, 18))
|
||||||
|
assertThat(form.end.date).isEqualTo(LocalDate(2026, 6, 19)) // last covered day
|
||||||
|
}
|
||||||
|
}
|
||||||
122
docs/superpowers/plans/2026-06-18-06-ics-import.md
Normal file
122
docs/superpowers/plans/2026-06-18-06-ics-import.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Calendula - Plan 06: ICS Import (v2.7, Branch 2 von 2)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Die Lese-Hälfte des `.ics`-Themas, aufgesetzt auf den Writer aus
|
||||||
|
Branch 1 (`feat/ics-export`, gemerged in `release/v2.7.0`). Calendula parst
|
||||||
|
RFC-5545-Dateien und führt sie über zwei Wege ein: eine einzelne `VEVENT` öffnet
|
||||||
|
das vorausgefüllte Erstellen-Formular (Review vor dem Speichern), eine Datei mit
|
||||||
|
vielen Events geht in einen Bulk-Import mit Ziel-Kalender-Auswahl und
|
||||||
|
Ergebnis-Report. Damit schließt sich der Backup→Restore-Kreis für lokale
|
||||||
|
Kalender. Beide Branches landen in **einem** Release v2.7.0.
|
||||||
|
`./gradlew lint test assembleDebug` bleibt grün; Release erst nach
|
||||||
|
On-Device-Review.
|
||||||
|
|
||||||
|
**Architecture:** `IcsParser` lebt rein in `domain/ics/` neben `IcsWriter` —
|
||||||
|
kein Android, JVM-testbar, symmetrisch zum Writer. Ausgabe ist ein
|
||||||
|
`IcsParseResult` (`events: List<ParsedIcsEvent>` + `warnings: List<String>`).
|
||||||
|
`ParsedIcsEvent` ist die Parse-Variante von `IcsEvent` (gleiche Felder, aber
|
||||||
|
`uid: String?` — eine eingelesene `VEVENT` kann ohne UID kommen). Zwei Adapter:
|
||||||
|
`ParsedIcsEvent.toEventForm(zone)` (Einzel-Öffnen → `EventForm`) und eine
|
||||||
|
Repository-Methode `importEvents(targetCalendarId, events)` → `ImportSummary`
|
||||||
|
(Bulk). Datei-IO (`Uri` lesen, Intent) liegt in `data/ics/` bzw. der
|
||||||
|
Activity/Compose-Schicht; Routing 1-vs-viele entscheidet anhand der geparsten
|
||||||
|
Event-Anzahl.
|
||||||
|
|
||||||
|
**Liberal-in/strict-out (Leitprinzip):** unbekannte Properties, fremde
|
||||||
|
`VTIMEZONE`-Blöcke und `RECURRENCE-ID`-Ausnahmen werden **übersprungen und im
|
||||||
|
Report vermerkt**, nie still verschluckt; ein einzelnes kaputtes `VEVENT` lässt
|
||||||
|
den Rest der Datei durch.
|
||||||
|
|
||||||
|
**Leitentscheidungen:**
|
||||||
|
|
||||||
|
1. **Parse-Mechanik (Umkehr von `IcsText`):** zuerst **Unfolding** (CRLF +
|
||||||
|
Space/Tab → wegfalten), dann pro Zeile `NAME[;params]:value` zerlegen,
|
||||||
|
TEXT-Werte **unescapen** (`\\` `\;` `\,` `\n`). Eine reine, einzeln getestete
|
||||||
|
Schicht (`IcsLineParser`), nicht ad hoc im Walker.
|
||||||
|
2. **Datum/Zeit-Parsing (Umkehr der Writer-Zeitzonenregel):**
|
||||||
|
- `VALUE=DATE` (`YYYYMMDD`) → all-day, Instant auf UTC-Mitternacht (wie der
|
||||||
|
Provider all-day speichert), exklusives `DTEND` bleibt exklusiv.
|
||||||
|
- `…T…Z` → UTC-Instant.
|
||||||
|
- `…T…` mit `TZID=<zone>` → lokale Wandzeit in der Zone, aufgelöst gegen die
|
||||||
|
**OS-tz-Datenbank** (`TimeZone.of`); unbekannte/fehlende `TZID` →
|
||||||
|
Gerätezone als Fallback (+ Warnung).
|
||||||
|
- Kein `VTIMEZONE`-Parsing — `TZID` wird gegen die OS-DB aufgelöst (s. Branch-1
|
||||||
|
Entscheidung); ein `VTIMEZONE`-Block wird übersprungen (Warnung nur, wenn
|
||||||
|
seine `TZID` nicht in der OS-DB ist).
|
||||||
|
3. **Routing 1-vs-viele:** genau **eine** `VEVENT` → vorausgefülltes
|
||||||
|
Erstellen-Formular (`ParsedIcsEvent.toEventForm`, `calendarId=null` →
|
||||||
|
Formular wählt wie gehabt den zuletzt genutzten Kalender vor). **Mehr als
|
||||||
|
eine** → Bulk-Import-Screen (Ziel-Kalender-Picker, nur beschreibbare). Leere
|
||||||
|
Datei → freundlicher „nichts gefunden"-Hinweis.
|
||||||
|
4. **UID-Dedup beim Bulk-Import:** vor dem Insert die vorhandenen
|
||||||
|
`Events.UID_2445` des Ziel-Kalenders lesen; eine eingelesene UID, die schon
|
||||||
|
existiert, wird **übersprungen** (gezählt als „bereits vorhanden"). v1:
|
||||||
|
skip-not-update — kein Überschreiben, das hält den Restore idempotent und
|
||||||
|
verlustfrei. Events ohne UID bekommen beim Insert eine frische
|
||||||
|
(`UUID@calendula`, wie `insertEvent`).
|
||||||
|
5. **Empfang via Intent:** Manifest-`ACTION_VIEW`/`SEND` mit MIME `text/calendar`
|
||||||
|
(+ `.ics`-Pfadmuster für `file`/`content`-Schemes). `MainActivity`
|
||||||
|
(`singleTop`, wie beim Reminder-Tap) liest den `Uri`, parst, und reicht das
|
||||||
|
Ergebnis als Compose-State an `CalendarHost` (gleiches Key-Muster wie der
|
||||||
|
Notification-Deep-Link).
|
||||||
|
6. **Reminder/Status/Transp zurück:** `VALARM` `TRIGGER` (negatives `-PT…`,
|
||||||
|
`PT0…`) → Lead-Minuten; `STATUS`/`TRANSP` → `EventStatus`/`Availability`.
|
||||||
|
`DURATION`-statt-`DTEND` über `parseRfc2445DurationMillis` (existiert aus
|
||||||
|
Branch 1). Attendees werden **nicht** importiert (kein Modell; Warnung wenn
|
||||||
|
vorhanden).
|
||||||
|
|
||||||
|
**Recherche-Befunde (Codebase, 2026-06-18 — aus Branch 1):**
|
||||||
|
- `IcsText.escapeText`/`foldLine` + `IcsWriter` existieren; Parser spiegelt sie.
|
||||||
|
- `parseRfc2445DurationMillis` (in `IcsExportMapper.kt`) parst die
|
||||||
|
Provider-`DURATION`-Formen inkl. des nicht-standardkonformen `P<n>S`.
|
||||||
|
- `EventForm` (Domain): Zeiten als `LocalDateTime` in Gerätezone, `calendarId`
|
||||||
|
nullable; das Formular wählt bei `null` den zuletzt genutzten Kalender vor.
|
||||||
|
- `insertEvent` schreibt bereits `Events.UID_2445`; für den Import muss eine
|
||||||
|
**vorgegebene** UID durchgereicht werden (Insert-Variante / Parameter).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
**Parser-Engine (`domain/ics/`, reine Kotlin, JVM-Tests):**
|
||||||
|
- [ ] `IcsText`: `unescapeText` + `unfold(lines)` ergänzen (+ Test)
|
||||||
|
- [ ] `IcsLineParser`: `NAME;PARAM=v;PARAM=v:VALUE` → (name, params-map, value);
|
||||||
|
Param-Werte ggf. in Quotes; Test (Kantenfälle: Doppelpunkt im Wert,
|
||||||
|
gequotete Params)
|
||||||
|
- [ ] `ParsedIcsEvent` (wie `IcsEvent`, aber `uid: String?`) + Datum/Zeit-Parser
|
||||||
|
(`VALUE=DATE` / `…Z` / `TZID` → Instant + isAllDay + zoneId)
|
||||||
|
- [ ] `IcsParser.parse(text)` → `IcsParseResult(events, warnings)`: VCALENDAR/
|
||||||
|
VEVENT-Walk, skip-and-report für `RECURRENCE-ID`/unbekannte `VTIMEZONE`/
|
||||||
|
Attendees; ein defektes VEVENT killt nicht den Rest. **Round-trip-Test**
|
||||||
|
gegen `IcsWriter`-Ausgabe (all-day, getimt, wiederkehrend+TZID, Reminder)
|
||||||
|
+ Fremd-Quirks (gefaltete Zeilen, fehlende UID, `PT…`-Trigger)
|
||||||
|
|
||||||
|
**Datenschicht (`data/calendar/` + `data/ics/`):**
|
||||||
|
- [ ] `ParsedIcsEvent.toEventForm(zone)` (Einzel-Öffnen → `EventForm`); Test
|
||||||
|
- [ ] Datasource: `existingUids(calendarId)` (Query `Events.UID_2445`) +
|
||||||
|
`insertImported(event, calendarId)` (Insert mit vorgegebener/erzeugter UID)
|
||||||
|
- [ ] Repository `importEvents(targetCalendarId, events)` → `ImportSummary`
|
||||||
|
(imported / skippedDuplicate / skippedUnsupported); UID-Dedup; Test mit
|
||||||
|
Fake-Datasource
|
||||||
|
- [ ] `IcsImporter` (`data/ics/`): `Uri` → Text lesen (UTF-8, `contentResolver`)
|
||||||
|
|
||||||
|
**Intent + Routing:**
|
||||||
|
- [ ] Manifest: `ACTION_VIEW`/`ACTION_SEND`, MIME `text/calendar`, `.ics`-
|
||||||
|
Pfadmuster (`file`/`content`); `MainActivity` parst eingehenden `Uri`
|
||||||
|
- [ ] Routing in `CalendarHost`: 1 Event → Erstellen-Formular vorausgefüllt;
|
||||||
|
>1 → Bulk-Import-Screen; 0 → Hinweis
|
||||||
|
|
||||||
|
**UI:**
|
||||||
|
- [ ] Bulk-Import-Screen: Ziel-Kalender-Picker (OptionCard, nur beschreibbare),
|
||||||
|
Anzahl/Vorschau, Import-Button → `ImportSummary` als Ergebnis
|
||||||
|
- [ ] Einzel-Öffnen: `EventEditScreen` mit vorausgefülltem Formular (neuer
|
||||||
|
Prefill-Pfad im `EventEditViewModel`, ohne `eventId`)
|
||||||
|
- [ ] Strings DE+EN: Import-Titel, Ziel-Auswahl, Ergebnis-Plurals
|
||||||
|
(importiert / übersprungen-vorhanden / übersprungen-nicht-unterstützt),
|
||||||
|
leere-Datei-Hinweis
|
||||||
|
|
||||||
|
**Abschluss:**
|
||||||
|
- [ ] `./gradlew lint test assembleDebug` grün
|
||||||
|
- [ ] CHANGELOG (`[Unreleased]`), ROADMAP/STATE; v2.7 cut **erst** wenn beide
|
||||||
|
Branches gemerged sind und On-Device-Review durch ist
|
||||||
Reference in New Issue
Block a user