Source code for calendaring_jmap.objects.calendar_object

# SPDX-FileCopyrightText: 2026 calendaring-jmap contributors
# SPDX-License-Identifier: AGPL-3.0-or-later

"""
JMAP calendar resource object.

Wraps a raw JSCalendar CalendarEvent dict with a minimal interface:
``.id``, ``.parent``, ``get_data``, ``get_icalendar_instance``,
``edit_icalendar_instance``, and ``save``.
"""

from __future__ import annotations

from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import TYPE_CHECKING

import icalendar

from calendaring_jmap.convert import jscal_to_ical
from calendaring_jmap.error import JMAPMethodError

if TYPE_CHECKING:
    from calendaring_jmap.objects.calendar import JMAPCalendar


[docs] @dataclass class JMAPCalendarObject: """Thin wrapper around a raw JSCalendar CalendarEvent dict. Stores the server's JSON response as-is. No JMAP field names are mapped to typed attributes: callers work with the dict directly via :meth:`get_data`, or convert to iCalendar via :meth:`get_icalendar_instance`. Attributes: data: Raw JSCalendar CalendarEvent dict as returned by ``CalendarEvent/get``. parent: The :class:`~calendaring_jmap.objects.calendar.JMAPCalendar` this object belongs to, or ``None`` when fetched without a calendar context (e.g. via :meth:`~calendaring_jmap.client.JMAPClient.get_event`). """ data: dict parent: JMAPCalendar | None _ical_cache: icalendar.Calendar | None = field( default=None, init=False, repr=False, compare=False ) @property def id(self) -> str: """Server-assigned JMAP event ID.""" return self.data["id"]
[docs] def get_data(self) -> dict: """Return the raw JSCalendar dict as returned by ``CalendarEvent/get``.""" return self.data
[docs] def get_icalendar_instance(self) -> icalendar.Calendar: """Return an :class:`icalendar.Calendar <icalendar.cal.calendar.Calendar>` for this object. The result is cached after the first conversion. Treat it as read-only; use :meth:`edit_icalendar_instance` to make and persist changes. """ if self._ical_cache is None: self._ical_cache = icalendar.Calendar.from_ical(jscal_to_ical(self.data)) return self._ical_cache
[docs] @contextmanager def edit_icalendar_instance(self): """Borrow an editable :class:`icalendar.Calendar <icalendar.cal.calendar.Calendar>` for this object. Yields the cached :class:`icalendar.Calendar <icalendar.cal.calendar.Calendar>` for in-place editing. Call :meth:`save` after the ``with`` block to persist changes to the server. Note: :meth:`save` is sync-only. Async-backed calendars cannot use this path yet. Example:: with obj.edit_icalendar_instance() as cal: cal.subcomponents[0]["SUMMARY"] = vText("New title") obj.save() """ cal = self.get_icalendar_instance() yield cal
[docs] def save(self) -> None: """Persist changes made via :meth:`edit_icalendar_instance` to the server. Serialises the (possibly edited) icalendar object back to an iCalendar string and calls ``update_event()`` on the parent calendar's client. Raises: JMAPMethodError: If no parent calendar is set (``parent`` is ``None``). RuntimeError: If called on an async-backed calendar. ValueError: If an edit made via :meth:`edit_icalendar_instance` leaves the object in a shape rejected by :meth:`~calendaring_jmap.client.JMAPClient.update_event` (e.g. removing ``UID``/``DTSTART``, or moving ``DTEND`` before ``DTSTART``). """ if self.parent is None: raise JMAPMethodError(url="N/A", reason="Cannot save: no parent calendar is set") if self.parent._is_async: raise RuntimeError( "save() is not supported for async-backed calendars. " "Use await parent._client.update_event() directly." ) ical_str = ( self._ical_cache.to_ical().decode() if self._ical_cache is not None else jscal_to_ical(self.data) ) self.parent._bound_client.update_event( self.id, ical_str, account_id=self.parent._account_id )