webdav to ical export

This commit is contained in:
XenGi 2026-07-13 19:42:47 +02:00
commit 289e502d6a
Signed by: xengi
SSH key fingerprint: SHA256:jxWM2RTHvxxcncXycwwWkP7HCWb4VREN05UGJTbIPZg
6 changed files with 155 additions and 0 deletions

110
caldav-export.py Normal file
View file

@ -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()

View file

@ -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

37
hosts/www/dav2ical.nix Normal file
View file

@ -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;
};
};
};
}

View file

@ -7,6 +7,7 @@
./openssh.nix
../../services/prometheus-node.nix
./nginx.nix
./dav2ical.nix
];
networking = {

Binary file not shown.

View file

@ -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 ];
}