From 289e502d6abe34ffc6cfc10d3e2cfe28e368cf52 Mon Sep 17 00:00:00 2001 From: "Ricardo (XenGi) Band" Date: Mon, 13 Jul 2026 19:42:47 +0200 Subject: [PATCH] webdav to ical export --- caldav-export.py | 110 +++++++++++++++++++++++++++++++ flake.nix | 6 ++ hosts/www/dav2ical.nix | 37 +++++++++++ hosts/www/default.nix | 1 + secrets/caldav-export-config.age | Bin 0 -> 1084 bytes secrets/secrets.nix | 1 + 6 files changed, 155 insertions(+) create mode 100644 caldav-export.py create mode 100644 hosts/www/dav2ical.nix create mode 100644 secrets/caldav-export-config.age 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 0000000000000000000000000000000000000000..625d7bb7f839fca0283ce3424df0b57d1b8197fa GIT binary patch literal 1084 zcmYdHPt{G$OD?J`D9Oyv)5|YP*Do{V(zR14F3!+RO))YxHMCSH_0Y~UELRAP(67w* z@hvi~h)gYWGRv$?%k=a&bIEs0@(kBDEio@CaY+fz_VV#Gbma=Luq?{(Omp%x%gPTa zHuZ@#@GLa149cs}PA;mfuqbfSFZ6SAOmvJWPDZ!Qwah0eyj&q9Eh^nLw7jG!(_1^h z(Xz_hEhi~F*di(@$2-w1+dCpVEiu^Cu*@sX#h1&;ywcGy(WxZT+axfc(%3w;+`S|$ zDI(G(tR%T2$uG;)&C@)>EY~78-vZsX6kqK$pFo8Y?yx z(!(Q4BfQg%^9>3O!a}*cBmKjD%(5z-0#ovxB9pzGoYK9G(|uinL(*N{3!_2{14@gX z(t`4f!UNH5)30<6bxv0>F)(&14)ySNcdjtYu`n|-4k`+%jH>kaHA>9P%_=YPO*0Mg z&P>ZTHj3n`NC`AF_jOJ5(RMXXamjSa4Rnq)^hq*z@-!{-4z_eJOE=9=$t%q)%#TF3 z&CR{Q)G%Ek-J>MjBe5heH6z3&HOJ4~IV;Jm%*{C>B_z`+Br_tzv!KG?GR-owJUfyr zr8qU;F~v2|%P%Ue(km&=u%y7EDxxGSy*S0q!a1=pzu4W;Frd=O#RDTGojeLGwaXRU zs>1T~oLo{2Jxn7EOp1z)T=fIe!!x~063YyWE6kiC0;+s7D??4p1IxKwGE&3J0-}sk zEsK+U(t<<1tHL}z3QDU2awEgEBSP~nk_$?+d_BCJGD5j@b#)a&5-r1B(=+mYD?Ka< z0@9*Fa}3F*1TNTq|p1Lq2l8Y z-XAzI3a`vSmBJ?bWU1