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 index 9c5693a..0c95821 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt @@ -166,14 +166,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault } when { component != null -> { + // A component whose END line never arrives ends at the next + // one's BEGIN, not at that one's END: reading to the far END + // would fold two events into one body, where the later + // properties overwrite the earlier and one event is lost. val end = indexOfEnd(lines, i + 1, component) + val body = indexOfNextComponent(lines, i + 1, end) parseComponent( - body = lines.subList(i + 1, end), + body = lines.subList(i + 1, body), fileCalendarName = calendarName, warnings = warnings, isTask = component == "VTODO", )?.let(events::add) - i = end + 1 + i = if (body < end) body else end + 1 } line.isBegin("VTIMEZONE") -> { // Skipped wholesale; TZIDs resolve against the OS tz database. @@ -445,6 +450,23 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault } /** Index of the matching `END:` at/after [from], or list end. */ + /** + * Where an unterminated component's body has to stop: the next top-level + * `VEVENT` / `VTODO` in `[from, end)`, or [end] when there is none. A + * nested `VALARM` is part of the body and is not a boundary. + */ + fun indexOfNextComponent(lines: List, from: Int, end: Int): Int { + var i = from + while (i < end) { + val line = parseContentLine(lines[i]) + if (line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))) { + return i + } + i++ + } + return end + } + fun indexOfEnd(lines: List, from: Int, component: String): Int { var i = from while (i < lines.size) { diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsUnterminatedComponentTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsUnterminatedComponentTest.kt new file mode 100644 index 0000000..c46821b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsUnterminatedComponentTest.kt @@ -0,0 +1,66 @@ +package de.jeanlucmakiola.calendula.domain.ics + +import kotlinx.datetime.TimeZone +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * A component whose `END:` line never arrives (a download cut short, a producer + * that dropped a line). `indexOfEnd` falls back to the end of the file, so the + * block is read as if it had been closed. + */ +class IcsUnterminatedComponentTest { + + private val parser = IcsParser(TimeZone.of("Europe/Berlin")) + + @Test + fun `file truncated mid-property keeps the event it had started`() { + val result = parser.parse( + """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + UID:truncated-1@fixture + SUMMARY:Cut off mid-file + DTSTART:20260924T100000Z + DTEND:20260924T1 + """.trimIndent(), + ) + assertEquals(1, result.events.size) + val event = result.events.single() + assertEquals("Cut off mid-file", event.summary) + // The truncated DTEND does not parse, so the event is zero length. + assertEquals(event.start, event.end) + assertEquals(emptySet(), result.warnings) + } + + @Test + fun `a missing END between two events does not merge them`() { + val result = parser.parse( + """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + UID:first@fixture + SUMMARY:First event + DTSTART:20260924T100000Z + DTEND:20260924T110000Z + BEGIN:VEVENT + UID:second@fixture + SUMMARY:Second event + DTSTART:20260925T100000Z + DTEND:20260925T110000Z + END:VEVENT + END:VCALENDAR + """.trimIndent(), + ) + assertEquals( + listOf("First event", "Second event"), + result.events.map { it.summary }, + ) + assertEquals( + listOf("first@fixture", "second@fixture"), + result.events.map { it.uid }, + ) + } +}