fix(ics): round all-day reminders up and stop dropping them on export (#225)

Review follow-ups on the import work:

- All-day reminder offsets are `days * 1440 - timeOfDay`, so rounding to the
  nearest day lost a day for any producer whose all-day notifications fire after
  noon ("1 day before at 18:00" arrives as -PT6H). Round up instead.
- Export decodes an all-day row's raw provider offset back to its whole-day lead
  time. It is normally negative, and the writer discards a trigger that fires
  after the event, so our own backups came back with no all-day reminder at all.
- A floating EXDATE DATE-TIME is read in the series' zone, not the device's;
  otherwise the exclusion lands on an instant no occurrence has.
- Wire the parsed calendar name up: it had no reader, so the CATEGORIES handling
  was inert. It now preselects a target calendar of that name.
This commit is contained in:
2026-08-27 19:36:45 +02:00
parent c0361686df
commit db4611d284
13 changed files with 254 additions and 39 deletions

View File

@@ -13,9 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
read this file". Events are now added one at a time: anything rejected is read this file". Events are now added one at a time: anything rejected is
counted and shown, and the rest still arrive. A faulty repeat rule in the file counted and shown, and the rest still arrive. A faulty repeat rule in the file
is repaired instead of being handed on ([#225]). is repaired instead of being handed on ([#225]).
- **Imported reminders fire at the hour you chose**, not at midnight UTC — the - **All-day reminders survive a backup and an import.** They fire at the hour you
same rule the app already applied to all-day events you create yourself chose, not at midnight UTC — the same rule the app already applied to all-day
([#225]). events you create yourself — and an export no longer leaves them out of the
file entirely ([#225]).
- **Birthdays and anniversaries imported from Fossify Calendar now show up.** - **Birthdays and anniversaries imported from Fossify Calendar now show up.**
Fossify writes the ones it mirrors from your contacts as events that start and Fossify writes the ones it mirrors from your contacts as events that start and
end on the same day, which the calendar read as lasting no time at all: they end on the same day, which the calendar read as lasting no time at all: they
@@ -26,9 +27,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- **More of an imported `.ics` survives the trip**: tasks come across as events - **More of an imported `.ics` survives the trip**: tasks come across as events
rather than being dropped in silence, deleted occurrences of a repeating event rather than being dropped in silence, deleted occurrences of a repeating event
stay deleted, the file's own calendar name is picked up, and event colours are stay deleted, a calendar named like the one the file came from is preselected,
carried over — matched to the closest colour your calendar's account offers, so and event colours are carried over — matched to the closest colour your
a migrated calendar still reads at a glance ([#225]). calendar's account offers, so a migrated calendar still reads at a glance
([#225]).
## [2.19.2] — 2026-08-17 ## [2.19.2] — 2026-08-17

View File

@@ -91,8 +91,10 @@ interface CalendarDataSource {
* rows are excluded (see [EventExportProjection]). When [calendarIds] is * rows are excluded (see [EventExportProjection]). When [calendarIds] is
* given, only those calendars are exported (still intersected with the * given, only those calendars are exported (still intersected with the
* eligible set); `null` exports every eligible calendar. * eligible set); `null` exports every eligible calendar.
* [allDayReminderTimeMinutes]: needed to write all-day reminders as whole-day
* lead times rather than raw provider offsets (see [toIcsEvent]).
*/ */
fun exportableEvents(calendarIds: Set<Long>? = null): List<IcsEvent> fun exportableEvents(calendarIds: Set<Long>?, allDayReminderTimeMinutes: Int): List<IcsEvent>
/** /**
* The non-empty `Events.UID_2445` values present in [calendarId] — used to * The non-empty `Events.UID_2445` values present in [calendarId] — used to
@@ -752,7 +754,10 @@ class AndroidCalendarDataSource @Inject constructor(
?: emptyList() ?: emptyList()
} }
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> { override fun exportableEvents(
calendarIds: Set<Long>?,
allDayReminderTimeMinutes: Int,
): List<IcsEvent> {
// Only the local calendars the app owns and can write — synced calendars // Only the local calendars the app owns and can write — synced calendars
// already have a backup (their server). Exclude the managed special-dates // already have a backup (their server). Exclude the managed special-dates
// mirror calendars: their events are derived from contacts, not authored // mirror calendars: their events are derived from contacts, not authored
@@ -787,6 +792,7 @@ class AndroidCalendarDataSource @Inject constructor(
reader.toIcsEvent( reader.toIcsEvent(
reminderMinutes = queryReminders(eventId).map { it.minutes }, reminderMinutes = queryReminders(eventId).map { it.minutes },
calendarName = names[calendarId], calendarName = names[calendarId],
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
) )
} }
} ?: emptyList() } ?: emptyList()

View File

@@ -212,7 +212,7 @@ class CalendarRepositoryImpl @Inject constructor(
} }
override suspend fun exportEvents(calendarIds: Set<Long>?) = override suspend fun exportEvents(calendarIds: Set<Long>?) =
withContext(io) { dataSource.exportableEvents(calendarIds) } withContext(io) { dataSource.exportableEvents(calendarIds, allDayReminderTimeMinutes()) }
override suspend fun importEvents( override suspend fun importEvents(
targetCalendarId: Long, targetCalendarId: Long,

View File

@@ -560,7 +560,8 @@ internal fun buildImportedEventValues(
* series, seconds otherwise. An all-day series is never shorter than a day — * series, seconds otherwise. An all-day series is never shorter than a day —
* the provider expands a zero-length one into no instances at all, so the * the provider expands a zero-length one into no instances at all, so the
* series would vanish (which is what a literal read of the Fossify family's * series would vanish (which is what a literal read of the Fossify family's
* inclusive DTEND used to produce; see `IcsQuirks`). * inclusive DTEND used to produce; see "Importing foreign .ics" in
* docs/ARCHITECTURE.md).
*/ */
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String { private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
val span = (endMillis - startMillis).coerceAtLeast(0) val span = (endMillis - startMillis).coerceAtLeast(0)

View File

@@ -5,6 +5,9 @@ import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
/** /**
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent] * Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
@@ -12,14 +15,23 @@ import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
* [calendarName] the display name of its calendar (emitted as * [calendarName] the display name of its calendar (emitted as
* `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with * `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with
* MapColumnReader. * MapColumnReader.
*
* An all-day row's raw offset has the firing time of day encoded into it and is
* normally *negative* (see [AllDayReminderEncoding]); written out literally it
* would be dropped as a trigger that fires after the event, losing the reminder.
* [allDayReminderTimeMinutes] and [zone] decode it back to the whole-day lead
* time the file should carry, which an import re-encodes for its own device.
*/ */
internal fun ColumnReader.toIcsEvent( internal fun ColumnReader.toIcsEvent(
reminderMinutes: List<Int>, reminderMinutes: List<Int>,
calendarName: String?, calendarName: String?,
allDayReminderTimeMinutes: Int,
zone: ZoneId = ZoneId.systemDefault(),
): IcsEvent { ): IcsEvent {
val eventId = getLong(EventExportProjection.IDX_ID) val eventId = getLong(EventExportProjection.IDX_ID)
val dtStart = getLong(EventExportProjection.IDX_DTSTART) val dtStart = getLong(EventExportProjection.IDX_DTSTART)
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() } val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it // Recurring rows store DURATION instead of DTEND; reconstruct the end from it
// so the writer can render DTEND. A missing/blank both means a zero-length event. // so the writer can render DTEND. A missing/blank both means a zero-length event.
@@ -40,13 +52,20 @@ internal fun ColumnReader.toIcsEvent(
summary = getString(EventExportProjection.IDX_TITLE).orEmpty(), summary = getString(EventExportProjection.IDX_TITLE).orEmpty(),
start = dtStart.toKotlinInstantFromEpochMillis(), start = dtStart.toKotlinInstantFromEpochMillis(),
end = end.toKotlinInstantFromEpochMillis(), end = end.toKotlinInstantFromEpochMillis(),
isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0, isAllDay = isAllDay,
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() } zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
?: "UTC", ?: "UTC",
recurrenceRule = rrule, recurrenceRule = rrule,
location = getString(EventExportProjection.IDX_LOCATION), location = getString(EventExportProjection.IDX_LOCATION),
description = getString(EventExportProjection.IDX_DESCRIPTION), description = getString(EventExportProjection.IDX_DESCRIPTION),
reminderMinutes = reminderMinutes, reminderMinutes = if (isAllDay) {
val startDate = Instant.ofEpochMilli(dtStart).atZone(ZoneOffset.UTC).toLocalDate()
reminderMinutes.map {
fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes)
}
} else {
reminderMinutes
},
status = status, status = status,
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)), availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
calendarName = calendarName, calendarName = calendarName,

View File

@@ -10,7 +10,7 @@ import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.number import kotlinx.datetime.number
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.math.roundToInt import kotlin.math.ceil
import kotlin.time.Instant import kotlin.time.Instant
/** Milliseconds in a calendar day; all-day times are UTC midnights, so this is exact. */ /** Milliseconds in a calendar day; all-day times are UTC midnights, so this is exact. */
@@ -55,15 +55,21 @@ data class ParsedIcsEvent(
* *
* For a timed event the raw lead time already is that. All-day reminders are * For a timed event the raw lead time already is that. All-day reminders are
* whole days before the event, fired at the user's configured time of day (see * whole days before the event, fired at the user's configured time of day (see
* `AllDayReminderEncoding`), so a raw offset is rounded to the nearest day — * `AllDayReminderEncoding`), so a raw offset counts the whole days its trigger
* which is also what makes Fossify's all-day encoding land correctly: it writes * lands *earlier than* the event's UTC midnight — i.e. it rounds **up**.
* "on the day at 09:00" as a *positive* `TRIGGER:P0DT9H0M0S`, i.e. nine hours *
* after the UTC midnight, and that rounds to zero days before. * Rounding to the nearest day instead would lose a day for every producer whose
* all-day notifications fire after noon: a file's raw offset is
* `days × 1440 timeOfDay`, so "1 day before at 18:00" arrives as
* `TRIGGER:-PT6H` (raw 360) and would read as "on the day". Rounding up also
* lands Fossify's encoding correctly — it writes "on the day at 09:00" as a
* *positive* `TRIGGER:P0DT9H0M0S`, nine hours after the UTC midnight, which
* ceils to zero days before.
*/ */
fun ParsedIcsEvent.semanticReminderMinutes(): List<Int> = reminderMinutes fun ParsedIcsEvent.semanticReminderMinutes(): List<Int> = reminderMinutes
.map { raw -> .map { raw ->
if (isAllDay) { if (isAllDay) {
(raw.toDouble() / MINUTES_PER_DAY).roundToInt().coerceAtLeast(0) * MINUTES_PER_DAY ceil(raw.toDouble() / MINUTES_PER_DAY).toInt().coerceAtLeast(0) * MINUTES_PER_DAY
} else { } else {
raw.coerceAtLeast(0) raw.coerceAtLeast(0)
} }
@@ -306,7 +312,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
.mapNotNull { (line, token) -> .mapNotNull { (line, token) ->
if (token.isEmpty()) return@mapNotNull null if (token.isEmpty()) return@mapNotNull null
if (token.contains('T')) { if (token.contains('T')) {
val instant = parseExDateTime(token, line) ?: return@mapNotNull null val instant = parseExDateTime(token, line, startZone) ?: return@mapNotNull null
if (start.isAllDay) utcDayCode(instant) else utcStamp(instant) if (start.isAllDay) utcDayCode(instant) else utcStamp(instant)
} else { } else {
val date = parseBasicDate(token) ?: return@mapNotNull null val date = parseBasicDate(token) ?: return@mapNotNull null
@@ -320,12 +326,22 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
.distinct() .distinct()
} }
private fun parseExDateTime(token: String, line: IcsContentLine): Instant? { /**
* [startZone] is the series' own zone, which a floating value (no `Z`, no
* `TZID`) has to be read in — RFC 5545 ties `EXDATE` to `DTSTART`'s form,
* and reading it in the device's zone instead would put the exclusion on an
* instant no occurrence has.
*/
private fun parseExDateTime(
token: String,
line: IcsContentLine,
startZone: TimeZone,
): Instant? {
val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null
val zone = when { val zone = when {
token.endsWith("Z") -> TimeZone.UTC token.endsWith("Z") -> TimeZone.UTC
else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() } else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() }
?: deviceZone ?: startZone
} }
return ldt.toInstant(zone) return ldt.toInstant(zone)
} }

View File

@@ -93,15 +93,11 @@ fun ImportScreen(
} }
// Hoisted target calendar so the always-visible top-bar Import action can // Hoisted target calendar so the always-visible top-bar Import action can
// read it without the user scrolling to a bottom button. Defaults to the // read it without the user scrolling to a bottom button (see
// first *local* calendar — the first row the picker shows ("Your calendars" // [defaultImportTarget] for the choice). Re-defaults when the "many" list
// group leads) — so the pre-selection lines up with the top of the list; // first arrives (keyed on it), then holds the pick.
// falls back to the first calendar if there are no local ones. Re-defaults
// when the "many" list first arrives (keyed on it), then holds the pick.
val many = state as? ImportUiState.Many val many = state as? ImportUiState.Many
val defaultTarget = many?.calendars?.let { cals -> val defaultTarget = many?.let { defaultImportTarget(it.calendars, it.fileCalendarName) }
(cals.firstOrNull { it.isLocal } ?: cals.firstOrNull())?.id
}
var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) } var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) }
Scaffold( Scaffold(

View File

@@ -43,6 +43,12 @@ sealed interface ImportUiState {
val events: List<ParsedIcsEvent>, val events: List<ParsedIcsEvent>,
val warnings: Set<IcsParseWarning>, val warnings: Set<IcsParseWarning>,
val calendars: List<CalendarSource>, val calendars: List<CalendarSource>,
/**
* The calendar the file says its events came from, when it names exactly
* one — used to preselect a matching target. Null when the file names
* none or its events disagree.
*/
val fileCalendarName: String? = null,
) : ImportUiState ) : ImportUiState
data class Done(val summary: IcsImportSummary) : ImportUiState data class Done(val summary: IcsImportSummary) : ImportUiState
@@ -92,6 +98,10 @@ class ImportViewModel @Inject constructor(
warnings = parsed.warnings, warnings = parsed.warnings,
calendars = repository.calendars().first() calendars = repository.calendars().first()
.filter { it.isEventTarget }, .filter { it.isEventTarget },
fileCalendarName = parsed.events
.mapNotNull { it.calendarName }
.distinct()
.singleOrNull(),
) )
} }
} }
@@ -113,3 +123,22 @@ class ImportViewModel @Inject constructor(
} }
} }
} }
/**
* The target calendar to preselect for a bulk import.
*
* A calendar named like the one the file came from wins — restoring "Birthdays"
* onto the Birthdays calendar is what the user means. Otherwise the first
* *local* calendar, which is the first row the picker shows ("Your calendars"
* leads), so the pre-selection lines up with the top of the list; failing that
* the first calendar of any kind.
*/
internal fun defaultImportTarget(
calendars: List<CalendarSource>,
fileCalendarName: String?,
): Long? {
val named = fileCalendarName?.let { name ->
calendars.firstOrNull { it.displayName.equals(name, ignoreCase = true) }
}
return (named ?: calendars.firstOrNull { it.isLocal } ?: calendars.firstOrNull())?.id
}

