"""piSynapse Calendar Nextcloud CalDAV integration for calendar operations. """ import logging import threading import time from datetime import date, datetime, timedelta from utils import nc_cache_key, retry, sanitize_external_text logger = logging.getLogger("piSynapse") _dav_client = None # Primary calendar per Nextcloud identity (utils.nc_cache_key) — a single # global would hand user A's calendar object to user B (2026-10-08 analysis). _primary_calendars: dict[str, object] = {} # Guard lazy singleton creation: calendar_ops runs in worker threads # (asyncio.to_thread), so two concurrent calls could both build a client / # fetch calendars. The lock makes creation single-flight. _dav_lock = threading.Lock() # TTL cache for the calendar widget (list_events_today). The sidebar polls the # widget frequently while Nextcloud events change rarely; the cache is dropped # on every calendar write so the widget never goes stale for long. _TODAY_CACHE_TTL = 300.0 # seconds (~5 min) # Shape: (monotonic_ts, nc_cache_key, events) — identity-checked on read. _today_cache: tuple[float, str, list[dict]] | None = None # Short-lived cache for _find_events to avoid redundant CalDAV calls. # Shape: (timestamp, nc_cache_key, events) — identity-checked on read so one # user's events are never served to another (2026-10-08 analysis). _find_events_cache: tuple[float, str, list] | None = None _FIND_EVENTS_CACHE_TTL = 5.0 # seconds _cache_lock = threading.Lock() def _invalidate_today_cache() -> None: """Drop cached today's events after a calendar write (create/update/delete).""" global _today_cache, _find_events_cache with _cache_lock: _today_cache = None _find_events_cache = None def _get_nextcloud_client(): global _dav_client from config import NEXTCLOUD_PASSWORD, NEXTCLOUD_TIMEOUT, NEXTCLOUD_URL, NEXTCLOUD_USER from utils import NC_CREDS, NC_SHARED_OK creds = NC_CREDS.get() if creds and creds.get("url") and creds.get("password"): import caldav caldav_url = f"{creds['url'].rstrip('/')}/remote.php/dav/" try: return caldav.DAVClient( url=caldav_url, username=creds.get("user", ""), password=creds["password"], timeout=NEXTCLOUD_TIMEOUT, ) except Exception as e: logger.error("Failed to create per-user CalDAV client: %s", e) raise if not NEXTCLOUD_URL or not NEXTCLOUD_PASSWORD: return None if not NC_SHARED_OK.get(): return None if _dav_client is not None: return _dav_client with _dav_lock: if _dav_client is not None: return _dav_client import caldav caldav_url = f"{NEXTCLOUD_URL.rstrip('/')}/remote.php/dav/" try: _dav_client = caldav.DAVClient( url=caldav_url, username=NEXTCLOUD_USER, password=NEXTCLOUD_PASSWORD, timeout=NEXTCLOUD_TIMEOUT, ) except Exception as e: logger.error("Failed to create CalDAV client: %s", e) raise return _dav_client def _get_primary_calendar(client): key = nc_cache_key() cached = _primary_calendars.get(key) if cached is not None: return cached with _dav_lock: cached = _primary_calendars.get(key) if cached is not None: return cached try: principal = client.principal() calendars = principal.calendars() except Exception as e: logger.error("Failed to fetch CalDAV calendars: %s", e) raise if not calendars: raise Exception("No calendar found on Nextcloud.") _primary_calendars[key] = calendars[0] return calendars[0] def _get_calendar(): """Return the primary CalDAV calendar, or raise if unavailable.""" client = _get_nextcloud_client() if not client: raise Exception("Nextcloud credentials missing. Set NEXTCLOUD_URL and NEXTCLOUD_PASSWORD.") return _get_primary_calendar(client) def _get_uid(d) -> str: uid = getattr(d, "uid", None) if uid and hasattr(uid, "value"): return str(uid.value) return "" def _ical_escape_text(value: str) -> str: r"""Escape a TEXT value for embedding in an iCalendar (RFC 5545) property. Newlines become ``\\n`` (instead of a real line break, which would inject a fake property/VEVENT), and backslash/semicolon/comma are escaped so user text cannot break out of the field or alter the calendar structure. """ return ( str(value) .replace("\\", "\\\\") .replace("\r", "") .replace("\n", "\\n") .replace(";", "\\;") .replace(",", "\\,") ) def _find_events(search_window_days_back: int = 30, search_window_days_ahead: int = 90): """Return all events in the search window (cached for 5s to avoid redundant CalDAV calls).""" global _find_events_cache now = time.time() key = nc_cache_key() with _cache_lock: if (_find_events_cache and _find_events_cache[1] == key and (now - _find_events_cache[0]) < _FIND_EVENTS_CACHE_TTL): return _find_events_cache[2] try: calendar = _get_calendar() events = calendar.date_search( datetime.now() - timedelta(days=search_window_days_back), datetime.now() + timedelta(days=search_window_days_ahead), ) with _cache_lock: _find_events_cache = (now, key, events) return events except Exception as e: logger.error("Failed to search calendar events: %s", e) return None def _match_event(events, summary: str, event_uid: str = "") -> tuple: """Find an event by UID (exact) or summary (substring). Returns ``(event, matched_summary, status)`` where status is ``""`` on a unique match, ``"not_found"``, ``"ambiguous"`` (more than one event matched — never auto-picks) or ``"error"``. """ try: def _matches() -> list: found = [] for ev in events: d = ev.vobject_instance.vevent if event_uid: uid = _get_uid(d) if uid == event_uid or uid.startswith(event_uid): found.append((ev, getattr(d, "summary", "").value)) else: s = getattr(d, "summary", "").value if summary.lower() in s.lower(): found.append((ev, s)) return found matches = _matches() if not matches: return None, None, "not_found" if len(matches) > 1: labels = ", ".join(f"'{s}'" for _, s in matches[:5]) logger.warning( "Ambiguous calendar match for %r (%d events: %s)", event_uid or summary, len(matches), labels, ) return None, None, "ambiguous" ev, s = matches[0] return ev, s, "" except Exception as e: logger.error("Failed to match calendar event: %s", e) return None, None, "error" def _parse_start(raw: str) -> datetime: """Accept an ISO datetime or a plain YYYY-MM-DD date string. ``create_event``'s ``start_time`` is ISO 8601 for timed events and a bare date for all-day events; this normalizes both to a datetime. """ raw = (raw or "").strip() if "T" in raw: return datetime.fromisoformat(raw) return datetime.combine(date.fromisoformat(raw), datetime.min.time()) @retry(attempts=2, delay=1.0) def create_event( summary: str, start_time_str: str, duration_minutes: int = 60, all_day: bool = False, rrule: str | None = None, ) -> tuple[str, str]: """Create a calendar event. Args: summary: Event title. start_time_str: ISO 8601 datetime (timed) or YYYY-MM-DD (all-day). duration_minutes: Duration in minutes (ignored for all-day). all_day: True for date-only event. rrule: Optional RFC 5545 RRULE string (e.g. "FREQ=WEEKLY;BYDAY=MO,WE,FR"). Returns: Tuple of (result_message, uid). uid is empty string on error. """ try: calendar = _get_calendar() if all_day: start_date = _parse_start(start_time_str).date() end_date = start_date + timedelta(days=1) dtstart = f"DTSTART;VALUE=DATE:{start_date:%Y%m%d}" dtend = f"DTEND;VALUE=DATE:{end_date:%Y%m%d}" else: start_dt = datetime.fromisoformat(start_time_str) end_dt = start_dt + timedelta(minutes=duration_minutes) dtstart = f"DTSTART;VALUE=DATE-TIME:{start_dt.strftime('%Y%m%dT%H%M%S')}" dtend = f"DTEND;VALUE=DATE-TIME:{end_dt.strftime('%Y%m%dT%H%M%S')}" lines = [ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//piSynapse//EN", "BEGIN:VEVENT", f"SUMMARY:{_ical_escape_text(summary)}", dtstart, dtend, ] if rrule: lines.append(f"RRULE:{rrule}") lines.extend(["END:VEVENT", "END:VCALENDAR"]) ical = "\r\n".join(lines) + "\r\n" event = calendar.add_event(ical) uid = event.id or "" _invalidate_today_cache() return f"OK '{summary}' added to calendar.", uid except Exception as e: logger.error("Failed to create calendar event '%s': %s", summary, e) return f"ERROR: Could not create event '{summary}'.", "" @retry(attempts=2, delay=1.0) def list_events(days_ahead: int = 7) -> tuple[str, list[dict]]: """List upcoming events. Returns ``(display_text, items)`` where ``items`` are ``{"uid": ..., "summary": ..., "start": ...}`` dicts in the exact order they are numbered in ``display_text`` (the model only sees list positions; real UIDs never appear in tool output). """ try: calendar = _get_calendar() start = datetime.now() end = start + timedelta(days=days_ahead) events = calendar.date_search(start, end) if not events: return f"Next {days_ahead} days: no events.", [] lines = [] items: list[dict] = [] for i, ev in enumerate(events, 1): d = ev.vobject_instance.vevent s = getattr(d, "summary", getattr(d, "description", "Untitled")).value st = d.dtstart.value ts = st.strftime("%Y-%m-%d %H:%M") if hasattr(st, "strftime") else str(st) uid = _get_uid(d) line = f" {i}. {ts} | {s}" desc = getattr(d, "description", None) if desc and hasattr(desc, "value") and desc.value and desc.value != s: line += f"\n {sanitize_external_text(desc.value)[:100]}" lines.append(line) items.append({"uid": uid, "summary": s, "start": ts}) return "Events:\n" + "\n".join(lines), items except Exception as e: logger.error("Failed to list calendar events: %s", e) return "ERROR: Could not load calendar events.", [] @retry(attempts=2, delay=1.0) def list_events_today() -> list[dict]: """Structured today's events for the widget (TTL-cached, see ``_TODAY_CACHE_TTL``).""" global _today_cache key = nc_cache_key() with _cache_lock: if _today_cache is not None and _today_cache[1] == key: cached_at, _, cached = _today_cache if time.monotonic() - cached_at < _TODAY_CACHE_TTL: return cached try: from datetime import date try: calendar = _get_calendar() except Exception: return [] today = datetime.combine(date.today(), datetime.min.time()) tomorrow = today + timedelta(days=1) events = calendar.date_search(today, tomorrow) result = [] for ev in events: d = ev.vobject_instance.vevent s = getattr(d, "summary", "Untitled").value st = d.dtstart.value ts = st.strftime("%H:%M") if hasattr(st, "strftime") else str(st) uid = _get_uid(d) result.append({"time": ts, "title": s, "uid": uid}) result = sorted(result, key=lambda x: x["time"]) with _cache_lock: _today_cache = (time.monotonic(), key, result) return result except Exception as e: logger.error("Failed to list today's events: %s", e) return [] def find_events_by_summary(summary: str, days_back: int = 30, days_ahead: int = 90) -> list[dict]: """Find calendar events matching summary substring. Returns list of {summary, start, uid} dicts.""" try: try: calendar = _get_calendar() except Exception: return [] events = calendar.date_search( datetime.now() - timedelta(days=days_back), datetime.now() + timedelta(days=days_ahead), ) results = [] for ev in events: d = ev.vobject_instance.vevent s = getattr(d, "summary", "").value if summary.lower() in s.lower(): st = d.dtstart.value ts = st.strftime("%Y-%m-%d %H:%M") if hasattr(st, "strftime") else str(st) results.append({"summary": s, "start": ts, "uid": _get_uid(d)}) return results except Exception as e: logger.error("Failed to search calendar events for '%s': %s", summary, e) return [] @retry(attempts=2, delay=1.0) def find_free_slots(date_str: str, duration_minutes: int = 60, day_start: str = "09:00", day_end: str = "18:00") -> tuple[str, list[dict]]: """Find free time slots on a given day. Args: date_str: Date in YYYY-MM-DD format. duration_minutes: Minimum slot length in minutes (default 60). day_start: Day start time HH:MM (default 09:00). day_end: Day end time HH:MM (default 18:00). Returns: (display_text, slots) where slots are {"start": "HH:MM", "end": "HH:MM"} dicts. """ try: calendar = _get_calendar() day_date = datetime.fromisoformat(date_str).date() day_start_dt = datetime.combine(day_date, datetime.strptime(day_start, "%H:%M").time()) day_end_dt = datetime.combine(day_date, datetime.strptime(day_end, "%H:%M").time()) # Fetch events for that day events = calendar.date_search(day_start_dt, day_end_dt) # Build busy intervals (start, end) in minutes from day_start busy = [] for ev in events: d = ev.vobject_instance.vevent st = d.dtstart.value et = d.dtend.value if hasattr(d, "dtend") else st if hasattr(st, "strftime"): ev_start = st ev_end = et if hasattr(et, "strftime") else ev_start + timedelta(hours=1) # Only consider events within day bounds if ev_end <= day_start_dt or ev_start >= day_end_dt: continue ev_start = max(ev_start, day_start_dt) ev_end = min(ev_end, day_end_dt) start_min = int((ev_start - day_start_dt).total_seconds() / 60) end_min = int((ev_end - day_start_dt).total_seconds() / 60) busy.append((start_min, end_min)) # Merge overlapping busy intervals busy.sort() merged = [] for s, e in busy: if not merged or s > merged[-1][1]: merged.append([s, e]) else: merged[-1][1] = max(merged[-1][1], e) # Find free slots >= duration_minutes total_min = int((day_end_dt - day_start_dt).total_seconds() / 60) slots = [] cursor = 0 for s, e in merged: if s - cursor >= duration_minutes: slot_start = day_start_dt + timedelta(minutes=cursor) slot_end = day_start_dt + timedelta(minutes=s) slots.append({"start": slot_start.strftime("%H:%M"), "end": slot_end.strftime("%H:%M")}) cursor = max(cursor, e) if total_min - cursor >= duration_minutes: slot_start = day_start_dt + timedelta(minutes=cursor) slot_end = day_start_dt + timedelta(minutes=total_min) slots.append({"start": slot_start.strftime("%H:%M"), "end": slot_end.strftime("%H:%M")}) if not slots: return f"No free slots of {duration_minutes}min on {date_str} between {day_start}-{day_end}.", [] lines = [f"Free slots on {date_str} ({duration_minutes}min min, {day_start}-{day_end}):"] for i, sl in enumerate(slots, 1): lines.append(f" {i}. {sl['start']} - {sl['end']}") return "\n".join(lines), slots except Exception as e: logger.error("Failed to find free slots for %s: %s", date_str, e) return f"ERROR: Could not find free slots for {date_str}.", [] @retry(attempts=2, delay=1.0) def delete_event(summary: str, event_uid: str = "") -> str: try: _get_nextcloud_client() # fail fast if no credentials events = _find_events() if not events: return f"ERROR: '{summary}' not found." ev, s, status = _match_event(events, summary, event_uid) if status == "ambiguous": return f"'{summary}' matches multiple events — use a more specific title or the event UID." if status == "error": return "ERROR: Could not match event." if ev is None: return f"ERROR: '{summary}' not found." ev.delete() _invalidate_today_cache() return f"OK '{s}' deleted from calendar." except Exception as e: logger.error("Failed to delete calendar event '%s': %s", summary, e) return "ERROR: Could not delete event." @retry(attempts=2, delay=1.0) def update_event(summary: str, new_summary: str = "", new_start_time: str = "", new_duration_minutes: int = 0, event_uid: str = "") -> str: try: _get_nextcloud_client() # fail fast if no credentials events = _find_events() if not events: return f"ERROR: '{summary}' not found." ev, s, status = _match_event(events, summary, event_uid) if status == "ambiguous": return f"'{summary}' matches multiple events — use a more specific title or the event UID." if status == "error": return "ERROR: Could not match event." if ev is None: return f"ERROR: '{summary}' not found." d = ev.vobject_instance.vevent def _is_date(v) -> bool: return isinstance(v, datetime.date) and not isinstance(v, datetime) if new_summary: d.summary.value = new_summary if new_start_time: new_dt = datetime.fromisoformat(new_start_time) old_dt = d.dtstart.value all_day = _is_date(old_dt) if all_day: d.dtstart.value = new_dt.date() # All-day event: DTEND is always next day (RFC 5545), ignore duration_minutes d.dtend.value = new_dt.date() + timedelta(days=1) else: d.dtstart.value = new_dt if new_duration_minutes and new_duration_minutes > 0: new_end = new_dt + timedelta(minutes=new_duration_minutes) else: old_end = d.dtend.value if hasattr(d, "dtend") else old_dt try: duration = old_end - old_dt except TypeError: duration = timedelta(hours=1) new_end = new_dt + duration d.dtend.value = new_end elif new_duration_minutes > 0: old_dt = d.dtstart.value all_day = _is_date(old_dt) if all_day: # All-day: duration_minutes ignored, keep DTEND as next day pass else: old_end = d.dtend.value if hasattr(d, "dtend") else old_dt + timedelta(hours=1) new_end = old_dt + timedelta(minutes=new_duration_minutes) d.dtend.value = new_end ev.data = ev.vobject_instance.serialize() ev.save() _invalidate_today_cache() parts = [] if new_summary: parts.append(f"title '{s}' → '{new_summary}'") if new_start_time: parts.append(f"time → {new_start_time}") if new_duration_minutes > 0: parts.append(f"duration → {new_duration_minutes}min") return f"OK '{s}' updated ({', '.join(parts)})." except Exception as e: logger.error("Failed to update calendar event '%s': %s", summary, e) return "ERROR: Could not update event."