Files
espresso_frame/server/app/caldav_client.py
T
tfaour ffce798754
Build and push server image / build-and-push (push) Successful in 48s
Add weather to calendar mode; fix CalDAV events never showing
Weather: multiple cities per frame, geocoded via Open-Meteo (no API
key), shown above the event list on agenda/today & tomorrow/week views
-- never month, no room for it there. Hand-drawn sun/cloud/rain/snow/
thunderstorm icons (no new font/icon asset, same primitives-only
approach the rest of calendar_render.py already uses). City geocoding
handles "City, State" qualifiers Open-Meteo's own search doesn't
(disambiguates same-named cities, e.g. the three "Portland"s).

CalDAV fix: events were never showing despite calendars discovering
fine, because fetch_calendar_events relied on the calendar-query
REPORT's server-side time-range filter, which real servers implement
inconsistently (confirmed against a real server, not just guessed --
reproduced locally with Radicale). Switched to fetching every event
unfiltered and doing all date-window filtering/expansion client-side,
same approach already used for plain ICS feeds.
2026-07-22 22:22:11 -04:00

120 lines
5.3 KiB
Python

"""CalDAV account support: discovering which calendars an account exposes,
and fetching one calendar's events -- the second way (alongside
calendar_feed.py's single-file ICS subscription) a user can link a
calendar for calendar frame mode (Nextcloud, Fastmail, iCloud, Radicale,
Baikal, ...).
Thin wrapper around the `caldav` PyPI package (RFC 4791 client). NOTE ON
LICENSING: `caldav` itself is dual-licensed GPL-3.0-or-later / Apache-2.0,
but it hard-depends on `icalendar-searcher`, which is AGPL-3.0-or-later --
the strongest copyleft license in this project's dependency tree, and the
one whose network-use clause is specifically written for server
applications like this one. This was an explicit, informed call by the
project owner to accept that exposure rather than hand-roll a CalDAV
client -- see the server README's Notes section. Anyone redistributing
this project (as opposed to just self-hosting it) should reread that
tradeoff for their own situation.
Pure functions -- no ORM, no FastAPI Depends -- same testability
philosophy as calendar_feed.py. Event parsing/expansion reuses
icalendar + recurring_ical_events directly (rather than trusting each
CalDAV server's own possibly-inconsistent RRULE expansion) so a CalDAV
calendar and an ICS subscription behave identically once fetched.
"""
from __future__ import annotations
import logging
from datetime import date, datetime
import caldav
import icalendar
import recurring_ical_events
logger = logging.getLogger(__name__)
HTTP_TIMEOUT_S = 15
class CalDavError(Exception):
"""Discovery or fetch failed -- network, auth, or an unexpected
server response. Raised loudly; callers (Settings' discover
endpoint, calendar_feed.merge_events) decide what to do. Wraps
whatever the caldav package/its transport raised, since that
exception hierarchy isn't something call sites should need to know
about directly."""
def discover_calendars(base_url: str, username: str, password: str) -> list[dict]:
"""[{"href": absolute_calendar_url, "display_name": str}, ...] for
every calendar in this account. base_url is the server's CalDAV
entry point (e.g. "https://cloud.example.com/remote.php/dav/" for
Nextcloud) -- the caller supplies it directly, same idiom as the
plain ICS subscription URL."""
try:
client = caldav.DAVClient(url=base_url, username=username, password=password, timeout=HTTP_TIMEOUT_S)
calendars = client.principal().calendars()
except Exception as e:
raise CalDavError(str(e)) from e
result = []
for cal in calendars:
try:
display_name = cal.get_display_name() or cal.name
except Exception:
display_name = None
result.append({"href": str(cal.url), "display_name": display_name or str(cal.url)})
return result
def fetch_calendar_events(calendar_url: str, username: str, password: str,
window_start: date, window_end: date) -> list[dict]:
"""One CalDAV calendar's events in [window_start, window_end] -- same
event dict shape as calendar_feed.fetch_source_events (no
"owner_display_name"; the caller adds that).
Deliberately does NOT use the calendar-query REPORT's server-side
time-range filter (caldav.Calendar.date_search) -- RFC 4791 leaves
that corner case underspecified and real servers disagree on it
(the caldav package's own docs warn "servers often behave
differently when presented with a search request"; confirmed here
too, once against a real server, as a calendar whose events just
silently never came back despite discovery/auth both working
fine). Instead this fetches every event in the calendar unfiltered
(get_events() is a plain "list VEVENTs" REPORT with no time-range
element -- the much more universally-supported case) and does 100%
of the date-window filtering/recurrence-expansion client-side via
icalendar + recurring_ical_events, exactly like calendar_feed.py
already does for plain ICS feeds. Heavier per-fetch (the whole
calendar, not just the window) but far more reliable."""
try:
client = caldav.DAVClient(url=calendar_url, username=username, password=password, timeout=HTTP_TIMEOUT_S)
calendar = caldav.Calendar(client=client, url=calendar_url)
objects = calendar.get_events()
except Exception as e:
raise CalDavError(str(e)) from e
events: list[dict] = []
for obj in objects:
try:
ical = icalendar.Calendar.from_ical(obj.data)
occurrences = recurring_ical_events.of(ical).between(window_start, window_end)
except Exception as e: # one malformed resource shouldn't blank the whole calendar
logger.warning("Could not parse a CalDAV event from %s: %s", calendar_url, e)
continue
for occ in occurrences:
dtstart = occ.get("DTSTART")
dtend = occ.get("DTEND")
if dtstart is None:
continue
start_dt = dtstart.dt
end_dt = dtend.dt if dtend is not None else start_dt
all_day = not isinstance(start_dt, datetime)
events.append({
"summary": str(occ.get("SUMMARY") or "(untitled)"),
"start": start_dt.isoformat(),
"end": end_dt.isoformat(),
"all_day": all_day,
})
return events