ics2mdwn.py 6.4 KB

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