bcd46791a6
Remove line breaks within a paragraph to allow calendar software to handle the text formatting. Change-Id: Ic4c335b9bff7f0213f22c821059d9e0a842b859b Signed-off-by: Doug Hellmann <doug@doughellmann.com>
193 lines
6.2 KiB
Python
193 lines
6.2 KiB
Python
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)
|