View File

@@ -81,7 +81,10 @@ internal class FakeCalendarDataSource : CalendarDataSource {
eventDetailResult(eventId) eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List<EventColorOption> = override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
eventColorPaletteResult(calendarId) eventColorPaletteResult(calendarId)
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> { override fun exportableEvents(
calendarIds: Set<Long>?,
allDayReminderTimeMinutes: Int,
): List<IcsEvent> {
lastExportableEventsCalendarIds = calendarIds lastExportableEventsCalendarIds = calendarIds
return exportableEventsResult return exportableEventsResult
} }

View File

@@ -4,6 +4,7 @@ import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.time.ZoneId
class IcsExportMapperTest { class IcsExportMapperTest {
@@ -20,7 +21,11 @@ class IcsExportMapperTest {
EventExportProjection.IDX_AVAILABILITY to CalendarContract.Events.AVAILABILITY_BUSY, EventExportProjection.IDX_AVAILABILITY to CalendarContract.Events.AVAILABILITY_BUSY,
) )
val event = reader.toIcsEvent(reminderMinutes = listOf(10), calendarName = "Personal") val event = reader.toIcsEvent(
reminderMinutes = listOf(10),
calendarName = "Personal",
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.uid).isEqualTo("abc@host") assertThat(event.uid).isEqualTo("abc@host")
assertThat(event.summary).isEqualTo("Standup") assertThat(event.summary).isEqualTo("Standup")
@@ -47,7 +52,11 @@ class IcsExportMapperTest {
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC", EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
) )
val event = reader.toIcsEvent(reminderMinutes = emptyList(), calendarName = null) val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.uid).isEqualTo("7-1000000@calendula") assertThat(event.uid).isEqualTo("7-1000000@calendula")
assertThat(event.recurrenceRule).isEqualTo("FREQ=WEEKLY") assertThat(event.recurrenceRule).isEqualTo("FREQ=WEEKLY")
@@ -65,6 +74,32 @@ class IcsExportMapperTest {
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC", EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
) )
assertThat(reader.toIcsEvent(emptyList(), null).isAllDay).isTrue() assertThat(reader.toIcsEvent(emptyList(), null, NINE_AM).isAllDay).isTrue()
}
@Test
fun `an all-day reminder is exported as its whole-day lead time`() {
// The raw offset encodes the firing time and is normally negative; left
// raw it would be written as a trigger after the event and dropped, so
// the backup would come back with no reminder at all.
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 1L,
EventExportProjection.IDX_TITLE to "Anna Birthday",
EventExportProjection.IDX_DTSTART to 1_782_864_000_000L, // 2026-07-01 UTC, CEST
EventExportProjection.IDX_DTEND to 1_782_950_400_000L,
EventExportProjection.IDX_ALL_DAY to 1,
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val onTheDay = reader.toIcsEvent(listOf(-420), null, NINE_AM, BERLIN)
val dayBefore = reader.toIcsEvent(listOf(1_020), null, NINE_AM, BERLIN)
assertThat(onTheDay.reminderMinutes).containsExactly(0)
assertThat(dayBefore.reminderMinutes).containsExactly(1_440)
}
private companion object {
const val NINE_AM = 9 * 60
val BERLIN: ZoneId = ZoneId.of("Europe/Berlin")
} }
} }

