Skip to content

Commit 425bbf7

Browse files
author
Hermes AI
committed
feat(calendar): expose ordered VALARM reminders
1 parent 4688f2f commit 425bbf7

4 files changed

Lines changed: 326 additions & 22 deletions

File tree

nextcloud_mcp_server/client/calendar.py

Lines changed: 208 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -794,6 +794,162 @@ def _parse_event_datetime(
794794
)
795795
return parsed, None
796796

797+
798+
@staticmethod
799+
def _decode_ical_value(value: Any) -> str:
800+
"""Return a JSON-friendly RFC5545 value string."""
801+
if value is None:
802+
return ""
803+
if hasattr(value, "to_ical"):
804+
raw = value.to_ical()
805+
if isinstance(raw, bytes):
806+
return raw.decode("utf-8")
807+
return str(raw)
808+
return str(value)
809+
810+
@staticmethod
811+
def _parse_alarm_datetime(value: str) -> dt.datetime:
812+
"""Parse an absolute alarm trigger datetime from ISO or basic iCalendar."""
813+
cleaned = value.strip().replace("Z", "+00:00")
814+
try:
815+
return dt.datetime.fromisoformat(cleaned)
816+
except ValueError:
817+
# RFC5545 basic format, with optional UTC suffix.
818+
utc = value.strip().endswith("Z")
819+
basic = value.strip().removesuffix("Z")
820+
parsed = dt.datetime.strptime(basic, "%Y%m%dT%H%M%S")
821+
if utc:
822+
parsed = parsed.replace(tzinfo=dt.UTC)
823+
return parsed
824+
825+
@staticmethod
826+
def _format_alarm_datetime(value: dt.datetime, tzid: str | None = None) -> str:
827+
"""Return an ISO datetime, preserving simple UTC±HH:MM TZIDs as offsets."""
828+
if value.tzinfo is not None:
829+
return value.isoformat()
830+
if tzid and tzid.startswith("UTC") and len(tzid) == 9:
831+
sign = 1 if tzid[3] == "+" else -1
832+
try:
833+
hours = int(tzid[4:6])
834+
minutes = int(tzid[7:9])
835+
except ValueError:
836+
return value.isoformat()
837+
offset = dt.timezone(sign * dt.timedelta(hours=hours, minutes=minutes))
838+
return value.replace(tzinfo=offset).isoformat()
839+
return value.isoformat()
840+
841+
def _extract_valarms(self, component: Any) -> list[dict[str, Any]]:
842+
"""Extract VALARM components as ordered, JSON-friendly reminder dicts."""
843+
reminders: list[dict[str, Any]] = []
844+
for index, alarm in enumerate(
845+
sub for sub in component.subcomponents if sub.name == "VALARM"
846+
):
847+
trigger = alarm.get("trigger")
848+
trigger_dt = getattr(trigger, "dt", None)
849+
reminder: dict[str, Any] = {
850+
"index": index,
851+
"action": str(alarm.get("action", "DISPLAY")),
852+
}
853+
854+
description = alarm.get("description")
855+
if description is not None:
856+
reminder["description"] = str(description)
857+
858+
if trigger is not None:
859+
reminder["trigger"] = self._decode_ical_value(trigger)
860+
params = getattr(trigger, "params", {}) or {}
861+
related = params.get("RELATED")
862+
if related:
863+
reminder["related"] = str(related)
864+
value_param = params.get("VALUE")
865+
if value_param:
866+
reminder["value"] = str(value_param)
867+
tzid = params.get("TZID")
868+
if tzid:
869+
reminder["trigger_tz"] = str(tzid)
870+
871+
if isinstance(trigger_dt, dt.datetime):
872+
reminder["trigger_at"] = self._format_alarm_datetime(
873+
trigger_dt, str(tzid) if tzid else None
874+
)
875+
elif isinstance(trigger_dt, dt.timedelta):
876+
total_seconds = int(trigger_dt.total_seconds())
877+
reminder["offset_seconds"] = total_seconds
878+
if total_seconds < 0 and total_seconds % 60 == 0:
879+
reminder["minutes_before"] = abs(total_seconds) // 60
880+
881+
reminders.append(reminder)
882+
return reminders
883+
884+
def _build_valarm(self, reminder: dict[str, Any]) -> Alarm:
885+
"""Build a VALARM component from a reminder dict."""
886+
alarm = Alarm()
887+
alarm.add("action", reminder.get("action", "DISPLAY"))
888+
alarm.add("description", reminder.get("description", "Event reminder"))
889+
890+
params: dict[str, str] = {}
891+
related = reminder.get("related")
892+
if related:
893+
params["RELATED"] = str(related)
894+
895+
if reminder.get("trigger_at"):
896+
trigger_dt = self._parse_alarm_datetime(str(reminder["trigger_at"]))
897+
params["VALUE"] = "DATE-TIME"
898+
alarm.add("trigger", trigger_dt, parameters=params)
899+
elif reminder.get("trigger"):
900+
trigger_value = str(reminder["trigger"])
901+
if trigger_value.startswith(("P", "-P", "+P")):
902+
alarm.add("trigger", vDDDTypes.from_ical(trigger_value), parameters=params)
903+
else:
904+
params["VALUE"] = "DATE-TIME"
905+
alarm.add("trigger", self._parse_alarm_datetime(trigger_value), parameters=params)
906+
elif "minutes_before" in reminder:
907+
alarm.add(
908+
"trigger",
909+
dt.timedelta(minutes=-int(reminder["minutes_before"])),
910+
parameters=params,
911+
)
912+
elif "offset_seconds" in reminder:
913+
alarm.add(
914+
"trigger",
915+
dt.timedelta(seconds=int(reminder["offset_seconds"])),
916+
parameters=params,
917+
)
918+
else:
919+
alarm.add("trigger", dt.timedelta(minutes=-15), parameters=params)
920+
921+
return alarm
922+
923+
def _sync_valarms_by_index(
924+
self, component: Any, reminders: list[dict[str, Any]]
925+
) -> None:
926+
"""Synchronize VALARM subcomponents as an ordered list."""
927+
component.subcomponents = [
928+
sub for sub in component.subcomponents if sub.name != "VALARM"
929+
]
930+
for reminder in reminders:
931+
component.add_component(self._build_valarm(reminder))
932+
933+
def _add_reminders_or_legacy_alarm(
934+
self, component: Any, data: dict[str, Any], default_description: str
935+
) -> None:
936+
"""Apply explicit reminders, else backward-compatible reminder_minutes."""
937+
if "reminders" in data:
938+
self._sync_valarms_by_index(component, data.get("reminders") or [])
939+
return
940+
941+
reminder_minutes = data.get("reminder_minutes", 0)
942+
if reminder_minutes > 0:
943+
component.add_component(
944+
self._build_valarm(
945+
{
946+
"action": "DISPLAY",
947+
"description": default_description,
948+
"minutes_before": reminder_minutes,
949+
}
950+
)
951+
)
952+
797953
def _create_ical_event(self, event_data: dict[str, Any], event_uid: str) -> str:
798954
"""Create iCalendar content from event data."""
799955
cal = Calendar()
@@ -859,14 +1015,10 @@ def _create_ical_event(self, event_data: dict[str, Any], event_uid: str) -> str:
8591015
if recurrence_rule:
8601016
event.add("rrule", vRecur.from_ical(recurrence_rule))
8611017

