Add CalDAV calendar integration, LLM-suggested tags, and settings refinements
- CalDAV integration: per-user calendar config, create/list/search events via caldav library, LLM tools for calendar operations from chat - LLM-suggested tags: new tag_suggestions service prompts LLM with existing tags and note content to suggest 3-5 relevant tags; exposed via API endpoints (suggest-tags, append-tag); integrated into editor views (suggest button + clickable pills) and chat tool calls (pills in ToolCallCard with one-click apply) - Settings/model UI refinements, generation task improvements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
"""CalDAV calendar integration service."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import caldav
|
||||
import icalendar
|
||||
|
||||
from fabledassistant.services.settings import get_all_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CALDAV_SETTING_KEYS = ["caldav_url", "caldav_username", "caldav_password", "caldav_calendar_name"]
|
||||
|
||||
|
||||
async def get_caldav_config(user_id: int) -> dict[str, str]:
|
||||
"""Read CalDAV settings for the given user."""
|
||||
all_settings = await get_all_settings(user_id)
|
||||
return {k: all_settings.get(k, "") for k in CALDAV_SETTING_KEYS}
|
||||
|
||||
|
||||
async def is_caldav_configured(user_id: int) -> bool:
|
||||
"""Check if CalDAV URL, username, and password are all set."""
|
||||
config = await get_caldav_config(user_id)
|
||||
return bool(config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password"))
|
||||
|
||||
|
||||
def _get_calendar(client: caldav.DAVClient, calendar_name: str) -> caldav.Calendar:
|
||||
"""Get a named calendar or the first available one (synchronous)."""
|
||||
principal = client.principal()
|
||||
calendars = principal.calendars()
|
||||
if not calendars:
|
||||
raise ValueError("No calendars found on the CalDAV server.")
|
||||
if calendar_name:
|
||||
for cal in calendars:
|
||||
if cal.name == calendar_name:
|
||||
return cal
|
||||
names = [c.name for c in calendars]
|
||||
raise ValueError(f"Calendar '{calendar_name}' not found. Available: {', '.join(names)}")
|
||||
return calendars[0]
|
||||
|
||||
|
||||
def _make_client(config: dict[str, str]) -> caldav.DAVClient:
|
||||
"""Create a CalDAV client from config dict."""
|
||||
return caldav.DAVClient(
|
||||
url=config["caldav_url"],
|
||||
username=config["caldav_username"],
|
||||
password=config["caldav_password"],
|
||||
)
|
||||
|
||||
|
||||
def _parse_vevent(component) -> dict | None:
|
||||
"""Extract event data from a VEVENT component."""
|
||||
if component.name != "VEVENT":
|
||||
return None
|
||||
title = str(component.get("SUMMARY", ""))
|
||||
dtstart = component.get("DTSTART")
|
||||
dtend = component.get("DTEND")
|
||||
location = str(component.get("LOCATION", ""))
|
||||
start_str = dtstart.dt.isoformat() if dtstart else ""
|
||||
end_str = dtend.dt.isoformat() if dtend else ""
|
||||
return {
|
||||
"title": title,
|
||||
"start": start_str,
|
||||
"end": end_str,
|
||||
"location": location,
|
||||
}
|
||||
|
||||
|
||||
async def create_event(
|
||||
user_id: int,
|
||||
title: str,
|
||||
start: str,
|
||||
end: str | None = None,
|
||||
duration: int | None = None,
|
||||
description: str | None = None,
|
||||
location: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a calendar event. start/end are ISO datetime strings."""
|
||||
config = await get_caldav_config(user_id)
|
||||
if not (config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")):
|
||||
raise ValueError("CalDAV is not configured. Go to Settings → Calendar to set it up.")
|
||||
|
||||
dt_start = datetime.fromisoformat(start)
|
||||
if end:
|
||||
dt_end = datetime.fromisoformat(end)
|
||||
else:
|
||||
dt_end = dt_start + timedelta(minutes=duration or 60)
|
||||
|
||||
cal = icalendar.Calendar()
|
||||
cal.add("prodid", "-//FabledAssistant//EN")
|
||||
cal.add("version", "2.0")
|
||||
|
||||
event = icalendar.Event()
|
||||
event.add("summary", title)
|
||||
event.add("dtstart", dt_start)
|
||||
event.add("dtend", dt_end)
|
||||
if description:
|
||||
event.add("description", description)
|
||||
if location:
|
||||
event.add("location", location)
|
||||
cal.add_component(event)
|
||||
|
||||
ical_str = cal.to_ical().decode("utf-8")
|
||||
|
||||
def _save():
|
||||
client = _make_client(config)
|
||||
calendar = _get_calendar(client, config.get("caldav_calendar_name", ""))
|
||||
calendar.save_event(ical_str)
|
||||
|
||||
await asyncio.to_thread(_save)
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"start": dt_start.isoformat(),
|
||||
"end": dt_end.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def list_events(user_id: int, date_from: str, date_to: str) -> list[dict]:
|
||||
"""List calendar events in a date range. Dates are ISO datetime strings."""
|
||||
config = await get_caldav_config(user_id)
|
||||
if not (config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")):
|
||||
raise ValueError("CalDAV is not configured. Go to Settings → Calendar to set it up.")
|
||||
|
||||
dt_from = datetime.fromisoformat(date_from)
|
||||
dt_to = datetime.fromisoformat(date_to)
|
||||
|
||||
def _search():
|
||||
client = _make_client(config)
|
||||
calendar = _get_calendar(client, config.get("caldav_calendar_name", ""))
|
||||
return calendar.date_search(dt_from, dt_to)
|
||||
|
||||
results = await asyncio.to_thread(_search)
|
||||
|
||||
events = []
|
||||
for result in results:
|
||||
cal = icalendar.Calendar.from_ical(result.data)
|
||||
for component in cal.walk():
|
||||
parsed = _parse_vevent(component)
|
||||
if parsed:
|
||||
events.append(parsed)
|
||||
return events
|
||||
|
||||
|
||||
async def search_events(user_id: int, query: str, days_ahead: int = 90) -> list[dict]:
|
||||
"""Search events by keyword in the next N days."""
|
||||
now = datetime.now()
|
||||
date_from = now.isoformat()
|
||||
date_to = (now + timedelta(days=days_ahead)).isoformat()
|
||||
|
||||
all_events = await list_events(user_id, date_from, date_to)
|
||||
q = query.lower()
|
||||
return [
|
||||
e for e in all_events
|
||||
if q in e["title"].lower() or q in e.get("location", "").lower()
|
||||
]
|
||||
|
||||
|
||||
async def test_connection(user_id: int) -> dict:
|
||||
"""Test the CalDAV connection and return status."""
|
||||
config = await get_caldav_config(user_id)
|
||||
if not (config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")):
|
||||
return {"success": False, "error": "CalDAV is not configured. Please fill in URL, username, and password."}
|
||||
|
||||
def _test():
|
||||
client = _make_client(config)
|
||||
principal = client.principal()
|
||||
calendars = principal.calendars()
|
||||
return [c.name for c in calendars]
|
||||
|
||||
try:
|
||||
calendar_names = await asyncio.to_thread(_test)
|
||||
return {
|
||||
"success": True,
|
||||
"calendars": calendar_names,
|
||||
"message": f"Connected successfully. Found {len(calendar_names)} calendar(s).",
|
||||
}
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "401" in error_msg or "403" in error_msg or "Unauthorized" in error_msg:
|
||||
error_msg = "Authentication failed. Check your username and password."
|
||||
elif "404" in error_msg or "Not Found" in error_msg:
|
||||
error_msg = "CalDAV endpoint not found. Check your URL."
|
||||
elif "Connection" in error_msg or "resolve" in error_msg:
|
||||
error_msg = f"Connection failed: {error_msg}"
|
||||
return {"success": False, "error": error_msg}
|
||||
Reference in New Issue
Block a user