Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 69 additions & 184 deletions caldav/jmap/async_client.py

Large diffs are not rendered by default.

489 changes: 314 additions & 175 deletions caldav/jmap/client.py

Large diffs are not rendered by default.

33 changes: 33 additions & 0 deletions caldav/jmap/convert/_patch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""
RFC 8620 PatchObject helpers for CalendarEvent/set update calls.

When updating an event, absent keys preserve the server's current value.
To delete an optional property the patch must set it to null explicitly.
"""

from __future__ import annotations

# Optional JSCalendar top-level properties that must be explicitly nulled in
# a CalendarEvent/set update when they are absent from the converted result.
# This ensures properties removed client-side (e.g. LOCATION deleted from
# the iCalendar) are actually removed on the server, not silently preserved.
_NULL_FOR_UPDATE: frozenset[str] = frozenset(
Comment thread
tobixen marked this conversation as resolved.
Dismissed
{
"description",
"color",
"locations",
"keywords",
"priority",
"privacy",
"freeBusyStatus",
"status",
"sequence",
"showWithoutTime",
"timeZone",
"recurrenceRules",
"excludedRecurrenceRules",
"recurrenceOverrides",
"participants",
"alerts",
}
)
13 changes: 6 additions & 7 deletions caldav/jmap/convert/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,22 +111,21 @@ def _duration_to_timedelta(duration_str: str) -> timedelta:


def _format_local_dt(dt: datetime | date) -> str:
Comment thread
tobixen marked this conversation as resolved.
"""Format a datetime or date as a JSCalendar LocalDateTime or UTCDateTime string.
"""Format a datetime or date as a JSCalendar LocalDateTime string.

JSCalendar uses:
- LocalDateTime: "2024-03-15T09:00:00" (no TZ suffix)
- UTCDateTime: "2024-03-15T09:00:00Z" (uppercase Z)
RFC 8984 requires LocalDateTime (no Z suffix) for override keys and RRULE
``until`` values. Timezone information is stripped — callers must convert
UTC datetimes to the event's local timezone before calling if the event uses
TZID; for floating or all-day events the naive value is already correct.

For date objects (all-day), uses T00:00:00 suffix.

Args:
dt: A datetime (with or without tzinfo) or a date.

Returns:
Formatted string suitable for use as a JSCalendar override key or datetime value.
Formatted string suitable for use as a JSCalendar override key or RRULE until.
"""
if isinstance(dt, datetime):
if dt.tzinfo is not None and dt.utcoffset() == timedelta(0):
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
return dt.strftime("%Y-%m-%dT%H:%M:%S")
return f"{dt.isoformat()}T00:00:00"
54 changes: 47 additions & 7 deletions caldav/jmap/convert/ical_to_jscal.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,40 @@

import uuid
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

import icalendar

from caldav.jmap.convert._utils import _format_local_dt, _timedelta_to_duration
from caldav.lib import vcal

# RFC 5545 STATUS -> RFC 8984 status; module-level constant (cf. _CLASS_MAP etc.)
_STATUS_ICAL_TO_JSCAL = {
"CONFIRMED": "confirmed",
"TENTATIVE": "tentative",
"CANCELLED": "cancelled",
}


def _to_event_local(dt, time_zone):
"""Shift a tz-aware datetime to naive wall-clock in the event's timeZone.

RFC 8984 LocalDateTime values (RRULE ``until``, EXDATE override keys) are
expressed in the recurring event's own time zone. A UTC UNTIL/EXDATE on a
TZID event must therefore be converted to that zone before being formatted
as a (suffix-less) LocalDateTime, otherwise the series ends at the wrong
wall-clock time and the round-trip emits a Z-less UNTIL that RFC 5545
§3.3.10 forbids for TZID events. Floating / all-day / naive values (and
unknown time zones) pass through unchanged.
"""
if time_zone and isinstance(dt, datetime) and dt.tzinfo is not None:
try:
return dt.astimezone(ZoneInfo(time_zone)).replace(tzinfo=None)
except ZoneInfoNotFoundError:
return dt
return dt


