Share RRULE part tokenising

This commit is contained in:
makiolaj committed 2026-10-16 13:35:34 +02:00
1 parent 59a8f5cebe
commit 4a4daaaf76
4 files changed
+35 -28

No files matched your search

@@ -60,13 +60,7 @@ fun parseSimpleRecurrence(
rrule: String, rrule: String,
zone: TimeZone = TimeZone.currentSystemDefault(), zone: TimeZone = TimeZone.currentSystemDefault(),
): SimpleRecurrence? { ): SimpleRecurrence? {
val parts = rrule.removePrefix("RRULE:").split(';') val parts = rruleParts(rrule).associate { part -> (part.key ?: return null) to part.value }
.filter { it.isNotBlank() }
.associate { token ->
val eq = token.indexOf('=')
if (eq <= 0) return null
token.substring(0, eq).uppercase() to token.substring(eq + 1)
}
if (parts.keys.any { it !in setOf("FREQ", "INTERVAL", "UNTIL", "COUNT", "WKST", "BYDAY") }) { if (parts.keys.any { it !in setOf("FREQ", "INTERVAL", "UNTIL", "COUNT", "WKST", "BYDAY") }) {
return null 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<RrulePart> =
rule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }.map(::RrulePart)
internal val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf( internal val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
DayOfWeek.MONDAY to "MO", DayOfWeek.MONDAY to "MO",
DayOfWeek.TUESDAY to "TU", DayOfWeek.TUESDAY to "TU",
@@ -19,30 +19,30 @@ import kotlinx.datetime.LocalDate
fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? { fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? {
if (oldStart == newStart) return rrule if (oldStart == newStart) return rrule
val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else "" 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 if (parts.isEmpty()) return null
var freq = false
var weekly = false var weekly = false
var byDay = false
val rebuilt = parts.map { part -> val rebuilt = parts.map { part ->
val eq = part.indexOf('=') val value = part.value.trim()
if (eq <= 0) return null when (part.key) {
val key = part.substring(0, eq).uppercase()
val value = part.substring(eq + 1).trim()
when (key) {
"FREQ" -> { "FREQ" -> {
freq = true
weekly = value.equals("WEEKLY", ignoreCase = true) weekly = value.equals("WEEKLY", ignoreCase = true)
part part.raw
} }
"BYDAY" -> { "BYDAY" -> {
byDay = true
val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null
if (!value.equals(old, ignoreCase = true)) return null if (!value.equals(old, ignoreCase = true)) return null
"BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}" "BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}"
} }
"INTERVAL", "COUNT", "UNTIL", "WKST" -> part "INTERVAL", "COUNT", "UNTIL", "WKST" -> part.raw
else -> return null else -> return null
} }
} }
// BYDAY is only simple on a weekly rule, matching parseSimpleRecurrence. // BYDAY is only simple on a weekly rule, matching parseSimpleRecurrence.
if (!weekly && parts.any { it.substringBefore('=').trim().uppercase() == "BYDAY" }) return null if (!freq || (byDay && !weekly)) return null
if (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null
return prefix + rebuilt.joinToString(";") return prefix + rebuilt.joinToString(";")
} }
@@ -1,5 +1,7 @@
package de.jeanlucmakiola.calendula.domain.ics package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.rruleParts
private val VALID_FREQ = setOf( private val VALID_FREQ = setOf(
"SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY", "SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY",
) )
@@ -79,13 +81,12 @@ fun sanitizeRrule(raw: String?): SanitizedRrule {
return kept.takeIf { it.isNotEmpty() }?.joinToString(",") return kept.takeIf { it.isNotEmpty() }?.joinToString(",")
} }
for (part in rule.split(';')) { // A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost. // A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part // being dropped, so it falls through and marks the rule repaired.
// being dropped, so it falls through and marks the rule repaired. for (part in rruleParts(raw.orEmpty().trim())) {
if (part.isBlank()) continue val key = part.key?.trim().orEmpty()
val key = part.substringBefore('=', "").trim().uppercase() val value = part.value.trim()
val value = if ('=' in part) part.substringAfter('=').trim() else ""
val kept = when { val kept = when {
key.isEmpty() || value.isEmpty() -> null key.isEmpty() || value.isEmpty() -> null
key == "FREQ" -> key == "FREQ" ->
@@ -10,6 +10,7 @@ import androidx.compose.ui.text.font.FontStyle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.occurrencesSpanYears import de.jeanlucmakiola.calendula.domain.occurrencesSpanYears
import de.jeanlucmakiola.calendula.domain.rruleParts
import de.jeanlucmakiola.calendula.domain.ics.parseBasicDate import de.jeanlucmakiola.calendula.domain.ics.parseBasicDate
import de.jeanlucmakiola.calendula.domain.upcomingOccurrences import de.jeanlucmakiola.calendula.domain.upcomingOccurrences
import de.jeanlucmakiola.floret.locale.localizedDateFormatter import de.jeanlucmakiola.floret.locale.localizedDateFormatter
@@ -33,10 +34,7 @@ import java.util.Locale
*/ */
@Composable @Composable
fun recurrenceText(rrule: String, locale: Locale): AnnotatedString { fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token -> val parts = rruleParts(rrule).mapNotNull { part -> part.key?.let { it to part.value } }.toMap()
val eq = token.indexOf('=')
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
}.toMap()
val freq = parts["FREQ"]?.uppercase() val freq = parts["FREQ"]?.uppercase()
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1 val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1