package org.meshbay.client.phonesync import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.TimeZone /** One event as the calendar provider stores it, as much of it as a backup needs. */ data class CalEvent( val id: Long, val calendar: String, /** RFC 5545 UID from the provider, when the event came with one. */ val uid: String?, val title: String?, val description: String?, val location: String?, /** Milliseconds; for an all-day event, midnight UTC of its first day. */ val start: Long, val end: Long?, /** Android's form, `P3600S` or `P1D`, set instead of `end` on a recurring event. */ val duration: String?, val allDay: Boolean, /** Olson id (`Europe/Paris`), or null. */ val timeZone: String?, val rrule: String?, val rdate: String?, val exdate: String?, /** On an exception to a recurring event: the event it changes, and the instance. */ val originalId: Long?, val originalTime: Long?, val originalAllDay: Boolean, /** 0 tentative, 1 confirmed, 2 cancelled, or null. */ val status: Int?, val reminderMinutes: List, val organizer: String?, val attendees: List, ) data class Attendee(val email: String, val name: String?) /** * Events written as one iCalendar file (RFC 5545), the format every calendar * imports. Pure, so the JVM tests hold it; `line` receives each folded line * without its CRLF, so the caller can write it and leave the DTSTAMP lines out * of the hash that says whether anything changed. */ object Ics { fun write(events: List, stamp: Long, line: (String) -> Unit) { val uids = events.associate { it.id to uidOf(it) } line("BEGIN:VCALENDAR") line("VERSION:2.0") line("PRODID:-//MeshBay//Android backup//EN") line("CALSCALE:GREGORIAN") for (e in events) { val out = ArrayList() out += "BEGIN:VEVENT" // An exception carries the UID of the event it changes. out += "UID:" + text(e.originalId?.let { uids[it] } ?: uids.getValue(e.id)) out += "DTSTAMP:" + utc(stamp) if (e.originalId != null && e.originalTime != null) { out += "RECURRENCE-ID" + time(e.originalTime, e.originalAllDay, e.timeZone) } out += "DTSTART" + time(e.start, e.allDay, e.timeZone) when { e.end != null && e.end >= e.start -> out += "DTEND" + time(e.end, e.allDay, e.timeZone) e.duration != null -> duration(e.duration)?.let { out += "DURATION:$it" } } e.title?.takeIf { it.isNotEmpty() }?.let { out += "SUMMARY:" + text(it) } e.description?.takeIf { it.isNotEmpty() }?.let { out += "DESCRIPTION:" + text(it) } e.location?.takeIf { it.isNotEmpty() }?.let { out += "LOCATION:" + text(it) } e.rrule?.takeIf { it.isNotBlank() }?.let { out += "RRULE:" + it.trim() } e.rdate?.let { dates("RDATE", it) }?.let { out += it } e.exdate?.let { dates("EXDATE", it) }?.let { out += it } when (e.status) { 0 -> out += "STATUS:TENTATIVE" 1 -> out += "STATUS:CONFIRMED" 2 -> out += "STATUS:CANCELLED" } out += "X-MESHBAY-CALENDAR:" + text(e.calendar) e.organizer?.takeIf { it.isNotBlank() }?.let { out += "ORGANIZER:mailto:" + it.trim() } for (a in e.attendees) { val cn = a.name?.takeIf { it.isNotBlank() }?.let { ";CN=" + param(it) } ?: "" out += "ATTENDEE$cn:mailto:" + a.email.trim() } for (m in e.reminderMinutes.distinct()) { out += "BEGIN:VALARM" out += "ACTION:DISPLAY" out += "DESCRIPTION:" + text(e.title ?: "Reminder") out += "TRIGGER:-PT${maxOf(0, m)}M" out += "END:VALARM" } out += "END:VEVENT" for (l in out) fold(l, line) } line("END:VCALENDAR") } private fun uidOf(e: CalEvent): String = e.uid?.takeIf { it.isNotBlank() }?.trim() ?: "android-${e.id}@meshbay" /** `;VALUE=DATE:20261010`, `;TZID=Europe/Paris:20261010T090000` or `:20261010T070000Z`. */ fun time(ms: Long, allDay: Boolean, zone: String?): String { if (allDay) return ";VALUE=DATE:" + fmt("yyyyMMdd", "UTC", ms) val tz = zone?.takeIf { it.isNotBlank() && it != "UTC" && it != "GMT" && it in zones } return if (tz == null) ":" + utc(ms) else ";TZID=$tz:" + fmt("yyyyMMdd'T'HHmmss", tz, ms) } private val zones by lazy { TimeZone.getAvailableIDs().toHashSet() } fun utc(ms: Long) = fmt("yyyyMMdd'T'HHmmss'Z'", "UTC", ms) private fun fmt(pattern: String, zone: String, ms: Long) = SimpleDateFormat(pattern, Locale.ROOT).apply { timeZone = TimeZone.getTimeZone(zone) }.format(Date(ms)) /** Android's `P3600S` is not RFC 5545, which wants `PT3600S`; days and weeks are the same. */ fun duration(android: String): String? { val m = Regex("^([+-]?)P(?:(\\d+)W)?(?:(\\d+)D)?(?:T?(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)S)?)?$") .matchEntire(android.trim()) ?: return null val (sign, w, d, h, min, s) = m.destructured if (listOf(w, d, h, min, s).all { it.isEmpty() }) return null val time = (if (h.isEmpty()) "" else "${h}H") + (if (min.isEmpty()) "" else "${min}M") + (if (s.isEmpty()) "" else "${s}S") return sign + "P" + (if (w.isEmpty()) "" else "${w}W") + (if (d.isEmpty()) "" else "${d}D") + (if (time.isEmpty()) "" else "T$time") } /** * Android keeps RDATE and EXDATE as `20261010T090000Z,20261017T090000Z`, * or with a zone first: `Europe/Paris;20261010T090000,…`. */ fun dates(name: String, value: String): String? { val v = value.trim().ifEmpty { return null } val semi = v.indexOf(';') if (semi > 0) return "$name;TZID=${v.substring(0, semi)}:${v.substring(semi + 1)}" return if (v.split(',').all { it.length == 8 }) "$name;VALUE=DATE:$v" else "$name:$v" } /** A TEXT value: backslash, semicolon, comma and line breaks escaped. */ fun text(s: String): String = buildString { for (c in s) when (c) { '\\' -> append("\\\\") ';' -> append("\\;") ',' -> append("\\,") '\n' -> append("\\n") '\r' -> {} else -> if (c < ' ' && c != '\t') {} else append(c) } } /** A parameter value, quoted when it holds what would end it. */ private fun param(s: String): String { val clean = s.replace("\"", "'").filter { it >= ' ' } return if (clean.any { it == ';' || it == ':' || it == ',' }) "\"$clean\"" else clean } /** Lines of at most 75 octets, continued with a space; never inside a character. */ fun fold(l: String, line: (String) -> Unit) { var rest = l var limit = 75 while (rest.toByteArray(Charsets.UTF_8).size > limit) { var cut = 0 var bytes = 0 while (cut < rest.length) { val cp = rest.codePointAt(cut) val n = String(Character.toChars(cp)).toByteArray(Charsets.UTF_8).size if (bytes + n > limit) break bytes += n cut += Character.charCount(cp) } line((if (limit == 74) " " else "") + rest.substring(0, cut)) rest = rest.substring(cut) limit = 74 } line((if (limit == 74) " " else "") + rest) } }