diff --git a/caldav-export.py b/caldav-export.py new file mode 100644 index 0000000..44012c2 --- /dev/null +++ b/caldav-export.py @@ -0,0 +1,110 @@ +import argparse +import os +import re +from pathlib import Path + +import yaml +from caldav import DAVClient +from icalendar import Calendar + + +def safe_filename(name): + return re.sub(r"[^a-zA-Z0-9._-]+", "_", name) + + +def export_calendar(url, username, password, output): + client = DAVClient( + url=url, + username=username, + password=password, + ) + + principal = client.principal() + + calendars = principal.calendars() + + target = None + for cal in calendars: + if cal.name == output["calendar"]: + target = cal + break + + if target is None: + raise RuntimeError( + f"Calendar '{output['calendar']}' not found for {username}" + ) + + print(f"Exporting {username}/{target.name}") + + result = Calendar() + result.add("prodid", "-//CalDAV Export//") + result.add("version", "2.0") + + objects = target.objects() + + print(f" {len(objects)} objects") + + for obj in objects: + cal = Calendar.from_ical(obj.data) + + for component in cal.walk(): + if component.name != "VCALENDAR": + result.add_component(component) + + return result + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("config") + parser.add_argument( + "-o", + "--output", + default="/srv/http/ics", + ) + args = parser.parse_args() + + with open(args.config) as f: + config = yaml.safe_load(f) + + output_dir = Path(args.output) + output_dir.mkdir(parents=True, exist_ok=True) + + combined = Calendar() + combined.add("prodid", "-//CalDAV Export Combined//") + combined.add("version", "2.0") + + for entry in config["calendars"]: + username = entry["username"] + password = entry["password"] + + cal = export_calendar( + entry["url"], + username, + password, + entry, + ) + + filename = output_dir / f"{safe_filename(entry['calendar'])}.ics" + + with open(filename, "wb") as f: + f.write(cal.to_ical()) + + print(f" -> {filename}") + + for component in cal.walk(): + if component.name != "VCALENDAR": + combined.add_component(component) + + combined_file = output_dir / "all-calendars.ics" + + with open(combined_file, "wb") as f: + f.write(combined.to_ical()) + + print() + print(f"Combined export: {combined_file}") + + +if __name__ == "__main__": + main() + diff --git a/flake.nix b/flake.nix index f2f73ff..332341d 100644 --- a/flake.nix +++ b/flake.nix @@ -110,6 +110,12 @@ group = "nginx"; mode = "0440"; }; + caldav-export-config = { + file = ./secrets/caldav-export-config.age; + owner = "nobody"; + group = "nobody"; + mode = "0400"; + }; }; } ./hosts/www diff --git a/hosts/www/dav2ical.nix b/hosts/www/dav2ical.nix new file mode 100644 index 0000000..8b9a215 --- /dev/null +++ b/hosts/www/dav2ical.nix @@ -0,0 +1,37 @@ +{ config, pkgs, ... }: + +let + outputDir = "/srv/http/ical"; + script = pkgs.writeScript "caldav-export.py" '' + #!${pkgs.python3}/bin/python3 + + ${builtins.readFile ./caldav-export.py} + ''; + pythonEnv = pkgs.python3.withPackages (ps: with ps; [ + caldav + icalendar + pyyaml + ]); +in +{ + systemd = { + services.caldav-export = { + description = "Export CalDAV calendars to ICS"; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${pythonEnv}/bin/python ${script} ${config.age.secrets.caldav-export-config.path} -o ${outputDir}"; + }; + }; + timers.caldav-export = { + description = "Daily CalDAV export"; + wantedBy = [ + "timers.target" + ]; + timerConfig = { + OnCalendar = "daily"; + Persistent = true; + }; + }; + }; +} + diff --git a/hosts/www/default.nix b/hosts/www/default.nix index 2b89987..fe2a9d7 100644 --- a/hosts/www/default.nix +++ b/hosts/www/default.nix @@ -7,6 +7,7 @@ ./openssh.nix ../../services/prometheus-node.nix ./nginx.nix + ./dav2ical.nix ]; networking = { diff --git a/secrets/caldav-export-config.age b/secrets/caldav-export-config.age new file mode 100644 index 0000000..625d7bb Binary files /dev/null and b/secrets/caldav-export-config.age differ diff --git a/secrets/secrets.nix b/secrets/secrets.nix index 4013e03..80ca25c 100644 --- a/secrets/secrets.nix +++ b/secrets/secrets.nix @@ -65,4 +65,5 @@ in ]; "www-staging-htpasswd.age".publicKeys = xengi ++ [ _www ]; "radicale_htpasswd.age".publicKeys = xengi ++ [ _dav ]; + "caldav-export-config.age".publicKeys = xengi ++ [ _dav ]; }