mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 10:15:27 -04:00
01f1278811
* fix(calendar): expose source in calendar list and add per-calendar delete
- GET /api/calendar/calendars now includes source field so the frontend
can distinguish CalDAV collections from local calendars
- Add DELETE /api/calendar/calendars/{cal_id} to remove a specific
calendar and its events by owner-scoped ID
* fix(settings): show all CalDAV calendars in integrations list
Previously one card was shown for the CalDAV server connection regardless
of how many calendar collections had been synced. The Calendars page showed
them all; Settings did not.
- Fetch /api/calendar/calendars alongside existing requests and render
one card per source=caldav collection, falling back to the single
server-level card if nothing has synced yet
- Delete now targets the specific calendar by ID rather than clearing
the whole server config
- Confirm dialog shows the calendar name so the user can verify before
removing
1405 lines
57 KiB
Python
1405 lines
57 KiB
Python
"""Calendar routes — local SQLite-backed calendar CRUD."""
|
|
|
|
import logging
|
|
import re
|
|
import uuid
|
|
from datetime import datetime, date, timedelta
|
|
from typing import Optional, List
|
|
|
|
from fastapi import APIRouter, HTTPException, Request, UploadFile, File
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import or_, and_
|
|
from dateutil.rrule import rrulestr
|
|
|
|
from core.database import SessionLocal, CalendarCal, CalendarEvent
|
|
from src.auth_helpers import require_user
|
|
from src.upload_limits import read_upload_limited
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _ics_naive_dtstart(dt):
|
|
"""Naive value matching how import_ics STORES CalendarEvent.dtstart.
|
|
|
|
Timed tz-aware events are stored as UTC with tzinfo stripped, all-day
|
|
dates as midnight datetimes, naive datetimes unchanged. The ICS dedup
|
|
must compute the same value or a re-import never matches the stored row.
|
|
"""
|
|
if isinstance(dt, datetime):
|
|
if dt.tzinfo is not None:
|
|
from datetime import timezone as _tz
|
|
return dt.astimezone(_tz.utc).replace(tzinfo=None)
|
|
return dt
|
|
if isinstance(dt, date):
|
|
return datetime(dt.year, dt.month, dt.day)
|
|
return dt
|
|
|
|
# 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 _safe_ics_filename(name: str) -> str:
|
|
"""Return a conservative .ics filename safe for Content-Disposition."""
|
|
stem = name if isinstance(name, str) else ""
|
|
stem = re.sub(r"[^A-Za-z0-9._-]", "_", stem).strip("._-")
|
|
if not stem:
|
|
stem = "calendar"
|
|
return f"{stem[:128]}.ics"
|
|
|
|
|
|
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 time context. chat_routes sets this from browser timezone
|
|
# headers so 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 src.user_time import (
|
|
get_user_tz_name,
|
|
get_user_tz_offset,
|
|
now_user_local,
|
|
set_user_tz_name,
|
|
set_user_tz_offset,
|
|
user_timezone,
|
|
)
|
|
|
|
|
|
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()
|
|
tz_name = get_user_tz_name()
|
|
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 and not tz_name:
|
|
# No user tz known — preserve legacy behavior (naive server-local).
|
|
return _parse_dt(s).isoformat()
|
|
|
|
user_tz = user_timezone()
|
|
|
|
# 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 = now_user_local(server_now_utc)
|
|
# 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):
|
|
t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
|
|
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 <weekday> [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."""
|
|
t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
|
|
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/tonight/tomorrow/yesterday [at] TIME
|
|
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 + 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 <weekday> [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
|
|
parsed = _du.parse(s)
|
|
# Strip tz like every other return path above — this function's
|
|
# contract is naive datetimes (CalendarEvent.dtstart is naive). An
|
|
# offset-bearing non-ISO input (e.g. RFC-2822 "Mon, 05 Jan 2026
|
|
# 14:00:00 +0900") otherwise leaked tz-aware into the naive column and
|
|
# crashed read-back comparisons in _expand_rrule with "can't compare
|
|
# offset-naive and offset-aware datetimes".
|
|
if parsed.tzinfo is not None:
|
|
from datetime import timezone as _tz
|
|
return parsed.astimezone(_tz.utc).replace(tzinfo=None)
|
|
return parsed
|
|
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 ──
|
|
|
|
_RRULE_EXPANSION_LIMIT = 1000
|
|
|
|
|
|
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
|
|
d["truncated"] = False
|
|
return [d]
|
|
|
|
# Parse the rrule, applying it to the base dtstart.
|
|
rrule_str = ev.rrule
|
|
if ev.dtstart is not None and getattr(ev.dtstart, "tzinfo", None) is None:
|
|
# Events are stored with a naive (UTC) dtstart, but standard .ics
|
|
# exporters (Google/Apple/Outlook/Fastmail) write the bound as an
|
|
# absolute UTC value, e.g. UNTIL=20240105T090000Z. dateutil refuses to
|
|
# mix a tz-aware UNTIL with a naive DTSTART ("RRULE UNTIL values must be
|
|
# specified in UTC when DTSTART is timezone-aware"), so the except branch
|
|
# below would silently collapse the whole series to a single event.
|
|
# Drop the trailing Z so UNTIL matches the naive DTSTART.
|
|
import re as _re
|
|
rrule_str = _re.sub(
|
|
r"(UNTIL=\d{8}(?:T\d{6})?)Z", r"\1", rrule_str, flags=_re.IGNORECASE
|
|
)
|
|
try:
|
|
rule = rrulestr(rrule_str, 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
|
|
d["truncated"] = False
|
|
# 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
|
|
results = []
|
|
truncated = False
|
|
base = _event_to_dict(ev)
|
|
|
|
for occ_start in rule.xafter(expand_start, inc=True):
|
|
if occ_start >= end:
|
|
break
|
|
|
|
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_end <= start:
|
|
continue
|
|
|
|
if len(results) >= _RRULE_EXPANSION_LIMIT:
|
|
truncated = True
|
|
break
|
|
|
|
# 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
|
|
d["truncated"] = False
|
|
|
|
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)
|
|
|
|
if truncated:
|
|
for d in results:
|
|
d["truncated"] = True
|
|
|
|
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 {}
|
|
caldav_password = cfg.get("password") or ""
|
|
if caldav_password:
|
|
try:
|
|
from src.secret_storage import decrypt
|
|
caldav_password = decrypt(caldav_password)
|
|
except Exception:
|
|
pass
|
|
# 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(caldav_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}
|
|
from src.caldav_sync import validate_caldav_url
|
|
try:
|
|
cfg["url"] = validate_caldav_url(body.get("url", ""))
|
|
except ValueError as e:
|
|
raise HTTPException(400, str(e))
|
|
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).
|
|
# cfg already holds the existing (already-encrypted) password from
|
|
# prefs, so we only touch it when a new password is supplied —
|
|
# re-encrypting the stored value would double-encrypt it.
|
|
if body.get("password"):
|
|
from src.secret_storage import encrypt
|
|
cfg["password"] = encrypt(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 "")
|
|
if not pw:
|
|
pw = cfg.get("password") or ""
|
|
if pw:
|
|
try:
|
|
from src.secret_storage import decrypt
|
|
pw = decrypt(pw)
|
|
except Exception:
|
|
pass
|
|
if not (url and user and pw):
|
|
return {"ok": False, "error": "Missing URL, username, or password"}
|
|
from src.caldav_sync import validate_caldav_url
|
|
try:
|
|
url = validate_caldav_url(url)
|
|
except ValueError as e:
|
|
return {"ok": False, "error": str(e)}
|
|
import httpx
|
|
propfind_body = (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/>'
|
|
'</d:prop></d:propfind>'
|
|
)
|
|
try:
|
|
async with httpx.AsyncClient(timeout=8.0, follow_redirects=False, trust_env=False) as cx:
|
|
r = await cx.request(
|
|
"PROPFIND", url,
|
|
auth=(user, pw),
|
|
headers={"Depth": "0", "Content-Type": "application/xml"},
|
|
content=propfind_body,
|
|
)
|
|
# If the server demands Digest (Baïkal default, SabreDAV-based
|
|
# servers, Radicale with htdigest), the Basic attempt above
|
|
# 401s. Retry once with httpx.DigestAuth so this test matches
|
|
# what the real sync does via caldav.DAVClient in
|
|
# src/caldav_sync.py (which negotiates the scheme).
|
|
if r.status_code == 401 and "digest" in r.headers.get("www-authenticate", "").lower():
|
|
r = await cx.request(
|
|
"PROPFIND", url,
|
|
auth=httpx.DigestAuth(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"}
|
|
if 300 <= r.status_code < 400:
|
|
return {"ok": False, "error": "Redirects are not followed for CalDAV safety; use the final URL"}
|
|
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.delete("/calendars/{cal_id}")
|
|
async def delete_calendar(cal_id: str, request: Request):
|
|
owner = _require_user(request)
|
|
db = SessionLocal()
|
|
try:
|
|
cal = db.query(CalendarCal).filter(
|
|
CalendarCal.id == cal_id,
|
|
CalendarCal.owner == owner,
|
|
).first()
|
|
if not cal:
|
|
raise HTTPException(404, "Calendar not found")
|
|
db.delete(cal)
|
|
db.commit()
|
|
return {"ok": True}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to delete calendar %s: %s", cal_id, e)
|
|
raise HTTPException(500, "Failed to delete calendar")
|
|
finally:
|
|
db.close()
|
|
|
|
@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, "source": c.source}
|
|
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.
|
|
truncated = any(e.get("truncated") for e in expanded)
|
|
expanded.sort(key=lambda d: d["dtstart"])
|
|
response: dict = {"events": expanded}
|
|
if truncated:
|
|
response["truncated"] = True
|
|
return response
|
|
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 read_upload_limited(file, _ICS_MAX_BYTES, "ICS file")
|
|
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
|
|
# Normalize to the SAME naive form import_ics stores, so a
|
|
# re-import of a tz-aware event matches the existing row.
|
|
# The old code stripped tzinfo WITHOUT converting to UTC
|
|
# (wall clock), while storage converts to UTC first, so
|
|
# every re-import of a TZID event created a duplicate.
|
|
naive_src = _ics_naive_dtstart(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)
|
|
download_name = _safe_ics_filename(cal.name)
|
|
return Response(
|
|
content=ics_data,
|
|
media_type="text/calendar",
|
|
headers={
|
|
"Content-Disposition": f'attachment; filename="{download_name}"',
|
|
"X-Content-Type-Options": "nosniff",
|
|
},
|
|
)
|
|
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")
|
|
from src.user_time import (
|
|
clear_user_time_context,
|
|
current_datetime_prompt,
|
|
now_user_local,
|
|
set_user_tz_name,
|
|
set_user_tz_offset,
|
|
)
|
|
|
|
clear_user_time_context()
|
|
tz_hint = (body.get("tz") or "").strip()
|
|
if body.get("tz_offset") is not None:
|
|
set_user_tz_offset(body.get("tz_offset"))
|
|
if tz_hint:
|
|
set_user_tz_name(tz_hint)
|
|
|
|
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 = now_user_local()
|
|
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 = (
|
|
current_datetime_prompt()
|
|
+ "You are a calendar event parser. Read the user's one-line "
|
|
"description and emit STRICT JSON describing the event. "
|
|
f"The current user-local timestamp is {now_iso}. "
|
|
+ "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": "<event title, capitalized>",\n'
|
|
' "dtstart": "<YYYY-MM-DDTHH:MM:00>",\n'
|
|
' "dtend": "<YYYY-MM-DDTHH:MM:00>",\n'
|
|
' "all_day": <true|false>,\n'
|
|
' "location": "<place or empty>",\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
|