398 lines
15 KiB
Python
398 lines
15 KiB
Python
"""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)
|