diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt index 4a59358..d441fd8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt @@ -23,12 +23,16 @@ internal fun ColumnReader.toEventDetailCore( attendees: List, reminders: List, ): EventDetail? { - val begin = getLong(EventDetailProjection.IDX_DTSTART) - - if (begin < 0L) { - Log.w(TAG, "Dropping event with negative dtstart=$begin") + // DTSTART is epoch millis in UTC, so a series anchored before 1970 (common + // for yearly birthdays/anniversaries synced over CalDAV) is legitimately + // negative — only an *absent* DTSTART marks a malformed row worth dropping. + // 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 } + val begin = getLong(EventDetailProjection.IDX_DTSTART) // Recurring events store DURATION instead of DTEND, so the series row's // DTEND is null. Keep the event (end == begin); callers that opened a diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt index 4c09781..f83733d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt @@ -10,8 +10,11 @@ import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis * of DTEND — reconstruct the end the same way the `.ics` export does. */ 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) - if (dtStart < 0L) return null val end = when { !isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND) else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION)) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt index 6ef9528..9870cde 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt @@ -22,8 +22,8 @@ class EventDetailMapperTest { eventColor: Any? = null, eventColorKey: String? = null, calendarColor: Int = 0xFFAABBCC.toInt(), - dtstart: Long = 1_000_000_000L, - dtend: Long = 1_000_003_600L, + dtstart: Long? = 1_000_000_000L, + dtend: Long? = 1_000_003_600L, allDay: Int = 0, location: String? = "Berlin", calendarId: Long = 7L, @@ -125,6 +125,23 @@ class EventDetailMapperTest { 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 fun `rrule passes through when present`() { val detail = detailReader(rrule = "FREQ=WEEKLY;BYDAY=MO").toDetail() diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapperTest.kt new file mode 100644 index 0000000..9b1bb30 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapperTest.kt @@ -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() + } +}