fix(detail): open events whose series starts before 1970 (#34)
DTSTART is stored as UTC epoch millis, so a recurring series anchored before 1970-01-01 (common for yearly birthdays/anniversaries synced over CalDAV) has a legitimately negative DTSTART. The detail and search mappers dropped any row with dtstart < 0, and since the detail query reads the series-master DTSTART (the ancient anchor), every occurrence of such a series became un-openable — surfacing as the generic "Something went wrong." error screen — and the events vanished from search too. Relax the guard to reject only an *absent* DTSTART (isNull), which is the malformed case it was meant to catch; negative epoch millis flow through correctly (Instant/formatting and the all-day reminder decode are all Long-based). Add regression tests for both mappers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -23,12 +23,16 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
attendees: List<Attendee>,
|
attendees: List<Attendee>,
|
||||||
reminders: List<Reminder>,
|
reminders: List<Reminder>,
|
||||||
): EventDetail? {
|
): EventDetail? {
|
||||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
|
||||||
|
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
|
||||||
if (begin < 0L) {
|
// negative — only an *absent* DTSTART marks a malformed row worth dropping.
|
||||||
Log.w(TAG, "Dropping event with negative dtstart=$begin")
|
// Dropping negatives made every occurrence of such a series un-openable
|
||||||
|
// (the detail loads the ancient series-master DTSTART), see issue #34.
|
||||||
|
if (isNull(EventDetailProjection.IDX_DTSTART)) {
|
||||||
|
Log.w(TAG, "Dropping event with missing dtstart")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||||
|
|
||||||
// Recurring events store DURATION instead of DTEND, so the series row's
|
// Recurring events store DURATION instead of DTEND, so the series row's
|
||||||
// DTEND is null. Keep the event (end == begin); callers that opened a
|
// DTEND is null. Keep the event (end == begin); callers that opened a
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
|||||||
* of DTEND — reconstruct the end the same way the `.ics` export does.
|
* of DTEND — reconstruct the end the same way the `.ics` export does.
|
||||||
*/
|
*/
|
||||||
internal fun ColumnReader.toSearchResult(): EventInstance? {
|
internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||||
|
// A pre-1970 series anchor is a legitimately negative epoch-millis DTSTART
|
||||||
|
// (see EventDetailMapper / issue #34); drop only a genuinely absent one, so
|
||||||
|
// long-running birthdays/anniversaries still surface in search.
|
||||||
|
if (isNull(SearchProjection.IDX_DTSTART)) return null
|
||||||
val dtStart = getLong(SearchProjection.IDX_DTSTART)
|
val dtStart = getLong(SearchProjection.IDX_DTSTART)
|
||||||
if (dtStart < 0L) return null
|
|
||||||
val end = when {
|
val end = when {
|
||||||
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
|
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
|
||||||
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ class EventDetailMapperTest {
|
|||||||
eventColor: Any? = null,
|
eventColor: Any? = null,
|
||||||
eventColorKey: String? = null,
|
eventColorKey: String? = null,
|
||||||
calendarColor: Int = 0xFFAABBCC.toInt(),
|
calendarColor: Int = 0xFFAABBCC.toInt(),
|
||||||
dtstart: Long = 1_000_000_000L,
|
dtstart: Long? = 1_000_000_000L,
|
||||||
dtend: Long = 1_000_003_600L,
|
dtend: Long? = 1_000_003_600L,
|
||||||
allDay: Int = 0,
|
allDay: Int = 0,
|
||||||
location: String? = "Berlin",
|
location: String? = "Berlin",
|
||||||
calendarId: Long = 7L,
|
calendarId: Long = 7L,
|
||||||
@@ -125,6 +125,23 @@ class EventDetailMapperTest {
|
|||||||
assertThat(detail).isNull()
|
assertThat(detail).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() {
|
||||||
|
// A yearly birthday/anniversary anchored before the epoch has a
|
||||||
|
// legitimately negative UTC epoch-millis DTSTART; recurring rows carry
|
||||||
|
// no DTEND (they use DURATION), so it stays end == begin.
|
||||||
|
val begin = -157_766_400_000L // 1965-01-01T00:00:00Z
|
||||||
|
val detail = detailReader(dtstart = begin, dtend = null).toDetail()
|
||||||
|
assertThat(detail).isNotNull()
|
||||||
|
assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(begin)
|
||||||
|
assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `absent dtstart drops detail`() {
|
||||||
|
assertThat(detailReader(dtstart = null).toDetail()).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `rrule passes through when present`() {
|
fun `rrule passes through when present`() {
|
||||||
val detail = detailReader(rrule = "FREQ=WEEKLY;BYDAY=MO").toDetail()
|
val detail = detailReader(rrule = "FREQ=WEEKLY;BYDAY=MO").toDetail()
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class SearchMapperTest {
|
||||||
|
|
||||||
|
private fun searchReader(
|
||||||
|
id: Long = 1L,
|
||||||
|
calendarId: Long = 7L,
|
||||||
|
title: String? = "Birthday",
|
||||||
|
dtstart: Long? = 1_000_000_000L,
|
||||||
|
dtend: Long? = null,
|
||||||
|
duration: String? = "P1D",
|
||||||
|
allDay: Int = 1,
|
||||||
|
eventColor: Any? = null,
|
||||||
|
calendarColor: Int = 0xFFAABBCC.toInt(),
|
||||||
|
location: String? = null,
|
||||||
|
): MapColumnReader = MapColumnReader(
|
||||||
|
SearchProjection.IDX_ID to id,
|
||||||
|
SearchProjection.IDX_CALENDAR_ID to calendarId,
|
||||||
|
SearchProjection.IDX_TITLE to title,
|
||||||
|
SearchProjection.IDX_DTSTART to dtstart,
|
||||||
|
SearchProjection.IDX_DTEND to dtend,
|
||||||
|
SearchProjection.IDX_DURATION to duration,
|
||||||
|
SearchProjection.IDX_ALL_DAY to allDay,
|
||||||
|
SearchProjection.IDX_EVENT_COLOR to eventColor,
|
||||||
|
SearchProjection.IDX_CALENDAR_COLOR to calendarColor,
|
||||||
|
SearchProjection.IDX_LOCATION to location,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pre-1970 negative dtstart still surfaces in search (issue #34)`() {
|
||||||
|
val begin = -157_766_400_000L // 1965-01-01T00:00:00Z
|
||||||
|
val result = searchReader(dtstart = begin, dtend = null, duration = "P1D").toSearchResult()
|
||||||
|
assertThat(result).isNotNull()
|
||||||
|
assertThat(result!!.start.toEpochMilliseconds()).isEqualTo(begin)
|
||||||
|
assertThat(result.title).isEqualTo("Birthday")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `absent dtstart drops the search hit`() {
|
||||||
|
assertThat(searchReader(dtstart = null).toSearchResult()).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user