diff --git a/server/README.md b/server/README.md
index c72c515..222f07d 100644
--- a/server/README.md
+++ b/server/README.md
@@ -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).
diff --git a/server/app/caldav_client.py b/server/app/caldav_client.py
new file mode 100644
index 0000000..1ce7882
--- /dev/null
+++ b/server/app/caldav_client.py
@@ -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
diff --git a/server/app/calendar_feed.py b/server/app/calendar_feed.py
index 656a917..7cb7266 100644
--- a/server/app/calendar_feed.py
+++ b/server/app/calendar_feed.py
@@ -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"])
diff --git a/server/app/calendar_render.py b/server/app/calendar_render.py
index efd676e..a504698 100644
--- a/server/app/calendar_render.py
+++ b/server/app/calendar_render.py
@@ -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/
diff --git a/server/app/migration.py b/server/app/migration.py
index add63b5..7aaa33a 100644
--- a/server/app/migration.py
+++ b/server/app/migration.py
@@ -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),
]
diff --git a/server/app/models.py b/server/app/models.py
index 8216795..9605d07 100644
--- a/server/app/models.py
+++ b/server/app/models.py
@@ -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:" (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):
diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py
index fc05237..80afc12 100644
--- a/server/app/routers/api_frames.py
+++ b/server/app/routers/api_frames.py
@@ -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:", 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):
diff --git a/server/app/routers/common.py b/server/app/routers/common.py
index 3d2dab3..805b4c6 100644
--- a/server/app/routers/common.py
+++ b/server/app/routers/common.py
@@ -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:"
+ 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]:
diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py
index ad47935..c8b1078 100644
--- a/server/app/routers/frame_pages.py
+++ b/server/app/routers/frame_pages.py
@@ -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,
)
diff --git a/server/app/routers/pages.py b/server/app/routers/pages.py
index a850d88..af84c5b 100644
--- a/server/app/routers/pages.py
+++ b/server/app/routers/pages.py
@@ -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:
diff --git a/server/app/static/frame_calendar.js b/server/app/static/frame_calendar.js
index 5fc4ee1..c0f1f51 100644
--- a/server/app/static/frame_calendar.js
+++ b/server/app/static/frame_calendar.js
@@ -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);
diff --git a/server/app/static/settings.js b/server/app/static/settings.js
new file mode 100644
index 0000000..f245a8f
--- /dev/null
+++ b/server/app/static/settings.js
@@ -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 = '
Each linked person decides whether their own calendar
- contributes to this frame -- being linked here doesn't include it
- automatically.
+
Each linked person adds their own calendars (ICS
+ subscription or CalDAV, set up in Settings)
+ -- 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.
{% for u in calendar_users %}
- {% if u.user_id == user.id %}
- {% if u.has_url %}
+
{{ u.display_name }}{% if u.is_self %} (you){% endif %}
+ {% if u.calendars %}
+ {% for c in u.calendars %}
- {% else %}
-
{{ u.display_name }} (you) -- no calendar set, add one in Settings.