|
@@ -1,220 +1,16 @@
|
|
|
-import asyncio
|
|
|
|
|
import logging
|
|
import logging
|
|
|
-import uuid
|
|
|
|
|
-from caldav import aio
|
|
|
|
|
-from datetime import date, datetime, timedelta, timezone
|
|
|
|
|
-from icalendar import Calendar as _iCal, Event as _iEvent
|
|
|
|
|
|
|
+from datetime import datetime, timedelta
|
|
|
|
|
|
|
|
|
|
+# Re-export for backward compatibility — Calendar lives in room_cal.caldav
|
|
|
|
|
+from room_cal.caldav import (
|
|
|
|
|
+ Calendar, DEFAULT_CALENDAR_NAME,
|
|
|
|
|
+ _parse_event, _build_ical, _X_DATA_PROP,
|
|
|
|
|
+)
|
|
|
from room_cal.store._backend import EventRecord, register_backend
|
|
from room_cal.store._backend import EventRecord, register_backend
|
|
|
|
|
|
|
|
_log = logging.getLogger(__name__)
|
|
_log = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
-def _naive(dt) -> datetime:
|
|
|
|
|
- if isinstance(dt, datetime):
|
|
|
|
|
- return dt.replace(tzinfo=None)
|
|
|
|
|
- return datetime(dt.year, dt.month, dt.day)
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-_X_DATA_PROP = 'X-ROOMCAL-DATA'
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-def _parse_event(caldav_event) -> dict:
|
|
|
|
|
- cal = caldav_event.icalendar_instance
|
|
|
|
|
- vevent = next(c for c in cal.walk() if c.name == 'VEVENT')
|
|
|
|
|
-
|
|
|
|
|
- dtstart = vevent.get('DTSTART').dt
|
|
|
|
|
- dtend_prop = vevent.get('DTEND') or vevent.get('DUE')
|
|
|
|
|
- dtend = dtend_prop.dt if dtend_prop else dtstart
|
|
|
|
|
-
|
|
|
|
|
- all_day = isinstance(dtstart, date) and not isinstance(dtstart, datetime)
|
|
|
|
|
-
|
|
|
|
|
- recurrence_id = vevent.get('RECURRENCE-ID')
|
|
|
|
|
-
|
|
|
|
|
- x_data_prop = vevent.get(_X_DATA_PROP)
|
|
|
|
|
- x_data = str(x_data_prop) if x_data_prop is not None else None
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- 'start': _naive(dtstart),
|
|
|
|
|
- 'end': _naive(dtend),
|
|
|
|
|
- 'all_day': all_day,
|
|
|
|
|
- 'title': str(vevent.get('SUMMARY', '')),
|
|
|
|
|
- 'description': str(vevent.get('DESCRIPTION', '')),
|
|
|
|
|
- 'x_data': x_data,
|
|
|
|
|
- 'uid': str(vevent.get('UID', '')),
|
|
|
|
|
- 'url': str(caldav_event.url),
|
|
|
|
|
- 'etag': getattr(caldav_event, 'etag', None),
|
|
|
|
|
- 'recurrence_id': _naive(recurrence_id.dt) if recurrence_id else None,
|
|
|
|
|
- '_vevent': vevent,
|
|
|
|
|
- '_raw_event': caldav_event,
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-def _build_ical(dtstart, dtend, title: str, description: str = '', uid: str = None, x_data: str = None) -> str:
|
|
|
|
|
- cal = _iCal()
|
|
|
|
|
- cal.add('prodid', '-//room_cal//EN')
|
|
|
|
|
- cal.add('version', '2.0')
|
|
|
|
|
-
|
|
|
|
|
- event = _iEvent()
|
|
|
|
|
- event.add('uid', uid or str(uuid.uuid4()))
|
|
|
|
|
- event.add('dtstamp', datetime.now(timezone.utc))
|
|
|
|
|
- event.add('dtstart', dtstart)
|
|
|
|
|
- event.add('dtend', dtend)
|
|
|
|
|
- event.add('summary', title)
|
|
|
|
|
- if description:
|
|
|
|
|
- event.add('description', description)
|
|
|
|
|
- if x_data is not None:
|
|
|
|
|
- event.add(_X_DATA_PROP, x_data)
|
|
|
|
|
-
|
|
|
|
|
- cal.add_component(event)
|
|
|
|
|
- return cal.to_ical().decode('utf-8')
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-DEFAULT_CALENDAR_NAME = 'Room'
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-class Calendar:
|
|
|
|
|
- def __init__(self, calendar_name=DEFAULT_CALENDAR_NAME, *, url='', username='', password=''):
|
|
|
|
|
- self._calendar_name = calendar_name
|
|
|
|
|
- self._connection = {'url': url, 'username': username, 'password': password}
|
|
|
|
|
- self._cached_url = None
|
|
|
|
|
- self._event_cache: dict[tuple, list] = {}
|
|
|
|
|
- self._inflight: dict[tuple, asyncio.Task] = {}
|
|
|
|
|
-
|
|
|
|
|
- @property
|
|
|
|
|
- def calendar_name(self) -> str:
|
|
|
|
|
- return self._calendar_name
|
|
|
|
|
-
|
|
|
|
|
- def _resolved_connection(self) -> dict:
|
|
|
|
|
- if not self._connection.get('url'):
|
|
|
|
|
- raise ValueError('CALDAV_URL must be set via store.caldav.url in config.json')
|
|
|
|
|
- return self._connection
|
|
|
|
|
-
|
|
|
|
|
- async def _client(self):
|
|
|
|
|
- return await aio.get_async_davclient(**self._resolved_connection())
|
|
|
|
|
-
|
|
|
|
|
- async def _get_calendar(self, client):
|
|
|
|
|
- if self._cached_url is not None:
|
|
|
|
|
- return client.calendar(url=self._cached_url)
|
|
|
|
|
- from caldav.lib.error import AuthorizationError
|
|
|
|
|
- try:
|
|
|
|
|
- principal = await client.principal()
|
|
|
|
|
- except AuthorizationError:
|
|
|
|
|
- conn = self._resolved_connection()
|
|
|
|
|
- raise AuthorizationError(
|
|
|
|
|
- f"CalDAV auth failed for {conn['url']} (username={conn.get('username', '(none)')})"
|
|
|
|
|
- ) from None
|
|
|
|
|
- calendars = await principal.get_calendars()
|
|
|
|
|
- for cal in calendars:
|
|
|
|
|
- if await cal.get_display_name() == self._calendar_name:
|
|
|
|
|
- self._cached_url = str(cal.url)
|
|
|
|
|
- return cal
|
|
|
|
|
- raise ValueError(f"Calendar '{self._calendar_name}' not found")
|
|
|
|
|
-
|
|
|
|
|
- async def fetch_events(self, start: datetime, end: datetime) -> list[dict]:
|
|
|
|
|
- async with await self._client() as client:
|
|
|
|
|
- cal = await self._get_calendar(client)
|
|
|
|
|
- raw_events = await cal.search(
|
|
|
|
|
- start=start,
|
|
|
|
|
- end=end,
|
|
|
|
|
- event=True,
|
|
|
|
|
- expand=True,
|
|
|
|
|
- )
|
|
|
|
|
- events = [_parse_event(e) for e in raw_events]
|
|
|
|
|
- events.sort(key=lambda e: e['start'])
|
|
|
|
|
- return events
|
|
|
|
|
-
|
|
|
|
|
- async def fetch_date_range(self, start_date: date, end_date: date) -> list[dict]:
|
|
|
|
|
- key = (start_date, end_date)
|
|
|
|
|
- if key in self._event_cache:
|
|
|
|
|
- return self._event_cache[key]
|
|
|
|
|
- if key in self._inflight:
|
|
|
|
|
- return await self._inflight[key]
|
|
|
|
|
- task = asyncio.create_task(self._fetch_date_range_uncached(start_date, end_date))
|
|
|
|
|
- self._inflight[key] = task
|
|
|
|
|
- try:
|
|
|
|
|
- result = await task
|
|
|
|
|
- finally:
|
|
|
|
|
- self._inflight.pop(key, None)
|
|
|
|
|
- self._event_cache[key] = result
|
|
|
|
|
- return result
|
|
|
|
|
-
|
|
|
|
|
- async def _fetch_date_range_uncached(self, start_date: date, end_date: date) -> list[dict]:
|
|
|
|
|
- start = datetime(start_date.year, start_date.month, start_date.day)
|
|
|
|
|
- end = datetime(end_date.year, end_date.month, end_date.day) + timedelta(days=1)
|
|
|
|
|
- return await self.fetch_events(start, end)
|
|
|
|
|
-
|
|
|
|
|
- async def create_timed_event(self, dtstart: datetime, dtend: datetime, title: str, description: str = '', x_data: str = None) -> dict:
|
|
|
|
|
- ical_str = _build_ical(dtstart, dtend, title, description, x_data=x_data)
|
|
|
|
|
- async with await self._client() as client:
|
|
|
|
|
- cal = await self._get_calendar(client)
|
|
|
|
|
- saved = await cal.save_event(ical_str)
|
|
|
|
|
- self._event_cache.clear()
|
|
|
|
|
- _log.info('create date=%s title=%r', dtstart.date(), title)
|
|
|
|
|
- return _parse_event(saved)
|
|
|
|
|
-
|
|
|
|
|
- async def create_event(self, target_date: date, title: str, description: str = '', x_data: str = None, end_date: date = None) -> dict:
|
|
|
|
|
- if isinstance(target_date, datetime):
|
|
|
|
|
- target_date = target_date.date()
|
|
|
|
|
- dtend = end_date if end_date is not None else target_date + timedelta(days=1)
|
|
|
|
|
- ical_str = _build_ical(target_date, dtend, title, description, x_data=x_data)
|
|
|
|
|
- async with await self._client() as client:
|
|
|
|
|
- cal = await self._get_calendar(client)
|
|
|
|
|
- saved = await cal.save_event(ical_str)
|
|
|
|
|
- self._event_cache.clear()
|
|
|
|
|
- _log.info('create date=%s title=%r', target_date, title)
|
|
|
|
|
- return _parse_event(saved)
|
|
|
|
|
-
|
|
|
|
|
- async def delete_event(self, event_dict: dict) -> None:
|
|
|
|
|
- async with await self._client() as client:
|
|
|
|
|
- cal = await self._get_calendar(client)
|
|
|
|
|
- raw = await cal.event_by_uid(event_dict['uid'])
|
|
|
|
|
- await raw.delete()
|
|
|
|
|
- self._event_cache.clear()
|
|
|
|
|
- _log.info('delete date=%s title=%r', event_dict['start'].date(), event_dict.get('title', ''))
|
|
|
|
|
-
|
|
|
|
|
- async def update_event(self, event_dict: dict, title: str = None, description: str = None, x_data: str = None) -> dict:
|
|
|
|
|
- async with await self._client() as client:
|
|
|
|
|
- cal = await self._get_calendar(client)
|
|
|
|
|
- raw = await cal.event_by_uid(event_dict['uid'])
|
|
|
|
|
- ical = raw.icalendar_instance
|
|
|
|
|
- for component in ical.walk():
|
|
|
|
|
- if component.name != 'VEVENT':
|
|
|
|
|
- continue
|
|
|
|
|
- if title is not None:
|
|
|
|
|
- if 'SUMMARY' in component:
|
|
|
|
|
- del component['SUMMARY']
|
|
|
|
|
- component.add('summary', title)
|
|
|
|
|
- if description is not None:
|
|
|
|
|
- if 'DESCRIPTION' in component:
|
|
|
|
|
- del component['DESCRIPTION']
|
|
|
|
|
- component.add('description', description)
|
|
|
|
|
- if x_data is not None:
|
|
|
|
|
- if _X_DATA_PROP in component:
|
|
|
|
|
- del component[_X_DATA_PROP]
|
|
|
|
|
- component.add(_X_DATA_PROP, x_data)
|
|
|
|
|
- raw.data = ical.to_ical().decode('utf-8')
|
|
|
|
|
- await raw.save()
|
|
|
|
|
- self._event_cache.clear()
|
|
|
|
|
- effective_title = title if title is not None else event_dict.get('title', '')
|
|
|
|
|
- _log.info('update date=%s title=%r', event_dict['start'].date(), effective_title)
|
|
|
|
|
- return _parse_event(raw)
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-_calendar_registry: dict[str, Calendar] = {}
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-def get_calendar(name: str = DEFAULT_CALENDAR_NAME) -> Calendar:
|
|
|
|
|
- """Return the shared Calendar instance for the given name, creating it on first call."""
|
|
|
|
|
- if name not in _calendar_registry:
|
|
|
|
|
- from room_cal._utils import get_config
|
|
|
|
|
- cal_cfg = get_config().store.caldav
|
|
|
|
|
- _calendar_registry[name] = Calendar(name, url=cal_cfg.url, username=cal_cfg.username, password=cal_cfg.password)
|
|
|
|
|
- return _calendar_registry[name]
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── CalDAVBackend ─────────────────────────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
def _dict_to_record(d: dict) -> EventRecord:
|
|
def _dict_to_record(d: dict) -> EventRecord:
|
|
|
return EventRecord(
|
|
return EventRecord(
|
|
|
uid=d['uid'],
|
|
uid=d['uid'],
|
|
@@ -228,8 +24,22 @@ def _dict_to_record(d: dict) -> EventRecord:
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+_calendar_registry: dict[str, Calendar] = {}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def get_calendar(name: str = DEFAULT_CALENDAR_NAME) -> Calendar:
|
|
|
|
|
+ """Return the store's shared Calendar instance for the given name, creating it on first call."""
|
|
|
|
|
+ if name not in _calendar_registry:
|
|
|
|
|
+ from room_cal._utils import get_config
|
|
|
|
|
+ cal_cfg = get_config().store.caldav
|
|
|
|
|
+ _calendar_registry[name] = Calendar(name, url=cal_cfg.url, username=cal_cfg.username, password=cal_cfg.password)
|
|
|
|
|
+ return _calendar_registry[name]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
@register_backend('caldav')
|
|
@register_backend('caldav')
|
|
|
class CalDAVBackend:
|
|
class CalDAVBackend:
|
|
|
|
|
+ """EventBackend adapter that bridges a Calendar instance to the store EventBackend protocol."""
|
|
|
|
|
+
|
|
|
def __init__(self, cal: Calendar):
|
|
def __init__(self, cal: Calendar):
|
|
|
self._cal = cal
|
|
self._cal = cal
|
|
|
|
|
|