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:
+41
-17
@@ -1,10 +1,11 @@
|
||||
"""Fetch, parse, and merge per-user ICS calendar feeds for calendar frame
|
||||
mode (see routers/device.py's RENDERERS["calendar"] and calendar_render.py).
|
||||
"""Fetch, parse, and merge per-user calendar feeds -- ICS subscriptions
|
||||
and (via caldav_client.py) CalDAV collections -- for calendar frame mode
|
||||
(see routers/device.py's RENDERERS["calendar"] and calendar_render.py).
|
||||
|
||||
Pure functions -- no ORM, no FastAPI Depends. Callers (routers/common.py's
|
||||
get_or_refresh_calendar_events) supply plain (owner_display_name, url)
|
||||
pairs, not ORM objects, so this module stays testable against fixture .ics
|
||||
text with no database or app involved.
|
||||
get_or_refresh_calendar_events) supply plain CalendarSource values, not
|
||||
ORM objects, so this module stays testable against fixture .ics text with
|
||||
no database or app involved.
|
||||
|
||||
Recurring events (RRULE/EXDATE/RDATE, DST-aware) are expanded via
|
||||
recurring-ical-events rather than hand-rolled -- that's genuinely fiddly
|
||||
@@ -16,12 +17,15 @@ code under LGPL terms).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
|
||||
import httpx
|
||||
import icalendar
|
||||
import recurring_ical_events
|
||||
|
||||
from . import caldav_client
|
||||
|
||||
HTTP_TIMEOUT_S = 15.0
|
||||
FETCH_MAX_BYTES = 10 * 1024 * 1024 # sanity cap -- a real feed is KB, not MB
|
||||
|
||||
@@ -83,26 +87,46 @@ def fetch_source_events(url: str, window_start: date, window_end: date) -> list[
|
||||
return events
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CalendarSource:
|
||||
"""One calendar to merge in: either a plain ICS subscription (kind
|
||||
"ics", url is the feed itself) or one CalDAV collection (kind
|
||||
"caldav", url is the calendar's own URL, username/password its
|
||||
account credentials) -- see caldav_client.py. owner_display_name
|
||||
tags every event pulled from this source so a merged agenda can show
|
||||
whose event is whose."""
|
||||
|
||||
owner_display_name: str
|
||||
kind: str
|
||||
url: str
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
|
||||
|
||||
def merge_events(
|
||||
sources: list[tuple[str, str]], window_start: date, window_end: date
|
||||
sources: list[CalendarSource], window_start: date, window_end: date
|
||||
) -> tuple[list[dict], str]:
|
||||
"""sources: [(owner_display_name, ics_url), ...]. Fetches each
|
||||
independently -- one broken feed never blanks another's events.
|
||||
Returns (merged_time_sorted_events, fetch_summary); fetch_summary is
|
||||
"" when every source succeeded, else "N of M calendars unavailable"
|
||||
(never *which* source -- naming whose feed is down to everyone who
|
||||
looks at a shared household display is a bigger overshare than the
|
||||
outage itself)."""
|
||||
"""Fetches each source independently -- one broken feed never blanks
|
||||
another's events. Returns (merged_time_sorted_events, fetch_summary);
|
||||
fetch_summary is "" when every source succeeded, else "N of M
|
||||
calendars unavailable" (never *which* source -- naming whose feed is
|
||||
down to everyone who looks at a shared household display is a bigger
|
||||
overshare than the outage itself)."""
|
||||
merged: list[dict] = []
|
||||
failures = 0
|
||||
for owner_display_name, url in sources:
|
||||
for source in sources:
|
||||
try:
|
||||
events = fetch_source_events(url, window_start, window_end)
|
||||
except CalendarFetchError:
|
||||
if source.kind == "caldav":
|
||||
events = caldav_client.fetch_calendar_events(
|
||||
source.url, source.username, source.password, window_start, window_end
|
||||
)
|
||||
else:
|
||||
events = fetch_source_events(source.url, window_start, window_end)
|
||||
except (CalendarFetchError, caldav_client.CalDavError):
|
||||
failures += 1
|
||||
continue
|
||||
for event in events:
|
||||
event["owner_display_name"] = owner_display_name
|
||||
event["owner_display_name"] = source.owner_display_name
|
||||
merged.append(event)
|
||||
|
||||
merged.sort(key=lambda e: e["start"])
|
||||
|
||||
Reference in New Issue
Block a user