From 82a98d7f514b5116d2b57e15137ffc3b679bc6ba Mon Sep 17 00:00:00 2001 From: Doug Hellmann Date: Wed, 4 Jan 2017 10:40:09 -0500 Subject: [PATCH] add directive for generating ICS files Change-Id: Ia2a0f25cfa55873737ce26d1317ed8ac08bf5ddb Signed-off-by: Doug Hellmann --- doc/source/_exts/ics.py | 145 ++++++++++++++++++++++++++++++++++ doc/source/conf.py | 6 ++ doc/source/ocata/schedule.rst | 6 ++ setup.cfg | 1 + 4 files changed, 158 insertions(+) create mode 100644 doc/source/_exts/ics.py diff --git a/doc/source/_exts/ics.py b/doc/source/_exts/ics.py new file mode 100644 index 0000000000..9838239df9 --- /dev/null +++ b/doc/source/_exts/ics.py @@ -0,0 +1,145 @@ +import datetime +import json +import os +import os.path + +from docutils.io import FileOutput +from docutils import nodes +from docutils.parsers import rst +from docutils.statemachine import ViewList +import icalendar +from sphinx.util.nodes import nested_parse_with_titles +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] + + +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() + + event.add('summary', week['name']) + + 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())) + + description = [] + 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 + description.append(title) + if description: + event.add('description', ', '.join(description)) + + cal.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 setup(app): + app.info('initializing ICS extension') + app.add_directive('ics', ICS) + app.connect('doctree-resolved', doctree_resolved) diff --git a/doc/source/conf.py b/doc/source/conf.py index c55b784b26..0dd0bba1d7 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -4,6 +4,11 @@ import datetime import os import sys +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +sys.path.insert(0, os.path.join(os.path.abspath('.'), '_exts')) + # -- General configuration ---------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be @@ -11,6 +16,7 @@ import sys extensions = [ 'openstack_releases.sphinxext', 'sphinxcontrib.datatemplates', + 'ics', ] config_generator_config_file = 'config-generator.conf' diff --git a/doc/source/ocata/schedule.rst b/doc/source/ocata/schedule.rst index e5bcafed7a..4eaa36a4d8 100644 --- a/doc/source/ocata/schedule.rst +++ b/doc/source/ocata/schedule.rst @@ -8,6 +8,12 @@ :source: schedule.yaml :template: schedule_table.tmpl +.. ics:: + :source: schedule.yaml + :name: Ocata + +`Subscribe to iCalendar file `__ + .. note:: With the exception of the final release date and cycle-trailing diff --git a/setup.cfg b/setup.cfg index adf83befe0..e43d2226bc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,6 +38,7 @@ sphinxext = sphinx<1.4 oslosphinx sphinxcontrib.datatemplates + icalendar [build_sphinx] source-dir = doc/source