Das Problem
Du baust ein Script, das deine heutigen Kalender-Termine abruft. CalDAV-Query mit time-range für heute, 00:00 bis 23:59 Uhr. Sollte einfach sein, oder?
# CalDAV Query: heute 00:00 bis 23:59
start_str = "20250123T000000Z"
end_str = "20250123T235959Z"
response = caldav_report(start_str, end_str)
events = parse_events(response)
print(f"Events heute: {len(events)}")
# Output: Events heute: 63
63 Events. Aber du weißt genau, dass heute nur ein einziger Termin stattfindet.
Was zum Teufel?
TL;DR
CalDAV gibt bei wiederkehrenden Terminen (RRULE) alle Events zurück, die den Zeitraum potenziell betreffen könnten — nicht nur die, die heute tatsächlich stattfinden. DTSTART ist das Ursprungsdatum des wiederkehrenden Termins, nicht das aktuelle.
Lösung: dateutil.rrule nutzen, um zu berechnen, ob ein Event-Instanz heute stattfindet:
from dateutil.rrule import rrulestr
recur = rrulestr(rrule_str, dtstart=dtstart_dt, ignoretz=True)
today_start = datetime.combine(today_date, datetime.min.time())
today_end = datetime.combine(today_date, datetime.max.time())
instances_today = list(recur.between(today_start, today_end, inc=True))
Ursache: RRULE — die Zeitreise-Maschine
Wiederkehrende Termine haben eine RRULE-Zeile:
BEGIN:VEVENT
SUMMARY:Daily Standup
DTSTART;TZID="(GMT +01:00)":20240101T090000
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR
END:VEVENT
Hier steht:
- DTSTART: 1. Januar 2024, 09:00 Uhr ← Das ist das Ursprungsdatum
- RRULE: Jede Woche, Montag bis Freitag
Wenn du jetzt am 23. Januar 2025 deine CalDAV-Query abschickst mit time-range="20250123T000000Z/20250123T235959Z", dann denkt CalDAV:
“Okay, dieser Event könnte theoretisch heute stattfinden (weil RRULE sagt wöchentlich Mo-Fr). Ich gebe ihn zurück.”
CalDAV gibt dir das komplette Event — mit dem DTSTART vom 1. Januar 2024. Nicht heute. Das Ursprungsdatum.
Und wenn du 50 wiederkehrende Termine im Kalender hast (wöchentliches Team-Meeting, tägliches Standup, jeden Freitag Sport, etc.), dann bekommst du alle 50 zurück, obwohl heute nur 3 davon stattfinden.
Warum macht CalDAV das?
Weil CalDAV-Server dumm sind. Sie expandieren RRULE nicht selbst, sondern überlassen dir die Arbeit.
Der time-range Filter checkt nur:
- Liegt
DTSTARTim Zeitraum? ✅ - Betrifft die RRULE den Zeitraum potenziell? ✅
Beide Bedingungen erfüllt → Event kommt in die Antwort.
Das perfide
CalDAV sieht aus als ob es filtert, tut es aber nicht.
Du schickst eine Query: “Gib mir alle Termine für heute, 23. Januar 2025.” CalDAV antwortet mit 63 Events. Du denkst: “Perfekt, 63 Termine heute!”
Falsch. CalDAV hat dir 63 Events gegeben, die potenziell heute stattfinden könnten. Nicht die, die tatsächlich heute stattfinden.
Das ist wie wenn du dein Grommunio auf srv-mail.rebellion.local fragst: “Welche Meetings habe ich heute?” und es antwortet: “Hier sind alle Meetings die jemals montags stattfinden, egal welches Jahr.”
Die Konsequenz: Du musst jedes Event selbst prüfen. CalDAV verschwendet Bandbreite mit 60+ Events die du nicht brauchst, und du musst lokal filtern.
Lösung: RRULE selbst auswerten
Du musst für jedes Event prüfen, ob es heute tatsächlich stattfindet.
Python-Bibliotheken
- icalendar: Parst iCal-Daten, aber handhabt RRULE nicht selbst
- dateutil.rrule: Berechnet Instanzen von wiederkehrenden Terminen
Schritt 1: Event parsen
from icalendar import Calendar as iCalendar
from datetime import datetime, date, timedelta
cal = iCalendar.from_ical(raw_ical_text)
for component in cal.walk():
if component.name != 'VEVENT':
continue
summary = str(component.get('SUMMARY', 'Ohne Titel'))
dtstart = component.get('DTSTART').dt
rrule_prop = component.get('RRULE')
Schritt 2: RRULE-Check
from dateutil.rrule import rrulestr
def event_happens_today(component, today_date):
"""Prüft ob Event heute stattfindet (berücksichtigt RRULE)"""
dtstart = component.get('DTSTART')
if not dtstart:
return False
dt_val = dtstart.dt
all_day = isinstance(dt_val, date) and not isinstance(dt_val, datetime)
rrule_prop = component.get('RRULE')
if rrule_prop is None:
# Kein RRULE → einfacher Termin
event_date = dt_val if all_day else dt_val.date()
return event_date == today_date
# RRULE vorhanden → Instanzen berechnen
rrule_str = rrule_prop.to_ical().decode('utf-8')
# DTSTART als naive datetime (dateutil erwartet keine Timezone)
if all_day:
dtstart_dt = datetime.combine(dt_val, datetime.min.time())
else:
dtstart_dt = dt_val.replace(tzinfo=None) if dt_val.tzinfo else dt_val
# Parse RRULE
recur = rrulestr(rrule_str, dtstart=dtstart_dt, ignoretz=True)
# Prüfe ob heute eine Instanz stattfindet
today_start = datetime.combine(today_date, datetime.min.time())
today_end = datetime.combine(today_date, datetime.max.time())
instances_today = list(recur.between(today_start, today_end, inc=True))
return len(instances_today) > 0
Schritt 3: Filtern
today = datetime.now().date()
events_today = []
for component in cal.walk():
if component.name == 'VEVENT':
if event_happens_today(component, today):
events_today.append(component)
print(f"Tatsächliche Events heute: {len(events_today)}")
# Output: Tatsächliche Events heute: 1
Boom. Von 63 auf 1.
Pitfall: DTSTART mit TZID parsen
Manche CalDAV-Server schreiben DTSTART so:
DTSTART;TZID="(GMT +01:00)":20250123T090000
Die icalendar-Library kommt damit manchmal nicht klar (vor allem mit den Quotes um die TZID).
Fallback: Regex-Parsing
import re
def _parse_ical_datetime(ical_text, property_name):
"""Robuster Parser für DTSTART/DTEND aus iCal-Text"""
pattern = rf'{property_name}[^:]*?:(\d{{8}}(?:T\d{{6}})?(?:Z)?)'
match = re.search(pattern, ical_text)
if match:
datetime_str = match.group(1)
if 'T' not in datetime_str:
# Nur Datum (ganztägiges Event)
dt = datetime.strptime(datetime_str, '%Y%m%d')
return dt.replace(tzinfo=timezone(timedelta(hours=1)))
elif datetime_str.endswith('Z'):
# UTC time
dt = datetime.strptime(datetime_str, '%Y%m%dT%H%M%SZ')
return dt.replace(tzinfo=timezone.utc)
else:
# Local time
dt = datetime.strptime(datetime_str, '%Y%m%dT%H%M%S')
return dt.replace(tzinfo=timezone(timedelta(hours=1)))
return None
Nutze das als Fallback, wenn component.get('DTSTART') fehlschlägt.
Vollständige Funktion
Hier die produktionsreife Version (aus einem echten TTS-Termin-Ansage-Script):
from dateutil.rrule import rrulestr
from datetime import datetime, date, timedelta, timezone
def _get_event_instance_for_today(component, today_date, ical_raw_text=None):
"""
Berechnet ob ein Event (mit oder ohne RRULE) heute stattfindet.
Gibt None zurück wenn nicht, oder ein dict mit start/end/all_day wenn ja.
"""
dtstart = component.get('DTSTART')
dtend_component = component.get('DTEND')
# Fallback auf Regex-Parsing wenn icalendar fehlschlägt
if not dtstart and ical_raw_text:
dt_val = _parse_ical_datetime(ical_raw_text, 'DTSTART')
dtend_val = _parse_ical_datetime(ical_raw_text, 'DTEND')
if not dt_val:
return None
all_day = False
else:
if not dtstart:
return None
dt_val = dtstart.dt
dtend_val = dtend_component.dt if dtend_component else None
all_day = isinstance(dt_val, date) and not isinstance(dt_val, datetime)
# Check for RRULE
rrule_prop = component.get('RRULE')
if rrule_prop is None:
# Simple event ohne Wiederholung
if all_day:
event_date = dt_val
else:
event_date = dt_val.date() if hasattr(dt_val, 'date') else dt_val
if event_date != today_date:
return None
else:
# Wiederkehrendes Event - berechne Instanzen
rrule_str = rrule_prop.to_ical().decode('utf-8')
# DTSTART als naive datetime für rrule
if all_day:
dtstart_dt = datetime.combine(dt_val, datetime.min.time())
else:
if isinstance(dt_val, datetime):
dtstart_dt = dt_val.replace(tzinfo=None) if dt_val.tzinfo else dt_val
else:
dtstart_dt = datetime.combine(dt_val, datetime.min.time())
# Parse RRULE mit ignoretz
recur = rrulestr(rrule_str, dtstart=dtstart_dt, ignoretz=True)
# Berechne ob heute eine Instanz stattfindet
today_start = datetime.combine(today_date, datetime.min.time())
today_end = datetime.combine(today_date, datetime.max.time())
instances_today = list(recur.between(today_start, today_end, inc=True))
if not instances_today:
return None
# Nutze die erste Instanz heute
first_instance = instances_today[0]
if all_day:
dt_val = first_instance.date()
else:
dt_val = first_instance
# Event findet heute statt - berechne Start/End
start_dt = dt_val if isinstance(dt_val, datetime) else datetime.combine(dt_val, datetime.min.time())
if dtend_val:
end_dt = dtend_val if isinstance(dtend_val, datetime) else datetime.combine(dtend_val, datetime.min.time())
else:
duration = component.get('DURATION')
if duration:
end_dt = start_dt + duration.dt
else:
end_dt = start_dt + (timedelta(days=1) if all_day else timedelta(hours=1))
return {
'start': start_dt,
'end': end_dt,
'all_day': all_day
}
Verifikation
Test mit einem wiederkehrenden Termin:
from icalendar import Calendar as iCalendar
ical_data = """BEGIN:VCALENDAR
BEGIN:VEVENT
SUMMARY:Daily Standup
DTSTART:20240101T090000
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR
END:VEVENT
END:VCALENDAR"""
cal = iCalendar.from_ical(ical_data)
component = [c for c in cal.walk() if c.name == 'VEVENT'][0]
# Heute ist Donnerstag, 23. Januar 2025
today = date(2025, 1, 23)
result = _get_event_instance_for_today(component, today)
print(result)
# Output: {'start': datetime(2025, 1, 23, 9, 0), 'end': datetime(2025, 1, 23, 10, 0), 'all_day': False}
Fazit
CalDAV ist nicht dein Freund wenn es um wiederkehrende Termine geht. Der time-range Filter gibt dir Events, die theoretisch im Zeitraum stattfinden könnten — aber nicht, ob sie es tatsächlich tun.
Was du gelernt hast:
- CalDAV gibt ALLE RRULE-Events zurück, die den Zeitraum betreffen
DTSTARTist bei wiederkehrenden Terminen das Ursprungsdatum, nicht das aktuelledateutil.rruleist dein Freund:rrulestr()+between()für Instanz-Berechnung- Regex-Parsing als Fallback wenn
icalendarbei TZID mit Quotes scheitert
Und wenn du das nächste Mal denkst “CalDAV gibt mir 63 Events zurück, aber es sollten nur 3 sein” — jetzt weißt du warum.
Happy hacking! 🚀