1"""Compute the times and states of alarms.
2
3This takes different calendar software into account and the RFC 9074 (Alarm Extension).
4
5- RFC 9074 defines an ACKNOWLEDGED property in the VALARM.
6- Outlook does not export VALARM information.
7- Google Calendar uses the DTSTAMP to acknowledge the alarms.
8- Thunderbird snoozes the alarms with a X-MOZ-SNOOZE-TIME attribute in the event.
9- Thunderbird acknowledges the alarms with a X-MOZ-LASTACK attribute in the event.
10- Etar deletes alarms that are acknowledged.
11- Nextcloud's Webinterface does not do anything with the alarms when the time passes.
12"""
13
14from __future__ import annotations
15
16from datetime import date, timedelta, tzinfo
17from typing import TYPE_CHECKING, Generator, Optional, Union
18
19from icalendar.cal.event import Event
20from icalendar.cal.todo import Todo
21from icalendar.error import (
22 ComponentEndMissing,
23 ComponentStartMissing,
24 IncompleteAlarmInformation,
25 LocalTimezoneMissing,
26)
27from icalendar.timezone import tzp
28from icalendar.tools import is_date, normalize_pytz, to_datetime
29
30if TYPE_CHECKING:
31 from datetime import datetime
32
33 from icalendar.cal.alarm import Alarm
34
35Parent = Union[Event, Todo]
36
37
38class AlarmTime:
39 """An alarm time with all the information."""
40
41 def __init__(
42 self,
43 alarm: Alarm,
44 trigger: datetime,
45 acknowledged_until: Optional[datetime] = None,
46 snoozed_until: Optional[datetime] = None,
47 parent: Optional[Parent] = None,
48 ):
49 """Create a new AlarmTime.
50
51 alarm
52 the Alarm component
53
54 trigger
55 a date or datetime at which to trigger the alarm
56
57 acknowledged_until
58 an optional datetime in UTC until when all alarms
59 have been acknowledged
60
61 snoozed_until
62 an optional datetime in UTC until which all alarms of
63 the same parent are snoozed
64
65 parent
66 the optional parent component the alarm refers to
67
68 local_tzinfo
69 the local timezone that events without tzinfo should have
70 """
71 self._alarm = alarm
72 self._parent = parent
73 self._trigger = trigger
74 self._last_ack = acknowledged_until
75 self._snooze_until = snoozed_until
76
77 @property
78 def acknowledged(self) -> Optional[datetime]:
79 """The time in UTC at which this alarm was last acknowledged.
80
81 If the alarm was not acknowledged (dismissed), then this is None.
82 """
83 ack = self.alarm.ACKNOWLEDGED
84 if ack is None:
85 return self._last_ack
86 if self._last_ack is None:
87 return ack
88 return max(ack, self._last_ack)
89
90 @property
91 def alarm(self) -> Alarm:
92 """The alarm component."""
93 return self._alarm
94
95 @property
96 def parent(self) -> Optional[Parent]:
97 """This is the component that contains the alarm.
98
99 This is None if you did not use Alarms.set_component().
100 """
101 return self._parent
102
103 def is_active(self) -> bool:
104 """Whether this alarm is active (True) or acknowledged (False).
105
106 For example, in some calendar software, this is True until the user looks
107 at the alarm message and clicked the dismiss button.
108
109 Alarms can be in local time (without a timezone).
110 To calculate if the alarm really happened, we need it to be in a timezone.
111 If a timezone is required but not given, we throw an IncompleteAlarmInformation.
112 """
113 acknowledged = self.acknowledged
114 if not acknowledged:
115 # if nothing is acknowledged, this alarm counts
116 return True
117 if self._snooze_until is not None and self._snooze_until > acknowledged:
118 return True
119 trigger = self.trigger
120 if trigger.tzinfo is None:
121 raise LocalTimezoneMissing(
122 "A local timezone is required to check if the alarm is still active. "
123 "Use Alarms.set_local_timezone()."
124 )
125 return trigger > acknowledged
126
127 @property
128 def trigger(self) -> date:
129 """This is the time to trigger the alarm.
130
131 If the alarm has been snoozed, this can differ from the TRIGGER property.
132 """
133 if self._snooze_until is not None and self._snooze_until > self._trigger:
134 return self._snooze_until
135 return self._trigger
136
137
138class Alarms:
139 """Compute the times and states of alarms.
140
141 This is an example using RFC 9074.
142 One alarm is 30 minutes before the event and acknowledged.
143 Another alarm is 15 minutes before the event and still active.
144
145 >>> from icalendar import Event, Alarms
146 >>> event = Event.from_ical(
147 ... '''BEGIN:VEVENT
148 ... CREATED:20210301T151004Z
149 ... UID:AC67C078-CED3-4BF5-9726-832C3749F627
150 ... DTSTAMP:20210301T151004Z
151 ... DTSTART;TZID=America/New_York:20210302T103000
152 ... DTEND;TZID=America/New_York:20210302T113000
153 ... SUMMARY:Meeting
154 ... BEGIN:VALARM
155 ... UID:8297C37D-BA2D-4476-91AE-C1EAA364F8E1
156 ... TRIGGER:-PT30M
157 ... ACKNOWLEDGED:20210302T150004Z
158 ... DESCRIPTION:Event reminder
159 ... ACTION:DISPLAY
160 ... END:VALARM
161 ... BEGIN:VALARM
162 ... UID:8297C37D-BA2D-4476-91AE-C1EAA364F8E1
163 ... TRIGGER:-PT15M
164 ... DESCRIPTION:Event reminder
165 ... ACTION:DISPLAY
166 ... END:VALARM
167 ... END:VEVENT
168 ... ''')
169 >>> alarms = Alarms(event)
170 >>> len(alarms.times) # all alarms including those acknowledged
171 2
172 >>> len(alarms.active) # the alarms that are not acknowledged, yet
173 1
174 >>> alarms.active[0].trigger # this alarm triggers 15 minutes before 10:30
175 datetime.datetime(2021, 3, 2, 10, 15, tzinfo=ZoneInfo(key='America/New_York'))
176
177 RFC 9074 specifies that alarms can also be triggered by proximity.
178 This is not implemented yet.
179 """
180
181 def __init__(self, component: Optional[Alarm | Event | Todo] = None):
182 """Start computing alarm times."""
183 self._absolute_alarms: list[Alarm] = []
184 self._start_alarms: list[Alarm] = []
185 self._end_alarms: list[Alarm] = []
186 self._start: Optional[date] = None
187 self._end: Optional[date] = None
188 self._parent: Optional[Parent] = None
189 self._last_ack: Optional[datetime] = None
190 self._snooze_until: Optional[datetime] = None
191 self._local_tzinfo: Optional[tzinfo] = None
192
193 if component is not None:
194 self.add_component(component)
195
196 def add_component(self, component: Alarm | Parent):
197 """Add a component.
198
199 If this is an alarm, it is added.
200 Events and Todos are added as a parent and all
201 their alarms are added, too.
202 """
203 if isinstance(component, (Event, Todo)):
204 self.set_parent(component)
205 self.set_start(component.start)
206 self.set_end(component.end)
207 if component.is_thunderbird():
208 self.acknowledge_until(component.X_MOZ_LASTACK)
209 self.snooze_until(component.X_MOZ_SNOOZE_TIME)
210 else:
211 self.acknowledge_until(component.DTSTAMP)
212
213 for alarm in component.walk("VALARM"):
214 self.add_alarm(alarm)
215
216 def set_parent(self, parent: Parent):
217 """Set the parent of all the alarms.
218
219 If you would like to collect alarms from a component, use add_component
220 """
221 if self._parent is not None and self._parent is not parent:
222 raise ValueError("You can only set one parent for this alarm calculation.")
223 self._parent = parent
224
225 def add_alarm(self, alarm: Alarm) -> None:
226 """Optional: Add an alarm component."""
227 trigger = alarm.TRIGGER
228 if trigger is None:
229 return
230 if isinstance(trigger, date):
231 self._absolute_alarms.append(alarm)
232 elif alarm.TRIGGER_RELATED == "START":
233 self._start_alarms.append(alarm)
234 else:
235 self._end_alarms.append(alarm)
236
237 def set_start(self, dt: Optional[date]):
238 """Set the start of the component.
239
240 If you have only absolute alarms, this is not required.
241 If you have alarms relative to the start of a compoment, set the start here.
242 """
243 self._start = dt
244
245 def set_end(self, dt: Optional[date]):
246 """Set the end of the component.
247
248 If you have only absolute alarms, this is not required.
249 If you have alarms relative to the end of a compoment, set the end here.
250 """
251 self._end = dt
252
253 def _add(self, dt: date, td: timedelta):
254 """Add a timedelta to a datetime."""
255 if is_date(dt):
256 if td.seconds == 0:
257 return dt + td
258 dt = to_datetime(dt)
259 return normalize_pytz(dt + td)
260
261 def acknowledge_until(self, dt: Optional[date]) -> None:
262 """This is the time in UTC when all the alarms of this component were acknowledged.
263
264 Only the last call counts.
265
266 Since RFC 9074 (Alarm Extension) was created later,
267 calendar implementations differ in how they acknowledge alarms.
268 For example, Thunderbird and Google Calendar store the last time
269 an event has been acknowledged because of an alarm.
270 All alarms that happen before this time count as acknowledged.
271 """
272 self._last_ack = tzp.localize_utc(dt) if dt is not None else None
273
274 def snooze_until(self, dt: Optional[date]) -> None:
275 """This is the time in UTC when all the alarms of this component were snoozed.
276
277 Only the last call counts.
278
279 The alarms are supposed to turn up again at dt when they are not acknowledged
280 but snoozed.
281 """
282 self._snooze_until = tzp.localize_utc(dt) if dt is not None else None
283
284 def set_local_timezone(self, tzinfo: Optional[tzinfo | str]):
285 """Set the local timezone.
286
287 Events are sometimes in local time.
288 In order to compute the exact time of the alarm, some
289 alarms without timezone are considered local.
290
291 Some computations work without setting this, others don't.
292 If they need this information, expect a LocalTimezoneMissing exception
293 somewhere down the line.
294 """
295 self._local_tzinfo = tzp.timezone(tzinfo) if isinstance(tzinfo, str) else tzinfo
296
297 @property
298 def times(self) -> list[AlarmTime]:
299 """Compute and return the times of the alarms given.
300
301 If the information for calculation is incomplete, this will raise a
302 IncompleteAlarmInformation exception.
303
304 Please make sure to set all the required parameters before calculating.
305 If you forget to set the acknowledged times, that is not problem.
306 """
307 return (
308 self._get_end_alarm_times()
309 + self._get_start_alarm_times()
310 + self._get_absolute_alarm_times()
311 )
312
313 def _repeat(self, first: datetime, alarm: Alarm) -> Generator[datetime]:
314 """The times when the alarm is triggered relative to start."""
315 yield first # we trigger at the start
316 repeat = alarm.REPEAT
317 duration = alarm.DURATION
318 if repeat and duration:
319 for i in range(1, repeat + 1):
320 yield self._add(first, duration * i)
321
322 def _alarm_time(self, alarm: Alarm, trigger: date):
323 """Create an alarm time with the additional attributes."""
324 if getattr(trigger, "tzinfo", None) is None and self._local_tzinfo is not None:
325 trigger = normalize_pytz(trigger.replace(tzinfo=self._local_tzinfo))
326 return AlarmTime(
327 alarm, trigger, self._last_ack, self._snooze_until, self._parent
328 )
329
330 def _get_absolute_alarm_times(self) -> list[AlarmTime]:
331 """Return a list of absolute alarm times."""
332 return [
333 self._alarm_time(alarm, trigger)
334 for alarm in self._absolute_alarms
335 for trigger in self._repeat(alarm.TRIGGER, alarm)
336 ]
337
338 def _get_start_alarm_times(self) -> list[AlarmTime]:
339 """Return a list of alarm times relative to the start of the component."""
340 if self._start is None and self._start_alarms:
341 raise ComponentStartMissing(
342 "Use Alarms.set_start because at least one alarm is relative to the start of a component."
343 )
344 return [
345 self._alarm_time(alarm, trigger)
346 for alarm in self._start_alarms
347 for trigger in self._repeat(self._add(self._start, alarm.TRIGGER), alarm)
348 ]
349
350 def _get_end_alarm_times(self) -> list[AlarmTime]:
351 """Return a list of alarm times relative to the start of the component."""
352 if self._end is None and self._end_alarms:
353 raise ComponentEndMissing(
354 "Use Alarms.set_end because at least one alarm is relative to the end of a component."
355 )
356 return [
357 self._alarm_time(alarm, trigger)
358 for alarm in self._end_alarms
359 for trigger in self._repeat(self._add(self._end, alarm.TRIGGER), alarm)
360 ]
361
362 @property
363 def active(self) -> list[AlarmTime]:
364 """The alarm times that are still active and not acknowledged.
365
366 This considers snoozed alarms.
367
368 Alarms can be in local time (without a timezone).
369 To calculate if the alarm really happened, we need it to be in a timezone.
370 If a timezone is required but not given, we throw an IncompleteAlarmInformation.
371 """
372 return [alarm_time for alarm_time in self.times if alarm_time.is_active()]
373
374
375__all__ = [
376 "AlarmTime",
377 "Alarms",
378 "ComponentEndMissing",
379 "ComponentStartMissing",
380 "IncompleteAlarmInformation",
381]