all repos — mycal @ 2d391590d85498f2703f295dc7c64c31b4ff6697

private calendar anonymiser

allow multiple calendars with override of tentative detection

Alan Pearce
commit

2d391590d85498f2703f295dc7c64c31b4ff6697

parent

eaa2c4aaca33da3ea78d29fa5e4abddf0aceaea9

1 file changed, 79 insertions(+), 31 deletions(-)

changed files
M mycal.pymycal.py
@@ -7,21 +7,25 @@ import requests
from flask import Flask from datetime import date, datetime, timedelta, time import zoneinfo -from dataclasses import dataclass +from dataclasses import dataclass, field from danoan.toml_dataclass import TomlDataClassIO from os import environ +import typing +import traceback @dataclass class CalendarConfig(TomlDataClassIO): - file: str = "" - url: str = "" + file: typing.Optional[str] = None + url: typing.Optional[str] = None + all_tentative: bool = False @dataclass class Config(TomlDataClassIO): name: str email: str timezone: str - calendar: CalendarConfig + calendar: typing.Optional[CalendarConfig] = None # Keep for backward compatibility + calendars: typing.List[CalendarConfig] = field(default_factory=list) # New field for multiple calendars def load_config(file_path): with open(file_path, "r") as fr:
@@ -41,11 +45,11 @@ 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) + if 'file' in calendar_config and calendar_config['file']: + return read_calendar_file(calendar_config['file']) else: - if calendar_config.url != "": - u = urllib.parse.urlsplit(calendar_config.url) + if 'url' in calendar_config and 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))
@@ -60,9 +64,49 @@ else:
return dt.astimezone(tz) def looks_tentative(component): - summary = str(component.get('summary')) + summary = str(component.get('summary', '')) return component.get('status') == 'TENTATIVE' or 'TBD' in summary or summary.endswith("?") +def custom_tentative_detector(component): + """ + A custom function to determine if an event is tentative. + This can be passed to process_calendar_events to override the default logic. + """ + summary = str(component.get('summary', '')) + # Add your custom logic here + return (component.get('status') == 'TENTATIVE' or + 'TBD' in summary or + summary.endswith("?")) + +def process_calendar_events(calendar, busy, start_date, end_date, is_tentative_fn=None): + """ + Process events from a calendar and add them to the FreeBusy component. + + Args: + calendar: The input calendar to process + busy: The FreeBusy component to add events to + start_date: Start date for filtering events + end_date: End date for filtering events + is_tentative_fn: Optional function to determine if an event is tentative + """ + if is_tentative_fn is None: + is_tentative_fn = looks_tentative + + for component in calendar.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: + vp = icalendar.prop.vPeriod([t.astimezone(utc) for t in [dtstart, dtend]]) + vp.params.pop('TZID') # remove timezone information + + if is_tentative_fn(component): + vp.FBTYPE = icalendar.enums.FBTYPE.BUSY_TENTATIVE + else: + vp.FBTYPE = icalendar.enums.FBTYPE.BUSY_UNAVAILABLE + + busy.add('freebusy', vp) + @app.route(f'/{str.lower(config.name)}.ics') def index(): today = date.today()
@@ -77,36 +121,40 @@ output = Calendar()
output.add('prodid', '-//Calendar Anonymiser//alin.ovh//') output.add('version', '2.0') - # Parse with icalendar + busy = FreeBusy(uid=f'{uuid4()}@{domain}') + busy.add('organizer', organizer) + busy.add('dtstamp', datetime.now(tz=utc)) + busy.add('dtstart', start_date.astimezone(tz=utc)) + busy.add('dtend', end_date.astimezone(tz=utc)) + try: - input = Calendar.from_ical(get_calendar(config.calendar)) + # Process calendars from the config + calendars_to_process = [] - busy = FreeBusy(uid=f'{uuid4()}@{domain}') - busy.add('organizer', organizer) - busy.add('dtstamp', datetime.now(tz=utc)) - busy.add('dtstart', start_date.astimezone(tz=utc)) - busy.add('dtend', end_date.astimezone(tz=utc)) + # For backward compatibility + if config.calendar is not None and (config.calendar['file'] != "" or config.calendar['url'] != ""): + calendars_to_process.append(config.calendar) + + # Add calendars from the new field if available + if config.calendars: + calendars_to_process.extend(config.calendars) - 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: - vp = icalendar.prop.vPeriod([ t.astimezone(utc) for t in [dtstart, dtend] ]) - vp.params.pop('TZID') + for calendar_config in calendars_to_process: + calendar_data = get_calendar(calendar_config) + input_calendar = Calendar.from_ical(calendar_data) - if looks_tentative(component): - vp.FBTYPE = icalendar.enums.FBTYPE.BUSY_TENTATIVE - else: - vp.FBTYPE = icalendar.enums.FBTYPE.BUSY_UNAVAILABLE + if 'all_tentative' in calendar_config and calendar_config['all_tentative']: + tentative_fn = lambda component: True + else: + tentative_fn = looks_tentative - busy.add('freebusy', vp) + process_calendar_events(input_calendar, busy, start_date, end_date, is_tentative_fn=tentative_fn) output.add_component(busy) - return output.to_ical(), { "Content-Type": "text/calendar" } + return output.to_ical(), {"Content-Type": "text/calendar"} except Exception as e: - print("error:", e) - return f"Error parsing with icalendar: {str(e)}", 500 + print(f"error: {str(e)} {''.join(traceback.format_exception(e))}") + return f"Error processing calendar: {str(e)}", 500 if __name__ == '__main__': app.run(debug=True)