# Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. import datetime import logging import os import os.path import icalendar import pytz class Yaml2IcalCalendar(icalendar.Calendar): """A calendar in ics format.""" def __init__(self, calname=None, caldescription=None): super(Yaml2IcalCalendar, self).__init__() self.add('prodid', '-//yaml2ical agendas//EN') self.add('version', '2.0') if calname is not None: self.add('X-WR-CALNAME', calname) if caldescription is not None: self.add('X-WR-CALDESC', caldescription) def add_meeting(self, meeting): """Add this meeting to the calendar.""" for sch in meeting.schedules: self.add_schedule(meeting, sch) def add_schedule(self, meeting, sch, exdate=None): event = icalendar.Event() # NOTE(jotan): I think the summary field needs to be unique per # event in an ical file (at least, for it to work with # Google Calendar) summary = meeting.project # NOTE(tonyb): If we're adding an a place holder event for a # cancelled meeting make that as obvious as possible in the # summary. if exdate: # NOTE(tonyb): Because some iCal consumers require that the # summary be unique, and adding multiple "CANCELLED: $x" # entries would violate that rule, append the (UTC) # timestamp for the cancelled meeting. suffix = exdate.date_str summary = 'CANCELLED: %s (%s)' % (summary, suffix) event.add('summary', summary) event.add('location', '#' + sch.irc) # add ical description project_descript = "Project: %s" % (meeting.project) chair_descript = "Chair: %s" % (meeting.chair) descript_descript = "Description: %s" % (meeting.description) ical_descript = "\n".join((project_descript, chair_descript, descript_descript)) # Add URLs, if present, to the description if 'agenda_url' in meeting.extras: ical_descript = "\n".join((ical_descript, "Agenda URL: %s" % (meeting.extras['agenda_url']))) if 'project_url' in meeting.extras: ical_descript = "\n".join((ical_descript, "Project URL: %s" % (meeting.extras['project_url']))) # NOTE(tonyb): If we're adding an a place holder event for a # cancelled meeting do not add an rrule and set dtstart to the # skipped date. if not exdate: # get starting date next_meeting = sch.recurrence.next_occurence(sch.start_date, sch.day) # NOTE(aschultz): to handle adhoc meetings, we check to make # sure there is a next meeting before trying to add it to the # the calendar if next_meeting is None: return next_meeting_date = datetime.datetime(next_meeting.year, next_meeting.month, next_meeting.day, sch.time.hour, sch.time.minute, tzinfo=pytz.utc) event.add('dtstart', next_meeting_date) # add recurrence rule event.add('rrule', sch.recurrence.rrule()) event.add('description', ical_descript) else: event.add('dtstart', exdate.date) event.add('description', exdate.reason) event.add('duration', datetime.timedelta(minutes=sch.duration)) # Add exdate (exclude date) if present if not exdate and hasattr(sch, 'skip_dates'): for skip_date in sch.skip_dates: event.add('exdate', skip_date.date) # NOTE(tonyb): If this is a skipped meeting add a # non-recurring event with an obvious summary. self.add_schedule(meeting, sch, exdate=skip_date) # Add update timestamp and unique ID for the event. # Note: we need a way to uniquely identify the meeting, even if its # details are modified. We don't really have much to go on to do # that, so best effort just use the project name and date. event.add('dtstamp', meeting.last_update) start_date = exdate.date if exdate else next_meeting_date event.add('uid', '%s-%s' % ( meeting.project.replace(' ', '').lower(), datetime.datetime.strftime(start_date, '%Y%m%d'))) # add event to calendar self.add_component(event) def write_to_disk(self, filename): # write ical files to disk with open(filename, 'wb') as ics: ics.write(self.to_ical()) def convert_meetings_to_ical(meetings, outputdir=None, outputfile=None, calname=None, caldescription=None): """Converts a meeting list to iCal. :param meetings: list of meetings to convert :param outputdir: location to store iCal files (one file per meeting) :param outputfile: output iCal file (one single file for all meetings) """ # convert meetings to a list of ical if outputdir: for m in meetings: cal = Yaml2IcalCalendar() cal.add_meeting(m) cal.write_to_disk(os.path.join(outputdir, m.outfile)) # convert meetings into a single ical if outputfile: cal = Yaml2IcalCalendar(calname, caldescription) for m in meetings: cal.add_meeting(m) cal.write_to_disk(outputfile) # TODO(jotan): verify converted ical is valid logging.info('Wrote %d meetings to iCal' % (len(meetings)))