feat(events): recurring expansion, CalDAV pull sync, past search, reminders
- RRULE expansion: list_events now expands recurring events into individual occurrences within the query window using python-dateutil - CalDAV pull sync: new caldav_sync.py + POST /api/events/sync route; imports remote events into the internal store by caldav_uid - Past event search: search_events accepts include_past=true to search historical events; exposed in the LLM tool definition - Internal reminders: migration 0037 adds reminder_minutes + reminder_sent_at columns; event_scheduler.py checks every 5 min and fires push notifications; CalDAV sync job runs hourly - reminder_minutes now stored and returned in create/update routes + tools Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
"""Add reminder_minutes and reminder_sent_at to events.
|
||||
|
||||
Revision ID: 0037
|
||||
Revises: 0036
|
||||
Create Date: 2026-04-04
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0037"
|
||||
down_revision = "0036"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("events", sa.Column("reminder_minutes", sa.Integer(), nullable=True))
|
||||
op.add_column(
|
||||
"events",
|
||||
sa.Column("reminder_sent_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("events", "reminder_sent_at")
|
||||
op.drop_column("events", "reminder_minutes")
|
||||
@@ -317,6 +317,10 @@ def create_app() -> Quart:
|
||||
from fabledassistant.services.briefing_scheduler import start_briefing_scheduler
|
||||
await start_briefing_scheduler(asyncio.get_running_loop())
|
||||
|
||||
# Start event scheduler (reminders + CalDAV pull sync)
|
||||
from fabledassistant.services.event_scheduler import start_event_scheduler
|
||||
start_event_scheduler(asyncio.get_running_loop())
|
||||
|
||||
# Voice model loading (enabled via Admin → Config in the UI, or VOICE_ENABLED env var)
|
||||
from fabledassistant.services.stt import load_stt_model
|
||||
from fabledassistant.services.tts import load_tts_model
|
||||
@@ -327,6 +331,8 @@ def create_app() -> Quart:
|
||||
async def shutdown():
|
||||
from fabledassistant.services.briefing_scheduler import stop_briefing_scheduler
|
||||
stop_briefing_scheduler()
|
||||
from fabledassistant.services.event_scheduler import stop_event_scheduler
|
||||
stop_event_scheduler()
|
||||
|
||||
@app.route("/")
|
||||
async def serve_index():
|
||||
|
||||
@@ -27,6 +27,8 @@ class Event(Base):
|
||||
caldav_uid: Mapped[str] = mapped_column(Text, default="")
|
||||
color: Mapped[str] = mapped_column(Text, default="")
|
||||
recurrence: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
reminder_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
reminder_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
@@ -51,6 +53,7 @@ class Event(Base):
|
||||
"location": self.location,
|
||||
"color": self.color,
|
||||
"recurrence": self.recurrence,
|
||||
"reminder_minutes": self.reminder_minutes,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ async def list_events():
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
return jsonify([e.to_dict() for e in events])
|
||||
return jsonify(events)
|
||||
|
||||
|
||||
@events_bp.post("")
|
||||
@@ -65,6 +65,7 @@ async def create_event():
|
||||
color=data.get("color", ""),
|
||||
recurrence=data.get("recurrence"),
|
||||
project_id=data.get("project_id"),
|
||||
reminder_minutes=data.get("reminder_minutes"),
|
||||
)
|
||||
return jsonify(event.to_dict()), 201
|
||||
|
||||
@@ -92,7 +93,7 @@ async def update_event(event_id: int):
|
||||
for bool_field in ("all_day",):
|
||||
if bool_field in data:
|
||||
fields[bool_field] = data[bool_field]
|
||||
for int_field in ("project_id",):
|
||||
for int_field in ("project_id", "reminder_minutes"):
|
||||
if int_field in data:
|
||||
fields[int_field] = data[int_field]
|
||||
for dt_field in ("start_dt", "end_dt"):
|
||||
@@ -129,3 +130,12 @@ async def delete_event(event_id: int):
|
||||
event_id=event_id,
|
||||
)
|
||||
return "", 204
|
||||
|
||||
|
||||
@events_bp.post("/sync")
|
||||
@login_required
|
||||
async def sync_caldav():
|
||||
"""Trigger a CalDAV pull sync for the current user."""
|
||||
from fabledassistant.services.caldav_sync import sync_user_events
|
||||
result = await sync_user_events(user_id=_get_current_user_id())
|
||||
return jsonify(result)
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""CalDAV pull sync — imports remote events into the internal event store.
|
||||
|
||||
Runs as a scheduled job (hourly) and is also callable via the API.
|
||||
Only syncs events in a rolling 30-day-past / 180-day-future window.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.event import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SYNC_PAST_DAYS = 30
|
||||
_SYNC_FUTURE_DAYS = 180
|
||||
|
||||
|
||||
def _parse_dt(val: Any) -> datetime | None:
|
||||
"""Convert a date or datetime from an iCal component to a UTC-aware datetime."""
|
||||
if val is None:
|
||||
return None
|
||||
import datetime as _dt_mod
|
||||
if isinstance(val, _dt_mod.datetime):
|
||||
if val.tzinfo is None:
|
||||
return val.replace(tzinfo=timezone.utc)
|
||||
return val.astimezone(timezone.utc)
|
||||
if isinstance(val, _dt_mod.date):
|
||||
# All-day date: treat as midnight UTC
|
||||
return datetime(val.year, val.month, val.day, tzinfo=timezone.utc)
|
||||
return None
|
||||
|
||||
|
||||
def _sync_one_user(config: dict[str, str], user_id: int) -> list[dict]:
|
||||
"""Synchronous CalDAV fetch — runs in a thread executor."""
|
||||
import caldav # noqa: PLC0415
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
range_start = now - timedelta(days=_SYNC_PAST_DAYS)
|
||||
range_end = now + timedelta(days=_SYNC_FUTURE_DAYS)
|
||||
|
||||
client = caldav.DAVClient(
|
||||
url=config["caldav_url"],
|
||||
username=config.get("caldav_username") or None,
|
||||
password=config.get("caldav_password") or None,
|
||||
)
|
||||
principal = client.principal()
|
||||
calendars = principal.calendars()
|
||||
if not calendars:
|
||||
return []
|
||||
|
||||
cal_name = config.get("caldav_calendar_name", "")
|
||||
if cal_name:
|
||||
calendars = [c for c in calendars if c.name == cal_name] or calendars
|
||||
|
||||
events: list[dict] = []
|
||||
for calendar in calendars:
|
||||
try:
|
||||
results = calendar.date_search(start=range_start, end=range_end, expand=False)
|
||||
except Exception:
|
||||
logger.warning("CalDAV date_search failed for calendar %s", getattr(calendar, "name", "?"), exc_info=True)
|
||||
continue
|
||||
for vevent_obj in results:
|
||||
try:
|
||||
ical = vevent_obj.icalendar_instance
|
||||
for component in ical.walk():
|
||||
if component.name != "VEVENT":
|
||||
continue
|
||||
dtstart = component.get("DTSTART")
|
||||
dtend = component.get("DTEND")
|
||||
uid = str(component.get("UID", ""))
|
||||
if not uid:
|
||||
continue
|
||||
start_dt = _parse_dt(dtstart.dt if dtstart else None)
|
||||
end_dt = _parse_dt(dtend.dt if dtend else None)
|
||||
if start_dt is None:
|
||||
continue
|
||||
|
||||
import datetime as _dt_mod
|
||||
all_day = dtstart and isinstance(dtstart.dt, _dt_mod.date) and not isinstance(dtstart.dt, _dt_mod.datetime)
|
||||
|
||||
rrule = component.get("RRULE")
|
||||
recurrence = rrule.to_ical().decode("utf-8") if rrule else None
|
||||
|
||||
events.append({
|
||||
"caldav_uid": uid,
|
||||
"title": str(component.get("SUMMARY", "")),
|
||||
"start_dt": start_dt,
|
||||
"end_dt": end_dt,
|
||||
"all_day": bool(all_day),
|
||||
"description": str(component.get("DESCRIPTION", "")),
|
||||
"location": str(component.get("LOCATION", "")),
|
||||
"recurrence": recurrence,
|
||||
})
|
||||
except Exception:
|
||||
logger.debug("Failed to parse CalDAV event", exc_info=True)
|
||||
|
||||
return events
|
||||
|
||||
|
||||
async def sync_user_events(user_id: int) -> dict:
|
||||
"""Pull CalDAV events for one user and upsert into the DB.
|
||||
|
||||
Returns a summary dict: {created, updated, unchanged}.
|
||||
"""
|
||||
from fabledassistant.services.caldav import get_caldav_config, is_caldav_configured # noqa: PLC0415
|
||||
|
||||
if not await is_caldav_configured(user_id):
|
||||
return {"skipped": True, "reason": "CalDAV not configured"}
|
||||
|
||||
config = await get_caldav_config(user_id)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
remote_events: list[dict] = await loop.run_in_executor(
|
||||
None, _sync_one_user, config, user_id
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("CalDAV pull sync failed for user %d", user_id, exc_info=True)
|
||||
return {"error": "CalDAV fetch failed"}
|
||||
|
||||
created = updated = unchanged = 0
|
||||
|
||||
async with async_session() as session:
|
||||
for ev in remote_events:
|
||||
caldav_uid = ev["caldav_uid"]
|
||||
result = await session.execute(
|
||||
select(Event).where(
|
||||
Event.user_id == user_id,
|
||||
Event.caldav_uid == caldav_uid,
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing is None:
|
||||
# Create new event
|
||||
new_ev = Event(
|
||||
user_id=user_id,
|
||||
uid=str(uuid.uuid4()),
|
||||
caldav_uid=caldav_uid,
|
||||
title=ev["title"],
|
||||
start_dt=ev["start_dt"],
|
||||
end_dt=ev["end_dt"],
|
||||
all_day=ev["all_day"],
|
||||
description=ev["description"],
|
||||
location=ev["location"],
|
||||
recurrence=ev["recurrence"],
|
||||
)
|
||||
session.add(new_ev)
|
||||
created += 1
|
||||
else:
|
||||
# Update if anything changed
|
||||
changed = False
|
||||
for field in ("title", "start_dt", "end_dt", "all_day", "description", "location", "recurrence"):
|
||||
if getattr(existing, field) != ev[field]:
|
||||
setattr(existing, field, ev[field])
|
||||
changed = True
|
||||
if changed:
|
||||
updated += 1
|
||||
else:
|
||||
unchanged += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"CalDAV sync user %d: %d created, %d updated, %d unchanged",
|
||||
user_id, created, updated, unchanged,
|
||||
)
|
||||
return {"created": created, "updated": updated, "unchanged": unchanged}
|
||||
|
||||
|
||||
async def sync_all_users() -> None:
|
||||
"""Pull CalDAV events for all users with CalDAV configured."""
|
||||
from sqlalchemy import select as sa_select # noqa: PLC0415
|
||||
|
||||
from fabledassistant.models.user import User # noqa: PLC0415
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(sa_select(User.id))
|
||||
user_ids = [row[0] for row in result.all()]
|
||||
|
||||
for user_id in user_ids:
|
||||
try:
|
||||
await sync_user_events(user_id)
|
||||
except Exception:
|
||||
logger.warning("CalDAV sync failed for user %d", user_id, exc_info=True)
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Scheduler jobs for calendar events.
|
||||
|
||||
- Reminder notifications: checks every 5 minutes for due reminders.
|
||||
- CalDAV pull sync: runs every hour for all users with CalDAV configured.
|
||||
|
||||
Uses the same BackgroundScheduler pattern as briefing_scheduler.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.event import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_scheduler: BackgroundScheduler | None = None
|
||||
_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reminder job
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _fire_reminders() -> None:
|
||||
"""Find events with reminders due in the next 5 minutes and fire push notifications."""
|
||||
now = datetime.now(timezone.utc)
|
||||
window_end = now + timedelta(minutes=5)
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Event).where(
|
||||
Event.reminder_minutes.isnot(None),
|
||||
Event.reminder_sent_at.is_(None),
|
||||
Event.start_dt > now, # event hasn't started yet
|
||||
# reminder fires when now >= start_dt - reminder_minutes
|
||||
# i.e. start_dt <= now + reminder_minutes (approximated by window_end check)
|
||||
)
|
||||
)
|
||||
candidates = list(result.scalars().all())
|
||||
|
||||
to_notify: list[Event] = []
|
||||
for event in candidates:
|
||||
reminder_dt = event.start_dt - timedelta(minutes=event.reminder_minutes)
|
||||
if reminder_dt <= window_end:
|
||||
to_notify.append(event)
|
||||
|
||||
if not to_notify:
|
||||
return
|
||||
|
||||
async with async_session() as session:
|
||||
for event in to_notify:
|
||||
try:
|
||||
from fabledassistant.services.push import send_push_notification # noqa: PLC0415
|
||||
start_local = event.start_dt.strftime("%H:%M")
|
||||
await send_push_notification(
|
||||
user_id=event.user_id,
|
||||
title=f"Reminder: {event.title}",
|
||||
body=f"Starting at {start_local} UTC",
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to send reminder push for event %d", event.id, exc_info=True)
|
||||
|
||||
# Mark as sent regardless of push success to avoid re-firing
|
||||
result = await session.execute(
|
||||
select(Event).where(Event.id == event.id)
|
||||
)
|
||||
ev = result.scalar_one_or_none()
|
||||
if ev:
|
||||
ev.reminder_sent_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _run_reminders(loop: asyncio.AbstractEventLoop) -> None:
|
||||
asyncio.run_coroutine_threadsafe(_fire_reminders(), loop)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CalDAV pull sync job
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _run_caldav_sync() -> None:
|
||||
from fabledassistant.services.caldav_sync import sync_all_users # noqa: PLC0415
|
||||
try:
|
||||
await sync_all_users()
|
||||
except Exception:
|
||||
logger.warning("CalDAV pull sync job failed", exc_info=True)
|
||||
|
||||
|
||||
def _run_caldav_sync_threadsafe(loop: asyncio.AbstractEventLoop) -> None:
|
||||
asyncio.run_coroutine_threadsafe(_run_caldav_sync(), loop)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def start_event_scheduler(loop: asyncio.AbstractEventLoop) -> None:
|
||||
global _scheduler, _loop
|
||||
if _scheduler is not None:
|
||||
return
|
||||
_loop = loop
|
||||
_scheduler = BackgroundScheduler()
|
||||
|
||||
# Check reminders every 5 minutes
|
||||
_scheduler.add_job(
|
||||
_run_reminders,
|
||||
trigger=IntervalTrigger(minutes=5),
|
||||
args=[loop],
|
||||
id="event_reminders",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# CalDAV pull sync every hour
|
||||
_scheduler.add_job(
|
||||
_run_caldav_sync_threadsafe,
|
||||
trigger=IntervalTrigger(hours=1),
|
||||
args=[loop],
|
||||
id="caldav_pull_sync",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
_scheduler.start()
|
||||
logger.info("Event scheduler started (reminders every 5m, CalDAV sync every 1h)")
|
||||
|
||||
|
||||
def stop_event_scheduler() -> None:
|
||||
global _scheduler
|
||||
if _scheduler is not None:
|
||||
_scheduler.shutdown(wait=False)
|
||||
_scheduler = None
|
||||
logger.info("Event scheduler stopped")
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from dateutil.rrule import rrulestr
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
@@ -25,9 +26,9 @@ async def create_event(
|
||||
color: str = "",
|
||||
recurrence: str | None = None,
|
||||
project_id: int | None = None,
|
||||
reminder_minutes: int | None = None,
|
||||
# CalDAV-only fields (not stored in DB, forwarded to push)
|
||||
duration: int | None = None,
|
||||
reminder_minutes: int | None = None,
|
||||
attendees: list[str] | None = None,
|
||||
calendar_name: str | None = None,
|
||||
) -> Event:
|
||||
@@ -46,6 +47,7 @@ async def create_event(
|
||||
color=color,
|
||||
recurrence=recurrence,
|
||||
project_id=project_id,
|
||||
reminder_minutes=reminder_minutes,
|
||||
)
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
@@ -74,48 +76,87 @@ async def list_events(
|
||||
user_id: int,
|
||||
date_from: datetime,
|
||||
date_to: datetime,
|
||||
) -> list[Event]:
|
||||
) -> list[dict]:
|
||||
"""List events for user_id that overlap [date_from, date_to].
|
||||
|
||||
An event overlaps the range if it starts before date_to AND either
|
||||
has no end_dt (point event) or its end_dt is after date_from.
|
||||
This ensures multi-day events that span the query boundary are included.
|
||||
Recurring events (with an RRULE recurrence string) are expanded into
|
||||
individual occurrences within the range. Non-recurring events are
|
||||
returned as-is. All results are sorted by start time and returned as
|
||||
dicts (same shape as Event.to_dict()).
|
||||
"""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Event).where(
|
||||
Event.user_id == user_id,
|
||||
Event.start_dt <= date_to,
|
||||
# Base window: non-recurring events must overlap range;
|
||||
# recurring events always need to be fetched so they can be expanded.
|
||||
or_(
|
||||
Event.recurrence.isnot(None),
|
||||
Event.start_dt <= date_to,
|
||||
),
|
||||
or_(
|
||||
Event.end_dt.is_(None),
|
||||
Event.end_dt >= date_from,
|
||||
Event.recurrence.isnot(None),
|
||||
),
|
||||
).order_by(Event.start_dt)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
events = list(result.scalars().all())
|
||||
|
||||
items: list[dict] = []
|
||||
for event in events:
|
||||
if not event.recurrence:
|
||||
items.append(event.to_dict())
|
||||
continue
|
||||
|
||||
# Expand recurring event occurrences within [date_from, date_to]
|
||||
duration = (event.end_dt - event.start_dt) if event.end_dt else None
|
||||
try:
|
||||
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False)
|
||||
occurrences = rule.between(date_from, date_to, inc=True)
|
||||
except Exception:
|
||||
logger.warning("Failed to expand RRULE for event %d: %r", event.id, event.recurrence)
|
||||
items.append(event.to_dict())
|
||||
continue
|
||||
|
||||
base = event.to_dict()
|
||||
for occ in occurrences:
|
||||
# Ensure occurrence is UTC-aware
|
||||
if occ.tzinfo is None:
|
||||
occ = occ.replace(tzinfo=timezone.utc)
|
||||
occurrence_dict = dict(base)
|
||||
occurrence_dict["start_dt"] = occ.isoformat()
|
||||
if duration is not None:
|
||||
occurrence_dict["end_dt"] = (occ + duration).isoformat()
|
||||
items.append(occurrence_dict)
|
||||
|
||||
items.sort(key=lambda x: x["start_dt"])
|
||||
return items
|
||||
|
||||
|
||||
async def search_events(
|
||||
user_id: int,
|
||||
query: str,
|
||||
days_ahead: int = 90,
|
||||
include_past: bool = False,
|
||||
) -> list[Event]:
|
||||
"""Search events by keyword in title, description, or location."""
|
||||
now = datetime.now(timezone.utc)
|
||||
date_to = now + timedelta(days=days_ahead)
|
||||
q = f"%{query}%"
|
||||
async with async_session() as session:
|
||||
where = [
|
||||
Event.user_id == user_id,
|
||||
or_(
|
||||
Event.title.ilike(q),
|
||||
Event.description.ilike(q),
|
||||
Event.location.ilike(q),
|
||||
),
|
||||
]
|
||||
if not include_past:
|
||||
date_to = now + timedelta(days=days_ahead)
|
||||
where.extend([Event.start_dt >= now, Event.start_dt <= date_to])
|
||||
result = await session.execute(
|
||||
select(Event).where(
|
||||
Event.user_id == user_id,
|
||||
Event.start_dt >= now,
|
||||
Event.start_dt <= date_to,
|
||||
or_(
|
||||
Event.title.ilike(q),
|
||||
Event.description.ilike(q),
|
||||
Event.location.ilike(q),
|
||||
),
|
||||
).order_by(Event.start_dt)
|
||||
select(Event).where(*where).order_by(Event.start_dt)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -131,9 +172,9 @@ async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
|
||||
return None
|
||||
old_title = event.title # capture before mutation for CalDAV lookup
|
||||
allowed = {"title", "start_dt", "end_dt", "all_day", "description",
|
||||
"location", "color", "recurrence", "project_id"}
|
||||
"location", "color", "recurrence", "project_id", "reminder_minutes"}
|
||||
# Nullable fields that callers can explicitly set to None to clear
|
||||
nullable = {"end_dt", "recurrence", "project_id"}
|
||||
nullable = {"end_dt", "recurrence", "project_id", "reminder_minutes"}
|
||||
for key, value in fields.items():
|
||||
if key in allowed and (value is not None or key in nullable):
|
||||
setattr(event, key, value)
|
||||
|
||||
@@ -695,6 +695,10 @@ _CORE_TOOLS = [
|
||||
"type": "string",
|
||||
"description": "Search keyword to match against event titles, locations, and descriptions",
|
||||
},
|
||||
"include_past": {
|
||||
"type": "boolean",
|
||||
"description": "Set to true to include past events in results (default: future events only)",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
@@ -744,6 +748,10 @@ _CORE_TOOLS = [
|
||||
"type": "string",
|
||||
"description": "New iCalendar RRULE",
|
||||
},
|
||||
"reminder_minutes": {
|
||||
"type": "integer",
|
||||
"description": "Reminder N minutes before the event. Pass 0 to remove an existing reminder.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
@@ -1471,7 +1479,7 @@ async def execute_tool(
|
||||
"type": "events",
|
||||
"data": {
|
||||
"count": len(events),
|
||||
"events": [e.to_dict() for e in events],
|
||||
"events": events,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1479,6 +1487,7 @@ async def execute_tool(
|
||||
events = await events_search_events(
|
||||
user_id=user_id,
|
||||
query=arguments.get("query", ""),
|
||||
include_past=arguments.get("include_past", False),
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
@@ -1502,6 +1511,9 @@ async def execute_tool(
|
||||
fields[str_field] = arguments[str_field]
|
||||
if arguments.get("all_day") is not None:
|
||||
fields["all_day"] = arguments["all_day"]
|
||||
if "reminder_minutes" in arguments:
|
||||
rm = arguments["reminder_minutes"]
|
||||
fields["reminder_minutes"] = None if rm == 0 else rm
|
||||
for dt_field, key in (("start_dt", "start"), ("end_dt", "end")):
|
||||
val = arguments.get(key)
|
||||
if val:
|
||||
|
||||
Reference in New Issue
Block a user