From 59a8f5cebe44312c7d6cf69fa907ede6bd417d22 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 16 Oct 2026 13:24:16 +0200 Subject: [PATCH] One RFC 5545 basic-format helper for the ics and RRULE code --- .../calendula/domain/Recurrence.kt | 42 +++++-------------- .../calendula/domain/ics/IcsParser.kt | 33 ++------------- .../calendula/domain/ics/IcsWriter.kt | 35 ++++------------ .../calendula/ui/common/RecurrenceText.kt | 10 +---- 4 files changed, 25 insertions(+), 95 deletions(-) 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 7b6ecde..807868c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Recurrence.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Recurrence.kt @@ -1,5 +1,8 @@ package de.jeanlucmakiola.calendula.domain +import de.jeanlucmakiola.calendula.domain.ics.parseBasicDate +import de.jeanlucmakiola.calendula.domain.ics.parseBasicDateTime +import de.jeanlucmakiola.calendula.domain.ics.toUtcBasic import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime @@ -124,15 +127,8 @@ fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()): when (val e = end) { RecurrenceEnd.Never -> Unit is RecurrenceEnd.Until -> { - val utc = LocalDateTime(e.date, LocalTime(23, 59, 59)) - .toInstant(zone) - .toLocalDateTime(TimeZone.UTC) - append( - ";UNTIL=%04d%02d%02dT%02d%02d%02dZ".format( - utc.year, utc.month.number, utc.day, - utc.hour, utc.minute, utc.second, - ), - ) + append(";UNTIL=") + append(LocalDateTime(e.date, LocalTime(23, 59, 59)).toInstant(zone).toUtcBasic()) } is RecurrenceEnd.Count -> append(";COUNT=${e.times}") } @@ -165,11 +161,7 @@ fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String { val key = part.substringBefore('=').trim().uppercase() key == "UNTIL" || key == "COUNT" } - val until = Instant.fromEpochMilliseconds(untilUtcMillis).toLocalDateTime(TimeZone.UTC) - val untilPart = "UNTIL=%04d%02d%02dT%02d%02d%02dZ".format( - until.year, until.month.number, until.day, - until.hour, until.minute, until.second, - ) + val untilPart = "UNTIL=" + Instant.fromEpochMilliseconds(untilUtcMillis).toUtcBasic() return (kept + untilPart).joinToString(";") } @@ -178,20 +170,8 @@ fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String { * forms are UTC (RFC 5545); the date is taken after converting into [zone] so * a [toRRule]-rendered value round-trips to the day the user picked. */ -private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? = runCatching { - val date = LocalDate( - raw.substring(0, 4).toInt(), - raw.substring(4, 6).toInt(), - raw.substring(6, 8).toInt(), - ) - if (raw.length >= 15 && raw[8] == 'T') { - val time = LocalTime( - raw.substring(9, 11).toInt(), - raw.substring(11, 13).toInt(), - raw.substring(13, 15).toInt(), - ) - LocalDateTime(date, time).toInstant(TimeZone.UTC).toLocalDateTime(zone).date - } else { - date - } -}.getOrNull() +private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? { + val date = parseBasicDate(raw) ?: return null + if (raw.length < 15 || raw[8] != 'T') return date + return parseBasicDateTime(raw)?.toInstant(TimeZone.UTC)?.toLocalDateTime(zone)?.date +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt index 83cc35a..7adb142 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt @@ -3,12 +3,9 @@ package de.jeanlucmakiola.calendula.domain.ics import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.zoneOrNull -import kotlinx.datetime.LocalDate 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.ceil @@ -351,10 +348,10 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault .mapNotNull { (line, token) -> when { token.isEmpty() -> null - start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.let(::dayCode) - token.contains('T') -> parseExDateTime(token, line, startZone)?.let(::utcStamp) + start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.toBasic() + token.contains('T') -> parseExDateTime(token, line, startZone)?.toUtcBasic() else -> parseBasicDate(token) - ?.let { utcStamp(LocalDateTime(it, startTime).toInstant(startZone)) } + ?.let { LocalDateTime(it, startTime).toInstant(startZone).toUtcBasic() } } } .distinct() @@ -425,13 +422,6 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault fun Instant.plusDays(days: Int): Instant = Instant.fromEpochMilliseconds(toEpochMilliseconds() + days * DAY_MILLIS) - 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 @@ -487,22 +477,5 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault "CANCELLED" -> EventStatus.Cancelled else -> EventStatus.Confirmed } - - fun parseBasicDate(s: String): LocalDate? = runCatching { - LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt()) - }.getOrNull() - - fun parseBasicDateTime(s: String): LocalDateTime? = runCatching { - val date = LocalDate( - s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt(), - ) - // Format is YYYYMMDD 'T' HHMMSS; seconds optional. - val time = LocalTime( - s.substring(9, 11).toInt(), - s.substring(11, 13).toInt(), - if (s.length >= 15) s.substring(13, 15).toInt() else 0, - ) - LocalDateTime(date, time) - }.getOrNull() } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt index 40192cd..2d5ee6b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt @@ -3,9 +3,7 @@ package de.jeanlucmakiola.calendula.domain.ics import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.zoneOrNull -import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone -import kotlinx.datetime.number import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant @@ -42,7 +40,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) { private fun MutableList.appendEvent(event: IcsEvent, dtStamp: Instant) { add("BEGIN:VEVENT") add("UID:${event.uid}") - add("DTSTAMP:${utcStamp(dtStamp)}") + add("DTSTAMP:${dtStamp.toUtcBasic()}") add("SUMMARY:${escapeText(event.summary)}") appendTimes(event) event.recurrenceRule?.takeIf { it.isNotBlank() } @@ -64,24 +62,24 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) { private fun MutableList.appendTimes(event: IcsEvent) = when { event.isAllDay -> { - add("DTSTART;VALUE=DATE:${utcDate(event.start)}") - add("DTEND;VALUE=DATE:${utcDate(event.end)}") + add("DTSTART;VALUE=DATE:${event.start.toLocalDateTime(TimeZone.UTC).date.toBasic()}") + add("DTEND;VALUE=DATE:${event.end.toLocalDateTime(TimeZone.UTC).date.toBasic()}") } // Recurring: anchor to wall-clock in the event's own zone. event.recurrenceRule?.isNotBlank() == true -> { val zone = zoneOrNull(event.zoneId) if (zone != null) { - add("DTSTART;TZID=${event.zoneId}:${localStamp(event.start, zone)}") - add("DTEND;TZID=${event.zoneId}:${localStamp(event.end, zone)}") + add("DTSTART;TZID=${event.zoneId}:${event.start.toLocalDateTime(zone).toBasic()}") + add("DTEND;TZID=${event.zoneId}:${event.end.toLocalDateTime(zone).toBasic()}") } else { // Unknown zone id → fall back to plain UTC instants. - add("DTSTART:${utcStamp(event.start)}") - add("DTEND:${utcStamp(event.end)}") + add("DTSTART:${event.start.toUtcBasic()}") + add("DTEND:${event.end.toUtcBasic()}") } } else -> { - add("DTSTART:${utcStamp(event.start)}") - add("DTEND:${utcStamp(event.end)}") + add("DTSTART:${event.start.toUtcBasic()}") + add("DTEND:${event.end.toUtcBasic()}") } } @@ -121,20 +119,5 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) { // A lead time of 0 fires at start (PT0M); anything positive is "before". fun triggerValue(minutes: Int): String = if (minutes <= 0) "PT0M" else "-PT${minutes}M" - - fun utcStamp(instant: Instant): String = - basic(instant.toLocalDateTime(TimeZone.UTC)) + "Z" - - fun localStamp(instant: Instant, zone: TimeZone): String = - basic(instant.toLocalDateTime(zone)) - - fun utcDate(instant: Instant): String { - val dt = instant.toLocalDateTime(TimeZone.UTC) - return "%04d%02d%02d".format(dt.year, dt.month.number, dt.day) - } - - fun basic(dt: LocalDateTime): String = "%04d%02d%02dT%02d%02d%02d".format( - dt.year, dt.month.number, dt.day, dt.hour, dt.minute, dt.second, - ) } } 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 252bc9e..b197ccd 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.ics.parseBasicDate import de.jeanlucmakiola.calendula.domain.upcomingOccurrences import de.jeanlucmakiola.floret.locale.localizedDateFormatter import kotlinx.datetime.toJavaLocalDate @@ -171,12 +172,5 @@ internal fun untilLocalDate(raw: String, zone: ZoneId = ZoneId.systemDefault()): .atOffset(ZoneOffset.UTC) .atZoneSameInstant(zone) .toLocalDate() - }.recoverCatching { - val digits = value.takeWhile { it.isDigit() } - LocalDate.of( - digits.substring(0, 4).toInt(), - digits.substring(4, 6).toInt(), - digits.substring(6, 8).toInt(), - ) - }.getOrNull() + }.getOrNull() ?: parseBasicDate(value.takeWhile { it.isDigit() })?.toJavaLocalDate() }