Forked from VincenzoLaSpesa/fosdem_events_to_vcards.py
Last active
January 31, 2026 01:15
-
-
Save saerdnaer/ea054ca0f997b2655441fbcb732d89c6 to your computer and use it in GitHub Desktop.
generate a VCARD calendar out of the events exported from https://fosdem.sojourner.rocks/2026/
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # -*- coding: utf-8 -*- | |
| """ | |
| Filename: fosdem_events_to_vcards.py | |
| Date: 2026-01-31 | |
| Version: 1.1 | |
| Description: | |
| This script allows to make a VCARD calendar out of the events exported from https://fosdem.sojourner.rocks/2026/ | |
| as a CSV. | |
| Usage: | |
| - Generate the ics file from the csv exported from solojourner: | |
| fosdem_events_to_vcards.py -s https://fosdem.org/2026/schedule/xml -i fosdem-2026.csv -o your-favs.ics | |
| License: MIT License | |
| """ | |
| from datetime import datetime, timezone | |
| from html2text import HTML2Text | |
| import icalendar | |
| import argparse | |
| import csv | |
| from schedulexml import ScheduleXML, Event | |
| DOMAIN = "fosdem.org" | |
| html2text = HTML2Text() | |
| html2text.ignore_links = False | |
| html2text.body_width = 0 # no forced line wrapping | |
| now = datetime.now(timezone.utc) | |
| def to_ical(self: Event) -> icalendar.Event: | |
| event = icalendar.Event() | |
| event.add("uid", f"{self['guid']}@{DOMAIN}") | |
| event.add("summary", self["title"]) | |
| # Add dtstart and dtend to event | |
| event.add("dtstart", self.start) | |
| event.add("dtend", self.end) | |
| event.add("dtstamp", now) | |
| if self.get("room") is not None: | |
| event.add("location", self["room"], encode=False) | |
| description = "" | |
| if self.get('persons'): | |
| for person in self.participants: | |
| if person: | |
| attendee = icalendar.vCalAddress(f"urn:{DOMAIN}:person:{person.id}") | |
| attendee.params["CN"] = icalendar.vText(person.name) | |
| attendee.params["ROLE"] = icalendar.vText("SPEAKER") | |
| attendee.params["PARTSTAT"] = icalendar.vText("ACCEPTED") | |
| attendee.params["RSVP"] = icalendar.vText("TRUE") | |
| event.add("attendee", attendee, encode=False) | |
| #description += f"{person.name}\n" | |
| description += f"\n{html2text.handle(self['abstract'])}" if self.get("abstract") else "" | |
| description += f"\n\n{html2text.handle(self['description'])}" if self.get("description") else "" | |
| event.add("description", description) | |
| event.add("url", self["url"]) | |
| return event | |
| def convert_fav_csv_to_ics(csv_path, ics_path, events_lookup): | |
| favs=[] | |
| with open(csv_path, newline='', encoding='utf-8') as f: | |
| for row in csv.DictReader(f): | |
| favs.append(row["ID"].lower()) | |
| events=[] | |
| for id in favs: | |
| events.append(events_lookup[id]) | |
| cal = icalendar.Calendar() | |
| cal.add("prodid", f"-//{DOMAIN}///Favs to iCal//EN") | |
| cal.add("version", "2.0") | |
| for event in events: | |
| cal.add_component(to_ical(event)) | |
| with open(ics_path, "w", encoding="utf-8") as f: | |
| f.write(cal.to_ical().decode("utf-8")) | |
| def main(): | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument('-s',"--schedule", type=str, help="input lookup file", required=True) | |
| parser.add_argument("-i", "--input",type=str, help="input csv file", required=True) | |
| parser.add_argument("-o", "--output",type=str, help="output ics file", required=True) | |
| args = parser.parse_args() | |
| schedule = ScheduleXML.from_url(args.schedule) | |
| lookup = { e["guid"]: e for e in schedule.events() } | |
| convert_fav_csv_to_ics(args.input, args.output, lookup) | |
| if __name__ == "__main__": | |
| main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| [project] | |
| name = "fosdem_events_to_vcards.py" | |
| version = "1.1.0" | |
| description = "Add your description here" | |
| requires-python = ">=3.13" | |
| dependencies = [ | |
| "html2text>=2025.4.15", | |
| "icalendar>=6.3.2", | |
| "python-dateutil>=2.9.0.post0", | |
| "requests>=2.32.5", | |
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| from collections.abc import Mapping | |
| from dataclasses import dataclass | |
| import re | |
| from typing import Union | |
| from xml.etree import ElementTree | |
| from datetime import datetime, timedelta | |
| from dateutil.parser import parse as parse_datetime | |
| import requests | |
| class ScheduleXML: | |
| """ | |
| Schedule from XML document using etree, with inspirations from | |
| - https://github.com/pretalx/pretalx-downstream/blob/master/pretalx_downstream/tasks.py#L67 | |
| - https://github.com/Zverik/schedule-convert/blob/master/schedule_convert/importers/frab_xml.py#L55 | |
| """ | |
| _schedule: ElementTree = None | |
| tz = None | |
| def __init__(self, tree): | |
| self._schedule = tree | |
| @classmethod | |
| def from_url(cls, url): | |
| r = requests.get(url) | |
| if r.ok is False: | |
| raise Exception(f'Request failed, HTTP {r.status_code}.') | |
| schedule = ElementTree.fromstring(r.text) | |
| # Close the raw file handle if it's still open | |
| if hasattr(r, 'raw') and r.raw.closed is False: | |
| r.raw.close() | |
| return ScheduleXML(tree=schedule) | |
| def __getitem__(self, key): | |
| return self._schedule.find(key) | |
| def schedule(self): | |
| return self._schedule | |
| def version(self): | |
| v = self._schedule.find('version') | |
| return v.text if v is not None else None | |
| def days(self): | |
| return self._schedule.findall('day') | |
| def rooms(self): | |
| rooms = {} | |
| for day in self.days(): | |
| for room in day.findall('room'): | |
| room_name = room.attrib['name'] | |
| room_guid = room.attrib.get('guid') | |
| room_desc = room.attrib.get('description') | |
| rooms[room_name] = {'guid': room_guid, 'name': room_name, 'description': room_desc} | |
| return list(rooms.values()) | |
| def events(self): | |
| for day in self.days(): | |
| for room in day.findall('room'): | |
| for event in room.findall('event'): | |
| e = Event(event) | |
| e['id'] = event.attrib.get('id') | |
| e['guid'] = event.attrib.get('guid') | |
| yield e | |
| def __str__(self): | |
| return ElementTree.tounicode(self._schedule, pretty_print=True) | |
| @dataclass | |
| class Person: | |
| name: str | |
| id: str | |
| @dataclass | |
| class Event(Mapping): | |
| def __init__(self, | |
| tree: ElementTree, | |
| start: datetime|None = None | |
| ): | |
| self._event = tree | |
| self._values = {} | |
| if start is not None: | |
| self.start: datetime = start | |
| else: | |
| self.start = parse_datetime(self['date']) | |
| self.duration = str2timedelta(self["duration"]) | |
| @property | |
| def end(self): | |
| return self.start + self.duration | |
| @property | |
| def participants(self) -> list[Person]: | |
| participants = [] | |
| for person in self._event.find('persons').findall('person'): | |
| name = person.text | |
| if name: | |
| participants.append(Person(name, person.attrib.get('id'))) | |
| return participants | |
| def __getitem__(self, key): | |
| if key in self._values: | |
| return self._values[key] | |
| item = self._event.find(key) | |
| return item.text if item is not None else None | |
| def __setitem__(self, key, value): | |
| self._values[key] = value | |
| def __iter__(self): | |
| return self._event.__iter__() | |
| def __len__(self): | |
| return len(self._event) | |
| def __lt__(self, other): | |
| return self.start < other.start | |
| def items(self): | |
| return self._event.items() | |
| def format_duration(value: Union[int, timedelta]) -> str: | |
| if type(value) == timedelta: | |
| minutes = round(value.total_seconds() / 60) | |
| else: | |
| minutes = value | |
| return '%d:%02d' % divmod(minutes, 60) | |
| # from https://git.cccv.de/hub/hub/-/blob/develop/src/core/utils.py | |
| _RE_STR2TIMEDELTA = re.compile(r'((?P<days>\d+?)hr?\s*)?((?P<hours>\d+?)m(ins?)?\s*)?((?P<minutes>\d+?)s)?') | |
| def str2timedelta(s) -> timedelta: | |
| if ':' in s: | |
| parts = s.split(':') | |
| kwargs = {'minutes': int(parts.pop())} | |
| if parts: | |
| kwargs['hours'] = int(parts.pop()) | |
| if parts: | |
| kwargs['days'] = int(parts.pop()) | |
| return timedelta(**kwargs) | |
| parts = _RE_STR2TIMEDELTA.match(s) | |
| if not parts: | |
| raise ValueError(f'Could not parse time duration string: {s}') | |
| parts = parts.groupdict() | |
| time_params = {} | |
| for name, param in parts.items(): | |
| if param: | |
| time_params[name] = int(param) | |
| return timedelta(**time_params) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment