From 652a352ee3021bfb3cc2498f92915f67f54c039c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 18:12:33 +0000 Subject: [PATCH 1/5] Read calendars through the IETF JMAP URN, with the vendor one as fallback The server now answers its calendar methods under urn:ietf:params:jmap:calendars in the draft's shape, and plMail's vendor URN is to go once the apps read the IETF one (pl_mail#409). The app now declares the IETF URN whenever the session advertises it, and its calendar model is spelled in the draft's names: calendarIds, one recurrenceRule, baseEventId. A server released before the IETF URN advertises only the vendor one, so that is still read: CalendarDialect.PLMAIL translates plMail's earlier shape (calendarId, recurrenceRules, seriesId) into the draft's at the edge, and back on a write. Retiring the vendor URN is deleting that enum entry and Capability.PLMAIL_CALENDARS. The draft has no isRecurring, so under the IETF URN recurring is read from the rule, and a create's from the rule that was sent. Under it a get is held to core's maxObjectsInGet, which is what the server allows there. Also corrects ContactAutocomplete's comment saying the server has no Contact/get: it serves RFC 9610 contacts now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FXqu9YFt9pfgWBaXXy2zZa --- .../de/plmail/core/data/CalendarRepository.kt | 60 ++-- .../core/data/CalendarRepositoryTest.kt | 61 +++- .../de/plmail/core/data/CalendarStack.kt | 154 +++++++--- .../de/plmail/core/database/Entities.kt | 7 +- .../de/plmail/jmap/calendar/Calendar.kt | 6 +- .../plmail/jmap/calendar/CalendarDialect.kt | 149 ++++++++++ .../de/plmail/jmap/calendar/CalendarEvent.kt | 64 ++-- .../plmail/jmap/methods/CalendarEventSet.kt | 65 +++- .../de/plmail/jmap/methods/CalendarMethods.kt | 53 +++- .../jmap/methods/ContactAutocomplete.kt | 14 +- .../de/plmail/jmap/protocol/Capability.kt | 45 +-- .../kotlin/de/plmail/jmap/protocol/Session.kt | 53 +++- .../jmap/calendar/CalendarDialectTest.kt | 280 ++++++++++++++++++ .../calendar/CalendarMethodResultsTest.kt | 56 ++-- .../jmap/calendar/CalendarWriteMethodsTest.kt | 54 +++- .../jmap/calendar/CalendarsCapabilityTest.kt | 7 +- core/jmap/src/test/resources/jmap/README.md | 7 + .../feature/calendar/EventEditorScreen.kt | 2 +- 18 files changed, 939 insertions(+), 198 deletions(-) create mode 100644 core/jmap/src/main/kotlin/de/plmail/jmap/calendar/CalendarDialect.kt create mode 100644 core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarDialectTest.kt diff --git a/core/data/src/main/kotlin/de/plmail/core/data/CalendarRepository.kt b/core/data/src/main/kotlin/de/plmail/core/data/CalendarRepository.kt index 38ec01e5..f0ec9b18 100644 --- a/core/data/src/main/kotlin/de/plmail/core/data/CalendarRepository.kt +++ b/core/data/src/main/kotlin/de/plmail/core/data/CalendarRepository.kt @@ -10,6 +10,7 @@ import de.plmail.core.database.PlMailDatabase import de.plmail.core.database.StoreKey import de.plmail.core.datastore.CredentialStore import de.plmail.jmap.calendar.Calendar +import de.plmail.jmap.calendar.CalendarDialect import de.plmail.jmap.calendar.CalendarEvent import de.plmail.jmap.calendar.CalendarEventFilter import de.plmail.jmap.calendar.RecurrenceRule @@ -28,7 +29,6 @@ import de.plmail.jmap.methods.SetError import de.plmail.jmap.protocol.AccountId import de.plmail.jmap.protocol.CalendarEventId import de.plmail.jmap.protocol.CalendarId -import de.plmail.jmap.protocol.Capability import de.plmail.jmap.protocol.JmapError import de.plmail.jmap.protocol.MaterialisedHorizon import de.plmail.jmap.protocol.RequestBuilder @@ -229,7 +229,7 @@ data class EventDraft( val description: String? = null, val status: String = NewCalendarEvent.STATUS_CONFIRMED, /** - * **Create only.** An update deliberately never sends `recurrenceRules`. + * **Create only.** An update deliberately never sends `recurrenceRule`. * * The cache stores *whether* an event recurs but not the rule it recurs by — no screen in this * app draws a rule — so an update built from a draft would send one derived from nothing, and @@ -282,7 +282,7 @@ interface EventEditing { * * An occurrence id is **opaque** — `42_20260304T090000Z` is the series id and the occurrence's * original start, and reading either half back out is the same client-side expansion by a quieter - * route. Which series an occurrence belongs to is its object's `seriesId`, and where it goes is its + * route. Which series an occurrence belongs to is its object's `baseEventId`, and where it goes is its * object's `start`. */ @Singleton @@ -539,7 +539,7 @@ constructor( place( occurrence = occurrence, - // Read from `seriesId`, never from the occurrence id. The + // Read from `baseEventId`, never from the occurrence id. The // two agree on this server and the id is documented opaque, // which is exactly the combination that lets a shortcut go // unnoticed until an id shape changes. @@ -634,7 +634,7 @@ constructor( * **Both are needed, and the second is not the first with extra rows.** `expandRecurrences` * answers one id per occurrence, and the object behind an occurrence id is the series with that * occurrence's override merged into it — its `start` is that Tuesday's, its `title` may be that - * Tuesday's, and `recurrenceRules` comes back null. Those are exactly the properties an editor + * Tuesday's, and `recurrenceRule` comes back null. Those are exactly the properties an editor * must *not* see: a form seeded from one of them would send that occurrence's date as the * series' start and drag a whole standup onto the day somebody happened to be looking at. So * the collapsed query answers the series, the expanded one answers the days, and the cache @@ -758,7 +758,7 @@ constructor( val q = batch.add(query(accountId, after, before, paging.position, pageSize)) - q to batch.add(hydrate(accountId, q.reference("/ids"), CACHED_PROPERTIES)) + q to batch.add(hydrate(session, accountId, q.reference("/ids"), CACHED_PROPERTIES)) } val occurrenceHandles = @@ -780,6 +780,7 @@ constructor( q to batch.add( hydrate( + session, accountId, q.reference("/ids"), CalendarEventGet.OCCURRENCE_PROPERTIES, @@ -910,6 +911,7 @@ constructor( mapOf( CREATION_ID to stored.toNewEvent(CalendarId(calendar.calendarId)) ), + dialect = session.dialect, ) ) @@ -926,6 +928,13 @@ constructor( "The server created no event and gave no reason.", ) + // Read from the answer where there is one: a rule the server + // cannot convert is stored verbatim and expands to one + // occurrence, so a create carrying a recurrence can come back + // not recurring. Only plMail's vendor shape answers it; under + // the IETF URN the rule that was sent is all there is to go on. + val recurs = created.isRecurring ?: (stored.recurrenceRule != null) + val eventKey = StoreKey.objectKey(calendar.accountKey, created.id.value) val row = stored.toEntity( @@ -943,11 +952,7 @@ constructor( // `uid` therefore keeps the ordinary id-only reconcile until // the first refresh fills the uid in. eventUid = created.uid?.takeIf { it.isNotBlank() }, - // Read from the answer, never inferred from the rule that - // was sent: a rule the server cannot convert is stored - // verbatim and expands to one occurrence, so a create - // carrying a recurrence can come back not recurring. - isRecurring = created.isRecurring, + isRecurring = recurs, sequence = created.sequence, ) @@ -958,12 +963,12 @@ constructor( // appear without a round trip. A recurring one cannot be: where // its occurrences land is the server's answer, and the refresh // below is how that is asked. - if (!created.isRecurring) { + if (!recurs) { database.calendarEvents().upsertOccurrences(row.placeFromItself(zone)) } } - if (created.isRecurring) refreshLastWindow() + if (recurs) refreshLastWindow() CalendarWriteResult.Applied(eventKey, created.id) } @@ -1043,6 +1048,7 @@ constructor( CalendarEventSet( accountId = accountId, update = mapOf(CalendarEventId(existing.eventId) to stored.toPatch()), + dialect = session.dialect, ) ) @@ -1102,6 +1108,7 @@ constructor( CalendarEventSet( accountId = accountId, destroy = listOf(CalendarEventId(existing.eventId)), + dialect = session.dialect, ) ) @@ -1190,7 +1197,7 @@ constructor( * Which days those are is read off the object the server answered for this occurrence id and * from nothing else: its `start` is already resolved against whatever override moved it, so * there is no rule to expand, no override map to interpret, and no timestamp to lift out of an - * id. [series] is the row it hangs off — [CalendarEvent.seriesId]'s object, never a prefix of + * id. [series] is the row it hangs off — [CalendarEvent.baseEventId]'s object, never a prefix of * the occurrence id — and an occurrence whose series did not come back is dropped rather than * orphaned, because nothing joins to a series row that is not there. * @@ -1296,28 +1303,41 @@ constructor( expandRecurrences = expandRecurrences, ) - private fun hydrate(accountId: AccountId, ids: ResultReference, properties: List) = + private fun hydrate( + session: Session, + accountId: AccountId, + ids: ResultReference, + properties: List, + ) = CalendarEventGet.byReference( accountId = accountId, queryReference = ids, properties = properties, + dialect = session.dialect, ) /** * A request declaring the calendars capability, and nothing else. * - * `USING_CALENDARS` behaves **oppositely** to the push URN: push is advertised in the session + * The calendars URN behaves **oppositely** to the push URN: push is advertised in the session * and fails the whole request if it is declared, calendars is advertised and refuses the call * if it is not. Mail is deliberately absent — a calendar request needs nothing from it, and * declaring a capability a request does not use is how a client breaks on an instance where * that one is switched off. + * + * Which calendars URN is the session's to say — see [Session.calendarDialect]. Every caller has + * already found a calendar account, so the session names one. */ private fun request(session: Session): RequestBuilder = RequestBuilder( - using = Capability.USING_CALENDARS, + using = session.dialect.using, maxCallsInRequest = session.core.maxCallsInRequest, ) + /** See [request]: only ever read once the session has named a calendar account. */ + private val Session.dialect: CalendarDialect + get() = calendarDialect ?: CalendarDialect.IETF + private companion object { const val CREATION_ID = "c1" @@ -1347,7 +1367,7 @@ constructor( listOf( "id", "uid", - "calendarId", + "calendarIds", "title", "description", "start", @@ -1355,7 +1375,9 @@ constructor( "timeZone", "showWithoutTime", "status", - "isRecurring", + // What recurring is read from under the IETF URN, which + // publishes no `isRecurring`; see CalendarEvent.isRecurring. + "recurrenceRule", "sequence", "locations", "recurrenceOverrides", diff --git a/core/data/src/test/kotlin/de/plmail/core/data/CalendarRepositoryTest.kt b/core/data/src/test/kotlin/de/plmail/core/data/CalendarRepositoryTest.kt index d5ff1e4a..dcc80e24 100644 --- a/core/data/src/test/kotlin/de/plmail/core/data/CalendarRepositoryTest.kt +++ b/core/data/src/test/kotlin/de/plmail/core/data/CalendarRepositoryTest.kt @@ -3,6 +3,7 @@ package de.plmail.core.data import app.cash.turbine.test import de.plmail.core.database.PlMailDatabase import de.plmail.core.database.StoreKey +import de.plmail.jmap.calendar.CalendarDialect import de.plmail.jmap.client.HttpRequest import de.plmail.jmap.protocol.CalendarEventId import de.plmail.jmap.testing.RecordingTransport @@ -457,7 +458,7 @@ class CalendarRepositoryTest { * of the same, has nothing to parse here and draws nothing. * * The dates asserted are therefore only reachable through the object's own `start`, and the - * series row is only reachable through its `seriesId`. + * series row is only reachable through its `baseEventId`. */ @Test fun `occurrences are placed from their objects, never from their ids`() = runTest { @@ -474,15 +475,69 @@ class CalendarRepositoryTest { assertEquals( "10867", database.calendarEvents().byUid(standupKey)?.eventId, - "and the series row is keyed by seriesId, which is the id a write is addressed to", + "and the series row is keyed by baseEventId, which is the id a write is addressed to", ) } + /** + * A server from before the IETF calendars URN is read just the same. + * + * It advertises only plMail's vendor URN and answers in plMail's earlier shape — `calendarId`, + * `recurrenceRules`, `seriesId`, `isRecurring` — and refuses a request that declares the IETF + * one. Every plMail released before that URN arrived is such a server, and a phone updated + * ahead of its server must not lose its calendar to the order the two were upgraded in. + */ + @Test + fun `a server that speaks only the vendor calendars URN is still read`() = runTest { + val server = FakeCalendarServer(events = seededWeek(), dialect = CalendarDialect.PLMAIL) + val repository = calendarStack(database, calendarTransport(server)) + + val result = repository.refresh(week) as CalendarRefresh.Refreshed + + assertEquals(1, result.requests) + assertEquals( + listOf("2026-08-03", "2026-08-05", "2026-08-07"), + database.calendarEvents().occurrencesOf(standupKey).map { it.date }, + ) + assertEquals(true, database.calendarEvents().byUid(standupKey)?.isRecurring) + assertEquals(testCalendarKey, database.calendarEvents().byUid(standupKey)?.calendarKey) + } + + @Test + fun `a write to a vendor-only server is sent in its names`() = runTest { + val server = FakeCalendarServer(events = seededWeek(), dialect = CalendarDialect.PLMAIL) + var sent: JsonObject? = null + + server.onSet = { arguments -> + sent = arguments["create"]!!.jsonObject.getValue("c1").jsonObject + createdEvent(id = "10999", dialect = CalendarDialect.PLMAIL) + } + + val repository = calendarStack(database, calendarTransport(server)) + + repository.refresh(week) + + val result = + repository.create( + calendarKey = testCalendarKey, + draft = + EventDraft( + title = "Zahnarzt", + start = LocalDateTime.parse("2026-08-06T09:00:00"), + duration = Duration.ofMinutes(30), + ), + ) + + assertTrue(result is CalendarWriteResult.Applied, "was $result") + assertEquals(TEST_CALENDAR_ID, sent?.get("calendarId")?.jsonPrimitive?.content) + assertFalse(sent!!.containsKey("calendarIds")) + } + /** * An edit of a recurring series goes to the series id, never to an occurrence's. * * `CalendarEvent/set` refuses an occurrence id by name — `invalidArguments`, pointing at - * `seriesId` — so a cache that had filed a standup under `o2` would have an editor that cannot + * `baseEventId` — so a cache that had filed a standup under `o2` would have an editor that cannot * save. The occurrence ids never reach a row at all, which is what this asserts from the other * end: the id on the wire is the plain one. */ diff --git a/core/data/src/test/kotlin/de/plmail/core/data/CalendarStack.kt b/core/data/src/test/kotlin/de/plmail/core/data/CalendarStack.kt index 7d1a4a5b..660076ed 100644 --- a/core/data/src/test/kotlin/de/plmail/core/data/CalendarStack.kt +++ b/core/data/src/test/kotlin/de/plmail/core/data/CalendarStack.kt @@ -4,6 +4,7 @@ import de.plmail.core.database.AccountEntity import de.plmail.core.database.PlMailDatabase import de.plmail.core.datastore.CredentialStore import de.plmail.core.datastore.ServerConnection +import de.plmail.jmap.calendar.CalendarDialect import de.plmail.jmap.client.Credential import de.plmail.jmap.client.HttpResponse import de.plmail.jmap.client.JmapTransport @@ -83,6 +84,15 @@ internal fun berlinClock(instant: String = "2026-08-05T12:00:00Z"): Clock = internal class FakeCalendarServer( /** Null publishes no calendars capability at all, which is the no-calendar-account case. */ var calendarAccount: String? = TEST_ACCOUNT_ID, + /** + * The one calendars URN this server speaks, and the shape it answers in. + * + * The IETF one, as plMail does from the release that added it; [CalendarDialect.PLMAIL] for a + * server from before, which advertises only its vendor URN and answers in plMail's earlier + * shape. A request declaring the other one is refused with `unknownCapability`, as the real + * server refuses a URN it does not know — so a client that declared the wrong one fails here. + */ + var dialect: CalendarDialect = CalendarDialect.IETF, var events: MutableList = mutableListOf(), /** The `CalendarEvent/set` result arguments, when a test exercises a write. */ var onSet: ((JsonObject) -> String)? = null, @@ -266,14 +276,26 @@ internal fun recurring( internal fun calendarTransport(server: FakeCalendarServer): RecordingTransport = RecordingTransport { request -> val body = - if (request.url.endsWith("/.well-known/jmap")) calendarSession(server.calendarAccount) - else answer(server, request.body!!.decodeToString()) + if (request.url.endsWith("/.well-known/jmap")) { + calendarSession(server.calendarAccount, server.dialect) + } else { + answer(server, request.body!!.decodeToString()) + } HttpResponse(200, mapOf("Content-Type" to "application/json"), body.encodeToByteArray()) } private fun answer(server: FakeCalendarServer, requestBody: String): String { - val calls = Json.parseToJsonElement(requestBody).jsonObject["methodCalls"]!!.jsonArray + val request = Json.parseToJsonElement(requestBody).jsonObject + val using = request["using"]!!.jsonArray.map { it.jsonPrimitive.content } + val unknown = using.firstOrNull { it.endsWith(":calendars") && it != server.dialect.capability } + + if (unknown != null) { + return """{"type":"urn:ietf:params:jmap:error:unknownCapability","status":400, + "detail":"Unknown capability $unknown"}""" + } + + val calls = request["methodCalls"]!!.jsonArray val responses = mutableListOf() // What each query answered, by its call id, so a back-referenced get can be @@ -437,13 +459,13 @@ private fun FakeCalendarServer.objectFor(id: String): String? { events .firstOrNull { it.id == id } ?.let { series -> - return series.toJson() + return series.toJson(dialect) } events.forEach { event -> event.instances().forEach { instance -> if (instanceId(event.id, instance.recurrenceId) == id) { - return event.toJson(instance, id) + return event.toJson(instance, id, dialect) } } } @@ -517,51 +539,92 @@ private fun FakeInstance.startUtc(event: FakeEvent): LocalDateTime = .withZoneSameInstant(ZoneOffset.UTC) .toLocalDateTime() -/** The series object: its own start, its own title, and the override map published whole. */ -private fun FakeEvent.toJson(): String = buildString { - append("""{"id":"$id","uid":"${uid ?: "$id@plmail"}","calendarId":"$calendarId",""") +/** + * The series object: its own start, its own title, and the override map published whole. + * + * Under the IETF URN a recurring one carries a rule, because that is all the draft says about + * recurring; which rule does not matter, since the days come from [FakeEvent.days]. Under the vendor + * URN it carries `isRecurring`, as plMail's earlier shape does. + */ +private fun FakeEvent.toJson(dialect: CalendarDialect): String = buildString { + append("""{"id":"$id","uid":"${uid ?: "$id@plmail"}",${calendarJson(dialect)},""") append(""""title":"$title","start":"$start","duration":"$duration",""") timeZone?.let { append(""""timeZone":"$it",""") } if (showWithoutTime) append(""""showWithoutTime":true,""") overrides?.let { append(""""recurrenceOverrides":$it,""") } - append(""""status":"confirmed","sequence":0,"isRecurring":$isRecurring}""") + + when (dialect) { + CalendarDialect.IETF -> + if (isRecurring) { + append(""""recurrenceRule":{"@type":"RecurrenceRule","frequency":"daily"},""") + } + CalendarDialect.PLMAIL -> append(""""isRecurring":$isRecurring,""") + } + + append(""""status":"confirmed","sequence":0}""") } +private fun FakeEvent.calendarJson(dialect: CalendarDialect): String = + when (dialect) { + CalendarDialect.IETF -> """"calendarIds":{"$calendarId":true}""" + CalendarDialect.PLMAIL -> """"calendarId":"$calendarId"""" + } + /** * One occurrence as `CalendarEvent/get` resolves it. * - * The series with the override merged in, its own `start` and `duration`, `seriesId` and - * `recurrenceId` — and `recurrenceRules`/`recurrenceOverrides` explicitly **null**, as the draft - * requires, which is also a decoding case worth exercising: an absent key and a null one are not - * the same thing to a deserialiser. + * The series with the override merged in, its own `start` and `duration`, `baseEventId` (or + * `seriesId`, under the vendor URN) and `recurrenceId` — and the rule and `recurrenceOverrides` + * explicitly **null**, as the draft requires, which is also a decoding case worth exercising: an + * absent key and a null one are not the same thing to a deserialiser. */ -private fun FakeEvent.toJson(instance: FakeInstance, occurrenceId: String): String = buildString { - append("""{"id":"$occurrenceId","seriesId":"$id","uid":"${uid ?: "$id@plmail"}",""") - append(""""calendarId":"$calendarId","title":"${instance.title}",""") +private fun FakeEvent.toJson( + instance: FakeInstance, + occurrenceId: String, + dialect: CalendarDialect, +): String = buildString { + val series = if (dialect == CalendarDialect.IETF) "baseEventId" else "seriesId" + val rule = if (dialect == CalendarDialect.IETF) "recurrenceRule" else "recurrenceRules" + + append("""{"id":"$occurrenceId","$series":"$id","uid":"${uid ?: "$id@plmail"}",""") + append("""${calendarJson(dialect)},"title":"${instance.title}",""") append(""""start":"${instance.start.toWire()}","duration":"${instance.duration}",""") append(""""recurrenceId":"${instance.recurrenceId.toWire()}",""") timeZone?.let { append(""""timeZone":"$it",""") } if (showWithoutTime) append(""""showWithoutTime":true,""") - append(""""recurrenceRules":null,"recurrenceOverrides":null,""") + append(""""$rule":null,"recurrenceOverrides":null,""") + if (dialect == CalendarDialect.PLMAIL) append(""""isRecurring":true,""") append( - """"status":"${if (instance.cancelled) "cancelled" else "confirmed"}",""" + - """"sequence":0,"isRecurring":true}""" + """"status":"${if (instance.cancelled) "cancelled" else "confirmed"}","sequence":0}""" ) } /** * The `CalendarEvent/set` result of a create that worked. * - * [isRecurring] is echoed rather than inferred from what was sent, because that is what the server - * does: a rule it cannot convert is stored verbatim and expands to one occurrence. + * Under the vendor URN [isRecurring] is echoed rather than inferred from what was sent, because + * that is what the server does: a rule it cannot convert is stored verbatim and expands to one + * occurrence. Under the IETF URN the server says nothing about recurring, and [isRecurring] is + * ignored. */ -internal fun createdEvent(id: String, isRecurring: Boolean = false): String = - """ - {"accountId":"$TEST_ACCOUNT_ID","oldState":"fixed","newState":"fixed", - "created":{"c1":{"id":"$id","uid":"$id@plmail","calendarId":"$TEST_CALENDAR_ID", - "isRecurring":$isRecurring,"sequence":0}}, - "notCreated":{},"updated":{},"notUpdated":{},"destroyed":[],"notDestroyed":{}} - """ +internal fun createdEvent( + id: String, + isRecurring: Boolean = false, + dialect: CalendarDialect = CalendarDialect.IETF, +): String { + val decided = + when (dialect) { + CalendarDialect.IETF -> """"calendarIds":{"$TEST_CALENDAR_ID":true}""" + CalendarDialect.PLMAIL -> + """"calendarId":"$TEST_CALENDAR_ID","isRecurring":$isRecurring""" + } + + return """ + {"accountId":"$TEST_ACCOUNT_ID","oldState":"fixed","newState":"fixed", + "created":{"c1":{"id":"$id","uid":"$id@plmail",$decided,"sequence":0}}, + "notCreated":{},"updated":{},"notUpdated":{},"destroyed":[],"notDestroyed":{}} + """ +} /** A per-object refusal, which is how this surface reports one — not a method-level error. */ internal fun refusedCreate(type: String, description: String): String = @@ -624,32 +687,39 @@ private val CALENDARS_RESULT = """ /** - * The session, with the calendars capability where [calendarAccount] says there is one. + * The session, with [dialect]'s calendars capability where [calendarAccount] says there is one. * * `maxCallsInRequest` is 32, as the real server publishes. A refresh needs five of them — * `Calendar/get`, two queries and two back-referenced gets — so an instance publishing RFC 8620's * default of 16 has room to spare, and a fake that omitted the value would silently be testing that * case instead of this one. */ -private fun calendarSession(calendarAccount: String?): String { +private fun calendarSession(calendarAccount: String?, dialect: CalendarDialect): String { + val urn = dialect.capability val capability = - if (calendarAccount == null) "" - else - """ - "urn:plmail:params:jmap:calendars":{ - "maxEventsInGet":100,"maxEventsInSet":500,"mayCreateCalendar":false, - "materialisedHorizon":{"past":"-1 year","future":"+2 years"}}, - """ - - val primary = - if (calendarAccount == null) "" - else ""","urn:plmail:params:jmap:calendars":"$calendarAccount"""" + when { + calendarAccount == null -> "" + dialect == CalendarDialect.IETF -> + """ + "$urn":{ + "maxCalendarsPerEvent":1,"maxExpandedQueryDuration":"P1Y", + "maxParticipantsPerEvent":0,"mayCreateCalendar":false}, + """ + else -> + """ + "$urn":{ + "maxEventsInGet":100,"maxEventsInSet":500,"mayCreateCalendar":false, + "materialisedHorizon":{"past":"-1 year","future":"+2 years"}}, + """ + } + + val primary = if (calendarAccount == null) "" else ""","$urn":"$calendarAccount"""" return """ { "capabilities": { "urn:ietf:params:jmap:core": {"maxCallsInRequest": 32, "maxConcurrentRequests": 4}, - ${if (calendarAccount == null) "" else "\"urn:plmail:params:jmap:calendars\": {},"} + ${if (calendarAccount == null) "" else "\"$urn\": {},"} "urn:ietf:params:jmap:mail": {} }, "accounts": { diff --git a/core/database/src/main/kotlin/de/plmail/core/database/Entities.kt b/core/database/src/main/kotlin/de/plmail/core/database/Entities.kt index 126b392d..36429e07 100644 --- a/core/database/src/main/kotlin/de/plmail/core/database/Entities.kt +++ b/core/database/src/main/kotlin/de/plmail/core/database/Entities.kt @@ -518,10 +518,13 @@ data class CalendarEventEntity( val location: String? = null, val status: String? = null, /** - * Derived server-side, and **not** `recurrenceRules != null`. + * `CalendarEvent.isRecurring`: derived server-side where the server says, and otherwise + * `recurrenceRule != null`. * * An imported rule plMail cannot convert is stored verbatim and expands to a single occurrence, - * so an event can carry a rule and still not recur. What a screen does with it: an editor shows + * so an event can carry a rule and still not recur. Only plMail's vendor calendars URN says so; + * under the IETF one the draft has no such property, and such an event reads as recurring + * here. What a screen does with it: an editor shows * a read-only repeat line rather than the dropdown, and a delete warns that it takes the * series. */ diff --git a/core/jmap/src/main/kotlin/de/plmail/jmap/calendar/Calendar.kt b/core/jmap/src/main/kotlin/de/plmail/jmap/calendar/Calendar.kt index 9fcb467b..714e835b 100644 --- a/core/jmap/src/main/kotlin/de/plmail/jmap/calendar/Calendar.kt +++ b/core/jmap/src/main/kotlin/de/plmail/jmap/calendar/Calendar.kt @@ -4,7 +4,11 @@ import de.plmail.jmap.protocol.CalendarId import kotlinx.serialization.Serializable /** - * A calendar, from plMail's vendor `urn:plmail:params:jmap:calendars` surface. + * A calendar, as `Calendar/get` publishes it under either calendars URN. + * + * The two answer alike but for additions: under the IETF one the server adds `isSubscribed` and the + * draft's finer rights (`mayWriteAll`, `mayRSVP`, …) beside plMail's own, and nothing here needs + * them — [CalendarRights.mayAddItems] still answers whether an event can be created. * * There is no `Calendar/query`, no `Calendar/changes` and no `Calendar/set`: the whole list arrives * from one `Calendar/get` and the server reports its state as the literal `"fixed"`. A client that diff --git a/core/jmap/src/main/kotlin/de/plmail/jmap/calendar/CalendarDialect.kt b/core/jmap/src/main/kotlin/de/plmail/jmap/calendar/CalendarDialect.kt new file mode 100644 index 00000000..8ec73c00 --- /dev/null +++ b/core/jmap/src/main/kotlin/de/plmail/jmap/calendar/CalendarDialect.kt @@ -0,0 +1,149 @@ +package de.plmail.jmap.calendar + +import de.plmail.jmap.protocol.Capability +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull + +/** + * Which calendars URN a request declares, and so which shape the server answers it in. + * + * The app speaks the IETF draft's shape — [CalendarEvent] and every write are spelled in it — and + * the server answers in it whenever the request declares [Capability.CALENDARS]. [PLMAIL] is for a + * server released before it did: the same methods under [Capability.PLMAIL_CALENDARS], answering + * in plMail's earlier shape, which is translated here at the edge and nowhere else. The server + * does the same thing in reverse (`IetfCalendarShape`), and for the same reason: when the vendor + * URN goes (pl_mail#409), this enum entry and its three functions go with it and nothing else + * changes. + * + * The two shapes differ in three names and one property: + * - an event is filed by a `calendarIds` map in the draft and by one `calendarId` in plMail's; + * - it repeats by one `recurrenceRule` in the draft and by a `recurrenceRules` list in plMail's; + * - an occurrence names its series by `baseEventId` in the draft and by `seriesId` in plMail's; + * - plMail's shape also publishes `isRecurring`, which the draft has nothing to match. Kept on the + * way through, because it is the better answer where it exists — see + * [CalendarEvent.isRecurring]. + */ +enum class CalendarDialect(val capability: String) { + IETF(Capability.CALENDARS), + PLMAIL(Capability.PLMAIL_CALENDARS); + + /** + * What a calendar request declares. + * + * Mail is deliberately absent: a calendar request needs nothing from it, and declaring + * capabilities a request does not use is how a client ends up broken on an instance where one + * of them is switched off. + */ + val using: List + get() = listOf(Capability.CORE, capability) + + /** The property names a `CalendarEvent/get` asks for, in this dialect's names. */ + fun properties(names: List): List = + when (this) { + IETF -> names + PLMAIL -> + buildList { + names.forEach { add(VENDOR_NAMES[it] ?: it) } + // The draft infers recurring from the rule; plMail's shape says + // so outright, and better -- see CalendarEvent.isRecurring. + if (RECURRENCE_RULE in names) add(IS_RECURRING) + } + } + + /** An event as the server published it, in the draft's shape. */ + fun read(event: JsonObject): JsonObject = + when (this) { + IETF -> event + PLMAIL -> + JsonObject( + buildMap { + event.forEach { (name, value) -> + when (name) { + VENDOR_CALENDAR_ID -> put(CALENDAR_IDS, calendarIds(value)) + VENDOR_RECURRENCE_RULES -> put(RECURRENCE_RULE, firstRule(value)) + VENDOR_SERIES_ID -> put(BASE_EVENT_ID, value) + else -> put(name, value) + } + } + } + ) + } + + /** A create or an update patch, spelled in the draft's names, in this dialect's. */ + fun write(event: JsonObject): JsonObject = + when (this) { + IETF -> event + PLMAIL -> + JsonObject( + buildMap { + event.forEach { (name, value) -> + when (name) { + CALENDAR_IDS -> put(VENDOR_CALENDAR_ID, calendarId(value)) + RECURRENCE_RULE -> + put( + VENDOR_RECURRENCE_RULES, + if (value is JsonObject) JsonArray(listOf(value)) + else JsonNull, + ) + else -> put(name, value) + } + } + } + ) + } + + companion object { + /** + * The dialect a session's calendars are read in, or null when it serves none. + * + * The IETF one whenever it is advertised, even beside the vendor one: a server that + * publishes both answers both, and the IETF one is the one that stays. + */ + fun of(capabilities: Set): CalendarDialect? = + when { + Capability.CALENDARS in capabilities -> IETF + Capability.PLMAIL_CALENDARS in capabilities -> PLMAIL + else -> null + } + + private const val CALENDAR_IDS = "calendarIds" + private const val RECURRENCE_RULE = "recurrenceRule" + private const val BASE_EVENT_ID = "baseEventId" + private const val IS_RECURRING = "isRecurring" + + private const val VENDOR_CALENDAR_ID = "calendarId" + private const val VENDOR_RECURRENCE_RULES = "recurrenceRules" + private const val VENDOR_SERIES_ID = "seriesId" + + private val VENDOR_NAMES = + mapOf( + CALENDAR_IDS to VENDOR_CALENDAR_ID, + RECURRENCE_RULE to VENDOR_RECURRENCE_RULES, + BASE_EVENT_ID to VENDOR_SERIES_ID, + ) + + private fun calendarIds(calendarId: JsonElement): JsonElement = + (calendarId as? JsonPrimitive) + ?.takeIf { it.isString } + ?.let { JsonObject(mapOf(it.content to JsonPrimitive(true))) } + ?: JsonObject(emptyMap()) + + /** + * The one calendar a `calendarIds` map names. plMail files an event in exactly one, so + * there is no second to lose; a map naming none is sent as null for the server to refuse. + */ + private fun calendarId(calendarIds: JsonElement): JsonElement = + (calendarIds as? JsonObject) + ?.entries + ?.firstOrNull { (it.value as? JsonPrimitive)?.booleanOrNull == true } + ?.key + ?.let(::JsonPrimitive) ?: JsonNull + + private fun firstRule(rules: JsonElement): JsonElement = + (rules as? JsonArray)?.firstOrNull() as? JsonObject ?: JsonNull + } +} diff --git a/core/jmap/src/main/kotlin/de/plmail/jmap/calendar/CalendarEvent.kt b/core/jmap/src/main/kotlin/de/plmail/jmap/calendar/CalendarEvent.kt index bb980284..690736ab 100644 --- a/core/jmap/src/main/kotlin/de/plmail/jmap/calendar/CalendarEvent.kt +++ b/core/jmap/src/main/kotlin/de/plmail/jmap/calendar/CalendarEvent.kt @@ -10,9 +10,9 @@ import kotlinx.serialization.json.JsonObject * One event, as JSCalendar (RFC 8984) plus plMail's envelope — a series, or **one occurrence of * one**. * - * Which of the two it is, is [seriesId]: present means this object came back for an occurrence id, - * and then [start], [duration] and [status] are that occurrence's own, its override already merged - * in, with [recurrenceRules] and [recurrenceOverrides] nulled by the server. Absent means the + * Which of the two it is, is [baseEventId]: present means this object came back for an occurrence + * id, and then [start], [duration] and [status] are that occurrence's own, its override already + * merged in, with [recurrenceRule] and [recurrenceOverrides] nulled by the server. Absent means the * series, and a one-off event is always the series — its single occurrence *is* the event, so an * expanded query names it by its plain id. * @@ -25,6 +25,10 @@ import kotlinx.serialization.json.JsonObject * tolerated rather than rejected by `MethodResults.JMAP_JSON`. Nothing here may be treated as the * whole object; in particular a round trip through this type is lossy and must never be written * back. + * + * Spelled in the IETF draft's names (`calendarIds`, `recurrenceRule`, `baseEventId`), which is what + * the server answers under `urn:ietf:params:jmap:calendars`. A server old enough to speak only + * plMail's vendor URN is read into the same shape by [CalendarDialect.PLMAIL]. */ @Serializable data class CalendarEvent( @@ -33,7 +37,13 @@ data class CalendarEvent( @SerialName("@type") val type: String? = null, /** Stable across servers; the id is not. `@plmail` for events this product created. */ val uid: String? = null, - val calendarId: CalendarId? = null, + /** + * The calendars this event is in, as the draft's id-to-true map. + * + * Exactly one on plMail, which files an event in one calendar and says so + * (`maxCalendarsPerEvent: 1`); read it through [calendarId]. + */ + val calendarIds: Map = emptyMap(), val title: String? = null, val description: String? = null, /** @@ -68,8 +78,8 @@ data class CalendarEvent( * stores a place as a label rather than as coordinates. */ val locations: Map = emptyMap(), - /** At most one rule; the server refuses a second. */ - val recurrenceRules: List = emptyList(), + /** The draft's one rule — plMail never stored a second. */ + val recurrenceRule: RecurrenceRule? = null, /** * Per-occurrence patches, keyed by the occurrence's original start (a LocalDateTime). * @@ -84,19 +94,12 @@ data class CalendarEvent( val created: String? = null, val updated: String? = null, /** - * Derived server-side, and **not** the same question as `recurrenceRules.isNotEmpty()`. - * - * A rule plMail cannot convert is stored verbatim and expands to a single occurrence, so an - * event can carry a rule and still not recur. Read this rather than inferring it; the two - * disagree exactly on the imported events a user is most likely to notice. + * plMail's derived `isRecurring`, which only its vendor shape publishes. Read [isRecurring]. */ - val isRecurring: Boolean = false, - /** plMail's extension. May be null; the vocabulary is open. */ - val kind: String? = null, - /** plMail's extension: where the event came from. Seen: `manual`. */ - val source: String? = null, + @SerialName("isRecurring") val publishedRecurring: Boolean? = null, /** - * plMail's extension, and the only way back from an occurrence to something writable. + * The only way back from an occurrence to something writable — the draft's name for what + * plMail's vendor shape called `seriesId`. * * Present exactly when [id] names one occurrence rather than a series. `CalendarEvent/set` * **refuses an occurrence id by name** — `invalidArguments`, pointing at this property — so @@ -108,27 +111,44 @@ data class CalendarEvent( * separator is plMail's own choice, and a client that parsed it would be deciding for itself * where an occurrence goes. */ - val seriesId: CalendarEventId? = null, + val baseEventId: CalendarEventId? = null, /** * The occurrence's **original** start, as a LocalDateTime — never where an override moved it. * * The key a `recurrenceOverrides` patch has to be filed under, which is what makes it worth - * keeping beside [start]. Read in [recurrenceIdTimeZone] where the server sends one. + * keeping beside [start]. Read in [recurrenceIdTimeZone] where the server sends one — plMail's + * own server does not, under the draft's URN. */ val recurrenceId: String? = null, val recurrenceIdTimeZone: String? = null, ) { + /** The one calendar this event is in. See [calendarIds]. */ + val calendarId: CalendarId? + get() = calendarIds.entries.firstOrNull { it.value }?.key?.let(::CalendarId) + + /** + * Whether this event repeats. + * + * plMail's vendor shape says so outright, derived server-side, and that answer wins where it + * exists: a rule plMail cannot convert is stored verbatim and expands to a single occurrence, + * so an event can carry a rule and still not recur. The draft publishes no such property, so + * under it the rule is the answer — wrong only on those imported events, where it errs towards + * treating a one-off as a series. + */ + val isRecurring: Boolean + get() = publishedRecurring ?: (recurrenceRule != null) + /** Whether this object is one occurrence of a series rather than the series itself. */ val isOccurrence: Boolean - get() = seriesId != null + get() = baseEventId != null /** * The id an edit is addressed to: the series', which for a one-off is its own. * - * Never derived from [id]. See [seriesId] for why that matters. + * Never derived from [id]. See [baseEventId] for why that matters. */ val writableId: CalendarEventId - get() = seriesId ?: id + get() = baseEventId ?: id } @Serializable diff --git a/core/jmap/src/main/kotlin/de/plmail/jmap/methods/CalendarEventSet.kt b/core/jmap/src/main/kotlin/de/plmail/jmap/methods/CalendarEventSet.kt index fd54ac88..1fbe68fd 100644 --- a/core/jmap/src/main/kotlin/de/plmail/jmap/methods/CalendarEventSet.kt +++ b/core/jmap/src/main/kotlin/de/plmail/jmap/methods/CalendarEventSet.kt @@ -1,5 +1,6 @@ package de.plmail.jmap.methods +import de.plmail.jmap.calendar.CalendarDialect import de.plmail.jmap.calendar.RecurrenceRule import de.plmail.jmap.protocol.AccountId import de.plmail.jmap.protocol.CalendarEventId @@ -28,12 +29,16 @@ import kotlinx.serialization.json.put * Destroying an event is a real delete, unlike `Email/set` where "destroy" means Trash — there is * no calendar bin to recover from, so a destructive control here needs its own confirmation rather * than borrowing mail's undo. + * + * Creates and patches are built in the draft's names and sent in [dialect]'s, and so is what + * `created` answers; see [CalendarDialect]. */ class CalendarEventSet( private val accountId: AccountId, private val create: Map = emptyMap(), private val update: Map = emptyMap(), private val destroy: List = emptyList(), + private val dialect: CalendarDialect = CalendarDialect.IETF, ) : JmapMethod { override val name = "CalendarEvent/set" @@ -42,13 +47,18 @@ class CalendarEventSet( put("accountId", accountId.value) if (create.isNotEmpty()) { - put("create", buildJsonObject { create.forEach { (id, e) -> put(id, e.toJson()) } }) + put( + "create", + buildJsonObject { create.forEach { (id, e) -> put(id, dialect.write(e.toJson())) } }, + ) } if (update.isNotEmpty()) { put( "update", - buildJsonObject { update.forEach { (id, patch) -> put(id.value, patch.toJson()) } }, + buildJsonObject { + update.forEach { (id, patch) -> put(id.value, dialect.write(patch.toJson())) } + }, ) } @@ -57,8 +67,21 @@ class CalendarEventSet( } } - override fun decode(json: Json, arguments: JsonObject): CalendarEventSetResult = - json.decodeFromJsonElement(CalendarEventSetResult.serializer(), arguments) + override fun decode(json: Json, arguments: JsonObject): CalendarEventSetResult { + val created = arguments["created"] as? JsonObject + val read = + if (created == null) arguments + else + JsonObject( + arguments + + ("created" to + JsonObject( + created.mapValues { (_, e) -> (e as? JsonObject)?.let(dialect::read) ?: e } + )) + ) + + return json.decodeFromJsonElement(CalendarEventSetResult.serializer(), read) + } } /** @@ -137,7 +160,7 @@ data class NewCalendarEvent( ) { fun toJson(): JsonObject = buildJsonObject { put("@type", "Event") - put("calendarId", calendarId.value) + put("calendarIds", buildJsonObject { put(calendarId.value, true) }) uid?.let { put("uid", it) } put("title", title) description?.let { put("description", it) } @@ -203,13 +226,17 @@ class CalendarEventPatch private constructor(private val fields: Map = emptyMap(), + val isRecurring: Boolean? = null, val sequence: Int = 0, -) +) { + val calendarId: CalendarId? + get() = calendarIds.entries.firstOrNull { it.value }?.key?.let(::CalendarId) +} /** The write shape of a rule, which is not what `@Serializable` would emit for the read one. */ internal fun RecurrenceRule.toJson(): JsonObject = buildJsonObject { @@ -372,7 +405,7 @@ private fun JsonObjectBuilder.putRecurrence( rule: RecurrenceRule?, overrides: Map, ) { - rule?.let { put("recurrenceRules", buildJsonArray { add(it.toJson()) }) } + rule?.let { put("recurrenceRule", it.toJson()) } if (overrides.isNotEmpty()) { put( diff --git a/core/jmap/src/main/kotlin/de/plmail/jmap/methods/CalendarMethods.kt b/core/jmap/src/main/kotlin/de/plmail/jmap/methods/CalendarMethods.kt index 933625e2..3a8acdde 100644 --- a/core/jmap/src/main/kotlin/de/plmail/jmap/methods/CalendarMethods.kt +++ b/core/jmap/src/main/kotlin/de/plmail/jmap/methods/CalendarMethods.kt @@ -1,6 +1,7 @@ package de.plmail.jmap.methods import de.plmail.jmap.calendar.Calendar +import de.plmail.jmap.calendar.CalendarDialect import de.plmail.jmap.calendar.CalendarEvent import de.plmail.jmap.calendar.CalendarEventFilter import de.plmail.jmap.protocol.AccountId @@ -13,6 +14,7 @@ import de.plmail.jmap.protocol.backReference import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.add import kotlinx.serialization.json.buildJsonArray @@ -85,11 +87,11 @@ data class CalendarGetResult( * plain series id either way, so a window with nothing recurring in it answers both identically. * * An occurrence id is **opaque**. It looks like `42_20260304T090000Z` and it must be handed - * straight back to [CalendarEventGet]; the series it belongs to is that object's `seriesId` and + * straight back to [CalendarEventGet]; the series it belongs to is that object's `baseEventId` and * never something read out of the id. * - * Expanding is refused with `cannotCalculateOccurrences` when the window reaches past the account's - * `materialisedHorizon` — collapsed, an overrunning window is merely thin, but the expanded answer + * Expanding is refused with `cannotCalculateOccurrences` when the window reaches past the server's + * materialised horizon — collapsed, an overrunning window is merely thin, but the expanded answer * *is* the list of occurrences, so a series that stops at the horizon would come back as a series * that ends. Clamp the window rather than discovering this per request. * @@ -162,18 +164,23 @@ data class CalendarEventQueryResult( * * Pair it with [CalendarEventQuery] in one request through [byReference] — the pairing works * unchanged for an expanded query, which is what makes a month one round trip. An occurrence id - * answers the series with its override merged in, plus `seriesId`, `recurrenceId` and the - * occurrence's own `start`/`duration`; `recurrenceRules` and `recurrenceOverrides` come back null. + * answers the series with its override merged in, plus `baseEventId`, `recurrenceId` and the + * occurrence's own `start`/`duration`; `recurrenceRule` and `recurrenceOverrides` come back null. * - * Note the id ceiling is the account's `maxEventsInGet` — **100**, not core's `maxObjectsInGet` of - * 500 — because expanding a recurring series costs far more than reading a row. Expanded, that - * ceiling counts occurrences, which a busy month reaches far sooner than it reaches 100 series. + * The id ceiling is [de.plmail.jmap.protocol.CalendarsCapability.maxEventsInGet]: core's + * `maxObjectsInGet` under the IETF URN, which gives a client no calendar limit of its own, and + * plMail's own **100** under the vendor one. Expanded, that ceiling counts occurrences, which a + * busy month reaches far sooner than it reaches as many series. + * + * [properties] and the answer are in the draft's names whatever [dialect] the request declares; + * see [CalendarDialect]. */ class CalendarEventGet( private val accountId: AccountId, private val ids: List? = null, private val idsReference: ResultReference? = null, private val properties: List? = null, + private val dialect: CalendarDialect = CalendarDialect.IETF, ) : JmapMethod { init { @@ -193,15 +200,29 @@ class CalendarEventGet( ids != null -> put("ids", buildJsonArray { ids.forEach { add(it.value) } }) } - properties?.let { put("properties", buildJsonArray { it.forEach { p -> add(p) } }) } + properties?.let { + put("properties", buildJsonArray { dialect.properties(it).forEach { p -> add(p) } }) + } } - override fun decode(json: Json, arguments: JsonObject): CalendarEventGetResult = - json.decodeFromJsonElement(CalendarEventGetResult.serializer(), arguments) + override fun decode(json: Json, arguments: JsonObject): CalendarEventGetResult { + val list = arguments["list"] as? JsonArray + val read = + if (list == null) arguments + else + JsonObject( + arguments + + ("list" to + JsonArray(list.map { (it as? JsonObject)?.let(dialect::read) ?: it })) + ) + + return json.decodeFromJsonElement(CalendarEventGetResult.serializer(), read) + } companion object { /** - * The account capability's value, as a floor to chunk by when no session is to hand. + * The vendor account capability's value, as a floor to chunk by when no session is to + * hand — the lowest ceiling any plMail has published. * * Read `Session.calendars(id).maxEventsInGet` where one is available — an instance * configured for more should not be second-guessed by its own client. @@ -216,13 +237,13 @@ class CalendarEventGet( * participants — so asking for the fields a day view draws from the series would move that * text once per occurrence over a domestic uplink. * - * `seriesId` earns its place twice over: it is the row an occurrence hangs off and the only - * id `CalendarEvent/set` will accept back. + * `baseEventId` earns its place twice over: it is the row an occurrence hangs off and the + * only id `CalendarEvent/set` will accept back. */ val OCCURRENCE_PROPERTIES = listOf( "id", - "seriesId", + "baseEventId", "recurrenceId", "title", "start", @@ -236,11 +257,13 @@ class CalendarEventGet( accountId: AccountId, queryReference: ResultReference, properties: List? = null, + dialect: CalendarDialect = CalendarDialect.IETF, ) = CalendarEventGet( accountId = accountId, idsReference = queryReference, properties = properties, + dialect = dialect, ) } } diff --git a/core/jmap/src/main/kotlin/de/plmail/jmap/methods/ContactAutocomplete.kt b/core/jmap/src/main/kotlin/de/plmail/jmap/methods/ContactAutocomplete.kt index 46a0bed1..bb8d5564 100644 --- a/core/jmap/src/main/kotlin/de/plmail/jmap/methods/ContactAutocomplete.kt +++ b/core/jmap/src/main/kotlin/de/plmail/jmap/methods/ContactAutocomplete.kt @@ -12,10 +12,16 @@ import kotlinx.serialization.json.put /** * `Contact/autocomplete` — who to offer while a recipient is being typed. * - * **Not RFC 8621 and not the JMAP Contacts draft**, which is why the verb is `autocomplete` rather - * than `query`: a `/query` promises ids for a `/get` to resolve, and there is no `Contact/get`, no - * id space and nothing here a client may create. The suggestion carries the address itself, and the - * address is the key — the server's own `uniq_contact_user_email` is what makes that true. + * **Not RFC 8621 and not RFC 9610**, which is why the verb is `autocomplete` rather than `query`: a + * `/query` promises ids for a `/get` to resolve, and a suggestion has no id and nothing here a + * client may create. The suggestion carries the address itself, and the address is the key — the + * server's own `uniq_contact_user_email` is what makes that true. + * + * The server does serve contacts with ids — RFC 9610's address books and cards, under + * `urn:ietf:params:jmap:contacts` — but that is a different surface for a different job. A contact + * list or editor, should the app grow one, reads those `ContactCard` methods; plMail's vendor + * `Contact/get`, `/query` and `/set`, which overlap them, are on their way out (pl_mail#409) and + * nothing here calls them. * * The ranking is the server's, deliberately. It is `frequency DESC, lastSeenAt DESC` over the * *whole* harvested address book, which is exactly the thing a phone cannot do for itself: a client diff --git a/core/jmap/src/main/kotlin/de/plmail/jmap/protocol/Capability.kt b/core/jmap/src/main/kotlin/de/plmail/jmap/protocol/Capability.kt index ffa75762..1d20f972 100644 --- a/core/jmap/src/main/kotlin/de/plmail/jmap/protocol/Capability.kt +++ b/core/jmap/src/main/kotlin/de/plmail/jmap/protocol/Capability.kt @@ -3,22 +3,40 @@ package de.plmail.jmap.protocol /** * Capability URNs. * - * [PUSH] and [CALENDARS] are both plMail vendor extensions, and they behave **oppositely** in the - * one place it matters. [PUSH] must never appear in a request's `using` list — the server - * advertises it in the session but rejects it as a declared capability, failing the whole request - * with `unknownCapability`. [CALENDARS] is the reverse: a calendar method call that omits it is - * refused. + * [PUSH] and the calendars URNs behave **oppositely** in the one place it matters. [PUSH] must + * never appear in a request's `using` list — the server advertises it in the session but rejects it + * as a declared capability, failing the whole request with `unknownCapability`. [CALENDARS] is the + * reverse: a calendar method call that omits it is refused. * * So the advertised set and the usable set are deliberately different things, and which side a - * vendor URN falls on cannot be guessed from the URN. [USING_MAIL] and [USING_CALENDARS] are what - * requests actually declare. + * vendor URN falls on cannot be guessed from the URN. [USING_MAIL] and + * [de.plmail.jmap.calendar.CalendarDialect.using] are what requests actually declare. */ object Capability { const val CORE = "urn:ietf:params:jmap:core" const val MAIL = "urn:ietf:params:jmap:mail" const val SUBMISSION = "urn:ietf:params:jmap:submission" const val PUSH = "urn:plmail:params:jmap:push" - const val CALENDARS = "urn:plmail:params:jmap:calendars" + + /** + * Calendars, under the IETF draft's URN (draft-ietf-jmap-calendars) and in the draft's shape: + * an event's `calendarIds` map, one `recurrenceRule`, `baseEventId` on an occurrence. + * + * What the app reads whenever the session advertises it. See + * [de.plmail.jmap.calendar.CalendarDialect] for the server that does not. + */ + const val CALENDARS = "urn:ietf:params:jmap:calendars" + + /** + * plMail's own calendars URN, from before the server spoke the IETF one, and on its way out + * (pl_mail#409). + * + * Read only from a server that does not advertise [CALENDARS] — every plMail released before + * the IETF URN arrived. It is the same methods in plMail's earlier shape, translated into the + * draft's at the edge by [de.plmail.jmap.calendar.CalendarDialect.PLMAIL], so deleting this + * constant and that one enum entry is the whole of retiring it. + */ + const val PLMAIL_CALENDARS = "urn:plmail:params:jmap:calendars" const val CONTACTS = "urn:plmail:params:jmap:contacts" const val APPEARANCE = "urn:plmail:params:jmap:appearance" @@ -60,7 +78,7 @@ object Capability { /** * For `Contact/autocomplete`. * - * Mail is absent for the same reason it is absent from [USING_CALENDARS]: the method reads the + * Mail is absent for the same reason it is absent from a calendar request: the method reads the * harvested address book and nothing from the mail capability. The server happens to answer * without this declared — verified against the 8002 stack — which is exactly the kind of * leniency that stops being true on the instance where the extension is switched off. @@ -81,13 +99,4 @@ object Capability { /** For `Appearance/get` and `Appearance/set`. Per user, so no mail capability is involved. */ val USING_APPEARANCE = listOf(CORE, APPEARANCE) - - /** - * For `Calendar/get` and every `CalendarEvent` method. - * - * Mail is deliberately absent: a calendar request needs nothing from it, and declaring - * capabilities a request does not use is how a client ends up broken on an instance where one - * of them is switched off. - */ - val USING_CALENDARS = listOf(CORE, CALENDARS) } diff --git a/core/jmap/src/main/kotlin/de/plmail/jmap/protocol/Session.kt b/core/jmap/src/main/kotlin/de/plmail/jmap/protocol/Session.kt index 89a1d676..89896454 100644 --- a/core/jmap/src/main/kotlin/de/plmail/jmap/protocol/Session.kt +++ b/core/jmap/src/main/kotlin/de/plmail/jmap/protocol/Session.kt @@ -1,5 +1,6 @@ package de.plmail.jmap.protocol +import de.plmail.jmap.calendar.CalendarDialect import java.time.Duration import java.util.Locale import kotlinx.serialization.Serializable @@ -78,10 +79,20 @@ data class Session( * * Read from `primaryAccounts` under the calendars URN rather than reused from * [primaryMailAccount]. The two agree on this server, which is exactly what would let a wrong - * assumption go unnoticed until an instance disagreed. + * assumption go unnoticed until an instance disagreed. The URN is [calendarDialect]'s, so the + * account is the one the request will be declared against. */ val primaryCalendarAccount: AccountId? - get() = primaryAccounts[Capability.CALENDARS]?.let(::AccountId) + get() = calendarDialect?.let { primaryAccounts[it.capability] }?.let(::AccountId) + + /** + * Which calendars URN this server is read through, or null when it serves no calendars. + * + * The IETF one wherever it is advertised; plMail's vendor one only from a server released + * before the IETF one. See [CalendarDialect]. + */ + val calendarDialect: CalendarDialect? + get() = CalendarDialect.of(capabilities.keys) /** * The calendar limits for one account, or null when that account serves no calendars. @@ -89,8 +100,15 @@ data class Session( * Absence is the signal — the second account in a multi-mailbox login publishes no calendars * capability at all — so this doubles as the check for whether calendars may be asked of it. */ - fun calendars(id: AccountId): CalendarsCapability? = - account(id)?.accountCapabilities?.get(Capability.CALENDARS)?.let(CalendarsCapability::from) + fun calendars(id: AccountId): CalendarsCapability? { + val dialect = calendarDialect ?: return null + val json = account(id)?.accountCapabilities?.get(dialect.capability) ?: return null + + return when (dialect) { + CalendarDialect.IETF -> CalendarsCapability.fromIetf(json, core) + CalendarDialect.PLMAIL -> CalendarsCapability.from(json) + } + } /** * Whether `Email/set` on this account stores the `identityId` a draft names. @@ -115,9 +133,9 @@ data class Session( fun supportsInboxTabs(id: AccountId): Boolean = account(id)?.accountCapabilities?.containsKey(Capability.INBOX_TABS) == true - /** Whether this server exposes calendars at all. */ + /** Whether this server exposes calendars at all, under either URN. */ val supportsCalendars: Boolean - get() = capabilities.containsKey(Capability.CALENDARS) + get() = calendarDialect != null /** * What this account will accept on an `EmailSubmission/set`, scheduling included. @@ -319,12 +337,16 @@ data class SubmissionCapability( } /** - * The per-account `urn:plmail:params:jmap:calendars` limits. + * The per-account calendar limits, from whichever calendars URN the session is read through. + * + * Under plMail's vendor URN, [maxEventsInGet] is **not** core's `maxObjectsInGet`, and the gap is + * the point: that server allows 500 objects in a general get and 100 events, because expanding a + * recurring series is far more work than reading a row. A client that chunked its hydration by the + * core limit would have every calendar request refused there. * - * [maxEventsInGet] is **not** core's `maxObjectsInGet`, and the gap is the point: the server allows - * 500 objects in a general get and 100 events, because expanding a recurring series is far more - * work than reading a row. A client that chunked its hydration by the core limit would have every - * calendar request refused. + * Under the IETF URN the draft gives a client no calendar limit to obey, so the server holds its + * calendar gets to core's `maxObjectsInGet` and that is what [fromIetf] reads. Nor does the draft + * publish a [materialisedHorizon]; it is left empty, which the app already treats as "unknown". */ data class CalendarsCapability( val maxEventsInGet: Int = 100, @@ -359,6 +381,15 @@ data class CalendarsCapability( ), ) } + + /** The draft's account capability (draft-ietf-jmap-calendars §1.5), and core's limits. */ + fun fromIetf(json: JsonObject, core: CoreCapability): CalendarsCapability = + CalendarsCapability( + maxEventsInGet = core.maxObjectsInGet, + maxEventsInSet = core.maxObjectsInSet, + mayCreateCalendar = + (json["mayCreateCalendar"] as? JsonPrimitive)?.booleanOrNull ?: false, + ) } } diff --git a/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarDialectTest.kt b/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarDialectTest.kt new file mode 100644 index 00000000..73970900 --- /dev/null +++ b/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarDialectTest.kt @@ -0,0 +1,280 @@ +package de.plmail.jmap.calendar + +import de.plmail.jmap.methods.CalendarEventGet +import de.plmail.jmap.methods.CalendarEventPatch +import de.plmail.jmap.methods.CalendarEventSet +import de.plmail.jmap.methods.NewCalendarEvent +import de.plmail.jmap.protocol.AccountId +import de.plmail.jmap.protocol.CalendarEventId +import de.plmail.jmap.protocol.CalendarId +import de.plmail.jmap.protocol.Capability +import de.plmail.jmap.protocol.MethodResults +import de.plmail.jmap.protocol.Session +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Which calendars URN the app speaks, and the vendor one it still reads. + * + * The answers here are written out rather than captured, because no seed of the 8002 stack speaks + * the IETF URN yet; they follow the server's own `IetfCalendarShape`, which is what turns plMail's + * shape into the draft's under that URN: `calendarIds` for `calendarId`, one `recurrenceRule` for + * `recurrenceRules`, `baseEventId` for `seriesId`, and no `isRecurring`, `kind` or `source`. + */ +class CalendarDialectTest { + + private val account = AccountId("1") + + // --- which URN --- + + @Test + fun `a server that speaks both is read through the IETF URN`() { + val session = session(Capability.CALENDARS, Capability.PLMAIL_CALENDARS) + + assertEquals(CalendarDialect.IETF, session.calendarDialect) + assertEquals(AccountId("1"), session.primaryCalendarAccount) + assertEquals( + listOf(Capability.CORE, Capability.CALENDARS), + session.calendarDialect!!.using, + ) + } + + @Test + fun `a server from before the IETF URN is read through the vendor one`() { + val session = session(Capability.PLMAIL_CALENDARS) + + assertEquals(CalendarDialect.PLMAIL, session.calendarDialect) + assertEquals(AccountId("1"), session.primaryCalendarAccount) + assertEquals(100, session.calendars(account)!!.maxEventsInGet) + } + + @Test + fun `a server with neither serves no calendars`() { + val session = session() + + assertNull(session.calendarDialect) + assertNull(session.primaryCalendarAccount) + assertNull(session.calendars(account)) + assertFalse(session.supportsCalendars) + } + + @Test + fun `under the IETF URN a get is held to core's limit, and no horizon is published`() { + // The draft gives a client no calendar limit, and the server holds its + // calendar gets to maxObjectsInGet there instead. + val limits = session(Capability.CALENDARS, Capability.PLMAIL_CALENDARS).calendars(account)!! + + assertEquals(500, limits.maxEventsInGet) + assertFalse(limits.mayCreateCalendar) + assertEquals("", limits.materialisedHorizon.future, "unknown, as on any bare capability") + } + + // --- reading --- + + @Test + fun `an IETF event is read in the draft's names`() { + val events = + get( + CalendarDialect.IETF, + """ + {"id":"10867","@type":"Event","calendarIds":{"10542":true},"title":"Standup", + "start":"2026-08-03T10:00:00","duration":"PT15M","timeZone":"Europe/Berlin", + "recurrenceRule":{"@type":"RecurrenceRule","frequency":"weekly", + "byDay":[{"@type":"NDay","day":"mo"}]}}, + {"id":"10867_20260805T080000Z","calendarIds":{"10542":true}, + "baseEventId":"10867","recurrenceId":"2026-08-05T10:00:00", + "start":"2026-08-05T10:00:00","recurrenceRule":null}, + {"id":"10865","calendarIds":{"10542":true},"title":"Dentist", + "start":"2026-08-04T09:00:00","recurrenceRule":null} + """, + ) + val (standup, occurrence, dentist) = events + + assertEquals(CalendarId("10542"), standup.calendarId) + assertEquals("weekly", standup.recurrenceRule?.frequency) + assertTrue(standup.isRecurring, "no isRecurring under the draft: the rule is the answer") + assertNull(standup.publishedRecurring) + + assertTrue(occurrence.isOccurrence) + assertEquals(CalendarEventId("10867"), occurrence.writableId) + + assertFalse(dentist.isRecurring) + } + + @Test + fun `a vendor event is read into the same names`() { + val (standup, occurrence) = + get( + CalendarDialect.PLMAIL, + """ + {"id":"10867","calendarId":"10542","title":"Standup", + "recurrenceRules":[{"@type":"RecurrenceRule","frequency":"weekly"}], + "isRecurring":false,"kind":null,"source":"manual"}, + {"id":"10867_20260805T080000Z","calendarId":"10542","seriesId":"10867", + "recurrenceRules":null} + """, + ) + + assertEquals(CalendarId("10542"), standup.calendarId) + assertEquals("weekly", standup.recurrenceRule?.frequency) + assertFalse( + standup.isRecurring, + "the server's own answer wins: a rule it could not convert does not recur", + ) + assertEquals(CalendarEventId("10867"), occurrence.writableId) + assertNull(occurrence.recurrenceRule) + } + + @Test + fun `properties are asked for in the dialect's names`() { + val asked = listOf("id", "calendarIds", "baseEventId", "recurrenceRule", "title") + + assertEquals(asked, propertiesSent(CalendarDialect.IETF, asked)) + assertEquals( + listOf("id", "calendarId", "seriesId", "recurrenceRules", "title", "isRecurring"), + propertiesSent(CalendarDialect.PLMAIL, asked), + "and the vendor shape is asked for isRecurring where the rule is wanted", + ) + } + + // --- writing --- + + @Test + fun `a vendor create is sent in plMail's names`() { + val create = + CalendarEventSet( + account, + create = mapOf("e1" to newEvent(RecurrenceRule(frequency = "daily"))), + dialect = CalendarDialect.PLMAIL, + ) + .arguments()["create"]!! + .jsonObject + .getValue("e1") + .jsonObject + + assertEquals("10542", create["calendarId"]?.jsonPrimitive?.content) + assertFalse(create.containsKey("calendarIds")) + assertEquals( + "daily", + create["recurrenceRules"]!!.jsonArray.single().jsonObject["frequency"] + ?.jsonPrimitive + ?.content, + ) + assertFalse(create.containsKey("recurrenceRule")) + } + + @Test + fun `an IETF create is sent as built`() { + val event = newEvent(RecurrenceRule(frequency = "daily")) + val create = + CalendarEventSet(account, create = mapOf("e1" to event)) + .arguments()["create"]!! + .jsonObject + .getValue("e1") + + assertEquals(event.toJson(), create) + } + + @Test + fun `a vendor patch moving calendars and stopping a series is sent in plMail's names`() { + val patch = + CalendarEventSet( + account, + update = + mapOf( + CalendarEventId("10867") to + CalendarEventPatch.build { + calendarId(CalendarId("10543")) + recurrenceRule(null) + } + ), + dialect = CalendarDialect.PLMAIL, + ) + .arguments()["update"]!! + .jsonObject + .getValue("10867") + .jsonObject + + assertEquals(setOf("calendarId", "recurrenceRules"), patch.keys) + assertEquals("10543", patch["calendarId"]?.jsonPrimitive?.content) + assertEquals(JsonNull, patch["recurrenceRules"]) + } + + @Test + fun `a vendor create's answer is read in the draft's names`() { + val created = + CalendarEventSet(account, dialect = CalendarDialect.PLMAIL) + .decode( + MethodResults.JMAP_JSON, + Json.parseToJsonElement( + """ + {"accountId":"1","oldState":"fixed","newState":"fixed", + "created":{"c1":{"id":"10999","calendarId":"10542", + "isRecurring":true,"sequence":0}}} + """ + ) + .jsonObject, + ) + .created + .getValue("c1") + + assertEquals(CalendarId("10542"), created.calendarId) + assertEquals(true, created.isRecurring) + } + + private fun newEvent(rule: RecurrenceRule?) = + NewCalendarEvent( + calendarId = CalendarId("10542"), + title = "Standup", + start = "2026-08-03T10:00:00", + duration = "PT15M", + recurrenceRule = rule, + ) + + private fun get(dialect: CalendarDialect, list: String): List = + CalendarEventGet(account, dialect = dialect) + .decode( + MethodResults.JMAP_JSON, + Json.parseToJsonElement("""{"accountId":"1","state":"s1","list":[$list]}""") + .jsonObject, + ) + .list + + private fun propertiesSent(dialect: CalendarDialect, asked: List): List = + CalendarEventGet(account, ids = emptyList(), properties = asked, dialect = dialect) + .arguments()["properties"]!! + .jsonArray + .map { it.jsonPrimitive.content } + + /** A one-account session advertising [calendars], at session level and on the account. */ + private fun session(vararg calendars: String): Session { + val top = calendars.joinToString("") { ""","$it":{}""" } + val perAccount = + calendars.joinToString("") { + if (it == Capability.PLMAIL_CALENDARS) ""","$it":{"maxEventsInGet":100}""" + else ""","$it":{"maxCalendarsPerEvent":1,"mayCreateCalendar":false}""" + } + val primary = calendars.joinToString("") { ""","$it":"1"""" } + + return MethodResults.JMAP_JSON.decodeFromString( + Session.serializer(), + """ + {"capabilities":{"urn:ietf:params:jmap:core":{"maxObjectsInGet":500}, + "urn:ietf:params:jmap:mail":{}$top}, + "accounts":{"1":{"name":"a@example.org","isPersonal":true,"isReadOnly":false, + "accountCapabilities":{"urn:ietf:params:jmap:mail":{}$perAccount}}}, + "primaryAccounts":{"urn:ietf:params:jmap:mail":"1"$primary}, + "username":"a@example.org","apiUrl":"https://x/jmap/api", + "downloadUrl":"https://x/d","uploadUrl":"https://x/u","state":"0"} + """, + ) + } +} diff --git a/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarMethodResultsTest.kt b/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarMethodResultsTest.kt index a6e21ddd..fbfab792 100644 --- a/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarMethodResultsTest.kt +++ b/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarMethodResultsTest.kt @@ -23,16 +23,20 @@ import kotlin.test.assertTrue import kotlinx.serialization.json.jsonPrimitive /** - * Decoding real calendar responses. + * Decoding real calendar responses, as a server that speaks only plMail's vendor calendars URN + * sends them. * - * Every fixture here came off the live 8002 stack. The assertions worth having are the ones that - * contradict the shape a reader would assume from the mail surface: an event whose `timeZone` key - * is absent entirely, an `isRecurring` that is not `recurrenceRules != null`, and a `state` that is - * a constant rather than a cursor. + * Every fixture here came off the live 8002 stack, from before the server spoke the IETF URN, so + * every event method is read through [CalendarDialect.PLMAIL] — which makes this the suite for that + * fallback too: each assertion reads the draft's names off a vendor-shaped answer. The assertions + * worth having are the ones that contradict the shape a reader would assume from the mail surface: + * an event whose `timeZone` key is absent entirely, an `isRecurring` that is not `recurrenceRule != + * null`, and a `state` that is a constant rather than a cursor. */ class CalendarMethodResultsTest { private val account = AccountId("1") + private val vendor = CalendarDialect.PLMAIL private val window = CalendarEventFilter("2026-08-01T00:00:00", "2026-09-01T00:00:00") private fun results(fixture: String) = @@ -46,7 +50,7 @@ class CalendarMethodResultsTest { results("calendar-get.json").result(handle(CalendarGet(account), "c0")) private fun eventGet(fixture: String, callId: String) = - results(fixture).result(handle(CalendarEventGet(account), callId)) + results(fixture).result(handle(CalendarEventGet(account, dialect = vendor), callId)) // --- Calendar/get --- @@ -127,7 +131,7 @@ class CalendarMethodResultsTest { fun `the back-referenced get answers the query's own ids`() { val results = results("event-query-get.json") val requested = results.result(handle(CalendarEventQuery(account, window), "q0")).ids - val returned = results.result(handle(CalendarEventGet(account), "g0")) + val returned = results.result(handle(CalendarEventGet(account, dialect = vendor), "g0")) assertEquals(requested, returned.list.map { it.id }) assertEquals( @@ -174,7 +178,7 @@ class CalendarMethodResultsTest { results.result( handle(CalendarEventQuery(account, window, expandRecurrences = true), "q0") ) - val events = results.result(handle(CalendarEventGet(account), "g0")) + val events = results.result(handle(CalendarEventGet(account, dialect = vendor), "g0")) assertEquals(5, query.total, "three standups, a dentist and an all-day party") assertEquals(query.ids, events.list.map { it.id }) @@ -191,7 +195,7 @@ class CalendarMethodResultsTest { assertEquals( listOf(CalendarEventId("10867")), standups.map { it.writableId }.distinct(), - "and all three write through one series id, read off seriesId and not out of the id", + "and all three write through one series id, read off baseEventId and not out of the id", ) assertEquals( listOf("2026-08-03T10:00:00", "2026-08-05T10:00:00", "2026-08-07T10:00:00"), @@ -212,11 +216,11 @@ class CalendarMethodResultsTest { // response, so a month would draw nothing. val occurrence = results("event-query-get-expanded.json") - .result(handle(CalendarEventGet(account), "g0")) + .result(handle(CalendarEventGet(account, dialect = vendor), "g0")) .list .first { it.isOccurrence } - assertEquals(emptyList(), occurrence.recurrenceRules) + assertNull(occurrence.recurrenceRule) assertEquals(emptyMap(), occurrence.recurrenceOverrides) } @@ -254,7 +258,7 @@ class CalendarMethodResultsTest { fun `an occurrence id is refused by set, per object, naming seriesId`() { val set = results("event-query-get-expanded.json") - .result(handle(CalendarEventSet(account), "instance-set")) + .result(handle(CalendarEventSet(account, dialect = vendor), "instance-set")) val error = set.notUpdated.getValue("10867_20260807T080000Z") @@ -307,21 +311,19 @@ class CalendarMethodResultsTest { @Test fun `isRecurring is read from the wire rather than derived from the rules`() { // A rule plMail cannot convert is stored verbatim and expands to one - // occurrence, so `recurrenceRules != null` is a different question and + // occurrence, so `recurrenceRule != null` is a different question and // the two disagree on exactly the imported events users notice. val events = eventGet("event-query-get.json", "g0").list val standup = events.first() val dentist = events.single { it.id.value == "10865" } assertTrue(standup.isRecurring) - assertEquals("weekly", standup.recurrenceRules.single().frequency) - assertEquals( - listOf("mo", "we", "fr"), - standup.recurrenceRules.single().byDay.map { it.day }, - ) + assertEquals(true, standup.publishedRecurring, "the vendor shape says so outright") + assertEquals("weekly", standup.recurrenceRule?.frequency) + assertEquals(listOf("mo", "we", "fr"), standup.recurrenceRule?.byDay?.map { it.day }) assertFalse(dentist.isRecurring) - assertEquals(emptyList(), dentist.recurrenceRules) + assertNull(dentist.recurrenceRule) } @Test @@ -336,11 +338,11 @@ class CalendarMethodResultsTest { } @Test - fun `plMail's own event extensions survive decoding`() { + fun `the vendor shape is read into the draft's names`() { val standup = eventGet("event-query-get.json", "g0").list.first() - assertEquals("manual", standup.source) - assertNull(standup.kind, "kind is published as null rather than omitted") + assertEquals(mapOf("10542" to true), standup.calendarIds) + assertEquals(CalendarId("10542"), standup.calendarId) assertEquals("public", standup.privacy) } @@ -356,7 +358,7 @@ class CalendarMethodResultsTest { @Test fun `a create echoes only what the server decided`() { - val set = results("event-set-create.json").result(handle(CalendarEventSet(account), "s0")) + val set = results("event-set-create.json").result(handle(CalendarEventSet(account, dialect = vendor), "s0")) assertEquals(setOf("e1", "e2", "e3", "e4"), set.created.keys) @@ -376,7 +378,7 @@ class CalendarMethodResultsTest { results("event-errors-update-destroy.json") .result( handle( - CalendarEventSet(account), + CalendarEventSet(account, dialect = vendor), "upd", ) ) @@ -391,7 +393,7 @@ class CalendarMethodResultsTest { results("event-errors-update-destroy.json") .result( handle( - CalendarEventSet(account), + CalendarEventSet(account, dialect = vendor), "upd", ) ) @@ -447,7 +449,7 @@ class CalendarMethodResultsTest { results("event-errors-update-destroy.json") .result( handle( - CalendarEventSet(account), + CalendarEventSet(account, dialect = vendor), "bad-props", ) ) @@ -466,7 +468,7 @@ class CalendarMethodResultsTest { results("event-errors-update-destroy.json") .result( handle( - CalendarEventSet(account), + CalendarEventSet(account, dialect = vendor), "bad-start", ) ) diff --git a/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarWriteMethodsTest.kt b/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarWriteMethodsTest.kt index d8e38c06..8cdb44d3 100644 --- a/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarWriteMethodsTest.kt +++ b/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarWriteMethodsTest.kt @@ -64,17 +64,19 @@ class CalendarWriteMethodsTest { // --- using --- @Test - fun `a calendar request declares the vendor URN and not mail`() { + fun `a calendar request declares the IETF URN and not mail`() { // The opposite of the push URN, which the server advertises and refuses // in `using`. Omitting this one fails the calendar call; including mail // declares a capability the request never uses. - val request = RequestBuilder(Capability.USING_CALENDARS).apply { add(CalendarGet(account)) } + val request = + RequestBuilder(CalendarDialect.IETF.using).apply { add(CalendarGet(account)) } val using = request.build()["using"]!!.jsonArray.map { it.jsonPrimitive.content } - assertEquals(listOf(Capability.CORE, Capability.CALENDARS), using) + assertEquals(listOf(Capability.CORE, "urn:ietf:params:jmap:calendars"), using) assertFalse(using.contains(Capability.MAIL)) assertFalse(using.contains(Capability.PUSH)) + assertFalse(using.contains(Capability.PLMAIL_CALENDARS)) } // --- query --- @@ -158,10 +160,15 @@ class CalendarWriteMethodsTest { val create = newEvent().toJson() assertEquals( - setOf("@type", "calendarId", "title", "start", "duration", "status"), + setOf("@type", "calendarIds", "title", "start", "duration", "status"), create.keys, ) assertEquals("Event", create["@type"]?.jsonPrimitive?.content) + assertEquals( + mapOf("10542" to true), + create["calendarIds"]!!.jsonObject.mapValues { it.value.jsonPrimitive.content == "true" }, + "the draft's map, naming the one calendar", + ) assertFalse(create.containsKey("privacy"), "published but not settable") assertFalse(create.containsKey("isRecurring"), "derived server-side") } @@ -195,7 +202,7 @@ class CalendarWriteMethodsTest { } @Test - fun `a recurrence rule is sent as a one-element array with lowercase day codes`() { + fun `a recurrence rule is sent as the draft's one rule with lowercase day codes`() { // JSCalendar spells days `mo`; iCalendar spells them `MO`. An importer // written against RFC 5545 gets this wrong silently. val create = @@ -208,15 +215,13 @@ class CalendarWriteMethodsTest { ) .toJson() - val rules = create["recurrenceRules"]!!.jsonArray + val rule = create["recurrenceRule"]!!.jsonObject - assertEquals(1, rules.size, "the server refuses a second rule") - assertEquals("RecurrenceRule", rules[0].jsonObject["@type"]?.jsonPrimitive?.content) + assertFalse(create.containsKey("recurrenceRules"), "plMail's vendor spelling") + assertEquals("RecurrenceRule", rule["@type"]?.jsonPrimitive?.content) assertEquals( listOf("mo", "we"), - rules[0].jsonObject["byDay"]!!.jsonArray.map { - it.jsonObject["day"]!!.jsonPrimitive.content - }, + rule["byDay"]!!.jsonArray.map { it.jsonObject["day"]!!.jsonPrimitive.content }, ) } @@ -269,7 +274,7 @@ class CalendarWriteMethodsTest { fun `moving an event between calendars is a plain property change`() { val patch = CalendarEventPatch.build { calendarId(CalendarId("10543")) }.toJson() - assertEquals("10543", patch["calendarId"]?.jsonPrimitive?.content) + assertEquals(setOf("10543"), patch["calendarIds"]?.jsonObject?.keys) } @Test @@ -282,10 +287,10 @@ class CalendarWriteMethodsTest { } @Test - fun `stopping a series recurring nulls the whole rules array`() { + fun `stopping a series recurring nulls the rule`() { assertEquals( JsonNull, - CalendarEventPatch.build { recurrenceRule(null) }.toJson()["recurrenceRules"], + CalendarEventPatch.build { recurrenceRule(null) }.toJson()["recurrenceRule"], ) } @@ -367,7 +372,26 @@ class CalendarWriteMethodsTest { ) assertEquals("df7a068@plmail", answered.created.getValue("c1").uid) - assertTrue(answered.created.getValue("c1").isRecurring) + assertEquals(true, answered.created.getValue("c1").isRecurring) + } + + @Test + fun `a created event under the IETF URN says nothing about recurring`() { + // The draft has no isRecurring; the server drops it there, and the + // caller is left to the rule it sent. + val answered = + decodeSet( + """ + {"accountId":"1","oldState":"s1","newState":"s2", + "created":{"c1":{"id":"10999","uid":"df7a068@plmail", + "calendarIds":{"10542":true},"sequence":0}}, + "notCreated":{},"updated":{},"notUpdated":{}, + "destroyed":[],"notDestroyed":{}} + """ + ) + + assertNull(answered.created.getValue("c1").isRecurring) + assertEquals(CalendarId("10542"), answered.created.getValue("c1").calendarId) } private fun decodeSet(json: String): CalendarEventSetResult = diff --git a/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarsCapabilityTest.kt b/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarsCapabilityTest.kt index c68fbea5..dea7dbb9 100644 --- a/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarsCapabilityTest.kt +++ b/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarsCapabilityTest.kt @@ -17,7 +17,9 @@ import kotlinx.serialization.json.JsonObject * How the session announces calendars. * * Captured from a two-account login, which is the shape that matters: only one of the two accounts - * serves calendars, and the difference is invisible from the session's top level. + * serves calendars, and the difference is invisible from the session's top level. Captured before + * the server spoke the IETF URN, too, so this is a vendor-only session — the fallback; see + * [CalendarDialectTest] for a server that speaks both. */ class CalendarsCapabilityTest { @@ -26,6 +28,7 @@ class CalendarsCapabilityTest { @Test fun `the vendor URN is advertised at the session level`() { assertTrue(session.supportsCalendars) + assertEquals(CalendarDialect.PLMAIL, session.calendarDialect) assertFalse( Fixture.decode("session.json").supportsCalendars, "an instance without the extension must degrade rather than fail", @@ -39,7 +42,7 @@ class CalendarsCapabilityTest { // disagreed. assertEquals(AccountId("1"), session.primaryCalendarAccount) assertEquals(session.primaryMailAccount, session.primaryCalendarAccount) - assertEquals("1", session.primaryAccounts[Capability.CALENDARS]) + assertEquals("1", session.primaryAccounts[Capability.PLMAIL_CALENDARS]) } @Test diff --git a/core/jmap/src/test/resources/jmap/README.md b/core/jmap/src/test/resources/jmap/README.md index e7ebd3fc..723c2bcd 100644 --- a/core/jmap/src/test/resources/jmap/README.md +++ b/core/jmap/src/test/resources/jmap/README.md @@ -85,6 +85,13 @@ surface, neither of which produces a fixture: Captured on 2026-08-05 against the same stack, after seeding four events onto the calendars the mail seed had already created. +**These are vendor-shaped answers**, from before the server spoke `urn:ietf:params:jmap:calendars` +(pl_mail#409). The app reads that URN wherever it is advertised and these only from an older +server, so the suites decode them through `CalendarDialect.PLMAIL`, which reads `calendarId`, +`recurrenceRules` and `seriesId` into the draft's `calendarIds`, `recurrenceRule` and +`baseEventId`. The IETF shape has no captures yet; `CalendarDialectTest` writes its answers out, +following the server's `IetfCalendarShape`. Capture a set once a seeded stack speaks it. + | File | Pins down | |---|---| | `session-calendars.json` | The vendor calendar capability, at session level *and* per account — and that a **two-account** login carries it on one account only. `primaryAccounts` has its own key for the calendars URN; it happens to equal the mail primary, which is what would let a client reusing `primaryMailAccount` go unnoticed. Note the VAPID key differs from `session.json`: this is a later seed, and that is why it is a second file rather than a replacement. | diff --git a/feature/calendar/src/main/kotlin/de/plmail/feature/calendar/EventEditorScreen.kt b/feature/calendar/src/main/kotlin/de/plmail/feature/calendar/EventEditorScreen.kt index 886ccaa2..3fca834c 100644 --- a/feature/calendar/src/main/kotlin/de/plmail/feature/calendar/EventEditorScreen.kt +++ b/feature/calendar/src/main/kotlin/de/plmail/feature/calendar/EventEditorScreen.kt @@ -80,7 +80,7 @@ import java.time.temporal.ChronoUnit * **A full recurrence editor is also out of scope**, and the shape of what is here is what protects * elaborate rules rather than a shortcut around writing one. The dropdown appears on a *create* * only. Editing an event that already repeats shows a read-only line instead, because an update - * never sends `recurrenceRules`: the cache stores whether a series recurs and not the rule it + * never sends `recurrenceRule`: the cache stores whether a series recurs and not the rule it * recurs by, so a dropdown on an edit could only send a rule derived from nothing — flattening * "every second Tuesday" to "every week", or clearing it altogether, because null on that property * means "stop recurring". From f000796adc337010cbc3feb47a84cfaec602cd4c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 18:14:24 +0000 Subject: [PATCH 2/5] Format the calendar dialect change with ktfmt, and fix one test's assertion Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FXqu9YFt9pfgWBaXXy2zZa --- .../de/plmail/core/data/CalendarRepository.kt | 15 +++++++++------ .../de/plmail/core/data/CalendarRepositoryTest.kt | 6 +++--- .../kotlin/de/plmail/core/data/CalendarStack.kt | 8 +++----- .../kotlin/de/plmail/core/database/Entities.kt | 7 +++---- .../de/plmail/jmap/calendar/CalendarDialect.kt | 12 +++++------- .../de/plmail/jmap/methods/CalendarEventSet.kt | 8 ++++++-- .../de/plmail/jmap/methods/CalendarMethods.kt | 10 +++++----- .../plmail/jmap/calendar/CalendarDialectTest.kt | 5 ++++- .../jmap/calendar/CalendarMethodResultsTest.kt | 6 ++++-- .../jmap/calendar/CalendarWriteMethodsTest.kt | 7 ++++--- .../plmail/feature/calendar/EventEditorScreen.kt | 8 ++++---- 11 files changed, 50 insertions(+), 42 deletions(-) diff --git a/core/data/src/main/kotlin/de/plmail/core/data/CalendarRepository.kt b/core/data/src/main/kotlin/de/plmail/core/data/CalendarRepository.kt index f0ec9b18..5a0fff66 100644 --- a/core/data/src/main/kotlin/de/plmail/core/data/CalendarRepository.kt +++ b/core/data/src/main/kotlin/de/plmail/core/data/CalendarRepository.kt @@ -282,8 +282,8 @@ interface EventEditing { * * An occurrence id is **opaque** — `42_20260304T090000Z` is the series id and the occurrence's * original start, and reading either half back out is the same client-side expansion by a quieter - * route. Which series an occurrence belongs to is its object's `baseEventId`, and where it goes is its - * object's `start`. + * route. Which series an occurrence belongs to is its object's `baseEventId`, and where it goes is + * its object's `start`. */ @Singleton class CalendarRepository @@ -758,7 +758,10 @@ constructor( val q = batch.add(query(accountId, after, before, paging.position, pageSize)) - q to batch.add(hydrate(session, accountId, q.reference("/ids"), CACHED_PROPERTIES)) + q to + batch.add( + hydrate(session, accountId, q.reference("/ids"), CACHED_PROPERTIES) + ) } val occurrenceHandles = @@ -1197,9 +1200,9 @@ constructor( * Which days those are is read off the object the server answered for this occurrence id and * from nothing else: its `start` is already resolved against whatever override moved it, so * there is no rule to expand, no override map to interpret, and no timestamp to lift out of an - * id. [series] is the row it hangs off — [CalendarEvent.baseEventId]'s object, never a prefix of - * the occurrence id — and an occurrence whose series did not come back is dropped rather than - * orphaned, because nothing joins to a series row that is not there. + * id. [series] is the row it hangs off — [CalendarEvent.baseEventId]'s object, never a prefix + * of the occurrence id — and an occurrence whose series did not come back is dropped rather + * than orphaned, because nothing joins to a series row that is not there. * * The day an occurrence lands on is the reader's: an event that names a zone is an instant, and * is placed where that instant falls on the device's clock — see [toDeviceClock]. An all-day or diff --git a/core/data/src/test/kotlin/de/plmail/core/data/CalendarRepositoryTest.kt b/core/data/src/test/kotlin/de/plmail/core/data/CalendarRepositoryTest.kt index dcc80e24..f4a5f88d 100644 --- a/core/data/src/test/kotlin/de/plmail/core/data/CalendarRepositoryTest.kt +++ b/core/data/src/test/kotlin/de/plmail/core/data/CalendarRepositoryTest.kt @@ -537,9 +537,9 @@ class CalendarRepositoryTest { * An edit of a recurring series goes to the series id, never to an occurrence's. * * `CalendarEvent/set` refuses an occurrence id by name — `invalidArguments`, pointing at - * `baseEventId` — so a cache that had filed a standup under `o2` would have an editor that cannot - * save. The occurrence ids never reach a row at all, which is what this asserts from the other - * end: the id on the wire is the plain one. + * `baseEventId` — so a cache that had filed a standup under `o2` would have an editor that + * cannot save. The occurrence ids never reach a row at all, which is what this asserts from the + * other end: the id on the wire is the plain one. */ @Test fun `an edit of a recurring series is addressed to the series id`() = runTest { diff --git a/core/data/src/test/kotlin/de/plmail/core/data/CalendarStack.kt b/core/data/src/test/kotlin/de/plmail/core/data/CalendarStack.kt index 660076ed..39fbe827 100644 --- a/core/data/src/test/kotlin/de/plmail/core/data/CalendarStack.kt +++ b/core/data/src/test/kotlin/de/plmail/core/data/CalendarStack.kt @@ -543,8 +543,8 @@ private fun FakeInstance.startUtc(event: FakeEvent): LocalDateTime = * The series object: its own start, its own title, and the override map published whole. * * Under the IETF URN a recurring one carries a rule, because that is all the draft says about - * recurring; which rule does not matter, since the days come from [FakeEvent.days]. Under the vendor - * URN it carries `isRecurring`, as plMail's earlier shape does. + * recurring; which rule does not matter, since the days come from [FakeEvent.days]. Under the + * vendor URN it carries `isRecurring`, as plMail's earlier shape does. */ private fun FakeEvent.toJson(dialect: CalendarDialect): String = buildString { append("""{"id":"$id","uid":"${uid ?: "$id@plmail"}",${calendarJson(dialect)},""") @@ -594,9 +594,7 @@ private fun FakeEvent.toJson( if (showWithoutTime) append(""""showWithoutTime":true,""") append(""""$rule":null,"recurrenceOverrides":null,""") if (dialect == CalendarDialect.PLMAIL) append(""""isRecurring":true,""") - append( - """"status":"${if (instance.cancelled) "cancelled" else "confirmed"}","sequence":0}""" - ) + append(""""status":"${if (instance.cancelled) "cancelled" else "confirmed"}","sequence":0}""") } /** diff --git a/core/database/src/main/kotlin/de/plmail/core/database/Entities.kt b/core/database/src/main/kotlin/de/plmail/core/database/Entities.kt index 36429e07..998d52f3 100644 --- a/core/database/src/main/kotlin/de/plmail/core/database/Entities.kt +++ b/core/database/src/main/kotlin/de/plmail/core/database/Entities.kt @@ -523,10 +523,9 @@ data class CalendarEventEntity( * * An imported rule plMail cannot convert is stored verbatim and expands to a single occurrence, * so an event can carry a rule and still not recur. Only plMail's vendor calendars URN says so; - * under the IETF one the draft has no such property, and such an event reads as recurring - * here. What a screen does with it: an editor shows - * a read-only repeat line rather than the dropdown, and a delete warns that it takes the - * series. + * under the IETF one the draft has no such property, and such an event reads as recurring here. + * What a screen does with it: an editor shows a read-only repeat line rather than the dropdown, + * and a delete warns that it takes the series. */ val isRecurring: Boolean = false, val sequence: Int = 0, diff --git a/core/jmap/src/main/kotlin/de/plmail/jmap/calendar/CalendarDialect.kt b/core/jmap/src/main/kotlin/de/plmail/jmap/calendar/CalendarDialect.kt index 8ec73c00..325b0528 100644 --- a/core/jmap/src/main/kotlin/de/plmail/jmap/calendar/CalendarDialect.kt +++ b/core/jmap/src/main/kotlin/de/plmail/jmap/calendar/CalendarDialect.kt @@ -13,19 +13,17 @@ import kotlinx.serialization.json.booleanOrNull * * The app speaks the IETF draft's shape — [CalendarEvent] and every write are spelled in it — and * the server answers in it whenever the request declares [Capability.CALENDARS]. [PLMAIL] is for a - * server released before it did: the same methods under [Capability.PLMAIL_CALENDARS], answering - * in plMail's earlier shape, which is translated here at the edge and nowhere else. The server - * does the same thing in reverse (`IetfCalendarShape`), and for the same reason: when the vendor - * URN goes (pl_mail#409), this enum entry and its three functions go with it and nothing else - * changes. + * server released before it did: the same methods under [Capability.PLMAIL_CALENDARS], answering in + * plMail's earlier shape, which is translated here at the edge and nowhere else. The server does + * the same thing in reverse (`IetfCalendarShape`), and for the same reason: when the vendor URN + * goes (pl_mail#409), this enum entry and its three functions go with it and nothing else changes. * * The two shapes differ in three names and one property: * - an event is filed by a `calendarIds` map in the draft and by one `calendarId` in plMail's; * - it repeats by one `recurrenceRule` in the draft and by a `recurrenceRules` list in plMail's; * - an occurrence names its series by `baseEventId` in the draft and by `seriesId` in plMail's; * - plMail's shape also publishes `isRecurring`, which the draft has nothing to match. Kept on the - * way through, because it is the better answer where it exists — see - * [CalendarEvent.isRecurring]. + * way through, because it is the better answer where it exists — see [CalendarEvent.isRecurring]. */ enum class CalendarDialect(val capability: String) { IETF(Capability.CALENDARS), diff --git a/core/jmap/src/main/kotlin/de/plmail/jmap/methods/CalendarEventSet.kt b/core/jmap/src/main/kotlin/de/plmail/jmap/methods/CalendarEventSet.kt index 1fbe68fd..c7dd594c 100644 --- a/core/jmap/src/main/kotlin/de/plmail/jmap/methods/CalendarEventSet.kt +++ b/core/jmap/src/main/kotlin/de/plmail/jmap/methods/CalendarEventSet.kt @@ -49,7 +49,9 @@ class CalendarEventSet( if (create.isNotEmpty()) { put( "create", - buildJsonObject { create.forEach { (id, e) -> put(id, dialect.write(e.toJson())) } }, + buildJsonObject { + create.forEach { (id, e) -> put(id, dialect.write(e.toJson())) } + }, ) } @@ -76,7 +78,9 @@ class CalendarEventSet( arguments + ("created" to JsonObject( - created.mapValues { (_, e) -> (e as? JsonObject)?.let(dialect::read) ?: e } + created.mapValues { (_, e) -> + (e as? JsonObject)?.let(dialect::read) ?: e + } )) ) diff --git a/core/jmap/src/main/kotlin/de/plmail/jmap/methods/CalendarMethods.kt b/core/jmap/src/main/kotlin/de/plmail/jmap/methods/CalendarMethods.kt index 3a8acdde..61eafb5b 100644 --- a/core/jmap/src/main/kotlin/de/plmail/jmap/methods/CalendarMethods.kt +++ b/core/jmap/src/main/kotlin/de/plmail/jmap/methods/CalendarMethods.kt @@ -13,8 +13,8 @@ import de.plmail.jmap.protocol.StateToken import de.plmail.jmap.protocol.backReference import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.add import kotlinx.serialization.json.buildJsonArray @@ -172,8 +172,8 @@ data class CalendarEventQueryResult( * plMail's own **100** under the vendor one. Expanded, that ceiling counts occurrences, which a * busy month reaches far sooner than it reaches as many series. * - * [properties] and the answer are in the draft's names whatever [dialect] the request declares; - * see [CalendarDialect]. + * [properties] and the answer are in the draft's names whatever [dialect] the request declares; see + * [CalendarDialect]. */ class CalendarEventGet( private val accountId: AccountId, @@ -221,8 +221,8 @@ class CalendarEventGet( companion object { /** - * The vendor account capability's value, as a floor to chunk by when no session is to - * hand — the lowest ceiling any plMail has published. + * The vendor account capability's value, as a floor to chunk by when no session is to hand + * — the lowest ceiling any plMail has published. * * Read `Session.calendars(id).maxEventsInGet` where one is available — an instance * configured for more should not be second-guessed by its own client. diff --git a/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarDialectTest.kt b/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarDialectTest.kt index 73970900..2954937d 100644 --- a/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarDialectTest.kt +++ b/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarDialectTest.kt @@ -164,7 +164,10 @@ class CalendarDialectTest { assertFalse(create.containsKey("calendarIds")) assertEquals( "daily", - create["recurrenceRules"]!!.jsonArray.single().jsonObject["frequency"] + create["recurrenceRules"]!! + .jsonArray + .single() + .jsonObject["frequency"] ?.jsonPrimitive ?.content, ) diff --git a/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarMethodResultsTest.kt b/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarMethodResultsTest.kt index fbfab792..ba9ca819 100644 --- a/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarMethodResultsTest.kt +++ b/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarMethodResultsTest.kt @@ -358,7 +358,9 @@ class CalendarMethodResultsTest { @Test fun `a create echoes only what the server decided`() { - val set = results("event-set-create.json").result(handle(CalendarEventSet(account, dialect = vendor), "s0")) + val set = + results("event-set-create.json") + .result(handle(CalendarEventSet(account, dialect = vendor), "s0")) assertEquals(setOf("e1", "e2", "e3", "e4"), set.created.keys) @@ -367,7 +369,7 @@ class CalendarMethodResultsTest { assertEquals(CalendarEventId("10867"), standup.id) assertEquals("df7a06897f850359167280505e68e0ef@plmail", standup.uid) assertEquals(CalendarId("10542"), standup.calendarId) - assertTrue(standup.isRecurring, "derived server-side, even on the create response") + assertEquals(true, standup.isRecurring, "derived server-side, even on the create response") assertEquals(0, standup.sequence) assertFalse(set.hasFailures) } diff --git a/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarWriteMethodsTest.kt b/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarWriteMethodsTest.kt index 8cdb44d3..1e54316b 100644 --- a/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarWriteMethodsTest.kt +++ b/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarWriteMethodsTest.kt @@ -68,8 +68,7 @@ class CalendarWriteMethodsTest { // The opposite of the push URN, which the server advertises and refuses // in `using`. Omitting this one fails the calendar call; including mail // declares a capability the request never uses. - val request = - RequestBuilder(CalendarDialect.IETF.using).apply { add(CalendarGet(account)) } + val request = RequestBuilder(CalendarDialect.IETF.using).apply { add(CalendarGet(account)) } val using = request.build()["using"]!!.jsonArray.map { it.jsonPrimitive.content } @@ -166,7 +165,9 @@ class CalendarWriteMethodsTest { assertEquals("Event", create["@type"]?.jsonPrimitive?.content) assertEquals( mapOf("10542" to true), - create["calendarIds"]!!.jsonObject.mapValues { it.value.jsonPrimitive.content == "true" }, + create["calendarIds"]!!.jsonObject.mapValues { + it.value.jsonPrimitive.content == "true" + }, "the draft's map, naming the one calendar", ) assertFalse(create.containsKey("privacy"), "published but not settable") diff --git a/feature/calendar/src/main/kotlin/de/plmail/feature/calendar/EventEditorScreen.kt b/feature/calendar/src/main/kotlin/de/plmail/feature/calendar/EventEditorScreen.kt index 3fca834c..c2435f92 100644 --- a/feature/calendar/src/main/kotlin/de/plmail/feature/calendar/EventEditorScreen.kt +++ b/feature/calendar/src/main/kotlin/de/plmail/feature/calendar/EventEditorScreen.kt @@ -80,10 +80,10 @@ import java.time.temporal.ChronoUnit * **A full recurrence editor is also out of scope**, and the shape of what is here is what protects * elaborate rules rather than a shortcut around writing one. The dropdown appears on a *create* * only. Editing an event that already repeats shows a read-only line instead, because an update - * never sends `recurrenceRule`: the cache stores whether a series recurs and not the rule it - * recurs by, so a dropdown on an edit could only send a rule derived from nothing — flattening - * "every second Tuesday" to "every week", or clearing it altogether, because null on that property - * means "stop recurring". + * never sends `recurrenceRule`: the cache stores whether a series recurs and not the rule it recurs + * by, so a dropdown on an edit could only send a rule derived from nothing — flattening "every + * second Tuesday" to "every week", or clearing it altogether, because null on that property means + * "stop recurring". */ @OptIn(ExperimentalMaterial3Api::class) @Composable From 9176cd919594807affc6d5e33badcc01043a315b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 19:45:44 +0000 Subject: [PATCH 3/5] Read whether an event recurs from plmail.org:isRecurring under the IETF URN The server now publishes isRecurring under the IETF calendars URN as the vendor property plmail.org:isRecurring, on CalendarEvent/get and on a create's answer. The model reads that name, the vendor dialect reads plMail's plain isRecurring into it, and it is asked for wherever the rule is. So an imported event whose rule the server could not convert reads as not recurring again under either URN; only a server that publishes neither still leaves the rule as the answer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FXqu9YFt9pfgWBaXXy2zZa --- .../de/plmail/core/data/CalendarRepository.kt | 8 ++-- .../de/plmail/core/data/CalendarStack.kt | 38 +++++++++++-------- .../de/plmail/core/database/Entities.kt | 10 ++--- .../plmail/jmap/calendar/CalendarDialect.kt | 37 ++++++++++-------- .../de/plmail/jmap/calendar/CalendarEvent.kt | 20 +++++++--- .../plmail/jmap/methods/CalendarEventSet.kt | 10 +++-- .../jmap/calendar/CalendarDialectTest.kt | 34 +++++++++++++++-- .../jmap/calendar/CalendarWriteMethodsTest.kt | 21 ++++++++-- 8 files changed, 120 insertions(+), 58 deletions(-) diff --git a/core/data/src/main/kotlin/de/plmail/core/data/CalendarRepository.kt b/core/data/src/main/kotlin/de/plmail/core/data/CalendarRepository.kt index 5a0fff66..2f7b6c1e 100644 --- a/core/data/src/main/kotlin/de/plmail/core/data/CalendarRepository.kt +++ b/core/data/src/main/kotlin/de/plmail/core/data/CalendarRepository.kt @@ -934,8 +934,8 @@ constructor( // Read from the answer where there is one: a rule the server // cannot convert is stored verbatim and expands to one // occurrence, so a create carrying a recurrence can come back - // not recurring. Only plMail's vendor shape answers it; under - // the IETF URN the rule that was sent is all there is to go on. + // not recurring. Only a server that answers neither name + // leaves the rule that was sent as all there is to go on. val recurs = created.isRecurring ?: (stored.recurrenceRule != null) val eventKey = StoreKey.objectKey(calendar.accountKey, created.id.value) @@ -1378,8 +1378,8 @@ constructor( "timeZone", "showWithoutTime", "status", - // What recurring is read from under the IETF URN, which - // publishes no `isRecurring`; see CalendarEvent.isRecurring. + // Asked for with whether it recurs beside it, which the + // server answers better; see CalendarEvent.isRecurring. "recurrenceRule", "sequence", "locations", diff --git a/core/data/src/test/kotlin/de/plmail/core/data/CalendarStack.kt b/core/data/src/test/kotlin/de/plmail/core/data/CalendarStack.kt index 39fbe827..3e8d5f20 100644 --- a/core/data/src/test/kotlin/de/plmail/core/data/CalendarStack.kt +++ b/core/data/src/test/kotlin/de/plmail/core/data/CalendarStack.kt @@ -542,9 +542,9 @@ private fun FakeInstance.startUtc(event: FakeEvent): LocalDateTime = /** * The series object: its own start, its own title, and the override map published whole. * - * Under the IETF URN a recurring one carries a rule, because that is all the draft says about - * recurring; which rule does not matter, since the days come from [FakeEvent.days]. Under the - * vendor URN it carries `isRecurring`, as plMail's earlier shape does. + * A recurring one carries a rule; which does not matter, since the days come from [FakeEvent.days]. + * Whether it recurs is `plmail.org:isRecurring` under the IETF URN and `isRecurring` under the + * vendor one, as the real server publishes them. */ private fun FakeEvent.toJson(dialect: CalendarDialect): String = buildString { append("""{"id":"$id","uid":"${uid ?: "$id@plmail"}",${calendarJson(dialect)},""") @@ -553,17 +553,26 @@ private fun FakeEvent.toJson(dialect: CalendarDialect): String = buildString { if (showWithoutTime) append(""""showWithoutTime":true,""") overrides?.let { append(""""recurrenceOverrides":$it,""") } - when (dialect) { - CalendarDialect.IETF -> - if (isRecurring) { - append(""""recurrenceRule":{"@type":"RecurrenceRule","frequency":"daily"},""") - } - CalendarDialect.PLMAIL -> append(""""isRecurring":$isRecurring,""") + if (isRecurring) { + val rule = """{"@type":"RecurrenceRule","frequency":"daily"}""" + + when (dialect) { + CalendarDialect.IETF -> append(""""recurrenceRule":$rule,""") + CalendarDialect.PLMAIL -> append(""""recurrenceRules":[$rule],""") + } } + append(""""${recurringName(dialect)}":$isRecurring,""") + append(""""status":"confirmed","sequence":0}""") } +private fun recurringName(dialect: CalendarDialect): String = + when (dialect) { + CalendarDialect.IETF -> "plmail.org:isRecurring" + CalendarDialect.PLMAIL -> "isRecurring" + } + private fun FakeEvent.calendarJson(dialect: CalendarDialect): String = when (dialect) { CalendarDialect.IETF -> """"calendarIds":{"$calendarId":true}""" @@ -593,17 +602,15 @@ private fun FakeEvent.toJson( timeZone?.let { append(""""timeZone":"$it",""") } if (showWithoutTime) append(""""showWithoutTime":true,""") append(""""$rule":null,"recurrenceOverrides":null,""") - if (dialect == CalendarDialect.PLMAIL) append(""""isRecurring":true,""") + append(""""${recurringName(dialect)}":true,""") append(""""status":"${if (instance.cancelled) "cancelled" else "confirmed"}","sequence":0}""") } /** * The `CalendarEvent/set` result of a create that worked. * - * Under the vendor URN [isRecurring] is echoed rather than inferred from what was sent, because - * that is what the server does: a rule it cannot convert is stored verbatim and expands to one - * occurrence. Under the IETF URN the server says nothing about recurring, and [isRecurring] is - * ignored. + * [isRecurring] is echoed rather than inferred from what was sent, because that is what the server + * does: a rule it cannot convert is stored verbatim and expands to one occurrence. */ internal fun createdEvent( id: String, @@ -612,7 +619,8 @@ internal fun createdEvent( ): String { val decided = when (dialect) { - CalendarDialect.IETF -> """"calendarIds":{"$TEST_CALENDAR_ID":true}""" + CalendarDialect.IETF -> + """"calendarIds":{"$TEST_CALENDAR_ID":true},"plmail.org:isRecurring":$isRecurring""" CalendarDialect.PLMAIL -> """"calendarId":"$TEST_CALENDAR_ID","isRecurring":$isRecurring""" } diff --git a/core/database/src/main/kotlin/de/plmail/core/database/Entities.kt b/core/database/src/main/kotlin/de/plmail/core/database/Entities.kt index 998d52f3..ee4e74df 100644 --- a/core/database/src/main/kotlin/de/plmail/core/database/Entities.kt +++ b/core/database/src/main/kotlin/de/plmail/core/database/Entities.kt @@ -518,14 +518,12 @@ data class CalendarEventEntity( val location: String? = null, val status: String? = null, /** - * `CalendarEvent.isRecurring`: derived server-side where the server says, and otherwise - * `recurrenceRule != null`. + * `CalendarEvent.isRecurring`: derived server-side, and **not** `recurrenceRule != null`. * * An imported rule plMail cannot convert is stored verbatim and expands to a single occurrence, - * so an event can carry a rule and still not recur. Only plMail's vendor calendars URN says so; - * under the IETF one the draft has no such property, and such an event reads as recurring here. - * What a screen does with it: an editor shows a read-only repeat line rather than the dropdown, - * and a delete warns that it takes the series. + * so an event can carry a rule and still not recur. What a screen does with it: an editor shows + * a read-only repeat line rather than the dropdown, and a delete warns that it takes the + * series. */ val isRecurring: Boolean = false, val sequence: Int = 0, diff --git a/core/jmap/src/main/kotlin/de/plmail/jmap/calendar/CalendarDialect.kt b/core/jmap/src/main/kotlin/de/plmail/jmap/calendar/CalendarDialect.kt index 325b0528..125e4d78 100644 --- a/core/jmap/src/main/kotlin/de/plmail/jmap/calendar/CalendarDialect.kt +++ b/core/jmap/src/main/kotlin/de/plmail/jmap/calendar/CalendarDialect.kt @@ -18,12 +18,13 @@ import kotlinx.serialization.json.booleanOrNull * the same thing in reverse (`IetfCalendarShape`), and for the same reason: when the vendor URN * goes (pl_mail#409), this enum entry and its three functions go with it and nothing else changes. * - * The two shapes differ in three names and one property: + * The two shapes differ in four names: * - an event is filed by a `calendarIds` map in the draft and by one `calendarId` in plMail's; * - it repeats by one `recurrenceRule` in the draft and by a `recurrenceRules` list in plMail's; * - an occurrence names its series by `baseEventId` in the draft and by `seriesId` in plMail's; - * - plMail's shape also publishes `isRecurring`, which the draft has nothing to match. Kept on the - * way through, because it is the better answer where it exists — see [CalendarEvent.isRecurring]. + * - whether it recurs is the vendor property `plmail.org:isRecurring` under the draft's URN, which + * has nothing of its own to say it with, and plain `isRecurring` in plMail's. See + * [CalendarEvent.isRecurring] for why it is asked for at all. */ enum class CalendarDialect(val capability: String) { IETF(Capability.CALENDARS), @@ -39,18 +40,21 @@ enum class CalendarDialect(val capability: String) { val using: List get() = listOf(Capability.CORE, capability) - /** The property names a `CalendarEvent/get` asks for, in this dialect's names. */ - fun properties(names: List): List = - when (this) { - IETF -> names - PLMAIL -> - buildList { - names.forEach { add(VENDOR_NAMES[it] ?: it) } - // The draft infers recurring from the rule; plMail's shape says - // so outright, and better -- see CalendarEvent.isRecurring. - if (RECURRENCE_RULE in names) add(IS_RECURRING) - } + /** + * The property names a `CalendarEvent/get` asks for, in this dialect's names. + * + * Where the rule is asked for, whether the event recurs is asked for beside it: the server's + * answer to that is better than the rule's — see [CalendarEvent.isRecurring]. + */ + fun properties(names: List): List { + val asked = + if (RECURRENCE_RULE in names && IS_RECURRING !in names) names + IS_RECURRING else names + + return when (this) { + IETF -> asked + PLMAIL -> asked.map { VENDOR_NAMES[it] ?: it } } + } /** An event as the server published it, in the draft's shape. */ fun read(event: JsonObject): JsonObject = @@ -64,6 +68,7 @@ enum class CalendarDialect(val capability: String) { VENDOR_CALENDAR_ID -> put(CALENDAR_IDS, calendarIds(value)) VENDOR_RECURRENCE_RULES -> put(RECURRENCE_RULE, firstRule(value)) VENDOR_SERIES_ID -> put(BASE_EVENT_ID, value) + VENDOR_IS_RECURRING -> put(IS_RECURRING, value) else -> put(name, value) } } @@ -111,17 +116,19 @@ enum class CalendarDialect(val capability: String) { private const val CALENDAR_IDS = "calendarIds" private const val RECURRENCE_RULE = "recurrenceRule" private const val BASE_EVENT_ID = "baseEventId" - private const val IS_RECURRING = "isRecurring" + private const val IS_RECURRING = CalendarEvent.IS_RECURRING private const val VENDOR_CALENDAR_ID = "calendarId" private const val VENDOR_RECURRENCE_RULES = "recurrenceRules" private const val VENDOR_SERIES_ID = "seriesId" + private const val VENDOR_IS_RECURRING = "isRecurring" private val VENDOR_NAMES = mapOf( CALENDAR_IDS to VENDOR_CALENDAR_ID, RECURRENCE_RULE to VENDOR_RECURRENCE_RULES, BASE_EVENT_ID to VENDOR_SERIES_ID, + IS_RECURRING to VENDOR_IS_RECURRING, ) private fun calendarIds(calendarId: JsonElement): JsonElement = diff --git a/core/jmap/src/main/kotlin/de/plmail/jmap/calendar/CalendarEvent.kt b/core/jmap/src/main/kotlin/de/plmail/jmap/calendar/CalendarEvent.kt index 690736ab..8bd6969e 100644 --- a/core/jmap/src/main/kotlin/de/plmail/jmap/calendar/CalendarEvent.kt +++ b/core/jmap/src/main/kotlin/de/plmail/jmap/calendar/CalendarEvent.kt @@ -94,9 +94,11 @@ data class CalendarEvent( val created: String? = null, val updated: String? = null, /** - * plMail's derived `isRecurring`, which only its vendor shape publishes. Read [isRecurring]. + * The server's own answer to whether this event recurs — `plmail.org:isRecurring`, a vendor + * property (RFC 8984 §3.3) beside the draft's, or `isRecurring` under plMail's vendor URN. Read + * [isRecurring]. */ - @SerialName("isRecurring") val publishedRecurring: Boolean? = null, + @SerialName(IS_RECURRING) val publishedRecurring: Boolean? = null, /** * The only way back from an occurrence to something writable — the draft's name for what * plMail's vendor shape called `seriesId`. @@ -129,15 +131,21 @@ data class CalendarEvent( /** * Whether this event repeats. * - * plMail's vendor shape says so outright, derived server-side, and that answer wins where it - * exists: a rule plMail cannot convert is stored verbatim and expands to a single occurrence, - * so an event can carry a rule and still not recur. The draft publishes no such property, so - * under it the rule is the answer — wrong only on those imported events, where it errs towards + * The server says so outright, derived server-side, and that answer wins: a rule plMail cannot + * convert is stored verbatim and expands to a single occurrence, so an event can carry a rule + * and still not recur. The draft has no property for it, so plMail publishes one of its own + * under that URN — [publishedRecurring]. Only a server that publishes neither leaves the rule + * as the answer, and that is wrong only on those imported events, where it errs towards * treating a one-off as a series. */ val isRecurring: Boolean get() = publishedRecurring ?: (recurrenceRule != null) + companion object { + /** plMail's vendor property for [isRecurring], under the draft's URN. */ + const val IS_RECURRING = "plmail.org:isRecurring" + } + /** Whether this object is one occurrence of a series rather than the series itself. */ val isOccurrence: Boolean get() = baseEventId != null diff --git a/core/jmap/src/main/kotlin/de/plmail/jmap/methods/CalendarEventSet.kt b/core/jmap/src/main/kotlin/de/plmail/jmap/methods/CalendarEventSet.kt index c7dd594c..1b3dcb41 100644 --- a/core/jmap/src/main/kotlin/de/plmail/jmap/methods/CalendarEventSet.kt +++ b/core/jmap/src/main/kotlin/de/plmail/jmap/methods/CalendarEventSet.kt @@ -1,11 +1,13 @@ package de.plmail.jmap.methods import de.plmail.jmap.calendar.CalendarDialect +import de.plmail.jmap.calendar.CalendarEvent import de.plmail.jmap.calendar.RecurrenceRule import de.plmail.jmap.protocol.AccountId import de.plmail.jmap.protocol.CalendarEventId import de.plmail.jmap.protocol.CalendarId import de.plmail.jmap.protocol.JmapMethod +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement @@ -346,9 +348,9 @@ data class CalendarEventSetResult( * What the server decided about a created event. * * [isRecurring] is here because it is derived server-side: a rule plMail cannot convert is stored - * verbatim and expands to one occurrence, so a create carrying a recurrence rule can come back - * `isRecurring: false`. Only plMail's vendor shape answers it, though; under the IETF URN it is - * null, and the caller falls back on whether it sent a rule — see + * verbatim and expands to one occurrence, so a create carrying a recurrence rule can come back not + * recurring. Under the IETF URN it is the vendor property `plmail.org:isRecurring`; null only from + * a server that publishes neither, and then the caller falls back on whether it sent a rule — see * [de.plmail.jmap.calendar.CalendarEvent.isRecurring]. */ @Serializable @@ -364,7 +366,7 @@ data class CreatedCalendarEvent( val uid: String? = null, /** The draft's id-to-true map; read it through [calendarId]. */ val calendarIds: Map = emptyMap(), - val isRecurring: Boolean? = null, + @SerialName(CalendarEvent.IS_RECURRING) val isRecurring: Boolean? = null, val sequence: Int = 0, ) { val calendarId: CalendarId? diff --git a/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarDialectTest.kt b/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarDialectTest.kt index 2954937d..27d07af7 100644 --- a/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarDialectTest.kt +++ b/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarDialectTest.kt @@ -27,7 +27,8 @@ import kotlinx.serialization.json.jsonPrimitive * The answers here are written out rather than captured, because no seed of the 8002 stack speaks * the IETF URN yet; they follow the server's own `IetfCalendarShape`, which is what turns plMail's * shape into the draft's under that URN: `calendarIds` for `calendarId`, one `recurrenceRule` for - * `recurrenceRules`, `baseEventId` for `seriesId`, and no `isRecurring`, `kind` or `source`. + * `recurrenceRules`, `baseEventId` for `seriesId`, `plmail.org:isRecurring` for `isRecurring`, and + * no `kind` or `source`. */ class CalendarDialectTest { @@ -100,7 +101,10 @@ class CalendarDialectTest { assertEquals(CalendarId("10542"), standup.calendarId) assertEquals("weekly", standup.recurrenceRule?.frequency) - assertTrue(standup.isRecurring, "no isRecurring under the draft: the rule is the answer") + assertTrue( + standup.isRecurring, + "a server that publishes no plmail.org:isRecurring leaves the rule as the answer", + ) assertNull(standup.publishedRecurring) assertTrue(occurrence.isOccurrence) @@ -109,6 +113,24 @@ class CalendarDialectTest { assertFalse(dentist.isRecurring) } + /** A rule the server could not convert: it is kept, and the event does not recur. */ + @Test + fun `an IETF event's own word on recurring beats its rule`() { + val (imported) = + get( + CalendarDialect.IETF, + """ + {"id":"10870","calendarIds":{"10542":true},"title":"Imported", + "recurrenceRule":{"@type":"RecurrenceRule","frequency":"yearly"}, + "plmail.org:isRecurring":false} + """, + ) + + assertEquals("yearly", imported.recurrenceRule?.frequency) + assertEquals(false, imported.publishedRecurring) + assertFalse(imported.isRecurring) + } + @Test fun `a vendor event is read into the same names`() { val (standup, occurrence) = @@ -137,12 +159,16 @@ class CalendarDialectTest { fun `properties are asked for in the dialect's names`() { val asked = listOf("id", "calendarIds", "baseEventId", "recurrenceRule", "title") - assertEquals(asked, propertiesSent(CalendarDialect.IETF, asked)) + assertEquals( + asked + "plmail.org:isRecurring", + propertiesSent(CalendarDialect.IETF, asked), + "whether it recurs is asked for wherever the rule is", + ) assertEquals( listOf("id", "calendarId", "seriesId", "recurrenceRules", "title", "isRecurring"), propertiesSent(CalendarDialect.PLMAIL, asked), - "and the vendor shape is asked for isRecurring where the rule is wanted", ) + assertEquals(listOf("id"), propertiesSent(CalendarDialect.IETF, listOf("id"))) } // --- writing --- diff --git a/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarWriteMethodsTest.kt b/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarWriteMethodsTest.kt index 1e54316b..81aa2adf 100644 --- a/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarWriteMethodsTest.kt +++ b/core/jmap/src/test/kotlin/de/plmail/jmap/calendar/CalendarWriteMethodsTest.kt @@ -365,7 +365,7 @@ class CalendarWriteMethodsTest { decodeSet( """ {"accountId":"1","oldState":"fixed","newState":"fixed", - "created":{"c1":{"id":"10999","uid":"df7a068@plmail","isRecurring":true, + "created":{"c1":{"id":"10999","uid":"df7a068@plmail","plmail.org:isRecurring":true, "sequence":0}}, "notCreated":{},"updated":{},"notUpdated":{}, "destroyed":[],"notDestroyed":{}} @@ -377,9 +377,22 @@ class CalendarWriteMethodsTest { } @Test - fun `a created event under the IETF URN says nothing about recurring`() { - // The draft has no isRecurring; the server drops it there, and the - // caller is left to the rule it sent. + fun `a created event under the IETF URN says whether it recurs by its vendor name`() { + val answered = + decodeSet( + """ + {"accountId":"1","oldState":"s1","newState":"s2", + "created":{"c1":{"id":"10999","calendarIds":{"10542":true}, + "plmail.org:isRecurring":false,"sequence":0}}} + """ + ) + + assertEquals(false, answered.created.getValue("c1").isRecurring) + } + + @Test + fun `a created event from a server that publishes no isRecurring says nothing`() { + // The caller is then left to the rule it sent. val answered = decodeSet( """ From 660eeb866e4905983dc585612d1ddaa5b53e22c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 20:11:28 +0000 Subject: [PATCH 4/5] chore: 0.4.3 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FXqu9YFt9pfgWBaXXy2zZa --- .../src/main/kotlin/AndroidApplicationConventionPlugin.kt | 2 +- fastlane/metadata/android/de-DE/changelogs/403.txt | 1 + fastlane/metadata/android/en-US/changelogs/403.txt | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 fastlane/metadata/android/de-DE/changelogs/403.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/403.txt diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index 41893459..a7ce66ca 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -11,7 +11,7 @@ import org.gradle.kotlin.dsl.configure * tag matches this string — so the version is a reviewed change in a commit, not a property of * whoever typed the tag. Bump this, commit, then tag the commit. */ -private const val VERSION_NAME = "0.4.2" +private const val VERSION_NAME = "0.4.3" /** * versionCode, computed from versionName rather than stored beside it. diff --git a/fastlane/metadata/android/de-DE/changelogs/403.txt b/fastlane/metadata/android/de-DE/changelogs/403.txt new file mode 100644 index 00000000..0242a8d9 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/403.txt @@ -0,0 +1 @@ +Der Kalender spricht jetzt die Standard-Schnittstelle JMAP Calendars, wo dein plMail-Server sie anbietet, und plMails eigene bei einem älteren Server – er funktioniert also, egal ob du zuerst den Server oder die App aktualisierst. diff --git a/fastlane/metadata/android/en-US/changelogs/403.txt b/fastlane/metadata/android/en-US/changelogs/403.txt new file mode 100644 index 00000000..8f840e84 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/403.txt @@ -0,0 +1 @@ +The calendar now speaks the standard JMAP Calendars interface wherever your plMail server offers it, and plMail's own interface on an older server, so it keeps working whichever of the two you update first. From 9b8c92bde06688351f6e9a57884257127f22e5c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 20:25:48 +0000 Subject: [PATCH 5/5] Pin the vendor URN's page size and horizon on a vendor-only server, and the IETF one's on its own Three repository tests asserted a hundred events a page and the server's +2 years horizon, which only the vendor calendars URN publishes; under the IETF URN a get takes core's maxObjectsInGet and no horizon is published. They now run against a vendor-only server, and a fourth pins one page of 120 under the IETF URN. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FXqu9YFt9pfgWBaXXy2zZa --- .../core/data/CalendarRepositoryTest.kt | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/core/data/src/test/kotlin/de/plmail/core/data/CalendarRepositoryTest.kt b/core/data/src/test/kotlin/de/plmail/core/data/CalendarRepositoryTest.kt index f4a5f88d..1543b8c3 100644 --- a/core/data/src/test/kotlin/de/plmail/core/data/CalendarRepositoryTest.kt +++ b/core/data/src/test/kotlin/de/plmail/core/data/CalendarRepositoryTest.kt @@ -824,7 +824,10 @@ class CalendarRepositoryTest { */ @Test fun `a window past the horizon is clamped and reported as possibly incomplete`() = runTest { - val server = FakeCalendarServer(events = seededWeek()) + // Vendor-only, because only that URN publishes the horizon asserted on + // below; the IETF one has nothing to say it with, and the clamp is the + // client's own line either way. + val server = FakeCalendarServer(events = seededWeek(), dialect = CalendarDialect.PLMAIL) val repository = calendarStack(database, calendarTransport(server)) val far = CalendarWindow(LocalDate.of(2027, 7, 1), LocalDate.of(2027, 9, 1)) @@ -1612,7 +1615,10 @@ class CalendarRepositoryTest { val days = (0 until 120).map { start.plusDays(it.toLong()) }.toSet() val server = + // Vendor-only: a hundred a page is that URN's maxEventsInGet. Under + // the IETF one a get takes core's 500, and these would fit on one. FakeCalendarServer( + dialect = CalendarDialect.PLMAIL, events = mutableListOf( recurring( @@ -1621,7 +1627,7 @@ class CalendarRepositoryTest { start = "2026-08-03T09:00:00", days = days, ) - ) + ), ) val repository = calendarStack(database, calendarTransport(server)) @@ -1643,8 +1649,9 @@ class CalendarRepositoryTest { assertEquals(days.map { it.toString() }.sorted(), cached.map { it.date }.sorted()) } + /** Under the IETF URN a get takes core's `maxObjectsInGet`, so the same quarter is one page. */ @Test - fun `the second page asks from where the first stopped, and only for what is left`() = runTest { + fun `under the IETF URN a get is held to core's limit rather than a hundred`() = runTest { val start = LocalDate.of(2026, 8, 3) val days = (0 until 120).map { start.plusDays(it.toLong()) }.toSet() @@ -1660,6 +1667,36 @@ class CalendarRepositoryTest { ) ) ) + val repository = calendarStack(database, calendarTransport(server)) + + val result = + repository.refresh(CalendarWindow(start, start.plusDays(120))) + as CalendarRefresh.Refreshed + + assertEquals(1, result.requests) + assertEquals(120, result.occurrences) + } + + @Test + fun `the second page asks from where the first stopped, and only for what is left`() = runTest { + val start = LocalDate.of(2026, 8, 3) + val days = (0 until 120).map { start.plusDays(it.toLong()) }.toSet() + + val server = + // Vendor-only: a hundred a page is that URN's maxEventsInGet. Under + // the IETF one a get takes core's 500, and these would fit on one. + FakeCalendarServer( + dialect = CalendarDialect.PLMAIL, + events = + mutableListOf( + recurring( + id = "10867", + title = "Standup", + start = "2026-08-03T09:00:00", + days = days, + ) + ), + ) val repository = calendarStack(database, calendarTransport(server))