"""Calendar routes — local SQLite-backed calendar CRUD.""" import logging import uuid from datetime import datetime, date, timedelta from typing import Optional, List, Tuple from fastapi import APIRouter, HTTPException, Request, UploadFile, File from pydantic import BaseModel from sqlalchemy import or_, and_ from dateutil.rrule import rrulestr, rruleset from dateutil.rrule import DAILY, WEEKLY, MONTHLY, YEARLY from core.database import SessionLocal, CalendarCal, CalendarEvent from src.auth_helpers import get_current_user, require_user logger = logging.getLogger(__name__) # Single-user fallback identity. Used only when: # 1. The app is configured for single-user (no auth middleware), AND # 2. The request didn't resolve to an authenticated user. # Override at deploy time via `ODYSSEUS_FALLBACK_OWNER` env var. In a real # multi-user install set `ODYSSEUS_SINGLE_USER=0` so unauthenticated requests # are rejected instead of silently writing to this address. import os as _os FALLBACK_OWNER = _os.environ.get("ODYSSEUS_FALLBACK_OWNER", "owner@localhost") _SINGLE_USER_MODE = _os.environ.get("ODYSSEUS_SINGLE_USER", "1") != "0" def _require_user(request: Request) -> str: """Return the authenticated user. Uses require_user so AUTH_ENABLED=false and single-user mode both work: require_user returns "" when auth is disabled or unconfigured, and only raises 401 when auth is configured but the caller is unauthenticated. Falls back to FALLBACK_OWNER for calendar writes so data isn't stored under an empty owner in single-user mode.""" user = require_user(request) if user: return user # require_user returned "" — auth is off or unconfigured (single-user). # Use FALLBACK_OWNER so calendar rows have a stable owner for filtering. return FALLBACK_OWNER def _get_or_404_calendar(db, cal_id: str, owner: str) -> CalendarCal: cal = db.query(CalendarCal).filter(CalendarCal.id == cal_id).first() if not cal: raise HTTPException(404, "Calendar not found") # Tighten the legacy null-owner gate (v2 review HIGH-12): if the # caller is authenticated AND the calendar's owner is null OR # belongs to a different user, treat it as not-found. The previous # rule (`if cal.owner and cal.owner != owner`) silently allowed any # authenticated user to read/edit any calendar with owner=None. if owner and (cal.owner is None or cal.owner != owner): raise HTTPException(404, "Calendar not found") return cal def _get_or_404_event(db, uid: str, owner: str) -> CalendarEvent: ev = db.query(CalendarEvent).join(CalendarCal).filter(CalendarEvent.uid == uid).first() if not ev: raise HTTPException(404, "Event not found") cal = ev.calendar if owner and cal and (cal.owner is None or cal.owner != owner): raise HTTPException(404, "Event not found") return ev def _ics_escape(text: str) -> str: """Escape a value for an iCalendar TEXT field (RFC 5545 §3.3.11). Backslash, semicolon and comma are structural in TEXT values and must be escaped, and newlines become a literal ``\\n``. Backslash is escaped first so the escapes we add aren't re-escaped. """ return ( (text or "") .replace("\\", "\\\\") .replace(";", "\\;") .replace(",", "\\,") .replace("\r\n", "\\n") .replace("\n", "\\n") .replace("\r", "\\n") ) def _resolve_base_uid(uid: str) -> str: """Extract the base series UID from a compound occurrence UID. Compound UIDs have the form ``{base_uid}::{date_suffix}``. For plain UIDs (no ``::``), returns the UID unchanged. """ if not uid: raise ValueError("empty uid") idx = uid.find("::") if idx == -1: return uid # plain UID — no suffix base = uid[:idx] if not base: raise ValueError("malformed compound UID: missing base before ::") return base # ── Pydantic models ── class EventCreate(BaseModel): summary: str dtstart: str # ISO 8601 dtend: Optional[str] = None all_day: bool = False description: str = "" location: str = "" calendar_href: Optional[str] = None # calendar id rrule: Optional[str] = None color: Optional[str] = None # per-event color override class EventUpdate(BaseModel): summary: Optional[str] = None dtstart: Optional[str] = None dtend: Optional[str] = None all_day: Optional[bool] = None description: Optional[str] = None location: Optional[str] = None rrule: Optional[str] = None color: Optional[str] = None # ── Helpers ── def _ensure_default_calendar(db, owner: str = None) -> CalendarCal: """Create default calendar if none exist for this owner.""" owner = owner or FALLBACK_OWNER cal = db.query(CalendarCal).filter(CalendarCal.owner == owner).first() if not cal: cal = CalendarCal( id=str(uuid.uuid4()), owner=owner, name="Personal", color="#5b8abf", source="local", ) db.add(cal) db.commit() db.refresh(cal) return cal # Per-request user UTC offset (in minutes east of UTC). chat_routes sets this # from the `X-Tz-Offset` header so naive natural-language times the LLM # emits ("today at 9pm") are parsed in the USER's timezone, not the server's # clock. None = unknown, fall back to legacy server-local behavior. from contextvars import ContextVar _USER_TZ_OFFSET_MIN: ContextVar = ContextVar("user_tz_offset_min", default=None) def set_user_tz_offset(offset_min): """Set the current user's UTC offset for this async context.""" try: v = int(offset_min) except (TypeError, ValueError): return _USER_TZ_OFFSET_MIN.set(v) def get_user_tz_offset(): """Read the current user's UTC offset (minutes east of UTC), or None.""" return _USER_TZ_OFFSET_MIN.get() def parse_due_for_user(s: str) -> str: """Parse a due-date string emitted by the LLM / agent in the USER's tz. Returns an ISO 8601 string with explicit offset (e.g. "2026-05-13T21:00:00+09:00") so downstream consumers preserve the absolute moment. Falls back to the legacy naive ISO when no user offset is set. Handles three input shapes: - Tz-aware ISO ("...Z" or "...+09:00") → returned as ISO with offset. - Naive ISO ("2026-05-13T21:00:00") → attach the user's offset. - Natural-language ("today at 9pm", "tomorrow 14:00", "in 2 hours") → evaluated against the user's local "now" instead of the server's, then ISO-with-offset. """ from datetime import timezone as _tz, timedelta as _td offset = get_user_tz_offset() s = (s or "").strip() if not s: return s # Tz-aware ISO short-circuit — preserve as-is. try: _s2 = s.replace("Z", "+00:00") if s.endswith("Z") else s parsed = datetime.fromisoformat(_s2) if parsed.tzinfo is not None: return parsed.isoformat() except ValueError: parsed = None if offset is None: # No user tz known — preserve legacy behavior (naive server-local). return _parse_dt(s).isoformat() user_tz = _tz(_td(minutes=offset)) # Naive ISO → tag with user tz. if parsed is not None and parsed.tzinfo is None: return parsed.replace(tzinfo=user_tz).isoformat() # Natural language — evaluate against user's "now". server_now_utc = datetime.now(_tz.utc) user_now = server_now_utc.astimezone(user_tz) # Patch datetime.now() inside _parse_dt by leveraging the user's clock: # we re-implement the small natural-language phrases here against user_now # so the result is naturally in the user's tz. import re as _re lower = s.lower().strip() def _parse_time(t): m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE) if not m: return None h = int(m.group(1)); mn = int(m.group(2) or 0); ampm = (m.group(3) or "").lower() if ampm == "pm" and h < 12: h += 12 elif ampm == "am" and h == 12: h = 0 if not (0 <= h < 24 and 0 <= mn < 60): return None return h, mn today = user_now.replace(hour=0, minute=0, second=0, microsecond=0) m = _re.match(r'^(today|tonight|tomorrow|tmrw|yesterday)(?:\s+at)?\s*(.*)$', lower) if m: word, rest = m.group(1), m.group(2).strip() base = today if word in ("tomorrow", "tmrw"): base = today + _td(days=1) elif word == "yesterday": base = today - _td(days=1) if not rest: return base.isoformat() t = _parse_time(rest) if t is not None: return base.replace(hour=t[0], minute=t[1]).isoformat() m = _re.match(r'^in\s+(\d+)\s*(hour|hr|minute|min|day)s?\s*$', lower) if m: n = int(m.group(1)); unit = m.group(2) if unit in ("hour", "hr"): return (user_now + _td(hours=n)).isoformat() if unit in ("minute", "min"): return (user_now + _td(minutes=n)).isoformat() if unit == "day": return (user_now + _td(days=n)).isoformat() t = _parse_time(lower) if t is not None: return today.replace(hour=t[0], minute=t[1]).isoformat() # Last resort: dateutil. Trust it but apply user tz if it returned naive. try: from dateutil import parser as _du parsed2 = _du.parse(s) if parsed2.tzinfo is None: parsed2 = parsed2.replace(tzinfo=user_tz) return parsed2.isoformat() except Exception: # Final fallback: legacy parser, naive. return _parse_dt(s).isoformat() def _parse_dt_pair(s: str): """Parse a date/datetime string and return ``(datetime, is_utc)``. is_utc is True iff the input carried explicit timezone info (Z, +HH:MM, -HH:MM); the returned datetime is naive UTC. Otherwise the datetime is naive-local (legacy behavior). DB column is naive — callers that care about tz semantics should set ``CalendarEvent.is_utc`` accordingly. """ from datetime import timezone as _tz s = (s or "").strip() if not s: raise ValueError("empty datetime string") try: if len(s) == 10: return datetime.fromisoformat(s), False _s2 = s.replace("Z", "+00:00") if s.endswith("Z") else s parsed = datetime.fromisoformat(_s2) if parsed.tzinfo is not None: return parsed.astimezone(_tz.utc).replace(tzinfo=None), True return parsed, False except ValueError: return _parse_dt(s), False def _parse_dt(s: str) -> datetime: """Parse a date/datetime string. Strict ISO first (cheapest path; this is what most callers pass). On failure, fall through a small natural-language parser that handles the phrasings LLMs commonly emit when given prompts like "1pm tomorrow": - today/tomorrow/yesterday [at] HH(:MM)? (am/pm)? - next [at] HH(:MM)? (am/pm)? - in N hour(s)/minute(s)/day(s) - bare time today: "1pm", "13:00" - YYYY-MM-DD optionally followed by time Anything still unparsed falls to dateutil.parser, which handles most other absolute formats. Local-naive datetimes returned to match the DB schema (CalendarEvent.dtstart is naive). """ import re as _re s = (s or "").strip() if not s: raise ValueError("empty datetime string") # Fast path: strict ISO try: if len(s) == 10: return datetime.fromisoformat(s) _s2 = s.replace("Z", "+00:00") if s.endswith("Z") else s parsed = datetime.fromisoformat(_s2) # Strip tz for the legacy callers — they expect naive. Real tz # handling lives in _parse_dt_pair. if parsed.tzinfo is not None: from datetime import timezone as _tz return parsed.astimezone(_tz.utc).replace(tzinfo=None) return parsed except ValueError: pass now = datetime.now() today = now.replace(hour=0, minute=0, second=0, microsecond=0) lower = s.lower().strip() def _parse_time(t: str): """Return (hour, minute) from '1pm', '1:30 PM', '13:00', etc., or None.""" m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE) if not m: return None h = int(m.group(1)) mn = int(m.group(2) or 0) ampm = (m.group(3) or "").lower() if ampm == "pm" and h < 12: h += 12 elif ampm == "am" and h == 12: h = 0 if not (0 <= h < 24 and 0 <= mn < 60): return None return h, mn # today/tomorrow/yesterday [at] TIME m = _re.match(r'^(today|tomorrow|tmrw|yesterday)(?:\s+at)?\s*(.*)$', lower) if m: word, rest = m.group(1), m.group(2).strip() base = today if word in ("tomorrow", "tmrw"): base = today + timedelta(days=1) elif word == "yesterday": base = today - timedelta(days=1) if not rest: return base t = _parse_time(rest) if t is not None: return base.replace(hour=t[0], minute=t[1]) # next [at] TIME weekdays = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] m = _re.match(r'^next\s+(\w+)(?:\s+at)?\s*(.*)$', lower) if m and m.group(1) in weekdays: target_dow = weekdays.index(m.group(1)) days = (target_dow - today.weekday()) % 7 or 7 base = today + timedelta(days=days) rest = m.group(2).strip() if not rest: return base t = _parse_time(rest) if t is not None: return base.replace(hour=t[0], minute=t[1]) # in N hours/minutes/days m = _re.match(r'^in\s+(\d+)\s*(hour|hr|minute|min|day)s?\s*$', lower) if m: n = int(m.group(1)) unit = m.group(2) if unit in ("hour", "hr"): return now + timedelta(hours=n) if unit in ("minute", "min"): return now + timedelta(minutes=n) if unit == "day": return now + timedelta(days=n) # Bare time → today at that time t = _parse_time(lower) if t is not None: return today.replace(hour=t[0], minute=t[1]) # Last resort: dateutil's fuzzy parser try: from dateutil import parser as _du return _du.parse(s) except Exception: raise ValueError(f"could not parse datetime: {s!r}") def _event_to_dict(ev: CalendarEvent) -> dict: """Convert a CalendarEvent model to the API dict format. Timed events whose stored datetimes represent UTC (is_utc=True) are serialized with a trailing `Z` so the frontend `new Date()` interprets them as absolute UTC and renders in the user's current local time. Legacy rows without the flag are emitted as naive ISO (read as local) to avoid silently shifting existing events. """ if ev.all_day: start_str = ev.dtstart.strftime("%Y-%m-%d") end_str = ev.dtend.strftime("%Y-%m-%d") else: suffix = "Z" if getattr(ev, "is_utc", False) else "" start_str = ev.dtstart.isoformat() + suffix end_str = ev.dtend.isoformat() + suffix return { "uid": ev.uid, "summary": ev.summary or "", "dtstart": start_str, "dtend": end_str, "all_day": ev.all_day, "is_utc": bool(getattr(ev, "is_utc", False)), "description": ev.description or "", "location": ev.location or "", "rrule": ev.rrule or "", "calendar": ev.calendar.name if ev.calendar else "", "calendar_href": ev.calendar_id, "color": ev.color or (ev.calendar.color if ev.calendar else ""), "event_type": getattr(ev, "event_type", None), "importance": getattr(ev, "importance", None) or "normal", } # ── Recurrence expansion ── def _expand_rrule( ev: CalendarEvent, start: datetime, end: datetime ) -> List[dict]: """Expand a single recurring CalendarEvent into occurrence dicts. Each occurrence gets a stable compound UID of the form ``{base_uid}::{date_or_datetime}`` so the frontend can tell occurrences apart while the series UID is still recoverable for edit/delete targeting. Non-recurring events (empty rrule) are returned as a single-item list — the caller doesn't need to branch. """ duration = ev.dtend - ev.dtstart if not ev.rrule or not ev.rrule.strip(): # Non-recurring — return the base event as-is. list_events # already filters non-recurring rows with the overlap check # in SQL, so we don't re-check here. d = _event_to_dict(ev) d["is_recurrence"] = False d["series_uid"] = ev.uid return [d] # Parse the rrule, applying it to the base dtstart. try: rule = rrulestr(ev.rrule, dtstart=ev.dtstart) except Exception as ex: logger.warning( "Failed to parse rrule=%r for event %s: %s", ev.rrule, ev.uid, ex ) d = _event_to_dict(ev) d["is_recurrence"] = False d["series_uid"] = ev.uid # Malformed RRULE rows are fetched by the recurring SQL branch # with only dtstart < end_dt — the base event may not actually # overlap the window. Only return if it does. if ev.dtstart < end and ev.dtend > start: return [d] return [] # Expand from start - duration so multi-day / overnight occurrences # that start before the window but end inside it are captured # (matching non-recurring overlap semantics: dtstart < end AND # dtend > start). expand_start = start - duration occurrences = rule.between(expand_start, end, inc=True) if not occurrences: return [] results = [] base = _event_to_dict(ev) for occ_start in occurrences: occ_end = occ_start + duration # Overlap filter: occurrence must intersect [start, end). # This enforces exclusive-end semantics (occ_start >= end is # excluded) and includes multi-day crossings (occ_end > start). if occ_start >= end or occ_end <= start: continue # Build the compound uid: {base_uid}::{date} or ::{datetime} if ev.all_day: occ_uid = f"{ev.uid}::{occ_start.strftime('%Y-%m-%d')}" else: occ_uid = f"{ev.uid}::{occ_start.strftime('%Y-%m-%dT%H:%M')}" d = dict(base) d["uid"] = occ_uid d["series_uid"] = ev.uid d["is_recurrence"] = True if ev.all_day: d["dtstart"] = occ_start.strftime("%Y-%m-%d") d["dtend"] = occ_end.strftime("%Y-%m-%d") else: suffix = "Z" if getattr(ev, "is_utc", False) else "" d["dtstart"] = occ_start.isoformat() + suffix d["dtend"] = occ_end.isoformat() + suffix d["is_utc"] = bool(getattr(ev, "is_utc", False)) results.append(d) return results # ── Routes ── def setup_calendar_routes() -> APIRouter: router = APIRouter(prefix="/api/calendar", tags=["calendar"]) # CalDAV connect form (Integrations → Calendar). Storage is local # SQLite; sync (src/caldav_sync.py) pulls remote events into it on # calendar open and periodically via the scheduler. @router.get("/config") async def get_config(request: Request): owner = _require_user(request) from routes.prefs_routes import _load_for_user cfg = (_load_for_user(owner) or {}).get("caldav", {}) or {} # Surface url+username but never hand the password back to the # client — saved-state UI shouldn't leak the credential. return { "url": cfg.get("url", "") or "", "username": cfg.get("username", "") or "", "password": "", "has_password": bool(cfg.get("password")), "local": not bool(cfg.get("url")), } @router.post("/config") async def save_config(request: Request): owner = _require_user(request) from routes.prefs_routes import _load_for_user, _save_for_user try: body = await request.json() except Exception: body = {} prefs = _load_for_user(owner) or {} cfg = dict(prefs.get("caldav") or {}) # Empty url => clear the whole entry (treat as "remove integration"). if not (body.get("url") or "").strip(): prefs.pop("caldav", None) _save_for_user(owner, prefs) return {"ok": True, "cleared": True} cfg["url"] = body.get("url", "").strip() cfg["username"] = (body.get("username") or "").strip() # Preserve the stored password when the client sends an empty # one (edit form re-submitted without re-typing the password). if body.get("password"): cfg["password"] = body["password"] prefs["caldav"] = cfg _save_for_user(owner, prefs) return {"ok": True} @router.post("/test") async def test_connection(request: Request): """Actually probe the configured CalDAV server with a PROPFIND request (the same handshake every CalDAV client uses). Accepts an optional {url, username, password} body so the user can test a configuration BEFORE saving it; falls back to the stored creds otherwise. Returns {ok, error?} with a useful message on failure (status code, auth issue, network error).""" owner = _require_user(request) try: body = await request.json() except Exception: body = {} url = (body.get("url") or "").strip() user = (body.get("username") or "").strip() pw = body.get("password") or "" if not (url and user and pw): # Fall back to saved settings for this user. from routes.prefs_routes import _load_for_user cfg = (_load_for_user(owner) or {}).get("caldav", {}) or {} url = url or (cfg.get("url") or "") user = user or (cfg.get("username") or "") pw = pw or (cfg.get("password") or "") if not (url and user and pw): return {"ok": False, "error": "Missing URL, username, or password"} import httpx propfind_body = ( '\n' '' '' ) try: async with httpx.AsyncClient(timeout=8.0, follow_redirects=True) as cx: r = await cx.request( "PROPFIND", url, auth=(user, pw), headers={"Depth": "0", "Content-Type": "application/xml"}, content=propfind_body, ) # 207 = Multi-Status — standard CalDAV success. 200 also # acceptable. Anything else (401/403/404/5xx) means trouble. if r.status_code in (200, 207): return {"ok": True} if r.status_code == 401: return {"ok": False, "error": "Auth failed — check username/password"} if r.status_code == 403: return {"ok": False, "error": "Forbidden — user can't access that URL"} if r.status_code == 404: return {"ok": False, "error": "Not found — check the URL path"} return {"ok": False, "error": f"HTTP {r.status_code}"} except httpx.ConnectError as e: return {"ok": False, "error": f"Connection refused: {e}"[:200]} except httpx.TimeoutException: return {"ok": False, "error": "Connection timed out"} except Exception as e: return {"ok": False, "error": str(e)[:200]} @router.post("/sync") async def sync_caldav_endpoint(request: Request): """Pull events from the configured CalDAV server into local DB. Returns counts + any per-calendar errors. Called by the frontend on calendar open and by the periodic scheduler loop.""" owner = _require_user(request) from src.caldav_sync import sync_caldav return await sync_caldav(owner) @router.get("/calendars") async def list_calendars(request: Request): owner = _require_user(request) db = SessionLocal() try: _ensure_default_calendar(db, owner) cals = db.query(CalendarCal).filter(CalendarCal.owner == owner).all() return {"calendars": [ {"name": c.name, "href": c.id, "color": c.color} for c in cals ]} except HTTPException: raise except Exception as e: logger.error("Failed to list calendars: %s", e) raise HTTPException(500, "Failed to list calendars") finally: db.close() @router.get("/events") async def list_events(request: Request, start: str, end: str, calendar: str = ""): owner = _require_user(request) try: start_dt = _parse_dt(start) end_dt = _parse_dt(end) except ValueError: # A malformed range (e.g. a stray "NaN-NaN-NaN" from the client) # shouldn't spam the user with an error notification on every poll — # just log it and return no events for this window. logger.warning("list_events: unparseable range start=%r end=%r", start, end) return {"events": []} db = SessionLocal() try: # Scope events to calendars owned by the caller. # Non-recurring events must overlap the query window; recurring # events (with RRULE) whose base dtstart is before the window end # are fetched so their actual occurrences can be expanded # server-side and appear in every year they repeat, not just the # DTSTART year. q = db.query(CalendarEvent).join(CalendarCal).filter( CalendarEvent.status != "cancelled", CalendarCal.owner == owner, or_( # Non-recurring: event times must overlap the query window and_( or_(CalendarEvent.rrule == "", CalendarEvent.rrule.is_(None)), CalendarEvent.dtstart < end_dt, CalendarEvent.dtend > start_dt, ), # Recurring: dtstart before window end — RRULE expansion # generates the actual occurrences within the window and_( CalendarEvent.rrule.isnot(None), CalendarEvent.rrule != "", CalendarEvent.dtstart < end_dt, ), ), ) if calendar: q = q.filter( (CalendarEvent.calendar_id == calendar) | (CalendarCal.name == calendar) ) events = q.order_by(CalendarEvent.dtstart).all() # Expand recurring events into individual occurrences. expanded = [] for e in events: expanded.extend(_expand_rrule(e, start_dt, end_dt)) # Sort by occurrence start time for consistent frontend ordering. expanded.sort(key=lambda d: d["dtstart"]) return {"events": expanded} except HTTPException: raise except Exception as e: logger.error("Failed to list events: %s", e) raise HTTPException(500, "Failed to list events") finally: db.close() @router.post("/events") async def create_event(request: Request, data: EventCreate): owner = _require_user(request) db = SessionLocal() try: cal = None if data.calendar_href: cal = db.query(CalendarCal).filter(CalendarCal.id == data.calendar_href).first() # Reject calendars that aren't owned by the caller. The # previous `if cal and cal.owner and ...` check silently # passed null-owner (legacy) rows, letting any authenticated # user write events into them. Same null-owner gate as # `_get_or_404_calendar`. if cal and (cal.owner is None or cal.owner != owner): raise HTTPException(404, "Calendar not found") if not cal: cal = _ensure_default_calendar(db, owner) uid = str(uuid.uuid4()) # Use the tz-detecting parser so events posted with an offset # (e.g. "2026-05-13T10:00:00+09:00" or "...Z") get stored as UTC # and flagged for proper Z-suffix on read-back. dtstart, _is_utc = _parse_dt_pair(data.dtstart) if data.dtend: dtend, _end_utc = _parse_dt_pair(data.dtend) # If start was tz-aware but end was naive (or vice-versa), # trust whichever flag is True — they should match. _is_utc = _is_utc or _end_utc elif data.all_day: dtend = dtstart + timedelta(days=1) else: dtend = dtstart + timedelta(hours=1) ev = CalendarEvent( uid=uid, calendar_id=cal.id, summary=data.summary, description=data.description, location=data.location, dtstart=dtstart, dtend=dtend, all_day=data.all_day, is_utc=_is_utc and not data.all_day, rrule=data.rrule or "", color=data.color or None, ) db.add(ev) db.commit() if cal.source == "caldav": # Push the new event to the remote so it appears on the user's # other devices — the sync is otherwise pull-only (#800). from src.caldav_writeback import writeback_event await writeback_event(owner, cal.source, cal.id, { "uid": uid, "summary": data.summary, "description": data.description, "location": data.location, "dtstart": dtstart, "dtend": dtend, "all_day": data.all_day, "is_utc": _is_utc and not data.all_day, "rrule": data.rrule or "", }) return {"ok": True, "uid": uid} except HTTPException: raise except Exception as e: db.rollback() logger.error("Failed to create event: %s", e) raise HTTPException(500, "Failed to create event") finally: db.close() @router.put("/events/{uid}") async def update_event(request: Request, uid: str, data: EventUpdate): owner = _require_user(request) try: base_uid = _resolve_base_uid(uid) except ValueError as e: raise HTTPException(400, str(e)) db = SessionLocal() try: ev = _get_or_404_event(db, base_uid, owner) if data.summary is not None: ev.summary = data.summary if data.description is not None: ev.description = data.description if data.location is not None: ev.location = data.location if data.dtstart is not None: ev.dtstart, _s_utc = _parse_dt_pair(data.dtstart) # When the incoming payload carries tz info, mark the row as # UTC-stored so the serializer adds Z. Don't flip the flag # off if start arrives naive but end was UTC — only escalate. if _s_utc: ev.is_utc = True if data.dtend is not None: ev.dtend, _e_utc = _parse_dt_pair(data.dtend) if _e_utc: ev.is_utc = True if data.all_day is not None: ev.all_day = data.all_day if data.all_day: ev.is_utc = False # all-day stays date-only if data.rrule is not None: ev.rrule = data.rrule if data.color is not None: ev.color = data.color if data.color else None db.commit() cal = db.query(CalendarCal).filter(CalendarCal.id == ev.calendar_id).first() if cal and cal.source == "caldav": from src.caldav_writeback import writeback_event await writeback_event(owner, cal.source, cal.id, { "uid": ev.uid, "summary": ev.summary, "description": ev.description, "location": ev.location, "dtstart": ev.dtstart, "dtend": ev.dtend, "all_day": ev.all_day, "is_utc": ev.is_utc, "rrule": ev.rrule or "", }) return {"ok": True} except HTTPException: raise except Exception as e: db.rollback() logger.error("Failed to update event: %s", e) raise HTTPException(500, "Failed to update event") finally: db.close() @router.delete("/events/{uid}") async def delete_event(request: Request, uid: str): owner = _require_user(request) try: base_uid = _resolve_base_uid(uid) except ValueError as e: raise HTTPException(400, str(e)) db = SessionLocal() try: ev = _get_or_404_event(db, base_uid, owner) # Capture what the remote push needs BEFORE the row is gone. _cal = db.query(CalendarCal).filter(CalendarCal.id == ev.calendar_id).first() _is_caldav = bool(_cal and _cal.source == "caldav") _cal_id, _ev_uid = ev.calendar_id, ev.uid db.delete(ev) db.commit() if _is_caldav: from src.caldav_writeback import writeback_event await writeback_event(owner, "caldav", _cal_id, {"uid": _ev_uid}, delete=True) return {"ok": True} except HTTPException: raise except Exception as e: db.rollback() logger.error("Failed to delete event: %s", e) raise HTTPException(500, "Failed to delete event") finally: db.close() @router.post("/calendars") async def create_calendar(request: Request, name: str = "Imported", color: str = "#5b8abf"): owner = _require_user(request) db = SessionLocal() try: cal = CalendarCal( id=str(uuid.uuid4()), owner=owner, name=name, color=color, source="local", ) db.add(cal) db.commit() return {"ok": True, "id": cal.id, "name": cal.name, "color": cal.color} except Exception as e: db.rollback() logger.error("Failed to create calendar: %s", e) raise HTTPException(500, "Failed to create calendar") finally: db.close() @router.put("/calendars/{cal_id}") async def update_calendar(request: Request, cal_id: str, name: str = None, color: str = None): owner = _require_user(request) db = SessionLocal() try: cal = _get_or_404_calendar(db, cal_id, owner) if name is not None: cal.name = name if color is not None: cal.color = color db.commit() return {"ok": True} except HTTPException: raise except Exception as e: db.rollback() logger.error("Failed to update calendar: %s", e) raise HTTPException(500, "Failed to update calendar") finally: db.close() @router.delete("/calendars/{cal_id}") async def delete_calendar(request: Request, cal_id: str): owner = _require_user(request) db = SessionLocal() try: cal = _get_or_404_calendar(db, cal_id, owner) db.query(CalendarEvent).filter(CalendarEvent.calendar_id == cal_id).delete() db.delete(cal) db.commit() return {"ok": True} except HTTPException: raise except Exception as e: db.rollback() return {"error": str(e)} finally: db.close() # 10 MB hard cap on ICS upload. Loading the whole file into memory is # unavoidable with python-icalendar, so an unbounded upload would OOM. _ICS_MAX_BYTES = 10 * 1024 * 1024 @router.post("/import") async def import_ics(request: Request, file: UploadFile = File(...), calendar_name: str = ""): """Import events from an .ics file (scoped to caller's account).""" from icalendar import Calendar as iCal owner = _require_user(request) db = SessionLocal() try: content = await file.read() if len(content) > _ICS_MAX_BYTES: raise HTTPException(413, f"ICS file too large (max {_ICS_MAX_BYTES // (1024*1024)} MB)") try: cal_data = iCal.from_ical(content) except Exception as e: raise HTTPException(400, f"Invalid ICS file: {e}") # Sanitize display name — length cap + strip control chars raw_name = calendar_name.strip() or (file.filename or "").replace(".ics", "").replace("_", " ").strip() or "Imported" cal_display = "".join(c for c in raw_name if c.isprintable())[:120] or "Imported" target_cal = db.query(CalendarCal).filter( CalendarCal.name == cal_display, CalendarCal.owner == owner, ).first() if not target_cal: target_cal = CalendarCal( id=str(uuid.uuid4()), owner=owner, name=cal_display, color="#7c4dff", source="import", ) db.add(target_cal) db.commit() db.refresh(target_cal) imported = skipped = 0 for comp in cal_data.walk(): if comp.name != "VEVENT": continue # Generate a fresh uid for each import. The old code reused # the VEVENT uid from the file, which leaked across users: # a uid present on ANY user's calendar caused this user's # row to be silently skipped (and enabled enumeration). # Using a fresh uuid scopes uniqueness per-row. uid_val = str(uuid.uuid4()) dtstart = comp.get("dtstart") if not dtstart: skipped += 1 continue # Dedup INSIDE this user's target calendar only — same # source-uid + same dtstart in the same target = duplicate. source_uid = str(comp.get("uid", "")) or None if source_uid: src_dtstart = dtstart.dt naive_src = src_dtstart.replace(tzinfo=None) if hasattr(src_dtstart, 'tzinfo') and src_dtstart.tzinfo else src_dtstart existing = ( db.query(CalendarEvent) .filter( CalendarEvent.calendar_id == target_cal.id, CalendarEvent.dtstart == naive_src, CalendarEvent.summary == str(comp.get("summary", "")), ) .first() ) if existing: skipped += 1 continue dt_val = dtstart.dt all_day = isinstance(dt_val, date) and not isinstance(dt_val, datetime) # For timed events, preserve the source timezone by converting # to UTC before stripping tzinfo (DB stores naive). We mark # the row with is_utc=True so the serializer adds the Z # suffix on output — without this, the frontend would parse # the naive ISO as the user's CURRENT local, which is exactly # the bug where imported events fire reminders at wrong times. from datetime import timezone as _tz row_is_utc = False if all_day: start_dt = datetime(dt_val.year, dt_val.month, dt_val.day) dtend = comp.get("dtend") end_dt = datetime(dtend.dt.year, dtend.dt.month, dtend.dt.day) if dtend else start_dt + timedelta(days=1) else: if hasattr(dt_val, 'tzinfo') and dt_val.tzinfo is not None: start_dt = dt_val.astimezone(_tz.utc).replace(tzinfo=None) row_is_utc = True else: start_dt = dt_val dtend = comp.get("dtend") if dtend: d_end = dtend.dt if hasattr(d_end, 'tzinfo') and d_end.tzinfo is not None: end_dt = d_end.astimezone(_tz.utc).replace(tzinfo=None) else: end_dt = d_end else: end_dt = start_dt + timedelta(hours=1) ev = CalendarEvent( uid=uid_val, calendar_id=target_cal.id, summary=str(comp.get("summary", "")), description=str(comp.get("description", "")), location=str(comp.get("location", "")), dtstart=start_dt, dtend=end_dt, all_day=all_day, is_utc=row_is_utc, rrule=(comp.get("rrule").to_ical().decode() if comp.get("rrule") else ""), ) db.add(ev) imported += 1 db.commit() return { "ok": True, "imported": imported, "skipped": skipped, "calendar": cal_display, "calendar_id": target_cal.id, } except HTTPException: raise except Exception as e: db.rollback() logger.error("Failed to import ICS: %s", e) raise HTTPException(500, "Failed to import ICS") finally: db.close() @router.get("/export/{cal_id}") async def export_ics(request: Request, cal_id: str): """Export a calendar as .ics file.""" from fastapi.responses import Response owner = _require_user(request) db = SessionLocal() try: cal = _get_or_404_calendar(db, cal_id, owner) events = db.query(CalendarEvent).filter( CalendarEvent.calendar_id == cal_id, CalendarEvent.status != "cancelled", ).all() lines = [ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Odysseus//Calendar//EN", f"X-WR-CALNAME:{_ics_escape(cal.name)}", ] for ev in events: lines.append("BEGIN:VEVENT") lines.append(f"UID:{ev.uid}") lines.append(f"SUMMARY:{_ics_escape(ev.summary or '')}") if ev.all_day: lines.append(f"DTSTART;VALUE=DATE:{ev.dtstart.strftime('%Y%m%d')}") lines.append(f"DTEND;VALUE=DATE:{ev.dtend.strftime('%Y%m%d')}") else: _dt_suffix = "Z" if getattr(ev, "is_utc", False) else "" lines.append(f"DTSTART:{ev.dtstart.strftime('%Y%m%dT%H%M%S')}{_dt_suffix}") lines.append(f"DTEND:{ev.dtend.strftime('%Y%m%dT%H%M%S')}{_dt_suffix}") if ev.description: lines.append(f"DESCRIPTION:{_ics_escape(ev.description)}") if ev.location: lines.append(f"LOCATION:{_ics_escape(ev.location)}") if ev.rrule: lines.append(f"RRULE:{ev.rrule}") lines.append("END:VEVENT") lines.append("END:VCALENDAR") ics_data = "\r\n".join(lines) safe_name = cal.name.replace(" ", "_").replace("/", "_") return Response( content=ics_data, media_type="text/calendar", headers={"Content-Disposition": f'attachment; filename="{safe_name}.ics"'}, ) except HTTPException: raise except Exception as e: logger.error("Failed to export ICS: %s", e) raise HTTPException(500, "Failed to export ICS") finally: db.close() @router.post("/quick-parse") async def quick_parse(request: Request): """Parse a natural-language event description into structured fields. Input: {"text": "lunch with sara friday 1pm downtown", "tz": "America/New_York"} Output: {"ok": true, "event": {"summary", "dtstart", "dtend", "all_day", "location", "description"}, "confidence": 0.0-1.0} Anchored on the server's current date/time so phrases like "tomorrow", "next Tuesday", "in 30 minutes" resolve correctly. Uses the "utility" endpoint (small / fast model) to keep latency low. """ _require_user(request) from src.endpoint_resolver import resolve_endpoint from src.llm_core import llm_call_async from src.text_helpers import strip_think import json as _json import re as _re body = await request.json() text = (body.get("text") or "").strip() if not text: raise HTTPException(400, "text is required") tz_hint = (body.get("tz") or "").strip() url, model, headers = resolve_endpoint("utility") if not url: url, model, headers = resolve_endpoint("default") if not url or not model: return {"ok": False, "error": "No LLM endpoint configured"} now = datetime.now() now_iso = now.strftime("%Y-%m-%dT%H:%M:%S") # The model gets only the schema it needs to fill out; we re-validate # everything client-side too. system_prompt = ( "You are a calendar event parser. Read the user's one-line " "description and emit STRICT JSON describing the event. " f"Today is {now.strftime('%A, %Y-%m-%d')} ({now_iso}). " + (f"User timezone: {tz_hint}. " if tz_hint else "") + "Resolve relative dates (\"tomorrow\", \"friday\", \"next monday\", " "\"in 30 minutes\") against today. Default duration is 60 minutes " "when no end time is given. If the text mentions a date with no " "time, treat it as an all-day event.\n\n" "Output ONLY this JSON shape, nothing else:\n" "{\n" ' "summary": "",\n' ' "dtstart": "",\n' ' "dtend": "",\n' ' "all_day": ,\n' ' "location": "",\n' ' "description": "",\n' ' "confidence": <0.0-1.0>\n' "}\n" "For all-day events use \"YYYY-MM-DD\" (no time) for both fields." ) try: raw = await llm_call_async( url=url, model=model, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": text}, ], headers=headers, temperature=0.0, max_tokens=512, timeout=20, ) except Exception as e: return {"ok": False, "error": f"LLM call failed: {e}"} cleaned = strip_think(raw or "", prose=False, prompt_echo=True) cleaned = _re.sub(r"^```(?:json)?\s*|\s*```$", "", cleaned, flags=_re.MULTILINE).strip() m = _re.search(r"\{[\s\S]*\}", cleaned) if not m: return {"ok": False, "error": "Could not extract JSON", "raw": cleaned[:400]} try: parsed = _json.loads(m.group()) except Exception as e: return {"ok": False, "error": f"Invalid JSON: {e}", "raw": cleaned[:400]} # Light validation / defaults so the frontend can trust the shape. summary = (parsed.get("summary") or text)[:200] # Strip stale relative/absolute time tokens that the LLM (or the # user's raw input) sometimes leaks into the summary — these # would otherwise be displayed verbatim in reminder notifications # that fire much later, when "in 29 min" is no longer true. The # actual timing lives in dtstart/dtend. summary = _re.sub(r'\bin\s+\d+\s*(min|minute|hour|hr|day)s?\b', '', summary, flags=_re.IGNORECASE) summary = _re.sub(r'\(\s*\d{1,2}:\d{2}\s*\)', '', summary) summary = _re.sub(r'\b\d{1,2}(:\d{2})?\s*(am|pm)\b', '', summary, flags=_re.IGNORECASE) summary = _re.sub(r'\s+@\s+(?=\d)', ' ', summary) # drop "@" when right before a time summary = _re.sub(r'\s+', ' ', summary).strip(' -—,@') all_day = bool(parsed.get("all_day")) dtstart = (parsed.get("dtstart") or "").strip() dtend = (parsed.get("dtend") or "").strip() # Force naive-local on LLM output. The model is anchored on the # user's local "now" via the system prompt, so its emitted # datetime is already meant to be the user's wall-clock time. # Some models append `Z` or a tz offset anyway, which would # make `_parse_dt_pair` flag the row as UTC and shift the # displayed time forward by the user's tz offset. Strip any # trailing tz marker so the time is stored exactly as the LLM # wrote it. def _strip_tz(s): if not s: return s s = s.strip() # Strip "Z" if s.endswith('Z') or s.endswith('z'): s = s[:-1] # Strip "+HH:MM" / "-HH:MM" if it followed a T-time s = _re.sub(r'[+-]\d{2}:?\d{2}$', '', s) return s dtstart = _strip_tz(dtstart) dtend = _strip_tz(dtend) if not dtstart: return {"ok": False, "error": "Model did not produce a start time", "raw": cleaned[:400]} if not dtend: # Auto-fill +60 min for timed events; +0 for all-day (single-day). try: if all_day: dtend = dtstart else: dt = datetime.fromisoformat(dtstart) dtend = (dt + timedelta(minutes=60)).strftime("%Y-%m-%dT%H:%M:00") except Exception: dtend = dtstart return { "ok": True, "event": { "summary": summary, "dtstart": dtstart, "dtend": dtend, "all_day": all_day, "location": (parsed.get("location") or "").strip()[:200], "description": (parsed.get("description") or "").strip()[:2000], }, "confidence": float(parsed.get("confidence", 0.7) or 0.7), } return router