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

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:
2026-07-22 22:01:05 -04:00
parent 95d69a5512
commit acdb929a99
21 changed files with 525 additions and 109 deletions
+11
View File
@@ -213,6 +213,17 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
never vendored or modified, so this project's own code stays under its never vendored or modified, so this project's own code stays under its
own license; LGPL's copyleft terms apply to that library itself, not own license; LGPL's copyleft terms apply to that library itself, not
to code that merely links against it dynamically. to code that merely links against it dynamically.
- CalDAV account support (`app/caldav_client.py`, alongside the plain ICS
subscription) wraps the `caldav` PyPI package. `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 in this project's dependency tree, and the one whose
network-use clause is written specifically for server applications
like this one (not just "don't vendor/modify it," which was enough
reasoning for the LGPL dependency above). Taking this on was an
explicit, informed call by the project owner, not a default -- anyone
redistributing this project (vs. just self-hosting it) should
re-evaluate that tradeoff for their own situation before doing so.
- The 6-color palette RGB values in `app/image_pipeline.py` - The 6-color palette RGB values in `app/image_pipeline.py`
(`DEFAULT_PALETTE_RGB`) are approximations, not measured values (`DEFAULT_PALETTE_RGB`) are approximations, not measured values
(Waveshare doesn't publish exact color primaries for this panel). (Waveshare doesn't publish exact color primaries for this panel).
+108
View File
@@ -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
+41 -17
View File
@@ -1,10 +1,11 @@
"""Fetch, parse, and merge per-user ICS calendar feeds for calendar frame """Fetch, parse, and merge per-user calendar feeds -- ICS subscriptions
mode (see routers/device.py's RENDERERS["calendar"] and calendar_render.py). 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 Pure functions -- no ORM, no FastAPI Depends. Callers (routers/common.py's
get_or_refresh_calendar_events) supply plain (owner_display_name, url) get_or_refresh_calendar_events) supply plain CalendarSource values, not
pairs, not ORM objects, so this module stays testable against fixture .ics ORM objects, so this module stays testable against fixture .ics text with
text with no database or app involved. no database or app involved.
Recurring events (RRULE/EXDATE/RDATE, DST-aware) are expanded via Recurring events (RRULE/EXDATE/RDATE, DST-aware) are expanded via
recurring-ical-events rather than hand-rolled -- that's genuinely fiddly recurring-ical-events rather than hand-rolled -- that's genuinely fiddly
@@ -16,12 +17,15 @@ code under LGPL terms).
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime from datetime import date, datetime
import httpx import httpx
import icalendar import icalendar
import recurring_ical_events import recurring_ical_events
from . import caldav_client
HTTP_TIMEOUT_S = 15.0 HTTP_TIMEOUT_S = 15.0
FETCH_MAX_BYTES = 10 * 1024 * 1024 # sanity cap -- a real feed is KB, not MB 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 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( 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]: ) -> tuple[list[dict], str]:
"""sources: [(owner_display_name, ics_url), ...]. Fetches each """Fetches each source independently -- one broken feed never blanks
independently -- one broken feed never blanks another's events. another's events. Returns (merged_time_sorted_events, fetch_summary);
Returns (merged_time_sorted_events, fetch_summary); fetch_summary is fetch_summary is "" when every source succeeded, else "N of M
"" when every source succeeded, else "N of M calendars unavailable" calendars unavailable" (never *which* source -- naming whose feed is
(never *which* source -- naming whose feed is down to everyone who down to everyone who looks at a shared household display is a bigger
looks at a shared household display is a bigger overshare than the overshare than the outage itself)."""
outage itself)."""
merged: list[dict] = [] merged: list[dict] = []
failures = 0 failures = 0
for owner_display_name, url in sources: for source in sources:
try: try:
events = fetch_source_events(url, window_start, window_end) if source.kind == "caldav":
except CalendarFetchError: 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 failures += 1
continue continue
for event in events: for event in events:
event["owner_display_name"] = owner_display_name event["owner_display_name"] = source.owner_display_name
merged.append(event) merged.append(event)
merged.sort(key=lambda e: e["start"]) merged.sort(key=lambda e: e["start"])
+3 -1
View File
@@ -35,7 +35,9 @@ MARGIN = 20
BG = (255, 255, 255) BG = (255, 255, 255)
FG = (0, 0, 0) FG = (0, 0, 0)
MUTED = (110, 110, 110) MUTED = (110, 110, 110)
RULE = (200, 200, 200) # Was a light gray, but that dithers away to near-invisible once quantized
# to the 6-color e-ink palette -- black reads as an actual line on-panel.
RULE = (0, 0, 0)
# Cycled per distinct owner_display_name so a merged multi-person calendar # Cycled per distinct owner_display_name so a merged multi-person calendar
# can visually tell whose event is whose -- the panel's own non-black/ # can visually tell whose event is whose -- the panel's own non-black/
+36
View File
@@ -110,6 +110,41 @@ def _migration_8(conn) -> None:
def _migration_9(conn) -> None:
"""CalDAV support alongside the plain ICS subscription (see
caldav_client.py), and the frame_calendars table that replaces
user_frames.calendar_included now that one account (CalDAV) can
expose more than one calendar -- see models.py's FrameCalendar.
Existing single-calendar opt-ins are carried forward as "ics" rows
before the old column is dropped, so nobody's frame goes silently
calendar-less after this migration."""
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_url TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_username TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_password TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_calendars TEXT"))
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_checked_at REAL NOT NULL DEFAULT 0.0"))
conn.execute(text(
"CREATE TABLE frame_calendars ("
"id INTEGER PRIMARY KEY, "
"frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
"calendar_key TEXT NOT NULL, "
"calendar_label TEXT NOT NULL DEFAULT '', "
"included INTEGER NOT NULL DEFAULT 1)"
))
conn.execute(text(
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)"
))
conn.execute(text(
"INSERT INTO frame_calendars (frame_id, user_id, calendar_key, calendar_label, included) "
"SELECT uf.frame_id, uf.user_id, 'ics', 'My calendar', 1 "
"FROM user_frames uf JOIN users u ON u.id = uf.user_id "
"WHERE uf.calendar_included = 1 AND u.calendar_ics_url != ''"
))
conn.execute(text("ALTER TABLE user_frames DROP COLUMN calendar_included"))
MIGRATIONS = [ MIGRATIONS = [
(1, _migration_1), (1, _migration_1),
(2, _migration_2), (2, _migration_2),
@@ -119,6 +154,7 @@ MIGRATIONS = [
(6, _migration_6), (6, _migration_6),
(7, _migration_7), (7, _migration_7),
(8, _migration_8), (8, _migration_8),
(9, _migration_9),
] ]
+51 -13
View File
@@ -48,12 +48,26 @@ class User(Base):
# email -- see routers/device.py's frame_battery) go here; blank = no # email -- see routers/device.py's frame_battery) go here; blank = no
# email configured, both features silently no-op for this user. # email configured, both features silently no-op for this user.
email: Mapped[str] = mapped_column(String, default="") email: Mapped[str] = mapped_column(String, default="")
# Personal iCal/CalDAV .ics subscription URL (no OAuth) for calendar # Personal ICS subscription URL (no OAuth) for calendar frame mode --
# frame mode -- see calendar_feed.py. Setting this alone shows up # see calendar_feed.py. Setting this alone shows up nowhere: a linked
# nowhere: a linked frame only pulls this user's events in once # frame only pulls this user's events in once they've also added it
# they've also opted in on that frame's own Configuration -> Calendar # on that frame's own Calendar tab (FrameCalendar below).
# card (UserFrame.calendar_included below).
calendar_ics_url: Mapped[str] = mapped_column(String, default="") calendar_ics_url: Mapped[str] = mapped_column(String, default="")
# A CalDAV account (Nextcloud, Fastmail, iCloud, ...) alongside the
# plain ICS subscription above -- see caldav_client.py. calendar_url
# is the server's CalDAV entry point the user pasted in, not any one
# calendar's own URL; the individual calendars it exposes are
# discovered and cached below.
calendar_caldav_url: Mapped[str] = mapped_column(String, default="")
calendar_caldav_username: Mapped[str] = mapped_column(String, default="")
calendar_caldav_password: Mapped[str] = mapped_column(String, default="")
# [{"href", "display_name"}, ...] from the last successful
# caldav_client.discover_calendars() call, refreshed by Settings'
# "Discover calendars" button -- NULL until discovery has ever
# succeeded. This is what a frame's Calendar tab offers the user to
# add, without hitting the CalDAV server on every page load.
calendar_caldav_calendars: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
calendar_caldav_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
created_at: Mapped[float] = mapped_column(Float, default=time.time) created_at: Mapped[float] = mapped_column(Float, default=time.time)
__table_args__ = ( __table_args__ = (
@@ -234,14 +248,38 @@ class UserFrame(Base):
ForeignKey("frames.id", ondelete="CASCADE"), primary_key=True ForeignKey("frames.id", ondelete="CASCADE"), primary_key=True
) )
created_at: Mapped[float] = mapped_column(Float, default=time.time) created_at: Mapped[float] = mapped_column(Float, default=time.time)
# Explicit per-(user,frame) opt-in for calendar frame mode -- being
# linked to a frame does NOT by itself contribute this user's
# calendar to it (deliberate choice, not an oversight: each person's class FrameCalendar(Base):
# calendar is their own data to share or not, not something a """One calendar included on one frame -- calendar_key is "ics" (the
# frame's controller decides on their behalf). Meaningless if the owner's single calendar_ics_url) or "caldav:<href>" (one of the
# user has no calendar_ics_url set. See routers/api_frames.py's owner's CalDAV collections; href matches an entry in
# api_calendar_included. User.calendar_caldav_calendars). Replaces the old single
calendar_included: Mapped[bool] = mapped_column(Boolean, default=False) UserFrame.calendar_included boolean now that a CalDAV account can
expose more than one calendar.
A row only ever gets created by its own owner (adding a calendar to
a frame is each person's own data-sharing choice, not something a
frame's controller decides on their behalf) -- but once it exists,
ANY user linked to the frame may flip included back to False, muting
a calendar they'd rather not see on a shared display even though
they don't own it. Only the owner may flip it back to True. See
routers/api_frames.py's api_calendar_select."""
__tablename__ = "frame_calendars"
id: Mapped[int] = mapped_column(primary_key=True)
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
calendar_key: Mapped[str] = mapped_column(String)
# Snapshot label for display -- so the list still reads sensibly even
# if the owner's CalDAV account later stops offering this calendar.
calendar_label: Mapped[str] = mapped_column(String, default="")
included: Mapped[bool] = mapped_column(Boolean, default=True)
__table_args__ = (
Index("ix_frame_calendars_unique", "frame_id", "user_id", "calendar_key", unique=True),
)
class PendingClaim(Base): class PendingClaim(Base):
+35 -15
View File
@@ -36,7 +36,7 @@ from ..image_pipeline import (
render_preview_png, render_preview_png,
) )
from ..firmware import firmware_path, parse_app_version from ..firmware import firmware_path, parse_app_version
from ..models import BatteryLog, Frame, UserFrame from ..models import BatteryLog, Frame, FrameCalendar
from .common import ( from .common import (
FRAME_MODES, FRAME_MODES,
OVERDUE_FACTOR, OVERDUE_FACTOR,
@@ -409,33 +409,53 @@ def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session
return Response(content=png, media_type="image/png") return Response(content=png, media_type="image/png")
class CalendarIncludedRequest(BaseModel): class CalendarSelectRequest(BaseModel):
user_id: int
calendar_key: str
calendar_label: str = ""
included: bool included: bool
@router.post("/api/frames/{frame_id}/calendar-included") @router.post("/api/frames/{frame_id}/calendar-select")
def api_calendar_included( def api_calendar_select(
body: CalendarIncludedRequest, body: CalendarSelectRequest,
request: Request, request: Request,
frame: Frame = Depends(require_frame_view), # view access only -- NOT require_frame_control frame: Frame = Depends(require_frame_view), # view access only -- NOT require_frame_control
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""A user's own opt-in into this frame's merged calendar (see """Include/exclude one calendar (calendar_key "ics" or
UserFrame.calendar_included). Deliberately not require_frame_control: "caldav:<href>", see FrameCalendar) on this frame. Deliberately not
this is the toggling user's own data-sharing preference about their require_frame_control: adding your own calendar, or muting anyone's
own calendar, not a frame setting its controller manages on someone (including your own), is each viewer's own call, not something a
else's behalf -- there's no target user_id in the request body by frame's controller manages on someone else's behalf. The one-sided
design, it always toggles the calling session's own row.""" permission split lives here: turning a calendar ON requires being its
owner (nobody can add someone else's calendar to a shared frame for
them); turning one OFF only requires being linked to the frame at
all, so anyone sharing the display can mute a calendar they'd rather
not see there even if they don't own it."""
user = require_user_api(request, db) user = require_user_api(request, db)
row = db.get(UserFrame, (user.id, frame.id)) if body.included and body.user_id != user.id:
raise HTTPException(403, "Only a calendar's owner can add it to a frame")
row = db.execute(
select(FrameCalendar).where(
FrameCalendar.frame_id == frame.id,
FrameCalendar.user_id == body.user_id,
FrameCalendar.calendar_key == body.calendar_key,
)
).scalar_one_or_none()
if row is None: if row is None:
raise HTTPException(404, "Not linked to this frame") if not body.included:
row.calendar_included = body.included raise HTTPException(404, "Not currently included on this frame")
row = FrameCalendar(frame_id=frame.id, user_id=body.user_id, calendar_key=body.calendar_key)
db.add(row)
row.included = body.included
if body.calendar_label:
row.calendar_label = body.calendar_label
# Force this frame's merged cache to pick up the change promptly # Force this frame's merged cache to pick up the change promptly
# rather than waiting out the throttle. # rather than waiting out the throttle.
frame.calendar_checked_at = 0.0 frame.calendar_checked_at = 0.0
db.commit() db.commit()
return {"status": "saved", "included": row.calendar_included} return {"status": "saved", "included": row.included}
def _calendar_photo_inlay(frame: Frame, db: Session): def _calendar_photo_inlay(frame: Frame, db: Session):
+23 -12
View File
@@ -19,7 +19,7 @@ from .. import calendar_feed, quiet_hours
from ..db import frame_locked from ..db import frame_locked
from ..image_pipeline import render_frame from ..image_pipeline import render_frame
from ..immich_client import ImmichClient from ..immich_client import ImmichClient
from ..models import BatteryLog, Frame, User, UserFrame from ..models import BatteryLog, Frame, FrameCalendar, User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -373,18 +373,29 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict:
return content return content
def calendar_sources_for_frame(db: Session, frame: Frame) -> list[tuple[str, str]]: def calendar_sources_for_frame(db: Session, frame: Frame) -> list[calendar_feed.CalendarSource]:
"""Every user linked to this frame with BOTH a calendar URL set AND """Every calendar included on this frame (FrameCalendar.included) --
explicit per-frame opt-in (UserFrame.calendar_included) -- the exact the exact set calendar_feed.merge_events needs. A calendar_key of
set calendar_feed.merge_events needs. [(display_name-or-username, "ics" resolves against its owner's calendar_ics_url; "caldav:<href>"
ics_url), ...].""" resolves against the href itself, authenticated with the owner's
CalDAV account credentials (see caldav_client.py)."""
rows = db.execute( rows = db.execute(
select(User) select(FrameCalendar, User)
.join(UserFrame, UserFrame.user_id == User.id) .join(User, User.id == FrameCalendar.user_id)
.where(UserFrame.frame_id == frame.id, UserFrame.calendar_included == True, # noqa: E712 .where(FrameCalendar.frame_id == frame.id, FrameCalendar.included == True) # noqa: E712
User.calendar_ics_url != "") ).all()
).scalars().all() sources = []
return [(u.display_name or u.username, u.calendar_ics_url) for u in rows] for fc, u in rows:
name = u.display_name or u.username
if fc.calendar_key == "ics":
if u.calendar_ics_url:
sources.append(calendar_feed.CalendarSource(name, "ics", u.calendar_ics_url))
elif fc.calendar_key.startswith("caldav:") and u.calendar_caldav_username:
href = fc.calendar_key[len("caldav:"):]
sources.append(calendar_feed.CalendarSource(
name, "caldav", href, u.calendar_caldav_username, u.calendar_caldav_password
))
return sources
def get_or_refresh_calendar_events(db: Session, frame: Frame) -> tuple[list[dict], str]: def get_or_refresh_calendar_events(db: Session, frame: Frame) -> tuple[list[dict], str]:
+50 -18
View File
@@ -20,7 +20,7 @@ from ..image_pipeline import (
PALETTE_LABELS, PALETTE_LABELS,
palette_to_hex, palette_to_hex,
) )
from ..models import Frame, User, UserFrame from ..models import Frame, FrameCalendar, User, UserFrame
from ..quiet_hours import ALL_TIMEZONES from ..quiet_hours import ALL_TIMEZONES
from .common import shell_context from .common import shell_context
@@ -45,23 +45,54 @@ def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get
return _frame_page(request, db, frame_id, "frame_photos.html", "photos") return _frame_page(request, db, frame_id, "frame_photos.html", "photos")
def _calendar_users_for_frame(db: Session, frame_id: int) -> list[dict]: def _user_available_calendars(user: User) -> list[dict]:
"""Every user linked to this frame, their calendar opt-in state, and """This user's full set of calendars available to add to any frame:
whether they even have a calendar URL set -- what the Configuration the single ICS subscription (if set) plus every CalDAV calendar last
tab's "Included calendars" list needs. Whether a given row is *this* discovered from Settings' "Discover calendars" button. Doesn't hit
viewer's own (and therefore editable) is decided in the template, the network -- reads the cached list a user refreshes themselves."""
using the `user` shell_context already provides.""" calendars = []
rows = db.execute( if user.calendar_ics_url:
select(User, UserFrame.calendar_included) calendars.append({"key": "ics", "label": "My calendar (ICS)"})
.join(UserFrame, UserFrame.user_id == User.id) for c in (user.calendar_caldav_calendars or []):
.where(UserFrame.frame_id == frame_id) calendars.append({"key": f"caldav:{c['href']}", "label": c.get("display_name") or "Calendar"})
.order_by(User.username) return calendars
).all()
return [
{"user_id": u.id, "display_name": u.display_name or u.username, def _calendar_users_for_frame(db: Session, frame_id: int, viewer_id: int | None) -> list[dict]:
"has_url": bool(u.calendar_ics_url), "included": included} """Per-linked-user calendar list for the Calendar tab's "Included
for u, included in rows calendars" section. The viewer's own row lists EVERY calendar they
have available, each with a full add/remove toggle; every other
linked user's row lists ONLY the calendars they've already included
(mute-only for the viewer -- see api_frames.py's api_calendar_select:
only a calendar's owner may turn it on, but anyone linked to the
frame may turn one off)."""
users = db.execute(
select(User).join(UserFrame, UserFrame.user_id == User.id)
.where(UserFrame.frame_id == frame_id).order_by(User.username)
).scalars().all()
included_by_user: dict[int, list[FrameCalendar]] = {}
for fc in db.execute(select(FrameCalendar).where(FrameCalendar.frame_id == frame_id)).scalars().all():
included_by_user.setdefault(fc.user_id, []).append(fc)
result = []
for u in users:
is_self = u.id == viewer_id
if is_self:
included = {fc.calendar_key: fc.included for fc in included_by_user.get(u.id, [])}
calendars = [
{**c, "included": included.get(c["key"], False)}
for c in _user_available_calendars(u)
] ]
else:
calendars = [
{"key": fc.calendar_key, "label": fc.calendar_label, "included": True}
for fc in included_by_user.get(u.id, []) if fc.included
]
result.append({
"user_id": u.id, "display_name": u.display_name or u.username,
"is_self": is_self, "calendars": calendars,
})
return result
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse) @router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
@@ -82,10 +113,11 @@ WEEK_START_LABELS = {0: "Monday", 1: "Tuesday", 2: "Wednesday", 3: "Thursday",
@router.get("/frames/{frame_id}/calendar", response_class=HTMLResponse) @router.get("/frames/{frame_id}/calendar", response_class=HTMLResponse)
def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(get_db)): def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
viewer = current_user(request, db)
return _frame_page( return _frame_page(
request, db, frame_id, "frame_calendar.html", "calendar", request, db, frame_id, "frame_calendar.html", "calendar",
calendar_views=CALENDAR_VIEW_LABELS, calendar_views=CALENDAR_VIEW_LABELS,
calendar_users=_calendar_users_for_frame(db, frame_id), calendar_users=_calendar_users_for_frame(db, frame_id, viewer.id if viewer else None),
week_start_labels=WEEK_START_LABELS, week_start_labels=WEEK_START_LABELS,
) )
+44 -1
View File
@@ -18,7 +18,7 @@ from fastapi.templating import Jinja2Templates
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import mail from .. import caldav_client, mail
from ..auth import ( from ..auth import (
SESSION_COOKIE, SESSION_COOKIE,
SESSION_LIFETIME_S, SESSION_LIFETIME_S,
@@ -30,6 +30,7 @@ from ..auth import (
destroy_session, destroy_session,
get_server_settings, get_server_settings,
hash_password, hash_password,
require_user_api,
users_exist, users_exist,
verify_password, verify_password,
) )
@@ -424,6 +425,9 @@ def settings_submit(
immich_url: str = Form(""), immich_url: str = Form(""),
immich_api_key: str = Form(""), immich_api_key: str = Form(""),
calendar_ics_url: str = Form(""), calendar_ics_url: str = Form(""),
calendar_caldav_url: str = Form(""),
calendar_caldav_username: str = Form(""),
calendar_caldav_password: str = Form(""),
current_password: str = Form(""), current_password: str = Form(""),
new_password: str = Form(""), new_password: str = Form(""),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -452,6 +456,23 @@ def settings_submit(
else: else:
user.calendar_ics_url = stripped_ics user.calendar_ics_url = stripped_ics
stripped_caldav_url = calendar_caldav_url.strip()
if stripped_caldav_url and not valid_http_url(stripped_caldav_url):
error = "CalDAV URL must be a plain http:// or https:// URL."
else:
if stripped_caldav_url != user.calendar_caldav_url:
# Server (and likely account) changed -- last discovery no
# longer describes what's actually there.
user.calendar_caldav_calendars = None
user.calendar_caldav_checked_at = 0.0
user.calendar_caldav_url = stripped_caldav_url
user.calendar_caldav_username = calendar_caldav_username.strip()
# Blank password field = keep the existing one, same idiom as the
# Immich API key -- a secret that round-trips through HTML is a
# secret in every browser's autofill store.
if calendar_caldav_password.strip():
user.calendar_caldav_password = calendar_caldav_password.strip()
if new_password: if new_password:
if not user.password_hash or not verify_password(current_password, user.password_hash): if not user.password_hash or not verify_password(current_password, user.password_hash):
error = "Current password is wrong -- password not changed." error = "Current password is wrong -- password not changed."
@@ -466,6 +487,28 @@ def settings_submit(
) )
@router.post("/api/settings/caldav-discover")
def api_caldav_discover(request: Request, db: Session = Depends(get_db)):
"""Lists the calendars in the CalDAV account already saved on this
user's Settings (not whatever's currently typed in the form but not
yet saved -- same idiom as /api/frames/{id}/albums using the frame's
already-saved Immich creds). Caches the result on the user row so
every frame's Calendar tab can offer it without a live round-trip."""
user = require_user_api(request, db)
if not user.calendar_caldav_url or not user.calendar_caldav_username:
raise HTTPException(400, "Save a CalDAV URL and username first")
try:
calendars = caldav_client.discover_calendars(
user.calendar_caldav_url, user.calendar_caldav_username, user.calendar_caldav_password
)
except caldav_client.CalDavError as e:
raise HTTPException(502, f"Could not discover calendars: {e}") from e
user.calendar_caldav_calendars = calendars
user.calendar_caldav_checked_at = time.time()
db.commit()
return calendars
def _require_admin_page(request: Request, db: Session) -> User: def _require_admin_page(request: Request, db: Session) -> User:
user = current_user(request, db) user = current_user(request, db)
if user is None or not user.is_admin: if user is None or not user.is_admin:
+15 -7
View File
@@ -24,19 +24,27 @@ document.getElementById('calendar-config-form').addEventListener('submit', async
} }
}); });
// Each person's own opt-in -- auto-saves on toggle, not batched into the // Each calendar's own include/mute toggle -- auto-saves on change, not
// form above, since it's the toggling user's own preference (see // batched into the form above, since it's a data-sharing choice (see
// api_frames.py's /calendar-included), not a frame-wide setting. // api_frames.py's /calendar-select), not a frame-wide setting. Works the
document.querySelectorAll('.calendar-self-toggle').forEach((el) => { // same element for your own calendars (full add/remove) and other
// people's (mute only) -- the server enforces which direction is allowed
// and this just reverts the checkbox with an error message if rejected.
document.querySelectorAll('.calendar-toggle').forEach((el) => {
el.addEventListener('change', async () => { el.addEventListener('change', async () => {
try { try {
const resp = await fetch(`${window.FRAME_API}/calendar-included`, { const resp = await fetch(`${window.FRAME_API}/calendar-select`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ included: el.checked }), body: JSON.stringify({
user_id: Number(el.dataset.userId),
calendar_key: el.dataset.key,
calendar_label: el.dataset.label,
included: el.checked,
}),
}); });
if (!resp.ok) throw new Error(await apiError(resp)); if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, el.checked ? 'Your calendar is included on this frame.' : 'Your calendar removed from this frame.'); showStatus(true, el.checked ? 'Calendar included on this frame.' : 'Calendar removed from this frame.');
} catch (e) { } catch (e) {
el.checked = !el.checked; el.checked = !el.checked;
showStatus(false, e.message); showStatus(false, e.message);
+32
View File
@@ -0,0 +1,32 @@
// Settings page: "Discover calendars" against the CalDAV account already
// saved on this form (same idiom as frame_photos.js's Load Albums using
// the frame's already-saved Immich creds) -- so this only works after
// the CalDAV URL/username/password have been saved once.
const discoverBtn = document.getElementById('caldav-discover');
if (discoverBtn) {
discoverBtn.addEventListener('click', async () => {
const list = document.getElementById('caldav-calendar-list');
discoverBtn.disabled = true;
try {
const resp = await fetch('/api/settings/caldav-discover', { method: 'POST' });
if (!resp.ok) throw new Error(await apiError(resp));
const calendars = await resp.json();
list.innerHTML = '';
if (calendars.length === 0) {
list.innerHTML = '<li>No calendars found in this account.</li>';
} else {
for (const c of calendars) {
const li = document.createElement('li');
li.textContent = c.display_name;
list.appendChild(li);
}
}
showStatus(true, `Found ${calendars.length} calendar${calendars.length === 1 ? '' : 's'}.`);
} catch (e) {
showStatus(false, e.message);
} finally {
discoverBtn.disabled = false;
}
});
}
+8
View File
@@ -261,6 +261,11 @@ input:focus, select:focus {
.checkbox-row input { width: auto; margin-top: 0; } .checkbox-row input { width: auto; margin-top: 0; }
.checkbox-row label { margin-top: 0; font-weight: normal; } .checkbox-row label { margin-top: 0; font-weight: normal; }
.calendar-user-list { list-style: none; margin: 12px 0 0; padding: 0; }
.calendar-user-list > li { margin-top: 14px; }
.calendar-user-list > li:first-child { margin-top: 0; }
.calendar-user-name { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); }
button { button {
margin-top: 20px; margin-top: 20px;
padding: 10px 16px; padding: 10px 16px;
@@ -540,6 +545,9 @@ code {
.device-status-row { column-gap: 16px; } .device-status-row { column-gap: 16px; }
} }
.mode-picker-row { display: flex; align-items: center; gap: 8px; margin-bottom: 18px; }
.mode-picker-label { font-size: 13px; font-weight: 600; color: var(--text-muted); }
/* Mobile: sidebar off-canvas, hamburger in a slim top bar. */ /* Mobile: sidebar off-canvas, hamburger in a slim top bar. */
.mobile-bar { display: none; } .mobile-bar { display: none; }
.sidebar-backdrop { display: none; } .sidebar-backdrop { display: none; }
@@ -0,0 +1,4 @@
<div class="mode-picker-row">
<span class="mode-picker-label">Mode</span>
{% include "_frame_mode_select.html" %}
</div>
+1
View File
@@ -73,6 +73,7 @@
</div> </div>
</div> </div>
{% block device_status %}{% endblock %} {% block device_status %}{% endblock %}
{% block mode_picker %}{% endblock %}
{% block tabs %}{% endblock %} {% block tabs %}{% endblock %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
+18 -13
View File
@@ -2,7 +2,7 @@
{% block title %}{{ frame.name or "Frame" }} · Calendar{% endblock %} {% block title %}{{ frame.name or "Frame" }} · Calendar{% endblock %}
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %} {% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
{% block head_actions %}{% include "_frame_mode_select.html" %}{% endblock %} {% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %} {% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %} {% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
@@ -49,24 +49,29 @@
</form> </form>
<h2 class="card-title" style="margin-top: 20px;">Included calendars</h2> <h2 class="card-title" style="margin-top: 20px;">Included calendars</h2>
<p class="sub">Each linked person decides whether their own calendar <p class="sub">Each linked person adds their own calendars (ICS
contributes to this frame -- being linked here doesn't include it subscription or CalDAV, set up in <a href="/settings">Settings</a>)
automatically.</p> -- being linked here doesn't include anything automatically.
Anyone linked to this frame can mute a calendar they'd rather
not see here, even one they don't own; only its owner can add
it back.</p>
<ul class="calendar-user-list"> <ul class="calendar-user-list">
{% for u in calendar_users %} {% for u in calendar_users %}
<li> <li>
{% if u.user_id == user.id %} <p class="calendar-user-name">{{ u.display_name }}{% if u.is_self %} (you){% endif %}</p>
{% if u.has_url %} {% if u.calendars %}
{% for c in u.calendars %}
<label class="checkbox-row" style="margin-top: 6px;"> <label class="checkbox-row" style="margin-top: 6px;">
<input type="checkbox" class="calendar-self-toggle" {% if u.included %}checked{% endif %}> <input type="checkbox" class="calendar-toggle"
{{ u.display_name }} (you) data-user-id="{{ u.user_id }}" data-key="{{ c.key }}" data-label="{{ c.label }}"
{% if c.included %}checked{% endif %}>
{{ c.label }}
</label> </label>
{% endfor %}
{% elif u.is_self %}
<p class="sub" style="margin-top: 6px;">No calendars set up yet -- add an ICS link or CalDAV account in <a href="/settings">Settings</a>.</p>
{% else %} {% else %}
<p class="sub" style="margin-top: 6px;">{{ u.display_name }} (you) -- no calendar set, add one in <a href="/settings">Settings</a>.</p> <p class="sub" style="margin-top: 6px;">No calendars included.</p>
{% endif %}
{% else %}
<p class="sub" style="margin-top: 6px;">{{ u.display_name }}:
{% if not u.has_url %}no calendar set{% elif u.included %}included{% else %}not included{% endif %}</p>
{% endif %} {% endif %}
</li> </li>
{% endfor %} {% endfor %}
+1 -1
View File
@@ -2,7 +2,7 @@
{% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %} {% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %}
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %} {% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
{% block head_actions %}{% include "_frame_mode_select.html" %}{% endblock %} {% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %} {% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %} {% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
+1 -1
View File
@@ -2,7 +2,7 @@
{% block title %}{{ frame.name or "Frame" }} · Photos{% endblock %} {% block title %}{{ frame.name or "Frame" }} · Photos{% endblock %}
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %} {% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
{% block head_actions %}{% include "_frame_mode_select.html" %}{% endblock %} {% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %} {% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %} {% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
+1 -1
View File
@@ -2,7 +2,7 @@
{% block title %}{{ frame.name or "Frame" }} · Stats{% endblock %} {% block title %}{{ frame.name or "Frame" }} · Stats{% endblock %}
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %} {% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
{% block head_actions %}{% include "_frame_mode_select.html" %}{% endblock %} {% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %} {% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %} {% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
+40 -8
View File
@@ -33,17 +33,43 @@
plus <code>sharedLink.create</code> for the on-frame share QR.</p> plus <code>sharedLink.create</code> for the on-frame share QR.</p>
<h2 class="card-title" style="margin-top: 24px;">Calendar</h2> <h2 class="card-title" style="margin-top: 24px;">Calendar</h2>
<label>Calendar URL (iCal/CalDAV .ics feed) <label>Calendar URL (iCal .ics feed)
<input type="text" name="calendar_ics_url" placeholder="https://calendar.example.com/you.ics" <input type="text" name="calendar_ics_url" placeholder="https://calendar.example.com/you.ics"
value="{{ user.calendar_ics_url }}"> value="{{ user.calendar_ics_url }}">
</label> </label>
<p class="sub" style="margin-top: 8px;">Your personal calendar <p class="sub" style="margin-top: 8px;">A single subscription link
subscription link (no login needed -- e.g. Google Calendar's (no login needed) -- e.g. Google Calendar's Settings &rarr;
Settings &rarr; "Secret address in iCal format", or Apple/Outlook/ "Secret address in iCal format", or Apple/Outlook's equivalent.</p>
Nextcloud's equivalent). Setting it here doesn't show it anywhere
by itself -- include it on any frame you're linked to from that <label style="margin-top: 20px;">CalDAV server URL
frame's Configuration &rarr; Calendar card, so a frame only shows <input type="text" name="calendar_caldav_url" placeholder="https://cloud.example.com/remote.php/dav/"
calendars people have actually chosen to share with it.</p> value="{{ user.calendar_caldav_url }}">
</label>
<label>CalDAV username
<input type="text" name="calendar_caldav_username" autocomplete="off"
value="{{ user.calendar_caldav_username }}">
</label>
<label>CalDAV password
<input type="password" name="calendar_caldav_password" autocomplete="off"
placeholder="{% if user.calendar_caldav_password %}(unchanged -- enter a new password to replace){% else %}app password or account password{% endif %}">
</label>
<p class="sub" style="margin-top: 8px;">An account (Nextcloud,
Fastmail, iCloud, ...) that can expose more than one calendar --
e.g. Nextcloud's is usually
<code>https://your-server/remote.php/dav/</code>. Save this
section first, then use "Discover calendars" below to list what's
in the account.</p>
<button type="button" class="secondary" id="caldav-discover" style="margin-top: 12px;">Discover calendars</button>
<ul class="sub" id="caldav-calendar-list" style="margin-top: 8px; padding-left: 18px;">
{% if user.calendar_caldav_calendars %}
{% for c in user.calendar_caldav_calendars %}<li>{{ c.display_name }}</li>{% endfor %}
{% endif %}
</ul>
<p class="sub" style="margin-top: 8px;">Neither of these shows up
anywhere by itself -- add individual calendars to any frame
you're linked to from that frame's Calendar tab, so a frame only
shows calendars people have actually chosen to share with it.</p>
<h2 class="card-title" style="margin-top: 24px;">Change password</h2> <h2 class="card-title" style="margin-top: 24px;">Change password</h2>
<label>Current password <label>Current password
@@ -58,4 +84,10 @@
<button type="submit">Save</button> <button type="submit">Save</button>
</form> </form>
</section> </section>
<div id="result"></div>
{% endblock %}
{% block scripts %}
<script src="/static/settings.js"></script>
{% endblock %} {% endblock %}
+1
View File
@@ -9,3 +9,4 @@ sqlalchemy==2.0.51
qrcode==8.2 qrcode==8.2
icalendar==7.2.2 icalendar==7.2.2
recurring-ical-events==3.8.2 recurring-ical-events==3.8.2
caldav==3.2.1