1"""
2Display upcoming Google Calendar events.
3
4This module will display information about upcoming Google Calendar events
5in one of two formats which can be toggled with a button press. The event
6URL may also be opened in a web browser with a button press.
7
8Some events details can be retreived in the Google Calendar API Documentation.
9https://developers.google.com/calendar/v3/reference/events
10
11Configuration parameters:
12    auth_token: The path to where the access/refresh token will be saved
13        after successful credential authorization.
14        (default '~/.config/py3status/google_calendar.auth_token')
15    blacklist_events: Event names in this list will not be shown in the module
16        (case insensitive).
17        (default [])
18    browser_invocation: Command to run to open browser. Curly braces stands for URL opened.
19        (default "xdg-open {}")
20    button_open: Opens the event URL in the default web browser.
21        (default 3)
22    button_refresh: Refreshes the module and updates the list of events.
23        (default 2)
24    button_toggle: Toggles a boolean to hide/show the data for each event.
25        (default 1)
26    cache_timeout: How often the module is refreshed in seconds
27        (default 60)
28    client_secret: the path to your client_secret file which
29        contains your OAuth 2.0 credentials.
30        (default '~/.config/py3status/google_calendar.client_secret')
31    events_within_hours: Select events within the next given hours.
32        (default 12)
33    force_lowercase: Sets whether to force all event output to lower case.
34        (default False)
35    format: The format for module output.
36        (default '{events}|\\?color=event \u2687')
37    format_date: The format for date related format placeholders.
38        May be any Python strftime directives for dates.
39        (default '%a %d-%m')
40    format_event: The format for each event. The information can be toggled
41        with 'button_toggle' based on the value of 'is_toggled'.
42        *(default '[\\?color=event {summary}][\\?if=is_toggled  ({start_time}'
43        ' - {end_time}, {start_date})|[ ({location})][ {format_timer}]]')*
44    format_notification: The format for event warning notifications.
45        (default '{summary} {start_time} - {end_time}')
46    format_separator: The string used to separate individual events.
47        (default ' \\| ')
48    format_time: The format for time-related placeholders except `{format_timer}`.
49        May use any Python strftime directives for times.
50        (default '%I:%M %p')
51    format_timer: The format used for the {format_timer} placeholder to display
52        time until an event starts or time until an event in progress is over.
53        *(default '\\?color=time ([\\?if=days {days}d ][\\?if=hours {hours}h ]'
54        '[\\?if=minutes {minutes}m])[\\?if=is_current  left]')*
55    ignore_all_day_events: Sets whether to display all day events or not.
56        (default False)
57    num_events: The maximum number of events to display.
58        (default 3)
59    preferred_event_link: link to open in the browser.
60        accepted values :
61        hangoutLink (open the VC room associated with the event),
62        htmlLink (open the event's details in Google Calendar)
63        fallback to htmlLink if the preferred_event_link does not exist it the event.
64        (default "htmlLink")
65    response: Only display events for which the response status is
66        on the list.
67        Available values in the Google Calendar API's documentation,
68        look for the attendees[].responseStatus.
69        (default ['accepted'])
70    thresholds: Thresholds for events. The first entry is the color for event 1,
71        the second for event 2, and so on.
72        (default [])
73    time_to_max: Threshold (in minutes) for when to display the `{format_timer}`
74        string; e.g. if time_to_max is 60, `{format_timer}` will only be
75        displayed for events starting in 60 minutes or less.
76        (default 180)
77    warn_threshold: The number of minutes until an event starts before a
78        warning is displayed to notify the user; e.g. if warn_threshold is 30
79        and an event is starting in 30 minutes or less, a notification will be
80        displayed. disabled by default.
81        (default 0)
82    warn_timeout: The number of seconds before a warning should be issued again.
83        (default 300)
84
85
86Control placeholders:
87    {is_toggled} a boolean toggled by button_toggle
88
89Format placeholders:
90    {events} All the events to display.
91
92format_event and format_notification placeholders:
93    {description} The description for the calendar event.
94    {end_date} The end date for the event.
95    {end_time} The end time for the event.
96    {location} The location for the event.
97    {start_date} The start date for the event.
98    {start_time} The start time for the event.
99    {summary} The summary (i.e. title) for the event.
100    {format_timer} The time until the event starts (or until it is over
101        if already in progress).
102
103format_timer placeholders:
104    {days} The number of days until the event.
105    {hours} The number of hours until the event.
106    {minutes} The number of minutes until the event.
107
108Color options:
109    color_event: Color for a single event.
110    color_time: Color for the time associated with each event.
111
112Requires:
113    1. Python library google-api-python-client.
114    2. Python library python-dateutil.
115    3. OAuth 2.0 credentials for the Google Calendar api.
116
117    Follow Step 1 of the guide here to obtain your OAuth 2.0 credentials:
118    https://developers.google.com/google-apps/calendar/quickstart/python
119
120    Download the client_secret.json file which contains your client ID and
121    client secret. In your config file, set configuration parameter
122    client_secret to the path to your client_secret.json file.
123
124    The first time you run the module, a browser window will open asking you
125    to authorize access to your calendar. After authorization is complete,
126    an access/refresh token will be saved to the path configured in
127    auth_token, and i3status will be restarted. This restart will
128    occur only once after the first time you successfully authorize.
129
130Examples:
131```
132# add color gradients for events and dates/times
133google_calendar {
134    thresholds = {
135        'event': [(1, '#d0e6ff'), (2, '#bbdaff'), (3, '#99c7ff'),
136            (4, '#86bcff'), (5, '#62a9ff'), (6, '#8c8cff'), (7, '#7979ff')],
137        'time': [(1, '#ffcece'), (2, '#ffbfbf'), (3, '#ff9f9f'),
138            (4, '#ff7f7f'), (5, '#ff5f5f'), (6, '#ff3f3f'), (7, '#ff1f1f')]
139    }
140}
141```
142
143@author Igor Grebenkov
144@license BSD
145
146SAMPLE OUTPUT
147[
148   {'full_text': "Homer's Birthday (742 Evergreen Terrace) (1h 23m) | "},
149   {'full_text': "Doctor's Appointment | Lunch with John"},
150]
151"""
152
153import httplib2
154import datetime
155import time
156from pathlib import Path
157
158try:
159    from googleapiclient import discovery
160except ImportError:
161    from apiclient import discovery
162from oauth2client import client
163from oauth2client import clientsecrets
164from oauth2client import tools
165from oauth2client.file import Storage
166from httplib2 import ServerNotFoundError
167from dateutil import parser
168from dateutil.tz import tzlocal
169
170SCOPES = "https://www.googleapis.com/auth/calendar.readonly"
171APPLICATION_NAME = "py3status google_calendar module"
172
173
174class Py3status:
175    """
176    """
177
178    # available configuration parameters
179    auth_token = "~/.config/py3status/google_calendar.auth_token"
180    blacklist_events = []
181    browser_invocation = "xdg-open {}"
182    button_open = 3
183    button_refresh = 2
184    button_toggle = 1
185    cache_timeout = 60
186    client_secret = "~/.config/py3status/google_calendar.client_secret"
187    events_within_hours = 12
188    force_lowercase = False
189    format = "{events}|\\?color=event \u2687"
190    format_date = "%a %d-%m"
191    format_event = (
192        r"[\?color=event {summary}][\?if=is_toggled  ({start_time}"
193        " - {end_time}, {start_date})|[ ({location})][ {format_timer}]]"
194    )
195    format_notification = "{summary} {start_time} - {end_time}"
196    format_separator = r" \| "
197    format_time = "%I:%M %p"
198    format_timer = (
199        r"\?color=time ([\?if=days {days}d ][\?if=hours {hours}h ]"
200        r"[\?if=minutes {minutes}m])[\?if=is_current  left]"
201    )
202    ignore_all_day_events = False
203    num_events = 3
204    preferred_event_link = "htmlLink"
205    response = ["accepted"]
206    thresholds = []
207    time_to_max = 180
208    warn_threshold = 0
209    warn_timeout = 300
210
211    def post_config_hook(self):
212        self.button_states = [False] * self.num_events
213        self.events = None
214        self.no_update = False
215
216        self.client_secret = Path(self.client_secret).expanduser()
217        self.auth_token = Path(self.auth_token).expanduser()
218
219        self.credentials = self._get_credentials()
220        self.is_authorized = False
221
222    def _get_credentials(self):
223        """
224        Gets valid user credentials from storage.
225
226        If nothing has been stored, or if the stored credentials are invalid,
227        the OAuth2 flow is completed to obtain the new credentials.
228
229        Returns: Credentials, the obtained credential.
230        """
231        client_secret_path = self.client_secret.parent
232        auth_token_path = self.auth_token.parent
233
234        auth_token_path.mkdir(parents=True, exist_ok=True)
235        client_secret_path.mkdir(parents=True, exist_ok=True)
236
237        flags = tools.argparser.parse_args(args=[])
238        store = Storage(self.auth_token)
239        credentials = store.get()
240
241        if not credentials or credentials.invalid:
242            try:
243                flow = client.flow_from_clientsecrets(self.client_secret, SCOPES)
244                flow.user_agent = APPLICATION_NAME
245                if flags:
246                    credentials = tools.run_flow(flow, store, flags)
247                else:  # Needed only for compatibility with Python 2.6
248                    credentials = tools.run(flow, store)
249            except clientsecrets.InvalidClientSecretsError:
250                raise Exception("missing client_secret")
251            """
252            Have to restart i3 after getting credentials to prevent bad output.
253            This only has to be done once on the first run of the module.
254            """
255            self.py3.command_run(f"{self.py3.get_wm_msg()} restart")
256
257        return credentials
258
259    def _authorize_credentials(self):
260        """
261        Fetches an access/refresh token by authorizing OAuth 2.0 credentials.
262
263        Returns: True, if the authorization was successful.
264                 False, if a ServerNotFoundError is thrown.
265        """
266        try:
267            http = self.credentials.authorize(httplib2.Http())
268            self.service = discovery.build("calendar", "v3", http=http)
269            return True
270        except ServerNotFoundError:
271            return False
272
273    def _get_events(self):
274        """
275        Fetches events from the calendar into a list.
276
277        Returns: The list of events.
278        """
279        self.last_update = time.perf_counter()
280        time_min = datetime.datetime.utcnow()
281        time_max = time_min + datetime.timedelta(hours=self.events_within_hours)
282        events = []
283
284        try:
285            eventsResult = (
286                self.service.events()
287                .list(
288                    calendarId="primary",
289                    timeMax=time_max.isoformat() + "Z",  # 'Z' indicates UTC time
290                    timeMin=time_min.isoformat() + "Z",  # 'Z' indicates UTC time
291                    singleEvents=True,
292                    orderBy="startTime",
293                )
294                .execute(num_retries=5)
295            )
296        except Exception:
297            return self.events or events
298        else:
299            for event in eventsResult.get("items", []):
300                # filter out events that we did not accept (default)
301                # unless we organized them with no attendees
302                i_organized = event.get("organizer", {}).get("self", False)
303                has_attendees = event.get("attendees", [])
304                for attendee in event.get("attendees", []):
305                    if attendee.get("self") is True:
306                        if attendee["responseStatus"] in self.response:
307                            break
308                else:
309                    # we did not organize the event or we did not accept it
310                    if not i_organized or has_attendees:
311                        continue
312
313                # strip and lower case output if needed
314                for key in ["description", "location", "summary"]:
315                    event[key] = event.get(key, "").strip()
316                    if self.force_lowercase is True:
317                        event[key] = event[key].lower()
318
319                # ignore all day events if configured
320                if event["start"].get("date") is not None:
321                    if self.ignore_all_day_events:
322                        continue
323
324                # filter out blacklisted event names
325                if event["summary"] is not None:
326                    if event["summary"].lower() in (
327                        e.lower() for e in self.blacklist_events
328                    ):
329                        continue
330
331                events.append(event)
332
333        return events[: self.num_events]
334
335    def _check_warn_threshold(self, time_to, event_dict):
336        """
337        Checks if the time until an event starts is less than or equal to the
338        warn_threshold. If True, issue a warning with self.py3.notify_user.
339        """
340        if time_to["total_minutes"] <= self.warn_threshold:
341            warn_message = self.py3.safe_format(self.format_notification, event_dict)
342            self.py3.notify_user(warn_message, "warning", self.warn_timeout)
343
344    def _gstr_to_date(self, date_str):
345        """ Returns a dateime object from calendar date string."""
346        return parser.parse(date_str).replace(tzinfo=tzlocal())
347
348    def _gstr_to_datetime(self, date_time_str):
349        """ Returns a datetime object from calendar date/time string."""
350        return parser.parse(date_time_str)
351
352    def _datetime_to_str(self, date_time, dt_format):
353        """ Returns a strftime formatted string from a datetime object."""
354        return date_time.strftime(dt_format)
355
356    def _delta_time(self, date_time):
357        """
358        Returns in a dict the number of days/hours/minutes and total minutes
359        until date_time.
360        """
361        now = datetime.datetime.now(tzlocal())
362        diff = date_time - now
363
364        days = int(diff.days)
365        hours = int(diff.seconds / 3600)
366        minutes = int((diff.seconds / 60) - (hours * 60)) + 1
367        total_minutes = int((diff.seconds / 60) + (days * 24 * 60)) + 1
368
369        return {
370            "days": days,
371            "hours": hours,
372            "minutes": minutes,
373            "total_minutes": total_minutes,
374        }
375
376    def _format_timedelta(self, index, time_delta, is_current):
377        """
378        Formats the dict time_to containing days/hours/minutes until an
379        event starts into a composite according to time_to_formatted.
380
381        Returns: A formatted composite.
382        """
383        time_delta_formatted = ""
384
385        if time_delta["total_minutes"] <= self.time_to_max:
386            time_delta_formatted = self.py3.safe_format(
387                self.format_timer,
388                {
389                    "days": time_delta["days"],
390                    "hours": time_delta["hours"],
391                    "minutes": time_delta["minutes"],
392                    "is_current": is_current,
393                },
394            )
395
396        return time_delta_formatted
397
398    def _build_response(self):
399        """
400        Builds the composite response to be output by the module by looping
401        through all events and formatting the necessary strings.
402
403        Returns: A composite containing the individual response for each event.
404        """
405        responses = []
406        self.event_urls = []
407
408        for index, event in enumerate(self.events):
409            self.py3.threshold_get_color(index + 1, "event")
410            self.py3.threshold_get_color(index + 1, "time")
411
412            event_dict = {}
413
414            event_dict["summary"] = event.get("summary")
415            event_dict["location"] = event.get("location")
416            event_dict["description"] = event.get("description")
417            self.event_urls.append(
418                event.get(self.preferred_event_link, event.get("htmlLink"))
419            )
420
421            if event["start"].get("date") is not None:
422                start_dt = self._gstr_to_date(event["start"].get("date"))
423                end_dt = self._gstr_to_date(event["end"].get("date"))
424            else:
425                start_dt = self._gstr_to_datetime(event["start"].get("dateTime"))
426                end_dt = self._gstr_to_datetime(event["end"].get("dateTime"))
427
428            if end_dt < datetime.datetime.now(tzlocal()):
429                continue
430
431            event_dict["start_time"] = self._datetime_to_str(start_dt, self.format_time)
432            event_dict["end_time"] = self._datetime_to_str(end_dt, self.format_time)
433
434            event_dict["start_date"] = self._datetime_to_str(start_dt, self.format_date)
435            event_dict["end_date"] = self._datetime_to_str(end_dt, self.format_date)
436
437            time_delta = self._delta_time(start_dt)
438            if time_delta["days"] < 0:
439                time_delta = self._delta_time(end_dt)
440                is_current = True
441            else:
442                is_current = False
443
444            event_dict["format_timer"] = self._format_timedelta(
445                index, time_delta, is_current
446            )
447
448            if self.warn_threshold > 0:
449                self._check_warn_threshold(time_delta, event_dict)
450
451            event_formatted = self.py3.safe_format(
452                self.format_event,
453                {
454                    "is_toggled": self.button_states[index],
455                    "summary": event_dict["summary"],
456                    "location": event_dict["location"],
457                    "description": event_dict["description"],
458                    "start_time": event_dict["start_time"],
459                    "end_time": event_dict["end_time"],
460                    "start_date": event_dict["start_date"],
461                    "end_date": event_dict["end_date"],
462                    "format_timer": event_dict["format_timer"],
463                },
464            )
465
466            self.py3.composite_update(event_formatted, {"index": index})
467            responses.append(event_formatted)
468
469            self.no_update = False
470
471        format_separator = self.py3.safe_format(self.format_separator)
472        self.py3.composite_update(format_separator, {"index": "sep"})
473        responses = self.py3.composite_join(format_separator, responses)
474
475        return {"events": responses}
476
477    def google_calendar(self):
478        """
479        The method that outputs the response.
480
481        First, we check credential authorization. If no authorization, we
482        display an error message, and try authorizing again in 5 seconds.
483
484        Otherwise, we fetch the events, build the response, and output
485        the resulting composite.
486        """
487        composite = {}
488
489        if not self.is_authorized:
490            cached_until = 0
491            self.is_authorized = self._authorize_credentials()
492        else:
493            if not self.no_update:
494                self.events = self._get_events()
495
496            composite = self._build_response()
497            cached_until = self.cache_timeout
498
499        return {
500            "cached_until": self.py3.time_in(cached_until),
501            "composite": self.py3.safe_format(self.format, composite),
502        }
503
504    def on_click(self, event):
505        if self.is_authorized and self.events is not None:
506            """
507            If button_refresh is clicked, we allow the events to be updated
508            if the last event update occurred at least 1 second ago. This
509            prevents a bug that can crash py3status since refreshing the
510            module too fast results in incomplete event information being
511            fetched as _get_events() is called repeatedly.
512
513            Otherwise, we disable event updates.
514            """
515            self.no_update = True
516            button = event["button"]
517            button_index = event["index"]
518
519            if button_index == "sep":
520                self.py3.prevent_refresh()
521            elif button == self.button_refresh:
522                # wait before the next refresh
523                if time.perf_counter() - self.last_update > 1:
524                    self.no_update = False
525            elif button == self.button_toggle:
526                self.button_states[button_index] = not self.button_states[button_index]
527            elif button == self.button_open:
528                if self.event_urls:
529                    self.py3.command_run(
530                        self.browser_invocation.format(self.event_urls[button_index])
531                    )
532                self.py3.prevent_refresh()
533            else:
534                self.py3.prevent_refresh()
535
536
537if __name__ == "__main__":
538    """
539    Run module in test mode.
540    """
541    from py3status.module_test import module_test
542
543    module_test(Py3status)
544