diff --git a/nextcloud_mcp_server/client/calendar.py b/nextcloud_mcp_server/client/calendar.py index a7f24252a..21f53b599 100644 --- a/nextcloud_mcp_server/client/calendar.py +++ b/nextcloud_mcp_server/client/calendar.py @@ -846,6 +846,162 @@ def _parse_event_datetime( ) return parsed, None + + @staticmethod + def _decode_ical_value(value: Any) -> str: + """Return a JSON-friendly RFC5545 value string.""" + if value is None: + return "" + if hasattr(value, "to_ical"): + raw = value.to_ical() + if isinstance(raw, bytes): + return raw.decode("utf-8") + return str(raw) + return str(value) + + @staticmethod + def _parse_alarm_datetime(value: str) -> dt.datetime: + """Parse an absolute alarm trigger datetime from ISO or basic iCalendar.""" + cleaned = value.strip().replace("Z", "+00:00") + try: + return dt.datetime.fromisoformat(cleaned) + except ValueError: + # RFC5545 basic format, with optional UTC suffix. + utc = value.strip().endswith("Z") + basic = value.strip().removesuffix("Z") + parsed = dt.datetime.strptime(basic, "%Y%m%dT%H%M%S") + if utc: + parsed = parsed.replace(tzinfo=dt.UTC) + return parsed + + @staticmethod + def _format_alarm_datetime(value: dt.datetime, tzid: str | None = None) -> str: + """Return an ISO datetime, preserving simple UTC±HH:MM TZIDs as offsets.""" + if value.tzinfo is not None: + return value.isoformat() + if tzid and tzid.startswith("UTC") and len(tzid) == 9: + sign = 1 if tzid[3] == "+" else -1 + try: + hours = int(tzid[4:6]) + minutes = int(tzid[7:9]) + except ValueError: + return value.isoformat() + offset = dt.timezone(sign * dt.timedelta(hours=hours, minutes=minutes)) + return value.replace(tzinfo=offset).isoformat() + return value.isoformat() + + def _extract_valarms(self, component: Any) -> list[dict[str, Any]]: + """Extract VALARM components as ordered, JSON-friendly reminder dicts.""" + reminders: list[dict[str, Any]] = [] + for index, alarm in enumerate( + sub for sub in component.subcomponents if sub.name == "VALARM" + ): + trigger = alarm.get("trigger") + trigger_dt = getattr(trigger, "dt", None) + reminder: dict[str, Any] = { + "index": index, + "action": str(alarm.get("action", "DISPLAY")), + } + + description = alarm.get("description") + if description is not None: + reminder["description"] = str(description) + + if trigger is not None: + reminder["trigger"] = self._decode_ical_value(trigger) + params = getattr(trigger, "params", {}) or {} + related = params.get("RELATED") + if related: + reminder["related"] = str(related) + value_param = params.get("VALUE") + if value_param: + reminder["value"] = str(value_param) + tzid = params.get("TZID") + if tzid: + reminder["trigger_tz"] = str(tzid) + + if isinstance(trigger_dt, dt.datetime): + reminder["trigger_at"] = self._format_alarm_datetime( + trigger_dt, str(tzid) if tzid else None + ) + elif isinstance(trigger_dt, dt.timedelta): + total_seconds = int(trigger_dt.total_seconds()) + reminder["offset_seconds"] = total_seconds + if total_seconds < 0 and total_seconds % 60 == 0: + reminder["minutes_before"] = abs(total_seconds) // 60 + + reminders.append(reminder) + return reminders + + def _build_valarm(self, reminder: dict[str, Any]) -> Alarm: + """Build a VALARM component from a reminder dict.""" + alarm = Alarm() + alarm.add("action", reminder.get("action", "DISPLAY")) + alarm.add("description", reminder.get("description", "Event reminder")) + + params: dict[str, str] = {} + related = reminder.get("related") + if related: + params["RELATED"] = str(related) + + if reminder.get("trigger_at"): + trigger_dt = self._parse_alarm_datetime(str(reminder["trigger_at"])) + params["VALUE"] = "DATE-TIME" + alarm.add("trigger", trigger_dt, parameters=params) + elif reminder.get("trigger"): + trigger_value = str(reminder["trigger"]) + if trigger_value.startswith(("P", "-P", "+P")): + alarm.add("trigger", vDDDTypes.from_ical(trigger_value), parameters=params) + else: + params["VALUE"] = "DATE-TIME" + alarm.add("trigger", self._parse_alarm_datetime(trigger_value), parameters=params) + elif "minutes_before" in reminder: + alarm.add( + "trigger", + dt.timedelta(minutes=-int(reminder["minutes_before"])), + parameters=params, + ) + elif "offset_seconds" in reminder: + alarm.add( + "trigger", + dt.timedelta(seconds=int(reminder["offset_seconds"])), + parameters=params, + ) + else: + alarm.add("trigger", dt.timedelta(minutes=-15), parameters=params) + + return alarm + + def _sync_valarms_by_index( + self, component: Any, reminders: list[dict[str, Any]] + ) -> None: + """Synchronize VALARM subcomponents as an ordered list.""" + component.subcomponents = [ + sub for sub in component.subcomponents if sub.name != "VALARM" + ] + for reminder in reminders: + component.add_component(self._build_valarm(reminder)) + + def _add_reminders_or_legacy_alarm( + self, component: Any, data: dict[str, Any], default_description: str + ) -> None: + """Apply explicit reminders, else backward-compatible reminder_minutes.""" + if "reminders" in data: + self._sync_valarms_by_index(component, data.get("reminders") or []) + return + + reminder_minutes = data.get("reminder_minutes", 0) + if reminder_minutes > 0: + component.add_component( + self._build_valarm( + { + "action": "DISPLAY", + "description": default_description, + "minutes_before": reminder_minutes, + } + ) + ) + def _create_ical_event(self, event_data: dict[str, Any], event_uid: str) -> str: """Create iCalendar content from event data.""" cal = Calendar() @@ -911,14 +1067,10 @@ def _create_ical_event(self, event_data: dict[str, Any], event_uid: str) -> str: if recurrence_rule: event.add("rrule", vRecur.from_ical(recurrence_rule)) - # Add alarms/reminders - reminder_minutes = event_data.get("reminder_minutes", 0) - if reminder_minutes > 0: - alarm = Alarm() - alarm.add("action", "DISPLAY") - alarm.add("description", "Event reminder") - alarm.add("trigger", dt.timedelta(minutes=-reminder_minutes)) - event.add_component(alarm) + # Add alarms/reminders. Explicit ``reminders`` is the full ordered + # VALARM list; ``reminder_minutes`` remains the backward-compatible + # shorthand for a single DISPLAY alarm. + self._add_reminders_or_legacy_alarm(event, event_data, "Event reminder") # Add attendees attendees = event_data.get("attendees", "") @@ -996,6 +1148,10 @@ def _extract_vevent_data(self, component) -> dict[str, Any]: if attendees: event_data["attendees"] = ",".join(attendees) + reminders = self._extract_valarms(component) + if reminders: + event_data["reminders"] = reminders + return event_data def _parse_ical_event(self, ical_text: str) -> dict[str, Any] | None: @@ -1064,20 +1220,25 @@ def _merge_ical_properties( if email.strip(): component.add("attendee", f"mailto:{email.strip()}") - # Handle reminder (VALARM) - if "reminder_minutes" in event_data: - component.subcomponents = [ - sub - for sub in component.subcomponents - if sub.name != "VALARM" - ] + # Handle reminders (VALARM). Omitted reminders preserve + # existing alarms; ``reminders: []`` clears them. + if "reminders" in event_data: + self._sync_valarms_by_index( + component, event_data.get("reminders") or [] + ) + elif "reminder_minutes" in event_data: + self._sync_valarms_by_index(component, []) minutes = event_data["reminder_minutes"] if minutes > 0: - alarm = Alarm() - alarm.add("action", "DISPLAY") - alarm.add("description", "Event reminder") - alarm.add("trigger", dt.timedelta(minutes=-minutes)) - component.add_component(alarm) + component.add_component( + self._build_valarm( + { + "action": "DISPLAY", + "description": "Event reminder", + "minutes_before": minutes, + } + ) + ) # Handle dates tz_name = event_data.get("timezone", "") @@ -1207,6 +1368,9 @@ def _create_ical_todo(self, todo_data: dict[str, Any], todo_uid: str) -> str: if categories: todo.add("categories", categories.split(",")) + # Add alarms/reminders + self._add_reminders_or_legacy_alarm(todo, todo_data, "Todo reminder") + # Add timestamps now = dt.datetime.now(dt.UTC) todo.add("created", now) @@ -1251,6 +1415,10 @@ def _parse_ical_todo(self, ical_text: str) -> dict[str, Any] | None: if categories: todo_data["categories"] = self._extract_categories(categories) + reminders = self._extract_valarms(component) + if reminders: + todo_data["reminders"] = reminders + return todo_data return None @@ -1320,6 +1488,26 @@ def _merge_ical_todo_properties( ] logger.debug("Set CATEGORIES to %s", categories_str) + # Handle reminders (VALARM). Omitted reminders preserve + # existing alarms; ``reminders: []`` clears them. + if "reminders" in todo_data: + self._sync_valarms_by_index( + component, todo_data.get("reminders") or [] + ) + elif "reminder_minutes" in todo_data: + self._sync_valarms_by_index(component, []) + minutes = todo_data["reminder_minutes"] + if minutes > 0: + component.add_component( + self._build_valarm( + { + "action": "DISPLAY", + "description": "Todo reminder", + "minutes_before": minutes, + } + ) + ) + # Update timestamps now = dt.datetime.now(dt.UTC) component["LAST-MODIFIED"] = vDDDTypes(now) diff --git a/nextcloud_mcp_server/models/calendar.py b/nextcloud_mcp_server/models/calendar.py index 95e2ede4a..73bf3dfd8 100644 --- a/nextcloud_mcp_server/models/calendar.py +++ b/nextcloud_mcp_server/models/calendar.py @@ -1,6 +1,6 @@ """Pydantic models for Calendar app responses.""" -from typing import List, Optional +from typing import Any, List, Optional from pydantic import BaseModel, Field @@ -70,6 +70,9 @@ class CalendarEventSummary(BaseModel): calendar_display_name: Optional[str] = Field( None, description="Display name of calendar containing this event" ) + reminders: List[dict[str, Any]] = Field( + default_factory=list, description="Ordered VALARM reminder objects" + ) class CalendarEvent(CalendarEventSummary): @@ -249,6 +252,9 @@ class Todo(BaseModel): calendar_display_name: Optional[str] = Field( None, description="Display name of calendar containing this todo" ) + reminders: List[dict[str, Any]] = Field( + default_factory=list, description="Ordered VALARM reminder objects" + ) class ListTodosResponse(BaseResponse): diff --git a/nextcloud_mcp_server/server/calendar.py b/nextcloud_mcp_server/server/calendar.py index 852c3b906..162eca502 100644 --- a/nextcloud_mcp_server/server/calendar.py +++ b/nextcloud_mcp_server/server/calendar.py @@ -1,6 +1,6 @@ import datetime as dt import logging -from typing import Optional +from typing import Any, Optional from mcp.server.fastmcp import Context, FastMCP from mcp.types import ToolAnnotations @@ -48,6 +48,7 @@ def _event_dict_to_summary(event: dict) -> CalendarEventSummary: calendar_name=event.get("calendar_name"), calendar_display_name=event.get("calendar_display_name") or event.get("calendar_name"), + reminders=event.get("reminders", []), ) @@ -95,6 +96,7 @@ async def nc_calendar_create_event( url: str = "", color: str = "", timezone: str = "", + reminders: list[dict[str, Any]] | None = None, ): """Create a comprehensive calendar event with full feature support. @@ -132,6 +134,10 @@ async def nc_calendar_create_event( Applied only when ``start_datetime``/``end_datetime`` are naive (no offset, no ``Z``). Ignored — with a warning — when the inputs already carry an explicit offset. + reminders: Optional ordered VALARM list. Each item may use + ``trigger`` (RFC5545 duration/date-time), ``trigger_at`` (ISO + absolute date-time), ``minutes_before``, ``offset_seconds``, + plus ``action``, ``description``, and ``related``. Returns: Dict with event creation result @@ -159,6 +165,8 @@ async def nc_calendar_create_event( "color": color, "timezone": timezone, } + if reminders is not None: + event_data["reminders"] = reminders return await client.calendar.create_event(calendar_name, event_data) @@ -330,6 +338,7 @@ async def nc_calendar_update_event( url: str | None = None, color: str | None = None, timezone: str | None = None, + reminders: list[dict[str, Any]] | None = None, etag: str = "", ): """Update any aspect of an existing event. @@ -379,6 +388,8 @@ async def nc_calendar_update_event( event_data["color"] = color if timezone is not None: event_data["timezone"] = timezone + if reminders is not None: + event_data["reminders"] = reminders return await client.calendar.update_event( calendar_name, event_uid, event_data, etag @@ -1009,6 +1020,7 @@ async def nc_calendar_create_todo( due: str = "", dtstart: str = "", categories: str = "", + reminders: list[dict[str, Any]] | None = None, ): """Create a new todo/task in a calendar. @@ -1022,6 +1034,8 @@ async def nc_calendar_create_todo( due: Due date/time (ISO format, e.g., "2025-01-15T14:00:00") dtstart: Start date/time (ISO format) categories: Comma-separated categories (e.g., "work,urgent") + reminders: Optional ordered VALARM list. Omit to create no VALARMs; + pass [] to explicitly create no VALARMs. Returns: Dict with todo creation result @@ -1037,6 +1051,8 @@ async def nc_calendar_create_todo( "dtstart": dtstart, "categories": categories, } + if reminders is not None: + todo_data["reminders"] = reminders return await client.calendar.create_todo(calendar_name, todo_data) @@ -1059,6 +1075,7 @@ async def nc_calendar_update_todo( dtstart: Optional[str] = None, completed: Optional[str] = None, categories: Optional[str] = None, + reminders: list[dict[str, Any]] | None = None, ): """Update an existing todo/task. @@ -1075,6 +1092,8 @@ async def nc_calendar_update_todo( dtstart: New start date/time (ISO format) completed: Completion timestamp (ISO format) categories: New categories (comma-separated) + reminders: Optional ordered VALARM list. Omitted preserves existing + VALARMs; [] clears them. Returns: Dict with todo update result @@ -1101,6 +1120,8 @@ async def nc_calendar_update_todo( todo_data["completed"] = completed if categories is not None: todo_data["categories"] = categories + if reminders is not None: + todo_data["reminders"] = reminders return await client.calendar.update_todo(calendar_name, todo_uid, todo_data) diff --git a/tests/unit/client/test_calendar.py b/tests/unit/client/test_calendar.py index 43983b41b..b5903b0bf 100644 --- a/tests/unit/client/test_calendar.py +++ b/tests/unit/client/test_calendar.py @@ -277,3 +277,93 @@ async def test_list_calendars_model_round_trip(mocker): holidays = next(c for c in calendars if c.name == "holidays") assert holidays.read_only is True assert holidays.source == "https://example.com/holidays.ics" + + +def _calendar_client(mocker): + mocker.patch("nextcloud_mcp_server.client.calendar.AsyncDAVClient") + from nextcloud_mcp_server.client.calendar import CalendarClient + + return CalendarClient("https://cloud.example.org", "alice", password="app-pw") + + +def test_event_reminders_round_trip_and_preserve_on_unrelated_update(mocker): + client = _calendar_client(mocker) + + ical = client._create_ical_event( + { + "title": "Fundraising", + "start_datetime": "2026-06-26T12:00:00+03:00", + "end_datetime": "2026-06-26T13:00:00+03:00", + "reminders": [ + { + "trigger_at": "2026-06-26T10:00:00+03:00", + "description": "absolute reminder", + }, + {"minutes_before": 30, "related": "START"}, + ], + }, + "event-uid", + ) + + parsed = client._parse_ical_event(ical) + assert parsed is not None + assert [r["index"] for r in parsed["reminders"]] == [0, 1] + assert parsed["reminders"][0]["action"] == "DISPLAY" + assert parsed["reminders"][0]["trigger_at"].startswith("2026-06-26T10:00:00") + assert parsed["reminders"][1]["trigger"] == "-PT30M" + assert parsed["reminders"][1]["minutes_before"] == 30 + assert parsed["reminders"][1]["related"] == "START" + + updated = client._merge_ical_properties(ical, {"location": "Office"}, "event-uid") + reparsed = client._parse_ical_event(updated) + assert reparsed is not None + assert reparsed["reminders"] == parsed["reminders"] + + +def test_event_reminders_empty_list_clears_valarms(mocker): + client = _calendar_client(mocker) + ical = client._create_ical_event( + { + "title": "Fundraising", + "start_datetime": "2026-06-26T12:00:00+03:00", + "reminder_minutes": 15, + }, + "event-uid", + ) + + cleared = client._merge_ical_properties(ical, {"reminders": []}, "event-uid") + parsed = client._parse_ical_event(cleared) + assert parsed is not None + assert "reminders" not in parsed + assert "VALARM" not in cleared + + +def test_todo_reminders_round_trip_and_update_by_ordered_list(mocker): + client = _calendar_client(mocker) + ical = client._create_ical_todo( + { + "summary": "Submit funding request", + "reminders": [{"trigger": "-PT6H", "description": "relative reminder"}], + }, + "todo-uid", + ) + + parsed = client._parse_ical_todo(ical) + assert parsed is not None + assert parsed["reminders"][0]["trigger"] == "-PT6H" + assert parsed["reminders"][0]["minutes_before"] == 360 + + updated = client._merge_ical_todo_properties( + ical, + { + "reminders": [ + {"trigger": "-PT1H", "description": "updated"}, + {"offset_seconds": -300}, + ] + }, + "todo-uid", + ) + reparsed = client._parse_ical_todo(updated) + assert reparsed is not None + assert [r["trigger"] for r in reparsed["reminders"]] == ["-PT1H", "-PT5M"] + assert reparsed["reminders"][0]["description"] == "updated"