|
@@ -0,0 +1,225 @@
|
|
|
+#!/usr/bin/python3
|
|
|
+
|
|
|
+import logging
|
|
|
+import argparse
|
|
|
+import os.path
|
|
|
+from typing import Iterable
|
|
|
+from pathlib import Path
|
|
|
+from datetime import timedelta
|
|
|
+import json
|
|
|
+
|
|
|
+import yaml
|
|
|
+from icalendar import Calendar, Event
|
|
|
+
|
|
|
+
|
|
|
+def get_parser():
|
|
|
+ p = argparse.ArgumentParser()
|
|
|
+ p.add_argument("files", nargs="+", type=str)
|
|
|
+ p.add_argument("--hm-json", default='./hackmeeting.json', type=Path)
|
|
|
+ p.add_argument(
|
|
|
+ "--out-talks-dir",
|
|
|
+ help="Output directory for markdown files",
|
|
|
+ default="talks/",
|
|
|
+ type=Path,
|
|
|
+ )
|
|
|
+ p.add_argument("--trust-location", type=str, nargs="*", default=[])
|
|
|
+ p.add_argument(
|
|
|
+ "--slot-size",
|
|
|
+ type=int,
|
|
|
+ metavar="MINUTES",
|
|
|
+ default=15,
|
|
|
+ help="Round times to the nearest */MINUTES",
|
|
|
+ )
|
|
|
+ p.add_argument("--night-threshold", metavar="HOUR", default=5, type=int)
|
|
|
+ p.add_argument("--mode", choices=["pelican"], default="pelican")
|
|
|
+ return p
|
|
|
+
|
|
|
+
|
|
|
+def round_down(num, divisor):
|
|
|
+ """
|
|
|
+ >>> round_down(1000, 10)
|
|
|
+ 1000
|
|
|
+ >>> round_down(1001, 10)
|
|
|
+ 1000
|
|
|
+ >>> round_down(1009, 10)
|
|
|
+ 1000
|
|
|
+ """
|
|
|
+ return num - (num % divisor)
|
|
|
+
|
|
|
+
|
|
|
+def round_down_time(hhmm: str, divisor: int):
|
|
|
+ hh = hhmm[:-2]
|
|
|
+ mm = hhmm[-2:]
|
|
|
+ mm = round_down(int(mm, base=10), divisor)
|
|
|
+ return int('%s%02d' % (hh,mm) , base=10)
|
|
|
+
|
|
|
+
|
|
|
+class Converter:
|
|
|
+ """
|
|
|
+ This class takes care of everything converter-related.
|
|
|
+
|
|
|
+ Objects are used to enable multiple output formats to be added pretty easily by subclassing
|
|
|
+ """
|
|
|
+
|
|
|
+ def __init__(self, args):
|
|
|
+ self.args = args
|
|
|
+ self.rooms = []
|
|
|
+ self.talks = {}
|
|
|
+ self.talk_room = {} # map talk uid to room name
|
|
|
+ self.talk_location = {} # same, but see --trust-location
|
|
|
+ self.changed_files = []
|
|
|
+
|
|
|
+ def _fname_to_room(self, fpath: str) -> str:
|
|
|
+ base = os.path.splitext(os.path.basename(fpath))[0]
|
|
|
+ if base == 'ALL':
|
|
|
+ return '*'
|
|
|
+ return base
|
|
|
+
|
|
|
+ def get_vevents_from_calendar(self, cal: Calendar) -> Iterable[Event]:
|
|
|
+ for subc in cal.subcomponents:
|
|
|
+ if type(subc) is Event:
|
|
|
+ yield subc
|
|
|
+
|
|
|
+ def load_input(self):
|
|
|
+ with self.args.hm_json.open() as buf:
|
|
|
+ self.hackmeeting_metadata = json.load(buf)
|
|
|
+ for fpath in self.args.files:
|
|
|
+ room = self._fname_to_room(fpath)
|
|
|
+ with open(fpath) as buf:
|
|
|
+ file_content = buf.read()
|
|
|
+ cal = Calendar.from_ical(file_content, multiple=True)
|
|
|
+ for subcal in cal:
|
|
|
+ for ev in self.get_vevents_from_calendar(subcal):
|
|
|
+ if ev.decoded('DTSTART').year != self.hackmeeting_metadata['year']:
|
|
|
+ continue
|
|
|
+ uid = ev.decoded("uid").decode("ascii")
|
|
|
+ self.talks[uid] = ev
|
|
|
+ self.talk_room[uid] = room
|
|
|
+ self.talk_location[uid] = room
|
|
|
+ if fpath in self.args.trust_location:
|
|
|
+ try:
|
|
|
+ self.talk_location[uid] = ev.decoded("location").decode(
|
|
|
+ "utf8"
|
|
|
+ )
|
|
|
+ except:
|
|
|
+ pass
|
|
|
+
|
|
|
+ def run(self):
|
|
|
+ self.rooms = [self._fname_to_room(fpath) for fpath in self.args.files]
|
|
|
+ self.load_input()
|
|
|
+ self.output()
|
|
|
+ for fpath in self.changed_files:
|
|
|
+ print(fpath)
|
|
|
+
|
|
|
+
|
|
|
+class PelicanConverter(Converter):
|
|
|
+ """
|
|
|
+ add relevant output features to the base converter
|
|
|
+ """
|
|
|
+
|
|
|
+ def load_input(self):
|
|
|
+ super().load_input()
|
|
|
+ talks_meta = self.args.out_talks_dir / 'meta.yaml'
|
|
|
+ with talks_meta.open() as buf:
|
|
|
+ self.talks_metadata = yaml.safe_load(buf)
|
|
|
+
|
|
|
+ def output_markdown(self):
|
|
|
+ for uid in sorted(self.talks):
|
|
|
+ talk = self.talks[uid]
|
|
|
+ fname = 'meta.yaml'
|
|
|
+ talkdir = self.args.out_talks_dir / uid
|
|
|
+ talkdir.mkdir(exist_ok=True)
|
|
|
+ fpath = talkdir / fname
|
|
|
+ self.changed_files.append(fpath)
|
|
|
+ day = (talk.decoded('DTSTART').date() - self.talks_metadata['startdate']).days
|
|
|
+ frontmatter = dict(
|
|
|
+ key=uid,
|
|
|
+ title=talk.decoded("SUMMARY").decode("utf8"),
|
|
|
+ format="conference",
|
|
|
+ start=talk.decoded("DTSTART"),
|
|
|
+ time=talk.decoded("DTSTART").strftime('%H:%M'),
|
|
|
+ day=day,
|
|
|
+ end=talk.decoded("DTEND"),
|
|
|
+ room=self.talk_location[uid],
|
|
|
+ duration=int(
|
|
|
+ (talk.decoded("DTEND") - talk.decoded("DTSTART")).total_seconds()
|
|
|
+ // 60
|
|
|
+ ),
|
|
|
+ tags=[],
|
|
|
+ )
|
|
|
+ if "CATEGORIES" in talk:
|
|
|
+ try:
|
|
|
+ vobject = talk.get("CATEGORIES")
|
|
|
+ if hasattr(vobject, "cats"):
|
|
|
+ vobject = vobject.cats
|
|
|
+ frontmatter["tags"] = [str(t) for t in vobject]
|
|
|
+ else:
|
|
|
+ frontmatter["tags"] = [str(vobject)]
|
|
|
+ except Exception as exc:
|
|
|
+ logging.warning("Error parsing categories: %s", str(exc))
|
|
|
+ if "base" in frontmatter["tags"]:
|
|
|
+ frontmatter["level"] = "beginner"
|
|
|
+ if "DESCRIPTION" in talk:
|
|
|
+ frontmatter['text'] = talk.decoded("DESCRIPTION").decode("utf8")
|
|
|
+ else:
|
|
|
+ frontmatter['text'] = ''
|
|
|
+ with open(str(fpath), "w") as buf:
|
|
|
+ yaml.safe_dump(frontmatter, buf)
|
|
|
+ # body
|
|
|
+
|
|
|
+ def output_schedule(self):
|
|
|
+ days = {}
|
|
|
+ for uid in sorted(self.talks):
|
|
|
+ talk = self.talks[uid]
|
|
|
+ # TODO: talk just after midnight should belong to the preceding day
|
|
|
+ dt = talk.decoded("dtstart")
|
|
|
+ after_midnight = dt.time().hour < self.args.night_threshold
|
|
|
+ if after_midnight:
|
|
|
+ dt = dt - timedelta(days=1)
|
|
|
+ day = dt.strftime("%Y-%m-%d")
|
|
|
+
|
|
|
+ hour = talk.decoded("dtstart").time().hour
|
|
|
+ minute = talk.decoded("dtstart").time().minute
|
|
|
+ if after_midnight:
|
|
|
+ hour += 24
|
|
|
+
|
|
|
+ start = "%02d:%02d" % (hour, minute)
|
|
|
+ if day not in days:
|
|
|
+ days[day] = dict(day=day, start=start, rooms={})
|
|
|
+ if days[day]["start"] > start:
|
|
|
+ days[day]["start"] = start
|
|
|
+
|
|
|
+ room = self.talk_room[uid]
|
|
|
+ days[day]["rooms"].setdefault(room, dict(room=room, slots=[]))
|
|
|
+
|
|
|
+ talkstart = round_down_time('%02d%02d' % (hour, minute), self.args.slot_size)
|
|
|
+ duration = talk.decoded("dtend") - talk.decoded("dtstart")
|
|
|
+ duration_minutes = int(duration.total_seconds() // 60)
|
|
|
+ duration_minutes = round_down(duration_minutes, self.args.slot_size)
|
|
|
+ slot = "%04d-%dmin" % (talkstart, duration_minutes)
|
|
|
+ days[day]["rooms"][room]["slots"].append(dict(slot=slot, talk=uid))
|
|
|
+
|
|
|
+ # convert from our intermediate format to the correct one
|
|
|
+ for d in sorted(days):
|
|
|
+ # vanity: let's sort
|
|
|
+ for room in sorted(days[d]["rooms"]):
|
|
|
+ days[d]["rooms"][room]["slots"].sort(key=lambda x: x["slot"])
|
|
|
+ # convert dict to list
|
|
|
+ days[d]["rooms"] = [days[d]["rooms"][k] for k in sorted(days[d]["rooms"])]
|
|
|
+ out = {"schedule": [days[k] for k in sorted(days)]}
|
|
|
+
|
|
|
+ # XXX: dump, finally
|
|
|
+
|
|
|
+ def output(self):
|
|
|
+ self.output_markdown()
|
|
|
+
|
|
|
+
|
|
|
+def main():
|
|
|
+ converter_register = {"pelican": PelicanConverter}
|
|
|
+ args = get_parser().parse_args()
|
|
|
+ c = converter_register[args.mode](args)
|
|
|
+ c.run()
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ main()
|