Fix gkeep renderer's f-strings.
[kiosk.git] / gcal_renderer.py
1 #!/usr/bin/env python3
2
3 """Renders an upcoming events page and countdowns page based on the
4 contents of several Google calendars."""
5
6 import datetime
7 import gdata
8 import gdata_oauth
9 from oauth2client.client import AccessTokenRefreshError
10 import os
11 import time
12 from typing import Dict, List, Tuple
13
14 import constants
15 import file_writer
16 import globals
17 import renderer
18 import secrets
19
20
21 class gcal_renderer(renderer.debuggable_abstaining_renderer):
22     """A renderer to fetch upcoming events from www.google.com/calendar"""
23
24     calendar_whitelist = frozenset(
25         [
26             "Alex's calendar",
27             "Family",
28             "Holidays in United States",
29             "Lynn Gasch",
30             "Lynn's Work",
31             "[email protected]",
32             "Scott Gasch External - Misc",
33             "Birthdays",  # <-- from g+ contacts
34         ]
35     )
36
37     class comparable_event(object):
38         """A helper class to sort events."""
39
40         def __init__(
41             self,
42             start_time: datetime.datetime,
43             end_time: datetime.datetime,
44             summary: str,
45             calendar: str,
46         ) -> None:
47             if start_time is None:
48                 assert end_time is None
49             self.start_time = start_time
50             self.end_time = end_time
51             self.summary = summary
52             self.calendar = calendar
53
54         def __lt__(self, that) -> bool:
55             if self.start_time is None and that.start_time is None:
56                 return self.summary < that.summary
57             if self.start_time is None or that.start_time is None:
58                 return self.start_time is None
59             return (self.start_time, self.end_time, self.summary, self.calendar) < (
60                 that.start_time,
61                 that.end_time,
62                 that.summary,
63                 that.calendar,
64             )
65
66         def __str__(self) -> str:
67             return "[%s]&nbsp;%s" % (self.timestamp(), self.friendly_name())
68
69         def friendly_name(self) -> str:
70             name = self.summary
71             name = name.replace("countdown:", "")
72             return "<B>%s</B>" % name
73
74         def timestamp(self) -> str:
75             if self.start_time is None:
76                 return "None"
77             elif self.start_time.hour == 0:
78                 return datetime.datetime.strftime(self.start_time, "%a %b %d %Y")
79             else:
80                 return datetime.datetime.strftime(
81                     self.start_time, "%a %b %d %Y %H:%M%p"
82                 )
83
84     def __init__(
85         self, name_to_timeout_dict: Dict[str, int], oauth: gdata_oauth.OAuth
86     ) -> None:
87         super(gcal_renderer, self).__init__(name_to_timeout_dict, True)
88         self.oauth = oauth
89         self.client = self.oauth.calendar_service()
90         self.sortable_events = []
91         self.countdown_events = []
92
93     def debug_prefix(self) -> str:
94         return "gcal"
95
96     def periodic_render(self, key: str) -> bool:
97         self.debug_print('called for "%s"' % key)
98         if key == "Render Upcoming Events":
99             return self.render_upcoming_events()
100         elif key == "Look For Triggered Events":
101             return self.look_for_triggered_events()
102         else:
103             raise error("Unexpected operation")
104
105     def get_min_max_timewindow(self) -> Tuple[str, str]:
106         now = datetime.datetime.now()
107         time_min = now - datetime.timedelta(1)
108         time_max = now + datetime.timedelta(95)
109         time_min, time_max = list(
110             map(
111                 lambda x: datetime.datetime.strftime(x, "%Y-%m-%dT%H:%M:%SZ"),
112                 (time_min, time_max),
113             )
114         )
115         print(type(time_min))
116         self.debug_print("time_min is %s" % time_min)
117         self.debug_print("time_max is %s" % time_max)
118         return (time_min, time_max)
119
120     @staticmethod
121     def parse_date(date_str: str) -> datetime.datetime:
122         retval = None
123         try:
124             _ = date_str.get("date")
125             if _:
126                 retval = datetime.datetime.strptime(_, "%Y-%m-%d")
127             else:
128                 _ = date_str.get("dateTime")
129                 if _:
130                     retval = datetime.datetime.strptime(_[:-6], "%Y-%m-%dT%H:%M:%S")
131             return retval
132         except:
133             pass
134         return None
135
136     def get_events_from_interesting_calendars(
137         self, time_min: str, time_max: str
138     ) -> Tuple[List[comparable_event], List[comparable_event]]:
139         page_token = None
140         sortable_events = []
141         countdown_events = []
142         while True:
143             calendar_list = (
144                 self.client.calendarList().list(pageToken=page_token).execute()
145             )
146             for calendar in calendar_list["items"]:
147                 if calendar["summary"] in gcal_renderer.calendar_whitelist:
148                     self.debug_print(
149                         f"{calendar['summary']} is an interesting calendar..."
150                     )
151                     events = (
152                         self.client.events()
153                         .list(
154                             calendarId=calendar["id"],
155                             singleEvents=True,
156                             timeMin=time_min,
157                             timeMax=time_max,
158                             maxResults=50,
159                         )
160                         .execute()
161                     )
162                     for event in events["items"]:
163                         summary = event["summary"]
164                         self.debug_print(
165                             f" ... event '{summary}' ({event['start']} to {event['end']}"
166                         )
167                         start = gcal_renderer.parse_date(event["start"])
168                         end = gcal_renderer.parse_date(event["end"])
169                         if start is not None and end is not None:
170                             sortable_events.append(
171                                 gcal_renderer.comparable_event(
172                                     start, end, summary, calendar["summary"]
173                                 )
174                             )
175                             if (
176                                 "countdown" in summary
177                                 or "Holidays" in calendar["summary"]
178                                 or "Countdown" in summary
179                             ):
180                                 self.debug_print(" ... event is countdown worthy!")
181                                 countdown_events.append(
182                                     gcal_renderer.comparable_event(
183                                         start, end, summary, calendar["summary"]
184                                     )
185                                 )
186             page_token = calendar_list.get("nextPageToken")
187             if not page_token:
188                 break
189         return (sortable_events, countdown_events)
190
191     def render_upcoming_events(self) -> bool:
192         (time_min, time_max) = self.get_min_max_timewindow()
193         try:
194             # Populate the "Upcoming Events" page.
195             (
196                 self.sortable_events,
197                 self.countdown_events,
198             ) = self.get_events_from_interesting_calendars(time_min, time_max)
199             self.sortable_events.sort()
200             with file_writer.file_writer("gcal_3_86400.html") as f:
201                 f.write("<h1>Upcoming Calendar Events:</h1><hr>\n")
202                 f.write("<center><table width=96%>\n")
203                 upcoming_sortable_events = self.sortable_events[:12]
204                 for event in upcoming_sortable_events:
205                     f.write(
206                         f"""
207 <tr>
208   <td style="padding-right: 1em;">
209     {event.timestamp()}
210   </td>
211   <td style="padding-left: 1em;">
212     {event.friendly_name()}
213   </td>
214 </tr>\n"""
215                     )
216                 f.write("</table></center>\n")
217
218             # Populate the "Countdown" page.
219             self.countdown_events.sort()
220             with file_writer.file_writer("countdown_3_7200.html") as g:
221                 g.write("<h1>Countdowns:</h1><hr><ul>\n")
222                 now = datetime.datetime.now()
223                 upcoming_countdown_events = self.countdown_events[:12]
224                 count = 0
225                 timestamps = {}
226                 for event in upcoming_countdown_events:
227                     eventstamp = event.start_time
228                     name = event.friendly_name()
229                     delta = eventstamp - now
230                     x = int(delta.total_seconds())
231                     if x > 0:
232                         identifier = "id%d" % count
233                         days = divmod(x, constants.seconds_per_day)
234                         hours = divmod(days[1], constants.seconds_per_hour)
235                         minutes = divmod(hours[1], constants.seconds_per_minute)
236                         g.write(
237                             f'<li><SPAN id="%s">%d days, %02d:%02d</SPAN> until %s</li>\n'
238                             % (
239                                 identifier,
240                                 int(days[0]),
241                                 int(hours[0]),
242                                 int(minutes[0]),
243                                 name,
244                             )
245                         )
246                         timestamps[identifier] = time.mktime(eventstamp.timetuple())
247                         count += 1
248                         self.debug_print(
249                             "countdown to %s is %dd %dh %dm"
250                             % (name, days[0], hours[0], minutes[0])
251                         )
252                 g.write("</ul>")
253                 g.write("<SCRIPT>\nlet timestampMap = new Map([")
254                 for x in list(timestamps.keys()):
255                     g.write(f'    ["{x}", {timestamps[x] * 1000.0}],\n')
256                 g.write("]);\n\n")
257                 g.write(
258                     """
259 // Pad things with a leading zero if necessary.
260 function pad(n) {
261     return (n < 10) ? ("0" + n) : n;
262 }
263
264 // Return an 's' if things are plural.
265 function plural(n) {
266     return (n == 1) ? "" : "s";
267 }
268
269 // Periodic function to run the page timers.
270 var fn = setInterval(function() {
271     var now = new Date().getTime();
272     for (let [id, timestamp] of timestampMap) {
273         var delta = timestamp - now;
274
275         if (delta > 0) {
276             var days = Math.floor(delta / (1000 * 60 * 60 * 24));
277             var hours = pad(Math.floor((delta % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)));
278             var minutes = pad(Math.floor((delta % (1000 * 60 * 60)) / (1000 * 60)));
279             var seconds = pad(Math.floor((delta % (1000 * 60)) / 1000));
280
281             var s = days + " day" + plural(days) + ", ";
282             s = s + hours + ":" + minutes;
283             document.getElementById(id).innerHTML = s;
284         } else {
285             document.getElementById(id).innerHTML = "EXPIRED";
286         }
287     }
288 }, 1000);
289 </script>"""
290                 )
291             return True
292         except (gdata.service.RequestError, AccessTokenRefreshError):
293             print("********* TRYING TO REFRESH GCAL CLIENT *********")
294             self.oauth.refresh_token()
295             self.client = self.oauth.calendar_service()
296             return False
297         except:
298             raise
299
300     def look_for_triggered_events(self) -> bool:
301         with file_writer.file_writer(constants.gcal_imminent_pagename) as f:
302             f.write("<h1>Imminent Upcoming Calendar Events:</h1>\n<hr>\n")
303             f.write("<center><table width=99%>\n")
304             now = datetime.datetime.now()
305             count = 0
306             for event in self.sortable_events:
307                 eventstamp = event.start_time
308                 delta = eventstamp - now
309                 x = int(delta.total_seconds())
310                 if x > 0 and x <= constants.seconds_per_minute * 3:
311                     days = divmod(x, constants.seconds_per_day)
312                     hours = divmod(days[1], constants.seconds_per_hour)
313                     minutes = divmod(hours[1], constants.seconds_per_minute)
314                     eventstamp = event.start_time
315                     name = event.friendly_name()
316                     calendar = event.calendar
317                     f.write(
318                         f"<LI> {name} ({calendar}) upcoming in {int(minutes[0])} minutes.\n"
319                     )
320                     count += 1
321             f.write("</table>")
322         if count > 0:
323             globals.put("gcal_triggered", True)
324         else:
325             globals.put("gcal_triggered", False)
326         return True
327
328
329 # Test
330 # oauth = gdata_oauth.OAuth(secrets.google_client_id, secrets.google_client_secret)
331 # x = gcal_renderer(
332 #    {"Render Upcoming Events": 10000, "Look For Triggered Events": 1},
333 #    oauth)
334 # x.periodic_render("Render Upcoming Events")