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:
2026-06-18 14:59:32 +02:00
parent 90b219bdad
commit e1c2e9f2e5
17 changed files with 1000 additions and 55 deletions

View File

@@ -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",
)
}

View File

@@ -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)

View File

@@ -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(

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}