diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 508e7d8b8f..dcee725348 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -2810,3 +2810,423 @@ async def delete_automation( except Exception as e: log.exception(f'delete_automation error: {e}') return json.dumps({'error': str(e)}) + + +# ============================================================================= +# CALENDAR TOOLS +# ============================================================================= + + +def _get_user_tz(user_dict: dict): + """Get the user's timezone as a ZoneInfo, falling back to UTC.""" + from zoneinfo import ZoneInfo + + tz_name = None + if user_dict: + tz_name = user_dict.get('timezone') + if tz_name: + try: + return ZoneInfo(tz_name) + except Exception: + pass + return ZoneInfo('UTC') + + +def _dt_to_ns(dt_str: str, tz) -> int: + """Convert a datetime string to nanoseconds since epoch, interpreting in the given timezone.""" + from datetime import datetime + + dt = datetime.fromisoformat(dt_str) + # If naive (no timezone info), localize to user's timezone + if dt.tzinfo is None: + dt = dt.replace(tzinfo=tz) + return int(dt.timestamp() * 1_000) * 1_000_000 + + +def _ns_to_dt(ns: int, tz) -> str: + """Convert nanoseconds since epoch to a datetime string in the given timezone.""" + from datetime import datetime + + seconds = ns / 1_000_000_000 + dt = datetime.fromtimestamp(seconds, tz=tz) + return dt.strftime('%Y-%m-%d %H:%M') + + +def _event_to_dict(event, tz) -> dict: + """Convert a calendar event model to a human-friendly dict with local timestamps.""" + return { + 'id': event.id, + 'calendar_id': event.calendar_id, + 'title': event.title, + 'description': event.description or '', + 'start': _ns_to_dt(event.start_at, tz), + 'end': _ns_to_dt(event.end_at, tz) if event.end_at else None, + 'all_day': event.all_day, + 'location': event.location or '', + 'color': event.color, + 'is_cancelled': event.is_cancelled, + } + + + + + +async def search_calendar_events( + query: Optional[str] = None, + start: Optional[str] = None, + end: Optional[str] = None, + count: int = 10, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Search calendar events by text and/or date range. + Returns matching events across all accessible calendars. + + :param query: Search text to match against event title, description, or location (optional) + :param start: Only return events starting at or after this datetime, e.g. "2026-04-20 00:00" (optional) + :param end: Only return events starting before this datetime, e.g. "2026-04-27 00:00" (optional) + :param count: Maximum number of events to return (default: 10) + :return: JSON list of matching events with id, title, description, start, end, calendar_id, location + """ + if __request__ is None: + return json.dumps({'error': 'Request context not available'}) + + if not __user__: + return json.dumps({'error': 'User context not available'}) + + try: + from open_webui.models.calendar import CalendarEvents + + user_id = __user__.get('id') + tz = _get_user_tz(__user__) + + if isinstance(count, str): + try: + count = int(count) + except ValueError: + count = 10 + + if start or end: + # Date range query — use get_events_by_range + try: + start_ns = _dt_to_ns(start, tz) if start else 0 + except (ValueError, TypeError) as e: + return json.dumps({'error': f'Invalid start datetime: {e}'}) + + try: + end_ns = _dt_to_ns(end, tz) if end else int(time.time() * 1_000) * 1_000_000 + 365 * 86400 * 1_000_000_000_000 + except (ValueError, TypeError) as e: + return json.dumps({'error': f'Invalid end datetime: {e}'}) + + items = await CalendarEvents.get_events_by_range( + user_id=user_id, + start=start_ns, + end=end_ns, + ) + + # Apply text filter if query is also provided + if query: + q = query.lower() + items = [ + e for e in items + if q in (e.title or '').lower() + or q in (e.description or '').lower() + or q in (e.location or '').lower() + ] + + events = [_event_to_dict(item, tz) for item in items[:count]] + return json.dumps( + {'events': events, 'total': len(items)}, + ensure_ascii=False, + ) + else: + # Text-only search + result = await CalendarEvents.search_events( + user_id=user_id, + query=query, + skip=0, + limit=count, + ) + + events = [_event_to_dict(item, tz) for item in result.items] + return json.dumps( + {'events': events, 'total': result.total}, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f'search_calendar_events error: {e}') + return json.dumps({'error': str(e)}) + + +async def create_calendar_event( + title: str, + start: str, + end: Optional[str] = None, + description: Optional[str] = None, + calendar_id: Optional[str] = None, + all_day: bool = False, + location: Optional[str] = None, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Create a new calendar event. If no calendar_id is provided, the event is + added to the user's default calendar. + + :param title: Event title + :param start: Start datetime string in your local time (e.g. "2026-04-20 09:00" or "2026-04-20T09:00:00") + :param end: End datetime string in your local time (optional, omit for point-in-time events) + :param description: Event description (optional) + :param calendar_id: Target calendar ID (optional, uses default calendar if omitted) + :param all_day: Whether this is an all-day event (default: false) + :param location: Event location (optional) + :return: JSON with the created event details including id + """ + if __request__ is None: + return json.dumps({'error': 'Request context not available'}) + + if not __user__: + return json.dumps({'error': 'User context not available'}) + + try: + from open_webui.models.calendar import Calendars, CalendarEvents, CalendarEventForm + + user_id = __user__.get('id') + + # Resolve calendar_id: use provided, or fall back to default + if not calendar_id: + calendars = await Calendars.get_calendars_by_user(user_id) + default_cal = next((c for c in calendars if c.is_default), None) + if not default_cal and calendars: + default_cal = calendars[0] + if not default_cal: + return json.dumps({'error': 'No calendars found. Cannot create event.'}) + calendar_id = default_cal.id + + # Verify access + cal = await Calendars.get_calendar_by_id(calendar_id) + if not cal: + return json.dumps({'error': 'Calendar not found'}) + if cal.user_id != user_id and __user__.get('role') != 'admin': + from open_webui.models.access_grants import AccessGrants + from open_webui.models.groups import Groups + + user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)] + if not await AccessGrants.has_access( + user_id=user_id, + resource_type='calendar', + resource_id=cal.id, + permission='write', + user_group_ids=set(user_group_ids), + ): + return json.dumps({'error': 'Access denied to this calendar'}) + + # Coerce boolean from LLM + if isinstance(all_day, str): + all_day = all_day.lower() in ('true', '1', 'yes') + + # Convert datetime strings to nanoseconds using user's timezone + tz = _get_user_tz(__user__) + try: + start_ns = _dt_to_ns(start, tz) + except (ValueError, TypeError) as e: + return json.dumps({'error': f'Invalid start datetime: {e}. Use format like "2026-04-20 09:00"'}) + + end_ns = None + if end: + try: + end_ns = _dt_to_ns(end, tz) + except (ValueError, TypeError) as e: + return json.dumps({'error': f'Invalid end datetime: {e}. Use format like "2026-04-20 10:00"'}) + elif not all_day: + # Default to 1 hour duration + end_ns = start_ns + 3_600_000_000_000 + + form = CalendarEventForm( + calendar_id=calendar_id, + title=title, + description=description, + start_at=start_ns, + end_at=end_ns, + all_day=all_day, + location=location, + ) + + event = await CalendarEvents.insert_new_event(user_id, form) + if not event: + return json.dumps({'error': 'Failed to create event'}) + + return json.dumps( + { + 'status': 'success', + **_event_to_dict(event, tz), + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f'create_calendar_event error: {e}') + return json.dumps({'error': str(e)}) + + +async def update_calendar_event( + event_id: str, + title: Optional[str] = None, + description: Optional[str] = None, + start: Optional[str] = None, + end: Optional[str] = None, + all_day: Optional[bool] = None, + location: Optional[str] = None, + is_cancelled: Optional[bool] = None, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Update an existing calendar event. Only provided fields are changed; + omitted fields stay the same. + + :param event_id: The ID of the event to update + :param title: New event title (optional) + :param description: New event description (optional) + :param start: New start datetime string in your local time, e.g. "2026-04-20 09:00" (optional) + :param end: New end datetime string in your local time (optional) + :param all_day: Whether this is an all-day event (optional) + :param location: New event location (optional) + :param is_cancelled: Set to true to cancel the event (optional) + :return: JSON with the updated event details + """ + if __request__ is None: + return json.dumps({'error': 'Request context not available'}) + + if not __user__: + return json.dumps({'error': 'User context not available'}) + + try: + from open_webui.models.calendar import Calendars, CalendarEvents, CalendarEventUpdateForm + from open_webui.models.access_grants import AccessGrants + from open_webui.models.groups import Groups + + user_id = __user__.get('id') + + event = await CalendarEvents.get_event_by_id(event_id) + if not event: + return json.dumps({'error': 'Event not found'}) + + # Check write access to the event's calendar + cal = await Calendars.get_calendar_by_id(event.calendar_id) + if cal and cal.user_id != user_id and __user__.get('role') != 'admin': + user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)] + if not await AccessGrants.has_access( + user_id=user_id, + resource_type='calendar', + resource_id=cal.id, + permission='write', + user_group_ids=set(user_group_ids), + ): + return json.dumps({'error': 'Access denied'}) + + # Coerce boolean strings from LLM + if isinstance(all_day, str): + all_day = all_day.lower() in ('true', '1', 'yes') + if isinstance(is_cancelled, str): + is_cancelled = is_cancelled.lower() in ('true', '1', 'yes') + + # Convert datetime strings to nanoseconds using user's timezone + tz = _get_user_tz(__user__) + start_ns = None + if start is not None: + try: + start_ns = _dt_to_ns(start, tz) + except (ValueError, TypeError) as e: + return json.dumps({'error': f'Invalid start datetime: {e}'}) + + end_ns = None + if end is not None: + try: + end_ns = _dt_to_ns(end, tz) + except (ValueError, TypeError) as e: + return json.dumps({'error': f'Invalid end datetime: {e}'}) + + form = CalendarEventUpdateForm( + title=title, + description=description, + start_at=start_ns, + end_at=end_ns, + all_day=all_day, + location=location, + is_cancelled=is_cancelled, + ) + + updated = await CalendarEvents.update_event_by_id(event_id, form) + if not updated: + return json.dumps({'error': 'Failed to update event'}) + + return json.dumps( + { + 'status': 'success', + **_event_to_dict(updated, tz), + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f'update_calendar_event error: {e}') + return json.dumps({'error': str(e)}) + + +async def delete_calendar_event( + event_id: str, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Delete a calendar event permanently. + + :param event_id: The ID of the event to delete + :return: JSON confirming the event was deleted + """ + if __request__ is None: + return json.dumps({'error': 'Request context not available'}) + + if not __user__: + return json.dumps({'error': 'User context not available'}) + + try: + from open_webui.models.calendar import Calendars, CalendarEvents + from open_webui.models.access_grants import AccessGrants + from open_webui.models.groups import Groups + + user_id = __user__.get('id') + + event = await CalendarEvents.get_event_by_id(event_id) + if not event: + return json.dumps({'error': 'Event not found'}) + + # Check write access + cal = await Calendars.get_calendar_by_id(event.calendar_id) + if cal and cal.user_id != user_id and __user__.get('role') != 'admin': + user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)] + if not await AccessGrants.has_access( + user_id=user_id, + resource_type='calendar', + resource_id=cal.id, + permission='write', + user_group_ids=set(user_group_ids), + ): + return json.dumps({'error': 'Access denied'}) + + title = event.title + result = await CalendarEvents.delete_event_by_id(event_id) + if not result: + return json.dumps({'error': 'Failed to delete event'}) + + return json.dumps( + { + 'status': 'success', + 'message': f'Event "{title}" deleted', + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f'delete_calendar_event error: {e}') + return json.dumps({'error': str(e)}) + diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index da817c4741..52a8868391 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -93,6 +93,10 @@ from open_webui.tools.builtin import ( list_automations, toggle_automation, delete_automation, + search_calendar_events, + create_calendar_event, + update_calendar_event, + delete_calendar_event, ) import copy @@ -552,6 +556,12 @@ async def get_builtin_tools( [create_automation, update_automation, list_automations, toggle_automation, delete_automation] ) + # Calendar tools - search/create/update/delete events + if is_builtin_tool_enabled('calendar'): + builtin_functions.extend( + [search_calendar_events, create_calendar_event, update_calendar_event, delete_calendar_event] + ) + for func in builtin_functions: callable = await get_async_tool_function_and_apply_extra_params( func, diff --git a/src/lib/components/calendar/CalendarEventModal.svelte b/src/lib/components/calendar/CalendarEventModal.svelte index 6e334e9cc6..5540bda2fe 100644 --- a/src/lib/components/calendar/CalendarEventModal.svelte +++ b/src/lib/components/calendar/CalendarEventModal.svelte @@ -3,6 +3,7 @@ import { toast } from 'svelte-sonner'; import Modal from '$lib/components/common/Modal.svelte'; + import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import XMark from '$lib/components/icons/XMark.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; @@ -28,6 +29,7 @@ let allDay = false; let location = ''; let loading = false; + let showDeleteConfirmDialog = false; const NS = 1_000_000; @@ -223,9 +225,9 @@