"""CalDAV calendar integration service.""" import asyncio import logging from datetime import date as date_type, datetime, timedelta from zoneinfo import ZoneInfo import caldav import icalendar from fabledassistant.services.settings import get_all_settings logger = logging.getLogger(__name__) CALDAV_SETTING_KEYS = ["caldav_url", "caldav_username", "caldav_password", "caldav_calendar_name", "caldav_timezone"] # Sentinel: distinguishes "leave the RRULE untouched" from "clear it" (None/"") # in update_event, since None is a meaningful value for recurrence. _RECURRENCE_UNSET = object() async def get_caldav_config(user_id: int) -> dict[str, str]: """Return the user's CalDAV config from their settings.""" all_settings = await get_all_settings(user_id) return {k: all_settings.get(k, "") for k in CALDAV_SETTING_KEYS} async def is_caldav_configured(user_id: int) -> bool: """Check if the user has configured an external CalDAV server.""" config = await get_caldav_config(user_id) return bool(config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")) def _get_calendar(client: caldav.DAVClient, calendar_name: str) -> caldav.Calendar: """Get a named calendar or the first available one (synchronous).""" principal = client.principal() calendars = principal.calendars() if not calendars: raise ValueError("No calendars found on the CalDAV server.") if calendar_name: for cal in calendars: if cal.name == calendar_name: return cal names = [c.name for c in calendars] raise ValueError(f"Calendar '{calendar_name}' not found. Available: {', '.join(names)}") return calendars[0] def _get_all_calendars(client: caldav.DAVClient) -> list[caldav.Calendar]: """Get all calendars for the user (synchronous).""" principal = client.principal() calendars = principal.calendars() if not calendars: raise ValueError("No calendars found on the CalDAV server.") return calendars def _make_client(config: dict[str, str]) -> caldav.DAVClient: """Create a CalDAV client from config dict.""" return caldav.DAVClient( url=config["caldav_url"], username=config.get("caldav_username") or None, password=config.get("caldav_password") or None, ) def _parse_vevent(component) -> dict | None: """Extract event data from a VEVENT component.""" if component.name != "VEVENT": return None title = str(component.get("SUMMARY", "")) dtstart = component.get("DTSTART") dtend = component.get("DTEND") location = str(component.get("LOCATION", "")) description = str(component.get("DESCRIPTION", "")) uid = str(component.get("UID", "")) start_str = dtstart.dt.isoformat() if dtstart else "" end_str = dtend.dt.isoformat() if dtend else "" result = { "uid": uid, "title": title, "start": start_str, "end": end_str, "location": location, "description": description, } # Extract recurrence rule rrule = component.get("RRULE") if rrule: result["recurrence"] = rrule.to_ical().decode("utf-8") # Extract alarms alarms = [] for sub in component.subcomponents: if sub.name == "VALARM": trigger = sub.get("TRIGGER") if trigger and trigger.dt: minutes = abs(int(trigger.dt.total_seconds() // 60)) alarms.append({"minutes_before": minutes}) if alarms: result["alarms"] = alarms # Extract attendees attendees = component.get("ATTENDEE") if attendees: if not isinstance(attendees, list): attendees = [attendees] result["attendees"] = [str(a).replace("mailto:", "") for a in attendees] return result def _parse_vtodo(component) -> dict | None: """Extract todo data from a VTODO component.""" if component.name != "VTODO": return None uid = str(component.get("UID", "")) summary = str(component.get("SUMMARY", "")) description = str(component.get("DESCRIPTION", "")) status = str(component.get("STATUS", "")) due = component.get("DUE") due_str = due.dt.isoformat() if due else "" priority = component.get("PRIORITY") priority_val = int(priority) if priority else None return { "uid": uid, "summary": summary, "description": description, "due": due_str, "status": status, "priority": priority_val, } def _apply_timezone(dt: datetime, timezone: str | None) -> datetime: """Apply a timezone to a naive datetime. Returns dt unchanged if already aware.""" if dt.tzinfo is not None: return dt if timezone: return dt.replace(tzinfo=ZoneInfo(timezone)) return dt def _build_valarm(minutes_before: int) -> icalendar.Alarm: """Create a DISPLAY alarm component triggered N minutes before the event.""" alarm = icalendar.Alarm() alarm.add("action", "DISPLAY") alarm.add("description", "Reminder") alarm.add("trigger", timedelta(minutes=-minutes_before)) return alarm def _add_attendees(event: icalendar.Event, attendees: list[str]) -> None: """Add mailto: attendees to an iCalendar event.""" for email in attendees: attendee = icalendar.vCalAddress(f"mailto:{email}") event.add("attendee", attendee) def _check_config(config: dict[str, str]) -> None: """Raise if CalDAV is not configured.""" if not config.get("caldav_url"): raise ValueError("CalDAV is not configured. Go to Settings → Calendar to enter your server URL.") async def create_event( user_id: int, title: str, start: str, end: str | None = None, duration: int | None = None, description: str | None = None, location: str | None = None, all_day: bool = False, recurrence: str | None = None, timezone: str | None = None, reminder_minutes: int | None = None, attendees: list[str] | None = None, calendar_name: str | None = None, uid: str | None = None, ) -> dict: """Create a calendar event. start/end are ISO date (YYYY-MM-DD) or datetime strings. If all_day is True, DTSTART/DTEND use DATE values. recurrence is an iCalendar RRULE string (e.g. "FREQ=YEARLY"). """ config = await get_caldav_config(user_id) _check_config(config) tz = timezone or config.get("caldav_timezone") or None cal = icalendar.Calendar() cal.add("prodid", "-//FabledAssistant//EN") cal.add("version", "2.0") event = icalendar.Event() if uid: # Remove auto-generated UID if the library added one, then inject ours if "UID" in event: del event["UID"] event.add("uid", uid) event.add("summary", title) if all_day: # All-day events use DATE values (no time component) d_start = datetime.fromisoformat(start).date() if "T" in start else date_type.fromisoformat(start) if end: d_end = datetime.fromisoformat(end).date() if "T" in end else date_type.fromisoformat(end) else: d_end = d_start + timedelta(days=1) event.add("dtstart", d_start) event.add("dtend", d_end) result_start = d_start.isoformat() result_end = d_end.isoformat() else: dt_start = _apply_timezone(datetime.fromisoformat(start), tz) event.add("dtstart", dt_start) result_start = dt_start.isoformat() if end: dt_end = _apply_timezone(datetime.fromisoformat(end), tz) elif duration: dt_end = dt_start + timedelta(minutes=duration) else: dt_end = None if dt_end is not None: event.add("dtend", dt_end) result_end = dt_end.isoformat() else: # Point event (no end, no duration): emit DTSTART only. Fabricating # a 60-min DTEND here would round-trip back on the next pull as # duration_minutes=60, silently lengthening a point event. result_end = None if description: event.add("description", description) if location: event.add("location", location) if recurrence: # Parse RRULE string like "FREQ=YEARLY" into a vRecur dict rrule_parts = {} for part in recurrence.split(";"): if "=" in part: key, value = part.split("=", 1) rrule_parts[key.strip().lower()] = value.strip() event.add("rrule", rrule_parts) if reminder_minutes is not None: event.add_component(_build_valarm(reminder_minutes)) if attendees: _add_attendees(event, attendees) cal.add_component(event) ical_str = cal.to_ical().decode("utf-8") def _save(): client = _make_client(config) cal_name = calendar_name or config.get("caldav_calendar_name", "") calendar = _get_calendar(client, cal_name) calendar.save_event(ical_str) await asyncio.to_thread(_save) result = { "title": title, "start": result_start, "end": result_end, "all_day": all_day, } if recurrence: result["recurrence"] = recurrence return result async def list_events(user_id: int, date_from: str, date_to: str) -> list[dict]: """List calendar events in a date range. Dates are ISO datetime strings. Searches all calendars unless caldav_calendar_name is configured. """ config = await get_caldav_config(user_id) _check_config(config) dt_from = datetime.fromisoformat(date_from) dt_to = datetime.fromisoformat(date_to) def _search(): client = _make_client(config) cal_name = config.get("caldav_calendar_name", "") if cal_name: calendars = [_get_calendar(client, cal_name)] else: calendars = _get_all_calendars(client) all_results = [] for calendar in calendars: try: all_results.extend(calendar.date_search(dt_from, dt_to)) except Exception: logger.warning("Failed to search calendar '%s'", getattr(calendar, 'name', '?')) return all_results results = await asyncio.to_thread(_search) events = [] for result in results: cal = icalendar.Calendar.from_ical(result.data) for component in cal.walk(): parsed = _parse_vevent(component) if parsed: events.append(parsed) return events async def search_events(user_id: int, query: str, days_ahead: int = 90) -> list[dict]: """Search events by keyword in the next N days.""" now = datetime.now() date_from = now.isoformat() date_to = (now + timedelta(days=days_ahead)).isoformat() all_events = await list_events(user_id, date_from, date_to) q = query.lower() return [ e for e in all_events if q in e["title"].lower() or q in e.get("location", "").lower() or q in e.get("description", "").lower() ] async def update_event( user_id: int, query: str, title: str | None = None, start: str | None = None, end: str | None = None, description: str | None = None, location: str | None = None, timezone: str | None = None, calendar_name: str | None = None, recurrence: str | None | object = _RECURRENCE_UNSET, ) -> dict: """Update a calendar event matching the query. ``recurrence``: leave at the sentinel to keep the existing RRULE; pass an RRULE string to set it, or None/"" to remove it. The push path passes the local event's recurrence so RRULE edits propagate to the server. """ config = await get_caldav_config(user_id) _check_config(config) tz = timezone or config.get("caldav_timezone") or None def _do_update(): client = _make_client(config) cal_name = calendar_name or config.get("caldav_calendar_name", "") now = datetime.now() if cal_name: calendars = [_get_calendar(client, cal_name)] else: calendars = _get_all_calendars(client) results = [] for cal in calendars: try: results.extend(cal.date_search(now - timedelta(days=30), now + timedelta(days=365))) except Exception: logger.warning("Failed to search calendar '%s'", getattr(cal, 'name', '?')) q = query.lower() matches = [] for r in results: cal_obj = icalendar.Calendar.from_ical(r.data) for component in cal_obj.walk(): if component.name == "VEVENT": event_title = str(component.get("SUMMARY", "")) if q in event_title.lower(): matches.append((r, component)) if not matches: raise ValueError(f"No event found matching '{query}'.") if len(matches) > 3: titles = [str(m[1].get("SUMMARY", "")) for m in matches] raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}") event_obj, component = matches[0] if title: component["SUMMARY"] = title if start: dt_start = _apply_timezone(datetime.fromisoformat(start), tz) del component["DTSTART"] component.add("dtstart", dt_start) if end: dt_end = _apply_timezone(datetime.fromisoformat(end), tz) if "DTEND" in component: del component["DTEND"] component.add("dtend", dt_end) if description is not None: if "DESCRIPTION" in component: del component["DESCRIPTION"] component.add("description", description) if location is not None: if "LOCATION" in component: del component["LOCATION"] component.add("location", location) if recurrence is not _RECURRENCE_UNSET: # Authoritatively sync the RRULE to the local event: drop the old # rule, then re-add if a non-empty rule was provided (else clear it). if "RRULE" in component: del component["RRULE"] if recurrence: rrule_parts = {} for part in str(recurrence).split(";"): if "=" in part: key, value = part.split("=", 1) rrule_parts[key.strip().lower()] = value.strip() component.add("rrule", rrule_parts) # Rebuild ical data and save cal_data = icalendar.Calendar() cal_data.add("prodid", "-//FabledAssistant//EN") cal_data.add("version", "2.0") cal_data.add_component(component) event_obj.data = cal_data.to_ical().decode("utf-8") event_obj.save() return _parse_vevent(component) return await asyncio.to_thread(_do_update) async def delete_event( user_id: int, query: str, calendar_name: str | None = None, ) -> dict: """Delete a calendar event matching the query.""" config = await get_caldav_config(user_id) _check_config(config) def _do_delete(): client = _make_client(config) cal_name = calendar_name or config.get("caldav_calendar_name", "") now = datetime.now() if cal_name: calendars = [_get_calendar(client, cal_name)] else: calendars = _get_all_calendars(client) results = [] for cal in calendars: try: results.extend(cal.date_search(now - timedelta(days=30), now + timedelta(days=365))) except Exception: logger.warning("Failed to search calendar '%s'", getattr(cal, 'name', '?')) q = query.lower() matches = [] for r in results: cal_obj = icalendar.Calendar.from_ical(r.data) for component in cal_obj.walk(): if component.name == "VEVENT": event_title = str(component.get("SUMMARY", "")) if q in event_title.lower(): matches.append((r, component)) if not matches: raise ValueError(f"No event found matching '{query}'.") if len(matches) > 3: titles = [str(m[1].get("SUMMARY", "")) for m in matches] raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}") event_obj, component = matches[0] parsed = _parse_vevent(component) event_obj.delete() return parsed return await asyncio.to_thread(_do_delete) async def list_calendars(user_id: int) -> list[dict]: """List all calendars for the user.""" config = await get_caldav_config(user_id) _check_config(config) def _list(): client = _make_client(config) principal = client.principal() calendars = principal.calendars() return [{"name": c.name, "url": str(c.url)} for c in calendars] return await asyncio.to_thread(_list) async def create_todo( user_id: int, summary: str, due: str | None = None, description: str | None = None, priority: int | None = None, reminder_minutes: int | None = None, timezone: str | None = None, calendar_name: str | None = None, ) -> dict: """Create a CalDAV todo (VTODO).""" config = await get_caldav_config(user_id) _check_config(config) tz = timezone or config.get("caldav_timezone") or None def _create(): client = _make_client(config) cal_name = calendar_name or config.get("caldav_calendar_name", "") calendar = _get_calendar(client, cal_name) kwargs = {"summary": summary} if due: dt_due = datetime.fromisoformat(due) dt_due = _apply_timezone(dt_due, tz) kwargs["due"] = dt_due todo = calendar.save_todo(**kwargs) # Modify component for extra fields cal_obj = icalendar.Calendar.from_ical(todo.data) modified = False for component in cal_obj.walk(): if component.name == "VTODO": if description: component.add("description", description) modified = True if priority is not None: component.add("priority", priority) modified = True if reminder_minutes is not None: component.add_component(_build_valarm(reminder_minutes)) modified = True if modified: todo.data = cal_obj.to_ical().decode("utf-8") todo.save() return _parse_vtodo(component) return {"summary": summary} return await asyncio.to_thread(_create) async def list_todos( user_id: int, include_completed: bool = False, calendar_name: str | None = None, ) -> list[dict]: """List CalDAV todos.""" config = await get_caldav_config(user_id) _check_config(config) def _list(): client = _make_client(config) cal_name = calendar_name or config.get("caldav_calendar_name", "") calendar = _get_calendar(client, cal_name) todos = calendar.todos(include_completed=include_completed) results = [] for t in todos: cal_obj = icalendar.Calendar.from_ical(t.data) for component in cal_obj.walk(): parsed = _parse_vtodo(component) if parsed: results.append(parsed) return results return await asyncio.to_thread(_list) async def search_todos( user_id: int, query: str, include_completed: bool = False, calendar_name: str | None = None, ) -> list[dict]: """Search CalDAV todos by keyword in summary or description.""" todos = await list_todos(user_id, include_completed=include_completed, calendar_name=calendar_name) q = query.lower() return [ t for t in todos if q in t.get("summary", "").lower() or q in (t.get("description") or "").lower() ] async def complete_todo( user_id: int, query: str, calendar_name: str | None = None, ) -> dict: """Complete a CalDAV todo matching the query.""" config = await get_caldav_config(user_id) _check_config(config) def _complete(): client = _make_client(config) cal_name = calendar_name or config.get("caldav_calendar_name", "") calendar = _get_calendar(client, cal_name) todos = calendar.todos(include_completed=False) q = query.lower() matches = [] for t in todos: cal_obj = icalendar.Calendar.from_ical(t.data) for component in cal_obj.walk(): if component.name == "VTODO": s = str(component.get("SUMMARY", "")) if q in s.lower(): matches.append((t, component)) if not matches: raise ValueError(f"No todo found matching '{query}'.") if len(matches) > 3: titles = [str(m[1].get("SUMMARY", "")) for m in matches] raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}") todo_obj, component = matches[0] todo_obj.complete() # Re-parse after completing cal_obj = icalendar.Calendar.from_ical(todo_obj.data) for comp in cal_obj.walk(): parsed = _parse_vtodo(comp) if parsed: return parsed return {"summary": str(component.get("SUMMARY", "")), "status": "COMPLETED"} return await asyncio.to_thread(_complete) async def update_todo( user_id: int, query: str, summary: str | None = None, due: str | None = None, description: str | None = None, priority: int | None = None, timezone: str | None = None, calendar_name: str | None = None, ) -> dict: """Update a CalDAV todo matching the query.""" config = await get_caldav_config(user_id) _check_config(config) tz = timezone or config.get("caldav_timezone") or None def _do_update(): client = _make_client(config) cal_name = calendar_name or config.get("caldav_calendar_name", "") calendar = _get_calendar(client, cal_name) todos = calendar.todos(include_completed=True) q = query.lower() matches = [] for t in todos: cal_obj = icalendar.Calendar.from_ical(t.data) for component in cal_obj.walk(): if component.name == "VTODO": s = str(component.get("SUMMARY", "")) if q in s.lower(): matches.append((t, component)) if not matches: raise ValueError(f"No todo found matching '{query}'.") if len(matches) > 3: titles = [str(m[1].get("SUMMARY", "")) for m in matches] raise ValueError( f"Too many matches ({len(matches)}) for '{query}'. " f"Be more specific. Found: {', '.join(titles[:10])}" ) todo_obj, component = matches[0] if summary: component["SUMMARY"] = summary if description is not None: if "DESCRIPTION" in component: del component["DESCRIPTION"] component.add("description", description) if priority is not None: if "PRIORITY" in component: del component["PRIORITY"] component.add("priority", priority) if due: if "DUE" in component: del component["DUE"] try: dt = datetime.fromisoformat(due) dt = _apply_timezone(dt, tz) component.add("due", dt) except ValueError: component.add("due", date_type.fromisoformat(due)) # Rebuild ical data and save cal_data = icalendar.Calendar() cal_data.add("prodid", "-//FabledAssistant//EN") cal_data.add("version", "2.0") cal_data.add_component(component) todo_obj.data = cal_data.to_ical().decode("utf-8") todo_obj.save() return _parse_vtodo(component) return await asyncio.to_thread(_do_update) async def delete_todo( user_id: int, query: str, calendar_name: str | None = None, ) -> dict: """Delete a CalDAV todo matching the query.""" config = await get_caldav_config(user_id) _check_config(config) def _delete(): client = _make_client(config) cal_name = calendar_name or config.get("caldav_calendar_name", "") calendar = _get_calendar(client, cal_name) todos = calendar.todos(include_completed=True) q = query.lower() matches = [] for t in todos: cal_obj = icalendar.Calendar.from_ical(t.data) for component in cal_obj.walk(): if component.name == "VTODO": s = str(component.get("SUMMARY", "")) if q in s.lower(): matches.append((t, component)) if not matches: raise ValueError(f"No todo found matching '{query}'.") if len(matches) > 3: titles = [str(m[1].get("SUMMARY", "")) for m in matches] raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}") todo_obj, component = matches[0] parsed = _parse_vtodo(component) todo_obj.delete() return parsed return await asyncio.to_thread(_delete) async def test_connection(user_id: int) -> dict: """Test the CalDAV connection and return status.""" config = await get_caldav_config(user_id) if not config.get("caldav_url"): return {"success": False, "error": "CalDAV is not configured."} def _test(): client = _make_client(config) principal = client.principal() calendars = principal.calendars() return [c.name for c in calendars] try: calendar_names = await asyncio.to_thread(_test) return { "success": True, "calendars": calendar_names, "message": f"Connected successfully. Found {len(calendar_names)} calendar(s).", } except Exception as e: error_msg = str(e) if "401" in error_msg or "403" in error_msg or "Unauthorized" in error_msg: error_msg = "Authentication failed. Check your username and password." elif "404" in error_msg or "Not Found" in error_msg: error_msg = "CalDAV endpoint not found. Check your URL." elif "Connection" in error_msg or "resolve" in error_msg: error_msg = f"Connection failed: {error_msg}" return {"success": False, "error": error_msg}