feat(calendar): пакет функционала — событие 1:1 с веб, вложения, повторение, шаринг, free/busy

- Русские месяцы в шапке; нативный DatePickerDialog (material3 крашился)
- 403 при PUT события: учёт supported-calendar-component-set (VTODO-коллекции
  не принимают VEVENT) + sabre-precondition в тексте ошибки
- Окно события по _calendar-edit-full-fields.css: pill-поля, крестик, зелёная
  галочка-сохранение, нативные пикеры даты/времени, высота 94% экрана
- Talk-беседа к событию: выбор существующей / создание публичной|приватной
- Вложения (ATTACH+FILENAME/FMTTYPE): «из файлов» (WebDAV-пикер) или загрузка
  с устройства в /Calendar; round-trip при редактировании события
- Кастомное повторение (_calendar-repeat-modal.css): интервал+частота, дни
  недели кружками, окончание никогда/до даты/N раз ↔ RRULE (parse/build/описание)
- Многодневные события (endDate) и несколько напоминаний (VALARM list)
- Управление календарём (✎ в сайдбаре): PROPPATCH имя/цвет, DELETE,
  шаринг через DAV oc:invite + OCS sharees (польз./группы, чтение/редакт.)
- Discard-диалог при закрытии редактора с несохранёнными изменениями
- Занятость участников: iTIP VFREEBUSY через DAV outbox, конфликты красным
This commit is contained in:
b-dev-mobile
2026-07-10 22:28:15 +00:00
parent 4b000d3b27
commit e6a71de7a6
11 changed files with 2026 additions and 100 deletions
@@ -25,6 +25,10 @@ data class DavCalendar(
// CTag коллекции (CalendarServer-расширение, Nextcloud поддерживает):
// меняется при любом изменении в календаре → ключ инкрементального кэша событий.
val ctag: String = "",
// supported-calendar-component-set: поддерживает ли коллекция VEVENT. VTODO-only списки
// задач не принимают события → PUT VEVENT туда возвращает 403. По умолчанию true (если
// сервер не вернул свойство — не ломаем поведение).
val supportsEvents: Boolean = true,
)
data class DavEvent(
@@ -50,6 +54,7 @@ data class DavEvent(
val organizerName: String = "",
val alarms: List<CalendarAlarmData> = emptyList(),
val conferenceUri: String = "",
val attachments: List<CalendarAttachmentData> = emptyList(),
)
data class DavTrashEvent(
@@ -112,8 +117,8 @@ object CalDavClient {
fun listCalendars(client: OkHttpClient, baseUrl: String): List<DavCalendar> {
val body = """
<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/">
<d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/><cs:getctag/></d:prop>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/><cs:getctag/><c:supported-calendar-component-set/></d:prop>
</d:propfind>
""".trimIndent()
val xml = propfind(client, baseUrl, depth = 1, body)
@@ -298,11 +303,27 @@ object CalDavClient {
}
client.newCall(builder.build()).execute().use { response ->
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
error("CalDAV put event HTTP ${response.code}")
error("CalDAV put event HTTP ${response.code}${davErrorDetail(response)}")
}
}
}
/** Из тела sabre-ошибки достаём человекочитаемую причину (precondition/сообщение). */
private fun davErrorDetail(response: okhttp3.Response): String {
val body = runCatching { response.peekBody(4096).string() }.getOrNull().orEmpty()
if (body.isBlank()) return ""
// sabre: <s:message>…</s:message> и имя нарушенного precondition-элемента.
val message = Regex("<s:message>(.*?)</s:message>", RegexOption.DOT_MATCHES_ALL)
.find(body)?.groupValues?.getOrNull(1)?.trim()
val precondition = Regex("<cal:(supported-calendar-component-set|valid-calendar-object-resource|calendar-collection-location-ok)")
.find(body)?.groupValues?.getOrNull(1)
?: Regex("<d:(need-privileges)").find(body)?.groupValues?.getOrNull(1)
return buildString {
if (precondition != null) append(" ($precondition)")
if (!message.isNullOrBlank()) append(": ${message.take(180)}")
}
}
fun createCalendar(
client: OkHttpClient,
baseUrl: String,
@@ -339,6 +360,122 @@ object CalDavClient {
return DavCalendar(href = url, displayName = displayName)
}
/** PROPPATCH: переименование и/или смена цвета календаря (null = не менять). */
fun updateCalendarProps(
client: OkHttpClient,
calendarHref: String,
displayName: String? = null,
color: String? = null,
) {
if (displayName == null && color == null) return
val props = buildString {
if (displayName != null) append("<d:displayname>${escapeXml(displayName)}</d:displayname>")
if (color != null) append("<ical:calendar-color>${escapeXml(color)}</ical:calendar-color>")
}
val body = """
<?xml version="1.0" encoding="utf-8"?>
<d:propertyupdate xmlns:d="DAV:" xmlns:ical="http://apple.com/ns/ical/">
<d:set><d:prop>$props</d:prop></d:set>
</d:propertyupdate>
""".trimIndent()
val req = Request.Builder()
.url(calendarHref.trimEnd('/') + "/")
.method("PROPPATCH", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
.build()
client.newCall(req).execute().use { response ->
if (response.code !in 200..299) error("CalDAV proppatch HTTP ${response.code}")
}
}
fun deleteCalendar(client: OkHttpClient, calendarHref: String) {
val req = Request.Builder().url(calendarHref.trimEnd('/') + "/").delete().build()
client.newCall(req).execute().use { response ->
if (response.code !in 200..299 && response.code != 204 && response.code != 404) {
error("CalDAV delete calendar HTTP ${response.code}")
}
}
}
data class DavSharee(
val principal: String, // principal:principals/users/<id> или groups/<id>
val displayName: String,
val writable: Boolean,
)
/** Текущие получатели шаринга календаря (oc:invite). */
fun listCalendarShares(client: OkHttpClient, calendarHref: String): List<DavSharee> {
val body = """
<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:prop><oc:invite/></d:prop>
</d:propfind>
""".trimIndent()
val xml = propfind(client, calendarHref.trimEnd('/') + "/", depth = 0, body)
val out = mutableListOf<DavSharee>()
val parser = newParser(xml)
var principal = ""
var name = ""
var writable = false
var inUser = false
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
XmlPullParser.START_TAG -> when (parser.localTag()) {
"user" -> { inUser = true; principal = ""; name = ""; writable = false }
"href" -> if (inUser) principal = parser.readText().trim()
"common-name" -> if (inUser) name = parser.readText().trim()
"read-write" -> if (inUser) writable = true
}
XmlPullParser.END_TAG -> if (parser.localTag() == "user" && inUser) {
if (principal.isNotBlank()) {
out += DavSharee(
principal = principal,
displayName = name.ifBlank { principal.substringAfterLast('/') },
writable = writable,
)
}
inUser = false
}
}
parser.next()
}
return out
}
/** Пошарить календарь пользователю/группе (sabre sharing POST). */
fun shareCalendar(client: OkHttpClient, calendarHref: String, principal: String, writable: Boolean) {
val rw = if (writable) "<o:read-write/>" else ""
val body = """
<?xml version="1.0" encoding="utf-8"?>
<o:share xmlns:d="DAV:" xmlns:o="http://owncloud.org/ns">
<o:set>
<d:href>${escapeXml(principal)}</d:href>
<o:summary/>$rw
</o:set>
</o:share>
""".trimIndent()
sharePost(client, calendarHref, body)
}
fun unshareCalendar(client: OkHttpClient, calendarHref: String, principal: String) {
val body = """
<?xml version="1.0" encoding="utf-8"?>
<o:share xmlns:d="DAV:" xmlns:o="http://owncloud.org/ns">
<o:remove><d:href>${escapeXml(principal)}</d:href></o:remove>
</o:share>
""".trimIndent()
sharePost(client, calendarHref, body)
}
private fun sharePost(client: OkHttpClient, calendarHref: String, body: String) {
val req = Request.Builder()
.url(calendarHref.trimEnd('/') + "/")
.post(body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
.build()
client.newCall(req).execute().use { response ->
if (response.code !in 200..299) error("CalDAV share HTTP ${response.code}")
}
}
fun createSubscription(
client: OkHttpClient,
baseUrl: String,
@@ -593,6 +730,7 @@ object CalDavClient {
organizerName = parsed.organizerName,
alarms = parsed.alarms,
conferenceUri = parsed.conferenceUri,
attachments = parsed.attachments,
)
}
@@ -799,6 +937,99 @@ object CalDavClient {
.format(instant)
}
/** Занятый интервал участника (free/busy), millis UTC. */
data class BusyPeriod(val startEpochMilli: Long, val endEpochMilli: Long)
/**
* Запрос занятости участников через scheduling outbox (iTIP VFREEBUSY REQUEST).
* Возвращает email → занятые интервалы; участник без данных — пустой список.
*/
fun freeBusy(
client: OkHttpClient,
serverUrl: String,
userId: String,
organizerEmail: String,
attendeeEmails: List<String>,
rangeStart: Instant,
rangeEnd: Instant,
): Map<String, List<BusyPeriod>> {
if (organizerEmail.isBlank() || attendeeEmails.isEmpty()) return emptyMap()
val outbox = calendarsBase(serverUrl, userId).trimEnd('/') + "/outbox/"
val ics = buildString {
appendLine("BEGIN:VCALENDAR")
appendLine("VERSION:2.0")
appendLine("PRODID:-//F7cloud Mobile//EN")
appendLine("METHOD:REQUEST")
appendLine("BEGIN:VFREEBUSY")
appendLine("UID:${UUID.randomUUID()}@f7cloud.mobile")
appendLine("DTSTAMP:${formatCalDavTime(Instant.now())}")
appendLine("DTSTART:${formatCalDavTime(rangeStart)}")
appendLine("DTEND:${formatCalDavTime(rangeEnd)}")
appendLine("ORGANIZER:mailto:$organizerEmail")
attendeeEmails.forEach { appendLine("ATTENDEE:mailto:$it") }
appendLine("END:VFREEBUSY")
appendLine("END:VCALENDAR")
}
val req = Request.Builder()
.url(outbox)
.post(ics.toRequestBody("text/calendar; charset=utf-8".toMediaType()))
.build()
val xml = client.newCall(req).execute().use { response ->
if (!response.isSuccessful) error("CalDAV free-busy HTTP ${response.code}")
response.body?.string().orEmpty()
}
return parseFreeBusyResponse(xml)
}
private fun parseFreeBusyResponse(xml: String): Map<String, List<BusyPeriod>> {
val out = mutableMapOf<String, List<BusyPeriod>>()
val parser = newParser(xml)
var recipient = ""
var calendarData = StringBuilder()
var inCalendarData = false
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
XmlPullParser.START_TAG -> when (parser.localTag()) {
"response" -> { recipient = ""; calendarData = StringBuilder() }
"href" -> if (recipient.isBlank()) recipient = parser.readText().trim()
"calendar-data" -> { inCalendarData = true; calendarData = StringBuilder() }
}
XmlPullParser.TEXT -> if (inCalendarData) calendarData.append(parser.text)
XmlPullParser.END_TAG -> when (parser.localTag()) {
"calendar-data" -> inCalendarData = false
"response" -> if (recipient.isNotBlank()) {
val email = recipient.removePrefix("mailto:").trim()
out[email] = parseBusyPeriods(calendarData.toString())
}
}
}
parser.next()
}
return out
}
private val freeBusyLine = Pattern.compile("^FREEBUSY[^:]*:(.+)$", Pattern.MULTILINE or Pattern.CASE_INSENSITIVE)
private fun parseBusyPeriods(ics: String): List<BusyPeriod> {
val unfolded = ics.replace(Regex("\\r?\\n[ \\t]"), "")
val out = mutableListOf<BusyPeriod>()
val m = freeBusyLine.matcher(unfolded)
while (m.find()) {
m.group(1).orEmpty().split(',').forEach { period ->
val parts = period.trim().split('/')
if (parts.size != 2) return@forEach
val start = CalendarIcs.parseIcsInstant(parts[0]) ?: return@forEach
val end = if (parts[1].startsWith("P", ignoreCase = true)) {
runCatching { start.plus(java.time.Duration.parse(parts[1])) }.getOrNull() ?: return@forEach
} else {
CalendarIcs.parseIcsInstant(parts[1]) ?: return@forEach
}
out += BusyPeriod(start.toEpochMilli(), end.toEpochMilli())
}
}
return out.sortedBy { it.startEpochMilli }
}
private fun parseCalendars(xml: String, baseUrl: String): List<DavCalendar> {
val parser = newParser(xml)
val out = mutableListOf<DavCalendar>()
@@ -809,6 +1040,7 @@ object CalDavClient {
var calendarColor: String? = null
var ctag = ""
var isCollection = false
var comps = mutableSetOf<String>()
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
XmlPullParser.START_TAG -> when (parser.localTag()) {
@@ -819,8 +1051,12 @@ object CalDavClient {
calendarColor = null
ctag = ""
isCollection = false
comps = mutableSetOf()
}
"collection" -> if (inResponse) isCollection = true
"comp" -> if (inResponse) {
parser.getAttributeValue(null, "name")?.trim()?.uppercase()?.let { comps += it }
}
"displayname" -> if (inResponse) displayName = parser.readText().trim()
"calendar-color" -> if (inResponse) calendarColor = parser.readText().trim()
"getctag" -> if (inResponse) ctag = parser.readText().trim()
@@ -837,7 +1073,15 @@ object CalDavClient {
val name = displayName.ifBlank {
fullPath.removePrefix(basePath).trim('/').substringAfterLast('/')
}
out += DavCalendar(href = full, displayName = name, color = calendarColor, ctag = ctag)
// Свойство не вернулось (comps пуст) → считаем событийным (не ломаем).
val supportsEvents = comps.isEmpty() || "VEVENT" in comps
out += DavCalendar(
href = full,
displayName = name,
color = calendarColor,
ctag = ctag,
supportsEvents = supportsEvents,
)
}
}
inResponse = false
@@ -23,6 +23,12 @@ data class CalendarAlarmData(
val action: String = "DISPLAY",
)
data class CalendarAttachmentData(
val url: String,
val fileName: String = "",
val mimeType: String = "",
)
data class CalendarEventData(
val uid: String,
val summary: String,
@@ -40,6 +46,7 @@ data class CalendarEventData(
val organizerName: String = "",
val alarms: List<CalendarAlarmData> = emptyList(),
val conferenceUri: String = "",
val attachments: List<CalendarAttachmentData> = emptyList(),
)
object CalendarIcs {
@@ -99,6 +106,13 @@ object CalendarIcs {
val talkUrl = conference.ifBlank {
if (location.contains("/call/")) location else ""
}
val attachments = props.filter { it.name == "ATTACH" && it.value.isNotBlank() }.map {
CalendarAttachmentData(
url = it.value.trim(),
fileName = it.params["FILENAME"]?.let(::unescape).orEmpty(),
mimeType = it.params["FMTTYPE"].orEmpty(),
)
}
return CalendarEventData(
uid = uid,
summary = unescape(lines["SUMMARY"].orEmpty()).ifBlank { "(без названия)" },
@@ -116,6 +130,7 @@ object CalendarIcs {
organizerName = orgName,
alarms = alarms,
conferenceUri = talkUrl,
attachments = attachments,
)
}
@@ -163,6 +178,13 @@ object CalendarIcs {
appendLine("CONFERENCE;FEATURE=PHONE,VIDEO;VALUE=URI:$talk")
if (data.location.isBlank()) appendLine("LOCATION:$talk")
}
data.attachments.forEach { att ->
if (att.url.isNotBlank()) {
val fmt = if (att.mimeType.isNotBlank()) ";FMTTYPE=${att.mimeType}" else ""
val fname = if (att.fileName.isNotBlank()) ";FILENAME=${escape(att.fileName)}" else ""
appendLine("ATTACH$fmt$fname:${att.url}")
}
}
data.alarms.forEach { alarm ->
appendLine("BEGIN:VALARM")
appendLine("ACTION:${alarm.action}")