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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user