View File

@@ -289,6 +289,28 @@ class IcsFossifyImportTest {
assertThat(event.semanticReminderMinutes()).containsExactly(0) assertThat(event.semanticReminderMinutes()).containsExactly(0)
} }
@Test
fun `an all-day reminder whose firing time is after noon keeps its full day`() {
// The raw offset is `days * 1440 - timeOfDay`, so an 18:00 notification
// time puts "1 day before" only six hours ahead of the UTC midnight.
// Rounding to the nearest day would read that as "on the day".
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"BEGIN:VALARM",
"ACTION:DISPLAY",
"TRIGGER:-P0DT6H0M0S",
"END:VALARM",
"END:VEVENT",
),
)
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(1440)
}
@Test @Test
fun `an all-day reminder rounds to whole days before`() { fun `an all-day reminder rounds to whole days before`() {
val result = parser.parse( val result = parser.parse(
@@ -366,6 +388,27 @@ class IcsFossifyImportTest {
assertThat(result.events.single().exDates).containsExactly("20260820T170000Z") assertThat(result.events.single().exDates).containsExactly("20260820T170000Z")
} }
@Test
fun `a floating EXDATE is read in the series' zone, not the device's`() {
// RFC 5545 ties EXDATE to DTSTART's form: with no Z and no TZID the
// value is New York wall time. Reading it in the device's Berlin zone
// would land on an instant no occurrence has, and the excluded
// occurrence would come back.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART;TZID=America/New_York:20260819T100000",
"DTEND;TZID=America/New_York:20260819T103000",
"RRULE:FREQ=WEEKLY",
"EXDATE:20260826T100000",
"END:VEVENT",
),
)
assertThat(result.events.single().exDates).containsExactly("20260826T140000Z")
}
@Test @Test
fun `EXDATE is dropped along with an unusable recurrence rule`() { fun `EXDATE is dropped along with an unusable recurrence rule`() {
val result = parser.parse( val result = parser.parse(

View File

@@ -0,0 +1,55 @@
package de.jeanlucmakiola.calendula.ui.imports
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.CalendarSource
import org.junit.jupiter.api.Test
/** Which calendar a bulk import lands on before the user touches the picker. */
class ImportTargetTest {
private fun cal(id: Long, name: String, local: Boolean = false) = CalendarSource(
id = id,
displayName = name,
accountName = "acc@local",
accountType = if (local) "LOCAL" else "com.google",
color = 0,
isVisibleInSystem = true,
isLocal = local,
)
private val calendars = listOf(
cal(1L, "Work"),
cal(2L, "Personal", local = true),
cal(3L, "Birthdays", local = true),
)
@Test
fun `a calendar named like the file's wins`() {
assertThat(defaultImportTarget(calendars, "Birthdays")).isEqualTo(3L)
}
@Test
fun `the name match ignores case`() {
assertThat(defaultImportTarget(calendars, "birthdays")).isEqualTo(3L)
}
@Test
fun `an unmatched name falls back to the first local calendar`() {
assertThat(defaultImportTarget(calendars, "Holidays")).isEqualTo(2L)
}
@Test
fun `no name at all falls back to the first local calendar`() {
assertThat(defaultImportTarget(calendars, null)).isEqualTo(2L)
}
@Test
fun `with no local calendars the first of any kind is taken`() {
assertThat(defaultImportTarget(listOf(cal(1L, "Work")), null)).isEqualTo(1L)
}
@Test
fun `an empty list preselects nothing`() {
assertThat(defaultImportTarget(emptyList(), "Birthdays")).isNull()
}
}

View File

@@ -384,12 +384,22 @@ read. `sanitizeRrule` drops empty and malformed parts before the write, and
Smaller dialect handling: `VTODO` components import as events (Calendula models Smaller dialect handling: `VTODO` components import as events (Calendula models
no tasks; dropping them silently lost half of some exports), `CATEGORIES` stands no tasks; dropping them silently lost half of some exports), `CATEGORIES` stands
in for the `X-WR-CALNAME` Fossify never writes, bare `EXDATE` day codes on a in for the `X-WR-CALNAME` Fossify never writes and preselects a target calendar
timed series are resolved against the series' own time of day, and a `VALARM` of that name, bare `EXDATE` day codes on a timed series are resolved against the
trigger pointing *after* the start — which is how that family encodes "on the day series' own time of day (as is a floating `EXDATE` DATE-TIME — RFC 5545 ties it
at 09:00" — is read as zero days before rather than clamped to a lead time of to `DTSTART`'s zone, not the device's), and a `VALARM` trigger pointing *after*
zero, so all-day reminders fire at the hour the user's own setting names instead the start — which is how that family encodes "on the day at 09:00" — is read as
of at UTC midnight. zero days before rather than clamped to a lead time of zero.
All-day reminder offsets are **whole days, rounded up** in both directions. A
file's raw offset is `days × 1440 timeOfDay`, so rounding to the nearest day
would drop a day for every producer whose all-day notifications fire after noon
("1 day before at 18:00" arrives as `TRIGGER:-PT6H`). The export side is the
mirror image: an all-day row's raw provider `MINUTES` has the firing time encoded
into it and is normally *negative*, which the writer used to discard as a trigger
after the event — so `toIcsEvent` decodes it back through
`fromProviderAllDayMinutes` first, and the importing device re-encodes it against
its own setting.
Colour arrives as a raw ARGB from an app with no idea which account it is landing Colour arrives as a raw ARGB from an app with no idea which account it is landing
in. A calendar whose account publishes a palette rejects a raw `EVENT_COLOR`, so in. A calendar whose account publishes a palette rejects a raw `EVENT_COLOR`, so