From 6c0f5a0c1b2fe3a3a8c704e04f2f4468a6d1f3aa Mon Sep 17 00:00:00 2001 From: rra Date: Fri, 24 Nov 2017 19:08:15 +0100 Subject: [PATCH] roels' flavour added switch for multiple calendars based on categories added support for multiple calendars based on categories, added support for setting a default location added support for date ranges and fixed incorrect conversions to ical --- Readme.rst | 62 +++++++++++++ __init__.py | 1 + events.py | 228 +++++++++++++++++++++++++++++++++++++++++++++++ events_list.html | 36 ++++++++ 4 files changed, 327 insertions(+) create mode 100644 Readme.rst create mode 100644 __init__.py create mode 100644 events.py create mode 100644 events_list.html diff --git a/Readme.rst b/Readme.rst new file mode 100644 index 0000000..9bf30d7 --- /dev/null +++ b/Readme.rst @@ -0,0 +1,62 @@ +events +---- + +This plugin scans blog posts for an events. +It also generates an ``.ical`` calendar file. + +Dependencies +------------ + +This plugin depends on the ``icalendar`` package, which can be installed +using APT or RPM or, if you are unlucky, via pip:: + + pip install icalendar + +Usage +----- + +Add the following to pelicanconf.py:: + PLUGIN_EVENTS = { + 'ics_calendars': ['events.ics', 'meetings.ics'], + 'default_location' : '', + 'calendar_per_category': False + } + +Create articles and usual and add the "event-start" metadata to turn them into +events. The event start is independent of the article "date". +"event-start" is in "YYYY-MM-DD hh:mm" format. +Also add "event-end", in the same format, or "event-duration" as a number +followed by a dimension: + +w: weeks +d: days +h: hours +m: minutes +s: seconds + +You can also add recurring events by setting the 'event-recurring' setting. Use 'daily', 'weekly', 'monthly' or 'yearly'. These will continue untill a set end date in "YYYY-MM-DD hh:mm" format. + +Specify an optional "location" or set a default location in pelicanconf.py + +Example in ReST format:: + + :event-start: 2015-01-21 10:30 + :event-duration: 2h + :event-recurring: weekly untill 2015-12-21 + :location: somewhere + +Calendar per Category: +If you set the calendar_per_category setting to True, the plugin will create a calendar for every category you list both in ics_calendar and the article metadata. + + + +To generate an sorted event list in a dedicated page copy the events_list.html +template under the templates directory in your theme, then create a page: + +content/pages/events_list.rst:: + + Events list + ########### + :slug: events-list + :summary: + :template: events_list diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..7b9b76e --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +from .events import * diff --git a/events.py b/events.py new file mode 100644 index 0000000..f6d72a7 --- /dev/null +++ b/events.py @@ -0,0 +1,228 @@ +# -*- coding: utf-8 -*- +""" +events plugin for Pelican +========================= + +This plugin looks for and parses an "events" directory and generates +blog posts with a user-defined event date. (typically in the future) +It also generates an ICalendar v2.0 calendar file. +https://en.wikipedia.org/wiki/ICalendar + + +Author: Federico Ceratto +Released under AGPLv3+ license, see LICENSE +""" + +from datetime import datetime, timedelta +from pelican import signals, utils +from collections import namedtuple, defaultdict +import icalendar +import logging +import os.path +import pytz + +log = logging.getLogger(__name__) + +TIME_MULTIPLIERS = { + 'w': 'weeks', + 'd': 'days', + 'h': 'hours', + 'm': 'minutes', + 's': 'seconds' +} + +events = [] +localized_events = defaultdict(list) +Event = namedtuple("Event", "dtstart dtend metadata rrule") + + +def parse_tstamp(ev, field_name): + """Parse a timestamp string in format "YYYY-MM-DD HH:MM" + + :returns: datetime + """ + try: + return datetime.strptime(ev[field_name], '%Y-%m-%d %H:%M') + except Exception as e: + log.error("Unable to parse the '%s' field in the event named '%s': %s" \ + % (field_name, ev['title'], e)) + raise + +def parse_timedelta(ev): + """Parse a timedelta string in format [ ]* + e.g. 2h 30m + + :returns: timedelta + """ + + chunks = ev['event-duration'].split() + tdargs = {} + for c in chunks: + try: + m = TIME_MULTIPLIERS[c[-1]] + val = int(c[:-1]) + tdargs[m] = val + except KeyError: + log.error("""Unknown time multiplier '%s' value in the \ +'event-duration' field in the '%s' event. Supported multipliers \ +are: '%s'.""" % (c, ev['title'], ' '.join(TIME_MULTIPLIERS))) + raise RuntimeError("Unknown time multiplier '%s'" % c) + except ValueError: + log.error("""Unable to parse '%s' value in the 'event-duration' \ +field in the '%s' event.""" % (c, ev['title'])) + raise ValueError("Unable to parse '%s'" % c) + + + return timedelta(**tdargs) + +def parse_recursion(ev, field_name): + """ + Parse information about recurring events and return frequency of event and untill time. + """ + chunks = ev[field_name].split() + freq = chunks[0].upper() + if 'until' in chunks: + until = datetime.strptime(chunks[-1], '%Y-%m-%d') + rrule = [freq, until] + else: + rrule = [freq] + return rrule + +def parse_article(generator, metadata): + """Collect articles metadata to be used for building the event calendar + + :returns: None + """ + if 'event-start' not in metadata: + return + + dtstart = parse_tstamp(metadata, 'event-start') + rrule=[] + if 'event-end' in metadata: + dtend = parse_tstamp(metadata, 'event-end') + + elif 'event-duration' in metadata: + dtdelta = parse_timedelta(metadata) + dtend = dtstart + dtdelta + + if 'event-recurring' in metadata: + rrule = parse_recursion(metadata, 'event-recurring') + + else: + msg = "Either 'event-end' or 'event-duration' must be" + \ + " speciefied in the event named '%s'" % metadata['title'] + log.error(msg) + raise ValueError(msg) + + events.append(Event(dtstart, dtend, metadata, rrule)) + + +def generate_ical_file(generator): + """Generate an iCalendar file + """ + from icalendar import vDatetime as vd + + global events + + ics_calendars = generator.settings['PLUGIN_EVENTS']['ics_calendars'] + default_loc = generator.settings['PLUGIN_EVENTS']['default_location'] + category_calendar = generator.settings['PLUGIN_EVENTS']['calendar_per_category'] + + if not ics_calendars: + return + + if not category_calendar: + ics_calendars = ics_calendars[:1] + + for calendar in ics_calendars: + + ics_fname = os.path.join(generator.settings['OUTPUT_PATH'], calendar) + log.debug("Generating calendar at %s with %d events" % (ics_fname, len(events))) + + tz = generator.settings.get('TIMEZONE', 'UTC') + tz = pytz.timezone(tz) + + ical = icalendar.Calendar() + ical.add('prodid', '-//My calendar product//mxm.dk//') + ical.add('version', '2.0') + + DEFAULT_LANG = generator.settings['DEFAULT_LANG'] + curr_events = events if not localized_events else localized_events[DEFAULT_LANG] + for e in curr_events: + + if str(e.metadata['category']) in calendar or not category_calendar: + #force convert to ical format here, because otherwise it doesn't happen? + dtend = vd(e.dtend).to_ical() + dtstart = vd(e.dtstart).to_ical() + dtstamp = vd (e.metadata['date']).to_ical() + + # print('*'*10) + print(e.metadata['category']) + + ie = icalendar.Event( + summary=e.metadata['title'], + dtstart=dtstart, + dtend=dtend, + dtstamp= dtstamp, + priority=5, + uid=e.metadata['title'], + ) + if 'event-location' in e.metadata: + ie.add('location', e.metadata['event-location']) + elif default_loc: + ie.add('location', default_loc) + + + if 'event-recurring' in e.metadata: + if len(e.rrule)>=2: + ie.add('rrule', {'freq':e.rrule[0],'until':e.rrule[1]}) + else: + ie.add('rrule', {'freq':e.rrule[0]}) + + ical.add_component(ie) + with open(ics_fname, 'wb') as f: + f.write(ical.to_ical()) + + +def generate_localized_events(generator): + """ Generates localized events dict if i18n_subsites plugin is active """ + + if "i18n_subsites" in generator.settings["PLUGINS"]: + if not os.path.exists(generator.settings['OUTPUT_PATH']): + os.makedirs(generator.settings['OUTPUT_PATH']) + + for e in events: + if "lang" in e.metadata: + localized_events[e.metadata["lang"]].append(e) + else: + log.debug("event %s contains no lang attribute" % (e.metadata["title"],)) + + +def generate_events_list(generator): + """Populate the event_list variable to be used in jinja templates""" + + if not localized_events: + generator.context['events_list'] = sorted(events, reverse = True, + key=lambda ev: (ev.dtstart, ev.dtend)) + else: + generator.context['events_list'] = {k: sorted(v, reverse = True, + key=lambda ev: (ev.dtstart, ev.dtend)) + for k, v in localized_events.items()} + +def initialize_events(article_generator): + """ + Clears the events list before generating articles to properly support plugins with + multiple generation passes like i18n_subsites + """ + + del events[:] + localized_events.clear() + +def register(): + signals.article_generator_init.connect(initialize_events) + signals.article_generator_context.connect(parse_article) + signals.article_generator_finalized.connect(generate_localized_events) + signals.article_generator_finalized.connect(generate_ical_file) + signals.article_generator_finalized.connect(generate_events_list) + + diff --git a/events_list.html b/events_list.html new file mode 100644 index 0000000..ac0184c --- /dev/null +++ b/events_list.html @@ -0,0 +1,36 @@ + +{% extends "base.html" %} + +{% block title %} Events list - {{ SITENAME }}{% endblock %} + +{% block content %} + + {% if events_list %} +
    + {% for evstart, evend, event in events_list %} +
  • +

    + + {{event['title']}} + +

    +

    + {% if evstart.date() == evend.date() %} + From {{evstart}} to {{evend.time()}} + {% else %} + From {{evstart}} to {{evend}} + {% endif %} +

    + + {% if event.location %} +

    Location: {{event.location}}

    + {% endif %} + +

    {{event['summary']}}

    + +
  • + {% endfor %} +
+ {% endif %} + +{% endblock %}