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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
25
app/src/test/resources/ics/fossify-holidays-at.ics
Normal file
25
app/src/test/resources/ics/fossify-holidays-at.ics
Normal 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
|
||||
Reference in New Issue
Block a user