** DONE OSMCal-Termine mit Erinnerungen im Nextcloud-Kalender :blog:nextcloud:openstreetmap:docker: CLOSED: [2026-10-23 Fri 17:30] :PROPERTIES: :CREATED: [2026-10-23 Fri 17:00] :ID: 2026-10-23-osmcal-nextcloud-erinnerungen :END: :LOGBOOK: - State "DONE" from "NEXT" [2026-10-23 Fri 17:30] :END: Auf [[https://osmcal.org][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. #+CAPTION: Beispielansicht: OSMCal-Termine (blau) neben eigenen Terminen im Nextcloud-Kalender #+ATTR_HTML: :alt Monatsansicht des Nextcloud-Kalenders mit OSMCal-Terminen :width 560 :linked-image-width original [[tsfile:2026-10-23T10.00.02 OSMCal Nextcloud Monatsansicht Demo -- screenshots.png]] *** Die einfache Variante: Kalender-Abo OSMCal bietet unter [[https://osmcal.org/subscribe/][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: #+BEGIN_EXAMPLE https://osmcal.org/events.ics #+END_EXAMPLE 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 #+CAPTION: Ablauf: Feed abholen, Erinnerungen ergänzen, per CalDAV in Nextcloud schreiben #+ATTR_HTML: :alt Ablaufdiagramm von osmcal.org über osmcal-sync und Nextcloud zu Handy und Laptop :width 560 :linked-image-width original [[tsfile:2026-10-23T10.00.01 OSMCal Nextcloud Ablauf -- diagramme.png]] 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//=: #+BEGIN_SRC sh mkdir -p /volume1/docker/osmcal-sync cd /volume1/docker/osmcal-sync #+END_SRC 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. #+BEGIN_SRC python #!/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""" {CAL_NAME} """ 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 = """ """ 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() #+END_SRC 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 ohne =DTSTAMP= gebildet und als =X-OSMCAL-HASH= im Termin gespeichert. - Termine mit Uhrzeit haben im Feed eine Zeitzone (=TZID=). Die passende =VTIMEZONE= aus dem Feed wird mit in jede einzelne Kalenderdatei kopiert, sonst landen die Termine eventuell zur falschen Uhrzeit im Kalender. *** Schritt 4: Dockerfile und Compose #+BEGIN_SRC dockerfile 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"] #+END_SRC Der Container läuft als unprivilegierter Benutzer =nobody=. Das =chmod= ist kein Zufall, dazu weiter unten mehr. #+BEGIN_SRC yaml services: osmcal-sync: build: . container_name: osmcal-sync restart: unless-stopped env_file: .env environment: TZ: Europe/Vienna #+END_SRC *** 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: #+BEGIN_SRC sh # 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 #+END_SRC Weil das App-Passwort darin steht, sollte nur root die Datei lesen dürfen: #+BEGIN_SRC sh chmod 600 .env #+END_SRC 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 #+BEGIN_SRC sh docker compose up -d --build docker logs -f osmcal-sync #+END_SRC =docker logs -f= verfolgt das Log laufend. Mit Strg+C beendest du nur die Anzeige, der Container läuft im Hintergrund weiter. #+CAPTION: Erster Start und die ersten Läufe im Log (Beispiel) #+ATTR_HTML: :alt Terminal mit docker compose up und docker logs osmcal-sync :width 560 :linked-image-width original [[tsfile:2026-10-23T10.00.04 OSMCal Sync Docker Log Demo -- screenshots.png]] Für einen einmaligen Testlauf ohne Endlosschleife gibt es =--once=: #+BEGIN_SRC sh docker compose run --rm osmcal-sync python /app/osmcal_sync.py --once #+END_SRC *** 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: #+CAPTION: Ein OSMCal-Termin mit vier Erinnerungen (Beispiel) #+ATTR_HTML: :alt Termindetails mit vier Erinnerungen: 1 Woche, 2 Tage, 1 Tag und 15 Minuten vorher :width 450 :linked-image-width original [[tsfile:2026-10-23T10.00.03 OSMCal Nextcloud Termin mit Erinnerungen Demo -- screenshots.png]] Ä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: #+BEGIN_EXAMPLE python: can't open file '/app/osmcal_sync.py': [Errno 13] Permission denied #+END_EXAMPLE 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=false~ setzen. - Bei einer Tailscale-Adresse (=*.ts.net=) kann der Container den Namen meist nicht auflösen, weil MagicDNS nur im Tailscale-Container aktiv ist. Ein =extra_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.