Source code for calendaring_jmap.convert.ical_to_jscal

# 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