@@ -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 )
0 commit comments