ics2mdwn.py 5.8 KB

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