184 lines
7.8 KiB
Python
184 lines
7.8 KiB
Python
"""Nextcloud calendar ingestion over CalDAV.
|
|
|
|
READ-ONLY INVARIANT: this module only ever issues CalDAV REPORT/PROPFIND reads
|
|
(`Calendar.search()` and the principal/calendar discovery it needs). It never
|
|
calls `save_event`, `add_event`, `Event.delete()`, `make_calendar()` or anything
|
|
else that would mutate the collection, per docs/project-plan.md Phase 12 step 8
|
|
and the Phase 8 "no silent mutation" precedent. The digest can say an event needs
|
|
attention; it can never move, create or delete one.
|
|
|
|
Auth is a Nextcloud **app password**, not OAuth2 and not the account password —
|
|
the same reasoning email_imap.py documents for Gmail App Passwords. Nextcloud
|
|
issues them at Settings -> Security -> Devices & sessions -> Create new app
|
|
password, and they are mandatory for CalDAV once two-factor authentication is on,
|
|
because the DAV endpoints have no way to prompt for a second factor. Even without
|
|
2FA they are the right credential here: revocable on their own, scoped to this
|
|
one integration, and they leave the account password out of a file on the
|
|
container host.
|
|
|
|
Sourcing, verified 2026-07-28:
|
|
|
|
The protocol is not hand-rolled. `caldav` on PyPI (3.2.1, 2026-05-28, Python
|
|
>=3.10, https://pypi.org/project/caldav/) does discovery, the calendar-query
|
|
REPORT and recurrence expansion; hand-writing that XML against a real server is
|
|
a bad trade. It pulls in `icalendar`, which is what actually parses the
|
|
returned VEVENTs here.
|
|
|
|
URL: point CALDAV_URL at Nextcloud's DAV root, `https://<host>/remote.php/dav`
|
|
— the library discovers the principal and its calendars from there. Nextcloud
|
|
documents the equivalent per-user form
|
|
`https://<host>/remote.php/dav/principals/users/<username>/`; either works.
|
|
|
|
`search(start=..., end=..., event=True, expand=True)` is the documented way to
|
|
get a time-bounded list of events with recurrences expanded into concrete
|
|
occurrences. Without `expand`, a weekly recurring event comes back once, as its
|
|
original master VEVENT with an RRULE, and the digest would report a meeting on
|
|
the day it was first created. caldav 2.0+ can expand client-side when the
|
|
server will not, but sabre/dav servers that reject the request outright are
|
|
handled by the retry below.
|
|
|
|
The window is deliberately asymmetric: everything else in this component looks
|
|
backwards over the digest window, but a calendar is mostly useful forwards, so
|
|
this reads `lookback_hours` back (to catch what happened earlier today, and
|
|
events still in progress) and CALDAV_LOOKAHEAD_HOURS forwards.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from datetime import date, datetime, timedelta, timezone
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
|
|
DEFAULT_LOOKAHEAD_HOURS = 48
|
|
DEFAULT_MAX_EVENTS = 100
|
|
MAX_DESCRIPTION_CHARS = 500
|
|
|
|
|
|
def _csv_set(name):
|
|
return {item.strip().lower() for item in os.environ.get(name, "").split(",") if item.strip()}
|
|
|
|
|
|
def _stamp(value):
|
|
if value is None:
|
|
return None, False
|
|
if isinstance(value, datetime):
|
|
# A floating (tz-naive) DTSTART is legal iCalendar; treating it as UTC keeps
|
|
# the ordering sane instead of raising on the comparison.
|
|
aware = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
|
return aware.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"), False
|
|
if isinstance(value, date):
|
|
return value.isoformat(), True
|
|
return None, False
|
|
|
|
|
|
def _text(component, key, limit=None):
|
|
value = component.get(key)
|
|
if value is None:
|
|
return None
|
|
text = str(value).strip()
|
|
if not text:
|
|
return None
|
|
return text[:limit] if limit else text
|
|
|
|
|
|
def _events_from(raw, calendar_name, ical_parser):
|
|
events = []
|
|
for component in ical_parser.from_ical(raw).walk("VEVENT"):
|
|
start_property = component.get("dtstart")
|
|
end_property = component.get("dtend")
|
|
start, all_day = _stamp(getattr(start_property, "dt", None))
|
|
end, _ = _stamp(getattr(end_property, "dt", None))
|
|
if start is None:
|
|
continue
|
|
events.append(
|
|
{
|
|
"source": "caldav",
|
|
"category": "calendar_event",
|
|
"calendar": calendar_name,
|
|
"uid": _text(component, "uid"),
|
|
"summary": _text(component, "summary") or "(no title)",
|
|
"start": start,
|
|
"end": end,
|
|
"all_day": all_day,
|
|
"location": _text(component, "location"),
|
|
"description": _text(component, "description", MAX_DESCRIPTION_CHARS),
|
|
"status": _text(component, "status"),
|
|
"recurring": component.get("rrule") is not None,
|
|
}
|
|
)
|
|
return events
|
|
|
|
|
|
def _search(calendar, window_start, window_end):
|
|
try:
|
|
return calendar.search(start=window_start, end=window_end, event=True, expand=True)
|
|
except Exception:
|
|
# Some sabre/dav deployments reject an expand request outright. An
|
|
# unexpanded window is still worth having; the only cost is that a
|
|
# recurring series shows up as its master event.
|
|
LOG.warning("caldav: expanded search failed, retrying unexpanded", exc_info=True)
|
|
return calendar.search(start=window_start, end=window_end, event=True)
|
|
|
|
|
|
def _calendar_name(calendar):
|
|
try:
|
|
return str(calendar.name or "")
|
|
except Exception:
|
|
# .name is a lazy PROPFIND; a calendar that will not name itself is still
|
|
# readable, and dropping it over a missing display name would be absurd.
|
|
return ""
|
|
|
|
|
|
def fetch(lookback_hours):
|
|
url = os.environ.get("CALDAV_URL", "").strip()
|
|
username = os.environ.get("CALDAV_USERNAME", "").strip()
|
|
password = os.environ.get("CALDAV_PASSWORD", "")
|
|
|
|
if not (url and username and password):
|
|
LOG.warning("caldav: CALDAV_URL/CALDAV_USERNAME/CALDAV_PASSWORD not all set, skipping")
|
|
return []
|
|
|
|
lookahead_hours = float(os.environ.get("CALDAV_LOOKAHEAD_HOURS", DEFAULT_LOOKAHEAD_HOURS))
|
|
max_events = int(os.environ.get("CALDAV_MAX_EVENTS", DEFAULT_MAX_EVENTS))
|
|
wanted = _csv_set("CALDAV_CALENDARS")
|
|
verify = os.environ.get("CALDAV_VERIFY_TLS", "true").strip().lower() == "true"
|
|
if not verify:
|
|
LOG.warning("caldav: CALDAV_VERIFY_TLS is false, the app password is sent over an unverified session")
|
|
|
|
now = datetime.now(timezone.utc)
|
|
window_start = now - timedelta(hours=float(lookback_hours))
|
|
window_end = now + timedelta(hours=lookahead_hours)
|
|
|
|
events = []
|
|
try:
|
|
# Imported here, not at module scope, so a missing/broken optional
|
|
# dependency degrades this one source instead of the whole run. The
|
|
# absolute import resolves to the PyPI `caldav` package, not to this
|
|
# module, which is only ever reachable as `ingest.caldav`.
|
|
import caldav as caldav_lib
|
|
from icalendar import Calendar as ICalendar
|
|
|
|
with caldav_lib.DAVClient(
|
|
url=url, username=username, password=password, ssl_verify_cert=verify
|
|
) as client:
|
|
for calendar in client.principal().calendars():
|
|
name = _calendar_name(calendar)
|
|
if wanted and name.lower() not in wanted:
|
|
continue
|
|
try:
|
|
for item in _search(calendar, window_start, window_end):
|
|
events.extend(_events_from(item.data, name, ICalendar))
|
|
except Exception:
|
|
LOG.warning("caldav: calendar %r could not be read, skipping", name, exc_info=True)
|
|
except Exception:
|
|
LOG.warning("caldav: ingestion failed, returning nothing", exc_info=True)
|
|
return []
|
|
|
|
events.sort(key=lambda event: event["start"])
|
|
if len(events) > max_events:
|
|
LOG.warning("caldav: %d events in the window, truncating to %d", len(events), max_events)
|
|
events = events[:max_events]
|
|
|
|
LOG.info("caldav: %d event(s) from -%sh to +%sh", len(events), lookback_hours, lookahead_hours)
|
|
return events
|