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