ics2mdwn.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. #!/usr/bin/python3
  2. import logging
  3. import argparse
  4. import os.path
  5. from typing import Iterable
  6. from pathlib import Path
  7. import yaml
  8. from datetime import timedelta
  9. from icalendar import Calendar, Event
  10. def get_parser():
  11. p = argparse.ArgumentParser()
  12. p.add_argument("files", nargs="+", type=str)
  13. p.add_argument(
  14. "--out-md-dir",
  15. help="Output directory for markdown files",
  16. default="content/talks/",
  17. type=Path,
  18. )
  19. p.add_argument(
  20. "--out-schedule",
  21. help="Output schedule.yml file",
  22. default="data/schedule.yml",
  23. type=Path,
  24. )
  25. p.add_argument("--trust-location", type=str, nargs="*", default=[])
  26. p.add_argument(
  27. "--slot-size",
  28. type=int,
  29. metavar="MINUTES",
  30. default=15,
  31. help="Round times to the nearest */MINUTES",
  32. )
  33. p.add_argument("--night-threshold", metavar="HOUR", default=5, type=int)
  34. p.add_argument("--mode", choices=["hugo"], default="hugo")
  35. return p
  36. def round_down(num, divisor):
  37. """
  38. >>> round_down(1000, 10)
  39. 1000
  40. >>> round_down(1001, 10)
  41. 1000
  42. >>> round_down(1009, 10)
  43. 1000
  44. """
  45. return num - (num % divisor)
  46. def round_down_time(hhmm: str, divisor: int):
  47. hh = hhmm[:-2]
  48. mm = hhmm[-2:]
  49. mm = round_down(int(mm, base=10), divisor)
  50. return int('%s%02d' % (hh,mm) , base=10)
  51. class Converter:
  52. """
  53. This class takes care of everything converter-related.
  54. Objects are used to enable multiple output formats to be added pretty easily by subclassing
  55. """
  56. def __init__(self, args):
  57. self.args = args
  58. self.rooms = []
  59. self.talks = {}
  60. self.talk_room = {} # map talk uid to room name
  61. self.talk_location = {} # same, but see --trust-location
  62. self.changed_files = []
  63. def _fname_to_room(self, fpath: str) -> str:
  64. return os.path.splitext(os.path.basename(fpath))[0]
  65. def get_vevents_from_calendar(self, cal: Calendar) -> Iterable[Event]:
  66. for subc in cal.subcomponents:
  67. if type(subc) is Event:
  68. yield subc
  69. def load_input(self):
  70. for fpath in self.args.files:
  71. room = self._fname_to_room(fpath)
  72. with open(fpath) as buf:
  73. file_content = buf.read()
  74. cal = Calendar.from_ical(file_content, multiple=True)
  75. for subcal in cal:
  76. for ev in self.get_vevents_from_calendar(subcal):
  77. uid = ev.decoded("uid").decode("ascii")
  78. self.talks[uid] = ev
  79. self.talk_room[uid] = room
  80. self.talk_location[uid] = room
  81. if fpath in self.args.trust_location:
  82. try:
  83. self.talk_location[uid] = ev.decoded("location").decode(
  84. "utf8"
  85. )
  86. except:
  87. pass
  88. def run(self):
  89. self.rooms = [self._fname_to_room(fpath) for fpath in self.args.files]
  90. self.load_input()
  91. self.output()
  92. for fpath in self.changed_files:
  93. print(fpath)
  94. class HugoConverter(Converter):
  95. """
  96. add relevant output features to the base converter
  97. """
  98. def output_markdown(self):
  99. for uid in sorted(self.talks):
  100. talk = self.talks[uid]
  101. fname = "%s.md" % uid
  102. fpath = self.args.out_md_dir / fname
  103. self.changed_files.append(fpath)
  104. frontmatter = dict(
  105. key=uid,
  106. title=talk.decoded("SUMMARY").decode("utf8"),
  107. format="conference",
  108. start=talk.decoded("DTSTART"),
  109. end=talk.decoded("DTEND"),
  110. location=self.talk_location[uid],
  111. duration=int(
  112. (talk.decoded("DTEND") - talk.decoded("DTSTART")).total_seconds()
  113. // 60
  114. ),
  115. tags=[],
  116. )
  117. if "CATEGORIES" in talk:
  118. try:
  119. vobject = talk.get("CATEGORIES")
  120. if hasattr(vobject, "cats"):
  121. vobject = vobject.cats
  122. frontmatter["tags"] = [str(t) for t in vobject]
  123. else:
  124. frontmatter["tags"] = [str(vobject)]
  125. except Exception as exc:
  126. logging.warning("Error parsing categories: %s", str(exc))
  127. if "base" in frontmatter["tags"]:
  128. frontmatter["level"] = "beginner"
  129. with open(str(fpath), "w") as buf:
  130. buf.write("---\n")
  131. yaml.safe_dump(frontmatter, buf)
  132. buf.write("---\n\n")
  133. # body
  134. if "DESCRIPTION" in talk:
  135. buf.write(talk.decoded("DESCRIPTION").decode("utf8"))
  136. def output_schedule(self):
  137. days = {}
  138. for uid in sorted(self.talks):
  139. talk = self.talks[uid]
  140. # TODO: talk just after midnight should belong to the preceding day
  141. dt = talk.decoded("dtstart")
  142. after_midnight = dt.time().hour < self.args.night_threshold
  143. if after_midnight:
  144. dt = dt - timedelta(days=1)
  145. day = dt.strftime("%Y-%m-%d")
  146. hour = talk.decoded("dtstart").time().hour
  147. minute = talk.decoded("dtstart").time().minute
  148. if after_midnight:
  149. hour += 24
  150. start = "%02d:%02d" % (hour, minute)
  151. if day not in days:
  152. days[day] = dict(day=day, start=start, rooms={})
  153. if days[day]["start"] > start:
  154. days[day]["start"] = start
  155. room = self.talk_room[uid]
  156. days[day]["rooms"].setdefault(room, dict(room=room, slots=[]))
  157. talkstart = round_down_time('%02d%02d' % (hour, minute), self.args.slot_size)
  158. duration = talk.decoded("dtend") - talk.decoded("dtstart")
  159. duration_minutes = int(duration.total_seconds() // 60)
  160. duration_minutes = round_down(duration_minutes, self.args.slot_size)
  161. slot = "%04d-%dmin" % (talkstart, duration_minutes)
  162. days[day]["rooms"][room]["slots"].append(dict(slot=slot, talk=uid))
  163. # convert from our intermediate format to the correct one
  164. for d in sorted(days):
  165. # vanity: let's sort
  166. for room in sorted(days[d]["rooms"]):
  167. days[d]["rooms"][room]["slots"].sort(key=lambda x: x["slot"])
  168. # convert dict to list
  169. days[d]["rooms"] = [days[d]["rooms"][k] for k in sorted(days[d]["rooms"])]
  170. out = {"schedule": [days[k] for k in sorted(days)]}
  171. # dump, finally
  172. with open(str(self.args.out_schedule), "w") as buf:
  173. yaml.safe_dump(out, buf)
  174. self.changed_files.append(self.args.out_schedule)
  175. def output(self):
  176. self.output_markdown()
  177. self.output_schedule()
  178. def main():
  179. converter_register = {"hugo": HugoConverter}
  180. args = get_parser().parse_args()
  181. c = converter_register[args.mode](args)
  182. c.run()
  183. if __name__ == "__main__":
  184. main()