{ config, pkgs, user, ... }: let radicale-root = "/var/lib/radicale"; web-root = "/var/nginx/webroot"; in { systemd.timers."nx_cal_publish" = { enable = true; wantedBy = [ "timers.target" ]; timerConfig = { OnBootSec = "2m"; OnUnitActiveSec = "6h"; Unit = "nx_cal_publish.service"; }; }; systemd.services."nx_cal_publish" = { script = with pkgs; let nx_cal_publish = (writers.writePython3Bin "nx_cal_publish" { libraries = with python3Packages; [ ical ics requests dateutils ]; flakeIgnore = [ "E302" "E305" "E226" "E501" ]; } /*python */ '' import pytz import os from ics import Calendar, Event from ics.grammar.parse import ContentLine from dateutil.rrule import rrulestr from ics.event import datetime, timedelta def combine_ics_from_directories(directories, output_file): """ Combine all .ics events from a list of directories into one .ics file, supporting recurring events. :param directories: List of directories containing .ics files. :param output_file: Path to the output .ics file. """ combined_calendar = Calendar() for directory in directories: if not os.path.exists(directory): print(f"Directory '{directory}' does not exist. Skipping.") continue for filename in os.listdir(directory): if filename.endswith(".ics"): file_path = os.path.join(directory, filename) try: with open(file_path, 'r') as file: calendar = Calendar(file.read()) for event in calendar.events: # Handle recurring events rrule_line = None for line in event.extra: if isinstance(line, ContentLine) and line.name == "RRULE": rrule_line = line break if rrule_line: # Convert UNTIL to UTC if DTSTART is timezone-aware rrule_params = rrule_line.value.split(";") rrule_dict = {} for param in rrule_params: key, value = param.split("=") rrule_dict[key] = value if "UNTIL" in rrule_dict and event.begin.tzinfo: until = datetime.fromisoformat(rrule_dict["UNTIL"]) if until.tzinfo is None: # If UNTIL is naive, make it UTC until = until.astimezone(pytz.UTC) rrule_dict["UNTIL"] = until.astimezone(pytz.UTC).strftime("%Y%m%dT%H%M%SZ") # Reconstruct RRULE string rrule_fixed = ";".join(f"{key}={value}" for key, value in rrule_dict.items()) rrule = rrulestr(rrule_fixed, dtstart=event.begin.astimezone(pytz.timezone('CET'))) # Expand recurring events and filter based on the date for occurrence in rrule: notTooOld = occurrence.date() >= (datetime.now().astimezone(pytz.UTC) - timedelta(days=1)).date() notTooFuturisic = occurrence.date() < (datetime.now().astimezone(pytz.UTC) + timedelta(days=60)).date() if notTooOld and notTooFuturisic: new_event = Event( name="", begin=occurrence, end=occurrence + (event.end - event.begin), transparent=event.transparent or True, ) combined_calendar.events.add(new_event) else: # Regular events, directly add if within date range if event.begin.astimezone(pytz.timezone('CET')).date() >= (datetime.now().astimezone(pytz.timezone('CET')) - timedelta(days=1)).date(): new_event = Event( name="", begin=event.begin, end=event.end, transparent=event.transparent or True, ) combined_calendar.events.add(new_event) except Exception as e: print(f"Error reading file '{file_path}': {e}") exit(1) try: with open(output_file, 'w') as file: file.writelines(combined_calendar.serialize_iter()) print(f"Combined .ics file saved to '{output_file}'") except Exception as e: print(f"Error saving combined .ics file: {e}") if __name__ == "__main__": # List of directories containing .ics files DIRECTORIES = [ "${radicale-root}/collections/collection-root/${user}/preservation", "${radicale-root}/collections/collection-root/${user}/effort", "${radicale-root}/collections/collection-root/${user}/experience", "${radicale-root}/collections/collection-root/${user}/exposure", "${radicale-root}/collections/collection-root/${user}/engagement", ] # Path to the output .ics file OUTPUT_FILE = "${web-root}/schedule.ics" combine_ics_from_directories(DIRECTORIES, OUTPUT_FILE) ''); in '' ${nx_cal_publish}/bin/nx_cal_publish ''; serviceConfig = { Type = "oneshot"; User = "nx2"; }; }; }