diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Recurrence.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Recurrence.kt index 807868c..9da085d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Recurrence.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Recurrence.kt @@ -60,13 +60,7 @@ fun parseSimpleRecurrence( rrule: String, zone: TimeZone = TimeZone.currentSystemDefault(), ): SimpleRecurrence? { - val parts = rrule.removePrefix("RRULE:").split(';') - .filter { it.isNotBlank() } - .associate { token -> - val eq = token.indexOf('=') - if (eq <= 0) return null - token.substring(0, eq).uppercase() to token.substring(eq + 1) - } + val parts = rruleParts(rrule).associate { part -> (part.key ?: return null) to part.value } if (parts.keys.any { it !in setOf("FREQ", "INTERVAL", "UNTIL", "COUNT", "WKST", "BYDAY") }) { return null } @@ -134,6 +128,20 @@ fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()): } } +/** One `;`-separated part of an RRULE as written; [key] is null when it has no `KEY=`. */ +internal class RrulePart(val raw: String) { + private val eq = raw.indexOf('=') + val key: String? = if (eq > 0) raw.substring(0, eq).uppercase() else null + val value: String = if (eq > 0) raw.substring(eq + 1) else "" +} + +/** + * [rule]'s non-blank parts, after an optional leading "RRULE:". What to do + * with a malformed one (reject, repair or skip) is the caller's call. + */ +internal fun rruleParts(rule: String): List = + rule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }.map(::RrulePart) + internal val RRULE_DAY_CODES: Map = mapOf( DayOfWeek.MONDAY to "MO", DayOfWeek.TUESDAY to "TU", diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealign.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealign.kt index b55db7d..205e2a2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealign.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealign.kt @@ -19,30 +19,30 @@ import kotlinx.datetime.LocalDate fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? { if (oldStart == newStart) return rrule val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else "" - val parts = rrule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() } + val parts = rruleParts(rrule) if (parts.isEmpty()) return null + var freq = false var weekly = false + var byDay = false val rebuilt = parts.map { part -> - val eq = part.indexOf('=') - if (eq <= 0) return null - val key = part.substring(0, eq).uppercase() - val value = part.substring(eq + 1).trim() - when (key) { + val value = part.value.trim() + when (part.key) { "FREQ" -> { + freq = true weekly = value.equals("WEEKLY", ignoreCase = true) - part + part.raw } "BYDAY" -> { + byDay = true val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null if (!value.equals(old, ignoreCase = true)) return null "BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}" } - "INTERVAL", "COUNT", "UNTIL", "WKST" -> part + "INTERVAL", "COUNT", "UNTIL", "WKST" -> part.raw else -> return null } } // BYDAY is only simple on a weekly rule, matching parseSimpleRecurrence. - if (!weekly && parts.any { it.substringBefore('=').trim().uppercase() == "BYDAY" }) return null - if (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null + if (!freq || (byDay && !weekly)) return null return prefix + rebuilt.joinToString(";") } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt index 7f67573..500f1f1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt @@ -1,5 +1,7 @@ package de.jeanlucmakiola.calendula.domain.ics +import de.jeanlucmakiola.calendula.domain.rruleParts + private val VALID_FREQ = setOf( "SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY", ) @@ -79,13 +81,12 @@ fun sanitizeRrule(raw: String?): SanitizedRrule { return kept.takeIf { it.isNotEmpty() }?.joinToString(",") } - for (part in rule.split(';')) { - // A stray separator ("FREQ=DAILY;") normalises away; nothing was lost. - // A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part - // being dropped, so it falls through and marks the rule repaired. - if (part.isBlank()) continue - val key = part.substringBefore('=', "").trim().uppercase() - val value = if ('=' in part) part.substringAfter('=').trim() else "" + // A stray separator ("FREQ=DAILY;") normalises away; nothing was lost. + // A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part + // being dropped, so it falls through and marks the rule repaired. + for (part in rruleParts(raw.orEmpty().trim())) { + val key = part.key?.trim().orEmpty() + val value = part.value.trim() val kept = when { key.isEmpty() || value.isEmpty() -> null key == "FREQ" -> diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceText.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceText.kt index b197ccd..7bc93dc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceText.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceText.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.text.font.FontStyle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.SimpleRecurrence import de.jeanlucmakiola.calendula.domain.occurrencesSpanYears +import de.jeanlucmakiola.calendula.domain.rruleParts import de.jeanlucmakiola.calendula.domain.ics.parseBasicDate import de.jeanlucmakiola.calendula.domain.upcomingOccurrences import de.jeanlucmakiola.floret.locale.localizedDateFormatter @@ -33,10 +34,7 @@ import java.util.Locale */ @Composable fun recurrenceText(rrule: String, locale: Locale): AnnotatedString { - val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token -> - val eq = token.indexOf('=') - if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1) - }.toMap() + val parts = rruleParts(rrule).mapNotNull { part -> part.key?.let { it to part.value } }.toMap() val freq = parts["FREQ"]?.uppercase() val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1