Auf OSMCal stehen die Termine der OpenStreetMap-Community: Stammtische, Mapathons, Online-Treffen und Konferenzen. Ich wollte diese Termine in meinem Nextcloud-Kalender haben, und zwar mit Erinnerungen, damit ich nicht erst am Abend selbst merke, dass heute Stammtisch gewesen wäre.
In diesem Artikel zeige ich, wie ich das mit einem kleinen Python-Skript in einem Docker-Container gelöst habe. Es holt den Feed regelmäßig ab, hängt an jeden Termin mehrere Erinnerungen und schreibt alles per CalDAV in einen ganz normalen Nextcloud-Kalender. Das ganze läuft auf meinem ugreen NAS.
Die einfache Variante: Kalender-Abo
OSMCal bietet unter osmcal.org/subscribe einen iCal-Feed an. Für Feed-Reader gibt es auch einen RSS-Feed, aber den können Kalender nicht lesen. Der iCal-Feed lautet:
https://osmcal.org/events.ics
Der Feed lässt sich mit einem Ländercode filtern, zum Beispiel nur Termine aus Österreich mit https://osmcal.org/events.ics?in=AT~.
In Nextcloud bindet man ihn in der Kalender-App über „+ Neuer Kalender“ → „Neues Abonnement aus Link (nur lesen)“ ein. Das funktioniert sofort, hat aber zwei Nachteile:
- Abonnierte Kalender sind schreibgeschützt. An einzelne Termine kann man keine Erinnerungen hängen.
- Nextcloud kann zwar pro Kalender eine Standarderinnerung setzen, das ist aber nur eine (getrennt nach ganztägigen und anderen Terminen). Außerdem gilt sie nur für Termine, die man in der Weboberfläche selbst neu anlegt.
Ich wollte aber mehrere Erinnerungen pro Termin: eine Woche vorher, zwei Tage vorher und einen Tag vorher. Deshalb die Variante mit Skript.
Die Idee
Ein Container auf dem NAS holt alle sechs Stunden den iCal-Feed. Für jeden Termin baut er eine eigene Kalenderdatei mit den gewünschten Erinnerungen (VALARM) und lädt sie per CalDAV in einen normalen Kalender „OSMCal“. Von dort synchronisieren Handy (DAVx⁵) und Laptop (Thunderbird) wie gewohnt.
Dabei gilt:
- Neue Termine werden angelegt, geänderte Termine überschrieben. Unveränderte Termine bleiben unangetastet, damit die Geräte nicht ständig alles neu synchronisieren.
- Termine in der Zukunft, die aus dem Feed verschwinden (also abgesagt wurden), werden gelöscht.
- Vergangene Termine bleiben stehen. Der Kalender wird so nebenbei zum Archiv.
- Ist der Feed einmal leer, ändert das Skript gar nichts. So wird bei einer Störung von OSMCal nicht versehentlich alles gelöscht.
- Den Kalender legt das Skript beim ersten Lauf selbst an.
Voraussetzungen
- Nextcloud mit der Kalender-App (bei mir Nextcloud 34 in Docker)
- Ein Rechner mit Docker und Docker Compose, der Nextcloud erreicht. Bei mir ist das dasselbe NAS.
- Ein Nextcloud-Benutzer, in dessen Konto der Kalender landen soll
Schritt 1: App-Passwort anlegen
Das Skript soll nicht mit dem richtigen Passwort arbeiten. In Nextcloud unter „Persönliche Einstellungen“ → „Sicherheit“ → „Neues App-Passwort erstellen“ legst du ein eigenes Passwort an, zum Beispiel mit dem Namen osmcal-sync. Das kann man später jederzeit einzeln widerrufen.
Schritt 2: Projektordner anlegen
Auf dem NAS lege ich einen Ordner für das Docker-Projekt an. Bei mir liegen alle Dienste unter /volume1/docker/<dienst>/:
mkdir -p /volume1/docker/osmcal-sync cd /volume1/docker/osmcal-sync
Dort kommen vier Dateien hinein: das Skript, ein Dockerfile, eine docker-compose.yaml und eine .env mit den Zugangsdaten.
Schritt 3: Das Skript
Die Datei osmcal_sync.py braucht nur zwei Bibliotheken: requests für HTTP und icalendar zum Lesen und Schreiben der Kalenderdaten. CalDAV selbst ist bloß HTTP mit ein paar zusätzlichen Methoden (PROPFIND, REPORT, MKCALENDAR), dafür braucht es keine eigene Bibliothek.
#!/usr/bin/env python3
"""OSMCal -> Nextcloud: holt den iCal-Feed von osmcal.org und schreibt jeden
Termin mit festen Erinnerungen per CalDAV in einen normalen Nextcloud-Kalender.
- Neue/geänderte Termine werden angelegt bzw. überschrieben.
- Zukünftige Termine, die aus dem Feed verschwinden (abgesagt), werden gelöscht.
- Vergangene Termine bleiben im Kalender stehen.
"""
import hashlib
import os
import re
import sys
import time
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from urllib.parse import quote
import requests
from icalendar import Alarm, Calendar
FEED_URL = os.environ.get("OSMCAL_URL", "https://osmcal.org/events.ics")
NC_URL = os.environ["NC_URL"].rstrip("/")
NC_USER = os.environ["NC_USER"]
NC_PASS = os.environ["NC_PASS"]
CAL_SLUG = os.environ.get("NC_CALENDAR", "osmcal")
CAL_NAME = os.environ.get("NC_CALENDAR_NAME", "OSMCal")
# Erinnerungen als ISO-8601-Dauer vor Terminbeginn, kommagetrennt
ALARMS = [a.strip() for a in os.environ.get("ALARMS", "P7D,P2D,P1D").split(",") if a.strip()]
INTERVAL = int(os.environ.get("INTERVAL_HOURS", "6")) * 3600
PREFIX = "osmcal-"
# Zertifikatsprüfung: "true", "false" (selbstsigniert) oder Pfad zu einer CA-/Zertifikatsdatei
VERIFY = os.environ.get("NC_VERIFY_TLS", "true")
VERIFY = {"true": True, "false": False}.get(VERIFY.lower(), VERIFY)
CAL_URL = f"{NC_URL}/remote.php/dav/calendars/{quote(NC_USER)}/{CAL_SLUG}/"
NS = {"d": "DAV:", "c": "urn:ietf:params:xml:ns:caldav"}
http = requests.Session()
http.auth = (NC_USER, NC_PASS)
http.verify = VERIFY
if VERIFY is False:
requests.packages.urllib3.disable_warnings()
def log(msg):
print(f"{datetime.now():%Y-%m-%d %H:%M:%S} {msg}", flush=True)
def ensure_calendar():
r = http.request("PROPFIND", CAL_URL, headers={"Depth": "0"})
if r.status_code == 207:
return
if r.status_code != 404:
r.raise_for_status()
body = f"""<?xml version="1.0" encoding="utf-8"?>
<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:set><d:prop>
<d:displayname>{CAL_NAME}</d:displayname>
<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>
</d:prop></d:set>
</c:mkcalendar>"""
r = http.request("MKCALENDAR", CAL_URL, data=body.encode(),
headers={"Content-Type": "application/xml; charset=utf-8"})
r.raise_for_status()
log(f"Kalender '{CAL_NAME}' angelegt")
def to_utc(value):
"""DTSTART (datetime oder date) als aware datetime in UTC."""
if isinstance(value, datetime):
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
return datetime(value.year, value.month, value.day, tzinfo=timezone.utc)
def existing_events():
"""href -> (Hash, Beginn) aller vom Skript angelegten Termine."""
body = """<?xml version="1.0" encoding="utf-8"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><c:calendar-data/></d:prop>
<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"/></c:comp-filter></c:filter>
</c:calendar-query>"""
r = http.request("REPORT", CAL_URL, data=body.encode(),
headers={"Depth": "1", "Content-Type": "application/xml; charset=utf-8"})
r.raise_for_status()
result = {}
for resp in ET.fromstring(r.content).findall("d:response", NS):
href = resp.findtext("d:href", namespaces=NS) or ""
name = href.rstrip("/").rsplit("/", 1)[-1]
data = resp.findtext(".//c:calendar-data", namespaces=NS)
if not name.startswith(PREFIX) or not data:
continue
ev = next(iter(Calendar.from_ical(data).walk("VEVENT")), None)
if ev is None:
continue
result[name] = (str(ev.get("X-OSMCAL-HASH", "")), to_utc(ev.decoded("DTSTART")))
return result
def build_objects(feed):
"""Dateiname -> (Hash, ics-Bytes) für jeden Termin im Feed."""
timezones = {str(tz["TZID"]): tz for tz in feed.walk("VTIMEZONE")}
objects = {}
for ev in feed.walk("VEVENT"):
uid = str(ev["UID"])
name = PREFIX + re.sub(r"[^A-Za-z0-9_.-]", "_", uid) + ".ics"
# DTSTAMP ändert sich bei jedem Abruf und zählt nicht als Änderung
stamp = ev.pop("DTSTAMP", None)
digest = hashlib.sha256(ev.to_ical() + ",".join(ALARMS).encode()).hexdigest()[:16]
if stamp is not None:
ev["DTSTAMP"] = stamp
ev.subcomponents = [c for c in ev.subcomponents if c.name != "VALARM"]
ev.add("X-OSMCAL-HASH", digest)
for trigger in ALARMS:
alarm = Alarm()
alarm.add("ACTION", "DISPLAY")
alarm.add("DESCRIPTION", str(ev.get("SUMMARY", "OSMCal-Termin")))
alarm.add("TRIGGER", "-" + trigger, encode=False, parameters={"VALUE": "DURATION"})
ev.add_component(alarm)
cal = Calendar()
cal.add("PRODID", "-//osmcal-sync//DE")
cal.add("VERSION", "2.0")
tzids = {p.params["TZID"] for p in (ev.get("DTSTART"), ev.get("DTEND"))
if p is not None and "TZID" in p.params}
for tzid in sorted(tzids):
if tzid in timezones:
cal.add_component(timezones[tzid])
cal.add_component(ev)
objects[name] = (digest, cal.to_ical())
return objects
def sync():
r = requests.get(FEED_URL, timeout=60)
r.raise_for_status()
wanted = build_objects(Calendar.from_ical(r.content))
if not wanted:
log("Feed ist leer, nichts geändert (Schutz vor versehentlichem Löschen)")
return
ensure_calendar()
have = existing_events()
now = datetime.now(timezone.utc)
created = updated = deleted = 0
for name, (digest, ics) in wanted.items():
if name in have and have[name][0] == digest:
continue
r = http.put(CAL_URL + name, data=ics, headers={"Content-Type": "text/calendar; charset=utf-8"})
if r.status_code >= 400:
log(f"Fehler {r.status_code} bei {name}: {r.text[:200]}")
continue
if name in have:
updated += 1
else:
created += 1
for name, (_, start) in have.items():
if name not in wanted and start > now:
http.delete(CAL_URL + name).raise_for_status()
deleted += 1
log(f"{len(wanted)} Termine im Feed: {created} neu, {updated} geändert, {deleted} gelöscht")
def main():
once = "--once" in sys.argv
while True:
try:
sync()
except Exception as e: # weiterlaufen, nächster Versuch im nächsten Intervall
log(f"Fehler: {e}")
if once:
sys.exit(1)
if once:
break
time.sleep(INTERVAL)
if __name__ == "__main__":
main()
Zwei Details, die mich beim Schreiben kurz beschäftigt haben:
- OSMCal setzt bei jedem Abruf einen neuen Zeitstempel (
DTSTAMP). Würde das Skript den ganzen Termin vergleichen, sähe jeder Termin bei jedem Lauf „geändert“ aus. Deshalb wird für den Vergleich ein Hash ohneDTSTAMPgebildet und alsX-OSMCAL-HASHim Termin gespeichert. - Termine mit Uhrzeit haben im Feed eine Zeitzone (
TZID). Die passendeVTIMEZONEaus dem Feed wird mit in jede einzelne Kalenderdatei kopiert, sonst landen die Termine eventuell zur falschen Uhrzeit im Kalender.
Schritt 4: Dockerfile und Compose
FROM python:3.13-alpine RUN pip install --no-cache-dir icalendar==7.3.0 requests==2.34.2 COPY osmcal_sync.py /app/osmcal_sync.py RUN chmod -R a+rX /app USER nobody CMD ["python", "-u", "/app/osmcal_sync.py"]
Der Container läuft als unprivilegierter Benutzer nobody. Das chmod ist kein Zufall, dazu weiter unten mehr.
services:
osmcal-sync:
build: .
container_name: osmcal-sync
restart: unless-stopped
env_file: .env
environment:
TZ: Europe/Vienna
Schritt 5: Konfiguration in der .env
Alle Einstellungen stehen in der .env. Die Adresse und der Benutzername hier sind Beispiele, die musst du an dein System anpassen:
# Nextcloud (vom Docker-Host aus erreichbar) NC_URL=http://192.168.0.10:8080 # Bei HTTPS mit selbstsigniertem Zertifikat: false NC_VERIFY_TLS=true NC_USER=benutzer # App-Passwort aus Schritt 1 NC_PASS=xxxxx-xxxxx-xxxxx-xxxxx-xxxxx # Kalender, der angelegt bzw. befüllt wird (Kürzel in der URL und Anzeigename) NC_CALENDAR=osmcal NC_CALENDAR_NAME=OSMCal # Feed: weltweit. Nur Österreich: https://osmcal.org/events.ics?in=AT OSMCAL_URL=https://osmcal.org/events.ics # Erinnerungen vor Terminbeginn ALARMS=P7D,P2D,P1D # Abruf alle X Stunden INTERVAL_HOURS=6
Weil das App-Passwort darin steht, sollte nur root die Datei lesen dürfen:
chmod 600 .env
Die Erinnerungen werden als ISO-8601-Dauer angegeben. Dabei gibt es eine Falle: Für Stunden und Minuten braucht es ein T, sonst sind Monate gemeint.
| Angabe | Bedeutung |
|---|---|
P1W |
1 Woche vorher |
P7D |
7 Tage vorher |
P1D |
1 Tag vorher |
PT2H |
2 Stunden vorher |
PT15M |
15 Minuten vorher |
P15M |
15 Monate vorher (!) |
Schritt 6: Starten
docker compose up -d --build docker logs -f osmcal-sync
docker logs -f verfolgt das Log laufend. Mit Strg+C beendest du nur die Anzeige, der Container läuft im Hintergrund weiter.
Für einen einmaligen Testlauf ohne Endlosschleife gibt es --once:
docker compose run --rm osmcal-sync python /app/osmcal_sync.py --once
Schritt 7: Aufräumen und Geräte
- Wer vorher das schreibgeschützte Abo aus dem ersten Abschnitt angelegt hat, sollte es jetzt löschen, sonst steht jeder Termin doppelt im Kalender.
- In DAVx⁵ am Handy die Kalenderliste aktualisieren und „OSMCal“ anhaken. Thunderbird zeigt neue Kalender eines CalDAV-Kontos meist von selbst an.
Danach hat jeder OSMCal-Termin seine Erinnerungen:
Ändert man später ALARMS in der .env, reicht ein docker compose up -d. Beim nächsten Lauf werden alle Termine mit den neuen Erinnerungen neu geschrieben, weil die Erinnerungen in den Hash einfließen.
Stolpersteine
„Permission denied“ beim Start
Mein erster Start endete in einer Endlosschleife aus:
python: can't open file '/app/osmcal_sync.py': [Errno 13] Permission denied
Die Datei lag auf einer NAS-Freigabe mit restriktiven Rechten. COPY übernimmt diese Rechte ins Image, und der Benutzer nobody im Container darf die Datei dann nicht lesen. COPY --chmod=644 hat das bei mir nicht behoben, erst ein eigenes RUN chmod -R a+rX /app nach dem COPY hat es gelöst.
HTTP oder HTTPS?
Mein Nextcloud ist von außen nur per HTTPS über Tailscale erreichbar. Das Skript spricht aber Nextcloud direkt im Heimnetz per HTTP an. Weil Skript und Nextcloud auf demselben NAS laufen, verlassen die Daten das Gerät nicht, und eine Komponente weniger kann ausfallen.
Wer trotzdem HTTPS möchte:
- Bei einem selbstsignierten Zertifikat
NC_VERIFY_TLS=falsesetzen. - Bei einer Tailscale-Adresse (
*.ts.net) kann der Container den Namen meist nicht auflösen, weil MagicDNS nur im Tailscale-Container aktiv ist. Einextra_hosts-Eintrag in der Compose-Datei mit der Tailscale-IP des Hosts hilft.
Achtung bei Ugreen-NAS: Port 9443 ist dort die Verwaltungsoberfläche von UGOS, nicht Nextcloud.
Eigene Änderungen an OSMCal-Terminen
Man kann die Termine im Kalender bearbeiten, etwa eine zusätzliche Erinnerung setzen. Ändert sich der Termin aber später bei OSMCal, überschreibt das Skript die eigenen Änderungen.
Doppelte Erinnerungen
Die Erinnerungen zeigen Handy und Thunderbird an. Nextcloud selbst kann zusätzlich Push- oder E-Mail-Erinnerungen verschicken. Wer alles doppelt bekommt, schaltet eine der beiden Quellen ab.
Fazit
Ein Abo ist in zwei Minuten eingerichtet und für viele völlig ausreichend. Wer aber mehrere Erinnerungen pro Termin möchte, kommt mit einem kleinen Container und knapp 190 Zeilen Python ans Ziel. Ich hoffe zumindest für mich, dass ich so weniger oft Termine verpasse.