π

OSMCal-Termine mit Erinnerungen im Nextcloud-Kalender

Show Sidebar

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.

Monatsansicht des Nextcloud-Kalenders mit OSMCal-Terminen
Beispielansicht: OSMCal-Termine (blau) neben eigenen Terminen im Nextcloud-Kalender (klicken für größere Version)

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:

Ich wollte aber mehrere Erinnerungen pro Termin: eine Woche vorher, zwei Tage vorher und einen Tag vorher. Deshalb die Variante mit Skript.

Die Idee

Ablaufdiagramm von osmcal.org über osmcal-sync und Nextcloud zu Handy und Laptop
Ablauf: Feed abholen, Erinnerungen ergänzen, per CalDAV in Nextcloud schreiben (klicken für größere Version)

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:

Voraussetzungen

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:

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.

Terminal mit docker compose up und docker logs osmcal-sync
Erster Start und die ersten Läufe im Log (Beispiel) (klicken für größere Version)

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

Danach hat jeder OSMCal-Termin seine Erinnerungen:

Termindetails mit vier Erinnerungen
Ein OSMCal-Termin mit vier Erinnerungen (Beispiel) (klicken für größere Version)

Ä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:

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.

Kommentiere via unter dem Hashtag #20261023_OsmcalNextcloudErinnerungen (verteilt) oder via E-Mail (langlebig).