feat(ics): import core — parser, dedup-aware bulk import, form prefill
v2.7 Branch 2 (core, no UI yet). The read side of the .ics engine: - domain/ics: IcsParser (inverse of IcsWriter) — unfold/unescape/param parsing, VALUE=DATE / UTC-Z / TZID date handling resolved against the OS tz db, VEVENT walk → ParsedIcsEvent + typed warnings. Liberal-in/ strict-out: a malformed VEVENT is skipped, RECURRENCE-ID overrides / attendees / unresolved TZIDs are reported, not silently dropped. - Promoted parseRfc2445DurationMillis into domain/ics (shared by writer- side mapper and parser); IcsDuration + test. - Datasource existingUids()/insertImportedEvent(); repository importEvents() with UID dedup (skip known UIDs → idempotent restore) → IcsImportSummary. IcsImporter reads a Uri's text. - ParsedIcsEvent.toEventForm() for the single-event "open into the create form" path. Parser round-trips against IcsWriter; dedup + form-adapter unit-tested. Intent filter, routing and import UI land in the next commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,8 +18,10 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.ZoneId
|
||||
@@ -57,6 +59,19 @@ interface CalendarDataSource {
|
||||
*/
|
||||
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;
|
||||
* returns its `Calendars._ID`. Inserted through the sync-adapter URI so the
|
||||
@@ -307,6 +322,79 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
} ?: 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. */
|
||||
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
||||
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.EventInstance
|
||||
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 kotlin.time.Instant
|
||||
|
||||
@@ -35,6 +37,13 @@ interface CalendarRepository {
|
||||
*/
|
||||
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`. */
|
||||
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.EventForm
|
||||
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.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -101,6 +103,26 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
|
||||
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) {
|
||||
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.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
if (instance.isAllDay) {
|
||||
// All-day end is the exclusive next midnight; step back to the last
|
||||
// covered day so a one-day event reads as a single date.
|
||||
val lastLdt = (instance.end - 1.seconds).toJavaLocalDateTime(zid)
|
||||
return if (startLdt.toLocalDate() == lastLdt.toLocalDate()) {
|
||||
allDayLabel to dateFull.format(startLdt.toLocalDate())
|
||||
// All-day events live at UTC midnights with an exclusive end. Resolve
|
||||
// the covered dates in UTC — not the device zone, which would shift the
|
||||
// midnight boundaries off the intended date (east of UTC pushes the
|
||||
// end past the last day; west of UTC pulls the start back) — and step
|
||||
// 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 {
|
||||
allDayLabel to
|
||||
"${dateMedium.format(startLdt.toLocalDate())} – ${dateMedium.format(lastLdt.toLocalDate())}"
|
||||
allDayLabel to "${dateMedium.format(startDate)} – ${dateMedium.format(lastDate)}"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -445,4 +445,35 @@ class CalendarRepositoryImplTest {
|
||||
.containsExactly(EventColorOption("5", 0xFF33B679.toInt()))
|
||||
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.EventInstance
|
||||
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
|
||||
@@ -18,6 +19,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
var eventDetailResult: (Long) -> EventDetail? = { null }
|
||||
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { 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. */
|
||||
var writeError: Exception? = null
|
||||
/** Id returned by the next [insertEvent]. */
|
||||
@@ -53,6 +56,17 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
eventColorPaletteResult(calendarId)
|
||||
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 {
|
||||
writeError?.let { throw it }
|
||||
createdCalendars += CreatedCalendar(displayName, color, description)
|
||||
|
||||
@@ -7,16 +7,6 @@ import org.junit.jupiter.api.Test
|
||||
|
||||
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
|
||||
fun `timed one-off row maps with its DTEND and kept UID`() {
|
||||
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