feat(calendar): internal event store with FullCalendar UI and CalDAV push sync
- AI calendar tools now always available (moved from _CALDAV_TOOLS to _CORE_TOOLS); create/list/search/update/delete events go through the internal DB store first, with fire-and-forget CalDAV push sync when the user has CalDAV configured - Add EventEntry interface and typed API helpers (listEvents, createEvent, getEvent, updateEvent, deleteEvent) to client.ts - Install @fullcalendar/vue3, daygrid, timegrid, interaction, core packages - Add EventSlideOver.vue: create/edit/delete slide-over with title, start/end, all-day toggle, location, description, color picker, and project selector - Add CalendarView.vue: month/week/day FullCalendar with drag-drop and resize wired to PATCH /api/events/:id; click empty date opens create slide-over - Wire /calendar route, Calendar nav link in AppHeader, g+l keyboard shortcut Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,13 +7,16 @@ from datetime import date, datetime
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
from fabledassistant.services.caldav import (
|
||||
create_event,
|
||||
delete_event,
|
||||
is_caldav_configured,
|
||||
list_calendars,
|
||||
list_events,
|
||||
search_events,
|
||||
update_event,
|
||||
)
|
||||
from fabledassistant.services.events import (
|
||||
create_event as events_create_event,
|
||||
list_events as events_list_events,
|
||||
search_events as events_search_events,
|
||||
update_event as events_update_event,
|
||||
delete_event as events_delete_event,
|
||||
find_events_by_query,
|
||||
)
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.notes import create_note, delete_note, get_note_by_title, list_notes, update_note
|
||||
@@ -573,10 +576,6 @@ _CORE_TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# CalDAV tools — only included when user has CalDAV configured
|
||||
_CALDAV_TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -609,6 +608,10 @@ _CALDAV_TOOLS = [
|
||||
"type": "string",
|
||||
"description": "Optional event location",
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"description": "Optional hex color for the event (e.g. '#6366f1')",
|
||||
},
|
||||
"all_day": {
|
||||
"type": "boolean",
|
||||
"description": "Set to true for all-day events like birthdays, holidays, deadlines (default false)",
|
||||
@@ -626,14 +629,14 @@ _CALDAV_TOOLS = [
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of attendee email addresses",
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"description": "Optional IANA timezone (e.g. 'America/New_York'). Falls back to user's caldav_timezone setting.",
|
||||
},
|
||||
"calendar_name": {
|
||||
"type": "string",
|
||||
"description": "Optional calendar name to create the event in. Falls back to default calendar.",
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Optional project name to associate this event with",
|
||||
},
|
||||
},
|
||||
"required": ["title", "start"],
|
||||
},
|
||||
@@ -701,6 +704,10 @@ _CALDAV_TOOLS = [
|
||||
"type": "string",
|
||||
"description": "New end datetime in ISO 8601 format",
|
||||
},
|
||||
"all_day": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the event is all-day",
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "New event description",
|
||||
@@ -709,13 +716,13 @@ _CALDAV_TOOLS = [
|
||||
"type": "string",
|
||||
"description": "New event location",
|
||||
},
|
||||
"timezone": {
|
||||
"color": {
|
||||
"type": "string",
|
||||
"description": "Optional IANA timezone (e.g. 'America/New_York')",
|
||||
"description": "New hex color for the event (e.g. '#6366f1')",
|
||||
},
|
||||
"calendar_name": {
|
||||
"recurrence": {
|
||||
"type": "string",
|
||||
"description": "Optional calendar name to search in",
|
||||
"description": "New iCalendar RRULE",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
@@ -734,15 +741,15 @@ _CALDAV_TOOLS = [
|
||||
"type": "string",
|
||||
"description": "Search term to find the event to delete (matches against title)",
|
||||
},
|
||||
"calendar_name": {
|
||||
"type": "string",
|
||||
"description": "Optional calendar name to search in",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# CalDAV tools — only included when user has CalDAV configured
|
||||
_CALDAV_TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -1223,17 +1230,45 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
}
|
||||
|
||||
elif tool_name == "create_event":
|
||||
result = await create_event(
|
||||
start_str = arguments["start"]
|
||||
end_str = arguments.get("end")
|
||||
all_day = arguments.get("all_day", False)
|
||||
# Parse start — accept date-only (YYYY-MM-DD) or full ISO datetime
|
||||
try:
|
||||
if "T" in start_str or " " in start_str:
|
||||
start_dt = datetime.fromisoformat(start_str)
|
||||
else:
|
||||
start_dt = datetime.fromisoformat(f"{start_str}T00:00:00")
|
||||
all_day = True
|
||||
except (ValueError, TypeError):
|
||||
return {"success": False, "error": f"Invalid start datetime: {start_str!r}"}
|
||||
end_dt = None
|
||||
if end_str:
|
||||
try:
|
||||
if "T" in end_str or " " in end_str:
|
||||
end_dt = datetime.fromisoformat(end_str)
|
||||
else:
|
||||
end_dt = datetime.fromisoformat(f"{end_str}T00:00:00")
|
||||
except (ValueError, TypeError):
|
||||
return {"success": False, "error": f"Invalid end datetime: {end_str!r}"}
|
||||
project_id = None
|
||||
project_name = arguments.get("project")
|
||||
if project_name:
|
||||
proj = await _resolve_project(user_id, project_name)
|
||||
if proj:
|
||||
project_id = proj.id
|
||||
event = await events_create_event(
|
||||
user_id=user_id,
|
||||
title=arguments.get("title", "Untitled Event"),
|
||||
start=arguments["start"],
|
||||
end=arguments.get("end"),
|
||||
duration=arguments.get("duration"),
|
||||
description=arguments.get("description"),
|
||||
location=arguments.get("location"),
|
||||
all_day=arguments.get("all_day", False),
|
||||
start_dt=start_dt,
|
||||
end_dt=end_dt,
|
||||
all_day=all_day,
|
||||
description=arguments.get("description") or "",
|
||||
location=arguments.get("location") or "",
|
||||
color=arguments.get("color") or "",
|
||||
recurrence=arguments.get("recurrence"),
|
||||
timezone=arguments.get("timezone"),
|
||||
project_id=project_id,
|
||||
duration=arguments.get("duration"),
|
||||
reminder_minutes=arguments.get("reminder_minutes"),
|
||||
attendees=arguments.get("attendees"),
|
||||
calendar_name=arguments.get("calendar_name"),
|
||||
@@ -1241,26 +1276,31 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
return {
|
||||
"success": True,
|
||||
"type": "event",
|
||||
"data": result,
|
||||
"data": event.to_dict(),
|
||||
}
|
||||
|
||||
elif tool_name == "list_events":
|
||||
events = await list_events(
|
||||
try:
|
||||
date_from = datetime.fromisoformat(arguments["date_from"])
|
||||
date_to = datetime.fromisoformat(arguments["date_to"])
|
||||
except (ValueError, TypeError, KeyError) as exc:
|
||||
return {"success": False, "error": f"Invalid date range: {exc}"}
|
||||
events = await events_list_events(
|
||||
user_id=user_id,
|
||||
date_from=arguments["date_from"],
|
||||
date_to=arguments["date_to"],
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "events",
|
||||
"data": {
|
||||
"count": len(events),
|
||||
"events": events,
|
||||
"events": [e.to_dict() for e in events],
|
||||
},
|
||||
}
|
||||
|
||||
elif tool_name == "search_events":
|
||||
events = await search_events(
|
||||
events = await events_search_events(
|
||||
user_id=user_id,
|
||||
query=arguments.get("query", ""),
|
||||
)
|
||||
@@ -1270,38 +1310,53 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
"data": {
|
||||
"query": arguments.get("query", ""),
|
||||
"count": len(events),
|
||||
"events": events,
|
||||
"events": [e.to_dict() for e in events],
|
||||
},
|
||||
}
|
||||
|
||||
elif tool_name == "update_event":
|
||||
result = await update_event(
|
||||
query = arguments.get("query", "")
|
||||
matches = await find_events_by_query(user_id=user_id, query=query)
|
||||
if not matches:
|
||||
return {"success": False, "error": f"No event found matching '{query}'."}
|
||||
event_to_update = matches[0]
|
||||
fields: dict = {}
|
||||
for str_field in ("title", "description", "location", "color", "recurrence"):
|
||||
if arguments.get(str_field) is not None:
|
||||
fields[str_field] = arguments[str_field]
|
||||
if arguments.get("all_day") is not None:
|
||||
fields["all_day"] = arguments["all_day"]
|
||||
for dt_field, key in (("start_dt", "start"), ("end_dt", "end")):
|
||||
val = arguments.get(key)
|
||||
if val:
|
||||
try:
|
||||
fields[dt_field] = datetime.fromisoformat(val)
|
||||
except (ValueError, TypeError):
|
||||
return {"success": False, "error": f"Invalid datetime for {key}: {val!r}"}
|
||||
updated = await events_update_event(
|
||||
user_id=user_id,
|
||||
query=arguments["query"],
|
||||
title=arguments.get("title"),
|
||||
start=arguments.get("start"),
|
||||
end=arguments.get("end"),
|
||||
description=arguments.get("description"),
|
||||
location=arguments.get("location"),
|
||||
timezone=arguments.get("timezone"),
|
||||
calendar_name=arguments.get("calendar_name"),
|
||||
event_id=event_to_update.id,
|
||||
**fields,
|
||||
)
|
||||
if updated is None:
|
||||
return {"success": False, "error": "Event not found or update failed."}
|
||||
return {
|
||||
"success": True,
|
||||
"type": "event_updated",
|
||||
"data": result,
|
||||
"data": updated.to_dict(),
|
||||
}
|
||||
|
||||
elif tool_name == "delete_event":
|
||||
result = await delete_event(
|
||||
user_id=user_id,
|
||||
query=arguments["query"],
|
||||
calendar_name=arguments.get("calendar_name"),
|
||||
)
|
||||
query = arguments.get("query", "")
|
||||
matches = await find_events_by_query(user_id=user_id, query=query)
|
||||
if not matches:
|
||||
return {"success": False, "error": f"No event found matching '{query}'."}
|
||||
event_to_delete = matches[0]
|
||||
await events_delete_event(user_id=user_id, event_id=event_to_delete.id)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "event_deleted",
|
||||
"data": result,
|
||||
"data": {"id": event_to_delete.id, "title": event_to_delete.title},
|
||||
}
|
||||
|
||||
elif tool_name == "list_calendars":
|
||||
|
||||
Reference in New Issue
Block a user