# SPDX-FileCopyrightText: 2026 calendaring-jmap contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
iCalendar to JSCalendar conversion (:rfc:`5545` to :rfc:`8984`).
Public API:
ical_to_jscal(ical_str, calendar_id=None) -> dict
The output dict is a raw JSCalendar CalendarEvent object suitable for passing
directly to CalendarEvent/set.
"""
from __future__ import annotations
import json
import logging
import uuid
from datetime import date, datetime, timedelta
import icalendar
from icalendar.timezone.tzid import tzid_from_dt
from calendaring_jmap.constants import (
LINK_REL_ENCLOSURE,
LOCAL_DATETIME_FORMAT,
PARTICIPATION_STATUS_ACCEPTED,
PARTICIPATION_STATUS_DECLINED,
PARTICIPATION_STATUS_DELEGATED,
PARTICIPATION_STATUS_NEEDS_ACTION,
PARTICIPATION_STATUS_TENTATIVE,
UTC_DATETIME_FORMAT,
)
from calendaring_jmap.convert._fixup import fixup
from calendaring_jmap.convert._utils import _format_local_dt, _timedelta_to_duration
log = logging.getLogger("calendaring_jmap")
# RFC 5545 STATUS -> RFC 8984 status; module-level constant (cf. _CLASS_MAP etc.)
_STATUS_ICAL_TO_JSCAL = {
"CONFIRMED": "confirmed",
"TENTATIVE": "tentative",
"CANCELLED": "cancelled",
}
_CLASS_MAP = {
"PRIVATE": "private",
"CONFIDENTIAL": "secret",
}
_PARTSTAT_MAP = {
"NEEDS-ACTION": PARTICIPATION_STATUS_NEEDS_ACTION,
"ACCEPTED": PARTICIPATION_STATUS_ACCEPTED,
"DECLINED": PARTICIPATION_STATUS_DECLINED,
"TENTATIVE": PARTICIPATION_STATUS_TENTATIVE,
"DELEGATED": PARTICIPATION_STATUS_DELEGATED,
}
_CUTYPE_MAP = {
"INDIVIDUAL": "individual",
"GROUP": "group",
"RESOURCE": "resource",
"ROOM": "room",
}
_BYDAY_ABBR = {"SU", "MO", "TU", "WE", "TH", "FR", "SA"}
def _as_list(value) -> list:
"""Normalise an icalendar multi-instance property to a list.
``Component.get()`` returns ``None`` when the property is absent, the
single parsed value when the property appears once, or a list when it
appears more than once (e.g. ``ATTENDEE``, ``ATTACH``). Every call site
that reads such a property needs this same normalisation.
"""
if value is None:
return []
if isinstance(value, list):
return value
return [value]
def _prop_date_or_datetime(prop) -> datetime | date:
"""Return a vDDDTypes property's ``.dt``, narrowed to ``date | datetime``.
``Component.__getitem__`` is typed as a large union of every possible
property value class, most of which have no ``.dt`` attribute at all;
``getattr`` sidesteps that fan-out, and the isinstance check narrows the
result for callers that need a concrete date/datetime to operate on.
"""
dt = getattr(prop, "dt", None)
if not isinstance(dt, datetime | date):
raise ValueError(f"Expected a date or datetime property value, got {dt!r}")
return dt
def _prop_timedelta(prop) -> timedelta:
"""Return a vDDDTypes property's ``.dt``, narrowed to ``timedelta``.
See ``_prop_date_or_datetime`` for why ``getattr`` is used here.
"""
dt = getattr(prop, "dt", None)
if not isinstance(dt, timedelta):
raise ValueError(f"Expected a timedelta property value, got {dt!r}")
return dt
def _dtstart_to_jscal(dtstart_prop) -> tuple[str, str | None, bool]:
"""Extract JSCalendar start, timeZone, showWithoutTime from a DTSTART property.
Returns:
(start_str, time_zone, show_without_time)
"""
dt = dtstart_prop.dt
if isinstance(dt, date) and not isinstance(dt, datetime):
# VALUE=DATE: all-day event
return f"{dt.isoformat()}T00:00:00", None, True
if dt.tzinfo is not None and dt.utcoffset() == timedelta(0):
# UTC: JSCalendar start is LocalDateTime; express via timeZone="Etc/UTC"
return dt.strftime(LOCAL_DATETIME_FORMAT), "Etc/UTC", False
if dt.tzinfo is not None:
# Timezone-aware. RFC 8984 requires an IANA name; a raw TZID param
# can be a Windows or vendor-prefixed name instead, so resolve via
# tzid_from_dt() rather than using the param string directly.
tz_str = tzid_from_dt(dt)
return dt.strftime(LOCAL_DATETIME_FORMAT), tz_str, False
# Floating (no timezone)
return dt.strftime(LOCAL_DATETIME_FORMAT), None, False
def _rrule_to_jscal(rrule_prop, tzinfo=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, which makes round-trip comparison predictable.
``tzinfo`` is the event's timezone; ``until`` is a LocalDateTime in that
zone, so a UTC ``UNTIL`` off the wire has to be converted, not truncated.
"""
rule: dict = {
"@type": "RecurrenceRule",
"rscale": "gregorian",
"skip": "omit",
}
freq_list = rrule_prop.get("FREQ", [])
if not freq_list:
raise ValueError(f"RRULE is missing required FREQ component: {rrule_prop!r}")
rule["frequency"] = freq_list[0].lower()
interval_list = rrule_prop.get("INTERVAL", [])
rule["interval"] = int(interval_list[0]) if interval_list else 1
wkst_list = rrule_prop.get("WKST", [])
rule["firstDayOfWeek"] = wkst_list[0].lower() if wkst_list else "mo"
count_list = rrule_prop.get("COUNT", [])
if count_list:
rule["count"] = int(count_list[0])
until_list = rrule_prop.get("UNTIL", [])
if until_list:
rule["until"] = _format_local_dt(until_list[0], tzinfo)
byday_list = rrule_prop.get("BYDAY", [])
if byday_list:
by_day = []
for item in byday_list:
s = str(item)
day_abbr = s.lstrip("+-0123456789")
nth_str = s[: len(s) - len(day_abbr)]
nday: dict = {"@type": "NDay", "day": day_abbr.lower()}
if nth_str:
nday["nthOfPeriod"] = int(nth_str)
by_day.append(nday)
rule["byDay"] = by_day
bymonth_list = rrule_prop.get("BYMONTH", [])
if bymonth_list:
rule["byMonth"] = [str(m) for m in bymonth_list]
bymonthday = rrule_prop.get("BYMONTHDAY", [])
if bymonthday:
rule["byMonthDay"] = [int(d) for d in bymonthday]
byyearday = rrule_prop.get("BYYEARDAY", [])
if byyearday:
rule["byYearDay"] = [int(d) for d in byyearday]
byweekno = rrule_prop.get("BYWEEKNO", [])
if byweekno:
rule["byWeekNo"] = [int(n) for n in byweekno]
byhour = rrule_prop.get("BYHOUR", [])
if byhour:
rule["byHour"] = [int(h) for h in byhour]
byminute = rrule_prop.get("BYMINUTE", [])
if byminute:
rule["byMinute"] = [int(m) for m in byminute]
bysecond = rrule_prop.get("BYSECOND", [])
if bysecond:
rule["bySecond"] = [int(s) for s in bysecond]
bysetpos = rrule_prop.get("BYSETPOS", [])
if bysetpos:
rule["bySetPosition"] = [int(p) for p in bysetpos]
return rule
def _first_recurrence_rule(master: dict, ical_key: str, tzinfo=None) -> dict | None:
"""Return the JSCalendar RecurrenceRule for the first ``ical_key`` line on ``master``.
``ical_key`` is ``"RRULE"`` or ``"EXRULE"``. Logs a warning if more than
one line is present; see the comment above this function's call sites
for why only the first is kept.
"""
rules = master.get(ical_key)
if rules is None:
return None
if not isinstance(rules, list):
rules = [rules]
if len(rules) > 1:
log.warning(
"ical_to_jscal(): VEVENT has %d %s lines, only the first is kept "
"(neither test server this repo targets accepts more than one)",
len(rules),
ical_key,
)
return _rrule_to_jscal(rules[0], tzinfo)
def _exdate_to_overrides(exdate_prop, tzinfo=None) -> dict:
"""Convert an EXDATE property (single or list) to recurrenceOverrides entries.
``tzinfo`` is the event's timezone; see ``_format_local_dt``.
Returns:
Dict mapping LocalDateTime/UTCDateTime string to {"excluded": True}
"""
# EXDATE may be a single vDDDLists or a list of them
if not isinstance(exdate_prop, list):
exdate_prop = [exdate_prop]
overrides: dict = {}
for ex in exdate_prop:
dts = getattr(ex, "dts", [ex])
for dt_prop in dts:
dt = getattr(dt_prop, "dt", dt_prop)
overrides[_format_local_dt(dt, tzinfo)] = {"excluded": True}
return overrides
def _cal_address_to_imip_and_email(addr: str) -> tuple[str, str | None]:
"""Split a CAL-ADDRESS value into its ``calendarAddress`` URI and, if it is one, its bare email.
ORGANIZER/ATTENDEE values are URIs (RFC 5545 section 3.3.3) and are
not required to use the ``mailto:`` scheme (e.g. ``sip:alice@example.com``).
RFC 8984 section 4.4.6's Participant ``email`` property is specifically
an RFC 5322 section 3.4.1 addr-spec, not an arbitrary URI, so a
non-mailto address is kept as-is for ``calendarAddress`` (not
double-wrapped in a spurious ``mailto:``) and ``email`` is left unset
rather than populated with a value that isn't actually an email address.
"""
if addr.startswith("mailto:"):
return addr, addr.removeprefix("mailto:")
if ":" in addr:
# Some other URI scheme (sip:, etc.); pass through as-is.
return addr, None
return f"mailto:{addr}", addr
def _organizer_to_participant(organizer) -> tuple[str, dict]:
"""Convert an ORGANIZER property to a (participant_id, Participant dict) tuple."""
imip, email = _cal_address_to_imip_and_email(str(organizer))
pid = str(uuid.uuid4())
p: dict = {
"roles": {"owner": True, "organizer": True},
# RFC 8984's own Participant object has no calendarAddress property;
# it belongs to JMAP Calendars' ParticipantIdentity/Principal objects
# instead, and sendTo (a map of delivery method to URI) is what
# RFC 8984 actually defines for this. Cyrus's CalendarEvent/set
# rejects sendTo outright with invalidProperties on both create and
# update, and separately requires calendarAddress, which RFC 8984
# does not define here at all. Verified live against a running Cyrus
# container: sendTo alone rejected, calendarAddress alone accepted,
# both present still rejected for sendTo. calendarAddress is set to
# sendTo's would-be "imip" value, its natural equivalent per
# ParticipantIdentity's own definition. sendTo itself is optional in
# RFC 8984, so omitting it is a real compatibility tradeoff, not a
# spec violation; Stalwart accepts calendarAddress-only fine too.
"calendarAddress": imip,
}
cn = organizer.params.get("CN")
if cn:
p["name"] = str(cn)
if email is not None:
p["email"] = email
return pid, p
def _attendee_to_participant(attendee) -> tuple[str, dict]:
"""Convert an ATTENDEE property to a (participant_id, Participant dict) tuple."""
imip, email = _cal_address_to_imip_and_email(str(attendee))
pid = str(uuid.uuid4())
p: dict = {
"roles": {"attendee": True},
# See _organizer_to_participant for why calendarAddress is set
# instead of sendTo.
"calendarAddress": imip,
}
if email is not None:
p["email"] = email
cn = attendee.params.get("CN")
if cn:
p["name"] = str(cn)
partstat = attendee.params.get("PARTSTAT")
if partstat:
p["participationStatus"] = _PARTSTAT_MAP.get(partstat.upper(), partstat.lower())
rsvp = attendee.params.get("RSVP", "")
if str(rsvp).upper() == "TRUE":
p["expectReply"] = True
cutype = attendee.params.get("CUTYPE")
if cutype:
p["kind"] = _CUTYPE_MAP.get(cutype.upper(), cutype.lower())
role = attendee.params.get("ROLE")
if role and role.upper() == "CHAIR":
p["roles"]["chair"] = True
return pid, p
def _component_to_participants(component) -> dict:
"""Build a JSCalendar participants map from a VEVENT-shaped component's
own ``ORGANIZER`` and ``ATTENDEE`` properties.
Shared by the master event and each recurrence override's child VEVENT
in ``ical_to_jscal``, since both need the identical "one ORGANIZER
plus zero or more ATTENDEEs" shape built the same way.
"""
participants: dict = {}
organizer = component.get("ORGANIZER")
if organizer is not None:
pid, p = _organizer_to_participant(organizer)
participants[pid] = p
for attendee in _as_list(component.get("ATTENDEE")):
pid, p = _attendee_to_participant(attendee)
participants[pid] = p
return participants
def _valarm_to_alert(alarm) -> tuple[str, dict] | None:
"""Convert a VALARM component to a (alert_id, Alert dict) tuple.
``trigger`` is an OffsetTrigger or AbsoluteTrigger object, not a bare
string (RFC 8984 section 4.5.2): ``{"@type": "OffsetTrigger",
"offset": "-PT15M", "relativeTo": "start"}`` for a relative VALARM
TRIGGER, or ``{"@type": "AbsoluteTrigger", "when": "..."}`` for an
absolute one.
Returns ``None`` if the VALARM has no ``TRIGGER``: mandatory per
RFC 5545 section 3.6.6 and RFC 8984 section 4.5.2 alike, but not
every producer enforces it. Skipping just this one alarm (with a
warning) rather than raising means one malformed VALARM doesn't fail
conversion of the whole event, matching ``_first_recurrence_rule``'s
same "degrade gracefully on a non-fatal sub-item issue" precedent.
"""
trigger_prop = alarm.get("TRIGGER")
if trigger_prop is None:
log.warning("ical_to_jscal(): VALARM has no TRIGGER, skipping this alert")
return None
alert_id = str(uuid.uuid4())
action = str(alarm.get("ACTION", "display")).lower()
alert: dict = {"@type": "Alert", "action": action}
trigger_val = trigger_prop.dt
if isinstance(trigger_val, timedelta):
offset_trigger: dict = {
"@type": "OffsetTrigger",
"offset": _timedelta_to_duration(trigger_val),
}
if str(trigger_prop.params.get("RELATED", "START")).upper() == "END":
offset_trigger["relativeTo"] = "end"
alert["trigger"] = offset_trigger
elif isinstance(trigger_val, datetime):
alert["trigger"] = {
"@type": "AbsoluteTrigger",
"when": trigger_val.strftime(UTC_DATETIME_FORMAT),
}
description = alarm.get("DESCRIPTION")
if description:
alert["description"] = str(description)
return alert_id, alert
def _component_to_alerts(component) -> dict:
"""Build a JSCalendar alerts map from a VEVENT-shaped component's own
``VALARM`` subcomponents.
Shared by the master event and each recurrence override's child VEVENT
in ``ical_to_jscal``, since both need the identical "convert every
VALARM, skipping ones with no TRIGGER" shape built the same way.
"""
alarms = [c for c in component.subcomponents if getattr(c, "name", None) == "VALARM"]
alerts: dict = {}
for alarm in alarms:
converted = _valarm_to_alert(alarm)
if converted is not None:
alert_id, alert = converted
alerts[alert_id] = alert
return alerts
def _attach_to_link(attach) -> tuple[str, dict]:
"""Convert an ATTACH property to a (link_id, Link dict) tuple.
Per draft-ietf-calext-jscalendar-icalendar section 2.3.3: a URI-form
ATTACH (``icalendar.vUri``) converts to ``href`` as-is; a binary-form
ATTACH (``icalendar.vBinary``, RFC 5545 section 3.8.1.1's inline
``ENCODING=BASE64;VALUE=BINARY`` form) converts to a ``data:`` URL
(RFC 2397), with ``FMTTYPE`` (if set) becoming both the data URL's
media type and ``contentType``. ``rel`` is always set to ``"enclosure"``
(``constants.LINK_REL_ENCLOSURE``): unlike JSCalendar's general Link object,
RFC 5545's ATTACH has no notion of a link that isn't an attachment, so
every ATTACH converts as one. This is narrower than the draft's own full
Link/ATTACH/IMAGE/LINK mapping (see draft-ietf-calext-jscalendar-icalendar
section 3.4), which is out of scope here; only the attachment case this
project's own attach_to_event/get_event_attachments need is covered.
"""
link_id = str(uuid.uuid4())
content_type = attach.params.get("FMTTYPE")
link: dict = {"@type": "Link", "rel": LINK_REL_ENCLOSURE}
if isinstance(attach, icalendar.vBinary):
# RFC 2397 section 3: dataurl := "data:" [ mediatype ] [ ";base64" ] "," data
media_type = str(content_type) if content_type else ""
link["href"] = f"data:{media_type};base64,{attach.base64data}"
else:
link["href"] = str(attach)
if content_type:
link["contentType"] = str(content_type)
return link_id, link
def _url_to_link(url) -> tuple[str, dict]:
"""Convert a URL property to a (link_id, Link dict) tuple.
Per RFC 5545 section 3.8.4.6, URL has no defined relationship to the
links RFC 8984 section 4.2.7 names (enclosure, describedby, icon), so
rel is left unset, matching the Link object's own optional rel
(RFC 8984 section 1.4.11). Narrower than the draft's full Link
mapping, same precedent as ``_attach_to_link``.
"""
link_id = str(uuid.uuid4())
return link_id, {"@type": "Link", "href": str(url)}
def _location_str_to_jscal(location_str: str) -> dict:
"""Convert a LOCATION string to a JSCalendar locations map entry.
Returns:
{"<uuid>": {"name": location_str}}
"""
return {str(uuid.uuid4()): {"name": location_str}}
def _conference_to_virtual_location(conference) -> tuple[str, dict]:
"""Convert a CONFERENCE property to a (virtual_location_id, VirtualLocation dict) tuple.
Per draft-ietf-calext-jscalendar-icalendar section 2.3.10: the property
value converts to ``uri`` (mandatory per RFC 8984 section 4.2.6), the
``LABEL`` parameter (RFC 7986 section 6.4) to ``name``, and the
``FEATURE`` parameter (RFC 7986 section 6.3, comma-separated when
more than one value is present) to a ``features`` map of lowercased
feature names to ``True``. ``icalendar`` returns a bare string for
``FEATURE`` when exactly one value is present and a list for more than
one, the same single-vs-list inconsistency ``_as_list`` already
normalizes for ``ATTENDEE``/``ATTACH``.
``VirtualLocation``'s ``description`` property is out of scope: the
draft (section 3.7) only converts it from a ``VCONFERENCE`` component's
own ``DESCRIPTION``/``STYLED-DESCRIPTION``, a separate component this
converter does not read or emit, matching ``_attach_to_link``'s own
narrower-than-the-draft precedent.
"""
virtual_location_id = str(uuid.uuid4())
vloc: dict = {"@type": "VirtualLocation", "uri": str(conference)}
label = conference.params.get("LABEL")
if label:
vloc["name"] = str(label)
features = _as_list(conference.params.get("FEATURE"))
if features:
vloc["features"] = {str(f).lower(): True for f in features}
return virtual_location_id, vloc
def _vconference_descriptions(component) -> dict:
"""Map each VCONFERENCE subcomponent's own URI to its resolved
(description, descriptionContentType) pair.
Per draft-ietf-calext-jscalendar-icalendar section 2.2.3: a VCONFERENCE
converts to the same VirtualLocation its matching CONFERENCE property
(same URI) converts to; one with no matching CONFERENCE is skipped.
Prefers STYLED-DESCRIPTION (RFC 9073 section 6.5, TEXT-valued, not
DERIVED, and with no FMTTYPE or a "text/*" one per section 2.3.41's own
conversion conditions) over DESCRIPTION (RFC 5545 section 3.8.1.5, not
DERIVED), the richer source when both are present, per section 2.3.13's
own "a non-derived STYLED-DESCRIPTION is expected to contain the
description instead" rule for a DERIVED DESCRIPTION. The draft does not
define what more than one VCONFERENCE sharing the same URI means; logs
a warning and keeps the first if this happens.
"""
result: dict = {}
for vconf in component.subcomponents:
if getattr(vconf, "name", None) != "VCONFERENCE":
continue
uri = vconf.get("URI")
if not uri:
continue
description = None
content_type = None
for sd in _as_list(vconf.get("STYLED-DESCRIPTION")):
if str(sd.params.get("VALUE", "TEXT")).upper() != "TEXT":
continue
if str(sd.params.get("DERIVED", "")).upper() == "TRUE":
continue
fmttype = sd.params.get("FMTTYPE")
if fmttype and not str(fmttype).lower().startswith("text/"):
continue
description = str(sd)
content_type = str(fmttype) if fmttype else None
break
if description is None:
desc_prop = vconf.get("DESCRIPTION")
if desc_prop and str(desc_prop.params.get("DERIVED", "")).upper() != "TRUE":
description = str(desc_prop)
if description is not None:
if str(uri) in result:
log.warning(
"ical_to_jscal(): VEVENT has more than one VCONFERENCE "
"with URI %s, only the first is kept",
uri,
)
continue
result[str(uri)] = (description, content_type)
return result
def _component_to_virtual_locations(component) -> dict:
"""Build a JSCalendar virtualLocations map from a VEVENT-shaped
component's own CONFERENCE properties and any matching VCONFERENCE
subcomponents.
Shared by the master event and each recurrence override's child VEVENT
in ``ical_to_jscal``, since both need the identical "convert every
CONFERENCE property, then attach any matching VCONFERENCE description"
shape built the same way.
"""
virtual_locations: dict = {}
for conference in _as_list(component.get("CONFERENCE")):
vloc_id, vloc = _conference_to_virtual_location(conference)
virtual_locations[vloc_id] = vloc
descriptions = _vconference_descriptions(component)
for vloc in virtual_locations.values():
found = descriptions.get(vloc["uri"])
if found is not None:
description, content_type = found
vloc["description"] = description
if content_type:
vloc["descriptionContentType"] = content_type
return virtual_locations
def _categories_to_keywords(categories_prop) -> dict:
"""Convert a CATEGORIES property to a JSCalendar keywords map.
icalendar returns one of three types depending on how CATEGORIES appears:
- vCategory (single CATEGORIES line, possibly multi-value): access .cats
- list of vCategory (multiple CATEGORIES lines): flatten .cats from each
- vText (rare, single bare string value): str() and comma-split
"""
if hasattr(categories_prop, "cats"):
values = [str(c) for c in categories_prop.cats]
elif isinstance(categories_prop, list):
values = []
for item in categories_prop:
if hasattr(item, "cats"):
values.extend(str(c) for c in item.cats)
else:
values.append(str(item))
else:
raw = str(categories_prop)
values = [v.strip() for v in raw.split(",") if v.strip()]
return {v: True for v in values}
def _map_values_differ(child_map: dict, master_map: dict) -> bool:
"""Return whether two ``Id[X]``-shaped JSCalendar maps (``participants``,
``locations``, ``alerts``) have different content, ignoring their keys
and insertion order.
``_location_str_to_jscal``/``_organizer_to_participant``/
``_attendee_to_participant``/``_valarm_to_alert`` each generate a fresh
``uuid.uuid4()`` map key on every call, so two maps built from identical
source data never share keys; a plain ``child_map != master_map`` check
would always report a difference even when nothing actually changed.
Comparing by sorted canonical JSON of the values also tolerates the two
maps having their entries in a different order, which can happen on a
round trip through ``jscal_to_ical``
without the content having actually changed.
"""
def canonical(m: dict) -> list[str]:
return sorted(json.dumps(v, sort_keys=True) for v in m.values())
return canonical(child_map) != canonical(master_map)
[docs]
def ical_to_jscal(ical_str: str, calendar_id: str | None = None) -> dict:
"""Convert an iCalendar string to a JSCalendar CalendarEvent dict (:rfc:`8984`).
Processes the first VEVENT found in the string. Any sibling VEVENTs with a
RECURRENCE-ID are folded into the ``recurrenceOverrides`` map of the master
event. EXDATE entries are also added to ``recurrenceOverrides``.
Args:
ical_str: A VCALENDAR string (or bare VEVENT; fixup() normalises it).
calendar_id: If provided, sets ``calendarIds: {calendar_id: true}``
on the output. Required when the result will be used in
``CalendarEvent/set`` (the server needs to know which calendar).
Returns:
Raw JSCalendar dict suitable for passing directly to ``CalendarEvent/set``.
Raises:
ValueError: If no VEVENT component is found; the master VEVENT is
missing ``UID`` or ``DTSTART`` (both mandatory per :rfc:`5545`,
but not every producer enforces this); ``DTSTART``/``DTEND``
have mismatched value types, one ``DATE`` and one
``DATE-TIME`` (:rfc:`5545#section-3.8.2.2` requires both to
match, and ``DTEND`` to be later in time than ``DTSTART``); or
``DTEND``/``DURATION`` resolves to before ``DTSTART``
(:rfc:`8984#section-5.1.2` requires ``duration`` to be zero or
positive).
"""
# Normalize iCal string (fixes common server-generated violations)
fixed = fixup(ical_str)
cal = icalendar.Calendar.from_ical(fixed)
# Split subcomponents into master VEVENTs and override VEVENTs
master: icalendar.Event | None = None
override_components: list[icalendar.Event] = []
for component in cal.subcomponents:
if not isinstance(component, icalendar.Event):
continue
if component.get("RECURRENCE-ID") is not None:
override_components.append(component)
elif master is None:
master = component
if master is None:
raise ValueError("No VEVENT component found in iCalendar string")
if "UID" not in master:
raise ValueError("Master VEVENT is missing the mandatory UID property")
if "DTSTART" not in master:
raise ValueError("Master VEVENT is missing the mandatory DTSTART property")
uid = str(master["UID"])
summary = master.get("SUMMARY")
title = str(summary) if summary else ""
dtstart_prop = master["DTSTART"]
start, time_zone, show_without_time = _dtstart_to_jscal(dtstart_prop)
## The event's own timezone. Every LocalDateTime slot below (RRULE
## until, EXDATE keys, RECURRENCE-ID keys) is expressed in it, so it has
## to be known before any of them can be formatted, which is why the
## override keys cannot be built in the loop above.
event_tzinfo = getattr(getattr(dtstart_prop, "dt", None), "tzinfo", None)
overrides_by_recurrence_id: dict[str, icalendar.Event] = {
_format_local_dt(
_prop_date_or_datetime(component["RECURRENCE-ID"]), event_tzinfo
): component
for component in override_components
}
dtend_prop = master.get("DTEND")
if master.get("DURATION"):
event_duration = _prop_timedelta(master["DURATION"])
elif dtend_prop is not None:
dtend_val = _prop_date_or_datetime(dtend_prop)
dtstart_val = _prop_date_or_datetime(dtstart_prop)
# RFC 5545 requires DTSTART/DTEND to share the same VALUE type
# (both DATE or both DATE-TIME) within one component; a mismatch
# here (one all-day, one timed) isn't just malformed data like a
# backwards DTEND, it's a type Python's stdlib can't subtract at
# all, so this needs its own check rather than falling into the
# generic negative-duration one below.
if isinstance(dtend_val, datetime) != isinstance(dtstart_val, datetime):
raise ValueError(
f"Event {uid!r} has mismatched DTSTART/DTEND value types "
f"({type(dtstart_val).__name__} vs {type(dtend_val).__name__}): "
"RFC 5545 requires both to be DATE or both DATE-TIME"
)
event_duration = dtend_val - dtstart_val # type: ignore[operator]
else:
event_duration = timedelta()
# RFC 8984 section 5.1.2: Event duration is "zero or positive"; a
# DTEND before DTSTART (malformed, but not unheard of from a
# nonconformant producer) would otherwise silently become a negative
# JSCalendar duration and fail server-side with an opaque
# invalidProperties error instead of failing fast here with a clear one.
if event_duration < timedelta():
raise ValueError(
f"Event {uid!r} has a negative duration ({event_duration}): "
"DTEND/DURATION resolves to before DTSTART"
)
duration = _timedelta_to_duration(event_duration)
jscal: dict = {
"@type": "Event",
# RFC 8984 itself has no "version" property; draft-ietf-calext-
# jscalendarbis (the 2.0 successor spec) adds it and registers "1.0"
# for data conforming to RFC 8984, "2.0" for jscalendarbis. This
# conversion does not emit any 2.0-only properties (COORDINATES,
# SHOW-WITHOUT-TIME), so "1.0" would be the strictly accurate value.
# We send "2.0" anyway: Cyrus's current test image rejects "1.0"
# outright (requires "2.0" on every Event), and this repo's own CI
# and integration tests run against that same image. Revisit once M4
# adds real jscalendarbis support, or once older servers matter more
# than this one.
"version": "2.0",
"uid": uid,
"title": title,
"start": start,
"duration": duration,
}
if calendar_id is not None:
jscal["calendarIds"] = {calendar_id: True}
if time_zone is not None:
jscal["timeZone"] = time_zone
if show_without_time:
jscal["showWithoutTime"] = True
description = master.get("DESCRIPTION")
if description:
jscal["description"] = str(description)
sequence = master.get("SEQUENCE")
if sequence is not None:
jscal["sequence"] = int(sequence)
priority = master.get("PRIORITY")
if priority is not None:
p_int = int(priority)
if p_int != 0:
jscal["priority"] = p_int
cls = master.get("CLASS")
if cls:
privacy = _CLASS_MAP.get(str(cls).upper())
if privacy:
jscal["privacy"] = privacy
transp = master.get("TRANSP")
if transp and str(transp).upper() == "TRANSPARENT":
jscal["freeBusyStatus"] = "free"
color = master.get("COLOR")
if color:
jscal["color"] = str(color)
categories = master.get("CATEGORIES")
if categories is not None:
kw = _categories_to_keywords(categories)
if kw:
jscal["keywords"] = kw
location = master.get("LOCATION")
if location:
jscal["locations"] = _location_str_to_jscal(str(location))
virtual_locations = _component_to_virtual_locations(master)
if virtual_locations:
jscal["virtualLocations"] = virtual_locations
status = master.get("STATUS")
if status:
jscal_status = _STATUS_ICAL_TO_JSCAL.get(str(status).upper())
if jscal_status:
jscal["status"] = jscal_status
participants = _component_to_participants(master)
if participants:
jscal["participants"] = participants
alerts = _component_to_alerts(master)
if alerts:
jscal["alerts"] = alerts
# RFC 8984 §4.3.3 defines "recurrenceRules" as RecurrenceRule[], since an
# event can in principle have more than one RRULE. Neither test server
# this repo targets actually implements that: Cyrus (pinned prodId
# "CyrusIMAP.org/Cyrus 3.13.7-545-gc04cece19") rejects the array outright
# with invalidProperties, and Stalwart's own CalendarEvent/get also
# returns the older singular "recurrenceRule" key, not the array,
# confirmed live against both. We follow both servers here, same as the
# "version" and calendarAddress/sendTo fixes: only the first RRULE is
# kept if more than one is present, which is a real but rare data-loss
# case (multiple RRULE lines on one VEVENT are uncommon in practice), so
# it's logged rather than silent or raised.
# Full recurrence override fidelity is its own separate M4 milestone item.
rule = _first_recurrence_rule(master, "RRULE", event_tzinfo)
if rule is not None:
jscal["recurrenceRule"] = rule
exrule = _first_recurrence_rule(master, "EXRULE", event_tzinfo)
if exrule is not None:
jscal["excludedRecurrenceRule"] = exrule
recurrence_overrides: dict = {}
exdate = master.get("EXDATE")
if exdate is not None:
recurrence_overrides.update(_exdate_to_overrides(exdate, event_tzinfo))
for rid_key, child in overrides_by_recurrence_id.items():
# Build a patch: only fields that differ from the master
patch: dict = {}
child_summary = child.get("SUMMARY")
if child_summary and str(child_summary) != title:
patch["title"] = str(child_summary)
child_start_prop = child.get("DTSTART")
if child_start_prop:
child_start, _, _ = _dtstart_to_jscal(child_start_prop)
if child_start != start:
patch["start"] = child_start
child_duration_prop = child.get("DURATION")
if child_duration_prop:
child_dur = _timedelta_to_duration(child_duration_prop.dt)
if child_dur != duration:
patch["duration"] = child_dur
child_description = child.get("DESCRIPTION")
if child_description and str(child_description) != jscal.get("description"):
patch["description"] = str(child_description)
child_status = child.get("STATUS")
master_status = jscal.get("status") or "confirmed"
if child_status:
child_jscal_status = _STATUS_ICAL_TO_JSCAL.get(str(child_status).upper())
if child_jscal_status and child_jscal_status != master_status:
patch["status"] = child_jscal_status
child_transp = child.get("TRANSP")
master_fb = jscal.get("freeBusyStatus") or "busy"
if child_transp and str(child_transp).upper() == "TRANSPARENT" and master_fb != "free":
patch["freeBusyStatus"] = "free"
child_categories = child.get("CATEGORIES")
if child_categories is not None:
child_kw = _categories_to_keywords(child_categories)
if child_kw != (jscal.get("keywords") or {}):
patch["keywords"] = child_kw
child_location = child.get("LOCATION")
if child_location:
child_loc = _location_str_to_jscal(str(child_location))
if _map_values_differ(child_loc, jscal.get("locations") or {}):
patch["locations"] = child_loc
child_virtual_locations = _component_to_virtual_locations(child)
if child_virtual_locations and _map_values_differ(
child_virtual_locations, jscal.get("virtualLocations") or {}
):
patch["virtualLocations"] = child_virtual_locations
child_participants = _component_to_participants(child)
if child_participants and _map_values_differ(
child_participants, jscal.get("participants") or {}
):
patch["participants"] = child_participants
child_alerts = _component_to_alerts(child)
if child_alerts and _map_values_differ(child_alerts, jscal.get("alerts") or {}):
patch["alerts"] = child_alerts
recurrence_overrides[rid_key] = patch or {}
if recurrence_overrides:
jscal["recurrenceOverrides"] = recurrence_overrides
attaches = _as_list(master.get("ATTACH"))
urls = _as_list(master.get("URL"))
if attaches or urls:
links: dict = {}
for attach in attaches:
link_id, link = _attach_to_link(attach)
links[link_id] = link
if urls:
if len(urls) > 1:
log.warning(
"ical_to_jscal(): VEVENT has %d URL lines, only the "
"first is kept (RFC 5545 section 3.8.4.6 allows at "
"most one)",
len(urls),
)
link_id, link = _url_to_link(urls[0])
links[link_id] = link
jscal["links"] = links
return jscal