"""CalDAV sync provider — bidirectional sync with Apple Calendar (U6). Uses the ``caldav`` library for the CalDAV protocol and ``icalendar`` for ICS serialization/parsing. Conflict resolution is last-write-wins based on ``last_modified``; conflicts emit a ``calendar_sync_conflict`` WS notification via the injectable ``notify_callback`` (G4). """ from __future__ import annotations import asyncio import json import logging import uuid from collections.abc import Awaitable, Callable from datetime import date, datetime, timedelta, timezone from pathlib import Path import caldav from icalendar import Calendar, Event from icalendar.prop import vRecur from agentkit.calendar.db import ( DEFAULT_CALENDAR_DB_PATH, get_event_by_external_id, insert_event, update_event, ) from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig, _now_iso from agentkit.calendar.sync.base import AbstractSyncProvider logger = logging.getLogger(__name__) # Async callback signature: (event_type: str, payload: dict) -> None NotifyCallback = Callable[[str, dict[str, object]], Awaitable[None]] def _parse_iso(dt_str: str) -> datetime: """Parse ISO 8601 string to UTC-aware datetime.""" dt = datetime.fromisoformat(dt_str) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc) def _to_iso_utc(dt: datetime) -> str: """Convert datetime to ISO 8601 UTC string.""" if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc).isoformat() def _extract_dt(component: object, key: str) -> tuple[str, bool]: """Extract date/datetime from icalendar component. Returns (iso, is_all_day).""" prop = component.get(key) if prop is None: return "", False val = prop.dt if isinstance(val, date) and not isinstance(val, datetime): return val.isoformat(), True return _to_iso_utc(val), False class CalDAVSyncProvider(AbstractSyncProvider): """Bidirectional CalDAV sync provider for Apple Calendar. The ``client_factory`` parameter allows tests to inject a mock CalDAV client without touching ``caldav.DAVClient``. When ``None``, a real ``caldav.DAVClient`` is constructed from ``config.credentials``. """ def __init__( self, db_path: str | Path | None = None, client_factory: Callable[[ExternalCalendarConfig], object] | None = None, notify_callback: NotifyCallback | None = None, ) -> None: self.db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH self._client_factory = client_factory self._notify = notify_callback # ponytail: conflicts list is in-memory only; if the process restarts # before a sync completes, conflict history is lost. Upgrade: persist # to a calendar_sync_conflicts table. # ------------------------------------------------------------------ # Client construction # ------------------------------------------------------------------ def _make_client(self, config: ExternalCalendarConfig) -> object: """Build a caldav.DAVClient from config credentials.""" if self._client_factory is not None: return self._client_factory(config) # ponytail: credentials stored as plain JSON dict; encryption deferred. # Upgrade: use agentkit.server.auth.crypto to encrypt at rest. creds = json.loads(config.credentials) if config.credentials else {} return caldav.DAVClient( url=creds.get("url", ""), username=creds.get("username", ""), password=creds.get("password", ""), # ponytail: 30s timeout prevents indefinite hangs on unreachable # CalDAV servers. Upgrade: make configurable per-config. timeout=30, ) def _get_calendar(self, config: ExternalCalendarConfig) -> object: """Connect and return the first calendar from the principal.""" client = self._make_client(config) principal = client.principal() calendars = principal.calendars() if not calendars: raise RuntimeError("No CalDAV calendars found for this account") return calendars[0] # ------------------------------------------------------------------ # Pull # ------------------------------------------------------------------ async def pull_changes( self, config: ExternalCalendarConfig, since: str | None = None ) -> list[CalendarEvent]: """Pull remote CalDAV events into local DB. Fetches events in a date range starting from ``since`` (or 1 year ago if None). Matches by ``external_id`` (CalDAV UID). Creates new local events or updates existing ones. Conflicts (both sides modified) are resolved last-write-wins with a WS notification. """ # caldav is synchronous — run in a thread to avoid blocking the loop remote_events = await asyncio.to_thread(self._fetch_remote_events, config, since) result: list[CalendarEvent] = [] for remote in remote_events: local = await get_event_by_external_id( remote.external_id, "caldav", config.user_id, self.db_path ) if local is None: # New remote event → create local await insert_event(remote, self.db_path) result.append(remote) else: # Existing → check for conflict resolved = await self._resolve_pull_conflict(local, remote) if resolved is not None: result.append(resolved) return result def _fetch_remote_events( self, config: ExternalCalendarConfig, since: str | None ) -> list[CalendarEvent]: """Synchronous CalDAV fetch — runs in thread.""" calendar = self._get_calendar(config) # Date range: since → now+90d (or wide default) if since: start_dt = _parse_iso(since) else: start_dt = datetime.now(timezone.utc) - timedelta(days=365) end_dt = datetime.now(timezone.utc) + timedelta(days=90) caldav_events = calendar.date_search(start_dt, end_dt) events: list[CalendarEvent] = [] for ce in caldav_events: parsed = self._parse_caldav_event(ce, config.user_id) if parsed is not None: events.append(parsed) return events def _parse_caldav_event(self, caldav_event: object, user_id: str) -> CalendarEvent | None: """Convert a caldav.Event to a CalendarEvent dataclass.""" try: ical_data = caldav_event.data cal = Calendar.from_ical(ical_data) except Exception as e: logger.warning("Failed to parse CalDAV event: %s", e) return None for component in cal.walk("VEVENT"): uid = str(component.get("UID", "") or "") or None if not uid: continue title = str(component.get("SUMMARY", "") or "") if not title: continue start_str, is_all_day = _extract_dt(component, "DTSTART") end_str, _ = _extract_dt(component, "DTEND") if not end_str: end_str = start_str description = str(component.get("DESCRIPTION", "") or "") location = str(component.get("LOCATION", "") or "") rrule_str: str | None = None rrule = component.get("RRULE") if rrule is not None: rrule_str = rrule.to_ical().decode("utf-8") # LAST-MODIFIED from VEVENT (for conflict resolution) lm_prop = component.get("LAST-MODIFIED") if lm_prop is not None: last_modified = _to_iso_utc(lm_prop.dt) else: last_modified = _now_iso() now = _now_iso() return CalendarEvent( id=uuid.uuid4().hex, user_id=user_id, title=title, description=description, start_time=start_str, end_time=end_str, is_all_day=is_all_day, location=location, rrule=rrule_str, source="manual", external_id=uid, external_provider="caldav", last_modified=last_modified, created_at=now, ) return None async def _resolve_pull_conflict( self, local: CalendarEvent, remote: CalendarEvent ) -> CalendarEvent | None: """Resolve conflict when both local and remote exist. If remote is newer → update local. If local is newer → conflict (last-write-wins keeps local, but log + notify). If equal → no-op. Returns the winning event (or None if local kept unchanged). """ local_lm = ( _parse_iso(local.last_modified) if local.last_modified else datetime.min.replace(tzinfo=timezone.utc) ) remote_lm = ( _parse_iso(remote.last_modified) if remote.last_modified else datetime.min.replace(tzinfo=timezone.utc) ) if remote_lm > local_lm: # Remote wins → update local await self._notify_conflict(local, remote, winner="remote") fields = { "title": remote.title, "description": remote.description, "start_time": remote.start_time, "end_time": remote.end_time, "is_all_day": remote.is_all_day, "location": remote.location, "rrule": remote.rrule, "last_modified": remote.last_modified, } await update_event(local.id, fields, self.db_path) return remote if local_lm > remote_lm: # Local wins → conflict, keep local, notify await self._notify_conflict(local, remote, winner="local") return None # Equal timestamps → no change needed return None # ------------------------------------------------------------------ # Push # ------------------------------------------------------------------ async def push_changes( self, config: ExternalCalendarConfig, events: list[CalendarEvent] ) -> list[CalendarEvent]: """Push local events to CalDAV. Returns events with external_id set.""" result: list[CalendarEvent] = [] for event in events: updated = await self._push_single(config, event) result.append(updated) return result async def _push_single( self, config: ExternalCalendarConfig, event: CalendarEvent ) -> CalendarEvent: """Push a single event to CalDAV, return event with external_id set.""" ical_bytes = self._event_to_ics(event) # caldav is synchronous — run in thread saved_uid = await asyncio.to_thread(self._save_remote_event, config, ical_bytes, event) # If event had no external_id, set it from the saved UID if not event.external_id and saved_uid: fields = { "external_id": saved_uid, "external_provider": "caldav", "last_modified": _now_iso(), } await update_event(event.id, fields, self.db_path) event.external_id = saved_uid event.external_provider = "caldav" return event def _save_remote_event( self, config: ExternalCalendarConfig, ical_bytes: bytes, event: CalendarEvent ) -> str | None: """Synchronous CalDAV save — runs in thread. Returns remote UID.""" calendar = self._get_calendar(config) saved = calendar.save_event(ical_bytes) # Extract UID from saved event try: cal = Calendar.from_ical(saved.data) for comp in cal.walk("VEVENT"): uid = str(comp.get("UID", "") or "") or None if uid: return uid except Exception as e: logger.warning("Failed to extract UID from saved event: %s", e) return event.external_id def _event_to_ics(self, event: CalendarEvent) -> bytes: """Convert CalendarEvent to ICS bytes using icalendar library.""" cal = Calendar() cal.add("prodid", "-//Fischer AgentKit//Calendar//EN") cal.add("version", "2.0") vevent = Event() # Use external_id if available, else local id as UID vevent.add("uid", event.external_id or event.id) vevent.add("summary", event.title) if event.start_time: start_dt = _parse_iso(event.start_time) if event.is_all_day: vevent.add("dtstart", start_dt.date()) else: vevent.add("dtstart", start_dt) if event.end_time: end_dt = _parse_iso(event.end_time) if event.is_all_day: vevent.add("dtend", end_dt.date()) else: vevent.add("dtend", end_dt) if event.description: vevent.add("description", event.description) if event.location: vevent.add("location", event.location) if event.rrule: vevent.add("rrule", vRecur.from_ical(event.rrule)) # LAST-MODIFIED for conflict resolution if event.last_modified: vevent.add("last-modified", _parse_iso(event.last_modified)) cal.add_component(vevent) return cal.to_ical() # ------------------------------------------------------------------ # Test connection # ------------------------------------------------------------------ async def test_connection(self, config: ExternalCalendarConfig) -> tuple[bool, str]: """Test CalDAV connectivity. Returns (ok, error_msg).""" try: await asyncio.to_thread(self._get_calendar, config) return True, "" except Exception as e: return False, str(e) # ------------------------------------------------------------------ # Conflict notification # ------------------------------------------------------------------ async def _notify_conflict( self, local: CalendarEvent, remote: CalendarEvent, winner: str ) -> None: """Log conflict and send WS notification via callback (G4).""" logger.info( "Sync conflict for event %s (external_id=%s): local_lm=%s remote_lm=%s winner=%s", local.id, local.external_id, local.last_modified, remote.last_modified, winner, ) if self._notify is not None: payload = { "event_id": local.id, "title": local.title, "external_id": local.external_id, "local_last_modified": local.last_modified, "remote_last_modified": remote.last_modified, "winner": winner, } await self._notify("calendar_sync_conflict", payload)