fix(import): read the Fossify/Simple Calendar .ics dialect (#225)
Their exporter writes the last day an all-day event occupies as DTEND, where RFC 5545 means the day after. Read literally every all-day event was a day short and single-day ones — birthdays, memorials, name days — came out zero length, which the provider expands into no instances at all, so they never appeared. IcsQuirks sniffs the producer and shifts the end; independently, an all-day event may no longer end at or before its start and an all-day DURATION is floored at P1D. Fossify also emits a bare ";BYDAY=" for weekly events with no weekday mask. EventRecurrence.parse throws on that, out of insert, and nothing caught it — one such row failed the entire file. sanitizeRrule now drops empty and malformed parts, and importEvents isolates each event, counting rejects into IcsImportSummary.failed instead of unwinding the batch. Rest of the dialect: VTODO imports as events (was dropped silently), CATEGORIES stands in for the X-WR-CALNAME they never write, bare EXDATE day codes resolve against a timed series' own time of day, and their positive VALARM trigger is read as "on the day" so all-day reminders fire at the hour the setting names rather than at UTC midnight. Colours come across too: X-FOSSIFY-EVENT-COLOR / COLOR / the category colour as fallback, plus the X-SMT-* legacy spellings, snapped in Oklab to the nearest key a palette account publishes since those reject a raw EVENT_COLOR. Closes #225
This commit is contained in:
16
CHANGELOG.md
16
CHANGELOG.md
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Calendars imported from Fossify Calendar arrive intact.** Fossify writes the
|
||||
last day an all-day event occupies where iCalendar means the day after it, so
|
||||
every birthday, anniversary and memorial in an export was zero days long and
|
||||
vanished on import — and a faulty repeat rule it writes for weekly events
|
||||
could abort the whole file with nothing but "couldn't read this" on screen.
|
||||
Both are handled now: an event the calendar refuses is left out and counted
|
||||
instead of costing you the rest of the import, tasks come across as events
|
||||
rather than being dropped in silence, deleted occurrences of a repeating event
|
||||
stay deleted, and reminders land at the time you chose rather than at midnight
|
||||
([#225]).
|
||||
- **Imported events keep their colour.** A colour from another app is matched to
|
||||
the closest one your calendar's account offers, so a migrated calendar still
|
||||
reads at a glance ([#225]).
|
||||
|
||||
## [2.19.2] — 2026-08-17
|
||||
|
||||
### Changed
|
||||
@@ -1455,3 +1470,4 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
|
||||
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
|
||||
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
|
||||
[#225]: https://codeberg.org/jlmakiola/calendula/issues/225
|
||||
|
||||
@@ -35,6 +35,7 @@ import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes
|
||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.Instant
|
||||
@@ -101,10 +102,18 @@ interface CalendarDataSource {
|
||||
|
||||
/**
|
||||
* Insert a parsed `.ics` event into [calendarId], preserving its UID (or
|
||||
* minting one when absent); returns the new `Events._ID`. Reminders are
|
||||
* written as the file's raw lead minutes (METHOD_ALERT).
|
||||
* minting one when absent); returns the new `Events._ID`.
|
||||
*
|
||||
* [colorPalette] is the target account's published event colours, looked up
|
||||
* once per import; [allDayReminderTimeMinutes] is the user's preferred
|
||||
* all-day firing time, applied exactly as a hand-created event's is.
|
||||
*/
|
||||
fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long
|
||||
fun insertImportedEvent(
|
||||
event: ParsedIcsEvent,
|
||||
calendarId: Long,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
colorPalette: List<EventColorOption>,
|
||||
): Long
|
||||
|
||||
/**
|
||||
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
||||
@@ -794,47 +803,41 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) }
|
||||
} ?: emptySet()
|
||||
|
||||
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
|
||||
val startMillis = event.start.toEpochMillis()
|
||||
val endMillis = event.end.toEpochMillis()
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
||||
override fun insertImportedEvent(
|
||||
event: ParsedIcsEvent,
|
||||
calendarId: Long,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
colorPalette: List<EventColorOption>,
|
||||
): Long {
|
||||
val values = buildImportedEventValues(
|
||||
event = event,
|
||||
calendarId = calendarId,
|
||||
// Preserve the file's UID so a re-import dedups against it; mint one
|
||||
// only when the source event carried none.
|
||||
put(
|
||||
CalendarContract.Events.UID_2445,
|
||||
event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
|
||||
uid = event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
|
||||
palette = colorPalette,
|
||||
)
|
||||
put(CalendarContract.Events.TITLE, event.summary.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, startMillis)
|
||||
if (event.recurrenceRule == null) {
|
||||
put(CalendarContract.Events.DTEND, endMillis)
|
||||
} else {
|
||||
put(CalendarContract.Events.RRULE, event.recurrenceRule)
|
||||
put(
|
||||
CalendarContract.Events.DURATION,
|
||||
importDuration(startMillis, endMillis, event.isAllDay),
|
||||
)
|
||||
}
|
||||
// All-day rows live at UTC midnights (the file already encodes them so);
|
||||
// timed rows keep the event's own zone.
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
|
||||
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
|
||||
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
|
||||
event.location?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||
event.description?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||
}
|
||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
|
||||
?: throw WriteFailedException("import event into calendar id=$calendarId")
|
||||
val eventId = ContentUris.parseId(uri)
|
||||
// Raw lead minutes straight from the file's VALARMs (best-effort, like insertEvent).
|
||||
event.reminderMinutes.distinct().filter { it >= 0 }.forEach { minutes ->
|
||||
val startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds())
|
||||
.atZone(ZoneOffset.UTC).toLocalDate()
|
||||
event.semanticReminderMinutes().forEach { minutes ->
|
||||
// An all-day reminder is stored the same way a hand-created one is,
|
||||
// so it fires at the time the user picked rather than at UTC midnight.
|
||||
val providerMinutes = if (event.isAllDay) {
|
||||
toProviderAllDayMinutes(
|
||||
semanticMinutes = minutes,
|
||||
startDate = startDate,
|
||||
zone = ZoneId.systemDefault(),
|
||||
timeOfDayMinutes = allDayReminderTimeMinutes,
|
||||
)
|
||||
} else {
|
||||
minutes
|
||||
}
|
||||
val reminder = ContentValues().apply {
|
||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||
put(CalendarContract.Reminders.MINUTES, providerMinutes)
|
||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||
}
|
||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||
@@ -844,18 +847,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
return eventId
|
||||
}
|
||||
|
||||
/** Provider DURATION for an imported recurring row: whole days / seconds. */
|
||||
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
|
||||
val span = (endMillis - startMillis).coerceAtLeast(0)
|
||||
return if (isAllDay) "P${span / 86_400_000L}D" else "P${span / 1_000L}S"
|
||||
}
|
||||
|
||||
private fun EventStatus.toProviderStatus(): Int = when (this) {
|
||||
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
|
||||
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
|
||||
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
|
||||
}
|
||||
|
||||
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
|
||||
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
@@ -26,6 +27,7 @@ import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.time.Instant
|
||||
@@ -217,19 +219,34 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
events: List<ParsedIcsEvent>,
|
||||
): IcsImportSummary = withContext(io) {
|
||||
val existing = dataSource.existingUids(targetCalendarId)
|
||||
// Both are per-calendar, not per-event: looking them up once keeps a
|
||||
// thousand-event restore to two extra queries.
|
||||
val palette = dataSource.eventColorPalette(targetCalendarId)
|
||||
val allDayMinutes = allDayReminderTimeMinutes()
|
||||
var imported = 0
|
||||
var skipped = 0
|
||||
var failed = 0
|
||||
for (event in events) {
|
||||
// A known UID means the event is already in this calendar — skip,
|
||||
// keeping a restore idempotent (no overwrite this pass).
|
||||
if (event.uid != null && event.uid in existing) {
|
||||
skipped++
|
||||
} else {
|
||||
dataSource.insertImportedEvent(event, targetCalendarId)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
dataSource.insertImportedEvent(event, targetCalendarId, allDayMinutes, palette)
|
||||
imported++
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// One event the provider won't take must not cost the user the
|
||||
// rest of the file — foreign exports do carry rows it rejects
|
||||
// outright (a malformed RRULE throws straight out of insert).
|
||||
failed++
|
||||
Log.w(TAG, "Skipped an unimportable event", e)
|
||||
}
|
||||
}
|
||||
IcsImportSummary(imported = imported, skippedDuplicate = skipped)
|
||||
IcsImportSummary(imported = imported, skippedDuplicate = skipped, failed = failed)
|
||||
}
|
||||
|
||||
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
||||
@@ -288,6 +305,11 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
) = withContext(io) {
|
||||
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
||||
}
|
||||
|
||||
|
||||
private companion object {
|
||||
const val TAG = "CalendarRepository"
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {
|
||||
|
||||
@@ -3,7 +3,11 @@ package de.jeanlucmakiola.calendula.data.calendar
|
||||
import android.provider.CalendarContract
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.nearestTo
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toJavaLocalDateTime
|
||||
import java.time.Duration
|
||||
@@ -499,3 +503,72 @@ internal fun AccessLevel.toProviderValue(): Int = when (this) {
|
||||
AccessLevel.Private -> CalendarContract.Events.ACCESS_PRIVATE
|
||||
AccessLevel.Public -> CalendarContract.Events.ACCESS_PUBLIC
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Events` row for a `.ics` event being imported into [calendarId].
|
||||
*
|
||||
* A recurring row carries RRULE + DURATION (and any EXDATE) with no DTEND; a
|
||||
* one-off carries DTEND. All-day rows live at UTC midnights, exactly as the
|
||||
* parser hands them over.
|
||||
*
|
||||
* [palette] is the target account's published event colours. A calendar that
|
||||
* publishes one rejects a raw `EVENT_COLOR`, so an imported colour — which is
|
||||
* an arbitrary ARGB from a foreign app — is snapped to the nearest key it does
|
||||
* accept; accounts with no palette take the raw value (see [eventColorColumns]).
|
||||
*/
|
||||
internal fun buildImportedEventValues(
|
||||
event: ParsedIcsEvent,
|
||||
calendarId: Long,
|
||||
uid: String,
|
||||
palette: List<EventColorOption>,
|
||||
): Map<String, Any?> = buildMap {
|
||||
val startMillis = event.start.toEpochMilliseconds()
|
||||
val endMillis = event.end.toEpochMilliseconds()
|
||||
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
||||
put(CalendarContract.Events.UID_2445, uid)
|
||||
put(CalendarContract.Events.TITLE, event.summary.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, startMillis)
|
||||
if (event.recurrenceRule == null) {
|
||||
put(CalendarContract.Events.DTEND, endMillis)
|
||||
} else {
|
||||
put(CalendarContract.Events.RRULE, event.recurrenceRule)
|
||||
put(
|
||||
CalendarContract.Events.DURATION,
|
||||
importDuration(startMillis, endMillis, event.isAllDay),
|
||||
)
|
||||
event.exDates.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.EXDATE, it.joinToString(",")) }
|
||||
}
|
||||
// All-day rows live at UTC midnights (the file already encodes them so);
|
||||
// timed rows keep the event's own zone.
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
|
||||
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
|
||||
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
|
||||
event.location?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||
event.description?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||
event.color?.let { color ->
|
||||
val key = palette.nearestTo(color)?.key
|
||||
putAll(eventColorColumns(colorKey = key, color = if (key == null) color else null))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider `DURATION` for an imported recurring row: whole days for an all-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
|
||||
* series would vanish (which is what a literal read of the Fossify family's
|
||||
* inclusive DTEND used to produce; see `IcsQuirks`).
|
||||
*/
|
||||
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
|
||||
val span = (endMillis - startMillis).coerceAtLeast(0)
|
||||
return if (isAllDay) "P${(span / 86_400_000L).coerceAtLeast(1)}D" else "P${span / 1_000L}S"
|
||||
}
|
||||
|
||||
private fun EventStatus.toProviderStatus(): Int = when (this) {
|
||||
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
|
||||
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
|
||||
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
|
||||
}
|
||||
|
||||
@@ -110,3 +110,17 @@ private const val CURATION_TRIGGER_SIZE = 36
|
||||
* apart that two swatches never read as the same colour in the grid.
|
||||
*/
|
||||
private const val MIN_DISTANCE = 0.025f
|
||||
|
||||
/**
|
||||
* The palette entry closest to [argb], or null when the account publishes no
|
||||
* palette (its calendars then take a raw `EVENT_COLOR` instead).
|
||||
*
|
||||
* Used to land a colour that came from outside the account — an imported `.ics`
|
||||
* carries an arbitrary ARGB, while a palette calendar only accepts one of its
|
||||
* own keys. Distance is measured in Oklab, so the match is the one that looks
|
||||
* closest rather than the one that is closest in RGB coordinates.
|
||||
*/
|
||||
fun List<EventColorOption>.nearestTo(argb: Int): EventColorOption? {
|
||||
val target = oklchOf(argb)
|
||||
return minByOrNull { oklchOf(it.argb).distanceTo(target) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
/** Opaque alpha, forced onto any colour that arrives without one. */
|
||||
private const val OPAQUE_ALPHA = 0xFF000000.toInt()
|
||||
|
||||
/**
|
||||
* Resolve a colour value out of a foreign `.ics` to an opaque ARGB int.
|
||||
*
|
||||
* Three encodings are in the wild and all appear in the files Calendula is
|
||||
* asked to import:
|
||||
* - a CSS3 colour name, which is what RFC 7986 `COLOR` is defined as
|
||||
* (`COLOR:tomato`);
|
||||
* - a raw signed Android colour int, which the Simple Calendar / Fossify
|
||||
* family writes in its `X-…-COLOR` extensions (`X-FOSSIFY-EVENT-COLOR:-2818048`);
|
||||
* - `#rrggbb` / `#aarrggbb` hex, used by assorted other producers.
|
||||
*
|
||||
* Anything else — including the literal `null` Fossify emits when its calendar
|
||||
* lookup misses — resolves to null.
|
||||
*/
|
||||
fun parseIcsColorValue(raw: String?): Int? {
|
||||
val value = raw?.trim().orEmpty()
|
||||
if (value.isEmpty()) return null
|
||||
|
||||
if (value.startsWith("#")) {
|
||||
val hex = value.substring(1)
|
||||
val parsed = hex.toLongOrNull(16)?.toInt() ?: return null
|
||||
return when (hex.length) {
|
||||
6 -> parsed or OPAQUE_ALPHA
|
||||
8 -> parsed
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
value.toIntOrNull()?.let { return if (it ushr 24 == 0) it or OPAQUE_ALPHA else it }
|
||||
return cssColors[value.lowercase()]
|
||||
}
|
||||
|
||||
/** The CSS3 extended colour keywords, the value space of RFC 7986 `COLOR`. */
|
||||
private val cssColors: Map<String, Int> by lazy {
|
||||
CSS3_TABLE.split(' ').associate { entry ->
|
||||
val name = entry.substringBefore('=')
|
||||
name to (entry.substringAfter('=').toInt(16) or OPAQUE_ALPHA)
|
||||
}
|
||||
}
|
||||
|
||||
private const val CSS3_TABLE =
|
||||
"aliceblue=f0f8ff antiquewhite=faebd7 aqua=00ffff aquamarine=7fffd4 " +
|
||||
"azure=f0ffff beige=f5f5dc bisque=ffe4c4 black=000000 blanchedalmond=ffebcd " +
|
||||
"blue=0000ff blueviolet=8a2be2 brown=a52a2a burlywood=deb887 cadetblue=5f9ea0 " +
|
||||
"chartreuse=7fff00 chocolate=d2691e coral=ff7f50 cornflowerblue=6495ed " +
|
||||
"cornsilk=fff8dc crimson=dc143c cyan=00ffff darkblue=00008b darkcyan=008b8b " +
|
||||
"darkgoldenrod=b8860b darkgray=a9a9a9 darkgreen=006400 darkgrey=a9a9a9 " +
|
||||
"darkkhaki=bdb76b darkmagenta=8b008b darkolivegreen=556b2f darkorange=ff8c00 " +
|
||||
"darkorchid=9932cc darkred=8b0000 darksalmon=e9967a darkseagreen=8fbc8f " +
|
||||
"darkslateblue=483d8b darkslategray=2f4f4f darkslategrey=2f4f4f darkturquoise=00ced1 " +
|
||||
"darkviolet=9400d3 deeppink=ff1493 deepskyblue=00bfff dimgray=696969 " +
|
||||
"dimgrey=696969 dodgerblue=1e90ff firebrick=b22222 floralwhite=fffaf0 " +
|
||||
"forestgreen=228b22 fuchsia=ff00ff gainsboro=dcdcdc ghostwhite=f8f8ff " +
|
||||
"gold=ffd700 goldenrod=daa520 gray=808080 green=008000 greenyellow=adff2f " +
|
||||
"grey=808080 honeydew=f0fff0 hotpink=ff69b4 indianred=cd5c5c indigo=4b0082 " +
|
||||
"ivory=fffff0 khaki=f0e68c lavender=e6e6fa lavenderblush=fff0f5 lawngreen=7cfc00 " +
|
||||
"lemonchiffon=fffacd lightblue=add8e6 lightcoral=f08080 lightcyan=e0ffff " +
|
||||
"lightgoldenrodyellow=fafad2 lightgray=d3d3d3 lightgreen=90ee90 lightgrey=d3d3d3 " +
|
||||
"lightpink=ffb6c1 lightsalmon=ffa07a lightseagreen=20b2aa lightskyblue=87cefa " +
|
||||
"lightslategray=778899 lightslategrey=778899 lightsteelblue=b0c4de " +
|
||||
"lightyellow=ffffe0 lime=00ff00 limegreen=32cd32 linen=faf0e6 magenta=ff00ff " +
|
||||
"maroon=800000 mediumaquamarine=66cdaa mediumblue=0000cd mediumorchid=ba55d3 " +
|
||||
"mediumpurple=9370db mediumseagreen=3cb371 mediumslateblue=7b68ee " +
|
||||
"mediumspringgreen=00fa9a mediumturquoise=48d1cc mediumvioletred=c71585 " +
|
||||
"midnightblue=191970 mintcream=f5fffa mistyrose=ffe4e1 moccasin=ffe4b5 " +
|
||||
"navajowhite=ffdead navy=000080 oldlace=fdf5e6 olive=808000 olivedrab=6b8e23 " +
|
||||
"orange=ffa500 orangered=ff4500 orchid=da70d6 palegoldenrod=eee8aa " +
|
||||
"palegreen=98fb98 paleturquoise=afeeee palevioletred=db7093 papayawhip=ffefd5 " +
|
||||
"peachpuff=ffdab9 peru=cd853f pink=ffc0cb plum=dda0dd powderblue=b0e0e6 " +
|
||||
"purple=800080 rebeccapurple=663399 red=ff0000 rosybrown=bc8f8f royalblue=4169e1 " +
|
||||
"saddlebrown=8b4513 salmon=fa8072 sandybrown=f4a460 seagreen=2e8b57 " +
|
||||
"seashell=fff5ee sienna=a0522d silver=c0c0c0 skyblue=87ceeb slateblue=6a5acd " +
|
||||
"slategray=708090 slategrey=708090 snow=fffafa springgreen=00ff7f " +
|
||||
"steelblue=4682b4 tan=d2b48c teal=008080 thistle=d8bfd8 tomato=ff6347 " +
|
||||
"turquoise=40e0d0 violet=ee82ee wheat=f5deb3 white=ffffff whitesmoke=f5f5f5 " +
|
||||
"yellow=ffff00 yellowgreen=9acd32"
|
||||
@@ -7,14 +7,25 @@ import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Milliseconds in a calendar day; all-day times are UTC midnights, so this is exact. */
|
||||
private const val DAY_MILLIS = 86_400_000L
|
||||
private const val MINUTES_PER_DAY = 1_440
|
||||
|
||||
/**
|
||||
* A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent],
|
||||
* but [uid] is nullable (an incoming event may carry none; the insert layer
|
||||
* then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror
|
||||
* how the writer encoded them.
|
||||
*
|
||||
* [reminderMinutes] holds raw lead times as the file's `VALARM`s expressed
|
||||
* them — minutes *before* start, so a negative entry means "after start".
|
||||
* [semanticReminderMinutes] turns those into the lead times Calendula models.
|
||||
*/
|
||||
data class ParsedIcsEvent(
|
||||
val uid: String?,
|
||||
@@ -30,8 +41,36 @@ data class ParsedIcsEvent(
|
||||
val status: EventStatus = EventStatus.Confirmed,
|
||||
val availability: Availability = Availability.Busy,
|
||||
val calendarName: String? = null,
|
||||
/** Opaque ARGB the source file gave this event, if any (see [parseIcsColorValue]). */
|
||||
val color: Int? = null,
|
||||
/** Excluded occurrences, already in the provider's `EXDATE` shape. */
|
||||
val exDates: List<String> = emptyList(),
|
||||
/** True when this came from a `VTODO` rather than a `VEVENT`. */
|
||||
val isTask: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* [reminderMinutes] mapped onto Calendula's model: a lead time in minutes before
|
||||
* the event, never negative.
|
||||
*
|
||||
* 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
|
||||
* `AllDayReminderEncoding`), so a raw offset is rounded to the nearest day —
|
||||
* which is also what makes Fossify's all-day encoding land correctly: it writes
|
||||
* "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.
|
||||
*/
|
||||
fun ParsedIcsEvent.semanticReminderMinutes(): List<Int> = reminderMinutes
|
||||
.map { raw ->
|
||||
if (isAllDay) {
|
||||
(raw.toDouble() / MINUTES_PER_DAY).roundToInt().coerceAtLeast(0) * MINUTES_PER_DAY
|
||||
} else {
|
||||
raw.coerceAtLeast(0)
|
||||
}
|
||||
}
|
||||
.distinct()
|
||||
.sorted()
|
||||
|
||||
/** Things the parser dropped rather than failing — surfaced in the import report. */
|
||||
enum class IcsParseWarning {
|
||||
/** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */
|
||||
@@ -45,6 +84,12 @@ enum class IcsParseWarning {
|
||||
|
||||
/** A `TZID` couldn't be resolved against the device tz database (used local zone). */
|
||||
UnknownTimezone,
|
||||
|
||||
/** `VTODO` components were imported as events — Calendula has no task model. */
|
||||
TasksImportedAsEvents,
|
||||
|
||||
/** A malformed `RRULE` was repaired or dropped (see [sanitizeRrule]). */
|
||||
RecurrenceRuleRepaired,
|
||||
}
|
||||
|
||||
data class IcsParseResult(
|
||||
@@ -52,8 +97,53 @@ data class IcsParseResult(
|
||||
val warnings: Set<IcsParseWarning>,
|
||||
)
|
||||
|
||||
/** Outcome of a bulk `.ics` import into one calendar. */
|
||||
data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
|
||||
/**
|
||||
* Outcome of a bulk `.ics` import into one calendar. [failed] counts events the
|
||||
* provider rejected — the import continues past them, so a single unusable
|
||||
* event can't cost the user the rest of the file.
|
||||
*/
|
||||
data class IcsImportSummary(
|
||||
val imported: Int,
|
||||
val skippedDuplicate: Int,
|
||||
val failed: Int = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* Producer-specific deviations a file has to be read through.
|
||||
*
|
||||
* The only one so far is the Simple Calendar / Fossify family's **inclusive**
|
||||
* all-day `DTEND`. Their exporter writes `DTEND;VALUE=DATE:` as the day code of
|
||||
* `endTS + 12h`, and stores `endTS` as midnight of an all-day event's *last*
|
||||
* day — so the property names the last occupied day, where RFC 5545 defines
|
||||
* `DTEND` as the first day *after* the event. Read literally, every one of
|
||||
* their all-day events is a day short and single-day ones (birthdays,
|
||||
* anniversaries, name days — the bulk of what people migrate) collapse to zero
|
||||
* length, which the calendar provider then expands into nothing.
|
||||
*/
|
||||
data class IcsQuirks(val inclusiveAllDayEnd: Boolean = false) {
|
||||
companion object {
|
||||
private val INCLUSIVE_END_PRODUCERS = listOf("fossify", "simple mobile tools", "simplemobiletools")
|
||||
|
||||
/**
|
||||
* Sniff [lines] for a known producer. The `PRODID` is the primary
|
||||
* signal; the `X-FOSSIFY-*` / `X-SMT-*` extensions catch files that
|
||||
* passed through a tool which rewrote the `PRODID` but kept the body.
|
||||
*/
|
||||
fun detect(lines: List<String>): IcsQuirks {
|
||||
val inclusive = lines.any { line ->
|
||||
when {
|
||||
line.startsWith("PRODID", ignoreCase = true) -> {
|
||||
val lower = line.lowercase()
|
||||
INCLUSIVE_END_PRODUCERS.any { it in lower }
|
||||
}
|
||||
else -> line.startsWith("X-FOSSIFY-", ignoreCase = true) ||
|
||||
line.startsWith("X-SMT-", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
return IcsQuirks(inclusiveAllDayEnd = inclusive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
|
||||
@@ -62,44 +152,62 @@ data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
|
||||
* (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings]
|
||||
* rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is
|
||||
* resolved against the OS tz database instead ([deviceZone] is the fallback).
|
||||
*
|
||||
* `VTODO` components are read as all-day/timed events: Calendula models no
|
||||
* tasks, and dropping them would silently lose half of some exports.
|
||||
*/
|
||||
class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) {
|
||||
|
||||
fun parse(text: String): IcsParseResult {
|
||||
val lines = unfoldLines(text)
|
||||
val quirks = IcsQuirks.detect(lines)
|
||||
val events = mutableListOf<ParsedIcsEvent>()
|
||||
val warnings = mutableSetOf<IcsParseWarning>()
|
||||
var calendarName: String? = null
|
||||
// Scanned up front rather than in document order: an X-WR-CALNAME after
|
||||
// the first VEVENT still names the calendar every event came from.
|
||||
val calendarName = lines.asSequence()
|
||||
.mapNotNull(::parseContentLine)
|
||||
.firstOrNull { it.name == "X-WR-CALNAME" }
|
||||
?.let { unescapeText(it.value).trim().ifEmpty { null } }
|
||||
|
||||
var i = 0
|
||||
while (i < lines.size) {
|
||||
val line = parseContentLine(lines[i])
|
||||
if (line == null) { i++; continue }
|
||||
val component = when {
|
||||
line.isBegin("VEVENT") -> "VEVENT"
|
||||
line.isBegin("VTODO") -> "VTODO"
|
||||
else -> null
|
||||
}
|
||||
when {
|
||||
line.isBegin("VEVENT") -> {
|
||||
val end = indexOfEnd(lines, i + 1, "VEVENT")
|
||||
parseVevent(lines.subList(i + 1, end), calendarName, warnings)
|
||||
?.let(events::add)
|
||||
component != null -> {
|
||||
val end = indexOfEnd(lines, i + 1, component)
|
||||
parseComponent(
|
||||
body = lines.subList(i + 1, end),
|
||||
fileCalendarName = calendarName,
|
||||
warnings = warnings,
|
||||
quirks = quirks,
|
||||
isTask = component == "VTODO",
|
||||
)?.let(events::add)
|
||||
i = end + 1
|
||||
}
|
||||
line.isBegin("VTIMEZONE") -> {
|
||||
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
||||
i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1
|
||||
}
|
||||
line.name == "X-WR-CALNAME" -> {
|
||||
calendarName = unescapeText(line.value).trim().ifEmpty { null }
|
||||
i++
|
||||
}
|
||||
else -> i++
|
||||
}
|
||||
}
|
||||
if (events.any { it.isTask }) warnings.add(IcsParseWarning.TasksImportedAsEvents)
|
||||
return IcsParseResult(events, warnings)
|
||||
}
|
||||
|
||||
private fun parseVevent(
|
||||
private fun parseComponent(
|
||||
body: List<String>,
|
||||
fileCalendarName: String?,
|
||||
warnings: MutableSet<IcsParseWarning>,
|
||||
quirks: IcsQuirks,
|
||||
isTask: Boolean,
|
||||
): ParsedIcsEvent? {
|
||||
var uid: String? = null
|
||||
var summary = ""
|
||||
@@ -112,7 +220,10 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
var status = EventStatus.Confirmed
|
||||
var availability = Availability.Busy
|
||||
var calendarName = fileCalendarName
|
||||
var eventColor: Int? = null
|
||||
var calendarColor: Int? = null
|
||||
val reminders = mutableListOf<Int>()
|
||||
val exDateLines = mutableListOf<IcsContentLine>()
|
||||
var skipAsOverride = false
|
||||
|
||||
var i = 0
|
||||
@@ -129,9 +240,12 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
"UID" -> uid = line.value.trim().ifEmpty { null }
|
||||
"SUMMARY" -> summary = unescapeText(line.value)
|
||||
"DTSTART" -> dtStart = parseIcsDateTime(line, warnings)
|
||||
// A VTODO has no DTSTART of its own in most producers' output.
|
||||
"DUE" -> if (isTask && dtStart == null) dtStart = parseIcsDateTime(line, warnings)
|
||||
"DTEND" -> dtEnd = parseIcsDateTime(line, warnings)
|
||||
"DURATION" -> duration = line.value.trim()
|
||||
"RRULE" -> rrule = line.value.trim().ifEmpty { null }
|
||||
"EXDATE" -> exDateLines.add(line)
|
||||
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
|
||||
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
|
||||
"STATUS" -> status = mapIcsStatus(line.value)
|
||||
@@ -142,6 +256,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
|
||||
"X-CALENDULA-CALENDAR" ->
|
||||
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
|
||||
// Fossify names the source calendar per event; it never writes
|
||||
// an X-WR-CALNAME, so this is the only calendar label its files carry.
|
||||
"CATEGORIES" -> calendarName = unescapeText(line.value).trim()
|
||||
.takeIf { it.isNotEmpty() && !it.equals("null", true) }
|
||||
?: calendarName
|
||||
// Per-event colour, most specific encoding first (see parseIcsColorValue).
|
||||
"X-FOSSIFY-EVENT-COLOR", "X-SMT-EVENT-COLOR" ->
|
||||
eventColor = parseIcsColorValue(line.value) ?: eventColor
|
||||
"COLOR" -> eventColor = eventColor ?: parseIcsColorValue(line.value)
|
||||
// The *calendar's* colour. Kept as a fallback so a migration that
|
||||
// folds several source calendars into one keeps them apart visually.
|
||||
"X-FOSSIFY-CATEGORY-COLOR", "X-SMT-CATEGORY-COLOR", "CATEGORY_COLOR" ->
|
||||
calendarColor = parseIcsColorValue(line.value) ?: calendarColor
|
||||
}
|
||||
i++
|
||||
}
|
||||
@@ -154,33 +281,104 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
warnings.add(IcsParseWarning.EventWithoutStartSkipped)
|
||||
return null
|
||||
}
|
||||
val end = dtEnd
|
||||
?: duration?.let {
|
||||
start.copy(
|
||||
instant = Instant.fromEpochMilliseconds(
|
||||
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
|
||||
),
|
||||
)
|
||||
val cleanRrule = sanitizeRrule(rrule)
|
||||
if (rrule != null && cleanRrule != rrule.removePrefix("RRULE:").trim()) {
|
||||
warnings.add(IcsParseWarning.RecurrenceRuleRepaired)
|
||||
}
|
||||
?: start
|
||||
val end = resolveEnd(start, dtEnd, duration, quirks)
|
||||
return ParsedIcsEvent(
|
||||
uid = uid,
|
||||
summary = summary,
|
||||
start = start.instant,
|
||||
end = end.instant,
|
||||
end = end,
|
||||
isAllDay = start.isAllDay,
|
||||
zoneId = start.zoneId,
|
||||
recurrenceRule = rrule,
|
||||
recurrenceRule = cleanRrule,
|
||||
location = location,
|
||||
description = description,
|
||||
reminderMinutes = reminders.distinct(),
|
||||
status = status,
|
||||
availability = availability,
|
||||
calendarName = calendarName,
|
||||
color = eventColor ?: calendarColor,
|
||||
exDates = if (cleanRrule == null) emptyList() else normalizeExDates(exDateLines, start),
|
||||
isTask = isTask,
|
||||
)
|
||||
}
|
||||
|
||||
/** A VALARM's lead time in minutes before start, or null if not a usable relative trigger. */
|
||||
/**
|
||||
* The event's end instant, correcting the two ways a file can leave an
|
||||
* all-day event empty: the inclusive-`DTEND` dialect ([IcsQuirks]), and RFC
|
||||
* 5545 §3.6.1's rule that a `DATE`-valued `DTSTART` with no `DTEND` lasts one
|
||||
* day. The final clamp also catches producers not covered by either.
|
||||
*/
|
||||
private fun resolveEnd(
|
||||
start: IcsDateTime,
|
||||
dtEnd: IcsDateTime?,
|
||||
duration: String?,
|
||||
quirks: IcsQuirks,
|
||||
): Instant {
|
||||
val explicit = dtEnd?.instant ?: duration?.let {
|
||||
Instant.fromEpochMilliseconds(
|
||||
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
|
||||
)
|
||||
}
|
||||
if (!start.isAllDay) return explicit ?: start.instant
|
||||
val adjusted = when {
|
||||
dtEnd != null && quirks.inclusiveAllDayEnd -> dtEnd.instant.plusDays(1)
|
||||
else -> explicit
|
||||
}
|
||||
return adjusted?.takeIf { it > start.instant } ?: start.instant.plusDays(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* `EXDATE` values in the shape `Events.EXDATE` wants: `yyyyMMdd` for an
|
||||
* all-day series, a UTC `yyyyMMddTHHmmssZ` stamp otherwise.
|
||||
*
|
||||
* Fossify stores its excluded occurrences as bare day codes and writes them
|
||||
* out that way even for timed series, where the property is then ambiguous.
|
||||
* Such a value is resolved against the series' own time of day, which is
|
||||
* what it meant — its repetitions never move within the day.
|
||||
*/
|
||||
private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> {
|
||||
if (lines.isEmpty()) return emptyList()
|
||||
val startZone = runCatching { TimeZone.of(start.zoneId) }.getOrNull() ?: deviceZone
|
||||
val startTime = start.instant.toLocalDateTime(startZone).time
|
||||
return lines
|
||||
.flatMap { line -> line.value.split(',').map { line to it.trim() } }
|
||||
.mapNotNull { (line, token) ->
|
||||
if (token.isEmpty()) return@mapNotNull null
|
||||
if (token.contains('T')) {
|
||||
val instant = parseExDateTime(token, line) ?: return@mapNotNull null
|
||||
if (start.isAllDay) utcDayCode(instant) else utcStamp(instant)
|
||||
} else {
|
||||
val date = parseBasicDate(token) ?: return@mapNotNull null
|
||||
if (start.isAllDay) {
|
||||
utcDayCode(date.atStartOfDayIn(TimeZone.UTC))
|
||||
} else {
|
||||
utcStamp(LocalDateTime(date, startTime).toInstant(startZone))
|
||||
}
|
||||
}
|
||||
}
|
||||
.distinct()
|
||||
}
|
||||
|
||||
private fun parseExDateTime(token: String, line: IcsContentLine): Instant? {
|
||||
val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null
|
||||
val zone = when {
|
||||
token.endsWith("Z") -> TimeZone.UTC
|
||||
else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() }
|
||||
?: deviceZone
|
||||
}
|
||||
return ldt.toInstant(zone)
|
||||
}
|
||||
|
||||
/**
|
||||
* A `VALARM`'s offset from the event start, in minutes *before* it — so a
|
||||
* trigger that fires after the start (which is how the Fossify family
|
||||
* encodes "on the day at 09:00") comes back negative. Null when the trigger
|
||||
* isn't a usable relative offset.
|
||||
*/
|
||||
private fun parseAlarmMinutes(body: List<String>): Int? {
|
||||
val trigger = body.asSequence()
|
||||
.mapNotNull { parseContentLine(it) }
|
||||
@@ -189,8 +387,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
// Absolute (DATE-TIME) triggers can't be expressed as a lead time.
|
||||
if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null
|
||||
val millis = parseRfc2445DurationMillis(trigger.value)
|
||||
// Negative = before start (the normal case) → positive lead minutes.
|
||||
return (-millis / 60_000L).toInt().coerceAtLeast(0)
|
||||
return (-millis / 60_000L).toInt()
|
||||
}
|
||||
|
||||
private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet<IcsParseWarning>): IcsDateTime? {
|
||||
@@ -218,6 +415,17 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
fun IcsContentLine.isBegin(component: String) =
|
||||
name == "BEGIN" && value.trim().equals(component, true)
|
||||
|
||||
fun Instant.plusDays(days: Int): Instant =
|
||||
Instant.fromEpochMilliseconds(toEpochMilliseconds() + days * DAY_MILLIS)
|
||||
|
||||
fun utcDayCode(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) {
|
||||
"%04d%02d%02d".format(year, month.number, day)
|
||||
}
|
||||
|
||||
fun utcStamp(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) {
|
||||
"%04d%02d%02dT%02d%02d%02dZ".format(year, month.number, day, hour, minute, second)
|
||||
}
|
||||
|
||||
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
||||
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
||||
var i = from
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
private val VALID_FREQ = setOf(
|
||||
"SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY",
|
||||
)
|
||||
|
||||
/** RRULE parts whose value is a comma-separated list, so empty items can be dropped. */
|
||||
private val LIST_PARTS = setOf(
|
||||
"BYSECOND", "BYMINUTE", "BYHOUR", "BYDAY", "BYMONTHDAY", "BYYEARDAY",
|
||||
"BYWEEKNO", "BYMONTH", "BYSETPOS",
|
||||
)
|
||||
|
||||
/**
|
||||
* Make a foreign RRULE safe to hand `CalendarContract`, or return null when it
|
||||
* can't be salvaged.
|
||||
*
|
||||
* The provider validates every RRULE through `EventRecurrence.parse`, which
|
||||
* **throws** on a malformed rule — and the throw surfaces from `insert`, not
|
||||
* from a later read. One bad rule in an imported file would therefore abort the
|
||||
* write it rides on. Producers do emit such rules: Fossify's exporter writes a
|
||||
* bare `;BYDAY=` whenever a weekly event carries no weekday mask
|
||||
* (`Parser.getByDay` interpolates an empty day string unconditionally), which is
|
||||
* the state every weekly event it imported from elsewhere is left in.
|
||||
*
|
||||
* Empty and malformed parts are dropped rather than failing the rule; only a
|
||||
* missing or unrecognised `FREQ` — without which there is no rule at all —
|
||||
* returns null.
|
||||
*/
|
||||
fun sanitizeRrule(raw: String?): String? {
|
||||
val rule = raw?.trim()?.removePrefix("RRULE:")?.trim().orEmpty()
|
||||
if (rule.isEmpty()) return null
|
||||
|
||||
var freqSeen = false
|
||||
val parts = rule.split(';').mapNotNull { part ->
|
||||
val key = part.substringBefore('=', "").trim().uppercase()
|
||||
val value = if ('=' in part) part.substringAfter('=').trim() else ""
|
||||
if (key.isEmpty() || value.isEmpty()) return@mapNotNull null
|
||||
when {
|
||||
key == "FREQ" -> {
|
||||
if (value.uppercase() !in VALID_FREQ) return null
|
||||
freqSeen = true
|
||||
"FREQ=${value.uppercase()}"
|
||||
}
|
||||
// A non-positive or non-numeric INTERVAL is rejected by the provider.
|
||||
key == "INTERVAL" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "INTERVAL=$it" }
|
||||
key in LIST_PARTS -> value.split(',')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { "$key=${it.joinToString(",")}" }
|
||||
else -> "$key=$value"
|
||||
}
|
||||
}
|
||||
return if (freqSeen) parts.joinToString(";") else null
|
||||
}
|
||||
@@ -13,6 +13,11 @@ import kotlinx.datetime.toLocalDateTime
|
||||
* calendar, exactly like a fresh create — the user confirms the target and
|
||||
* reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day
|
||||
* handling (provider all-day times are UTC midnights with an exclusive end).
|
||||
*
|
||||
* [ParsedIcsEvent.color] is deliberately not carried over: picking the calendar
|
||||
* is the first thing this form asks for, and that clears any colour (a raw ARGB
|
||||
* is invalid on a calendar whose account publishes a palette). Bulk import,
|
||||
* where the target is known up front, does keep it.
|
||||
*/
|
||||
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||
val (start, end) = if (isAllDay) {
|
||||
@@ -31,7 +36,7 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||
end = end,
|
||||
location = location.orEmpty(),
|
||||
description = description.orEmpty(),
|
||||
reminders = reminderMinutes.distinct().sorted(),
|
||||
reminders = semanticReminderMinutes(),
|
||||
availability = availability,
|
||||
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
|
||||
@@ -252,6 +252,15 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
if (state.summary.failed > 0) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
stringResource(R.string.import_done_failed_note),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
if (state.summary.skippedDuplicate > 0) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
@@ -290,6 +299,19 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
||||
onContainer = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (state.summary.failed > 0) {
|
||||
ImportStatCard(
|
||||
count = state.summary.failed,
|
||||
label = stringResource(R.string.import_done_failed_label),
|
||||
contentDescription = pluralStringResource(
|
||||
R.plurals.import_done_failed,
|
||||
state.summary.failed,
|
||||
state.summary.failed,
|
||||
),
|
||||
container = MaterialTheme.colorScheme.errorContainer,
|
||||
onContainer = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Button(
|
||||
@@ -301,7 +323,7 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** A big-number tonal tile summarising one import outcome (added / skipped). */
|
||||
/** A big-number tonal tile summarising one import outcome (added / skipped / failed). */
|
||||
@Composable
|
||||
private fun RowScope.ImportStatCard(
|
||||
count: Int,
|
||||
@@ -343,6 +365,9 @@ private fun WarningText(warning: IcsParseWarning) {
|
||||
IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start)
|
||||
IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees)
|
||||
IcsParseWarning.UnknownTimezone -> stringResource(R.string.import_warning_timezone)
|
||||
IcsParseWarning.TasksImportedAsEvents -> stringResource(R.string.import_warning_tasks)
|
||||
IcsParseWarning.RecurrenceRuleRepaired ->
|
||||
stringResource(R.string.import_warning_recurrence_repaired)
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
|
||||
@@ -693,11 +693,15 @@
|
||||
<string name="import_done_dedup_note">Events already in the calendar were skipped.</string>
|
||||
<string name="import_done_added_label">Added</string>
|
||||
<string name="import_done_skipped_label">Duplicates</string>
|
||||
<string name="import_done_failed_label">Not added</string>
|
||||
<string name="import_done_failed_note">Some events couldn\'t be added and were left out.</string>
|
||||
<string name="import_close">Close</string>
|
||||
<string name="import_warning_recurrence">Some changed occurrences of recurring events were skipped.</string>
|
||||
<string name="import_warning_no_start">An event without a start time was skipped.</string>
|
||||
<string name="import_warning_attendees">Guest lists weren\'t imported.</string>
|
||||
<string name="import_warning_timezone">An unknown time zone fell back to your device\'s.</string>
|
||||
<string name="import_warning_tasks">Tasks in this file were added as events.</string>
|
||||
<string name="import_warning_recurrence_repaired">A faulty repeat rule was repaired.</string>
|
||||
<string name="import_button">Import</string>
|
||||
<plurals name="import_title_count">
|
||||
<item quantity="one">Importing %d event</item>
|
||||
@@ -719,6 +723,10 @@
|
||||
<item quantity="one">Skipped %d already in this calendar.</item>
|
||||
<item quantity="other">Skipped %d already in this calendar.</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_failed">
|
||||
<item quantity="one">%d event couldn\'t be added.</item>
|
||||
<item quantity="other">%d events couldn\'t be added.</item>
|
||||
</plurals>
|
||||
<!-- Launcher long-press shortcuts -->
|
||||
<string name="shortcut_new_event_short">New event</string>
|
||||
<string name="shortcut_new_event_long">Create a new event</string>
|
||||
|
||||
@@ -801,6 +801,42 @@ class CalendarRepositoryImplTest {
|
||||
assertThat(fake.importedEvents.map { it.second }).containsExactly(3L, 3L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `importEvents carries on past an event the provider rejects`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
// A foreign export can hold a row the provider throws on outright; that
|
||||
// must cost the user one event, not the whole file (Codeberg #225).
|
||||
val fake = FakeCalendarDataSource().apply { failingImportSummaries += "bad" }
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
val events = listOf(
|
||||
parsedEvent("a@x", summary = "good"),
|
||||
parsedEvent("b@x", summary = "bad"),
|
||||
parsedEvent("c@x", summary = "good"),
|
||||
)
|
||||
|
||||
val summary = repo.importEvents(targetCalendarId = 3L, events = events)
|
||||
|
||||
assertThat(summary.imported).isEqualTo(2)
|
||||
assertThat(summary.failed).isEqualTo(1)
|
||||
assertThat(fake.importedEvents.map { it.first.uid }).containsExactly("a@x", "c@x")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `importEvents looks the target palette up once, not per event`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
var lookups = 0
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventColorPaletteResult = { lookups++; emptyList() }
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
repo.importEvents(3L, List(5) { parsedEvent("e$it@x") })
|
||||
|
||||
assertThat(lookups).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exportEvents forwards the chosen calendar-id subset to the data source`(
|
||||
@TempDir tempDir: Path,
|
||||
@@ -825,9 +861,10 @@ class CalendarRepositoryImplTest {
|
||||
assertThat(fake.lastExportableEventsCalendarIds).isNull()
|
||||
}
|
||||
|
||||
private fun parsedEvent(uid: String?) = de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
|
||||
private fun parsedEvent(uid: String?, summary: String = "E") =
|
||||
de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
|
||||
uid = uid,
|
||||
summary = "E",
|
||||
summary = summary,
|
||||
start = Instant.fromEpochMilliseconds(1_000_000_000L),
|
||||
end = Instant.fromEpochMilliseconds(1_000_003_600L),
|
||||
isAllDay = false,
|
||||
|
||||
@@ -91,8 +91,17 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
/** (event, targetCalendarId) pairs passed to [insertImportedEvent]. */
|
||||
val importedEvents = mutableListOf<Pair<ParsedIcsEvent, Long>>()
|
||||
|
||||
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
|
||||
/** Thrown instead of [writeError] for events whose summary is in this set. */
|
||||
val failingImportSummaries = mutableSetOf<String>()
|
||||
|
||||
override fun insertImportedEvent(
|
||||
event: ParsedIcsEvent,
|
||||
calendarId: Long,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
colorPalette: List<EventColorOption>,
|
||||
): Long {
|
||||
writeError?.let { throw it }
|
||||
if (event.summary in failingImportSummaries) error("rejected: ${event.summary}")
|
||||
importedEvents += event to calendarId
|
||||
return nextInsertId
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
class ImportedEventValuesTest {
|
||||
|
||||
private val dayStart = Instant.parse("2026-08-19T00:00:00Z")
|
||||
|
||||
private fun allDay(
|
||||
rrule: String? = null,
|
||||
exDates: List<String> = emptyList(),
|
||||
color: Int? = null,
|
||||
days: Int = 1,
|
||||
) = ParsedIcsEvent(
|
||||
uid = "u@x",
|
||||
summary = "Anna Birthday",
|
||||
start = dayStart,
|
||||
end = Instant.fromEpochMilliseconds(dayStart.toEpochMilliseconds() + days * 86_400_000L),
|
||||
isAllDay = true,
|
||||
zoneId = "UTC",
|
||||
recurrenceRule = rrule,
|
||||
exDates = exDates,
|
||||
color = color,
|
||||
)
|
||||
|
||||
private fun values(event: ParsedIcsEvent, palette: List<EventColorOption> = emptyList()) =
|
||||
buildImportedEventValues(event, calendarId = 7L, uid = "u@x", palette = palette)
|
||||
|
||||
@Test
|
||||
fun `a one-off carries DTEND and no recurrence`() {
|
||||
val v = values(allDay())
|
||||
assertThat(v[CalendarContract.Events.DTEND])
|
||||
.isEqualTo(dayStart.toEpochMilliseconds() + 86_400_000L)
|
||||
assertThat(v).doesNotContainKey(CalendarContract.Events.DURATION)
|
||||
assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
||||
assertThat(v[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a recurring row carries DURATION instead of DTEND`() {
|
||||
val v = values(allDay(rrule = "FREQ=YEARLY;INTERVAL=1"))
|
||||
assertThat(v).doesNotContainKey(CalendarContract.Events.DTEND)
|
||||
assertThat(v[CalendarContract.Events.RRULE]).isEqualTo("FREQ=YEARLY;INTERVAL=1")
|
||||
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day series is never zero days long`() {
|
||||
// A degenerate span would expand into no instances at all — the series
|
||||
// would simply not exist (Codeberg #225).
|
||||
val v = values(allDay(rrule = "FREQ=YEARLY;INTERVAL=1", days = 0))
|
||||
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EXDATEs ride along with the recurrence`() {
|
||||
val v = values(allDay(rrule = "FREQ=DAILY", exDates = listOf("20260820", "20260822")))
|
||||
assertThat(v[CalendarContract.Events.EXDATE]).isEqualTo("20260820,20260822")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EXDATEs are not written without a recurrence to exclude from`() {
|
||||
val v = values(allDay(exDates = listOf("20260820")))
|
||||
assertThat(v).doesNotContainKey(CalendarContract.Events.EXDATE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an imported colour is written raw when the account publishes no palette`() {
|
||||
val v = values(allDay(color = -2818048))
|
||||
assertThat(v[CalendarContract.Events.EVENT_COLOR]).isEqualTo(-2818048)
|
||||
assertThat(v[CalendarContract.Events.EVENT_COLOR_KEY]).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an imported colour snaps to the nearest published palette key`() {
|
||||
val palette = listOf(
|
||||
EventColorOption(key = "1", argb = 0xFF0000FF.toInt()), // blue
|
||||
EventColorOption(key = "2", argb = 0xFFFF4500.toInt()), // orangered
|
||||
EventColorOption(key = "3", argb = 0xFF008000.toInt()), // green
|
||||
)
|
||||
// Tomato — visually an orangered, nothing like the blue or the green.
|
||||
val v = values(allDay(color = 0xFFFF6347.toInt()), palette)
|
||||
|
||||
assertThat(v[CalendarContract.Events.EVENT_COLOR_KEY]).isEqualTo("2")
|
||||
// A raw colour alongside a key is what a palette calendar rejects.
|
||||
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event with no colour touches neither colour column`() {
|
||||
val v = values(allDay())
|
||||
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
|
||||
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed event keeps its own zone and a seconds duration`() {
|
||||
val start = Instant.parse("2026-08-19T08:00:00Z")
|
||||
val event = ParsedIcsEvent(
|
||||
uid = "u@x",
|
||||
summary = "Standup",
|
||||
start = start,
|
||||
end = Instant.parse("2026-08-19T08:15:00Z"),
|
||||
isAllDay = false,
|
||||
zoneId = "Europe/Berlin",
|
||||
recurrenceRule = "FREQ=WEEKLY",
|
||||
)
|
||||
|
||||
val v = values(event)
|
||||
assertThat(v[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
||||
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P900S")
|
||||
assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class IcsColorTest {
|
||||
|
||||
@Test
|
||||
fun `a raw Android colour int round-trips`() {
|
||||
assertThat(parseIcsColorValue("-2818048")).isEqualTo(-2818048)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a CSS3 name resolves opaque`() {
|
||||
assertThat(parseIcsColorValue("tomato")).isEqualTo(0xFFFF6347.toInt())
|
||||
assertThat(parseIcsColorValue("REBECCAPURPLE")).isEqualTo(0xFF663399.toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hex forms are accepted with and without alpha`() {
|
||||
assertThat(parseIcsColorValue("#ff6347")).isEqualTo(0xFFFF6347.toInt())
|
||||
assertThat(parseIcsColorValue("#80ff6347")).isEqualTo(0x80FF6347.toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a colour with no alpha byte is made opaque`() {
|
||||
// 0x00FF6347 as a decimal int — an RGB triple that lost its alpha.
|
||||
assertThat(parseIcsColorValue("16737095")).isEqualTo(0xFFFF6347.toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unusable values resolve to null`() {
|
||||
assertThat(parseIcsColorValue(null)).isNull()
|
||||
assertThat(parseIcsColorValue("")).isNull()
|
||||
// Fossify interpolates a nullable calendar straight into the property.
|
||||
assertThat(parseIcsColorValue("null")).isNull()
|
||||
assertThat(parseIcsColorValue("chartreusey")).isNull()
|
||||
assertThat(parseIcsColorValue("#abc")).isNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.TimeZone
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The Simple Calendar / Fossify export dialect (Codeberg #225). Every fixture
|
||||
* here is shaped exactly like `IcsExporter.writeEvent` emits it — property
|
||||
* order, the `X-FOSSIFY-*` extensions, and the inclusive all-day `DTEND`
|
||||
* included.
|
||||
*/
|
||||
class IcsFossifyImportTest {
|
||||
|
||||
private val parser = IcsParser(TimeZone.of("Europe/Berlin"))
|
||||
|
||||
private fun fossify(vararg body: String) = (
|
||||
listOf(
|
||||
"BEGIN:VCALENDAR",
|
||||
"PRODID:-//Fossify//NONSGML Event Calendar//EN",
|
||||
"VERSION:2.0",
|
||||
) + body + listOf("END:VCALENDAR")
|
||||
).joinToString("\r\n")
|
||||
|
||||
private fun days(event: ParsedIcsEvent): Long =
|
||||
(event.end - event.start).inWholeMilliseconds / 86_400_000L
|
||||
|
||||
@Test
|
||||
fun `single-day all-day event is one day long, not zero`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Anna Birthday",
|
||||
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
|
||||
"DTSTART;VALUE=DATE:19900412",
|
||||
// Fossify names the LAST occupied day here, not the day after.
|
||||
"DTEND;VALUE=DATE:19900412",
|
||||
"RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
val event = result.events.single()
|
||||
assertThat(event.isAllDay).isTrue()
|
||||
assertThat(days(event)).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multi-day all-day event keeps its last day`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Holiday",
|
||||
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
|
||||
// 19th to 21st inclusive — three days.
|
||||
"DTSTART;VALUE=DATE:20260819",
|
||||
"DTEND;VALUE=DATE:20260821",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(days(result.events.single())).isEqualTo(3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an RFC-conformant producer's exclusive DTEND is left alone`() {
|
||||
val result = IcsParser(TimeZone.UTC).parse(
|
||||
listOf(
|
||||
"BEGIN:VCALENDAR",
|
||||
"PRODID:$ICS_PROD_ID",
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Holiday",
|
||||
"DTSTART;VALUE=DATE:20260819",
|
||||
"DTEND;VALUE=DATE:20260821",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
).joinToString("\r\n"),
|
||||
)
|
||||
|
||||
assertThat(days(result.events.single())).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day event with no DTEND lasts one day`() {
|
||||
val result = IcsParser(TimeZone.UTC).parse(
|
||||
listOf(
|
||||
"BEGIN:VCALENDAR",
|
||||
"PRODID:-//Some Other App//EN",
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Holiday",
|
||||
"DTSTART;VALUE=DATE:20260819",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
).joinToString("\r\n"),
|
||||
)
|
||||
|
||||
assertThat(days(result.events.single())).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero-length all-day event from an unknown producer is still widened`() {
|
||||
val result = IcsParser(TimeZone.UTC).parse(
|
||||
listOf(
|
||||
"BEGIN:VCALENDAR",
|
||||
"PRODID:-//Some Other App//EN",
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Holiday",
|
||||
"DTSTART;VALUE=DATE:20260819",
|
||||
"DTEND;VALUE=DATE:20260819",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
).joinToString("\r\n"),
|
||||
)
|
||||
|
||||
assertThat(days(result.events.single())).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the dialect is recognised from the X-FOSSIFY extensions alone`() {
|
||||
val result = parser.parse(
|
||||
listOf(
|
||||
"BEGIN:VCALENDAR",
|
||||
"PRODID:-//Rewritten By Something Else//EN",
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Holiday",
|
||||
"X-FOSSIFY-MISSING-YEAR:0",
|
||||
"DTSTART;VALUE=DATE:20260819",
|
||||
"DTEND;VALUE=DATE:20260821",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
).joinToString("\r\n"),
|
||||
)
|
||||
|
||||
assertThat(days(result.events.single())).isEqualTo(3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty BYDAY is repaired rather than passed to the provider`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Standup",
|
||||
"DTSTART:20260819T080000Z",
|
||||
"DTEND:20260819T081500Z",
|
||||
// Fossify writes this whenever a weekly event has no weekday mask.
|
||||
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().recurrenceRule).isEqualTo("FREQ=WEEKLY;INTERVAL=1")
|
||||
assertThat(result.warnings).contains(IcsParseWarning.RecurrenceRuleRepaired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a task is imported as an event and reported`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VTODO",
|
||||
"SUMMARY:Pay rent",
|
||||
"DTSTART;VALUE=DATE:20260901",
|
||||
"END:VTODO",
|
||||
),
|
||||
)
|
||||
|
||||
val task = result.events.single()
|
||||
assertThat(task.isTask).isTrue()
|
||||
assertThat(task.summary).isEqualTo("Pay rent")
|
||||
assertThat(days(task)).isEqualTo(1)
|
||||
assertThat(result.warnings).contains(IcsParseWarning.TasksImportedAsEvents)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a task dated only by DUE still imports`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VTODO",
|
||||
"SUMMARY:File taxes",
|
||||
"DUE;VALUE=DATE:20260901",
|
||||
"END:VTODO",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().summary).isEqualTo("File taxes")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the per-event colour wins over the calendar colour`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Holiday",
|
||||
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
|
||||
"COLOR:tomato",
|
||||
"X-FOSSIFY-EVENT-COLOR:-2818048",
|
||||
"DTSTART:20260819T080000Z",
|
||||
"DTEND:20260819T090000Z",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().color).isEqualTo(-2818048)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the calendar colour stands in when the event has none of its own`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Holiday",
|
||||
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
|
||||
"DTSTART:20260819T080000Z",
|
||||
"DTEND:20260819T090000Z",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().color).isEqualTo(-1155931)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the literal null Fossify writes for a missing calendar is not a colour`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Holiday",
|
||||
"X-FOSSIFY-CATEGORY-COLOR:null",
|
||||
"CATEGORIES:null",
|
||||
"DTSTART:20260819T080000Z",
|
||||
"DTEND:20260819T090000Z",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
val event = result.events.single()
|
||||
assertThat(event.color).isNull()
|
||||
assertThat(event.calendarName).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CATEGORIES names the source calendar`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Anna Birthday",
|
||||
"CATEGORIES:Birthdays",
|
||||
"DTSTART;VALUE=DATE:20260819",
|
||||
"DTEND;VALUE=DATE:20260819",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().calendarName).isEqualTo("Birthdays")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day reminder written as a positive trigger lands on the day`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Anna Birthday",
|
||||
"DTSTART;VALUE=DATE:20260819",
|
||||
"DTEND;VALUE=DATE:20260819",
|
||||
"BEGIN:VALARM",
|
||||
"ACTION:DISPLAY",
|
||||
// Fossify's "on the day at 09:00" — nine hours AFTER the start.
|
||||
"TRIGGER:P0DT9H0M0S",
|
||||
"END:VALARM",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
val event = result.events.single()
|
||||
assertThat(event.reminderMinutes).containsExactly(-540)
|
||||
assertThat(event.semanticReminderMinutes()).containsExactly(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day reminder a day and a half out rounds to whole days before`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Anna Birthday",
|
||||
"DTSTART;VALUE=DATE:20260819",
|
||||
"DTEND;VALUE=DATE:20260819",
|
||||
"BEGIN:VALARM",
|
||||
"ACTION:DISPLAY",
|
||||
// 15h before midnight == "1 day before at 09:00".
|
||||
"TRIGGER:-P0DT15H0M0S",
|
||||
"END:VALARM",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(1440)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed reminder keeps its exact lead time`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Standup",
|
||||
"DTSTART:20260819T080000Z",
|
||||
"DTEND:20260819T081500Z",
|
||||
"BEGIN:VALARM",
|
||||
"ACTION:DISPLAY",
|
||||
"TRIGGER:-P0DT0H10M0S",
|
||||
"END:VALARM",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(10)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day series' EXDATE day codes carry over`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Standup",
|
||||
"DTSTART;VALUE=DATE:20260819",
|
||||
"DTEND;VALUE=DATE:20260819",
|
||||
"RRULE:FREQ=DAILY;INTERVAL=1",
|
||||
"EXDATE:20260820",
|
||||
"EXDATE:20260822",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().exDates).containsExactly("20260820", "20260822").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed series' bare EXDATE day code is resolved against the series time`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Gym",
|
||||
"DTSTART:20260819T170000Z",
|
||||
"DTEND:20260819T180000Z",
|
||||
"RRULE:FREQ=DAILY;INTERVAL=1",
|
||||
"EXDATE:20260820",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().exDates).containsExactly("20260820T170000Z")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EXDATE is dropped along with an unusable recurrence rule`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Gym",
|
||||
"DTSTART:20260819T170000Z",
|
||||
"DTEND:20260819T180000Z",
|
||||
"RRULE:INTERVAL=1",
|
||||
"EXDATE:20260820",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
val event = result.events.single()
|
||||
assertThat(event.recurrenceRule).isNull()
|
||||
assertThat(event.exDates).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a whole export imports every component`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Anna Birthday",
|
||||
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
|
||||
"CATEGORIES:Birthdays",
|
||||
"LAST-MODIFIED:20260101T120000Z",
|
||||
"TRANSP:TRANSPARENT",
|
||||
"DTSTART;VALUE=DATE:19900412",
|
||||
"DTEND;VALUE=DATE:19900412",
|
||||
"X-FOSSIFY-MISSING-YEAR:0",
|
||||
"DTSTAMP:20260819T100000Z",
|
||||
"CLASS:PUBLIC",
|
||||
"STATUS:CONFIRMED",
|
||||
"RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4",
|
||||
"END:VEVENT",
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Standup",
|
||||
"DTSTART:20260819T080000Z",
|
||||
"DTEND:20260819T081500Z",
|
||||
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=",
|
||||
"END:VEVENT",
|
||||
"BEGIN:VTODO",
|
||||
"SUMMARY:Pay rent",
|
||||
"DTSTART;VALUE=DATE:20260901",
|
||||
"END:VTODO",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.map { it.summary })
|
||||
.containsExactly("Anna Birthday", "Standup", "Pay rent").inOrder()
|
||||
assertThat(result.events.none { days(it) == 0L && it.isAllDay }).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class IcsRecurrenceTest {
|
||||
|
||||
@Test
|
||||
fun `a well-formed rule is unchanged`() {
|
||||
assertThat(sanitizeRrule("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE"))
|
||||
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the RRULE prefix is stripped`() {
|
||||
assertThat(sanitizeRrule("RRULE:FREQ=DAILY")).isEqualTo("FREQ=DAILY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty list part is dropped`() {
|
||||
assertThat(sanitizeRrule("FREQ=WEEKLY;INTERVAL=1;BYDAY="))
|
||||
.isEqualTo("FREQ=WEEKLY;INTERVAL=1")
|
||||
assertThat(sanitizeRrule("FREQ=WEEKLY;BYDAY=MO,,WE;BYMONTH="))
|
||||
.isEqualTo("FREQ=WEEKLY;BYDAY=MO,WE")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a nonsensical INTERVAL is dropped, not fatal`() {
|
||||
assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=0")).isEqualTo("FREQ=DAILY")
|
||||
assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=every")).isEqualTo("FREQ=DAILY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rule without a usable FREQ is unsalvageable`() {
|
||||
assertThat(sanitizeRrule("INTERVAL=1;BYDAY=MO")).isNull()
|
||||
assertThat(sanitizeRrule("FREQ=FORTNIGHTLY;INTERVAL=1")).isNull()
|
||||
assertThat(sanitizeRrule("FREQ=;INTERVAL=1")).isNull()
|
||||
assertThat(sanitizeRrule("")).isNull()
|
||||
assertThat(sanitizeRrule(null)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FREQ is normalised to upper case`() {
|
||||
assertThat(sanitizeRrule("freq=daily;count=3")).isEqualTo("FREQ=DAILY;COUNT=3")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown parts pass through untouched`() {
|
||||
assertThat(sanitizeRrule("FREQ=YEARLY;WKST=SU;X-THING=7"))
|
||||
.isEqualTo("FREQ=YEARLY;WKST=SU;X-THING=7")
|
||||
}
|
||||
}
|
||||
@@ -346,6 +346,48 @@ a latch that disables its own scheduling once a real broadcast arrives — canno
|
||||
be copied, because our failure mode includes a broadcast that arrives with no
|
||||
alert row behind it.
|
||||
|
||||
## Importing foreign `.ics`
|
||||
|
||||
`IcsParser` is deliberately liberal, because the files it is handed were written
|
||||
by other people's calendars. Two lessons from migrating users off Fossify
|
||||
Calendar (#225) shape the read path.
|
||||
|
||||
**All-day `DTEND` is not always exclusive.** RFC 5545 defines it as the first
|
||||
day *after* the event; the Simple Calendar / Fossify family writes the last day
|
||||
the event occupies. Read literally, every one of their all-day events is a day
|
||||
short and single-day ones — birthdays, anniversaries, name days, i.e. most of
|
||||
what anyone migrates — collapse to zero length, at which point the provider
|
||||
expands the series into no instances and the event simply is not there.
|
||||
`IcsQuirks` sniffs the producer (`PRODID`, or the `X-FOSSIFY-*` / `X-SMT-*`
|
||||
extensions for files that passed through a rewriter) and shifts the end by a
|
||||
day. Independently of any dialect, an all-day event is never allowed to end at
|
||||
or before it starts, and `buildImportedEventValues` floors an all-day `DURATION`
|
||||
at `P1D` — belt and braces, because the failure is invisible rather than loud.
|
||||
|
||||
**One bad event must not cost the file.** The provider validates `RRULE` through
|
||||
`EventRecurrence.parse`, which *throws* — out of `insert`, not out of a later
|
||||
read. Fossify emits a bare `;BYDAY=` for any weekly event with no weekday mask,
|
||||
so a single such row used to abort the whole import with nothing but "failed" on
|
||||
screen. `sanitizeRrule` drops empty and malformed parts before the write, and
|
||||
`CalendarRepositoryImpl.importEvents` isolates each event, counting rejects into
|
||||
`IcsImportSummary.failed` rather than unwinding the batch.
|
||||
|
||||
The rest of the dialect handling is smaller: `VTODO` components import as events
|
||||
(Calendula models 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 timed series are resolved against the series' own time of day, and
|
||||
a positive `VALARM` trigger on an all-day event is that family's encoding of "on
|
||||
the day at 09:00" — read as zero days before, so it fires at the hour the user's
|
||||
own setting names.
|
||||
|
||||
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
|
||||
`buildImportedEventValues` snaps the imported colour to the nearest published key
|
||||
(`nearestTo`, measured in Oklab) and writes the raw value only where there is no
|
||||
palette. The single-event review form deliberately keeps no colour at all: its
|
||||
first question is which calendar to use, and answering it clears the colour
|
||||
anyway.
|
||||
|
||||
## Testing
|
||||
|
||||
JUnit 5 + Truth + Turbine on the JVM. The seams that make it work:
|
||||
|
||||
Reference in New Issue
Block a user