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 = '
  • No calendars found in this account.
  • '; + } 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; + } + }); +} diff --git a/server/app/static/theme.css b/server/app/static/theme.css index 9c0b054..21c2b7d 100644 --- a/server/app/static/theme.css +++ b/server/app/static/theme.css @@ -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; } diff --git a/server/app/templates/_frame_mode_picker.html b/server/app/templates/_frame_mode_picker.html new file mode 100644 index 0000000..dc4e26e --- /dev/null +++ b/server/app/templates/_frame_mode_picker.html @@ -0,0 +1,4 @@ +
    + Mode + {% include "_frame_mode_select.html" %} +
    diff --git a/server/app/templates/app_base.html b/server/app/templates/app_base.html index 48b9793..cad687f 100644 --- a/server/app/templates/app_base.html +++ b/server/app/templates/app_base.html @@ -73,6 +73,7 @@ {% block device_status %}{% endblock %} + {% block mode_picker %}{% endblock %} {% block tabs %}{% endblock %} {% block content %}{% endblock %} diff --git a/server/app/templates/frame_calendar.html b/server/app/templates/frame_calendar.html index 8aea7af..8f65dbf 100644 --- a/server/app/templates/frame_calendar.html +++ b/server/app/templates/frame_calendar.html @@ -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 @@

    Included calendars

    -

    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.

      - {% endif %} + {% endfor %} + {% elif u.is_self %} +

      No calendars set up yet -- add an ICS link or CalDAV account in Settings.

      {% else %} -

      {{ u.display_name }}: - {% if not u.has_url %}no calendar set{% elif u.included %}included{% else %}not included{% endif %}

      +

      No calendars included.

      {% endif %}
    • {% endfor %} diff --git a/server/app/templates/frame_config.html b/server/app/templates/frame_config.html index b06ce44..0f41b66 100644 --- a/server/app/templates/frame_config.html +++ b/server/app/templates/frame_config.html @@ -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 %} diff --git a/server/app/templates/frame_photos.html b/server/app/templates/frame_photos.html index 9cad862..b8d3085 100644 --- a/server/app/templates/frame_photos.html +++ b/server/app/templates/frame_photos.html @@ -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 %} diff --git a/server/app/templates/frame_stats.html b/server/app/templates/frame_stats.html index f555158..223aa9f 100644 --- a/server/app/templates/frame_stats.html +++ b/server/app/templates/frame_stats.html @@ -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 %} diff --git a/server/app/templates/settings.html b/server/app/templates/settings.html index 9f8d4a9..6ccfd77 100644 --- a/server/app/templates/settings.html +++ b/server/app/templates/settings.html @@ -33,17 +33,43 @@ plus sharedLink.create for the on-frame share QR.

      Calendar

      -