fix(import): make .ics import survive events the provider rejects (#225)

importEvents inserted every event in one unguarded loop, so a single row
the provider refused — it throws on a malformed RRULE straight out of
insert — aborted the batch and left the user with "couldn't read this
file" and nothing imported. Each event is now isolated and rejects are
counted into IcsImportSummary.failed and shown. sanitizeRrule drops empty
and malformed rule parts before the write.

Alongside that, several things a foreign file carries that we dropped:
VTODO components (silently lost, now imported as events), EXDATE, the
CATEGORIES calendar name, and colours — X-FOSSIFY-EVENT-COLOR / COLOR /
the category colour plus the X-SMT-* legacy spellings, snapped in Oklab
to the nearest key a palette account publishes since those reject a raw
EVENT_COLOR.

Two correctness fixes on our side: an all-day event may no longer end at
or before it starts (the provider expands a zero-length series into no
instances, so it just disappears), and imported all-day reminders now go
through the same encoding as hand-created ones instead of firing at UTC
midnight. A VALARM trigger pointing after the start is read as a time of
day rather than clamped to zero.

Note for later: their all-day DTEND is RFC-correct — endTS anchors at
noon of the last day and the exporter's +12h rounds it to the following
midnight. Do not "fix" it by sniffing PRODID; the holiday files bundled
in Fossify are conformant and name Fossify in theirs. One is kept as a
fixture.

Refs #225
This commit is contained in:
2026-08-19 20:17:51 +02:00
parent d03985eada
commit 2ed18e9938
19 changed files with 1271 additions and 84 deletions

View File

@@ -801,6 +801,42 @@ class CalendarRepositoryImplTest {
assertThat(fake.importedEvents.map { it.second }).containsExactly(3L, 3L)
}
@Test
fun `importEvents carries on past an event the provider rejects`(
@TempDir tempDir: Path,
) = runTest {
// A foreign export can hold a row the provider throws on outright; that
// must cost the user one event, not the whole file (Codeberg #225).
val fake = FakeCalendarDataSource().apply { failingImportSummaries += "bad" }
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val events = listOf(
parsedEvent("a@x", summary = "good"),
parsedEvent("b@x", summary = "bad"),
parsedEvent("c@x", summary = "good"),
)
val summary = repo.importEvents(targetCalendarId = 3L, events = events)
assertThat(summary.imported).isEqualTo(2)
assertThat(summary.failed).isEqualTo(1)
assertThat(fake.importedEvents.map { it.first.uid }).containsExactly("a@x", "c@x")
}
@Test
fun `importEvents looks the target palette up once, not per event`(
@TempDir tempDir: Path,
) = runTest {
var lookups = 0
val fake = FakeCalendarDataSource().apply {
eventColorPaletteResult = { lookups++; emptyList() }
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
repo.importEvents(3L, List(5) { parsedEvent("e$it@x") })
assertThat(lookups).isEqualTo(1)
}
@Test
fun `exportEvents forwards the chosen calendar-id subset to the data source`(
@TempDir tempDir: Path,
@@ -825,9 +861,10 @@ class CalendarRepositoryImplTest {
assertThat(fake.lastExportableEventsCalendarIds).isNull()
}
private fun parsedEvent(uid: String?) = de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
private fun parsedEvent(uid: String?, summary: String = "E") =
de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
uid = uid,
summary = "E",
summary = summary,
start = Instant.fromEpochMilliseconds(1_000_000_000L),
end = Instant.fromEpochMilliseconds(1_000_003_600L),
isAllDay = false,

View File

@@ -91,8 +91,17 @@ internal class FakeCalendarDataSource : CalendarDataSource {
/** (event, targetCalendarId) pairs passed to [insertImportedEvent]. */
val importedEvents = mutableListOf<Pair<ParsedIcsEvent, Long>>()
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
/** Thrown instead of [writeError] for events whose summary is in this set. */
val failingImportSummaries = mutableSetOf<String>()
override fun insertImportedEvent(
event: ParsedIcsEvent,
calendarId: Long,
allDayReminderTimeMinutes: Int,
colorPalette: List<EventColorOption>,
): Long {
writeError?.let { throw it }
if (event.summary in failingImportSummaries) error("rejected: ${event.summary}")
importedEvents += event to calendarId
return nextInsertId
}

View File

@@ -0,0 +1,119 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class ImportedEventValuesTest {
private val dayStart = Instant.parse("2026-08-19T00:00:00Z")
private fun allDay(
rrule: String? = null,
exDates: List<String> = emptyList(),
color: Int? = null,
days: Int = 1,
) = ParsedIcsEvent(
uid = "u@x",
summary = "Anna Birthday",
start = dayStart,
end = Instant.fromEpochMilliseconds(dayStart.toEpochMilliseconds() + days * 86_400_000L),
isAllDay = true,
zoneId = "UTC",
recurrenceRule = rrule,
exDates = exDates,
color = color,
)
private fun values(event: ParsedIcsEvent, palette: List<EventColorOption> = emptyList()) =
buildImportedEventValues(event, calendarId = 7L, uid = "u@x", palette = palette)
@Test
fun `a one-off carries DTEND and no recurrence`() {
val v = values(allDay())
assertThat(v[CalendarContract.Events.DTEND])
.isEqualTo(dayStart.toEpochMilliseconds() + 86_400_000L)
assertThat(v).doesNotContainKey(CalendarContract.Events.DURATION)
assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
assertThat(v[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
}
@Test
fun `a recurring row carries DURATION instead of DTEND`() {
val v = values(allDay(rrule = "FREQ=YEARLY;INTERVAL=1"))
assertThat(v).doesNotContainKey(CalendarContract.Events.DTEND)
assertThat(v[CalendarContract.Events.RRULE]).isEqualTo("FREQ=YEARLY;INTERVAL=1")
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D")
}
@Test
fun `an all-day series is never zero days long`() {
// A degenerate span would expand into no instances at all — the series
// would simply not exist (Codeberg #225).
val v = values(allDay(rrule = "FREQ=YEARLY;INTERVAL=1", days = 0))
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D")
}
@Test
fun `EXDATEs ride along with the recurrence`() {
val v = values(allDay(rrule = "FREQ=DAILY", exDates = listOf("20260820", "20260822")))
assertThat(v[CalendarContract.Events.EXDATE]).isEqualTo("20260820,20260822")
}
@Test
fun `EXDATEs are not written without a recurrence to exclude from`() {
val v = values(allDay(exDates = listOf("20260820")))
assertThat(v).doesNotContainKey(CalendarContract.Events.EXDATE)
}
@Test
fun `an imported colour is written raw when the account publishes no palette`() {
val v = values(allDay(color = -2818048))
assertThat(v[CalendarContract.Events.EVENT_COLOR]).isEqualTo(-2818048)
assertThat(v[CalendarContract.Events.EVENT_COLOR_KEY]).isNull()
}
@Test
fun `an imported colour snaps to the nearest published palette key`() {
val palette = listOf(
EventColorOption(key = "1", argb = 0xFF0000FF.toInt()), // blue
EventColorOption(key = "2", argb = 0xFFFF4500.toInt()), // orangered
EventColorOption(key = "3", argb = 0xFF008000.toInt()), // green
)
// Tomato — visually an orangered, nothing like the blue or the green.
val v = values(allDay(color = 0xFFFF6347.toInt()), palette)
assertThat(v[CalendarContract.Events.EVENT_COLOR_KEY]).isEqualTo("2")
// A raw colour alongside a key is what a palette calendar rejects.
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
}
@Test
fun `an event with no colour touches neither colour column`() {
val v = values(allDay())
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY)
}
@Test
fun `a timed event keeps its own zone and a seconds duration`() {
val start = Instant.parse("2026-08-19T08:00:00Z")
val event = ParsedIcsEvent(
uid = "u@x",
summary = "Standup",
start = start,
end = Instant.parse("2026-08-19T08:15:00Z"),
isAllDay = false,
zoneId = "Europe/Berlin",
recurrenceRule = "FREQ=WEEKLY",
)
val v = values(event)
assertThat(v[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P900S")
assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(0)
}
}

View File

@@ -0,0 +1,40 @@
package de.jeanlucmakiola.calendula.domain.ics
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class IcsColorTest {
@Test
fun `a raw Android colour int round-trips`() {
assertThat(parseIcsColorValue("-2818048")).isEqualTo(-2818048)
}
@Test
fun `a CSS3 name resolves opaque`() {
assertThat(parseIcsColorValue("tomato")).isEqualTo(0xFFFF6347.toInt())
assertThat(parseIcsColorValue("REBECCAPURPLE")).isEqualTo(0xFF663399.toInt())
}
@Test
fun `hex forms are accepted with and without alpha`() {
assertThat(parseIcsColorValue("#ff6347")).isEqualTo(0xFFFF6347.toInt())
assertThat(parseIcsColorValue("#80ff6347")).isEqualTo(0x80FF6347.toInt())
}
@Test
fun `a colour with no alpha byte is made opaque`() {
// 0x00FF6347 as a decimal int — an RGB triple that lost its alpha.
assertThat(parseIcsColorValue("16737095")).isEqualTo(0xFFFF6347.toInt())
}
@Test
fun `unusable values resolve to null`() {
assertThat(parseIcsColorValue(null)).isNull()
assertThat(parseIcsColorValue("")).isNull()
// Fossify interpolates a nullable calendar straight into the property.
assertThat(parseIcsColorValue("null")).isNull()
assertThat(parseIcsColorValue("chartreusey")).isNull()
assertThat(parseIcsColorValue("#abc")).isNull()
}
}

View File

@@ -0,0 +1,400 @@
package de.jeanlucmakiola.calendula.domain.ics
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
/**
* Reading the Simple Calendar / Fossify export dialect (Codeberg #225).
*
* Every fixture is shaped as `IcsExporter.writeEvent` emits it — property order,
* the `X-FOSSIFY-*` extensions and the `P0DT1H5M0S` duration spelling included.
*
* Note what is *not* asserted here: their all-day `DTEND` is RFC-correct.
* `Event.endTS` anchors an all-day event at noon of its last day
* (`EventActivity.getStartEndTimes`), or at the exclusive midnight for a
* CalDAV-sourced one, and the exporter's `+ TWELVE_HOURS` rounds either to the
* following midnight. Treating it as inclusive would push every imported all-day
* event out by a day.
*/
class IcsFossifyImportTest {
private val parser = IcsParser(TimeZone.of("Europe/Berlin"))
private fun fossify(vararg body: String) = (
listOf(
"BEGIN:VCALENDAR",
"PRODID:-//Fossify//NONSGML Event Calendar//EN",
"VERSION:2.0",
) + body + listOf("END:VCALENDAR")
).joinToString("\r\n")
private fun days(event: ParsedIcsEvent): Long =
(event.end - event.start).inWholeMilliseconds / 86_400_000L
@Test
fun `an all-day event keeps the exact span the file gives it`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"UID:abc123",
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
"DTSTART;VALUE=DATE:19900412",
"DTEND;VALUE=DATE:19900413",
"X-FOSSIFY-MISSING-YEAR:0",
"RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4",
"END:VEVENT",
),
)
val event = result.events.single()
assertThat(event.isAllDay).isTrue()
assertThat(days(event)).isEqualTo(1)
}
@Test
fun `a real Fossify holiday file imports unchanged`() {
// Shipped inside Fossify Calendar (assets/holidays/AT/public.ics). Its
// PRODID names Fossify, so any producer-sniffing shortcut would corrupt
// it — the file is plain, conformant iCalendar.
val text = checkNotNull(
javaClass.classLoader?.getResourceAsStream("ics/fossify-holidays-at.ics"),
).use { it.readBytes().toString(Charsets.UTF_8) }
val result = parser.parse(text)
assertThat(result.events.map { it.summary })
.containsExactly("Neujahr", "Heilige Drei Könige").inOrder()
assertThat(result.events.map { days(it) }).containsExactly(1L, 1L)
assertThat(result.events.map { it.recurrenceRule }).containsExactly("FREQ=YEARLY", "FREQ=YEARLY")
}
@Test
fun `an all-day event with no DTEND lasts one day`() {
// RFC 5545 3.6.1, and the span the provider needs to expand a series.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"DTSTART;VALUE=DATE:20260819",
"END:VEVENT",
),
)
assertThat(days(result.events.single())).isEqualTo(1)
}
@Test
fun `a zero-length all-day event is widened rather than left to vanish`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260819",
"END:VEVENT",
),
)
assertThat(days(result.events.single())).isEqualTo(1)
}
@Test
fun `a malformed repeat rule is repaired rather than passed to the provider`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART:20260819T080000Z",
"DTEND:20260819T081500Z",
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=",
"END:VEVENT",
),
)
assertThat(result.events.single().recurrenceRule).isEqualTo("FREQ=WEEKLY;INTERVAL=1")
assertThat(result.warnings).contains(IcsParseWarning.RecurrenceRuleRepaired)
}
@Test
fun `a task is imported as an event and reported`() {
val result = parser.parse(
fossify(
"BEGIN:VTODO",
"SUMMARY:Pay rent",
"DTSTART;VALUE=DATE:20260901",
"END:VTODO",
),
)
val task = result.events.single()
assertThat(task.isTask).isTrue()
assertThat(task.summary).isEqualTo("Pay rent")
assertThat(days(task)).isEqualTo(1)
assertThat(result.warnings).contains(IcsParseWarning.TasksImportedAsEvents)
}
@Test
fun `a task dated only by DUE still imports`() {
val result = parser.parse(
fossify(
"BEGIN:VTODO",
"SUMMARY:File taxes",
"DUE;VALUE=DATE:20260901",
"END:VTODO",
),
)
assertThat(result.events.single().summary).isEqualTo("File taxes")
}
@Test
fun `the per-event colour wins over the calendar colour`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
"COLOR:tomato",
"X-FOSSIFY-EVENT-COLOR:-2818048",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
),
)
assertThat(result.events.single().color).isEqualTo(-2818048)
}
@Test
fun `the calendar colour stands in when the event has none of its own`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
),
)
assertThat(result.events.single().color).isEqualTo(-1155931)
}
@Test
fun `the legacy Simple Calendar colour spellings are read too`() {
val result = parser.parse(
listOf(
"BEGIN:VCALENDAR",
"PRODID:-//Simple Mobile Tools//NONSGML Event Calendar//EN",
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"X-SMT-CATEGORY-COLOR:-1155931",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
"END:VCALENDAR",
).joinToString("\r\n"),
)
assertThat(result.events.single().color).isEqualTo(-1155931)
}
@Test
fun `the literal null Fossify writes for a missing calendar is not a colour`() {
// Both properties interpolate a nullable calendar straight into the value.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"X-FOSSIFY-CATEGORY-COLOR:null",
"CATEGORIES:null",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
),
)
val event = result.events.single()
assertThat(event.color).isNull()
assertThat(event.calendarName).isNull()
}
@Test
fun `CATEGORIES names the source calendar`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"CATEGORIES:Birthdays",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"END:VEVENT",
),
)
assertThat(result.events.single().calendarName).isEqualTo("Birthdays")
}
@Test
fun `a reminder written as a positive trigger is not read as a lead time`() {
// The exporter flips the sign for reminders stored below -1
// (`sign = if (reminder.minutes < -1) "" else "-"`), so a trigger can
// point after the start. On an all-day event that means a time of day.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"BEGIN:VALARM",
"ACTION:DISPLAY",
"TRIGGER:P0DT9H0M0S",
"END:VALARM",
"END:VEVENT",
),
)
val event = result.events.single()
assertThat(event.reminderMinutes).containsExactly(-540)
assertThat(event.semanticReminderMinutes()).containsExactly(0)
}
@Test
fun `an all-day reminder rounds to whole days before`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"BEGIN:VALARM",
"ACTION:DISPLAY",
// 15h before midnight == "1 day before at 09:00".
"TRIGGER:-P0DT15H0M0S",
"END:VALARM",
"END:VEVENT",
),
)
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(1440)
}
@Test
fun `a timed reminder keeps its exact lead time`() {
// Their P0DT1H5M0S duration spelling, from Parser.getDurationCode.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART:20260819T080000Z",
"DTEND:20260819T081500Z",
"BEGIN:VALARM",
"ACTION:DISPLAY",
"TRIGGER:-P0DT0H10M0S",
"END:VALARM",
"END:VEVENT",
),
)
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(10)
}
@Test
fun `an all-day series' EXDATE day codes carry over`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"RRULE:FREQ=DAILY;INTERVAL=1",
"EXDATE:20260820",
"EXDATE:20260822",
"END:VEVENT",
),
)
assertThat(result.events.single().exDates).containsExactly("20260820", "20260822").inOrder()
}
@Test
fun `a timed series' bare EXDATE day code is resolved against the series time`() {
// They store excluded occurrences as day codes and write them out that
// way even for a timed series, where the property alone is ambiguous.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Gym",
"DTSTART:20260819T170000Z",
"DTEND:20260819T180000Z",
"RRULE:FREQ=DAILY;INTERVAL=1",
"EXDATE:20260820",
"END:VEVENT",
),
)
assertThat(result.events.single().exDates).containsExactly("20260820T170000Z")
}
@Test
fun `EXDATE is dropped along with an unusable recurrence rule`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Gym",
"DTSTART:20260819T170000Z",
"DTEND:20260819T180000Z",
"RRULE:INTERVAL=1",
"EXDATE:20260820",
"END:VEVENT",
),
)
val event = result.events.single()
assertThat(event.recurrenceRule).isNull()
assertThat(event.exDates).isEmpty()
}
@Test
fun `a whole export imports every component`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"UID:abc123",
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
"CATEGORIES:Birthdays",
"LAST-MODIFIED:20260101T120000Z",
"TRANSP:TRANSPARENT",
"DTSTART;VALUE=DATE:19900412",
"DTEND;VALUE=DATE:19900413",
"X-FOSSIFY-MISSING-YEAR:0",
"DTSTAMP:20260819T100000Z",
"CLASS:PUBLIC",
"STATUS:CONFIRMED",
"RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4",
"END:VEVENT",
"BEGIN:VEVENT",
"SUMMARY:Standup",
"UID:def456",
"DTSTART:20260819T080000Z",
"DTEND:20260819T081500Z",
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU",
"END:VEVENT",
"BEGIN:VTODO",
"SUMMARY:Pay rent",
"UID:ghi789",
"DTSTART;VALUE=DATE:20260901",
"END:VTODO",
),
)
assertThat(result.events.map { it.summary })
.containsExactly("Anna Birthday", "Standup", "Pay rent").inOrder()
// Nothing may reach the provider as a zero-length all-day row.
assertThat(result.events.none { it.isAllDay && days(it) == 0L }).isTrue()
}
}

