fix(ics): harden the import against foreign files (#225)
Review pass over the Fossify import work: - sanitizeRrule whitelists the parts EventRecurrence actually parses, and reports whether it dropped anything — normalising case or a stray ";" no longer warns the user about a faulty repeat rule. - CATEGORIES only stands in for a calendar name when it is single-valued and every event agrees; elsewhere it is a tag list and must not pick the target. - An all-day EXDATE keeps the day it spells out instead of the day its instant lands on in UTC. - An imported all-day reminder is sampled where it will fire, not at a recurrence anchor that Fossify dates to 1970. - Imported colours match against every published key, not the picker's curated subset.
This commit is contained in:
@@ -26,7 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **All-day reminders survive a backup and an import.** They fire at the hour you
|
||||
chose, not at midnight UTC — the same rule the app already applied to all-day
|
||||
events you create yourself — and an export no longer leaves them out of the
|
||||
file entirely ([#225]).
|
||||
file entirely. An imported yearly birthday fires at the right hour too, even
|
||||
though the file dates it back to the year of birth ([#225]).
|
||||
- **Birthdays and anniversaries imported from Fossify Calendar now show up.**
|
||||
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
|
||||
|
||||
@@ -70,6 +70,34 @@ internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): Local
|
||||
return LocalDate.of(today.year, month, day)
|
||||
}
|
||||
|
||||
/**
|
||||
* The date an **imported** all-day event's reminder offset is sampled at.
|
||||
*
|
||||
* A one-off fires once, on its own date. A series' `DTSTART` is only an anchor
|
||||
* and may be ancient — Fossify writes a year-less contact birthday at 1970, a
|
||||
* year whose rules predate DST across most of Europe, and a real birth year is
|
||||
* usually older still — so sampling there skews every modern occurrence by the
|
||||
* offset delta (the same trap [nextYearlyOccurrence] exists for). A yearly
|
||||
* series is therefore sampled at its next occurrence of the anchor's month/day,
|
||||
* which shares the season; any other series at [today], which its upcoming
|
||||
* occurrences are near.
|
||||
*
|
||||
* The decode side ([fromProviderAllDayMinutes]) only asks which local *day* the
|
||||
* encoded instant falls on, so it still reads the lead time back correctly from
|
||||
* the anchor's own date.
|
||||
*/
|
||||
internal fun importedAllDayReminderDate(
|
||||
startDate: LocalDate,
|
||||
recurrenceRule: String?,
|
||||
today: LocalDate,
|
||||
): LocalDate = when {
|
||||
recurrenceRule.isNullOrBlank() -> startDate
|
||||
recurrenceRule.contains("FREQ=YEARLY", ignoreCase = true) ->
|
||||
nextYearlyOccurrence(startDate.monthValue, startDate.dayOfMonth, today)
|
||||
startDate.isBefore(today) -> today
|
||||
else -> startDate
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the semantic whole-day lead time from a raw all-day reminder
|
||||
* [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the
|
||||
|
||||
@@ -85,6 +85,15 @@ interface CalendarDataSource {
|
||||
*/
|
||||
fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
||||
|
||||
/**
|
||||
* The same palette **uncurated** — every key the account publishes, in
|
||||
* provider order. Curation is a display concession (it folds look-alikes and
|
||||
* drops the neutrals outright from an oversized palette), so matching a
|
||||
* colour that came from outside the account has to run against the full set:
|
||||
* those keys are all the calendar accepts. See [eventColorPalette].
|
||||
*/
|
||||
fun publishedEventColors(calendarId: Long): List<EventColorOption>
|
||||
|
||||
/**
|
||||
* Every master/one-off event of the writable local calendars, mapped for a
|
||||
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
|
||||
@@ -106,9 +115,10 @@ interface CalendarDataSource {
|
||||
* Insert a parsed `.ics` event into [calendarId], preserving its UID (or
|
||||
* 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.
|
||||
* [colorPalette] is the target account's published event colours
|
||||
* ([publishedEventColors]), 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,
|
||||
@@ -732,7 +742,10 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> {
|
||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||
publishedEventColors(calendarId).curatedForPicker()
|
||||
|
||||
override fun publishedEventColors(calendarId: Long): List<EventColorOption> {
|
||||
val account = calendarAccount(calendarId) ?: return emptyList()
|
||||
return resolver.query(
|
||||
CalendarContract.Colors.CONTENT_URI,
|
||||
@@ -750,7 +763,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
||||
}
|
||||
?.filter { it.key.isNotEmpty() }
|
||||
?.curatedForPicker()
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
@@ -830,16 +842,27 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
|
||||
?: throw WriteFailedException("import event into calendar id=$calendarId")
|
||||
val eventId = ContentUris.parseId(uri)
|
||||
val startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds())
|
||||
.atZone(ZoneOffset.UTC).toLocalDate()
|
||||
val zone = ZoneId.systemDefault()
|
||||
// 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 — sampled
|
||||
// where it will actually fire, not at an ancient recurrence anchor
|
||||
// (see [importedAllDayReminderDate]).
|
||||
val reminderDate = if (event.isAllDay) {
|
||||
importedAllDayReminderDate(
|
||||
startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds())
|
||||
.atZone(ZoneOffset.UTC).toLocalDate(),
|
||||
recurrenceRule = event.recurrenceRule,
|
||||
today = LocalDate.now(zone),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
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) {
|
||||
val providerMinutes = if (reminderDate != null) {
|
||||
toProviderAllDayMinutes(
|
||||
semanticMinutes = minutes,
|
||||
startDate = startDate,
|
||||
zone = ZoneId.systemDefault(),
|
||||
startDate = reminderDate,
|
||||
zone = zone,
|
||||
timeOfDayMinutes = allDayReminderTimeMinutes,
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -220,8 +220,10 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
): 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)
|
||||
// thousand-event restore to two extra queries. The palette is the
|
||||
// uncurated one — an imported colour is matched against every key the
|
||||
// account accepts, not the subset the picker shows.
|
||||
val palette = dataSource.publishedEventColors(targetCalendarId)
|
||||
val allDayMinutes = allDayReminderTimeMinutes()
|
||||
var imported = 0
|
||||
var skipped = 0
|
||||
|
||||
@@ -186,6 +186,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
var status = EventStatus.Confirmed
|
||||
var availability = Availability.Busy
|
||||
var calendarName = fileCalendarName
|
||||
var category: String? = null
|
||||
var eventColor: Int? = null
|
||||
var calendarColor: Int? = null
|
||||
val reminders = mutableListOf<Int>()
|
||||
@@ -223,10 +224,10 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
"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
|
||||
// an X-WR-CALNAME, so this is the only calendar label its files
|
||||
// carry. Elsewhere CATEGORIES is a plain tag list, so it only
|
||||
// stands in where nothing named the calendar outright.
|
||||
"CATEGORIES" -> category = singleCategory(line.value) ?: category
|
||||
// Per-event colour, most specific encoding first (see parseIcsColorValue).
|
||||
"X-FOSSIFY-EVENT-COLOR", "X-SMT-EVENT-COLOR" ->
|
||||
eventColor = parseIcsColorValue(line.value) ?: eventColor
|
||||
@@ -247,10 +248,8 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
warnings.add(IcsParseWarning.EventWithoutStartSkipped)
|
||||
return null
|
||||
}
|
||||
val cleanRrule = sanitizeRrule(rrule)
|
||||
if (rrule != null && cleanRrule != rrule.removePrefix("RRULE:").trim()) {
|
||||
warnings.add(IcsParseWarning.RecurrenceRuleRepaired)
|
||||
}
|
||||
val recurrence = sanitizeRrule(rrule)
|
||||
if (recurrence.repaired) warnings.add(IcsParseWarning.RecurrenceRuleRepaired)
|
||||
val end = resolveEnd(start, dtEnd, duration)
|
||||
return ParsedIcsEvent(
|
||||
uid = uid,
|
||||
@@ -259,15 +258,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
end = end,
|
||||
isAllDay = start.isAllDay,
|
||||
zoneId = start.zoneId,
|
||||
recurrenceRule = cleanRrule,
|
||||
recurrenceRule = recurrence.rule,
|
||||
location = location,
|
||||
description = description,
|
||||
reminderMinutes = reminders.distinct(),
|
||||
status = status,
|
||||
availability = availability,
|
||||
calendarName = calendarName,
|
||||
calendarName = calendarName ?: category,
|
||||
color = eventColor ?: calendarColor,
|
||||
exDates = if (cleanRrule == null) emptyList() else normalizeExDates(exDateLines, start),
|
||||
exDates = if (recurrence.rule == null) {
|
||||
emptyList()
|
||||
} else {
|
||||
normalizeExDates(exDateLines, start)
|
||||
},
|
||||
isTask = isTask,
|
||||
)
|
||||
}
|
||||
@@ -302,6 +305,11 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
* 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.
|
||||
*
|
||||
* An all-day exclusion names a calendar day, so a zone-qualified DATE-TIME
|
||||
* still means the day it spells out; resolving it to an instant first and
|
||||
* reading *that* back in UTC would roll a midnight east of UTC onto the day
|
||||
* before and exclude an occurrence that doesn't exist.
|
||||
*/
|
||||
private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> {
|
||||
if (lines.isEmpty()) return emptyList()
|
||||
@@ -310,17 +318,12 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
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, startZone) ?: 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))
|
||||
}
|
||||
when {
|
||||
token.isEmpty() -> null
|
||||
start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.let(::dayCode)
|
||||
token.contains('T') -> parseExDateTime(token, line, startZone)?.let(::utcStamp)
|
||||
else -> parseBasicDate(token)
|
||||
?.let { utcStamp(LocalDateTime(it, startTime).toInstant(startZone)) }
|
||||
}
|
||||
}
|
||||
.distinct()
|
||||
@@ -391,14 +394,31 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
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 dayCode(date: LocalDate): String =
|
||||
"%04d%02d%02d".format(date.year, date.month.number, date.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)
|
||||
}
|
||||
|
||||
/**
|
||||
* A single-valued `CATEGORIES`, which is what the Fossify family writes
|
||||
* for the source calendar's name. A multi-item list is a tag list and
|
||||
* names no calendar, so it is ignored.
|
||||
*/
|
||||
fun singleCategory(raw: String): String? {
|
||||
var escaped = false
|
||||
for (c in raw) {
|
||||
when {
|
||||
escaped -> escaped = false
|
||||
c == '\\' -> escaped = true
|
||||
c == ',' -> return null
|
||||
}
|
||||
}
|
||||
return unescapeText(raw).trim()
|
||||
.takeIf { it.isNotEmpty() && !it.equals("null", true) }
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -4,15 +4,50 @@ 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",
|
||||
)
|
||||
private val VALID_WEEKDAYS = setOf("SU", "MO", "TU", "WE", "TH", "FR", "SA")
|
||||
|
||||
/**
|
||||
* Make a foreign RRULE safe to hand `CalendarContract`, or return null when it
|
||||
* can't be salvaged.
|
||||
* What `EventRecurrence` accepts in each numeric list part: a value range, plus
|
||||
* whether `0` is legal. Its `parseNumberList` throws on a non-number, on an item
|
||||
* outside the range *and* on a zero in the by-position parts, so checking only
|
||||
* that an item is a number would still hand the provider a rule it rejects.
|
||||
*
|
||||
* `BYSETPOS` is the one part it leaves unbounded; `0` is dropped from it anyway
|
||||
* because RFC 5545 §3.3.10 gives it no meaning.
|
||||
*/
|
||||
private val NUMERIC_LIST_PARTS = mapOf(
|
||||
"BYSECOND" to NumericPart(0..59, zeroAllowed = true),
|
||||
"BYMINUTE" to NumericPart(0..59, zeroAllowed = true),
|
||||
"BYHOUR" to NumericPart(0..23, zeroAllowed = true),
|
||||
"BYMONTHDAY" to NumericPart(-31..31, zeroAllowed = false),
|
||||
"BYYEARDAY" to NumericPart(-366..366, zeroAllowed = false),
|
||||
"BYWEEKNO" to NumericPart(-53..53, zeroAllowed = false),
|
||||
"BYMONTH" to NumericPart(1..12, zeroAllowed = false),
|
||||
"BYSETPOS" to NumericPart(Int.MIN_VALUE..Int.MAX_VALUE, zeroAllowed = false),
|
||||
)
|
||||
|
||||
private class NumericPart(private val range: IntRange, private val zeroAllowed: Boolean) {
|
||||
fun accepts(item: String): Boolean {
|
||||
val n = item.toIntOrNull() ?: return false
|
||||
return n in range && (zeroAllowed || n != 0)
|
||||
}
|
||||
}
|
||||
|
||||
/** `UNTIL` in RFC 5545 basic format; anything else fails at expansion time. */
|
||||
private val UNTIL_SHAPE = Regex("""\d{8}(T\d{6}Z?)?""")
|
||||
|
||||
/**
|
||||
* A foreign RRULE made safe to hand `CalendarContract`.
|
||||
*
|
||||
* [rule] is what survived, or null when there is nothing left to salvage.
|
||||
* [repaired] is true only when a part was actually dropped — pure normalisation
|
||||
* (case, a stray separator) doesn't count, so a conformant file is never
|
||||
* reported to the user as faulty.
|
||||
*/
|
||||
data class SanitizedRrule(val rule: String?, val repaired: Boolean)
|
||||
|
||||
/**
|
||||
* Salvage a foreign RRULE.
|
||||
*
|
||||
* The provider validates every RRULE through `EventRecurrence.parse`, which
|
||||
* **throws** on a malformed rule — and the throw surfaces from `insert`, not
|
||||
@@ -22,34 +57,65 @@ private val LIST_PARTS = setOf(
|
||||
* (`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.
|
||||
* That parser has a fixed table of parts and throws on any name outside it, so
|
||||
* this is a whitelist: an unrecognised part (RFC 7529 `RSCALE`, a vendor `X-`
|
||||
* extension) and an unusable value are dropped rather than passed on to fail the
|
||||
* whole event. Only a missing or unrecognised `FREQ` — without which there is no
|
||||
* rule at all — gives up entirely.
|
||||
*/
|
||||
fun sanitizeRrule(raw: String?): String? {
|
||||
fun sanitizeRrule(raw: String?): SanitizedRrule {
|
||||
val rule = raw?.trim()?.removePrefix("RRULE:")?.trim().orEmpty()
|
||||
if (rule.isEmpty()) return null
|
||||
if (rule.isEmpty()) return SanitizedRrule(rule = null, repaired = false)
|
||||
|
||||
var freqSeen = false
|
||||
val parts = rule.split(';').mapNotNull { part ->
|
||||
var repaired = false
|
||||
val parts = mutableListOf<String>()
|
||||
|
||||
/** The items [valid] accepts, uppercased and comma-joined; null if none survive. */
|
||||
fun keepItems(value: String, valid: (String) -> Boolean): String? {
|
||||
val items = value.split(',').map { it.trim().uppercase() }
|
||||
val kept = items.filter(valid)
|
||||
if (kept.size != items.size) repaired = true
|
||||
return kept.takeIf { it.isNotEmpty() }?.joinToString(",")
|
||||
}
|
||||
|
||||
for (part in rule.split(';')) {
|
||||
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.
|
||||
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
|
||||
if (key.isEmpty() && value.isEmpty()) continue
|
||||
val kept = when {
|
||||
key.isEmpty() || value.isEmpty() -> null
|
||||
key == "FREQ" ->
|
||||
value.uppercase().takeIf { it in VALID_FREQ }
|
||||
?.also { freqSeen = true }
|
||||
?.let { "FREQ=$it" }
|
||||
// A non-positive or non-numeric INTERVAL/COUNT is rejected by the
|
||||
// provider, and a COUNT of zero would expand to no occurrences.
|
||||
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"
|
||||
key == "COUNT" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "COUNT=$it" }
|
||||
key == "UNTIL" ->
|
||||
value.uppercase().takeIf { UNTIL_SHAPE.matches(it) }?.let { "UNTIL=$it" }
|
||||
key == "WKST" ->
|
||||
value.uppercase().takeIf { it in VALID_WEEKDAYS }?.let { "WKST=$it" }
|
||||
key == "BYDAY" -> keepItems(value, ::isWeekdayItem)?.let { "BYDAY=$it" }
|
||||
key in NUMERIC_LIST_PARTS ->
|
||||
keepItems(value, NUMERIC_LIST_PARTS.getValue(key)::accepts)?.let { "$key=$it" }
|
||||
else -> null
|
||||
}
|
||||
if (kept == null) repaired = true else parts += kept
|
||||
}
|
||||
return if (freqSeen) {
|
||||
SanitizedRrule(rule = parts.joinToString(";"), repaired = repaired)
|
||||
} else {
|
||||
SanitizedRrule(rule = null, repaired = true)
|
||||
}
|
||||
return if (freqSeen) parts.joinToString(";") else null
|
||||
}
|
||||
|
||||
/** A `BYDAY` item: a two-letter weekday, optionally preceded by an ordinal. */
|
||||
private fun isWeekdayItem(item: String): Boolean {
|
||||
if (item.length < 2) return false
|
||||
val ordinal = item.dropLast(2)
|
||||
return item.takeLast(2) in VALID_WEEKDAYS &&
|
||||
(ordinal.isEmpty() || ordinal.toIntOrNull() != null)
|
||||
}
|
||||
|
||||
@@ -44,9 +44,9 @@ sealed interface ImportUiState {
|
||||
val warnings: Set<IcsParseWarning>,
|
||||
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.
|
||||
* The calendar the file says its events came from, when every one of
|
||||
* them names the same — used to preselect a matching target (see
|
||||
* [fileCalendarName]).
|
||||
*/
|
||||
val fileCalendarName: String? = null,
|
||||
) : ImportUiState
|
||||
@@ -98,10 +98,7 @@ class ImportViewModel @Inject constructor(
|
||||
warnings = parsed.warnings,
|
||||
calendars = repository.calendars().first()
|
||||
.filter { it.isEventTarget },
|
||||
fileCalendarName = parsed.events
|
||||
.mapNotNull { it.calendarName }
|
||||
.distinct()
|
||||
.singleOrNull(),
|
||||
fileCalendarName = fileCalendarName(parsed.events),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -124,6 +121,17 @@ class ImportViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one calendar [events] all came from, or null.
|
||||
*
|
||||
* Every event has to agree, unnamed ones included: a foreign file where a single
|
||||
* `VEVENT` carries a `CATEGORIES` tag and the rest carry nothing names no
|
||||
* calendar — it just has one tagged event, and letting it pick the target would
|
||||
* land the whole file wherever that tag happens to match.
|
||||
*/
|
||||
internal fun fileCalendarName(events: List<ParsedIcsEvent>): String? =
|
||||
events.map { it.calendarName }.distinct().singleOrNull()
|
||||
|
||||
/**
|
||||
* The target calendar to preselect for a bulk import.
|
||||
*
|
||||
|
||||
@@ -143,6 +143,47 @@ class AllDayReminderEncodingTest {
|
||||
assertThat(fireFromNext).isEqualTo(LocalTime.of(9, 0)) // correct
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an imported yearly series samples at its next occurrence, not its anchor`() {
|
||||
// Fossify anchors a year-less contact birthday at 1970, when Berlin had
|
||||
// no DST; sampling there fires a modern June occurrence at 10:00 instead
|
||||
// of the 09:00 the user picked (Codeberg #225).
|
||||
val anchor = LocalDate.of(1970, 6, 3)
|
||||
val today = LocalDate.of(2026, 6, 1)
|
||||
|
||||
val sampled = importedAllDayReminderDate(anchor, "FREQ=YEARLY;BYMONTH=6", today)
|
||||
assertThat(sampled).isEqualTo(LocalDate.of(2026, 6, 3))
|
||||
|
||||
val raw = toProviderAllDayMinutes(0, sampled, berlin, nineAm)
|
||||
val fire = actualFire(raw, LocalDate.of(2027, 6, 3))
|
||||
.let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime()
|
||||
assertThat(fire).isEqualTo(LocalTime.of(9, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the lead time still reads back from the anchor's own date`() {
|
||||
// The offset is sampled at the next occurrence but decoded against
|
||||
// DTSTART, so the two must still agree on the whole-day lead time.
|
||||
val anchor = LocalDate.of(1970, 6, 3)
|
||||
val sampled = importedAllDayReminderDate(anchor, "FREQ=YEARLY", LocalDate.of(2026, 6, 1))
|
||||
|
||||
for (semantic in listOf(0, 1_440, 2_880)) {
|
||||
val raw = toProviderAllDayMinutes(semantic, sampled, berlin, nineAm)
|
||||
assertThat(fromProviderAllDayMinutes(raw, anchor, berlin, nineAm)).isEqualTo(semantic)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a one-off import samples at its own date, a stale series at today`() {
|
||||
val past = LocalDate.of(1970, 6, 3)
|
||||
val today = LocalDate.of(2026, 6, 1)
|
||||
val future = LocalDate.of(2027, 3, 4)
|
||||
|
||||
assertThat(importedAllDayReminderDate(past, null, today)).isEqualTo(past)
|
||||
assertThat(importedAllDayReminderDate(past, "FREQ=WEEKLY;BYDAY=MO", today)).isEqualTo(today)
|
||||
assertThat(importedAllDayReminderDate(future, "FREQ=WEEKLY", today)).isEqualTo(future)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a winter-anchored offset drifts one hour on a summer occurrence`() {
|
||||
// Known limitation: one fixed MINUTES per series can't track DST. An
|
||||
|
||||
@@ -828,7 +828,7 @@ class CalendarRepositoryImplTest {
|
||||
) = runTest {
|
||||
var lookups = 0
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventColorPaletteResult = { lookups++; emptyList() }
|
||||
publishedEventColorsResult = { lookups++; emptyList() }
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
@@ -837,6 +837,26 @@ class CalendarRepositoryImplTest {
|
||||
assertThat(lookups).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `importEvents matches colours against the uncurated palette`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
// Curation is a picker concession — it folds look-alikes and drops the
|
||||
// neutrals from an oversized palette — but every published key is one
|
||||
// the calendar accepts, so an imported colour matches against all of
|
||||
// them (Codeberg #225).
|
||||
val published = listOf(EventColorOption("1", 0xFF808080.toInt()))
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
publishedEventColorsResult = { published }
|
||||
eventColorPaletteResult = { emptyList() }
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
repo.importEvents(3L, listOf(parsedEvent("a@x")))
|
||||
|
||||
assertThat(fake.lastImportPalette).isEqualTo(published)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exportEvents forwards the chosen calendar-id subset to the data source`(
|
||||
@TempDir tempDir: Path,
|
||||
|
||||
@@ -23,6 +23,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
var searchResult: (String) -> List<SearchCandidate> = { _ -> emptyList() }
|
||||
var eventDetailResult: (Long) -> EventDetail? = { null }
|
||||
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
|
||||
var publishedEventColorsResult: (Long) -> List<EventColorOption> = { emptyList() }
|
||||
var exportableEventsResult: List<IcsEvent> = emptyList()
|
||||
/** The [calendarIds] the last [exportableEvents] call received (null = all). */
|
||||
var lastExportableEventsCalendarIds: Set<Long>? = null
|
||||
@@ -81,6 +82,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
eventDetailResult(eventId)
|
||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||
eventColorPaletteResult(calendarId)
|
||||
override fun publishedEventColors(calendarId: Long): List<EventColorOption> =
|
||||
publishedEventColorsResult(calendarId)
|
||||
override fun exportableEvents(
|
||||
calendarIds: Set<Long>?,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
@@ -97,12 +100,16 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
/** Thrown instead of [writeError] for events whose summary is in this set. */
|
||||
val failingImportSummaries = mutableSetOf<String>()
|
||||
|
||||
/** The [colorPalette] the last [insertImportedEvent] call received. */
|
||||
var lastImportPalette: List<EventColorOption> = emptyList()
|
||||
|
||||
override fun insertImportedEvent(
|
||||
event: ParsedIcsEvent,
|
||||
calendarId: Long,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
colorPalette: List<EventColorOption>,
|
||||
): Long {
|
||||
lastImportPalette = colorPalette
|
||||
writeError?.let { throw it }
|
||||
if (event.summary in failingImportSummaries) error("rejected: ${event.summary}")
|
||||
importedEvents += event to calendarId
|
||||
|
||||
@@ -265,6 +265,43 @@ class IcsFossifyImportTest {
|
||||
assertThat(result.events.single().calendarName).isEqualTo("Birthdays")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CATEGORIES does not outrank a property that names the calendar outright`() {
|
||||
// Elsewhere CATEGORIES is a tag list, so it only stands in where nothing
|
||||
// else named the calendar — X-WR-CALNAME and our own label win.
|
||||
val result = IcsParser().parse(
|
||||
listOf(
|
||||
"BEGIN:VCALENDAR",
|
||||
"X-WR-CALNAME:Work",
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Standup",
|
||||
"CATEGORIES:Holiday",
|
||||
"DTSTART:20260819T080000Z",
|
||||
"DTEND:20260819T090000Z",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
).joinToString("\r\n"),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().calendarName).isEqualTo("Work")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-valued CATEGORIES is a tag list, not a calendar name`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Standup",
|
||||
"CATEGORIES:Meeting,Personal",
|
||||
"DTSTART:20260819T080000Z",
|
||||
"DTEND:20260819T090000Z",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().calendarName).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reminder written as a positive trigger is not read as a lead time`() {
|
||||
// The exporter flips the sign for reminders stored below -1
|
||||
@@ -369,6 +406,28 @@ class IcsFossifyImportTest {
|
||||
assertThat(result.events.single().exDates).containsExactly("20260820", "20260822").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day EXDATE keeps the day it spells out, whatever zone it carries`() {
|
||||
// A midnight east of UTC resolves to an instant on the previous day;
|
||||
// reading that back in UTC would exclude a day that isn't an occurrence
|
||||
// and leave the one the user deleted in place.
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Standup",
|
||||
"DTSTART;VALUE=DATE:20260819",
|
||||
"DTEND;VALUE=DATE:20260820",
|
||||
"RRULE:FREQ=DAILY",
|
||||
"EXDATE;TZID=Europe/Berlin:20260821T000000",
|
||||
"EXDATE;VALUE=DATE:20260823",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().exDates)
|
||||
.containsExactly("20260821", "20260823").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed series' bare EXDATE day code is resolved against the series time`() {
|
||||
// They store excluded occurrences as day codes and write them out that
|
||||
|
||||
@@ -7,46 +7,126 @@ 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")
|
||||
val result = sanitizeRrule("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE")
|
||||
|
||||
assertThat(result.rule).isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE")
|
||||
assertThat(result.repaired).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the RRULE prefix is stripped`() {
|
||||
assertThat(sanitizeRrule("RRULE:FREQ=DAILY")).isEqualTo("FREQ=DAILY")
|
||||
assertThat(sanitizeRrule("RRULE:FREQ=DAILY").rule).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="))
|
||||
val bareByDay = sanitizeRrule("FREQ=WEEKLY;INTERVAL=1;BYDAY=")
|
||||
assertThat(bareByDay.rule).isEqualTo("FREQ=WEEKLY;INTERVAL=1")
|
||||
assertThat(bareByDay.repaired).isTrue()
|
||||
|
||||
assertThat(sanitizeRrule("FREQ=WEEKLY;BYDAY=MO,,WE;BYMONTH=").rule)
|
||||
.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")
|
||||
fun `a nonsensical INTERVAL or COUNT is dropped, not fatal`() {
|
||||
assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=0").rule).isEqualTo("FREQ=DAILY")
|
||||
assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=every").rule).isEqualTo("FREQ=DAILY")
|
||||
assertThat(sanitizeRrule("FREQ=DAILY;COUNT=0").rule).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()
|
||||
assertThat(sanitizeRrule("INTERVAL=1;BYDAY=MO").rule).isNull()
|
||||
assertThat(sanitizeRrule("FREQ=FORTNIGHTLY;INTERVAL=1").rule).isNull()
|
||||
assertThat(sanitizeRrule("FREQ=;INTERVAL=1").rule).isNull()
|
||||
assertThat(sanitizeRrule("").rule).isNull()
|
||||
assertThat(sanitizeRrule(null).rule).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FREQ is normalised to upper case`() {
|
||||
assertThat(sanitizeRrule("freq=daily;count=3")).isEqualTo("FREQ=DAILY;COUNT=3")
|
||||
fun `no rule at all is not a repair`() {
|
||||
assertThat(sanitizeRrule(null).repaired).isFalse()
|
||||
assertThat(sanitizeRrule("").repaired).isFalse()
|
||||
assertThat(sanitizeRrule("FREQ=FORTNIGHTLY").repaired).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown parts pass through untouched`() {
|
||||
assertThat(sanitizeRrule("FREQ=YEARLY;WKST=SU;X-THING=7"))
|
||||
.isEqualTo("FREQ=YEARLY;WKST=SU;X-THING=7")
|
||||
fun `normalising is not repairing`() {
|
||||
// Case and a trailing separator change the text but lose nothing, and
|
||||
// telling the user their file was faulty over either is crying wolf.
|
||||
val lowercase = sanitizeRrule("freq=daily;count=3")
|
||||
assertThat(lowercase.rule).isEqualTo("FREQ=DAILY;COUNT=3")
|
||||
assertThat(lowercase.repaired).isFalse()
|
||||
|
||||
val trailing = sanitizeRrule("FREQ=DAILY;")
|
||||
assertThat(trailing.rule).isEqualTo("FREQ=DAILY")
|
||||
assertThat(trailing.repaired).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parts the provider's parser has no entry for are dropped`() {
|
||||
// EventRecurrence.parse throws on any part name outside its own table,
|
||||
// so passing one on would cost the whole event (Codeberg #225).
|
||||
val result = sanitizeRrule("FREQ=MONTHLY;RSCALE=GREGORIAN;BYMONTHDAY=15")
|
||||
|
||||
assertThat(result.rule).isEqualTo("FREQ=MONTHLY;BYMONTHDAY=15")
|
||||
assertThat(result.repaired).isTrue()
|
||||
assertThat(sanitizeRrule("FREQ=YEARLY;X-THING=7").rule).isEqualTo("FREQ=YEARLY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WKST survives, a nonsense weekday does not`() {
|
||||
assertThat(sanitizeRrule("FREQ=WEEKLY;WKST=SU").rule).isEqualTo("FREQ=WEEKLY;WKST=SU")
|
||||
assertThat(sanitizeRrule("FREQ=WEEKLY;WKST=XX").rule).isEqualTo("FREQ=WEEKLY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a BYDAY item that isn't a weekday is dropped`() {
|
||||
assertThat(sanitizeRrule("FREQ=MONTHLY;BYDAY=-1FR,1,MO").rule)
|
||||
.isEqualTo("FREQ=MONTHLY;BYDAY=-1FR,MO")
|
||||
assertThat(sanitizeRrule("FREQ=WEEKLY;BYDAY=mo,tu").rule)
|
||||
.isEqualTo("FREQ=WEEKLY;BYDAY=MO,TU")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero is dropped from the by-position parts only`() {
|
||||
assertThat(sanitizeRrule("FREQ=MONTHLY;BYMONTHDAY=0,15").rule)
|
||||
.isEqualTo("FREQ=MONTHLY;BYMONTHDAY=15")
|
||||
assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=0,9").rule)
|
||||
.isEqualTo("FREQ=DAILY;BYHOUR=0,9")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an out-of-range numeric item is dropped`() {
|
||||
// EventRecurrence.parseNumberList range-checks every one of these and
|
||||
// throws out of insert, so being a number isn't enough (Codeberg #225).
|
||||
assertThat(sanitizeRrule("FREQ=MONTHLY;BYMONTHDAY=15,32").rule)
|
||||
.isEqualTo("FREQ=MONTHLY;BYMONTHDAY=15")
|
||||
assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=25").rule).isEqualTo("FREQ=DAILY")
|
||||
assertThat(sanitizeRrule("FREQ=YEARLY;BYMONTH=13").rule).isEqualTo("FREQ=YEARLY")
|
||||
assertThat(sanitizeRrule("FREQ=YEARLY;BYWEEKNO=60").rule).isEqualTo("FREQ=YEARLY")
|
||||
assertThat(sanitizeRrule("FREQ=YEARLY;BYYEARDAY=400").rule).isEqualTo("FREQ=YEARLY")
|
||||
assertThat(sanitizeRrule("FREQ=DAILY;BYSECOND=60").rule).isEqualTo("FREQ=DAILY")
|
||||
assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=25").repaired).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the edges of each range survive`() {
|
||||
assertThat(sanitizeRrule("FREQ=MONTHLY;BYMONTHDAY=-31,31").repaired).isFalse()
|
||||
assertThat(sanitizeRrule("FREQ=YEARLY;BYYEARDAY=-366,366;BYWEEKNO=-53,53").repaired)
|
||||
.isFalse()
|
||||
assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=23;BYMINUTE=59;BYSECOND=59").repaired)
|
||||
.isFalse()
|
||||
// BYSETPOS is the one part the provider leaves unbounded.
|
||||
assertThat(sanitizeRrule("FREQ=MONTHLY;BYDAY=MO;BYSETPOS=-1,400").rule)
|
||||
.isEqualTo("FREQ=MONTHLY;BYDAY=MO;BYSETPOS=-1,400")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an UNTIL that isn't a timestamp is dropped`() {
|
||||
assertThat(sanitizeRrule("FREQ=DAILY;UNTIL=20260819T235959Z").rule)
|
||||
.isEqualTo("FREQ=DAILY;UNTIL=20260819T235959Z")
|
||||
assertThat(sanitizeRrule("FREQ=DAILY;UNTIL=2026-08-19").rule).isEqualTo("FREQ=DAILY")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package de.jeanlucmakiola.calendula.ui.imports
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Which calendar a bulk import lands on before the user touches the picker. */
|
||||
class ImportTargetTest {
|
||||
@@ -52,4 +54,36 @@ class ImportTargetTest {
|
||||
fun `an empty list preselects nothing`() {
|
||||
assertThat(defaultImportTarget(emptyList(), "Birthdays")).isNull()
|
||||
}
|
||||
|
||||
private fun event(calendarName: String?) = ParsedIcsEvent(
|
||||
uid = "u@x",
|
||||
summary = "E",
|
||||
start = Instant.fromEpochMilliseconds(0L),
|
||||
end = Instant.fromEpochMilliseconds(3_600_000L),
|
||||
isAllDay = false,
|
||||
zoneId = "UTC",
|
||||
calendarName = calendarName,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a file whose events all name one calendar names it`() {
|
||||
assertThat(fileCalendarName(List(3) { event("Birthdays") })).isEqualTo("Birthdays")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disagreeing events name none`() {
|
||||
assertThat(fileCalendarName(listOf(event("Birthdays"), event("Anniversaries")))).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one tagged event among unnamed ones names none`() {
|
||||
// CATEGORIES is a tag list everywhere but the Fossify family, so a lone
|
||||
// tagged event must not decide where the whole file lands.
|
||||
assertThat(fileCalendarName(listOf(event("Holiday")) + List(9) { event(null) })).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file that names nothing names none`() {
|
||||
assertThat(fileCalendarName(List(3) { event(null) })).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,19 +377,28 @@ conformant one.
|
||||
|
||||
**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. `sanitizeRrule` drops empty and malformed parts before the write, and
|
||||
read. That parser works off a fixed table of part names and throws on anything
|
||||
outside it, so `sanitizeRrule` is a **whitelist**: unrecognised parts (RFC 7529
|
||||
`RSCALE`, vendor `X-` extensions) and unusable values go, and only a missing
|
||||
`FREQ` gives up on the rule. It reports whether it actually *dropped* something,
|
||||
separately from the rule text it returns — case and a trailing `;` normalise away
|
||||
and must not raise the "faulty repeat rule" warning at the user. Behind all that,
|
||||
`CalendarRepositoryImpl.importEvents` isolates each event, counting rejects into
|
||||
`IcsImportSummary.failed` rather than unwinding the batch and reporting only
|
||||
"couldn't read this file".
|
||||
|
||||
Smaller dialect handling: `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 and preselects a target calendar
|
||||
of that name, bare `EXDATE` day codes on a timed series are resolved against the
|
||||
no tasks; dropping them silently lost half of some exports), a single-valued
|
||||
`CATEGORIES` stands in for the `X-WR-CALNAME` Fossify never writes and preselects
|
||||
a target calendar of that name — as a *fallback* only, since everywhere else the
|
||||
property is a tag list and must not outrank something that names the calendar
|
||||
outright — bare `EXDATE` day codes on a timed series are resolved against the
|
||||
series' own time of day (as is a floating `EXDATE` DATE-TIME — RFC 5545 ties it
|
||||
to `DTSTART`'s zone, not the device's), and a `VALARM` trigger pointing *after*
|
||||
the start — which is how that family encodes "on the day at 09:00" — is read as
|
||||
zero days before rather than clamped to a lead time of zero.
|
||||
to `DTSTART`'s zone, not the device's) while an all-day `EXDATE` keeps the day it
|
||||
spells out rather than the day its resolved instant lands on in UTC, and a
|
||||
`VALARM` trigger pointing *after* the start — which is how that family encodes
|
||||
"on the day at 09:00" — is read as 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
|
||||
@@ -401,11 +410,23 @@ after the event — so `toIcsEvent` decodes it back through
|
||||
`fromProviderAllDayMinutes` first, and the importing device re-encodes it against
|
||||
its own setting.
|
||||
|
||||
That re-encode is sampled where the reminder will actually *fire*, not at
|
||||
`DTSTART` (`importedAllDayReminderDate`). An imported series' `DTSTART` is only an
|
||||
anchor and is routinely ancient — Fossify writes a year-less contact birthday at
|
||||
1970, and a real birth year is usually older still — from a year whose timezone
|
||||
rules predate DST across most of Europe. Sampling the UTC offset there skews every
|
||||
modern occurrence by the delta, the same trap `nextYearlyOccurrence` exists for on
|
||||
the managed special-dates path. Decoding is unaffected and stays anchored at
|
||||
`DTSTART`: it only asks which local *day* the encoded instant falls on.
|
||||
|
||||
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
|
||||
palette. The match runs against `publishedEventColors`, **not** the curated
|
||||
`eventColorPalette`: curation is a picker concession that folds look-alikes and
|
||||
drops the neutrals outright from an oversized palette, but every published key is
|
||||
one the calendar accepts. 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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user