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:
14
CHANGELOG.md
14
CHANGELOG.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user