Files
mcp-nextcloud/nextcloud_mcp_server/models/calendar.py
T
Chris CoutinhoandClaude Opus 4.7 9d5ac01f24 fix(calendar): preserve floating/TZID semantics across CalDAV roundtrip (#782)
The CalDAV REPORT in `_search_events_by_date` unconditionally requested
server-side `<C:expand>`. Per RFC 4791 §9.6.5 the server then normalizes
every expanded DTSTART/DTEND to UTC `Z`, which destroyed two pieces of
information on the read path:

- RFC 5545 floating local times came back as fake-UTC (a `+00:00` suffix
  that did not match the stored value), so a 2:30 PM floating event was
  indistinguishable from a 14:30 UTC event in the MCP response.
- TZID-bound events lost their IANA TZID context — a "10am America/New_York"
  event came back as `14:00:00+00:00`, making it impossible for callers to
  reconstruct DST-aware recurrence semantics.

Replace `<C:expand>` with client-side recurrence expansion via the
`recurring-ical-events` library (promoted from transitive to direct dep),
so the wire response retains its original DTSTART format. Surface the
TZID parameter as new `start_tz`/`end_tz` fields on `CalendarEventSummary`.

Add an optional `timezone` (IANA name) parameter to `nc_calendar_create_event`
and `nc_calendar_update_event` so callers can pin a TZID for naive input;
the helper attaches `ZoneInfo(...)` and emits a paired `VTIMEZONE`
component. Naive input without `timezone` continues to store as RFC 5545
floating local time (with a warning logged). Offset-aware input continues
to store as UTC `Z`.

Drive-by: switch the update path's DTSTART/DTEND assignment from raw
`datetime` to `vDDDTypes(dt)` wrappers — the previous code produced invalid
iCal like `DTSTART:2026-05-14 10:00:00+00:00` for any TZ-aware update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:41:10 +02:00

279 lines
10 KiB
Python

"""Pydantic models for Calendar app responses."""
from typing import List, Optional
from pydantic import BaseModel, Field
from .base import BaseResponse, StatusResponse
class Calendar(BaseModel):
"""Model for a Nextcloud calendar."""
name: str = Field(description="Calendar name/ID")
display_name: str = Field(description="Calendar display name")
description: Optional[str] = Field(None, description="Calendar description")
color: Optional[str] = Field(None, description="Calendar color")
href: Optional[str] = Field(None, description="Calendar DAV href")
timezone: Optional[str] = Field(None, description="Calendar timezone")
enabled: bool = Field(default=True, description="Whether calendar is enabled")
ctag: Optional[str] = Field(None, description="Calendar tag for synchronization")
class CalendarEventSummary(BaseModel):
"""Model for calendar event summary (for lists)."""
uid: str = Field(description="Event UID")
summary: str = Field(description="Event summary/title")
start: str = Field(
description=(
"Event start datetime (ISO format). No suffix = RFC 5545 floating "
"local time; ``+00:00`` = UTC; an explicit offset (e.g. ``-04:00``) "
"= TZID-bound at that instant. The IANA TZID name is exposed "
"separately as ``start_tz``."
)
)
end: Optional[str] = Field(
None,
description=(
"Event end datetime (ISO format). Same encoding rules as ``start``."
),
)
start_tz: Optional[str] = Field(
None,
description=(
"IANA timezone name when DTSTART had a TZID parameter (e.g. "
"``America/New_York``). ``None`` for floating local or UTC."
),
)
end_tz: Optional[str] = Field(
None, description="IANA timezone name when DTEND had a TZID parameter."
)
all_day: bool = Field(default=False, description="Whether event is all-day")
location: Optional[str] = Field(None, description="Event location")
description: Optional[str] = Field(None, description="Event description")
categories: List[str] = Field(default_factory=list, description="Event categories")
status: Optional[str] = Field(
None, description="Event status (CONFIRMED, TENTATIVE, CANCELLED)"
)
calendar_name: Optional[str] = Field(
None, description="Calendar containing this event"
)
calendar_display_name: Optional[str] = Field(
None, description="Display name of calendar containing this event"
)
class CalendarEvent(CalendarEventSummary):
"""Model for a complete calendar event."""
created: Optional[str] = Field(None, description="Event creation datetime")
last_modified: Optional[str] = Field(None, description="Last modification datetime")
recurring: bool = Field(default=False, description="Whether event is recurring")
recurrence_rule: Optional[str] = Field(None, description="RFC5545 recurrence rule")
recurrence_end: Optional[str] = Field(None, description="Recurrence end date")
attendees: List[str] = Field(
default_factory=list, description="List of attendee email addresses"
)
organizer: Optional[str] = Field(None, description="Event organizer")
priority: Optional[int] = Field(None, description="Event priority (1-9)")
privacy: Optional[str] = Field(None, description="Event privacy level")
url: Optional[str] = Field(None, description="Event URL")
duration_minutes: Optional[int] = Field(
None, description="Event duration in minutes"
)
reminder_minutes: Optional[int] = Field(
None, description="Reminder time in minutes before event"
)
reminder_email: bool = Field(
default=False, description="Whether to send email reminder"
)
color: Optional[str] = Field(None, description="Event color")
etag: Optional[str] = Field(None, description="ETag for versioning")
class CreateEventResponse(BaseResponse):
"""Response model for event creation."""
event: CalendarEvent = Field(description="The created event")
calendar_name: str = Field(
description="Name of the calendar the event was created in"
)
class UpdateEventResponse(BaseResponse):
"""Response model for event updates."""
event: CalendarEvent = Field(description="The updated event")
calendar_name: str = Field(description="Name of the calendar the event belongs to")
class DeleteEventResponse(StatusResponse):
"""Response model for event deletion."""
deleted_uid: str = Field(description="UID of the deleted event")
calendar_name: str = Field(
description="Name of the calendar the event was deleted from"
)
class ListEventsResponse(BaseResponse):
"""Response model for listing events."""
events: List[CalendarEventSummary] = Field(description="List of events")
calendar_name: Optional[str] = Field(
None, description="Calendar name (if filtered to one calendar)"
)
start_date: Optional[str] = Field(None, description="Start date filter applied")
end_date: Optional[str] = Field(None, description="End date filter applied")
total_found: int = Field(description="Total number of events found")
class ListCalendarsResponse(BaseResponse):
"""Response model for listing calendars."""
calendars: List[Calendar] = Field(description="List of available calendars")
total_count: int = Field(description="Total number of calendars")
class AvailabilitySlot(BaseModel):
"""Model for an available time slot."""
start: str = Field(description="Slot start datetime (ISO format)")
end: str = Field(description="Slot end datetime (ISO format)")
duration_minutes: int = Field(description="Slot duration in minutes")
date: str = Field(description="Date of the slot (YYYY-MM-DD)")
class FindAvailabilityResponse(BaseResponse):
"""Response model for finding availability."""
available_slots: List[AvailabilitySlot] = Field(
description="List of available time slots"
)
duration_requested: int = Field(description="Requested duration in minutes")
date_range_start: str = Field(description="Start date of search range")
date_range_end: str = Field(description="End date of search range")
attendees_checked: List[str] = Field(
default_factory=list, description="Attendees checked for availability"
)
business_hours_only: bool = Field(
description="Whether search was limited to business hours"
)
class BulkOperationResult(BaseModel):
"""Model for bulk operation results."""
operation: str = Field(description="Operation performed (update, delete, move)")
events_processed: int = Field(description="Number of events processed")
events_successful: int = Field(
description="Number of events successfully processed"
)
events_failed: int = Field(description="Number of events that failed processing")
failed_events: List[str] = Field(
default_factory=list, description="UIDs of events that failed"
)
errors: List[str] = Field(default_factory=list, description="Error messages")
class BulkOperationResponse(BaseResponse):
"""Response model for bulk operations."""
result: BulkOperationResult = Field(description="Bulk operation result")
class CreateMeetingResponse(CreateEventResponse):
"""Response model for meeting creation (same as event creation)."""
pass
class UpcomingEventsResponse(BaseResponse):
"""Response model for upcoming events."""
events: List[CalendarEventSummary] = Field(description="List of upcoming events")
days_ahead: int = Field(description="Number of days ahead searched")
calendar_name: Optional[str] = Field(
None, description="Calendar name (if filtered to one calendar)"
)
class ManageCalendarResponse(BaseResponse):
"""Response model for calendar management operations."""
action: str = Field(description="Action performed (create, delete, update, list)")
calendar: Optional[Calendar] = Field(None, description="Calendar that was affected")
calendars: Optional[List[Calendar]] = Field(
None, description="List of calendars (for list action)"
)
message: str = Field(description="Success message")
# ============= Todo/Task Models =============
class Todo(BaseModel):
"""Model for a CalDAV todo/task (VTODO)."""
uid: str = Field(description="Todo UID")
summary: str = Field(description="Todo summary/title")
description: str = Field(default="", description="Todo description")
status: str = Field(
default="NEEDS-ACTION",
description="Todo status: NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED",
)
priority: int = Field(
default=0, description="Todo priority (0=undefined, 1=highest, 9=lowest)"
)
percent_complete: int = Field(default=0, description="Percentage complete (0-100)")
due: Optional[str] = Field(None, description="Due date/time (ISO format)")
dtstart: Optional[str] = Field(None, description="Start date/time (ISO format)")
completed: Optional[str] = Field(
None, description="Completion timestamp (ISO format)"
)
categories: str = Field(default="", description="Comma-separated categories")
href: str = Field(default="", description="CalDAV href")
etag: str = Field(default="", description="ETag for versioning")
calendar_name: Optional[str] = Field(
None, description="Calendar containing this todo"
)
calendar_display_name: Optional[str] = Field(
None, description="Display name of calendar containing this todo"
)
class ListTodosResponse(BaseResponse):
"""Response model for listing todos."""
todos: List[Todo] = Field(description="List of todos/tasks")
calendar_name: Optional[str] = Field(
None, description="Calendar name (if filtered to one calendar)"
)
total_count: int = Field(description="Total number of todos found")
class CreateTodoResponse(BaseResponse):
"""Response model for todo creation."""
todo: Todo = Field(description="The created todo")
calendar_name: str = Field(
description="Name of the calendar the todo was created in"
)
class UpdateTodoResponse(BaseResponse):
"""Response model for todo updates."""
todo: Todo = Field(description="The updated todo")
calendar_name: str = Field(description="Name of the calendar the todo belongs to")
class DeleteTodoResponse(StatusResponse):
"""Response model for todo deletion."""
deleted_uid: str = Field(description="UID of the deleted todo")
calendar_name: str = Field(
description="Name of the calendar the todo was deleted from"
)