Share RRULE part tokenising

This commit is contained in:
2026-10-16 13:35:34 +02:00
parent 59a8f5cebe
commit 4a4daaaf76
4 changed files with 35 additions and 28 deletions
@@ -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<RrulePart> =
rule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }.map(::RrulePart)
internal val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
DayOfWeek.MONDAY to "MO",
DayOfWeek.TUESDAY to "TU",
@@ -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(";")
}
@@ -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" ->
@@ -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