View File

@@ -0,0 +1,52 @@
package de.jeanlucmakiola.calendula.domain.ics
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class IcsRecurrenceTest {
@Test
fun `a well-formed rule is unchanged`() {
assertThat(sanitizeRrule("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE"))
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE")
}
@Test
fun `the RRULE prefix is stripped`() {
assertThat(sanitizeRrule("RRULE:FREQ=DAILY")).isEqualTo("FREQ=DAILY")
}
@Test
fun `an empty list part is dropped`() {
assertThat(sanitizeRrule("FREQ=WEEKLY;INTERVAL=1;BYDAY="))
.isEqualTo("FREQ=WEEKLY;INTERVAL=1")
assertThat(sanitizeRrule("FREQ=WEEKLY;BYDAY=MO,,WE;BYMONTH="))
.isEqualTo("FREQ=WEEKLY;BYDAY=MO,WE")
}
@Test
fun `a nonsensical INTERVAL is dropped, not fatal`() {
assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=0")).isEqualTo("FREQ=DAILY")
assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=every")).isEqualTo("FREQ=DAILY")
}
@Test
fun `a rule without a usable FREQ is unsalvageable`() {
assertThat(sanitizeRrule("INTERVAL=1;BYDAY=MO")).isNull()
assertThat(sanitizeRrule("FREQ=FORTNIGHTLY;INTERVAL=1")).isNull()
assertThat(sanitizeRrule("FREQ=;INTERVAL=1")).isNull()
assertThat(sanitizeRrule("")).isNull()
assertThat(sanitizeRrule(null)).isNull()
}
@Test
fun `FREQ is normalised to upper case`() {
assertThat(sanitizeRrule("freq=daily;count=3")).isEqualTo("FREQ=DAILY;COUNT=3")
}
@Test
fun `unknown parts pass through untouched`() {
assertThat(sanitizeRrule("FREQ=YEARLY;WKST=SU;X-THING=7"))
.isEqualTo("FREQ=YEARLY;WKST=SU;X-THING=7")
}
}

View File

@@ -0,0 +1,25 @@
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:Fossify Calendar Holiday Generator
METHOD:PUBLISH
X-PUBLISHED-TTL:PT1H
BEGIN:VEVENT
UID:fossify_c10f9ad245dc2e57fb3652abee6ed8a06744f0bd
SUMMARY:Neujahr
DTSTAMP:20260526T132315Z
DTSTART;VALUE=DATE:19800101
DTEND;VALUE=DATE:19800102
STATUS:CONFIRMED
RRULE:FREQ=YEARLY
END:VEVENT
BEGIN:VEVENT
UID:fossify_8a60ccf65b65265f768dd33a0b7aefc7620e3ee2
SUMMARY:Heilige Drei Könige
DTSTAMP:20260526T132315Z
DTSTART;VALUE=DATE:19800106
DTEND;VALUE=DATE:19800107
STATUS:CONFIRMED
RRULE:FREQ=YEARLY
END:VEVENT
END:VCALENDAR