Move mode picker below device status bar; fix invisible calendar lines; add CalDAV calendar support
Build and push server image / build-and-push (push) Successful in 50s
Build and push server image / build-and-push (push) Successful in 50s
Calendar rule/grid lines were light gray, which dithers away to near-invisible on the 6-color e-ink palette -- now black. CalDAV accounts (Nextcloud, Fastmail, iCloud, ...) can now be linked alongside the existing single ICS subscription, since one account can expose several calendars. A frame's Calendar tab now lists calendars per person rather than one opt-in per person: your own row shows every calendar you have available with a full add/remove toggle, while other linked users' rows show only calendars they've included, toggleable off (mute) but not on -- only a calendar's owner can add it to a shared frame. FrameCalendar replaces the old single-boolean UserFrame.calendar_included; existing opt-ins are migrated forward.
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
"""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
|
||||
|
||||
from datetime import date, datetime, time as dtime
|
||||
|
||||
import caldav
|
||||
import icalendar
|
||||
import recurring_ical_events
|
||||
|
||||
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). Fetches raw (unexpanded)
|
||||
calendar objects and runs them through the same icalendar +
|
||||
recurring_ical_events pipeline calendar_feed.py uses for ICS feeds,
|
||||
rather than relying on server-side expand (RFC 4791 leaves plenty of
|
||||
corner cases server implementations disagree on)."""
|
||||
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.date_search(
|
||||
start=datetime.combine(window_start, dtime.min),
|
||||
end=datetime.combine(window_end, dtime.min),
|
||||
expand=False,
|
||||
)
|
||||
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: # one malformed resource shouldn't blank the whole calendar
|
||||
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
|
||||
Reference in New Issue
Block a user