012eb1d46b
## Projects & Milestones (Phases A + G) - New models: Project, Milestone (Project → Milestone → Task hierarchy) - notes table: project_id + milestone_id FKs; parent_id FK constraint activated - Migrations: 0017 (projects), 0018 (push_subscriptions), 0019 (events), 0020 (milestones) - Services: projects.py, milestones.py (CRUD + progress tracking) - Routes: /api/projects + /api/projects/<id>/milestones - LLM tools: create/list/get/update project; create/list milestone; project + milestone + parent_task params on note/task tools - Frontend: ProjectListView (stacked milestone bars), ProjectView (milestone-grouped kanban), ProjectSelector, MilestoneSelector, NoteEditorView + TaskEditorView updated ## RAG Auto-injection (Phase B) - Notes ≥0.60 cosine similarity auto-injected into system prompt (max 3, 800 chars each) - excluded_note_ids param; ChatView "Auto-included" sidebar section ## Summarisation improvements (Phase C) - Threshold 20→30, keep-recent 6→8, max_tokens 200→400 - Two-pass summarisation for histories >50 messages ## Browser push notifications (Phase E) - PushSubscription model + migration; pywebpush dependency - /api/push routes; VAPID config; fire-and-forget on generation complete - Frontend: sw.js, push store, Settings toggle ## PWA manifest (Phase F) - manifest.json, Apple meta tags, service worker registration in main.ts ## Tag normalisation - All tags lowercased + deduplicated at backend (create_note/update_note) and frontend (TagInput sanitize) - Note/Task types gain project_id + milestone_id fields; store signatures updated ## CalDAV - Radicale embedded server reverted; back to user-configured external CalDAV Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
305 lines
11 KiB
Python
305 lines
11 KiB
Python
"""Calendar sync service: bridges the DB Event table with local Radicale.
|
|
|
|
Each user's calendars live at: http://radicale:5232/user_{user_id}/calendar/
|
|
The app connects without auth (Radicale runs with auth.type = none inside Docker).
|
|
|
|
External clients (iOS, macOS, Thunderbird) connect directly to Radicale on port 5232.
|
|
Their CalDAV URL is: http://<host>:5232/user_{user_id}/calendar/
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
|
|
import httpx
|
|
import icalendar
|
|
from sqlalchemy import select
|
|
|
|
from fabledassistant.config import Config
|
|
from fabledassistant.models import async_session
|
|
from fabledassistant.models.event import Event
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Internal Radicale URL (Docker service name)
|
|
_RADICALE_INTERNAL = "http://radicale:5232"
|
|
|
|
|
|
def _user_calendar_path(user_id: int) -> str:
|
|
return f"/user_{user_id}/calendar/"
|
|
|
|
|
|
def _user_calendar_url(user_id: int) -> str:
|
|
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
|
return f"{base}/user_{user_id}/calendar/"
|
|
|
|
|
|
async def setup_user_calendar(user_id: int) -> bool:
|
|
"""Create Radicale collection for user if it doesn't exist.
|
|
|
|
Returns True on success or if already exists, False on error.
|
|
"""
|
|
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
|
user_url = f"{base}/user_{user_id}/"
|
|
cal_url = f"{base}/user_{user_id}/calendar/"
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
# Create user home collection
|
|
await client.request(
|
|
"MKCOL",
|
|
user_url,
|
|
headers={"Content-Type": "text/xml"},
|
|
content="""<?xml version="1.0" encoding="utf-8"?>
|
|
<mkcol xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
<set><prop><resourcetype><collection/></resourcetype></prop></set>
|
|
</mkcol>""",
|
|
)
|
|
# Create calendar collection
|
|
resp = await client.request(
|
|
"MKCOL",
|
|
cal_url,
|
|
headers={"Content-Type": "text/xml"},
|
|
content="""<?xml version="1.0" encoding="utf-8"?>
|
|
<mkcol xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
<set>
|
|
<prop>
|
|
<resourcetype><collection/><C:calendar/></resourcetype>
|
|
<displayname>Fabled Assistant</displayname>
|
|
</prop>
|
|
</set>
|
|
</mkcol>""",
|
|
)
|
|
if resp.status_code in (201, 405): # 405 = already exists
|
|
logger.info("Calendar collection ready for user %d", user_id)
|
|
return True
|
|
logger.warning("Unexpected status %d creating calendar for user %d", resp.status_code, user_id)
|
|
return False
|
|
except Exception:
|
|
logger.warning("Failed to setup Radicale calendar for user %d", user_id, exc_info=True)
|
|
return False
|
|
|
|
|
|
def _make_vcalendar(event: Event) -> str:
|
|
"""Build iCalendar string from DB Event."""
|
|
cal = icalendar.Calendar()
|
|
cal.add("prodid", "-//FabledAssistant//EN")
|
|
cal.add("version", "2.0")
|
|
|
|
vevent = icalendar.Event()
|
|
vevent.add("uid", event.uid)
|
|
vevent.add("summary", event.title)
|
|
|
|
if event.all_day:
|
|
vevent.add("dtstart", event.start_dt.date())
|
|
if event.end_dt:
|
|
vevent.add("dtend", event.end_dt.date())
|
|
else:
|
|
vevent.add("dtstart", event.start_dt)
|
|
if event.end_dt:
|
|
vevent.add("dtend", event.end_dt)
|
|
|
|
if event.description:
|
|
vevent.add("description", event.description)
|
|
if event.location:
|
|
vevent.add("location", event.location)
|
|
if event.recurrence:
|
|
rrule_parts: dict = {}
|
|
for part in event.recurrence.split(";"):
|
|
if "=" in part:
|
|
key, value = part.split("=", 1)
|
|
rrule_parts[key.strip().lower()] = value.strip()
|
|
if rrule_parts:
|
|
vevent.add("rrule", rrule_parts)
|
|
|
|
vevent.add("dtstamp", datetime.now(timezone.utc))
|
|
cal.add_component(vevent)
|
|
return cal.to_ical().decode("utf-8")
|
|
|
|
|
|
async def push_event_to_radicale(user_id: int, event: Event) -> bool:
|
|
"""Write a DB Event to Radicale. Creates or updates the resource.
|
|
|
|
Returns True on success.
|
|
"""
|
|
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
|
resource_url = f"{base}/user_{user_id}/calendar/{event.uid}.ics"
|
|
ical_data = _make_vcalendar(event)
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.put(
|
|
resource_url,
|
|
content=ical_data.encode("utf-8"),
|
|
headers={"Content-Type": "text/calendar; charset=utf-8"},
|
|
)
|
|
if resp.status_code in (200, 201, 204):
|
|
return True
|
|
logger.warning("Radicale PUT returned %d for event %s", resp.status_code, event.uid)
|
|
return False
|
|
except Exception:
|
|
logger.warning("Failed to push event %s to Radicale", event.uid, exc_info=True)
|
|
return False
|
|
|
|
|
|
async def delete_event_from_radicale(user_id: int, uid: str) -> bool:
|
|
"""Delete an event from Radicale by UID."""
|
|
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
|
resource_url = f"{base}/user_{user_id}/calendar/{uid}.ics"
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.delete(resource_url)
|
|
return resp.status_code in (200, 204, 404) # 404 is ok (already gone)
|
|
except Exception:
|
|
logger.warning("Failed to delete event %s from Radicale", uid, exc_info=True)
|
|
return False
|
|
|
|
|
|
async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
|
|
"""Fetch an event from Radicale and upsert into the DB Event table.
|
|
|
|
Returns the upserted Event or None on error.
|
|
"""
|
|
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
|
resource_url = f"{base}/user_{user_id}/calendar/{ical_uid}.ics"
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.get(resource_url)
|
|
if resp.status_code == 404:
|
|
return None
|
|
resp.raise_for_status()
|
|
ical_data = resp.text
|
|
except Exception:
|
|
logger.warning("Failed to fetch event %s from Radicale", ical_uid, exc_info=True)
|
|
return None
|
|
|
|
try:
|
|
cal = icalendar.Calendar.from_ical(ical_data)
|
|
for component in cal.walk():
|
|
if component.name != "VEVENT":
|
|
continue
|
|
title = str(component.get("SUMMARY", ""))
|
|
dtstart = component.get("DTSTART")
|
|
dtend = component.get("DTEND")
|
|
description = str(component.get("DESCRIPTION", ""))
|
|
location = str(component.get("LOCATION", ""))
|
|
rrule = component.get("RRULE")
|
|
recurrence = rrule.to_ical().decode("utf-8") if rrule else None
|
|
|
|
all_day = False
|
|
if dtstart and isinstance(dtstart.dt, datetime):
|
|
start_dt = dtstart.dt if dtstart.dt.tzinfo else dtstart.dt.replace(tzinfo=timezone.utc)
|
|
elif dtstart:
|
|
from datetime import date
|
|
d = dtstart.dt
|
|
start_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
|
|
all_day = True
|
|
else:
|
|
continue
|
|
|
|
end_dt = None
|
|
if dtend and isinstance(dtend.dt, datetime):
|
|
end_dt = dtend.dt if dtend.dt.tzinfo else dtend.dt.replace(tzinfo=timezone.utc)
|
|
elif dtend:
|
|
from datetime import date
|
|
d = dtend.dt
|
|
end_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
|
|
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Event).where(Event.user_id == user_id, Event.uid == ical_uid)
|
|
)
|
|
existing = result.scalars().first()
|
|
if existing:
|
|
existing.title = title
|
|
existing.start_dt = start_dt
|
|
existing.end_dt = end_dt
|
|
existing.all_day = all_day
|
|
existing.description = description
|
|
existing.location = location
|
|
existing.recurrence = recurrence
|
|
existing.updated_at = datetime.now(timezone.utc)
|
|
await session.commit()
|
|
await session.refresh(existing)
|
|
return existing
|
|
else:
|
|
event_obj = Event(
|
|
user_id=user_id,
|
|
uid=ical_uid,
|
|
title=title,
|
|
start_dt=start_dt,
|
|
end_dt=end_dt,
|
|
all_day=all_day,
|
|
description=description,
|
|
location=location,
|
|
recurrence=recurrence,
|
|
)
|
|
session.add(event_obj)
|
|
await session.commit()
|
|
await session.refresh(event_obj)
|
|
return event_obj
|
|
except Exception:
|
|
logger.warning("Failed to parse/upsert Radicale event %s", ical_uid, exc_info=True)
|
|
return None
|
|
|
|
|
|
async def sync_all_events_from_radicale(user_id: int) -> int:
|
|
"""Full sync: fetch all events from Radicale calendar and upsert into DB.
|
|
|
|
Returns number of events synced.
|
|
"""
|
|
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
|
cal_url = f"{base}/user_{user_id}/calendar/"
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
# PROPFIND to get all .ics resources
|
|
resp = await client.request(
|
|
"PROPFIND",
|
|
cal_url,
|
|
headers={"Depth": "1", "Content-Type": "text/xml"},
|
|
content="""<?xml version="1.0" encoding="utf-8"?>
|
|
<propfind xmlns="DAV:"><prop><getetag/><getcontenttype/></prop></propfind>""",
|
|
)
|
|
if resp.status_code == 404:
|
|
logger.info("No calendar found for user %d — setting up", user_id)
|
|
await setup_user_calendar(user_id)
|
|
return 0
|
|
if resp.status_code != 207:
|
|
logger.warning("PROPFIND returned %d for user %d", resp.status_code, user_id)
|
|
return 0
|
|
# Extract UIDs from href paths
|
|
import re
|
|
hrefs = re.findall(r"<[Dd]:[Hh]ref>([^<]+\.ics)</[Dd]:[Hh]ref>", resp.text)
|
|
uids = [h.rstrip("/").split("/")[-1].removesuffix(".ics") for h in hrefs]
|
|
except Exception:
|
|
logger.warning("Failed to list Radicale events for user %d", user_id, exc_info=True)
|
|
return 0
|
|
|
|
synced = 0
|
|
for uid in uids:
|
|
if uid:
|
|
event = await sync_event_to_db(user_id, uid)
|
|
if event:
|
|
synced += 1
|
|
|
|
logger.info("Synced %d events from Radicale for user %d", synced, user_id)
|
|
return synced
|
|
|
|
|
|
def get_external_caldav_url(user_id: int) -> str:
|
|
"""Return the CalDAV URL for external clients (iOS, macOS, Thunderbird).
|
|
|
|
Uses BASE_URL from Config with port 5232 substituted, or RADICALE_EXTERNAL_URL.
|
|
"""
|
|
external = Config.RADICALE_EXTERNAL_URL
|
|
if external:
|
|
return f"{external.rstrip('/')}/user_{user_id}/calendar/"
|
|
# Fall back to BASE_URL host with port 5232
|
|
from urllib.parse import urlparse
|
|
parsed = urlparse(Config.BASE_URL)
|
|
host = parsed.hostname or "localhost"
|
|
return f"http://{host}:5232/user_{user_id}/calendar/"
|