Add generic calendar module. (#598)
This commit is contained in:
parent
fc2ae2a387
commit
2314277b94
214
i3pystatus/calendar/__init__.py
Normal file
214
i3pystatus/calendar/__init__.py
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
import inspect
|
||||||
|
import threading
|
||||||
|
from abc import abstractmethod
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from i3pystatus import IntervalModule, formatp, SettingsBase
|
||||||
|
from i3pystatus.core.color import ColorRangeModule
|
||||||
|
from i3pystatus.core.desktop import DesktopNotification
|
||||||
|
|
||||||
|
|
||||||
|
def strip_microseconds(delta):
|
||||||
|
return delta - timedelta(microseconds=delta.microseconds)
|
||||||
|
|
||||||
|
|
||||||
|
def formatter(func):
|
||||||
|
""" Decorator to mark a CalendarEvent method as a formatter. """
|
||||||
|
func.formatter = True
|
||||||
|
return func
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarEvent:
|
||||||
|
"""
|
||||||
|
Simple class representing an Event. The attributes title, start, end and recurring are required as
|
||||||
|
these will be used for the formatters. The id attribute is used to uniquely identify the event.
|
||||||
|
|
||||||
|
If a backend wishes to provide extra formatters to the user, this can be done by adding additional
|
||||||
|
methods and decorating them with the @formatter decorator. See the LightningCalendarEvent from the
|
||||||
|
lightning module for an example of this.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Unique identifier for this event
|
||||||
|
id = None
|
||||||
|
|
||||||
|
# The title of this event
|
||||||
|
title = None
|
||||||
|
|
||||||
|
# Datetime object representing when this event begins
|
||||||
|
start = None
|
||||||
|
|
||||||
|
# Datetime object representing when this event ends
|
||||||
|
end = None
|
||||||
|
|
||||||
|
# Whether or not this event is a recurring event
|
||||||
|
recurring = False
|
||||||
|
|
||||||
|
def formatters(self):
|
||||||
|
"""
|
||||||
|
Build a dictionary containing all those key/value pairs that will be exposed to the user via formatters.
|
||||||
|
"""
|
||||||
|
event_dict = dict(
|
||||||
|
title=self.title,
|
||||||
|
remaining=self.time_remaining
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_formatter(x):
|
||||||
|
return inspect.ismethod(x) and hasattr(x, 'formatter') and getattr(x, 'formatter')
|
||||||
|
|
||||||
|
for method_name, method in inspect.getmembers(self, is_formatter):
|
||||||
|
event_dict[method_name] = method()
|
||||||
|
return event_dict
|
||||||
|
|
||||||
|
@property
|
||||||
|
def time_remaining(self):
|
||||||
|
return strip_microseconds(self.start - datetime.now(tz=self.start.tzinfo))
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "{}(title='{}', start={}, end={}, recurring={})" \
|
||||||
|
.format(type(self).__name__,
|
||||||
|
self.title,
|
||||||
|
repr(self.start),
|
||||||
|
repr(self.end),
|
||||||
|
self.recurring)
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarBackend(SettingsBase):
|
||||||
|
"""
|
||||||
|
Base class for calendar backend. Subclasses should implement update and populate the events list.
|
||||||
|
|
||||||
|
Optionally, subclasses can override on_click to perform actions on the current event when clicked.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def init(self):
|
||||||
|
self.events = []
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def update(self):
|
||||||
|
""" Subclasses should implement this method and populate the events list with CalendarEvents."""
|
||||||
|
|
||||||
|
def on_click(self, event):
|
||||||
|
""" Override this method to do more interesting things with the event. """
|
||||||
|
DesktopNotification(
|
||||||
|
title=event.title,
|
||||||
|
body="{} until {}!".format(event.time_remaining, event.title),
|
||||||
|
icon='dialog-information',
|
||||||
|
urgency=1,
|
||||||
|
timeout=0,
|
||||||
|
).display()
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return iter(self.events)
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self.events)
|
||||||
|
|
||||||
|
|
||||||
|
class Calendar(IntervalModule, ColorRangeModule):
|
||||||
|
"""
|
||||||
|
Generic calendar module. Requires the PyPI package ``colour``.
|
||||||
|
|
||||||
|
.. rubric:: Available formatters
|
||||||
|
|
||||||
|
* {title} - the title or summary of the event
|
||||||
|
* {remaining_time} - how long until this event is due
|
||||||
|
|
||||||
|
Additional formatters may be provided by the backend, consult their documentation for details.
|
||||||
|
"""
|
||||||
|
|
||||||
|
settings = (
|
||||||
|
('format', 'Format string to display in the bar'),
|
||||||
|
('backend', 'Backend to use for collecting calendar events'),
|
||||||
|
('skip_recurring', 'Whether or not to skip recurring events'),
|
||||||
|
('update_interval', "How often in seconds to call the backend's update method"),
|
||||||
|
('urgent_seconds', "When within this many seconds of the event, set the urgent flag"),
|
||||||
|
('urgent_blink', 'Whether or not to blink when within urgent_seconds of the event'),
|
||||||
|
('dynamic_color', 'Whether or not to change color as the event approaches'),
|
||||||
|
)
|
||||||
|
|
||||||
|
required = ('backend',)
|
||||||
|
|
||||||
|
skip_recurring = False
|
||||||
|
interval = 1
|
||||||
|
backend = None
|
||||||
|
update_interval = 600
|
||||||
|
dynamic_color = True
|
||||||
|
urgent_seconds = 300
|
||||||
|
urgent_blink = False
|
||||||
|
|
||||||
|
current_event = None
|
||||||
|
urgent_acknowledged = False
|
||||||
|
|
||||||
|
format = "{title} - {remaining}"
|
||||||
|
|
||||||
|
on_rightclick = 'handle_click'
|
||||||
|
on_leftclick = 'acknowledge'
|
||||||
|
|
||||||
|
def init(self):
|
||||||
|
self.condition = threading.Condition()
|
||||||
|
self.thread = threading.Thread(target=self.update_thread, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
self.colors = self.get_hex_color_range(self.end_color, self.start_color, self.urgent_seconds * 2)
|
||||||
|
|
||||||
|
def update_thread(self):
|
||||||
|
self.refresh_events()
|
||||||
|
while True:
|
||||||
|
with self.condition:
|
||||||
|
self.condition.wait(self.update_interval)
|
||||||
|
self.refresh_events()
|
||||||
|
|
||||||
|
def refresh_events(self):
|
||||||
|
self.backend.update()
|
||||||
|
|
||||||
|
def valid_event(ev):
|
||||||
|
if self.skip_recurring and ev.recurring:
|
||||||
|
return False
|
||||||
|
elif ev.time_remaining < timedelta(seconds=0):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
for event in self.backend:
|
||||||
|
if valid_event(event):
|
||||||
|
if self.current_event and self.current_event.id != event.id:
|
||||||
|
self.urgent_acknowledged = False
|
||||||
|
self.current_event = event
|
||||||
|
return
|
||||||
|
self.current_event = None
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
if self.current_event and self.current_event.time_remaining > timedelta(seconds=0):
|
||||||
|
self.output = {
|
||||||
|
"full_text": formatp(self.format, **self.current_event.formatters()),
|
||||||
|
"color": self.get_color() if self.dynamic_color else None,
|
||||||
|
"urgent": self.is_urgent()
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
self.output = {}
|
||||||
|
|
||||||
|
def handle_click(self):
|
||||||
|
if self.current_event:
|
||||||
|
self.backend.on_click(self.current_event)
|
||||||
|
|
||||||
|
def get_color(self):
|
||||||
|
if self.current_event.time_remaining.days > 0:
|
||||||
|
color = self.colors[-1]
|
||||||
|
else:
|
||||||
|
p = self.percentage(self.current_event.time_remaining.seconds, self.urgent_seconds)
|
||||||
|
color = self.get_gradient(p, self.colors)
|
||||||
|
return color
|
||||||
|
|
||||||
|
def is_urgent(self):
|
||||||
|
"""
|
||||||
|
Determine whether or not to set the urgent flag. If urgent_blink is set, toggles urgent flag
|
||||||
|
on and off every second.
|
||||||
|
"""
|
||||||
|
if not self.current_event:
|
||||||
|
return False
|
||||||
|
now = datetime.now(tz=self.current_event.start.tzinfo)
|
||||||
|
alert_time = now + timedelta(seconds=self.urgent_seconds)
|
||||||
|
urgent = alert_time > self.current_event.start
|
||||||
|
if urgent and self.urgent_blink:
|
||||||
|
urgent = now.second % 2 == 0 and not self.urgent_acknowledged
|
||||||
|
return urgent
|
||||||
|
|
||||||
|
def acknowledge(self):
|
||||||
|
self.urgent_acknowledged = not self.urgent_acknowledged
|
116
i3pystatus/calendar/google.py
Normal file
116
i3pystatus/calendar/google.py
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import datetime
|
||||||
|
from datetime import timezone
|
||||||
|
|
||||||
|
import httplib2
|
||||||
|
import oauth2client
|
||||||
|
import pytz
|
||||||
|
from apiclient import discovery
|
||||||
|
from dateutil import parser
|
||||||
|
from googleapiclient.errors import HttpError
|
||||||
|
from i3pystatus.calendar import CalendarBackend, CalendarEvent, formatter
|
||||||
|
from i3pystatus.core.util import user_open, require, internet
|
||||||
|
|
||||||
|
|
||||||
|
class GoogleCalendarEvent(CalendarEvent):
|
||||||
|
def __init__(self, google_event):
|
||||||
|
self.id = google_event['id']
|
||||||
|
self.title = google_event['summary']
|
||||||
|
self.start = self._parse_date(google_event['start'])
|
||||||
|
self.end = self._parse_date(google_event['end'])
|
||||||
|
self.recurring = 'recurringEventId' in google_event
|
||||||
|
self._link = google_event['htmlLink']
|
||||||
|
self._status = google_event['status']
|
||||||
|
self._kind = google_event['kind']
|
||||||
|
|
||||||
|
@formatter
|
||||||
|
def htmlLink(self):
|
||||||
|
return self._link
|
||||||
|
|
||||||
|
@formatter
|
||||||
|
def status(self):
|
||||||
|
return self._status
|
||||||
|
|
||||||
|
@formatter
|
||||||
|
def kind(self):
|
||||||
|
return self._kind
|
||||||
|
|
||||||
|
def _parse_date(self, date_section):
|
||||||
|
if 'dateTime' not in date_section:
|
||||||
|
result = parser.parse(date_section['date'])
|
||||||
|
else:
|
||||||
|
result = parser.parse(date_section['dateTime'])
|
||||||
|
return result.replace(tzinfo=timezone.utc).astimezone(tz=None)
|
||||||
|
|
||||||
|
|
||||||
|
class Google(CalendarBackend):
|
||||||
|
"""
|
||||||
|
Calendar backend for interacting with Google Calendar.
|
||||||
|
|
||||||
|
Requires the Google Calendar API package - https://developers.google.com/google-apps/calendar/quickstart/python.
|
||||||
|
Additionally requires the `colour`, `httplib2`, `oauth2client`, `pytz`, `apiclient` and `dateutil` modules.
|
||||||
|
|
||||||
|
.. rubric:: Available formatters
|
||||||
|
|
||||||
|
* `{kind}` — type of event
|
||||||
|
* `{status}` — eg, confirmed
|
||||||
|
* `{htmlLink}` — link to the calendar event
|
||||||
|
"""
|
||||||
|
|
||||||
|
settings = (
|
||||||
|
('credential_path', 'Path to credentials'),
|
||||||
|
('days', 'Only show events between now and this many days in the future'),
|
||||||
|
)
|
||||||
|
|
||||||
|
required = ('credential_path',)
|
||||||
|
|
||||||
|
days = 7
|
||||||
|
|
||||||
|
def init(self):
|
||||||
|
self.service = None
|
||||||
|
self.events = []
|
||||||
|
|
||||||
|
@require(internet)
|
||||||
|
def update(self):
|
||||||
|
if self.service is None:
|
||||||
|
self.connect_service()
|
||||||
|
self.refresh_events()
|
||||||
|
|
||||||
|
def on_click(self, event):
|
||||||
|
user_open(event.htmlLink())
|
||||||
|
|
||||||
|
def connect_service(self):
|
||||||
|
self.logger.debug("Connecting Service..")
|
||||||
|
self.credentials = oauth2client.file.Storage(self.credential_path).get()
|
||||||
|
self.service = discovery.build('calendar', 'v3', http=self.credentials.authorize(httplib2.Http()))
|
||||||
|
|
||||||
|
def refresh_events(self):
|
||||||
|
"""
|
||||||
|
Retrieve the next N events from Google.
|
||||||
|
"""
|
||||||
|
now = datetime.datetime.now(tz=pytz.UTC)
|
||||||
|
try:
|
||||||
|
now, later = self.get_timerange_formatted(now)
|
||||||
|
events_result = self.service.events().list(
|
||||||
|
calendarId='primary',
|
||||||
|
timeMin=now,
|
||||||
|
timeMax=later,
|
||||||
|
maxResults=10,
|
||||||
|
singleEvents=True,
|
||||||
|
orderBy='startTime',
|
||||||
|
timeZone='utc'
|
||||||
|
).execute()
|
||||||
|
self.events.clear()
|
||||||
|
for event in events_result.get('items', []):
|
||||||
|
self.events.append(GoogleCalendarEvent(event))
|
||||||
|
except HttpError as e:
|
||||||
|
if e.resp.status in (500, 503):
|
||||||
|
self.logger.warn("GoogleCalendar received %s while retrieving events" % e.resp.status)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_timerange_formatted(self, now):
|
||||||
|
"""
|
||||||
|
Return two ISO8601 formatted date strings, one for timeMin, the other for timeMax (to be consumed by get_events)
|
||||||
|
"""
|
||||||
|
later = now + datetime.timedelta(days=self.days)
|
||||||
|
return now.isoformat(), later.isoformat()
|
67
i3pystatus/calendar/khal_calendar.py
Normal file
67
i3pystatus/calendar/khal_calendar.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
import khal
|
||||||
|
import khal.cli
|
||||||
|
import khal.settings
|
||||||
|
from i3pystatus.calendar import CalendarBackend, CalendarEvent, formatter
|
||||||
|
|
||||||
|
|
||||||
|
class KhalEvent(CalendarEvent):
|
||||||
|
def __init__(self, khal_event):
|
||||||
|
self.id = khal_event.uid
|
||||||
|
self.start = khal_event.start_local
|
||||||
|
self.end = khal_event.end_local
|
||||||
|
self.title = khal_event.summary
|
||||||
|
self.recurring = khal_event.recurring
|
||||||
|
self._calendar = khal_event.calendar
|
||||||
|
|
||||||
|
@formatter
|
||||||
|
def calendar(self):
|
||||||
|
return self._calendar
|
||||||
|
|
||||||
|
|
||||||
|
class Khal(CalendarBackend):
|
||||||
|
"""
|
||||||
|
Backend for Khal. Requires `khal` to be installed.
|
||||||
|
|
||||||
|
.. rubric:: Available formatters
|
||||||
|
* `{calendar}` — Calendar event is from.
|
||||||
|
"""
|
||||||
|
|
||||||
|
settings = (
|
||||||
|
('config_path', 'Path to your khal.conf'),
|
||||||
|
('calendars', 'Restrict to these calendars pass as a list)'),
|
||||||
|
('days', 'Check for the next X days'),
|
||||||
|
)
|
||||||
|
|
||||||
|
required = ('config_path', 'calendars')
|
||||||
|
|
||||||
|
days = 7
|
||||||
|
|
||||||
|
config_path = None
|
||||||
|
calendars = None
|
||||||
|
|
||||||
|
def init(self):
|
||||||
|
self.collection = None
|
||||||
|
self.events = []
|
||||||
|
|
||||||
|
def open_connection(self):
|
||||||
|
self.logger.debug("Opening collection with config {}".format(self.config_path))
|
||||||
|
config = khal.settings.get_config(self.config_path)
|
||||||
|
self.collection = khal.cli.build_collection(config, None)
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
if self.collection is None:
|
||||||
|
self.open_connection()
|
||||||
|
events = []
|
||||||
|
for days in range(self.days):
|
||||||
|
events += list(self.collection.get_events_on(
|
||||||
|
date.today() + timedelta(days=days))
|
||||||
|
)
|
||||||
|
|
||||||
|
# filter out unwanted calendars
|
||||||
|
self.logger.debug("calendars %s" % self.calendars)
|
||||||
|
if self.calendars is not None:
|
||||||
|
events = [evt for evt in events if evt.calendar in self.calendars]
|
||||||
|
for event in events:
|
||||||
|
self.events.append(KhalEvent(event))
|
103
i3pystatus/calendar/lightning.py
Normal file
103
i3pystatus/calendar/lightning.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from dateutil.tz import tzlocal
|
||||||
|
from i3pystatus.calendar import CalendarEvent, CalendarBackend, formatter
|
||||||
|
|
||||||
|
|
||||||
|
class Flag:
|
||||||
|
PRIVATE = 1
|
||||||
|
HAS_ATTENDEES = 2
|
||||||
|
HAS_PROPERTIES = 4
|
||||||
|
EVENT_ALLDAY = 8
|
||||||
|
HAS_RECURRENCE = 16
|
||||||
|
HAS_EXCEPTIONS = 32
|
||||||
|
HAS_ATTACHMENTS = 64
|
||||||
|
HAS_RELATIONS = 128
|
||||||
|
HAS_ALARMS = 256
|
||||||
|
RECURRENCE_ID_ALLDAY = 512
|
||||||
|
|
||||||
|
|
||||||
|
class LightningCalendarEvent(CalendarEvent):
|
||||||
|
def __init__(self, row):
|
||||||
|
self.id = row['id']
|
||||||
|
self.title = row['title']
|
||||||
|
self._event_start = row['event_start']
|
||||||
|
self._event_start_tz = row['event_start_tz']
|
||||||
|
self._event_end = row['event_end']
|
||||||
|
self._event_end_tz = row['event_end_tz']
|
||||||
|
self._flags = row['flags']
|
||||||
|
self._location = row['location'] or ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def recurring(self):
|
||||||
|
return (self._flags & Flag.HAS_RECURRENCE) != 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def end(self):
|
||||||
|
return self._convert_date(self._event_end, self._event_end_tz)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def start(self):
|
||||||
|
return self._convert_date(self._event_start, self._event_start_tz)
|
||||||
|
|
||||||
|
@formatter
|
||||||
|
def location(self):
|
||||||
|
return self._location
|
||||||
|
|
||||||
|
def _convert_date(self, microseconds_from_epoch, timezone):
|
||||||
|
if timezone == 'floating':
|
||||||
|
tz = tzlocal()
|
||||||
|
else:
|
||||||
|
tz = pytz.timezone(timezone)
|
||||||
|
d = datetime.fromtimestamp(microseconds_from_epoch / 1000000, tz=pytz.UTC)
|
||||||
|
return d.astimezone(tz)
|
||||||
|
|
||||||
|
|
||||||
|
class Lightning(CalendarBackend):
|
||||||
|
"""
|
||||||
|
Backend for querying the Thunderbird's Lightning database. Requires `pytz` and `dateutil`.
|
||||||
|
|
||||||
|
.. rubric:: Available formatters
|
||||||
|
|
||||||
|
* `{location}` — Where the event occurs
|
||||||
|
"""
|
||||||
|
|
||||||
|
settings = (
|
||||||
|
('database_path', 'Path to local.sqlite.'),
|
||||||
|
('days', 'Only show events between now and this many days in the future'),
|
||||||
|
)
|
||||||
|
|
||||||
|
required = ('database_path',)
|
||||||
|
|
||||||
|
days = 7
|
||||||
|
|
||||||
|
database_path = None
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
with sqlite3.connect(self.database_path) as connection:
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
cursor = connection.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
event_start,
|
||||||
|
event_start_tz,
|
||||||
|
event_end,
|
||||||
|
event_end_tz,
|
||||||
|
flags,
|
||||||
|
cal_properties.value AS location
|
||||||
|
FROM cal_events
|
||||||
|
LEFT OUTER JOIN cal_properties ON cal_properties.item_id = id AND cal_properties.key = 'LOCATION'
|
||||||
|
WHERE
|
||||||
|
datetime(event_start / 1000000, 'unixepoch', 'localtime') < datetime('now', 'localtime', '+' || :days || ' days')
|
||||||
|
AND
|
||||||
|
datetime(event_start / 1000000, 'unixepoch', 'localtime') > datetime('now', 'localtime')
|
||||||
|
ORDER BY event_start ASC
|
||||||
|
""", dict(days=self.days))
|
||||||
|
self.events.clear()
|
||||||
|
for row in cursor:
|
||||||
|
self.events.append(LightningCalendarEvent(row))
|
||||||
|
cursor.close()
|
@ -1,212 +0,0 @@
|
|||||||
import datetime
|
|
||||||
import threading
|
|
||||||
|
|
||||||
import httplib2
|
|
||||||
import oauth2client
|
|
||||||
import pytz
|
|
||||||
from apiclient import discovery
|
|
||||||
from dateutil import parser
|
|
||||||
from googleapiclient.errors import HttpError
|
|
||||||
|
|
||||||
from i3pystatus import IntervalModule, logger
|
|
||||||
from i3pystatus.core.color import ColorRangeModule
|
|
||||||
from i3pystatus.core.util import user_open, internet, require
|
|
||||||
|
|
||||||
|
|
||||||
class GoogleCalendar(IntervalModule, ColorRangeModule):
|
|
||||||
"""
|
|
||||||
Simple module for displaying next Google Calendar event.
|
|
||||||
|
|
||||||
Requires the Google Calendar API package - https://developers.google.com/google-apps/calendar/quickstart/python.
|
|
||||||
Additionally requires the `colour`, `httplib2`, `oauth2client`, `pytz`, `apiclient` and `dateutil` modules.
|
|
||||||
|
|
||||||
All top level keys returned by the Google Calendar API can be used as formatters. Some
|
|
||||||
examples include:
|
|
||||||
|
|
||||||
.. rubric:: Available formatters
|
|
||||||
|
|
||||||
* `{kind}` — type of event
|
|
||||||
* `{status}` — eg, confirmed
|
|
||||||
* `{summary}` — essentially the title
|
|
||||||
* `{remaining_time}` - how long remaining until the event
|
|
||||||
* `{start_time}` - when this event starts
|
|
||||||
* `{htmlLink}` — link to the calendar event
|
|
||||||
|
|
||||||
|
|
||||||
"""
|
|
||||||
settings = (
|
|
||||||
('format', 'format string'),
|
|
||||||
("credential_path", "Path to credentials"),
|
|
||||||
("skip_recurring", "Skip recurring events."),
|
|
||||||
("update_interval", "How often (in seconds) to call the Goggle API and update events"),
|
|
||||||
("days", "Only show events between now and this many days in the future"),
|
|
||||||
("urgent_seconds", "Add urgent hint when this many seconds until event startTime"),
|
|
||||||
("urgent_blink", "Whether or not to blink when the within urgent_seconds of event start"),
|
|
||||||
("start_color", "Hex or English name for start of color range, eg '#00FF00' or 'green'"),
|
|
||||||
("end_color", "Hex or English name for end of color range, eg '#FF0000' or 'red'"),
|
|
||||||
)
|
|
||||||
|
|
||||||
required = ('credential_path',)
|
|
||||||
|
|
||||||
format = "{summary} ({remaining_time})"
|
|
||||||
credential_path = None
|
|
||||||
interval = 1
|
|
||||||
|
|
||||||
skip_recurring = True
|
|
||||||
update_interval = 60
|
|
||||||
days = 1
|
|
||||||
urgent_seconds = 300
|
|
||||||
urgent_blink = False
|
|
||||||
color = None
|
|
||||||
|
|
||||||
service = None
|
|
||||||
credentials = None
|
|
||||||
|
|
||||||
display_event = None
|
|
||||||
last_event_refresh = None
|
|
||||||
urgent_acknowledged = False
|
|
||||||
update_lock = threading.Lock()
|
|
||||||
|
|
||||||
on_rightclick = 'acknowledge'
|
|
||||||
on_leftclick = 'open_calendar'
|
|
||||||
|
|
||||||
def init(self):
|
|
||||||
self.colors = self.get_hex_color_range(self.end_color, self.start_color, self.urgent_seconds * 2)
|
|
||||||
self.last_event_refresh = datetime.datetime.now(tz=pytz.UTC) - datetime.timedelta(seconds=self.update_interval)
|
|
||||||
|
|
||||||
@require(internet)
|
|
||||||
def run(self):
|
|
||||||
if self.service is None:
|
|
||||||
self.connect_service()
|
|
||||||
now = datetime.datetime.now(tz=pytz.UTC)
|
|
||||||
if self.should_update(now):
|
|
||||||
threading.Thread(target=self.update_display_event, args=(now,), daemon=True).start()
|
|
||||||
self.refresh_output(now)
|
|
||||||
|
|
||||||
def should_update(self, now):
|
|
||||||
"""
|
|
||||||
Whether or not we should update events.
|
|
||||||
"""
|
|
||||||
wait_window = self.last_event_refresh + datetime.timedelta(seconds=self.update_interval)
|
|
||||||
if self.display_event is None:
|
|
||||||
should_update = wait_window < now
|
|
||||||
elif self.display_event['start_time'] < now:
|
|
||||||
should_update = True
|
|
||||||
elif wait_window < now:
|
|
||||||
should_update = True
|
|
||||||
else:
|
|
||||||
should_update = False
|
|
||||||
return should_update and not self.update_lock.locked()
|
|
||||||
|
|
||||||
def update_display_event(self, now):
|
|
||||||
"""
|
|
||||||
Call the Google API and attempt to update the current event.
|
|
||||||
"""
|
|
||||||
with self.update_lock:
|
|
||||||
logger.debug("Retrieving events...".format(threading.current_thread().name))
|
|
||||||
self.last_event_refresh = now
|
|
||||||
for event in self.get_events(now):
|
|
||||||
# If we don't have a dateTime just make do with a date.
|
|
||||||
if 'dateTime' not in event['start']:
|
|
||||||
event['start_time'] = pytz.utc.localize(parser.parse(event['start']['date']))
|
|
||||||
else:
|
|
||||||
event['start_time'] = parser.parse(event['start']['dateTime'])
|
|
||||||
|
|
||||||
if 'recurringEventId' in event and self.skip_recurring:
|
|
||||||
continue
|
|
||||||
elif event['start_time'] < now:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# It is possible for there to be no title...
|
|
||||||
if 'summary' not in event:
|
|
||||||
event['summary'] = '(no title)'
|
|
||||||
|
|
||||||
if self.display_event:
|
|
||||||
# If this is a new event, reset the urgent_acknowledged flag.
|
|
||||||
if self.display_event['id'] != event['id']:
|
|
||||||
self.urgent_acknowledged = False
|
|
||||||
self.display_event = event
|
|
||||||
return
|
|
||||||
self.display_event = None
|
|
||||||
|
|
||||||
def refresh_output(self, now):
|
|
||||||
"""
|
|
||||||
Build our output dict.
|
|
||||||
"""
|
|
||||||
if self.display_event:
|
|
||||||
start_time = self.display_event['start_time']
|
|
||||||
alert_time = now + datetime.timedelta(seconds=self.urgent_seconds)
|
|
||||||
self.display_event['remaining_time'] = str((start_time - now)).partition('.')[0]
|
|
||||||
urgent = self.is_urgent(alert_time, start_time, now)
|
|
||||||
color = self.get_color(now, start_time)
|
|
||||||
|
|
||||||
self.output = {
|
|
||||||
'full_text': self.format.format(**self.display_event),
|
|
||||||
'color': color,
|
|
||||||
'urgent': urgent
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
self.output = {
|
|
||||||
'full_text': "",
|
|
||||||
}
|
|
||||||
|
|
||||||
def is_urgent(self, alert_time, start_time, now):
|
|
||||||
"""
|
|
||||||
Determine whether or not to set the urgent flag. If urgent_blink is set, toggles urgent flag
|
|
||||||
on and off every second.
|
|
||||||
"""
|
|
||||||
urgent = alert_time > start_time
|
|
||||||
if urgent and self.urgent_blink:
|
|
||||||
urgent = now.second % 2 == 0 and not self.urgent_acknowledged
|
|
||||||
return urgent
|
|
||||||
|
|
||||||
def get_events(self, now):
|
|
||||||
"""
|
|
||||||
Retrieve the next N events from Google.
|
|
||||||
"""
|
|
||||||
events = []
|
|
||||||
try:
|
|
||||||
now, later = self.get_timerange_formatted(now)
|
|
||||||
events_result = self.service.events().list(
|
|
||||||
calendarId='primary',
|
|
||||||
timeMin=now,
|
|
||||||
timeMax=later,
|
|
||||||
maxResults=10,
|
|
||||||
singleEvents=True,
|
|
||||||
orderBy='startTime',
|
|
||||||
timeZone='utc'
|
|
||||||
).execute()
|
|
||||||
events = events_result.get('items', [])
|
|
||||||
except HttpError as e:
|
|
||||||
if e.resp.status in (500, 503):
|
|
||||||
logger.warn("GoogleCalendar received %s while retrieving events" % e.resp.status)
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
return events
|
|
||||||
|
|
||||||
def get_timerange_formatted(self, now):
|
|
||||||
"""
|
|
||||||
Return two ISO8601 formatted date strings, one for timeMin, the other for timeMax (to be consumed by get_events)
|
|
||||||
"""
|
|
||||||
later = now + datetime.timedelta(days=self.days)
|
|
||||||
return now.isoformat(), later.isoformat()
|
|
||||||
|
|
||||||
def get_color(self, now, start_time):
|
|
||||||
seconds_to_event = (start_time - now).seconds
|
|
||||||
v = self.percentage(seconds_to_event, self.urgent_seconds)
|
|
||||||
color = self.get_gradient(v, self.colors)
|
|
||||||
return color
|
|
||||||
|
|
||||||
def connect_service(self):
|
|
||||||
logger.debug("Connecting Service..")
|
|
||||||
self.credentials = oauth2client.file.Storage(self.credential_path).get()
|
|
||||||
self.service = discovery.build('calendar', 'v3', http=self.credentials.authorize(httplib2.Http()))
|
|
||||||
|
|
||||||
def open_calendar(self):
|
|
||||||
if self.display_event:
|
|
||||||
calendar_url = self.display_event.get('htmlLink', None)
|
|
||||||
if calendar_url:
|
|
||||||
user_open(calendar_url)
|
|
||||||
|
|
||||||
def acknowledge(self):
|
|
||||||
self.urgent_acknowledged = True
|
|
Loading…
Reference in New Issue
Block a user