import datetime import json import os import os.path from docutils.io import FileOutput from docutils import nodes from docutils.parsers import rst import icalendar import yaml class PendingICS(nodes.Element): def __init__(self, data_source, series_name, data): super(PendingICS, self).__init__() self._data_source = data_source self._series_name = series_name self._data = data class ICS(rst.Directive): option_spec = { 'source': rst.directives.unchanged, 'name': rst.directives.unchanged, } has_content = False def _load_data(self, env, data_source): rel_filename, filename = env.relfn2path(data_source) if data_source.endswith('.yaml'): with open(filename, 'r') as f: return yaml.load(f) elif data_source.endswith('.json'): with open(filename, 'r') as f: return json.load(f) else: raise NotImplementedError('cannot load file type of %s' % data_source) def run(self): env = self.state.document.settings.env try: data_source = self.options['source'] except KeyError: error = self.state_machine.reporter.error( 'No source set for ics directive', nodes.literal_block(self.block_text, self.block_text), line=self.lineno) return [error] try: series_name = self.options['name'] except KeyError: error = self.state_machine.reporter.error( 'No name set for ics directive', nodes.literal_block(self.block_text, self.block_text), line=self.lineno) return [error] data = self._load_data(env, data_source) node = PendingICS(data_source, series_name, data) node.document = self.state.document return [node] _global_calendar = icalendar.Calendar() _global_calendar.add('prodid', '-//releases.openstack.org//EN') _global_calendar.add('X-WR-CALNAME', 'OpenStack Release Schedule') def _format_description(node): "Given a node, get its text and remove line breaks in paragraphs." text = node.astext() parts = text.split('\n\n') return '\n\n'.join(p.replace('\n', ' ') for p in parts) def doctree_resolved(app, doctree, docname): builder = app.builder for node in doctree.traverse(PendingICS): series_name = node._series_name data = node._data app.info('building {} calendar'.format(series_name)) cal = icalendar.Calendar() cal.add('prodid', '-//releases.openstack.org//EN') cal.add('X-WR-CALNAME', '{} schedule'.format(series_name)) for week in data['cycle']: if not week.get('name'): continue event = icalendar.Event() summary = [] for item in week.get('x-project', []): try: # Look up the cross-reference name to get the # section, then get the title from the first child # node. title = doctree.ids[item].children[0].astext() except Exception as e: # NOTE(dhellmann): Catching "Exception" is a bit # ugly, but given the complexity of the expression # above there are a bunch of ways things might # fail. app.info('could not get title for {}: {}'.format(item, e)) title = item summary.append(title) if summary: summary_text = ' (' + '; '.join(summary) + ')' else: summary_text = '' event.add( 'summary', '{} {}{}'.format(series_name.title(), week['name'], summary_text), ) start = datetime.datetime.strptime(week['start'], '%Y-%m-%d') event.add('dtstart', icalendar.vDate(start.date())) # NOTE(dhellmann): ical assumes a time of midnight, so in # order to have the event span the final day of the week # we have to add an extra day. raw_end = datetime.datetime.strptime(week['end'], '%Y-%m-%d') end = raw_end + datetime.timedelta(days=1) event.add('dtend', icalendar.vDate(end.date())) # Look up the cross-reference name to get the # section, then add the full description to the # text. description = [ _format_description(doctree.ids[item]) for item in week.get('x-project', []) ] if description: event.add('description', '\n\n'.join(description)) cal.add_component(event) _global_calendar.add_component(event) output_full_name = os.path.join( builder.outdir, docname + '.ics', ) output_dir_name = os.path.dirname(output_full_name) if not os.path.exists(output_dir_name): os.makedirs(output_dir_name) destination = FileOutput( destination_path=output_full_name, encoding='utf-8', ) app.info('generating {}'.format(output_full_name)) destination.write(cal.to_ical()) # Remove the node that the writer won't understand. node.parent.replace(node, []) def build_finished(app, exception): if exception is not None: return builder = app.builder output_full_name = os.path.join( builder.outdir, 'schedule.ics', ) output_dir_name = os.path.dirname(output_full_name) if not os.path.exists(output_dir_name): os.makedirs(output_dir_name) destination = FileOutput( destination_path=output_full_name, encoding='utf-8', ) app.info('generating {}'.format(output_full_name)) destination.write(_global_calendar.to_ical()) def setup(app): app.info('initializing ICS extension') app.add_directive('ics', ICS) app.connect('doctree-resolved', doctree_resolved) app.connect('build-finished', build_finished)