One RFC 5545 basic-format helper for the ics and RRULE code
This commit is contained in:
1 parent
e7a8aea56c
commit
59a8f5cebe
4 files changed
+25
-95
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<String>.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<String>.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user