allow multiple calendars with override of tentative detection
M flake.nix → flake.nix
@@ -123,6 +123,34 @@ cfg = config.services.mycal; settingsFormat = pkgs.formats.toml { }; inherit (lib) mkEnableOption mkOption mkIf types; + + calendarOptionType = types.submodule { + options = { + file = mkOption { + type = with types; nullOr str; + default = ""; + example = "/path/to/calendar/file"; + description = '' + Path to the calendar file. Preferred over URL. + ''; + }; + url = mkOption { + type = with types; nullOr str; + default = ""; + example = "https://example.com/calendar.ics"; + description = '' + URL to the calendar file. + ''; + }; + all_tentative = mkOption { + type = types.bool; + default = false; + description = '' + Whether all events from this calendar should be marked as tentative. + ''; + }; + }; + }; in { options.services.mycal = {@@ -179,28 +207,31 @@ calendar = mkOption { default = { }; description = '' - Configuration for the calendar. Either a file or a URL must be provided. + Configuration for a single calendar. Either a file or a URL must be provided. + This option is kept for backward compatibility. Using the calendars option + is recommended for new configurations. + + Note: This option is deprecated and will be removed in a future release. ''; - type = types.submodule { - options = { - file = mkOption { - type = types.str; - default = ""; - example = "/path/to/calendar/file"; - description = '' - Path to the calendar file. Preferred over URL. - ''; - }; - url = mkOption { - type = types.str; - default = ""; - example = "https://example.com/calendar.ics"; - description = '' - URL to the calendar file. - ''; - }; - }; - }; + type = calendarOptionType; + }; + + calendars = mkOption { + default = [ ]; + description = '' + Configuration for multiple calendars. This option will be appended to the + singular calendar option if both are specified. + ''; + type = types.listOf calendarOptionType; + }; + + email = mkOption { + type = types.str; + default = ""; + example = "john.doe@example.com"; + description = '' + Email address of the user. + ''; }; }; };@@ -208,6 +239,21 @@ }; }; config = mkIf cfg.enable { + # Validate configuration + assertions = [ + { + assertion = with cfg.settings; + (calendars != [ ] && builtins.any (cal: cal.file != "" || cal.url != "") calendars) || + (calendar.file != "" || calendar.url != ""); + message = "At least one calendar must be configured with either a file or URL in either settings.calendar or settings.calendars"; + } + ]; + + # Issue deprecation warning if the calendar option is used + warnings = lib.optional + (cfg.settings.calendar.file != "" || cfg.settings.calendar.url != "") + "The settings.calendar option is deprecated and will be removed in a future release. Please use the settings.calendars option instead."; + systemd.services.mycal = { description = "Mycal Calendar Service";
M mycal.py → mycal.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)