release: v2.13.1 — recurring-event open fix, 24h time picker, INSERT intent #60

Merged
makiolaj merged 6 commits from release/v2.13.1 into main 2026-07-06 15:36:43 +00:00
4 changed files with 76 additions and 7 deletions
Showing only changes of commit 974d65f619 - Show all commits

View File

@@ -23,12 +23,16 @@ internal fun ColumnReader.toEventDetailCore(
attendees: List<Attendee>,
reminders: List<Reminder>,
): 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

View File

@@ -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))

View File

@@ -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()

View File

@@ -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()
}
}