From 73eaffbfe2f9b85e2f8b94eb8a9fda52483b21cc Mon Sep 17 00:00:00 2001 From: Daniel Havlik Date: Tue, 15 Feb 2022 21:42:53 +0100 Subject: [PATCH 1/7] draft: add valarm capabilities --- icalevents/icalparser.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/icalevents/icalparser.py b/icalevents/icalparser.py index a35469e..d86c4e2 100644 --- a/icalevents/icalparser.py +++ b/icalevents/icalparser.py @@ -14,6 +14,7 @@ from icalendar.windows_to_olson import WINDOWS_TO_OLSON from icalendar.prop import vDDDLists, vText from pytz import timezone +import copy def now(): @@ -52,6 +53,7 @@ def __init__(self): self.categories = None self.status = None self.url = None + self.alarms = [] def time_left(self, time=None): """ @@ -145,6 +147,7 @@ def copy_to(self, new_start=None, uid=None): ne.categories = self.categories ne.status = self.status ne.url = self.url + ne.alarms = copy.deepcopy(self.alarms) return ne @@ -166,7 +169,6 @@ def create_event(component, tz=UTC): :param tz: timezone for start and end times :return: event """ - event = Event() event.start = normalize(component.get("dtstart").dt, tz=tz) @@ -436,6 +438,26 @@ def parse_events(content, start=None, end=None, default_span=timedelta(days=7)): exdate = "%04d%02d%02d" % (e.start.year, e.start.month, e.start.day) if exdate not in exceptions: found.append(e) + elif component.name == "VALARM": + trigger = component.get('TRIGGER') + alarm_dt = None + trigger_dt = trigger.dt + if isinstance(trigger_dt, timedelta): + event_start = e.start + if type(event_start) == datetime.date: # support full day events + event_start = datetime(event_start.year, event_start.month, event_start.day) + alarm_dt = event_start + trigger_dt + elif isinstance(trigger_dt, datetime): + #XXX timezone + alarm_dt = trigger_dt + else: + log.debug("Can't handle {trigger.dt} TRIGGER objects.") + summary = e.summary + if str(component.get('ACTION')) == 'DISPLAY': + summary = str(component.get('DESCRIPTION')) + alarm_uid = component.get('UID') + e.alarms.append(dict(summary=summary, alarm_dt=alarm_dt, uid=alarm_uid)) + # Filter out all events that are moved as indicated by the recurrence-id prop return [ event From c6da2fcbf77772b22dbab2d3253c1ca8084a3d59 Mon Sep 17 00:00:00 2001 From: Daniel Havlik Date: Tue, 15 Feb 2022 23:39:06 +0100 Subject: [PATCH 2/7] better use subcomponents for a more logical flow. --- icalevents/icalparser.py | 41 ++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/icalevents/icalparser.py b/icalevents/icalparser.py index d86c4e2..6bca02d 100644 --- a/icalevents/icalparser.py +++ b/icalevents/icalparser.py @@ -147,7 +147,7 @@ def copy_to(self, new_start=None, uid=None): ne.categories = self.categories ne.status = self.status ne.url = self.url - ne.alarms = copy.deepcopy(self.alarms) + ne.alarms = self.alarms return ne @@ -438,25 +438,26 @@ def parse_events(content, start=None, end=None, default_span=timedelta(days=7)): exdate = "%04d%02d%02d" % (e.start.year, e.start.month, e.start.day) if exdate not in exceptions: found.append(e) - elif component.name == "VALARM": - trigger = component.get('TRIGGER') - alarm_dt = None - trigger_dt = trigger.dt - if isinstance(trigger_dt, timedelta): - event_start = e.start - if type(event_start) == datetime.date: # support full day events - event_start = datetime(event_start.year, event_start.month, event_start.day) - alarm_dt = event_start + trigger_dt - elif isinstance(trigger_dt, datetime): - #XXX timezone - alarm_dt = trigger_dt - else: - log.debug("Can't handle {trigger.dt} TRIGGER objects.") - summary = e.summary - if str(component.get('ACTION')) == 'DISPLAY': - summary = str(component.get('DESCRIPTION')) - alarm_uid = component.get('UID') - e.alarms.append(dict(summary=summary, alarm_dt=alarm_dt, uid=alarm_uid)) + for subcomponent in component.subcomponents: + if subcomponent.name == "VALARM": + trigger = subcomponent.get('TRIGGER') + alarm_dt = None + trigger_dt = trigger.dt + if isinstance(trigger_dt, timedelta): + event_start = e.start + if type(event_start) == datetime.date: # support full day events + event_start = datetime(event_start.year, event_start.month, event_start.day) + alarm_dt = event_start + trigger_dt + elif isinstance(trigger_dt, datetime): + #XXX timezone + alarm_dt = trigger_dt + else: + log.debug("Can't handle {trigger.dt} TRIGGER objects.") + summary = e.summary + if str(subcomponent.get('ACTION')) == 'DISPLAY': + summary = str(subcomponent.get('DESCRIPTION')) + alarm_uid = subcomponent.get('UID') + e.alarms.append(dict(summary=summary, alarm_dt=alarm_dt, uid=alarm_uid)) # Filter out all events that are moved as indicated by the recurrence-id prop return [ From fdd743b63f84d755104af3156fbe2bc5afcc1423 Mon Sep 17 00:00:00 2001 From: Daniel Havlik Date: Thu, 17 Feb 2022 00:23:04 +0100 Subject: [PATCH 3/7] calculate alarms before copying recurrences --- icalevents/icalparser.py | 44 ++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/icalevents/icalparser.py b/icalevents/icalparser.py index 6bca02d..2870fca 100644 --- a/icalevents/icalparser.py +++ b/icalevents/icalparser.py @@ -369,6 +369,29 @@ def parse_events(content, start=None, end=None, default_span=timedelta(days=7)): exdate = ex.to_ical().decode("UTF-8") exceptions[exdate[0:8]] = exdate + + for subcomponent in component.subcomponents: + if subcomponent.name == "VALARM": + trigger = subcomponent.get('TRIGGER') + alarm_dt = None + trigger_dt = trigger.dt + if isinstance(trigger_dt, timedelta): + event_start = e.start + if type(event_start) == datetime.date: # support full day events + event_start = datetime(event_start.year, event_start.month, event_start.day) + alarm_dt = event_start + trigger_dt + elif isinstance(trigger_dt, datetime): + #XXX timezone + alarm_dt = trigger_dt + else: + log.debug("Can't handle {trigger.dt} TRIGGER objects.") + summary = e.summary + if str(subcomponent.get('ACTION')) == 'DISPLAY': + summary = str(subcomponent.get('DESCRIPTION')) + alarm_uid = subcomponent.get('UID') + e.alarms.append(dict(summary=summary, alarm_dt=alarm_dt, uid=alarm_uid)) + + # Attempt to work out what timezone is used for the start # and end times. If the timezone is defined in the calendar, # use it; otherwise, attempt to load the rules from pytz. @@ -438,26 +461,7 @@ def parse_events(content, start=None, end=None, default_span=timedelta(days=7)): exdate = "%04d%02d%02d" % (e.start.year, e.start.month, e.start.day) if exdate not in exceptions: found.append(e) - for subcomponent in component.subcomponents: - if subcomponent.name == "VALARM": - trigger = subcomponent.get('TRIGGER') - alarm_dt = None - trigger_dt = trigger.dt - if isinstance(trigger_dt, timedelta): - event_start = e.start - if type(event_start) == datetime.date: # support full day events - event_start = datetime(event_start.year, event_start.month, event_start.day) - alarm_dt = event_start + trigger_dt - elif isinstance(trigger_dt, datetime): - #XXX timezone - alarm_dt = trigger_dt - else: - log.debug("Can't handle {trigger.dt} TRIGGER objects.") - summary = e.summary - if str(subcomponent.get('ACTION')) == 'DISPLAY': - summary = str(subcomponent.get('DESCRIPTION')) - alarm_uid = subcomponent.get('UID') - e.alarms.append(dict(summary=summary, alarm_dt=alarm_dt, uid=alarm_uid)) + # Filter out all events that are moved as indicated by the recurrence-id prop return [ From b3debc685f4cb647999bf8b215862650af2b3429 Mon Sep 17 00:00:00 2001 From: Daniel Havlik Date: Fri, 18 Feb 2022 10:55:58 +0100 Subject: [PATCH 4/7] correctly handle alarms on recurring events: recalculate the alarm dt and make a copy of the alarm datastructure instead a reference ;) --- icalevents/icalparser.py | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/icalevents/icalparser.py b/icalevents/icalparser.py index 2870fca..0cc01c3 100644 --- a/icalevents/icalparser.py +++ b/icalevents/icalparser.py @@ -147,7 +147,7 @@ def copy_to(self, new_start=None, uid=None): ne.categories = self.categories ne.status = self.status ne.url = self.url - ne.alarms = self.alarms + ne.alarms = copy.deepcopy(self.alarms) return ne @@ -293,6 +293,19 @@ def adjust_timezone(component, dates, tz=None): return dates +def calculate_alarm_dt(trigger_dt, event_start): + if isinstance(trigger_dt, timedelta): + if type(event_start) == datetime.date: # support full day events + event_start = datetime(event_start.year, event_start.month, event_start.day) + return event_start + trigger_dt + elif isinstance(trigger_dt, datetime): + #XXX timezone + return trigger_dt + else: + log.debug("Can't handle {trigger.dt} TRIGGER objects.") + + + def parse_events(content, start=None, end=None, default_span=timedelta(days=7)): """ Query the events occurring in a given time range. @@ -375,21 +388,12 @@ def parse_events(content, start=None, end=None, default_span=timedelta(days=7)): trigger = subcomponent.get('TRIGGER') alarm_dt = None trigger_dt = trigger.dt - if isinstance(trigger_dt, timedelta): - event_start = e.start - if type(event_start) == datetime.date: # support full day events - event_start = datetime(event_start.year, event_start.month, event_start.day) - alarm_dt = event_start + trigger_dt - elif isinstance(trigger_dt, datetime): - #XXX timezone - alarm_dt = trigger_dt - else: - log.debug("Can't handle {trigger.dt} TRIGGER objects.") + alarm_dt = calculate_alarm_dt(trigger_dt, e.start) summary = e.summary if str(subcomponent.get('ACTION')) == 'DISPLAY': summary = str(subcomponent.get('DESCRIPTION')) - alarm_uid = subcomponent.get('UID') - e.alarms.append(dict(summary=summary, alarm_dt=alarm_dt, uid=alarm_uid)) + alarm_uid = subcomponent.get('UID') or component.get('UID') + e.alarms.append(dict(summary=summary, alarm_dt=alarm_dt, uid=alarm_uid, trigger_dt=trigger_dt)) # Attempt to work out what timezone is used for the start @@ -455,6 +459,8 @@ def parse_events(content, start=None, end=None, default_span=timedelta(days=7)): ecopy.start.month, ecopy.start.day, ) + for alarm in ecopy.alarms: + alarm['alarm_dt'] = calculate_alarm_dt(alarm['trigger_dt'], ecopy.start) if exdate not in exceptions: found.append(ecopy) elif e.end >= start and e.start <= end: From a55274002257357945db2b382ed5a923c5eeb09e Mon Sep 17 00:00:00 2001 From: Daniel Havlik Date: Fri, 18 Feb 2022 11:26:26 +0100 Subject: [PATCH 5/7] always handle description, add action --- icalevents/icalparser.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/icalevents/icalparser.py b/icalevents/icalparser.py index 0cc01c3..3a3f994 100644 --- a/icalevents/icalparser.py +++ b/icalevents/icalparser.py @@ -390,10 +390,16 @@ def parse_events(content, start=None, end=None, default_span=timedelta(days=7)): trigger_dt = trigger.dt alarm_dt = calculate_alarm_dt(trigger_dt, e.start) summary = e.summary - if str(subcomponent.get('ACTION')) == 'DISPLAY': - summary = str(subcomponent.get('DESCRIPTION')) + action = str(subcomponent.get('ACTION')) + description = str(subcomponent.get('DESCRIPTION')) alarm_uid = subcomponent.get('UID') or component.get('UID') - e.alarms.append(dict(summary=summary, alarm_dt=alarm_dt, uid=alarm_uid, trigger_dt=trigger_dt)) + e.alarms.append(dict( + summary=summary, + description=description, + alarm_dt=alarm_dt, + action=action, + uid=alarm_uid, + trigger_dt=trigger_dt)) # Attempt to work out what timezone is used for the start From 52095fb1222c2eef817d278baac4c83cbc69506a Mon Sep 17 00:00:00 2001 From: Daniel Havlik Date: Fri, 18 Feb 2022 14:12:51 +0100 Subject: [PATCH 6/7] dont fall back to event uid for alarms without uid --- icalevents/icalparser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/icalevents/icalparser.py b/icalevents/icalparser.py index 3a3f994..63253dd 100644 --- a/icalevents/icalparser.py +++ b/icalevents/icalparser.py @@ -392,7 +392,7 @@ def parse_events(content, start=None, end=None, default_span=timedelta(days=7)): summary = e.summary action = str(subcomponent.get('ACTION')) description = str(subcomponent.get('DESCRIPTION')) - alarm_uid = subcomponent.get('UID') or component.get('UID') + alarm_uid = subcomponent.get('UID') e.alarms.append(dict( summary=summary, description=description, From 7cda3fc3c6584c60df33cc4e10a3e6093d384887 Mon Sep 17 00:00:00 2001 From: Daniel Havlik Date: Tue, 22 Feb 2022 15:23:24 +0100 Subject: [PATCH 7/7] add tests and cleaned up --- icalevents/icalparser.py | 49 +++++++++++-------- test/test_data/recurring_alarm.ics | 31 ++++++++++++ test/test_icalevents.py | 77 ++++++++++++++++++++++++++++++ test/test_icalparser.py | 18 ++++++- 4 files changed, 153 insertions(+), 22 deletions(-) create mode 100644 test/test_data/recurring_alarm.ics diff --git a/icalevents/icalparser.py b/icalevents/icalparser.py index 63253dd..98f0994 100644 --- a/icalevents/icalparser.py +++ b/icalevents/icalparser.py @@ -295,15 +295,11 @@ def adjust_timezone(component, dates, tz=None): def calculate_alarm_dt(trigger_dt, event_start): if isinstance(trigger_dt, timedelta): - if type(event_start) == datetime.date: # support full day events + if type(event_start) == datetime.date: # support full day events event_start = datetime(event_start.year, event_start.month, event_start.day) return event_start + trigger_dt elif isinstance(trigger_dt, datetime): - #XXX timezone return trigger_dt - else: - log.debug("Can't handle {trigger.dt} TRIGGER objects.") - def parse_events(content, start=None, end=None, default_span=timedelta(days=7)): @@ -382,25 +378,35 @@ def parse_events(content, start=None, end=None, default_span=timedelta(days=7)): exdate = ex.to_ical().decode("UTF-8") exceptions[exdate[0:8]] = exdate - for subcomponent in component.subcomponents: if subcomponent.name == "VALARM": - trigger = subcomponent.get('TRIGGER') + trigger = subcomponent.get("TRIGGER") + if trigger is None: + continue alarm_dt = None trigger_dt = trigger.dt alarm_dt = calculate_alarm_dt(trigger_dt, e.start) - summary = e.summary - action = str(subcomponent.get('ACTION')) - description = str(subcomponent.get('DESCRIPTION')) - alarm_uid = subcomponent.get('UID') - e.alarms.append(dict( - summary=summary, - description=description, - alarm_dt=alarm_dt, - action=action, - uid=alarm_uid, - trigger_dt=trigger_dt)) - + action = str(subcomponent.get("ACTION", "")) + attachment = str(subcomponent.get("ATTACH", "")) + description = str(subcomponent.get("DESCRIPTION", "")) + alarm_uid = subcomponent.get("UID") + if alarm_uid is None: + # try to get other X-FOO-UID + for key in subcomponent.keys(): + if key.endswith("-UID"): + alarm_uid = subcomponent.get(key) + if alarm_uid is None: + alarm_uid = "" + e.alarms.append( + dict( + description=description, + alarm_dt=alarm_dt, + action=action, + attachment=attachment, + uid=str(alarm_uid), + trigger_dt=trigger_dt, + ) + ) # Attempt to work out what timezone is used for the start # and end times. If the timezone is defined in the calendar, @@ -466,7 +472,9 @@ def parse_events(content, start=None, end=None, default_span=timedelta(days=7)): ecopy.start.day, ) for alarm in ecopy.alarms: - alarm['alarm_dt'] = calculate_alarm_dt(alarm['trigger_dt'], ecopy.start) + alarm["alarm_dt"] = calculate_alarm_dt( + alarm["trigger_dt"], ecopy.start + ) if exdate not in exceptions: found.append(ecopy) elif e.end >= start and e.start <= end: @@ -474,7 +482,6 @@ def parse_events(content, start=None, end=None, default_span=timedelta(days=7)): if exdate not in exceptions: found.append(e) - # Filter out all events that are moved as indicated by the recurrence-id prop return [ event diff --git a/test/test_data/recurring_alarm.ics b/test/test_data/recurring_alarm.ics new file mode 100644 index 0000000..d6308f3 --- /dev/null +++ b/test/test_data/recurring_alarm.ics @@ -0,0 +1,31 @@ +BEGIN:VCALENDAR +BEGIN:VTIMEZONE +TZID:Europe/Berlin +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;VALUE=DATE:20181030 +DTEND;VALUE=DATE:20181031 +DESCRIPTION:All-day event recurring on tuesday each week +SUMMARY:Recurring All-day Event +RRULE:FREQ=WEEKLY;BYDAY=TU +BEGIN:VALARM +ACTION:AUDIO +TRIGGER:-PT15H +UID:4BB6A40E-6845-4541-BD87-0962514D03DC +ATTACH;VALUE=URI:Basso +X-APPLE-DEFAULT-ALARM:TRUE +ACKNOWLEDGED:20170319T131719Z +END:VALARM +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:Funny Description +TRIGGER;RELATED=START:-P3D +X-EVOLUTION-ALARM-UID:def4351cbf2dc54c4019fa7a5b8557ec3b9ee26d +END:VALARM +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:This is an event reminder +TRIGGER:-P0DT10H0M0S +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/test/test_icalevents.py b/test/test_icalevents.py index d54f8b2..7d04ec5 100644 --- a/test/test_icalevents.py +++ b/test/test_icalevents.py @@ -450,3 +450,80 @@ def test_status_and_url(self): self.assertEqual(ev3.status, "CANCELLED") self.assertEqual(ev4.status, "CANCELLED") self.assertEqual(ev5.status, None) + + def test_alarms_absolute(self): + """Alarms which are set to a fixed datetime are properly + returned.""" + ical = "test/test_data/basic.ics" + start = date(2017, 5, 16) + evs = icalevents.events(url=None, file=ical, start=start) + self.assertEqual( + datetime(1976, 4, 1, 0, 55, 45, tzinfo=UTC), evs[0].alarms[0]["alarm_dt"] + ) + + def test_alarms_relative(self): + """Alarms which are set to a relative datetime are properly + returned.""" + ical = "test/test_data/basic.ics" + start = date(2017, 3, 19) + evs = icalevents.events(url=None, file=ical, start=start) + self.assertEqual( + datetime(2017, 3, 19, 9, 0, tzinfo=evs[0].start.tzinfo), + evs[0].alarms[0]["alarm_dt"], + ) + self.assertEqual(timedelta(hours=-15), evs[0].alarms[0]["trigger_dt"]) + + def test_alarms_recurring(self): + """Recurrences get their own alarm each.""" + ical = "test/test_data/recurring_alarm.ics" + start = date(2020, 3, 19) + end = start + timedelta(days=20) + evs = icalevents.events(url=None, file=ical, start=start, end=end) + expected_tz = evs[0].start.tzinfo + self.assertEqual( + datetime(2020, 3, 23, 9, 0, tzinfo=expected_tz), + evs[0].alarms[0]["alarm_dt"], + ) + self.assertEqual( + datetime(2020, 3, 30, 9, 0, tzinfo=expected_tz), + evs[1].alarms[0]["alarm_dt"], + ) + self.assertEqual( + datetime(2020, 4, 6, 9, 0, tzinfo=expected_tz), evs[2].alarms[0]["alarm_dt"] + ) + + def test_alarms_data__1(self): + ical = "test/test_data/recurring_alarm.ics" + start = date(2020, 3, 19) + evs = icalevents.events(url=None, file=ical, start=start) + expected_tz = evs[0].start.tzinfo + # apple + expected = { + "action": "AUDIO", + "alarm_dt": datetime(2020, 3, 23, 9, 0, tzinfo=expected_tz), + "attachment": "Basso", + "description": "", + "trigger_dt": timedelta(hours=-15), + "uid": "4BB6A40E-6845-4541-BD87-0962514D03DC", + } + self.assertEqual(expected, evs[0].alarms[0]) + # evolution + expected = { + "action": "DISPLAY", + "alarm_dt": datetime(2020, 3, 21, 0, 0, tzinfo=expected_tz), + "attachment": "", + "description": "Funny Description", + "trigger_dt": timedelta(days=-3), + "uid": "def4351cbf2dc54c4019fa7a5b8557ec3b9ee26d", + } + self.assertEqual(expected, evs[0].alarms[1]) + # google + expected = { + "action": "DISPLAY", + "alarm_dt": datetime(2020, 3, 23, 14, 0, tzinfo=expected_tz), + "attachment": "", + "description": "This is an event reminder", + "trigger_dt": timedelta(hours=-10), + "uid": "", + } + self.assertEqual(expected, evs[0].alarms[2]) diff --git a/test/test_icalparser.py b/test/test_icalparser.py index 16ca87f..e68f73d 100644 --- a/test/test_icalparser.py +++ b/test/test_icalparser.py @@ -1,6 +1,6 @@ import unittest import icalevents.icalparser -from datetime import datetime, date +from datetime import datetime, date, timedelta from dateutil.tz import UTC, gettz @@ -18,6 +18,18 @@ def setUp(self): self.eventA.summary = "Event A" self.eventA.attendee = "name@example.com" self.eventA.organizer = "name@example.com" + trigger_dt = timedelta(days=-1) + alarm_dt = self.eventA.start + trigger_dt + self.eventA.alarms = [ + dict( + summary="Reminder for Event A", + description="", + alarm_dt=alarm_dt, + action="", + uid="alarm_uid", + trigger_dt=trigger_dt, + ) + ] self.eventB = icalevents.icalparser.Event() self.eventB.uid = 1234 @@ -59,6 +71,10 @@ def test_event_copy_to(self): self.eventA.end - self.eventA.start, "new event has same duration", ) + self.assertEqual(len(eventC.alarms), 1) + self.eventA.alarms.append("test") + self.assertEqual(len(eventC.alarms), 1, "alarms is a copy") + self.assertEqual(eventC.all_day, False, "new event is no all day event") self.assertEqual(eventC.summary, self.eventA.summary, "copy to: summary") self.assertEqual(