862-
# Add alarms/reminders
863-
reminder_minutes = event_data.get("reminder_minutes", 0)
864-
if reminder_minutes > 0:
865-
alarm = Alarm()
866-
alarm.add("action", "DISPLAY")
867-
alarm.add("description", "Event reminder")
868-
alarm.add("trigger", dt.timedelta(minutes=-reminder_minutes))
869-
event.add_component(alarm)
1018+
# Add alarms/reminders. Explicit ``reminders`` is the full ordered
1019+
# VALARM list; ``reminder_minutes`` remains the backward-compatible
1020+
# shorthand for a single DISPLAY alarm.
1021+
self._add_reminders_or_legacy_alarm(event, event_data, "Event reminder")
8701022

8711023
# Add attendees
8721024
attendees = event_data.get("attendees", "")
@@ -944,6 +1096,10 @@ def _extract_vevent_data(self, component) -> dict[str, Any]:
9441096
if attendees:
9451097
event_data["attendees"] = ",".join(attendees)
9461098

1099+
reminders = self._extract_valarms(component)
1100+
if reminders:
1101+
event_data["reminders"] = reminders
1102+
9471103
return event_data
9481104

9491105
def _parse_ical_event(self, ical_text: str) -> dict[str, Any] | None:
@@ -1012,20 +1168,25 @@ def _merge_ical_properties(
10121168
if email.strip():
10131169
component.add("attendee", f"mailto:{email.strip()}")
10141170

1015-
# Handle reminder (VALARM)
1016-
if "reminder_minutes" in event_data:
1017-
component.subcomponents = [
1018-
sub
1019-
for sub in component.subcomponents
1020-
if sub.name != "VALARM"
1021-
]
1171+
# Handle reminders (VALARM). Omitted reminders preserve
1172+
# existing alarms; ``reminders: []`` clears them.
1173+
if "reminders" in event_data:
1174+
self._sync_valarms_by_index(
1175+
component, event_data.get("reminders") or []
1176+
)
1177+
elif "reminder_minutes" in event_data:
1178+
self._sync_valarms_by_index(component, [])
10221179
minutes = event_data["reminder_minutes"]
10231180
if minutes > 0:
1024-
alarm = Alarm()
1025-
alarm.add("action", "DISPLAY")
1026-
alarm.add("description", "Event reminder")
1027-
alarm.add("trigger", dt.timedelta(minutes=-minutes))
1028-
component.add_component(alarm)
1181+
component.add_component(
1182+
self._build_valarm(
1183+
{
1184+
"action": "DISPLAY",
1185+
"description": "Event reminder",
1186+
"minutes_before": minutes,
1187+
}
1188+
)
1189+
)
10291190

10301191
# Handle dates
10311192
tz_name = event_data.get("timezone", "")
@@ -1155,6 +1316,9 @@ def _create_ical_todo(self, todo_data: dict[str, Any], todo_uid: str) -> str:
11551316
if categories:
11561317
todo.add("categories", categories.split(","))
11571318

1319+
# Add alarms/reminders
1320+
self._add_reminders_or_legacy_alarm(todo, todo_data, "Todo reminder")
1321+
11581322
# Add timestamps
11591323
now = dt.datetime.now(dt.UTC)
11601324
todo.add("created", now)
@@ -1199,6 +1363,10 @@ def _parse_ical_todo(self, ical_text: str) -> dict[str, Any] | None:
11991363
if categories:
12001364
todo_data["categories"] = self._extract_categories(categories)
12011365

1366+
reminders = self._extract_valarms(component)
1367+
if reminders:
1368+
todo_data["reminders"] = reminders
1369+
12021370
return todo_data
12031371

12041372
return None
@@ -1268,6 +1436,26 @@ def _merge_ical_todo_properties(
12681436
]
12691437
logger.debug("Set CATEGORIES to %s", categories_str)
12701438

1439+
# Handle reminders (VALARM). Omitted reminders preserve
1440+
# existing alarms; ``reminders: []`` clears them.
1441+
if "reminders" in todo_data:
1442+
self._sync_valarms_by_index(
1443+
component, todo_data.get("reminders") or []
1444+
)
1445+
elif "reminder_minutes" in todo_data:
1446+
self._sync_valarms_by_index(component, [])
1447+
minutes = todo_data["reminder_minutes"]
1448+
if minutes > 0:
1449+
component.add_component(
1450+
self._build_valarm(
1451+
{
1452+
"action": "DISPLAY",
1453+
"description": "Todo reminder",
1454+
"minutes_before": minutes,
1455+
}
1456+
)
1457+
)
1458+
12711459
# Update timestamps
12721460
now = dt.datetime.now(dt.UTC)
12731461
component["LAST-MODIFIED"] = vDDDTypes(now)

nextcloud_mcp_server/models/calendar.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Pydantic models for Calendar app responses."""
22

3-
from typing import List, Optional
3+
from typing import Any, List, Optional
44

55
from pydantic import BaseModel, Field
66

@@ -62,6 +62,9 @@ class CalendarEventSummary(BaseModel):
6262
calendar_display_name: Optional[str] = Field(
6363
None, description="Display name of calendar containing this event"
6464
)
65+
reminders: List[dict[str, Any]] = Field(
66+
default_factory=list, description="Ordered VALARM reminder objects"
67+
)
6568

6669

6770
class CalendarEvent(CalendarEventSummary):
@@ -241,6 +244,9 @@ class Todo(BaseModel):
241244
calendar_display_name: Optional[str] = Field(
242245
None, description="Display name of calendar containing this todo"
243246
)
247+
reminders: List[dict[str, Any]] = Field(
248+
default_factory=list, description="Ordered VALARM reminder objects"
249+
)
244250

245251

246252
class ListTodosResponse(BaseResponse):

0 commit comments

Comments
 (0)