From 3fb29130f1ae23e3a8a95f57cc1f4276fb51f7af Mon Sep 17 00:00:00 2001 From: Arnau Mora Gras Date: Thu, 21 May 2026 10:42:44 +0200 Subject: [PATCH 1/8] Add Handlers for Recurrence Fields --- .../synctools/mapping/jtx/JtxObjectHandler.kt | 4 +- .../jtx/handler/RecurrenceFieldsHandler.kt | 117 +++++++ .../handler/RecurrenceFieldsHandlerTest.kt | 316 ++++++++++++++++++ 3 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt create mode 100644 lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/JtxObjectHandler.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/JtxObjectHandler.kt index 186110a9..863babdc 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/JtxObjectHandler.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/JtxObjectHandler.kt @@ -10,6 +10,7 @@ import android.content.Entity import at.bitfire.synctools.icalendar.AssociatedComponents import at.bitfire.synctools.mapping.jtx.handler.DescriptionHandler import at.bitfire.synctools.mapping.jtx.handler.JtxFieldHandler +import at.bitfire.synctools.mapping.jtx.handler.RecurrenceFieldsHandler import at.bitfire.synctools.storage.jtx.JtxObjectAndExceptions import at.techbee.jtx.JtxContract import net.fortuna.ical4j.model.Property @@ -29,7 +30,8 @@ class JtxObjectHandler( private val prodId: ProdId ) { private val fieldHandlers: Array = arrayOf( - DescriptionHandler() + DescriptionHandler(), + RecurrenceFieldsHandler() ) /** diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt new file mode 100644 index 00000000..84cc7ab3 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt @@ -0,0 +1,117 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.jtx.handler + +import android.content.Entity +import at.bitfire.synctools.icalendar.plusAssign +import at.techbee.jtx.JtxContract +import at.techbee.jtx.JtxContract.JtxICalObject.TZ_ALLDAY +import net.fortuna.ical4j.model.DateList +import net.fortuna.ical4j.model.ParameterList +import net.fortuna.ical4j.model.component.CalendarComponent +import net.fortuna.ical4j.model.parameter.TzId +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RecurrenceId +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.temporal.Temporal +import java.util.logging.Level +import java.util.logging.Logger + +/** + * Handler for recurrence-related fields of a jtx Board content provider data row. + */ +class RecurrenceFieldsHandler : JtxFieldHandler { + + private val logger + get() = Logger.getLogger(javaClass.name) + + override fun process(from: Entity, main: Entity, to: CalendarComponent) { + if (from === main) { + processMain(from, to) + } else { + processException(from, to) + } + } + + private fun processMain(from: Entity, to: CalendarComponent) { + val values = from.entityValues + val dtstartTimezone = values.getAsString(JtxContract.JtxICalObject.DTSTART_TIMEZONE) + + values.getAsString(JtxContract.JtxICalObject.RRULE)?.let { rruleString -> + try { + to += RRule(rruleString) + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't parse RRULE, ignoring", e) + } + } + + values.getAsString(JtxContract.JtxICalObject.RDATE)?.let { rdateString -> + try { + val timestamps = JtxContract.getLongListFromString(rdateString) + if (timestamps.isNotEmpty()) { + to += RDate(timestampsToDateList(timestamps, dtstartTimezone)) + } + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't parse RDATE, ignoring", e) + } + } + + values.getAsString(JtxContract.JtxICalObject.EXDATE)?.let { exdateString -> + try { + val timestamps = JtxContract.getLongListFromString(exdateString) + if (timestamps.isNotEmpty()) { + to += ExDate(timestampsToDateList(timestamps, dtstartTimezone)) + } + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't parse EXDATE, ignoring", e) + } + } + } + + private fun processException(from: Entity, to: CalendarComponent) { + val values = from.entityValues + val recurid = values.getAsString(JtxContract.JtxICalObject.RECURID) ?: return + val recuridTimezone = values.getAsString(JtxContract.JtxICalObject.RECURID_TIMEZONE) + + try { + to += if (recuridTimezone == TZ_ALLDAY || recuridTimezone.isNullOrEmpty()) { + RecurrenceId(recurid) + } else { + RecurrenceId(ParameterList(listOf(TzId(recuridTimezone))), recurid) + } + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't parse RECURID, ignoring", e) + } + } + + private fun timestampsToDateList(timestamps: List, dtstartTimezone: String?): DateList<*> = + when { + dtstartTimezone == TZ_ALLDAY -> + DateList(timestamps.map { + LocalDate.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) + }) + dtstartTimezone == ZoneOffset.UTC.id -> + DateList(timestamps.map { + ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) + }) + dtstartTimezone.isNullOrEmpty() -> + DateList(timestamps.map { + LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault()) + }) + else -> + DateList(timestamps.map { + ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.of(dtstartTimezone)) + }) + } +} diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt new file mode 100644 index 00000000..a62bb0ac --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt @@ -0,0 +1,316 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.jtx.handler + +import android.content.ContentValues +import android.content.Entity +import androidx.core.content.contentValuesOf +import at.techbee.jtx.JtxContract +import at.techbee.jtx.JtxContract.JtxICalObject.TZ_ALLDAY +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.component.VToDo +import net.fortuna.ical4j.model.parameter.TzId +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RecurrenceId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.time.LocalDate +import java.time.ZoneOffset +import kotlin.jvm.optionals.getOrNull + +@RunWith(RobolectricTestRunner::class) +class RecurrenceFieldsHandlerTest { + + private val handler = RecurrenceFieldsHandler() + + // ===== Main object tests ===== + + @Test + fun `No recurrence fields`() { + val from = Entity(ContentValues()) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + assertNull(output.getProperty>(Property.RRULE).getOrNull()) + assertNull(output.getProperty>(Property.RDATE).getOrNull()) + assertNull(output.getProperty>(Property.EXDATE).getOrNull()) + } + + @Test + fun `RRULE is mapped to main`() { + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + assertEquals( + "FREQ=DAILY;COUNT=5", + output.getProperty>(Property.RRULE).getOrNull()?.value + ) + } + + @Test + fun `RDATE with UTC timestamps`() { + // 1779451200000 ms = 2026-05-22T12:00:00Z + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.DTSTART_TIMEZONE to ZoneOffset.UTC.id, + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5", + JtxContract.JtxICalObject.RDATE to "1779451200000" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + val rdate = output.getProperty>(Property.RDATE).getOrNull() + assertNotNull(rdate) + assertEquals(1, rdate?.dates?.size) + assertEquals("20260522T120000Z", rdate?.value) + } + + @Test + fun `RDATE with all-day timestamps`() { + // 1779408000000 ms = 2026-05-22T00:00:00Z → all-day LocalDate 2026-05-22 + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.DTSTART_TIMEZONE to TZ_ALLDAY, + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5", + JtxContract.JtxICalObject.RDATE to "1779408000000" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + val rdate = output.getProperty>(Property.RDATE).getOrNull() + assertNotNull(rdate) + assertEquals(1, rdate?.dates?.size) + assertEquals(LocalDate.of(2026, 5, 22), rdate?.dates?.first()) + } + + @Test + fun `RDATE with named timezone`() { + // 1779444000000 ms = 2026-05-22T10:00:00Z = 2026-05-22T12:00:00+02:00[Europe/Vienna] + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.DTSTART_TIMEZONE to "Europe/Vienna", + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5", + JtxContract.JtxICalObject.RDATE to "1779444000000" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + val rdate = output.getProperty>(Property.RDATE).getOrNull() + assertNotNull(rdate) + assertEquals(1, rdate?.dates?.size) + assertEquals("20260522T120000", rdate?.value) + } + + @Test + fun `RDATE with floating timestamps`() { + // null DTSTART_TIMEZONE → floating LocalDateTime + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5", + JtxContract.JtxICalObject.RDATE to "1779451200000" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + val rdate = output.getProperty>(Property.RDATE).getOrNull() + assertNotNull(rdate) + assertEquals(1, rdate?.dates?.size) + // Floating datetimes have no TZID parameter + assertNull(rdate?.getParameter(Parameter.TZID)?.getOrNull()) + } + + @Test + fun `Multiple RDATE timestamps produce single RDate property with all dates`() { + // 1779451200000 = 2026-05-22T12:00:00Z, 1779624000000 = 2026-05-24T12:00:00Z + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.DTSTART_TIMEZONE to ZoneOffset.UTC.id, + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5", + JtxContract.JtxICalObject.RDATE to "1779451200000,1779624000000" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + val rdate = output.getProperty>(Property.RDATE).getOrNull() + assertNotNull(rdate) + assertEquals(2, rdate?.dates?.size) + } + + @Test + fun `RDATE with empty timestamps is ignored`() { + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.DTSTART_TIMEZONE to ZoneOffset.UTC.id, + JtxContract.JtxICalObject.RDATE to "" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + assertNull(output.getProperty>(Property.RDATE).getOrNull()) + } + + @Test + fun `EXDATE with UTC timestamps`() { + // 1779451200000 ms = 2026-05-22T12:00:00Z + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.DTSTART_TIMEZONE to ZoneOffset.UTC.id, + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5", + JtxContract.JtxICalObject.EXDATE to "1779451200000" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + val exdate = output.getProperty>(Property.EXDATE).getOrNull() + assertNotNull(exdate) + assertEquals(1, exdate?.dates?.size) + assertEquals("20260522T120000Z", exdate?.value) + } + + @Test + fun `EXDATE with all-day timestamps`() { + // 1779408000000 ms = 2026-05-22T00:00:00Z → all-day LocalDate 2026-05-22 + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.DTSTART_TIMEZONE to TZ_ALLDAY, + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5", + JtxContract.JtxICalObject.EXDATE to "1779408000000" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + val exdate = output.getProperty>(Property.EXDATE).getOrNull() + assertNotNull(exdate) + assertEquals(1, exdate?.dates?.size) + assertEquals(LocalDate.of(2026, 5, 22), exdate?.dates?.first()) + } + + @Test + fun `Invalid RRULE is ignored without crash`() { + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.RRULE to "NOT_A_VALID_RRULE" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + assertNull(output.getProperty>(Property.RRULE).getOrNull()) + } + + @Test + fun `Main ignores RECURID`() { + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.RECURID to "20260516T120000Z", + JtxContract.JtxICalObject.RECURID_TIMEZONE to ZoneOffset.UTC.id, + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + assertNull(output.getProperty>(Property.RECURRENCE_ID).getOrNull()) + assertNotNull(output.getProperty>(Property.RRULE).getOrNull()) + } + + // ===== Exception tests ===== + + @Test + fun `Exception without RECURID adds no RECURRENCE-ID`() { + val main = Entity(contentValuesOf( + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5" + )) + val exception = Entity(ContentValues()) + val output = VToDo() + + handler.process(from = exception, main = main, to = output) + + assertNull(output.getProperty>(Property.RECURRENCE_ID).getOrNull()) + } + + @Test + fun `Exception with all-day RECURID`() { + val main = Entity(contentValuesOf(JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5")) + val exception = Entity(contentValuesOf( + JtxContract.JtxICalObject.RECURID to "20260523", + JtxContract.JtxICalObject.RECURID_TIMEZONE to TZ_ALLDAY + )) + val output = VToDo() + + handler.process(from = exception, main = main, to = output) + + val recurId = output.getProperty>(Property.RECURRENCE_ID).getOrNull() + assertNotNull(recurId) + assertEquals("20260523", recurId?.value) + assertNull(recurId?.getParameter(Parameter.TZID)?.getOrNull()) + } + + @Test + fun `Exception with floating RECURID`() { + val main = Entity(contentValuesOf(JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5")) + // null RECURID_TIMEZONE → floating + val exception = Entity(contentValuesOf( + JtxContract.JtxICalObject.RECURID to "20260516T120000" + )) + val output = VToDo() + + handler.process(from = exception, main = main, to = output) + + val recurId = output.getProperty>(Property.RECURRENCE_ID).getOrNull() + assertNotNull(recurId) + assertEquals("20260516T120000", recurId?.value) + assertNull(recurId?.getParameter(Parameter.TZID)?.getOrNull()) + } + + @Test + fun `Exception with named timezone RECURID`() { + val main = Entity(contentValuesOf(JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5")) + val exception = Entity(contentValuesOf( + JtxContract.JtxICalObject.RECURID to "20260516T120000", + JtxContract.JtxICalObject.RECURID_TIMEZONE to "Europe/Vienna" + )) + val output = VToDo() + + handler.process(from = exception, main = main, to = output) + + val recurId = output.getProperty>(Property.RECURRENCE_ID).getOrNull() + assertNotNull(recurId) + assertEquals("20260516T120000", recurId?.value) + assertEquals("Europe/Vienna", recurId?.getParameter(Parameter.TZID)?.getOrNull()?.value) + } + + @Test + fun `Exception ignores RRULE, RDATE and EXDATE`() { + val main = Entity(contentValuesOf(JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5")) + val exception = Entity(contentValuesOf( + JtxContract.JtxICalObject.RECURID to "20260516T120000", + JtxContract.JtxICalObject.RECURID_TIMEZONE to "Europe/Vienna", + JtxContract.JtxICalObject.RRULE to "FREQ=WEEKLY;COUNT=3", + JtxContract.JtxICalObject.RDATE to "1779451200000", + JtxContract.JtxICalObject.EXDATE to "1779624000000" + )) + val output = VToDo() + + handler.process(from = exception, main = main, to = output) + + assertNull(output.getProperty>(Property.RRULE).getOrNull()) + assertNull(output.getProperty>(Property.RDATE).getOrNull()) + assertNull(output.getProperty>(Property.EXDATE).getOrNull()) + assertNotNull(output.getProperty>(Property.RECURRENCE_ID).getOrNull()) + } +} From e644566d8948106348e522fdc00f13c6b7984173 Mon Sep 17 00:00:00 2001 From: Arnau Mora Gras Date: Thu, 21 May 2026 10:49:14 +0200 Subject: [PATCH 2/8] Convert into Instant --- .../synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt index 84cc7ab3..a8e9ce56 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt @@ -103,7 +103,7 @@ class RecurrenceFieldsHandler : JtxFieldHandler { }) dtstartTimezone == ZoneOffset.UTC.id -> DateList(timestamps.map { - ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) + Instant.ofEpochMilli(it) }) dtstartTimezone.isNullOrEmpty() -> DateList(timestamps.map { From 4b7fc551486677aefde841e899bb8ebc6bfe03b0 Mon Sep 17 00:00:00 2001 From: Arnau Mora Gras Date: Thu, 21 May 2026 11:03:36 +0200 Subject: [PATCH 3/8] Improve fallback behavior --- .../jtx/handler/RecurrenceFieldsHandler.kt | 21 +++++++++++++++---- .../handler/RecurrenceFieldsHandlerTest.kt | 17 +++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt index a8e9ce56..e5893d99 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt @@ -109,9 +109,22 @@ class RecurrenceFieldsHandler : JtxFieldHandler { DateList(timestamps.map { LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault()) }) - else -> - DateList(timestamps.map { - ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.of(dtstartTimezone)) - }) + else -> { + val zone = try { + ZoneId.of(dtstartTimezone) + } catch (e: Exception) { + logger.log(Level.WARNING, "Invalid DTSTART_TIMEZONE '$dtstartTimezone', falling back to UTC", e) + null + } + if (zone != null) { + DateList(timestamps.map { + ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), zone) + }) + } else { + DateList(timestamps.map { + Instant.ofEpochMilli(it) + }) + } + } } } diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt index a62bb0ac..b622aa28 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt @@ -213,6 +213,23 @@ class RecurrenceFieldsHandlerTest { assertNull(output.getProperty>(Property.RRULE).getOrNull()) } + @Test + fun `RDATE with invalid DTSTART_TIMEZONE falls back to UTC`() { + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.DTSTART_TIMEZONE to "INVALID_TIMEZONE", + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5", + JtxContract.JtxICalObject.RDATE to "1779451200000" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + val rdate = output.getProperty>(Property.RDATE).getOrNull() + assertNotNull(rdate) + assertEquals(1, rdate?.dates?.size) + assertEquals("20260522T120000Z", rdate?.value) + } + @Test fun `Main ignores RECURID`() { val from = Entity(contentValuesOf( From ba38538f7f6daaed80fb4035e8ff2c23b57e39b4 Mon Sep 17 00:00:00 2001 From: Arnau Mora Gras Date: Thu, 21 May 2026 11:04:15 +0200 Subject: [PATCH 4/8] Ignore empty recurrences --- .../mapping/jtx/handler/RecurrenceFieldsHandler.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt index e5893d99..e2350ee5 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt @@ -48,7 +48,7 @@ class RecurrenceFieldsHandler : JtxFieldHandler { val values = from.entityValues val dtstartTimezone = values.getAsString(JtxContract.JtxICalObject.DTSTART_TIMEZONE) - values.getAsString(JtxContract.JtxICalObject.RRULE)?.let { rruleString -> + values.getAsString(JtxContract.JtxICalObject.RRULE)?.takeIf { it.isNotBlank() }?.let { rruleString -> try { to += RRule(rruleString) } catch (e: Exception) { @@ -56,7 +56,7 @@ class RecurrenceFieldsHandler : JtxFieldHandler { } } - values.getAsString(JtxContract.JtxICalObject.RDATE)?.let { rdateString -> + values.getAsString(JtxContract.JtxICalObject.RDATE)?.takeIf { it.isNotBlank() }?.let { rdateString -> try { val timestamps = JtxContract.getLongListFromString(rdateString) if (timestamps.isNotEmpty()) { @@ -67,7 +67,7 @@ class RecurrenceFieldsHandler : JtxFieldHandler { } } - values.getAsString(JtxContract.JtxICalObject.EXDATE)?.let { exdateString -> + values.getAsString(JtxContract.JtxICalObject.EXDATE)?.takeIf { it.isNotBlank() }?.let { exdateString -> try { val timestamps = JtxContract.getLongListFromString(exdateString) if (timestamps.isNotEmpty()) { From a80c07b96d78622a7b722ff83ad0e85728fe7912 Mon Sep 17 00:00:00 2001 From: Arnau Mora Gras Date: Thu, 21 May 2026 11:10:36 +0200 Subject: [PATCH 5/8] Fix RDATEs not being implemented --- .../synctools/mapping/jtx/JtxObjectHandler.kt | 9 ++- .../mapping/jtx/JtxObjectHandlerTest.kt | 67 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/JtxObjectHandler.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/JtxObjectHandler.kt index 863babdc..c897a99c 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/JtxObjectHandler.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/JtxObjectHandler.kt @@ -15,6 +15,7 @@ import at.bitfire.synctools.storage.jtx.JtxObjectAndExceptions import at.techbee.jtx.JtxContract import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.component.CalendarComponent +import net.fortuna.ical4j.model.property.RDate import net.fortuna.ical4j.model.component.VJournal import net.fortuna.ical4j.model.component.VToDo import net.fortuna.ical4j.model.property.ProdId @@ -55,8 +56,8 @@ class JtxObjectHandler( main = jtxObjectAndExceptions.main ) - val rRules = main.getProperties>(Property.RRULE) - val exceptions: List = if (rRules.isNotEmpty()) { + val isRecurring = main.containsProperty>(Property.RRULE) || main.containsProperty>(Property.RDATE) + val exceptions: List = if (isRecurring) { // add exceptions to recurring main jtx object jtxObjectAndExceptions.exceptions.map { exception -> mapJtxObject( @@ -112,6 +113,10 @@ class JtxObjectHandler( return JtxContract.JtxICalObject.Component.valueOf(componentValue) } + private fun CalendarComponent.containsProperty(name: String): Boolean { + return getProperties(name).isNotEmpty() + } + /** * Result of the [mapToCalendarComponents] operation. * diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/JtxObjectHandlerTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/JtxObjectHandlerTest.kt index bdde7574..aae936e1 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/JtxObjectHandlerTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/JtxObjectHandlerTest.kt @@ -20,6 +20,7 @@ import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import java.time.ZoneOffset @RunWith(RobolectricTestRunner::class) class JtxObjectHandlerTest { @@ -97,4 +98,70 @@ class JtxObjectHandlerTest { assertTrue(result.generatedUid) assertNotNull(result.uid) } + + @Test + fun `exceptions are mapped for recurring task with RRULE`() { + val exception = Entity(contentValuesOf( + JtxContract.JtxICalObject.COMPONENT to "VTODO", + JtxContract.JtxICalObject.RECURID to "20260516T120000Z", + JtxContract.JtxICalObject.RECURID_TIMEZONE to ZoneOffset.UTC.id + )) + val jtxObjectAndExceptions = JtxObjectAndExceptions( + main = Entity(contentValuesOf( + JtxContract.JtxICalObject.COMPONENT to "VTODO", + JtxContract.JtxICalObject.UID to "uid", + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5" + )), + exceptions = listOf(exception) + ) + + val result = handler.mapToCalendarComponents(jtxObjectAndExceptions) + + assertEquals(1, result.associatedComponents.exceptions.size) + } + + @Test + fun `exceptions are mapped for recurring task with RDATE only`() { + // Regression test: exceptions must be included even when recurrence is expressed + // via RDATE alone (no RRULE). Previously only RRULE was checked. + val exception = Entity(contentValuesOf( + JtxContract.JtxICalObject.COMPONENT to "VTODO", + JtxContract.JtxICalObject.RECURID to "20260516T120000Z", + JtxContract.JtxICalObject.RECURID_TIMEZONE to ZoneOffset.UTC.id + )) + val jtxObjectAndExceptions = JtxObjectAndExceptions( + main = Entity(contentValuesOf( + JtxContract.JtxICalObject.COMPONENT to "VTODO", + JtxContract.JtxICalObject.UID to "uid", + JtxContract.JtxICalObject.DTSTART_TIMEZONE to ZoneOffset.UTC.id, + JtxContract.JtxICalObject.RDATE to "1779451200000" // 2026-05-22T12:00:00Z + )), + exceptions = listOf(exception) + ) + + val result = handler.mapToCalendarComponents(jtxObjectAndExceptions) + + assertEquals(1, result.associatedComponents.exceptions.size) + } + + @Test + fun `exceptions are dropped for non-recurring task`() { + val exception = Entity(contentValuesOf( + JtxContract.JtxICalObject.COMPONENT to "VTODO", + JtxContract.JtxICalObject.RECURID to "20260516T120000Z", + JtxContract.JtxICalObject.RECURID_TIMEZONE to ZoneOffset.UTC.id + )) + val jtxObjectAndExceptions = JtxObjectAndExceptions( + main = Entity(contentValuesOf( + JtxContract.JtxICalObject.COMPONENT to "VTODO", + JtxContract.JtxICalObject.UID to "uid" + // no RRULE, no RDATE + )), + exceptions = listOf(exception) + ) + + val result = handler.mapToCalendarComponents(jtxObjectAndExceptions) + + assertEquals(0, result.associatedComponents.exceptions.size) + } } From 24135354170227e3c15a6d05a14d6440c6da4309 Mon Sep 17 00:00:00 2001 From: Arnau Mora Gras Date: Thu, 21 May 2026 11:19:17 +0200 Subject: [PATCH 6/8] Add specific handling for `Z` timezone -> UTC --- .../jtx/handler/RecurrenceFieldsHandler.kt | 2 +- .../jtx/handler/RecurrenceFieldsHandlerTest.kt | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt index e2350ee5..cb856f8f 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt @@ -85,7 +85,7 @@ class RecurrenceFieldsHandler : JtxFieldHandler { val recuridTimezone = values.getAsString(JtxContract.JtxICalObject.RECURID_TIMEZONE) try { - to += if (recuridTimezone == TZ_ALLDAY || recuridTimezone.isNullOrEmpty()) { + to += if (recuridTimezone == TZ_ALLDAY || recuridTimezone.isNullOrEmpty() || recuridTimezone == ZoneOffset.UTC.id) { RecurrenceId(recurid) } else { RecurrenceId(ParameterList(listOf(TzId(recuridTimezone))), recurid) diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt index b622aa28..ad0c9898 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt @@ -294,6 +294,23 @@ class RecurrenceFieldsHandlerTest { assertNull(recurId?.getParameter(Parameter.TZID)?.getOrNull()) } + @Test + fun `Exception with UTC RECURID has no TZID parameter`() { + val main = Entity(contentValuesOf(JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5")) + val exception = Entity(contentValuesOf( + JtxContract.JtxICalObject.RECURID to "20260516T120000Z", + JtxContract.JtxICalObject.RECURID_TIMEZONE to ZoneOffset.UTC.id + )) + val output = VToDo() + + handler.process(from = exception, main = main, to = output) + + val recurId = output.getProperty>(Property.RECURRENCE_ID).getOrNull() + assertNotNull(recurId) + assertEquals("20260516T120000Z", recurId?.value) + assertNull(recurId?.getParameter(Parameter.TZID)?.getOrNull()) + } + @Test fun `Exception with named timezone RECURID`() { val main = Entity(contentValuesOf(JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5")) From adadc23a923ded8b10bd5ba1af369689c3d33c34 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Thu, 21 May 2026 11:25:05 +0200 Subject: [PATCH 7/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt index ad0c9898..c140be16 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt @@ -115,6 +115,7 @@ class RecurrenceFieldsHandlerTest { assertNotNull(rdate) assertEquals(1, rdate?.dates?.size) assertEquals("20260522T120000", rdate?.value) + assertEquals("Europe/Vienna", rdate?.getParameter(Parameter.TZID)?.getOrNull()?.value) } @Test From 046f92044550ff4ddcefbc8a07499b4c9eb36cf8 Mon Sep 17 00:00:00 2001 From: Arnau Mora Gras Date: Thu, 21 May 2026 11:47:22 +0200 Subject: [PATCH 8/8] With TZID handling --- .../jtx/handler/RecurrenceFieldsHandler.kt | 20 +++++++++++++++-- .../handler/RecurrenceFieldsHandlerTest.kt | 22 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt index cb856f8f..ac1a467d 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt @@ -14,6 +14,8 @@ import net.fortuna.ical4j.model.DateList import net.fortuna.ical4j.model.ParameterList import net.fortuna.ical4j.model.component.CalendarComponent import net.fortuna.ical4j.model.parameter.TzId +import net.fortuna.ical4j.model.parameter.Value +import net.fortuna.ical4j.model.property.DateListProperty import net.fortuna.ical4j.model.property.ExDate import net.fortuna.ical4j.model.property.RDate import net.fortuna.ical4j.model.property.RRule @@ -60,7 +62,7 @@ class RecurrenceFieldsHandler : JtxFieldHandler { try { val timestamps = JtxContract.getLongListFromString(rdateString) if (timestamps.isNotEmpty()) { - to += RDate(timestampsToDateList(timestamps, dtstartTimezone)) + to += timestampsToProperty(timestamps, dtstartTimezone) { RDate(it) } } } catch (e: Exception) { logger.log(Level.WARNING, "Couldn't parse RDATE, ignoring", e) @@ -71,7 +73,7 @@ class RecurrenceFieldsHandler : JtxFieldHandler { try { val timestamps = JtxContract.getLongListFromString(exdateString) if (timestamps.isNotEmpty()) { - to += ExDate(timestampsToDateList(timestamps, dtstartTimezone)) + to += timestampsToProperty(timestamps, dtstartTimezone) { ExDate(it) } } } catch (e: Exception) { logger.log(Level.WARNING, "Couldn't parse EXDATE, ignoring", e) @@ -95,6 +97,20 @@ class RecurrenceFieldsHandler : JtxFieldHandler { } } + private fun > timestampsToProperty( + timestamps: List, + dtstartTimezone: String?, + factory: (DateList<*>) -> T + ): T { + val dateList = timestampsToDateList(timestamps, dtstartTimezone) + val property = factory(dateList) + when (val first = dateList.dates.firstOrNull()) { + is ZonedDateTime -> property.add(TzId(first.zone.id)) + is LocalDate -> property.add(Value.DATE) + } + return property + } + private fun timestampsToDateList(timestamps: List, dtstartTimezone: String?): DateList<*> = when { dtstartTimezone == TZ_ALLDAY -> diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt index c140be16..e9b00400 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt @@ -15,6 +15,7 @@ import net.fortuna.ical4j.model.Parameter import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.component.VToDo import net.fortuna.ical4j.model.parameter.TzId +import net.fortuna.ical4j.model.parameter.Value import net.fortuna.ical4j.model.property.ExDate import net.fortuna.ical4j.model.property.RDate import net.fortuna.ical4j.model.property.RRule @@ -97,6 +98,7 @@ class RecurrenceFieldsHandlerTest { assertNotNull(rdate) assertEquals(1, rdate?.dates?.size) assertEquals(LocalDate.of(2026, 5, 22), rdate?.dates?.first()) + assertEquals(Value.DATE, rdate?.getParameter(Parameter.VALUE)?.getOrNull()) } @Test @@ -200,6 +202,26 @@ class RecurrenceFieldsHandlerTest { assertNotNull(exdate) assertEquals(1, exdate?.dates?.size) assertEquals(LocalDate.of(2026, 5, 22), exdate?.dates?.first()) + assertEquals(Value.DATE, exdate?.getParameter(Parameter.VALUE)?.getOrNull()) + } + + @Test + fun `EXDATE with named timezone`() { + // 1779444000000 ms = 2026-05-22T10:00:00Z = 2026-05-22T12:00:00+02:00[Europe/Vienna] + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.DTSTART_TIMEZONE to "Europe/Vienna", + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5", + JtxContract.JtxICalObject.EXDATE to "1779444000000" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + val exdate = output.getProperty>(Property.EXDATE).getOrNull() + assertNotNull(exdate) + assertEquals(1, exdate?.dates?.size) + assertEquals("20260522T120000", exdate?.value) + assertEquals("Europe/Vienna", exdate?.getParameter(Parameter.TZID)?.getOrNull()?.value) } @Test