Stop a missing END:VEVENT from swallowing an event (#304)
An unterminated component read on to the *next* event's END, so both events' properties landed in one body and the later ones overwrote the earlier — one event silently became the other. It now stops at the next BEGIN:VEVENT or BEGIN:VTODO; a nested VALARM is still body, not a boundary. Tests cover both shapes: two events where the first has no END, and a file truncated mid-property, which still salvages the event it had started.
This commit is contained in:
@@ -166,14 +166,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
component != null -> {
|
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 end = indexOfEnd(lines, i + 1, component)
|
||||||
|
val body = indexOfNextComponent(lines, i + 1, end)
|
||||||
parseComponent(
|
parseComponent(
|
||||||
body = lines.subList(i + 1, end),
|
body = lines.subList(i + 1, body),
|
||||||
fileCalendarName = calendarName,
|
fileCalendarName = calendarName,
|
||||||
warnings = warnings,
|
warnings = warnings,
|
||||||
isTask = component == "VTODO",
|
isTask = component == "VTODO",
|
||||||
)?.let(events::add)
|
)?.let(events::add)
|
||||||
i = end + 1
|
i = if (body < end) body else end + 1
|
||||||
}
|
}
|
||||||
line.isBegin("VTIMEZONE") -> {
|
line.isBegin("VTIMEZONE") -> {
|
||||||
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
// 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:<component>` at/after [from], or list end. */
|
/** Index of the matching `END:<component>` 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<String>, 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<String>, from: Int, component: String): Int {
|
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
||||||
var i = from
|
var i = from
|
||||||
while (i < lines.size) {
|
while (i < lines.size) {
|
||||||
|
|||||||
+66
@@ -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<IcsParseWarning>(), 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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user