# SPDX-FileCopyrightText: 2026 calendaring-jmap contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
JSCalendar to iCalendar conversion (:rfc:`8984` to :rfc:`5545`).
Public API:
jscal_to_ical(jscal: dict) -> str
Accepts a raw JSCalendar CalendarEvent dict (as returned by CalendarEvent/get
or produced by ical_to_jscal). Returns a VCALENDAR string.
"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from urllib.request import urlopen
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import icalendar
from icalendar import vCalAddress, vText
from icalendar.cal.component_factory import ComponentFactory
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 _duration_to_timedelta
_PRIVACY_TO_CLASS = {
"private": "PRIVATE",
"secret": "CONFIDENTIAL",
}
# RFC 8984 section 1.4.9 (PatchObject): a pointer like "keywords/urgent" sets
# or (if null) removes just that one key of the map at "keywords", relative
# to whatever the patched object already has there. Observed against a running
# Cyrus instance: it returns a recurrenceOverrides patch in this flattened,
# per-key form when
# the master event already has its own value for that property (there is
# something to diff against); when the master has none, Cyrus sends the
# override's whole map under the literal key instead, since there is
# nothing to flatten relative to. This converter itself only ever sends a
# patch as a single whole-map replacement, e.g. {"keywords": {...}}, never
# per-key pointers, so a patch read back from Cyrus needs either shape
# resolved into the single whole-map key
# _add_keywords/_add_location/_add_virtual_locations_to_component/
# _add_participants_to_component/_add_alerts_to_component already expect, or
# the whole property is silently dropped (the literal "keywords" key is
# never present in a flattened patch).
_RECURRENCE_OVERRIDE_MAP_PROPERTIES = (
"participants",
"locations",
"virtualLocations",
"alerts",
"keywords",
)
def _resolve_flattened_map_patches(patch: dict, jscal: dict) -> dict:
"""Return ``patch`` with any flattened ``"X/subkey"`` pointers for a
map-typed recurrence-override property resolved into a single literal
``"X"`` whole-map key, merged onto the master event's own ``jscal[X]``
map. ``patch`` itself is never mutated.
No-op (returns ``patch`` unchanged, not a copy) when ``patch`` has no
flattened keys, which is the common case for a patch this converter
produced itself (``ical_to_jscal``
only ever emits whole-map replacements, never per-key pointers).
Only resolves one level of nesting (``"X/subkey"``), matching what
Cyrus has actually been observed to send. A deeper pointer into a
participant or alert's own properties (``"participants/p1/roles"``,
legal per RFC 8984 section 1.4.9 but not something either live test
server produces) is not resolved and is left in ``patch`` unchanged.
"""
prefixes = {f"{prop}/" for prop in _RECURRENCE_OVERRIDE_MAP_PROPERTIES}
flattened_keys = [k for k in patch if any(k.startswith(p) for p in prefixes)]
if not flattened_keys:
return patch
resolved = {k: v for k, v in patch.items() if k not in flattened_keys}
for prop in _RECURRENCE_OVERRIDE_MAP_PROPERTIES:
own_keys = [k for k in flattened_keys if k.startswith(f"{prop}/")]
if not own_keys:
continue
merged = dict(jscal.get(prop) or {})
for key in own_keys:
subkey = key[len(prop) + 1 :]
value = patch[key]
if value is None:
merged.pop(subkey, None)
else:
merged[subkey] = value
resolved[prop] = merged
return resolved
def _add_status(component, status: str | None) -> None:
"""Add a ``STATUS`` property to ``component`` from a JSCalendar
``status`` value, if it maps to one. Shared by the master event and
each recurrence override's child VEVENT in ``jscal_to_ical``."""
if status:
ical_status = _STATUS_JSCAL_TO_ICAL.get(status)
if ical_status:
component.add("status", ical_status)
def _add_free_busy_status(component, free_busy: str | None) -> None:
"""Add a ``TRANSP`` property to ``component`` from a JSCalendar
``freeBusyStatus`` value, if it resolves to something other than the
default ``OPAQUE``. Shared by the master event and each recurrence
override's child VEVENT in ``jscal_to_ical``."""
transp = _FREE_BUSY_TO_TRANSP.get(free_busy, "OPAQUE") if free_busy else "OPAQUE"
if transp != "OPAQUE":
component.add("transp", transp)
def _add_keywords(component, keywords: dict | None) -> None:
"""Add a ``CATEGORIES`` property to ``component`` from a JSCalendar
``keywords`` map, if non-empty. Shared by the master event and each
recurrence override's child VEVENT in ``jscal_to_ical``."""
keywords = keywords or {}
if keywords:
cats = _keywords_to_categories(keywords)
if cats:
component.add("categories", cats)
def _add_location(component, locations: dict | None) -> None:
"""Add a ``LOCATION`` property to ``component`` from a JSCalendar
``locations`` map, if it resolves to a name. Shared by the master
event and each recurrence override's child VEVENT in
``jscal_to_ical``."""
locations = locations or {}
if locations:
loc_name = _locations_to_location(locations)
if loc_name:
component.add("location", loc_name)
def _virtual_location_to_conference(vloc: dict):
"""Build an ``icalendar.vUri`` for a ``CONFERENCE`` property from a
JSCalendar VirtualLocation dict.
Per draft-ietf-calext-jscalendar-icalendar section 3.7: ``uri``
(mandatory per RFC 8984 section 4.2.6) converts to the property
value, ``name`` to the ``LABEL`` parameter (RFC 7986 section 6.4),
and ``features`` to the ``FEATURE`` parameter (RFC 7986 section 6.3)
as a list of uppercased names, matching the enum values RFC 7986 itself
defines in all caps; ``icalendar`` itself comma-joins a list-valued
parameter when serializing.
``description``/``descriptionContentType`` are handled separately: the
draft converts them to a ``VCONFERENCE`` component's own
``DESCRIPTION``/``STYLED-DESCRIPTION``, not a ``CONFERENCE`` parameter,
so ``_description_to_vconference`` builds that sibling component.
"""
uri = vloc.get("uri", "")
conf = icalendar.vUri(uri)
conf.params["VALUE"] = "URI"
name = vloc.get("name")
if name:
conf.params["LABEL"] = vText(name)
features = vloc.get("features") or {}
if features:
conf.params["FEATURE"] = [f.upper() for f, v in features.items() if v]
return conf
_VCONFERENCE_CLASS = ComponentFactory().get_component_class("VCONFERENCE")
def _description_to_vconference(uri: str, description: str, content_type: str | None):
"""Build a VCONFERENCE component carrying a VirtualLocation's
description, correlated to its sibling CONFERENCE property by URI.
Per draft-ietf-calext-jscalendar-icalendar section 2.2.3. Writes
STYLED-DESCRIPTION (carrying descriptionContentType via FMTTYPE) when
a content type is known, plain DESCRIPTION otherwise.
"""
vconf = _VCONFERENCE_CLASS()
vconf.add("uri", icalendar.vUri(uri))
if content_type:
vconf.add(
"styled-description",
icalendar.vText(description),
parameters={"VALUE": "TEXT", "FMTTYPE": content_type},
)
else:
vconf.add("description", description)
return vconf
def _add_virtual_locations_to_component(component, virtual_locations: dict) -> None:
"""Add a ``CONFERENCE`` property to ``component`` for every entry in a
JSCalendar virtualLocations map, plus a sibling ``VCONFERENCE``
component when an entry has ``description`` set. Shared by the master
event and each recurrence override's child VEVENT in
``jscal_to_ical``."""
for vloc in virtual_locations.values():
if vloc.get("uri"):
component.add("conference", _virtual_location_to_conference(vloc))
description = vloc.get("description")
if description:
component.add_component(
_description_to_vconference(
vloc["uri"], description, vloc.get("descriptionContentType")
)
)
_FREE_BUSY_TO_TRANSP = {
"free": "TRANSPARENT",
"busy": "OPAQUE",
}
_PARTSTAT_MAP = {
PARTICIPATION_STATUS_NEEDS_ACTION: "NEEDS-ACTION",
PARTICIPATION_STATUS_ACCEPTED: "ACCEPTED",
PARTICIPATION_STATUS_DECLINED: "DECLINED",
PARTICIPATION_STATUS_TENTATIVE: "TENTATIVE",
PARTICIPATION_STATUS_DELEGATED: "DELEGATED",
}
_KIND_TO_CUTYPE = {
"individual": "INDIVIDUAL",
"group": "GROUP",
"resource": "RESOURCE",
"room": "ROOM",
}
# RFC 8984 status -> RFC 5545 STATUS; module-level constant (cf. _KIND_TO_CUTYPE etc.)
_STATUS_JSCAL_TO_ICAL = {
"confirmed": "CONFIRMED",
"tentative": "TENTATIVE",
"cancelled": "CANCELLED",
}
def _start_to_dtstart(
component: icalendar.Event,
start_str: str,
time_zone: str | None,
show_without_time: bool,
) -> None:
"""Add a DTSTART property to component from JSCalendar start fields.
Handles four cases:
- All-day (showWithoutTime): VALUE=DATE
- UTC (start ends with Z): UTC DATETIME
- Timezone-aware: DATETIME;TZID=...
- Floating (no timeZone, no Z): plain DATETIME
"""
if show_without_time:
dt = date.fromisoformat(start_str[:10])
component.add("dtstart", dt)
return
if start_str.endswith("Z"):
dt = datetime.strptime(start_str, UTC_DATETIME_FORMAT).replace(tzinfo=timezone.utc)
component.add("dtstart", dt)
return
dt_naive = datetime.strptime(start_str[:19], LOCAL_DATETIME_FORMAT)
if time_zone:
try:
tz = ZoneInfo(time_zone)
dt = dt_naive.replace(tzinfo=tz)
component.add("dtstart", dt)
except ZoneInfoNotFoundError:
# Non-IANA TZID (e.g. "Eastern Standard Time"): pass through as-is
# so the consuming calendar client can resolve it.
dtstart = icalendar.vDatetime(dt_naive)
dtstart.params["TZID"] = time_zone
component.add("dtstart", dtstart)
else:
component.add("dtstart", dt_naive)
def _recurrence_rules(jscal: dict, singular_key: str, plural_key: str) -> list[dict]:
"""Return the RecurrenceRule dicts for ``singular_key``/``plural_key`` on an Event.
RFC 8984 section 4.3.3 defines the plural key as an array; both test
servers this repo targets send the singular key instead (see the matching
comment in ical_to_jscal.py). Prefer the plural array when present, since it is the
actual spec type and can hold more than one rule; fall back to the
singular key for these servers' shape.
"""
plural_rules = jscal.get(plural_key)
if plural_rules:
return plural_rules
single_rule = jscal.get(singular_key)
return [single_rule] if single_rule else []
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, which the icalendar library rejects.
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 section 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:
return {}
ical_rule: dict = {"FREQ": freq}
interval = rule.get("interval")
if interval and interval != 1:
ical_rule["INTERVAL"] = interval
count = rule.get("count")
if count is not None:
ical_rule["COUNT"] = count
until = rule.get("until")
if until:
if until.endswith("Z"):
ical_rule["UNTIL"] = datetime.strptime(until, UTC_DATETIME_FORMAT).replace(
tzinfo=timezone.utc
)
elif time_zone:
# RFC 5545 section 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], LOCAL_DATETIME_FORMAT)
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], LOCAL_DATETIME_FORMAT)
by_day = rule.get("byDay", [])
if by_day:
byday_strs = []
for nday in by_day:
day = nday.get("day", "").upper()
nth = nday.get("nthOfPeriod")
if nth:
byday_strs.append(f"{nth}{day}")
else:
byday_strs.append(day)
ical_rule["BYDAY"] = byday_strs
by_month = rule.get("byMonth", [])
if by_month:
ical_rule["BYMONTH"] = [
m if isinstance(m, int) else int(str(m).rstrip("L")) for m in by_month
]
by_month_day = rule.get("byMonthDay", [])
if by_month_day:
ical_rule["BYMONTHDAY"] = by_month_day
by_year_day = rule.get("byYearDay", [])
if by_year_day:
ical_rule["BYYEARDAY"] = by_year_day
by_week_no = rule.get("byWeekNo", [])
if by_week_no:
ical_rule["BYWEEKNO"] = by_week_no
by_hour = rule.get("byHour", [])
if by_hour:
ical_rule["BYHOUR"] = by_hour
by_minute = rule.get("byMinute", [])
if by_minute:
ical_rule["BYMINUTE"] = by_minute
by_second = rule.get("bySecond", [])
if by_second:
ical_rule["BYSECOND"] = by_second
by_set_pos = rule.get("bySetPosition", [])
if by_set_pos:
ical_rule["BYSETPOS"] = by_set_pos
first_day = rule.get("firstDayOfWeek")
if first_day and first_day != "mo":
ical_rule["WKST"] = first_day.upper()
return ical_rule
def _participant_imip(p: dict) -> str:
send_to = p.get("sendTo", {})
imip = (
send_to.get("imip")
or send_to.get("other")
or p.get("calendarAddress")
or p.get("email", "")
)
# calendarAddress (and sendTo.other) can already carry a non-mailto
# scheme (e.g. sip:); only bare addresses need mailto: added.
if imip and ":" not in imip:
imip = f"mailto:{imip}"
return imip
def _participant_to_organizer(p: dict) -> vCalAddress | None:
"""Build a vCalAddress for ORGANIZER, or None if this participant is not an organizer."""
roles = p.get("roles", {})
if not (roles.get("owner") or roles.get("organizer")):
return None
addr = vCalAddress(_participant_imip(p))
name = p.get("name")
if name:
addr.params["CN"] = vText(name)
return addr
def _participant_to_attendee(p: dict) -> vCalAddress | None:
"""Build a vCalAddress for ATTENDEE, or None if participant is purely an organizer."""
roles = p.get("roles", {})
has_attendee_role = any(
roles.get(r) for r in ("attendee", "chair", "informational", "optional")
)
if not has_attendee_role and (roles.get("owner") or roles.get("organizer")):
return None
addr = vCalAddress(_participant_imip(p))
name = p.get("name")
if name:
addr.params["CN"] = vText(name)
partstat = p.get("participationStatus")
if partstat:
addr.params["PARTSTAT"] = _PARTSTAT_MAP.get(partstat, partstat.upper())
else:
addr.params["PARTSTAT"] = "NEEDS-ACTION"
if p.get("expectReply"):
addr.params["RSVP"] = "TRUE"
kind = p.get("kind")
if kind:
addr.params["CUTYPE"] = _KIND_TO_CUTYPE.get(kind, kind.upper())
if roles.get("chair"):
addr.params["ROLE"] = "CHAIR"
elif roles.get("attendee") or has_attendee_role:
addr.params["ROLE"] = "REQ-PARTICIPANT"
return addr
def _add_participants_to_component(component, participants: dict) -> None:
"""Add ``ORGANIZER``/``ATTENDEE`` properties to ``component`` (a VEVENT
or child VEVENT) from a JSCalendar participants map.
Shared by the master event and each recurrence override's child VEVENT
in ``jscal_to_ical``: at most one ``ORGANIZER`` is added (the first
participant whose role resolves to one via
``_participant_to_organizer``), and every participant with an
attendee-shaped role becomes an ``ATTENDEE``.
"""
organizer_added = False
for p in participants.values():
org = _participant_to_organizer(p)
if org and not organizer_added:
component.add("organizer", org)
organizer_added = True
att = _participant_to_attendee(p)
if att is not None:
component.add("attendee", att)
def _alert_to_valarm(alert: dict) -> icalendar.Alarm:
"""Convert a JSCalendar Alert dict to an icalendar.Alarm component.
``trigger`` is an OffsetTrigger or AbsoluteTrigger object, not a bare
string (RFC 8984 section 4.5.2): ``{"@type": "OffsetTrigger",
"offset": "-PT15M", "relativeTo": "start"}`` or ``{"@type":
"AbsoluteTrigger", "when": "..."}``.
"""
alarm = icalendar.Alarm()
action = alert.get("action", "display").upper()
alarm.add("action", action)
trigger = alert.get("trigger") or {}
trigger_type = trigger.get("@type")
if trigger_type == "AbsoluteTrigger":
when = trigger.get("when", "")
try:
dt = datetime.strptime(when, UTC_DATETIME_FORMAT).replace(tzinfo=timezone.utc)
alarm.add("trigger", dt)
except ValueError:
alarm.add("trigger", timedelta(0))
elif trigger_type == "OffsetTrigger":
offset = trigger.get("offset", "")
try:
td = _duration_to_timedelta(offset)
ical_trigger = icalendar.vDuration(td)
if trigger.get("relativeTo") == "end":
ical_trigger.params["RELATED"] = "END"
alarm.add("trigger", ical_trigger)
except ValueError:
alarm.add("trigger", timedelta(0))
else:
alarm.add("trigger", timedelta(0))
description = alert.get("description")
if description:
alarm.add("description", description)
elif action == "DISPLAY":
alarm.add("description", "Reminder")
return alarm
def _add_alerts_to_component(component, alerts: dict) -> None:
"""Add a ``VALARM`` subcomponent to ``component`` for every entry in a
JSCalendar alerts map. Shared by the master event and each recurrence
override's child VEVENT in ``jscal_to_ical``."""
for alert in alerts.values():
alarm = _alert_to_valarm(alert)
component.add_component(alarm)
def _link_to_attach(link: dict):
"""Convert a JSCalendar Link dict to an icalendar ATTACH value.
Only converts a Link whose ``rel`` is ``"enclosure"``
(``constants.LINK_REL_ENCLOSURE``); other ``rel`` values are left alone
(not emitted as ATTACH, IMAGE, or LINK), matching ``_attach_to_link``'s
narrower-than-the-draft scope in the other direction. Returns ``None``
for a Link this function doesn't convert.
A ``data:`` URL ``href`` (RFC 2397, produced by the same function for a
binary-form ATTACH) converts back to inline ``ENCODING=BASE64;
VALUE=BINARY`` using the stdlib's own RFC 2397 support (``urlopen``),
rather than re-implementing data URL parsing by hand. Any other
``href`` converts to a plain URI-form ATTACH.
See ``JMAPAttachment.is_attachment`` for a known Cyrus limitation
affecting the ``rel`` this function filters on.
"""
if link.get("rel") != LINK_REL_ENCLOSURE:
return None
href = link.get("href")
if not href:
return None
content_type = link.get("contentType")
if href.startswith("data:"):
with urlopen(href) as resp:
data = resp.read()
## get_content_type() always returns a truthy default
## ("text/plain") when the data URL has no media type, so this
## is never empty.
resolved_type = content_type or resp.headers.get_content_type()
params = {"ENCODING": "BASE64", "VALUE": "BINARY", "FMTTYPE": str(resolved_type)}
return icalendar.vBinary(data, params=params)
attach = icalendar.vUri(href)
if content_type:
attach.params["FMTTYPE"] = str(content_type)
return attach
def _link_to_url(link: dict):
"""Convert a JSCalendar Link dict to an icalendar URL property value.
Only converts a Link with no ``rel`` set at all; a Link with any
``rel`` (including ``"enclosure"``) is left to ``_link_to_attach``
or a future rel-specific emitter, matching ``_link_to_attach``'s
own narrower-than-the-draft precedent.
"""
if link.get("rel"):
return None
href = link.get("href")
if not href:
return None
return icalendar.vUri(href)
def _keywords_to_categories(keywords: dict) -> list[str]:
"""Convert JSCalendar keywords map to a list of CATEGORIES strings."""
return [k for k, v in keywords.items() if v]
def _locations_to_location(locations: dict) -> str | None:
"""Extract the first location name from a JSCalendar locations map."""
for loc in locations.values():
name = loc.get("name")
if name:
return str(name)
return None
[docs]
def jscal_to_ical(jscal: dict) -> str:
"""Convert a JSCalendar CalendarEvent dict to an iCalendar VCALENDAR string.
Handles the full set of fields supported by ``ical_to_jscal`` for round-trip
fidelity. ``recurrenceOverrides`` entries with ``excluded: true`` become
EXDATE properties; patch dicts become child VEVENTs with RECURRENCE-ID.
Args:
jscal: A raw JSCalendar CalendarEvent dict as returned by ``CalendarEvent/get``.
Returns:
An iCalendar VCALENDAR string, normalised by ``fixup``.
Raises:
ValueError: If ``uid`` is absent or empty (mandatory per
:rfc:`8984#section-4.1.2`; a server response should always
include it, but emitting an ``UID``-less VEVENT, invalid per
:rfc:`5545`, would be a worse failure mode than raising here).
"""
cal = icalendar.Calendar()
cal.add("prodid", "-//calendaring-jmap//JMAP//EN")
cal.add("version", "2.0")
event = icalendar.Event()
uid = jscal.get("uid", "")
if not uid:
raise ValueError("JSCalendar event is missing the mandatory 'uid' property")
event.add("uid", uid)
event.add("dtstamp", datetime.now(tz=timezone.utc))
sequence = jscal.get("sequence", 0)
if sequence:
event.add("sequence", sequence)
start_str = jscal.get("start", "")
time_zone = jscal.get("timeZone")
show_without_time = jscal.get("showWithoutTime", False)
if start_str:
_start_to_dtstart(event, start_str, time_zone, show_without_time)
duration_str = jscal.get("duration", "P0D")
if duration_str and duration_str != "P0D":
td = _duration_to_timedelta(duration_str)
event.add("duration", td)
title = jscal.get("title", "")
if title:
event.add("summary", title)
description = jscal.get("description")
if description:
event.add("description", description)
priority = jscal.get("priority", 0)
if priority:
event.add("priority", priority)
privacy = jscal.get("privacy")
if privacy:
cls = _PRIVACY_TO_CLASS.get(privacy)
if cls:
event.add("class", cls)
_add_free_busy_status(event, jscal.get("freeBusyStatus", "busy"))
color = jscal.get("color")
if color:
event.add("color", color)
_add_keywords(event, jscal.get("keywords"))
_add_location(event, jscal.get("locations"))
_add_virtual_locations_to_component(event, jscal.get("virtualLocations") or {})
_add_status(event, jscal.get("status"))
for rule in _recurrence_rules(jscal, "recurrenceRule", "recurrenceRules"):
ical_rule = _jscal_rrule_to_rrule(rule, time_zone)
if ical_rule:
event.add("rrule", ical_rule)
for rule in _recurrence_rules(jscal, "excludedRecurrenceRule", "excludedRecurrenceRules"):
ical_rule = _jscal_rrule_to_rrule(rule, time_zone)
if ical_rule:
event.add("exrule", ical_rule)
exdates: list[datetime | date] = []
child_events: list[icalendar.Event] = []
for override_key, patch in (jscal.get("recurrenceOverrides") or {}).items():
if override_key.endswith("Z"):
rid_dt: datetime | date = datetime.strptime(override_key, UTC_DATETIME_FORMAT).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], LOCAL_DATETIME_FORMAT).replace(
tzinfo=ZoneInfo(time_zone)
)
except ZoneInfoNotFoundError:
rid_dt = datetime.strptime(override_key[:19], LOCAL_DATETIME_FORMAT)
else:
rid_dt = datetime.strptime(override_key[:19], LOCAL_DATETIME_FORMAT)
if patch is None or (isinstance(patch, dict) and patch.get("excluded")):
exdates.append(rid_dt)
else:
patch = _resolve_flattened_map_patches(patch, jscal)
child = icalendar.Event()
child.add("uid", uid)
child.add("dtstamp", datetime.now(tz=timezone.utc))
child.add("recurrence-id", rid_dt)
# 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:
_start_to_dtstart(child, child_start, child_tz, child_swt)
child_dur = patch.get("duration", duration_str)
if child_dur and child_dur != "P0D":
child.add("duration", _duration_to_timedelta(child_dur))
child_title = patch.get("title", title)
if child_title:
child.add("summary", child_title)
child_desc = patch.get("description", description)
if child_desc:
child.add("description", child_desc)
if "status" in patch:
_add_status(child, patch["status"])
if "freeBusyStatus" in patch:
_add_free_busy_status(child, patch["freeBusyStatus"])
if "keywords" in patch:
_add_keywords(child, patch["keywords"])
if "locations" in patch:
_add_location(child, patch["locations"])
if "virtualLocations" in patch:
_add_virtual_locations_to_component(child, patch["virtualLocations"] or {})
if "participants" in patch:
_add_participants_to_component(child, patch["participants"] or {})
if "alerts" in patch:
_add_alerts_to_component(child, patch["alerts"] or {})
child_events.append(child)
if exdates:
for exdate_dt in exdates:
event.add("exdate", exdate_dt)
_add_participants_to_component(event, jscal.get("participants") or {})
_add_alerts_to_component(event, jscal.get("alerts") or {})
url_written = False
for link in (jscal.get("links") or {}).values():
attach = _link_to_attach(link)
if attach is not None:
event.add("attach", attach)
elif not url_written:
url = _link_to_url(link)
if url is not None:
event.add("url", url)
url_written = True
cal.add_component(event)
for child in child_events:
cal.add_component(child)
raw = cal.to_ical().decode("utf-8")
return fixup(raw)