from icalendar.cal import Calendar, FreeBusy import icalendar import urllib.parse import requests from flask import Flask from datetime import date, datetime, timedelta, time, timezone import zoneinfo from dataclasses import dataclass from danoan.toml_dataclass import TomlDataClassIO from os import environ @dataclass class CalendarConfig(TomlDataClassIO): file: str = "" url: str = "" @dataclass class Config(TomlDataClassIO): name: str timezone: str calendar: CalendarConfig def load_config(file_path): with open(file_path, "r") as fr: return Config.read(fr) app = Flask(__name__) config = load_config(environ.get("CONFIG_FILE", "config.toml")) tz = zoneinfo.ZoneInfo(config.timezone) def fetch_calendar(calendar_url): return requests.get(calendar_url).content def read_calendar_file(calendar_file): with open(calendar_file, 'rb') as f: return f.read() def get_calendar(calendar_config): if calendar_config.file != "": return read_calendar_file(calendar_config.file) else: if calendar_config.url != "": u = urllib.parse.urlsplit(calendar_config.url) if u.scheme == "webcal": u = u._replace(scheme="https") return fetch_calendar(urllib.parse.urlunsplit(u)) raise ValueError("Calendar URL not configured.") def fixup_date(dt): if type(dt) == date: return datetime.combine(dt, time.min, tzinfo=tz) elif dt.tzinfo is None: return dt.replace(tzinfo=tz) else: return dt.astimezone(tz) def looks_tentative(component): summary = str(component.get('summary')) return component.get('status') == 'TENTATIVE' or 'TBD' in summary or summary.endswith("?") @app.route(f'/{str.lower(config.name)}.ics') def index(): today = date.today() start_of_week = today - timedelta(days=today.weekday()) start_date = datetime.combine(start_of_week, time.min, tzinfo=tz) end_date = start_date + timedelta(days=30) output = Calendar() output.add('prodid', '-//Calendar Anonymiser//alin.ovh//') output.add('version', '2.0') # Parse with icalendar try: input = Calendar.from_ical(get_calendar(config.calendar)) for component in input.walk(): if component.name == "VEVENT": dtstart = fixup_date(component.get('dtstart').dt) dtend = fixup_date(component.get('dtend', component.get('dtstart')).dt) if dtstart >= start_date and dtend <= end_date: busy = FreeBusy(uid=component.get('uid')) vp = icalendar.prop.vPeriod([ dtstart, dtend ]) if looks_tentative(component): vp.FBTYPE = icalendar.enums.FBTYPE.BUSY_TENTATIVE else: vp.FBTYPE = icalendar.enums.FBTYPE.BUSY_UNAVAILABLE busy.add('freebusy', vp) busy.add('summary', f'{config.name} Busy') busy.add('dtstamp', component.get('dtstamp', icalendar.prop.vDatetime(datetime.now(tz)))) busy.add('dtstart', dtstart) busy.add('dtend', dtend) output.add_component(busy) return output.to_ical(), { "Content-Type": "text/plain" } except Exception as e: print("error:", e) return f"Error parsing with icalendar: {str(e)}", 500 if __name__ == '__main__': app.run(debug=True)