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
own license; LGPL's copyleft terms apply to that library itself, not
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`
(`DEFAULT_PALETTE_RGB`) are approximations, not measured values
(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
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"])
+3 -1
View File
@@ -35,7 +35,9 @@ MARGIN = 20
BG = (255, 255, 255)
FG = (0, 0, 0)
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
# 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 = [
(1, _migration_1),
(2, _migration_2),
@@ -119,6 +154,7 @@ MIGRATIONS = [
(6, _migration_6),
(7, _migration_7),
(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 configured, both features silently no-op for this user.
email: Mapped[str] = mapped_column(String, default="")
# Personal iCal/CalDAV .ics subscription URL (no OAuth) for calendar
# frame mode -- see calendar_feed.py. Setting this alone shows up
# nowhere: a linked frame only pulls this user's events in once
# they've also opted in on that frame's own Configuration -> Calendar
# card (UserFrame.calendar_included below).
# Personal ICS subscription URL (no OAuth) for calendar frame mode --
# see calendar_feed.py. Setting this alone shows up nowhere: a linked
# frame only pulls this user's events in once they've also added it
# on that frame's own Calendar tab (FrameCalendar below).
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)
__table_args__ = (
@@ -234,14 +248,38 @@ class UserFrame(Base):
ForeignKey("frames.id", ondelete="CASCADE"), primary_key=True
)
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
# calendar is their own data to share or not, not something a
# frame's controller decides on their behalf). Meaningless if the
# user has no calendar_ics_url set. See routers/api_frames.py's
# api_calendar_included.
calendar_included: Mapped[bool] = mapped_column(Boolean, default=False)
class FrameCalendar(Base):
"""One calendar included on one frame -- calendar_key is "ics" (the
owner's single calendar_ics_url) or "caldav:<href>" (one of the
owner's CalDAV collections; href matches an entry in
User.calendar_caldav_calendars). Replaces the old single
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):
+35 -15
View File
@@ -36,7 +36,7 @@ from ..image_pipeline import (
render_preview_png,
)
from ..firmware import firmware_path, parse_app_version
from ..models import BatteryLog, Frame, UserFrame
from ..models import BatteryLog, Frame, FrameCalendar
from .common import (
FRAME_MODES,
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")
class CalendarIncludedRequest(BaseModel):
class CalendarSelectRequest(BaseModel):
user_id: int
calendar_key: str
calendar_label: str = ""
included: bool
@router.post("/api/frames/{frame_id}/calendar-included")
def api_calendar_included(
body: CalendarIncludedRequest,
@router.post("/api/frames/{frame_id}/calendar-select")
def api_calendar_select(
body: CalendarSelectRequest,
request: Request,
frame: Frame = Depends(require_frame_view), # view access only -- NOT require_frame_control
db: Session = Depends(get_db),
):
"""A user's own opt-in into this frame's merged calendar (see
UserFrame.calendar_included). Deliberately not require_frame_control:
this is the toggling user's own data-sharing preference about their
own calendar, not a frame setting its controller manages on someone
else's behalf -- there's no target user_id in the request body by
design, it always toggles the calling session's own row."""
"""Include/exclude one calendar (calendar_key "ics" or
"caldav:<href>", see FrameCalendar) on this frame. Deliberately not
require_frame_control: adding your own calendar, or muting anyone's
(including your own), is each viewer's own call, not something a
frame's controller manages on someone else's behalf. The one-sided
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)
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:
raise HTTPException(404, "Not linked to this frame")
row.calendar_included = body.included
if not 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
# rather than waiting out the throttle.
frame.calendar_checked_at = 0.0
db.commit()
return {"status": "saved", "included": row.calendar_included}
return {"status": "saved", "included": row.included}
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 ..image_pipeline import render_frame
from ..immich_client import ImmichClient
from ..models import BatteryLog, Frame, User, UserFrame
from ..models import BatteryLog, Frame, FrameCalendar, User
logger = logging.getLogger(__name__)
@@ -373,18 +373,29 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict:
return content
def calendar_sources_for_frame(db: Session, frame: Frame) -> list[tuple[str, str]]:
"""Every user linked to this frame with BOTH a calendar URL set AND
explicit per-frame opt-in (UserFrame.calendar_included) -- the exact
set calendar_feed.merge_events needs. [(display_name-or-username,
ics_url), ...]."""
def calendar_sources_for_frame(db: Session, frame: Frame) -> list[calendar_feed.CalendarSource]:
"""Every calendar included on this frame (FrameCalendar.included) --
the exact set calendar_feed.merge_events needs. A calendar_key of
"ics" resolves against its owner's calendar_ics_url; "caldav:<href>"
resolves against the href itself, authenticated with the owner's
CalDAV account credentials (see caldav_client.py)."""
rows = db.execute(
select(User)
.join(UserFrame, UserFrame.user_id == User.id)
.where(UserFrame.frame_id == frame.id, UserFrame.calendar_included == True, # noqa: E712
User.calendar_ics_url != "")
).scalars().all()
return [(u.display_name or u.username, u.calendar_ics_url) for u in rows]
select(FrameCalendar, User)
.join(User, User.id == FrameCalendar.user_id)
.where(FrameCalendar.frame_id == frame.id, FrameCalendar.included == True) # noqa: E712
).all()
sources = []
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]:
+51 -19
View File
@@ -20,7 +20,7 @@ from ..image_pipeline import (
PALETTE_LABELS,
palette_to_hex,
)
from ..models import Frame, User, UserFrame
from ..models import Frame, FrameCalendar, User, UserFrame
from ..quiet_hours import ALL_TIMEZONES
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")
def _calendar_users_for_frame(db: Session, frame_id: int) -> list[dict]:
"""Every user linked to this frame, their calendar opt-in state, and
whether they even have a calendar URL set -- what the Configuration
tab's "Included calendars" list needs. Whether a given row is *this*
viewer's own (and therefore editable) is decided in the template,
using the `user` shell_context already provides."""
rows = db.execute(
select(User, UserFrame.calendar_included)
.join(UserFrame, UserFrame.user_id == User.id)
.where(UserFrame.frame_id == frame_id)
.order_by(User.username)
).all()
return [
{"user_id": u.id, "display_name": u.display_name or u.username,
"has_url": bool(u.calendar_ics_url), "included": included}
for u, included in rows
]
def _user_available_calendars(user: User) -> list[dict]:
"""This user's full set of calendars available to add to any frame:
the single ICS subscription (if set) plus every CalDAV calendar last
discovered from Settings' "Discover calendars" button. Doesn't hit
the network -- reads the cached list a user refreshes themselves."""
calendars = []
if user.calendar_ics_url:
calendars.append({"key": "ics", "label": "My calendar (ICS)"})
for c in (user.calendar_caldav_calendars or []):
calendars.append({"key": f"caldav:{c['href']}", "label": c.get("display_name") or "Calendar"})
return calendars
def _calendar_users_for_frame(db: Session, frame_id: int, viewer_id: int | None) -> list[dict]:
"""Per-linked-user calendar list for the Calendar tab's "Included
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)
@@ -82,10 +113,11 @@ WEEK_START_LABELS = {0: "Monday", 1: "Tuesday", 2: "Wednesday", 3: "Thursday",
@router.get("/frames/{frame_id}/calendar", response_class=HTMLResponse)
def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
viewer = current_user(request, db)
return _frame_page(
request, db, frame_id, "frame_calendar.html", "calendar",
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,
)
+44 -1
View File
@@ -18,7 +18,7 @@ from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import mail
from .. import caldav_client, mail
from ..auth import (
SESSION_COOKIE,
SESSION_LIFETIME_S,
@@ -30,6 +30,7 @@ from ..auth import (
destroy_session,
get_server_settings,
hash_password,
require_user_api,
users_exist,
verify_password,
)
@@ -424,6 +425,9 @@ def settings_submit(
immich_url: str = Form(""),
immich_api_key: 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(""),
new_password: str = Form(""),
db: Session = Depends(get_db),
@@ -452,6 +456,23 @@ def settings_submit(
else:
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 not user.password_hash or not verify_password(current_password, user.password_hash):
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:
user = current_user(request, db)
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
// form above, since it's the toggling user's own preference (see
// api_frames.py's /calendar-included), not a frame-wide setting.
document.querySelectorAll('.calendar-self-toggle').forEach((el) => {
// Each calendar's own include/mute toggle -- auto-saves on change, not
// batched into the form above, since it's a data-sharing choice (see
// api_frames.py's /calendar-select), not a frame-wide setting. Works the
// 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 () => {
try {
const resp = await fetch(`${window.FRAME_API}/calendar-included`, {
const resp = await fetch(`${window.FRAME_API}/calendar-select`, {
method: 'POST',
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));
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) {
el.checked = !el.checked;
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 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 {
margin-top: 20px;
padding: 10px 16px;
@@ -540,6 +545,9 @@ code {
.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-bar { 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>
{% block device_status %}{% endblock %}
{% block mode_picker %}{% endblock %}
{% block tabs %}{% endblock %}
{% block content %}{% endblock %}
</main>
+18 -13
View File
@@ -2,7 +2,7 @@
{% block title %}{{ frame.name or "Frame" }} · Calendar{% 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 tabs %}{% include "_frame_tabs.html" %}{% endblock %}
@@ -49,24 +49,29 @@
</form>
<h2 class="card-title" style="margin-top: 20px;">Included calendars</h2>
<p class="sub">Each linked person decides whether their own calendar
contributes to this frame -- being linked here doesn't include it
automatically.</p>
<p class="sub">Each linked person adds their own calendars (ICS
subscription or CalDAV, set up in <a href="/settings">Settings</a>)
-- 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">
{% for u in calendar_users %}
<li>
{% if u.user_id == user.id %}
{% if u.has_url %}
<p class="calendar-user-name">{{ u.display_name }}{% if u.is_self %} (you){% endif %}</p>
{% if u.calendars %}
{% for c in u.calendars %}
<label class="checkbox-row" style="margin-top: 6px;">
<input type="checkbox" class="calendar-self-toggle" {% if u.included %}checked{% endif %}>
{{ u.display_name }} (you)
<input type="checkbox" class="calendar-toggle"
data-user-id="{{ u.user_id }}" data-key="{{ c.key }}" data-label="{{ c.label }}"
{% if c.included %}checked{% endif %}>
{{ c.label }}
</label>
{% else %}
<p class="sub" style="margin-top: 6px;">{{ u.display_name }} (you) -- no calendar set, add one in <a href="/settings">Settings</a>.</p>
{% endif %}
{% 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 %}
<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>
<p class="sub" style="margin-top: 6px;">No calendars included.</p>
{% endif %}
</li>
{% endfor %}
+1 -1
View File
@@ -2,7 +2,7 @@
{% block title %}{{ frame.name or "Frame" }} · Configuration{% 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 tabs %}{% include "_frame_tabs.html" %}{% endblock %}
+1 -1
View File
@@ -2,7 +2,7 @@
{% block title %}{{ frame.name or "Frame" }} · Photos{% 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 tabs %}{% include "_frame_tabs.html" %}{% endblock %}
+1 -1
View File
@@ -2,7 +2,7 @@
{% block title %}{{ frame.name or "Frame" }} · Stats{% 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 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>
<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"
value="{{ user.calendar_ics_url }}">
</label>
<p class="sub" style="margin-top: 8px;">Your personal calendar
subscription link (no login needed -- e.g. Google Calendar's
Settings &rarr; "Secret address in iCal format", or Apple/Outlook/
Nextcloud's equivalent). Setting it here doesn't show it anywhere
by itself -- include it on any frame you're linked to from that
frame's Configuration &rarr; Calendar card, so a frame only shows
calendars people have actually chosen to share with it.</p>
<p class="sub" style="margin-top: 8px;">A single subscription link
(no login needed) -- e.g. Google Calendar's Settings &rarr;
"Secret address in iCal format", or Apple/Outlook's equivalent.</p>
<label style="margin-top: 20px;">CalDAV server URL
<input type="text" name="calendar_caldav_url" placeholder="https://cloud.example.com/remote.php/dav/"
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>
<label>Current password
@@ -58,4 +84,10 @@
<button type="submit">Save</button>
</form>
</section>
<div id="result"></div>
{% endblock %}
{% block scripts %}
<script src="/static/settings.js"></script>
{% endblock %}
+1
View File
@@ -9,3 +9,4 @@ sqlalchemy==2.0.51
qrcode==8.2
icalendar==7.2.2
recurring-ical-events==3.8.2
caldav==3.2.1