<reponame>openstack/releases<filename>doc/source/_exts/ics.py
#    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 json
import os
import os.path
import subprocess

from docutils.io import FileOutput
from docutils import nodes
from docutils.parsers import rst
import icalendar
from sphinx.util import logging
import yaml

LOG = logging.getLogger(__name__)


class PendingICS(nodes.Element):

    def __init__(self, data_source, series_name, data, last_update):
        super(PendingICS, self).__init__()
        self._data_source = data_source
        self._series_name = series_name
        self._data = data
        self.last_update = last_update


class ICS(rst.Directive):

    option_spec = {
        'source': rst.directives.unchanged,
        'name': rst.directives.unchanged,
    }
    has_content = False
    last_update = ''

    def _load_data(self, env, data_source):
        rel_filename, filename = env.relfn2path(data_source)

        # Attempt to get the last update time for our timestamp
        try:
            last_update = subprocess.check_output(
                [
                    'git', 'log', '-n1', '--format=%ad',
                    '--date=format:%Y-%m-%d %H:%M:%S',
                    '--', filename,
                ]
            ).decode('utf-8').strip()
            self.last_update = datetime.datetime.strptime(
                last_update, '%Y-%m-%d %H:%M:%S')
            LOG.info('[ics] Last update of %s was %s',
                     rel_filename, last_update)
        except (subprocess.CalledProcessError, ValueError):
            self.last_update = datetime.datetime.utcnow()

        if data_source.endswith('.yaml'):
            with open(filename, 'r') as f:
                return yaml.safe_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, self.last_update)
        node.document = self.state.document
        return [node]


_global_calendar = icalendar.Calendar()
_global_calendar.add('prodid', '-//releases.openstack.org//EN')
_global_calendar.add('version', '2.0')
_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

        LOG.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.
                    LOG.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),
            )

            event.add('dtstamp', node.last_update)
            event.add('uid', '%s-%s' % (series_name, week.get('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()))

            # 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 item in doctree.ids
            ]
            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',
        )
        LOG.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',
    )
    LOG.info('generating {}'.format(output_full_name))
    destination.write(_global_calendar.to_ical())


def setup(app):
    LOG.info('initializing ICS extension')
    app.add_directive('ics', ICS)
    app.connect('doctree-resolved', doctree_resolved)
    app.connect('build-finished', build_finished)
    return {
        'parallel_read_safe': True,
        'parallel_write_safe': True,
    }

#    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 json
import os
import os.path
import subprocess

from docutils.io import FileOutput
from docutils import nodes
from docutils.parsers import rst
import icalendar
from sphinx.util import logging
import yaml

LOG = logging.getLogger(__name__)


class PendingICS(nodes.Element):

    def __init__(self, data_source, series_name, data, last_update):
        super(PendingICS, self).__init__()
        self._data_source = data_source
        self._series_name = series_name
        self._data = data
        self.last_update = last_update


class ICS(rst.Directive):

    option_spec = {
        'source': rst.directives.unchanged,
        'name': rst.directives.unchanged,
    }
    has_content = False
    last_update = ''

    def _load_data(self, env, data_source):
        rel_filename, filename = env.relfn2path(data_source)

        # Attempt to get the last update time for our timestamp
        try:
            last_update = subprocess.check_output(
                [
                    'git', 'log', '-n1', '--format=%ad',
                    '--date=format:%Y-%m-%d %H:%M:%S',
                    '--', filename,
                ]
            ).decode('utf-8').strip()
            self.last_update = datetime.datetime.strptime(
                last_update, '%Y-%m-%d %H:%M:%S')
            LOG.info('[ics] Last update of %s was %s',
                     rel_filename, last_update)
        except (subprocess.CalledProcessError, ValueError):
            self.last_update = datetime.datetime.utcnow()

        if data_source.endswith('.yaml'):
            with open(filename, 'r') as f:
                return yaml.safe_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, self.last_update)
        node.document = self.state.document
        return [node]


_global_calendar = icalendar.Calendar()
_global_calendar.add('prodid', '-//releases.openstack.org//EN')
_global_calendar.add('version', '2.0')
_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

        LOG.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.
                    LOG.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),
            )

            event.add('dtstamp', node.last_update)
            event.add('uid', '%s-%s' % (series_name, week.get('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()))

            # 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 item in doctree.ids
            ]
            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',
        )
        LOG.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',
    )
    LOG.info('generating {}'.format(output_full_name))
    destination.write(_global_calendar.to_ical())


def setup(app):
    LOG.info('initializing ICS extension')
    app.add_directive('ics', ICS)
    app.connect('doctree-resolved', doctree_resolved)
    app.connect('build-finished', build_finished)
    return {
        'parallel_read_safe': True,
        'parallel_write_safe': True,
    }
#    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. # Attempt to get the last update time for our timestamp # Look up the cross-reference name to get the # section, then get the title from the first child # node. # 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. # 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. # Look up the cross-reference name to get the # section, then add the full description to the # text. # Remove the node that the writer won't understand.