From e1c2e9f2e5cd9fc10f77d61ba5cca3572e7eb26c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 18 Jun 2026 14:59:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(ics):=20import=20core=20=E2=80=94=20parser?= =?UTF-8?q?,=20dedup-aware=20bulk=20import,=20form=20prefill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../data/calendar/CalendarDataSource.kt | 88 ++++++ .../data/calendar/CalendarRepository.kt | 9 + .../data/calendar/CalendarRepositoryImpl.kt | 22 ++ .../data/calendar/IcsExportMapper.kt | 39 +-- .../calendula/data/ics/IcsImporter.kt | 21 ++ .../calendula/domain/ics/IcsDuration.kt | 40 +++ .../calendula/domain/ics/IcsParser.kt | 259 ++++++++++++++++++ .../calendula/domain/ics/IcsText.kt | 93 +++++++ .../calendula/domain/ics/ParsedIcsForm.kt | 38 +++ .../calendula/ui/detail/EventDetailScreen.kt | 19 +- .../calendar/CalendarRepositoryImplTest.kt | 31 +++ .../data/calendar/FakeCalendarDataSource.kt | 14 + .../data/calendar/IcsExportMapperTest.kt | 10 - .../calendula/domain/ics/IcsDurationTest.kt | 28 ++ .../calendula/domain/ics/IcsParserTest.kt | 168 ++++++++++++ .../calendula/domain/ics/ParsedIcsFormTest.kt | 54 ++++ .../plans/2026-06-18-06-ics-import.md | 122 +++++++++ 17 files changed, 1000 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsImporter.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsDuration.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsDurationTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsParserTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsFormTest.kt create mode 100644 docs/superpowers/plans/2026-06-18-06-ics-import.md diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index a7a52f1..7aa838e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -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 + /** + * 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 + + /** + * 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 = 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), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt index 7b02429..4dcaac1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt @@ -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 + /** + * 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): IcsImportSummary + /** Create a new event from a validated form; returns the new `Events._ID`. */ suspend fun createEvent(form: EventForm): Long diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt index 7409d77..749c429 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt @@ -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, + ): 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()) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt index 92ccf53..817cff3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt @@ -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 PS / PD / PW — 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 `PD` / `PS`, 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsImporter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsImporter.kt new file mode 100644 index 0000000..5437e80 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsImporter.kt @@ -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() +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsDuration.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsDuration.kt new file mode 100644 index 0000000..2321021 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsDuration.kt @@ -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 PS / PD / PW — 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 `PD` / `PS`, 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt new file mode 100644 index 0000000..dc7e7e3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt @@ -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 = 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, + val warnings: Set, +) + +/** 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() + val warnings = mutableSetOf() + 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, + fileCalendarName: String?, + warnings: MutableSet, + ): 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() + 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): 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): 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:` at/after [from], or list end. */ + fun indexOfEnd(lines: List, 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() + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsText.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsText.kt index 3bc098c..6a06216 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsText.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsText.kt @@ -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 { + val out = mutableListOf() + 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, 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 { + val parts = mutableListOf() + 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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt new file mode 100644 index 0000000..3e8ff04 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt @@ -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() }, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index b9500f6..e19ca18 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -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)}" } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt index 9f4b94f..80c8d28 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt @@ -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", + ) } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt index f2dcd16..f6a447b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt @@ -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 = { emptyList() } var exportableEventsResult: List = emptyList() + /** UIDs the target calendar already holds, for import dedup. */ + var existingUidsResult: Set = 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 = exportableEventsResult + override fun existingUids(calendarId: Long): Set = existingUidsResult + + /** (event, targetCalendarId) pairs passed to [insertImportedEvent]. */ + val importedEvents = mutableListOf>() + + 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) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt index 7102d11..552e1d9 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt @@ -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( diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsDurationTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsDurationTest.kt new file mode 100644 index 0000000..9442c3e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsDurationTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsParserTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsParserTest.kt new file mode 100644 index 0000000..abdc710 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsParserTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsFormTest.kt new file mode 100644 index 0000000..0041ead --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsFormTest.kt @@ -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 + } +} diff --git a/docs/superpowers/plans/2026-06-18-06-ics-import.md b/docs/superpowers/plans/2026-06-18-06-ics-import.md new file mode 100644 index 0000000..362413d --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-06-ics-import.md @@ -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` + `warnings: List`). +`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=` → 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 `PS`. +- `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