|
|
@@ -1,28 +1,15 @@
|
|
|
import asyncio
|
|
|
import logging
|
|
|
-import os
|
|
|
import uuid
|
|
|
from caldav import aio
|
|
|
from datetime import date, datetime, timedelta, timezone
|
|
|
from icalendar import Calendar as _iCal, Event as _iEvent
|
|
|
|
|
|
-from room_cal._utils import data_home, read_env
|
|
|
-from room_cal.cal._backend import EventBackend, EventRecord
|
|
|
+from room_cal.store._backend import EventRecord, register_backend
|
|
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
-def _caldav_config() -> dict[str, str]:
|
|
|
- env_file = data_home() / 'caldav.env'
|
|
|
- env = {**read_env(env_file), **os.environ}
|
|
|
- url = env.get('CALDAV_URL', '')
|
|
|
- username = env.get('CALDAV_USERNAME', '')
|
|
|
- password = env.get('CALDAV_PASSWORD', '')
|
|
|
- if not url:
|
|
|
- raise ValueError(f'CALDAV_URL must be set in {env_file}')
|
|
|
- return {'url': url, 'username': username, 'password': password}
|
|
|
-
|
|
|
-
|
|
|
def _naive(dt) -> datetime:
|
|
|
if isinstance(dt, datetime):
|
|
|
return dt.replace(tzinfo=None)
|
|
|
@@ -54,12 +41,10 @@ def _parse_event(caldav_event) -> dict:
|
|
|
'title': str(vevent.get('SUMMARY', '')),
|
|
|
'description': str(vevent.get('DESCRIPTION', '')),
|
|
|
'x_data': x_data,
|
|
|
- # sync fields
|
|
|
'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,
|
|
|
- # escape hatches
|
|
|
'_vevent': vevent,
|
|
|
'_raw_event': caldav_event,
|
|
|
}
|
|
|
@@ -89,8 +74,9 @@ DEFAULT_CALENDAR_NAME = 'Room'
|
|
|
|
|
|
|
|
|
class Calendar:
|
|
|
- def __init__(self, calendar_name=DEFAULT_CALENDAR_NAME):
|
|
|
+ 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] = {}
|
|
|
@@ -99,8 +85,13 @@ class Calendar:
|
|
|
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(**_caldav_config())
|
|
|
+ return await aio.get_async_davclient(**self._resolved_connection())
|
|
|
|
|
|
async def _get_calendar(self, client):
|
|
|
if self._cached_url is not None:
|
|
|
@@ -127,10 +118,6 @@ class Calendar:
|
|
|
return events
|
|
|
|
|
|
async def fetch_date_range(self, start_date: date, end_date: date) -> list[dict]:
|
|
|
- """Fetch events covering start_date through end_date (inclusive).
|
|
|
-
|
|
|
- Results are cached; concurrent calls for the same range share one network request.
|
|
|
- """
|
|
|
key = (start_date, end_date)
|
|
|
if key in self._event_cache:
|
|
|
return self._event_cache[key]
|
|
|
@@ -207,25 +194,14 @@ class Calendar:
|
|
|
return _parse_event(raw)
|
|
|
|
|
|
|
|
|
-_registry: dict[str, 'Calendar'] = {}
|
|
|
+_calendar_registry: dict[str, Calendar] = {}
|
|
|
|
|
|
|
|
|
-def get_calendar(name: str = DEFAULT_CALENDAR_NAME) -> '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 _registry:
|
|
|
- _registry[name] = Calendar(name)
|
|
|
- return _registry[name]
|
|
|
-
|
|
|
-
|
|
|
-def clear_caches() -> int:
|
|
|
- """Clear event caches and cached URLs for all registered Calendar instances.
|
|
|
-
|
|
|
- Returns the number of calendars cleared.
|
|
|
- """
|
|
|
- for cal in _registry.values():
|
|
|
- cal._event_cache.clear()
|
|
|
- cal._cached_url = None
|
|
|
- return len(_registry)
|
|
|
+ if name not in _calendar_registry:
|
|
|
+ _calendar_registry[name] = Calendar(name)
|
|
|
+ return _calendar_registry[name]
|
|
|
|
|
|
|
|
|
# ── CalDAVBackend ─────────────────────────────────────────────────────────────
|
|
|
@@ -243,10 +219,20 @@ def _dict_to_record(d: dict) -> EventRecord:
|
|
|
)
|
|
|
|
|
|
|
|
|
+@register_backend('caldav')
|
|
|
class CalDAVBackend:
|
|
|
def __init__(self, cal: Calendar):
|
|
|
self._cal = cal
|
|
|
|
|
|
+ @classmethod
|
|
|
+ def from_store_config(cls, store_cfg, calendar_name: str) -> 'CalDAVBackend':
|
|
|
+ cal_cfg = store_cfg.caldav
|
|
|
+ return cls(Calendar(calendar_name, url=cal_cfg.url, username=cal_cfg.username, password=cal_cfg.password))
|
|
|
+
|
|
|
+ def clear_cache(self):
|
|
|
+ self._cal._event_cache.clear()
|
|
|
+ self._cal._cached_url = None
|
|
|
+
|
|
|
async def fetch_range(self, start: datetime, end: datetime) -> list[EventRecord]:
|
|
|
events = await self._cal.fetch_events(start, end)
|
|
|
return [_dict_to_record(e) for e in events]
|
|
|
@@ -301,70 +287,3 @@ class CalDAVBackend:
|
|
|
await raw.delete()
|
|
|
self._cal._event_cache.clear()
|
|
|
_log.info('delete uid=%s', uid)
|
|
|
-
|
|
|
-
|
|
|
-# ── Backend registry + factory ────────────────────────────────────────────────
|
|
|
-
|
|
|
-_backend_registry: dict[str, CalDAVBackend] = {}
|
|
|
-
|
|
|
-
|
|
|
-def get_backend(name: str = DEFAULT_CALENDAR_NAME) -> EventBackend:
|
|
|
- """Return the shared EventBackend for the given calendar name.
|
|
|
-
|
|
|
- Uses the backend type configured via the 'backend' key in config.json
|
|
|
- ('caldav' by default, 'sqlite' for local SQLite storage).
|
|
|
- """
|
|
|
- from room_cal._utils import get_config, data_home as _data_home
|
|
|
- cfg = get_config()
|
|
|
- if cfg.backend == 'sqlite':
|
|
|
- from room_cal.cal._sqlite import SQLiteBackend
|
|
|
- return SQLiteBackend(_data_home() / 'room_cal.db', name)
|
|
|
- if name not in _backend_registry:
|
|
|
- _backend_registry[name] = CalDAVBackend(get_calendar(name))
|
|
|
- return _backend_registry[name]
|
|
|
-
|
|
|
-
|
|
|
-# ── StoreBacked ───────────────────────────────────────────────────────────────
|
|
|
-
|
|
|
-class StoreBacked:
|
|
|
- """Base for objects backed by a single named event backend."""
|
|
|
- def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME, *,
|
|
|
- backend: 'EventBackend | None' = None):
|
|
|
- self._calendar_name = calendar_name
|
|
|
- self._backend: EventBackend = backend if backend is not None else get_backend(calendar_name)
|
|
|
-
|
|
|
- async def _fetch_date_range(self, start_date: date, end_date: date) -> list[EventRecord]:
|
|
|
- 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._backend.fetch_range(start, end)
|
|
|
-
|
|
|
- async def _create_all_day(self, start_date: date, title: str, description: str = '',
|
|
|
- x_data: str | None = None, *,
|
|
|
- end_date: date | None = None) -> EventRecord:
|
|
|
- end = end_date if end_date is not None else start_date + timedelta(days=1)
|
|
|
- record = EventRecord(
|
|
|
- uid=str(uuid.uuid4()),
|
|
|
- start=datetime(start_date.year, start_date.month, start_date.day),
|
|
|
- end=datetime(end.year, end.month, end.day),
|
|
|
- all_day=True,
|
|
|
- title=title,
|
|
|
- description=description,
|
|
|
- x_data=x_data,
|
|
|
- )
|
|
|
- return await self._backend.create(record)
|
|
|
-
|
|
|
- async def _create_timed(self, start: datetime, end: datetime, title: str,
|
|
|
- description: str = '', x_data: str | None = None) -> EventRecord:
|
|
|
- record = EventRecord(
|
|
|
- uid=str(uuid.uuid4()),
|
|
|
- start=start,
|
|
|
- end=end,
|
|
|
- all_day=False,
|
|
|
- title=title,
|
|
|
- description=description,
|
|
|
- x_data=x_data,
|
|
|
- )
|
|
|
- return await self._backend.create(record)
|
|
|
-
|
|
|
-
|
|
|
-CalendarBacked = StoreBacked # backward compat alias
|