_CLASS_MAP = {
"PRIVATE": "private",
"CONFIDENTIAL": "secret",
Expand Down Expand Up @@ -68,11 +96,14 @@ def _dtstart_to_jscal(dtstart_prop) -> tuple[str, str | None, bool]:
return dt.strftime("%Y-%m-%dT%H:%M:%S"), None, False


def _rrule_to_jscal(rrule_prop) -> dict:
def _rrule_to_jscal(rrule_prop, time_zone=None) -> dict:
"""Convert an iCalendar RRULE property to a JSCalendar RecurrenceRule dict.

Always emits @type, interval, rscale, skip, firstDayOfWeek to match the
fields Cyrus returns — makes round-trip comparison predictable.

``time_zone`` is the event's IANA time zone; a UTC ``UNTIL`` is shifted to
it so the LocalDateTime value matches the event frame (see _to_event_local).
"""
rule: dict = {
"@type": "RecurrenceRule",
Expand All @@ -97,7 +128,7 @@ def _rrule_to_jscal(rrule_prop) -> dict:

until_list = rrule_prop.get("UNTIL", [])
if until_list:
rule["until"] = _format_local_dt(until_list[0])
rule["until"] = _format_local_dt(_to_event_local(until_list[0], time_zone))

byday_list = rrule_prop.get("BYDAY", [])
if byday_list:
Expand Down Expand Up @@ -145,9 +176,12 @@ def _rrule_to_jscal(rrule_prop) -> dict:
return rule


def _exdate_to_overrides(exdate_prop) -> dict:
def _exdate_to_overrides(exdate_prop, time_zone=None) -> dict:
"""Convert an EXDATE property (single or list) to recurrenceOverrides entries.

``time_zone`` is the event's IANA time zone; a UTC EXDATE is shifted to it
so the override key matches the occurrence key (see _to_event_local).

Returns:
Dict mapping LocalDateTime/UTCDateTime string → {"excluded": True}
"""
Expand All @@ -160,7 +194,7 @@ def _exdate_to_overrides(exdate_prop) -> dict:
dts = getattr(ex, "dts", [ex])
for dt_prop in dts:
dt = getattr(dt_prop, "dt", dt_prop)
overrides[_format_local_dt(dt)] = {"excluded": True}
overrides[_format_local_dt(_to_event_local(dt, time_zone))] = {"excluded": True}
return overrides


Expand Down Expand Up @@ -386,6 +420,12 @@ def ical_to_jscal(ical_str: str, calendar_id: str | None = None) -> dict:
if location:
jscal["locations"] = _location_str_to_jscal(str(location))

status = master.get("STATUS")
if status:
jscal_status = _STATUS_ICAL_TO_JSCAL.get(str(status).upper())
if jscal_status:
jscal["status"] = jscal_status
Comment on lines +423 to +427

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue: _STATUS_ICAL_TO_JSCAL is rebuilt on every call. Should be a module-level constant alongside _CLASS_MAP, _PARTSTAT_MAP, etc.

Suggested change
status = master.get("STATUS")
if status:
_STATUS_ICAL_TO_JSCAL = {
"CONFIRMED": "confirmed",
"TENTATIVE": "tentative",
"CANCELLED": "cancelled",
}
jscal_status = _STATUS_ICAL_TO_JSCAL.get(str(status).upper())
if jscal_status:
jscal["status"] = jscal_status
status = master.get("STATUS")
if status:
jscal_status = _STATUS_ICAL_TO_JSCAL.get(str(status).upper())
if jscal_status:
jscal["status"] = jscal_status


participants: dict = {}
organizer = master.get("ORGANIZER")
if organizer is not None:
Expand All @@ -411,19 +451,19 @@ def ical_to_jscal(ical_str: str, calendar_id: str | None = None) -> dict:
if rrules is not None:
if not isinstance(rrules, list):
rrules = [rrules]
jscal["recurrenceRules"] = [_rrule_to_jscal(r) for r in rrules]
jscal["recurrenceRules"] = [_rrule_to_jscal(r, time_zone) for r in rrules]

exrules = master.get("EXRULE")
if exrules is not None:
if not isinstance(exrules, list):
exrules = [exrules]
jscal["excludedRecurrenceRules"] = [_rrule_to_jscal(r) for r in exrules]
jscal["excludedRecurrenceRules"] = [_rrule_to_jscal(r, time_zone) for r in exrules]

recurrence_overrides: dict = {}

exdate = master.get("EXDATE")
if exdate is not None:
recurrence_overrides.update(_exdate_to_overrides(exdate))
recurrence_overrides.update(_exdate_to_overrides(exdate, time_zone))

for rid_key, child in overrides_by_recurrence_id.items():
# Build a patch: only fields that differ from the master
Expand Down
46 changes: 42 additions & 4 deletions caldav/jmap/convert/jscal_to_ical.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@
"resource": "RESOURCE",
"room": "ROOM",
}
# RFC 8984 status -> RFC 5545 STATUS
_STATUS_JSCAL_TO_ICAL = {
"confirmed": "CONFIRMED",
"tentative": "TENTATIVE",
"cancelled": "CANCELLED",
}


def _start_to_dtstart(
Expand Down Expand Up @@ -86,11 +92,16 @@ def _start_to_dtstart(
component.add("dtstart", dt_naive)


def _jscal_rrule_to_rrule(rule: dict) -> dict:
def _jscal_rrule_to_rrule(rule: dict, time_zone: str | None = None) -> dict:
"""Convert a JSCalendar RecurrenceRule dict to an iCalendar vRecur-compatible dict.

Strips @type and NDay @type fields — icalendar library rejects them.
Returns a plain dict suitable for icalendar.vRecur.

``time_zone`` is the event's IANA time zone. The JSCalendar ``until`` is a
LocalDateTime in that zone; RFC 5545 §3.3.10 requires the iCalendar UNTIL to
be UTC whenever DTSTART is a TZID or UTC date-time, so a non-Z ``until`` is
converted back to UTC here.
"""
freq = rule.get("frequency", "").upper()
if not freq:
Expand All @@ -112,6 +123,17 @@ def _jscal_rrule_to_rrule(rule: dict) -> dict:
ical_rule["UNTIL"] = datetime.strptime(until, "%Y-%m-%dT%H:%M:%SZ").replace(
tzinfo=timezone.utc
)
elif time_zone:
# RFC 5545 §3.3.10: a TZID/UTC DTSTART requires a UTC UNTIL. The
# JSCalendar until is LocalDateTime in the event timeZone; convert
# it back to UTC so the emitted UNTIL carries the Z suffix.
naive = datetime.strptime(until[:19], "%Y-%m-%dT%H:%M:%S")
try:
ical_rule["UNTIL"] = naive.replace(tzinfo=ZoneInfo(time_zone)).astimezone(
timezone.utc
)
except ZoneInfoNotFoundError:
ical_rule["UNTIL"] = naive
else:
ical_rule["UNTIL"] = datetime.strptime(until[:19], "%Y-%m-%dT%H:%M:%S")

Expand Down Expand Up @@ -353,13 +375,19 @@ def jscal_to_ical(jscal: dict) -> str:
if loc_name:
event.add("location", loc_name)

status = jscal.get("status")
if status:
ical_status = _STATUS_JSCAL_TO_ICAL.get(status)
if ical_status:
event.add("status", ical_status)
Comment thread
tobixen marked this conversation as resolved.

for rule in jscal.get("recurrenceRules") or []:
ical_rule = _jscal_rrule_to_rrule(rule)
ical_rule = _jscal_rrule_to_rrule(rule, time_zone)
if ical_rule:
event.add("rrule", ical_rule)

for rule in jscal.get("excludedRecurrenceRules") or []:
ical_rule = _jscal_rrule_to_rrule(rule)
ical_rule = _jscal_rrule_to_rrule(rule, time_zone)
if ical_rule:
event.add("exrule", ical_rule)

Expand All @@ -371,6 +399,15 @@ def jscal_to_ical(jscal: dict) -> str:
rid_dt: datetime | date = datetime.strptime(override_key, "%Y-%m-%dT%H:%M:%SZ").replace(
tzinfo=timezone.utc
)
elif show_without_time:
rid_dt = date.fromisoformat(override_key[:10])
elif time_zone:
try:
rid_dt = datetime.strptime(override_key[:19], "%Y-%m-%dT%H:%M:%S").replace(
tzinfo=ZoneInfo(time_zone)
)
except ZoneInfoNotFoundError:
rid_dt = datetime.strptime(override_key[:19], "%Y-%m-%dT%H:%M:%S")
else:
rid_dt = datetime.strptime(override_key[:19], "%Y-%m-%dT%H:%M:%S")

Expand All @@ -381,7 +418,8 @@ def jscal_to_ical(jscal: dict) -> str:
child.add("uid", uid)
child.add("dtstamp", datetime.now(tz=timezone.utc))
child.add("recurrence-id", rid_dt)
child_start = patch.get("start", start_str)
# Default child start to the occurrence time (override key), not the master start.
child_start = patch.get("start", override_key)
child_tz = patch.get("timeZone", time_zone)
child_swt = patch.get("showWithoutTime", show_without_time)
if child_start:
Expand Down
21 changes: 16 additions & 5 deletions caldav/jmap/objects/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime
from datetime import datetime, timezone
from typing import TYPE_CHECKING

from caldav.jmap.objects.calendar_object import JMAPCalendarObject
Expand All @@ -18,6 +18,17 @@
from caldav.jmap.client import JMAPClient


def _to_utcdate(dt: datetime) -> str:
"""Convert a datetime to JMAP UTCDate format (YYYY-MM-DDTHH:MM:SSZ).

Naive datetimes are assumed to be UTC. Aware datetimes are converted to
UTC before formatting. Microseconds are dropped as JMAP does not allow them.
"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


@dataclass
class JMAPCalendar:
"""A JMAP Calendar object.
Expand Down Expand Up @@ -111,9 +122,9 @@ def search(self, **searchargs):
start = searchargs.get("start")
end = searchargs.get("end")
if isinstance(start, datetime):
start = start.isoformat()
start = _to_utcdate(start)
if isinstance(end, datetime):
end = end.isoformat()
end = _to_utcdate(end)
return self._client._search(
calendar_id=self.id,
start=start,
Expand All @@ -126,9 +137,9 @@ async def _async_search(self, **searchargs) -> list[JMAPCalendarObject]:
start = searchargs.get("start")
end = searchargs.get("end")
if isinstance(start, datetime):
start = start.isoformat()
start = _to_utcdate(start)
if isinstance(end, datetime):
end = end.isoformat()
end = _to_utcdate(end)
return await self._client._search(
calendar_id=self.id,
start=start,
Expand Down
Loading
Loading