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:
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user