From 644fdefa66d291c33328ddd79fadbd7e574ab2d4 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Thu, 23 Jul 2026 17:02:08 -0400 Subject: [PATCH] Add whiteboard frame mode (Nextcloud Whiteboard / Excalidraw over WebDAV) New third mode alongside photos/calendar: fetches a .whiteboard file over plain WebDAV (Basic auth -- generic, not Nextcloud-specific) and renders it via a small Node.js sidecar using Excalidraw's own real export code (@excalidraw/utils + @resvg/resvg-js, no headless browser), since a .whiteboard file turns out to be Excalidraw scene JSON, not an image. The sidecar runs as a second process inside this same container (Dockerfile installs Node, start.sh backgrounds it before exec'ing uvicorn) rather than a separate docker-compose service -- lightweight, stateless, reachable only at 127.0.0.1 from the Python process, nothing worth independently scaling. The rendered PNG is treated exactly like a photo from there on -- composed/quantized through the existing image_pipeline (letterboxed, never cropped) rather than a second parallel rendering pipeline. WebDAV credentials support the common "it's actually the same Nextcloud account as my CalDAV" case (an explicit opt-in checkbox, not silently inferred) while still working with any WebDAV server generically. Frame-level source (URL + owning account) follows the same owner- controls-their-own-data permission split as calendar sources and the week view's task list: only the account owner can point a frame at it, anyone linked can clear it. Honest limitation: this environment has no Node.js/npm, so render-service/ is written carefully against each library's documented API (verified via the npm registry, including transitive dependency licenses after the CalDAV/AGPL surprise earlier this session) but has never actually been executed. First real docker build is the first true test -- see render-service/README.md. --- .gitignore | 6 ++ server/.dockerignore | 7 ++ server/Dockerfile | 21 ++++- server/README.md | 23 +++++ server/app/migration.py | 20 ++++ server/app/models.py | 40 +++++++- server/app/routers/api_frames.py | 59 ++++++++++++ server/app/routers/common.py | 51 +++++++++- server/app/routers/device.py | 52 +++++++++- server/app/routers/frame_pages.py | 28 ++++++ server/app/routers/pages.py | 8 ++ server/app/static/frame_header.js | 5 +- server/app/static/frame_whiteboard.js | 79 ++++++++++++++++ server/app/static/settings.js | 13 +++ server/app/templates/_frame_mode_select.html | 1 + server/app/templates/_frame_tabs.html | 2 + server/app/templates/frame_whiteboard.html | 80 ++++++++++++++++ server/app/templates/settings.html | 26 +++++ server/app/webdav_client.py | 46 +++++++++ server/app/whiteboard.py | 75 +++++++++++++++ server/render-service/README.md | 33 +++++++ server/render-service/package.json | 17 ++++ server/render-service/server.js | 99 ++++++++++++++++++++ server/start.sh | 10 ++ 24 files changed, 792 insertions(+), 9 deletions(-) create mode 100644 server/.dockerignore create mode 100644 server/app/static/frame_whiteboard.js create mode 100644 server/app/templates/frame_whiteboard.html create mode 100644 server/app/webdav_client.py create mode 100644 server/app/whiteboard.py create mode 100644 server/render-service/README.md create mode 100644 server/render-service/package.json create mode 100644 server/render-service/server.js create mode 100755 server/start.sh diff --git a/.gitignore b/.gitignore index 500e022..6e00562 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,12 @@ server/**/__pycache__/ server/.venv/ server/*.egg-info/ server/data/ +# render-service/ (whiteboard mode's Node sidecar) -- installed fresh +# inside the Docker image, never committed. No package-lock.json exists +# yet either (no Node/npm available in this project's dev environment to +# generate one -- see render-service/README.md); if one's added later, do +# NOT ignore it, lockfiles belong in git. +server/render-service/node_modules/ # Real deploy config, copied from docker-compose.yml.example -- holds the # Immich API key, must never be committed. server/docker-compose.yml diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..f4935e8 --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,7 @@ +__pycache__/ +**/__pycache__/ +.venv/ +*.egg-info/ +data/ +render-service/node_modules/ +.git/ diff --git a/server/Dockerfile b/server/Dockerfile index 1c8c4f2..581ca9a 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -6,14 +6,31 @@ WORKDIR /app # database backing the web UI's "Timezone" setting (used by "Quiet hours") # would have no named zones to resolve without this -- ZoneInfo() would # raise for anything other than "UTC". -RUN apt-get update && apt-get install -y --no-install-recommends tzdata \ +# +# Node.js + fonts: whiteboard frame mode's render-service/ (own README +# there) runs as a second process in this same container rather than a +# separate compose service -- it's a lightweight, stateless, localhost- +# only sidecar with nothing worth independently scaling or restarting. +# NodeSource's setup script is used instead of Debian bookworm's own +# apt Node package, which is both older than jsdom's minimum (20.19+) +# and inconsistently available. fonts-dejavu-core gives the sidecar's +# SVG rasterizer something to render whiteboard text with. +RUN apt-get update && apt-get install -y --no-install-recommends \ + tzdata curl ca-certificates gnupg fontconfig fonts-dejavu-core \ + && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt +COPY render-service ./render-service +RUN cd render-service && npm install --omit=dev + COPY app ./app +COPY start.sh . +RUN chmod +x start.sh EXPOSE 8420 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8420"] +CMD ["./start.sh"] diff --git a/server/README.md b/server/README.md index 33f982b..d76df26 100644 --- a/server/README.md +++ b/server/README.md @@ -224,6 +224,29 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`, 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. +- Whiteboard frame mode (`app/webdav_client.py`, `app/whiteboard.py`) + fetches a Nextcloud Whiteboard (or any WebDAV server's) `.whiteboard` + file -- which turns out to be Excalidraw scene JSON (elements/appState/ + files), not an image -- and renders it via `render-service/`, a small + Node.js sidecar using Excalidraw's own real export code + (`@excalidraw/utils`'s `exportToSvg`) plus `@resvg/resvg-js` (a native + Rust SVG rasterizer, no headless browser) to turn that into a PNG. That + sidecar runs as a **second process inside this same container** + (`Dockerfile` installs Node, `start.sh` launches it in the background + before `exec`-ing uvicorn), reachable only at `127.0.0.1:3001` from the + Python process -- not a second docker-compose service, since it's + lightweight, stateless, and has nothing worth independently scaling or + restarting. License check (after getting burned once already in this + same file, on the CalDAV dependency below, into checking transitive + deps and not just top-level ones): Excalidraw, `@excalidraw/utils`, + every one of its own runtime dependencies, `@resvg/resvg-js` + (MPL-2.0 -- weak/file-level copyleft, doesn't extend to code that just + calls into it), `jsdom`, and `express` are all MIT/Apache-2.0/Zlib/ + MPL-2.0 -- no repeat of the AGPL surprise. **Not runtime-tested against + a real `npm install`/`docker build`** -- this project's dev environment + has no Node.js/npm, only network access to the npm registry API (used + to verify the above and pick real, current dependency versions). See + `render-service/README.md` for exactly what is and isn't verified. - Calendar event titles can contain emoji, which `ImageFont.load_default()` (used for every other bit of text this project renders) has no glyphs for -- PIL/FreeType substitute a visible ".notdef" tofu box rather than diff --git a/server/app/migration.py b/server/app/migration.py index ca4e75a..827b465 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -199,6 +199,25 @@ def _migration_13(conn) -> None: conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_start_offset INTEGER NOT NULL DEFAULT 0")) +def _migration_14(conn) -> None: + """Whiteboard frame mode: generic WebDAV credentials per user + (webdav_username/password, plus webdav_reuse_caldav_creds as a + convenience when it's the same Nextcloud account as an already- + configured CalDAV one -- see models.py's User docstring), and the + frame-level whiteboard source (whiteboard_user_id/url) + rendered- + PNG cache (see webdav_client.py, whiteboard.py, + routers/device.py's RENDERERS["whiteboard"]). Every new column has a + behavior-preserving default -- no existing frame's render changes + until its mode is actually switched to "whiteboard".""" + conn.execute(text("ALTER TABLE users ADD COLUMN webdav_username TEXT NOT NULL DEFAULT ''")) + conn.execute(text("ALTER TABLE users ADD COLUMN webdav_password TEXT NOT NULL DEFAULT ''")) + conn.execute(text("ALTER TABLE users ADD COLUMN webdav_reuse_caldav_creds INTEGER NOT NULL DEFAULT 0")) + conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL")) + conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_url TEXT NOT NULL DEFAULT ''")) + conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_checked_at REAL NOT NULL DEFAULT 0.0")) + conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_cached_image BLOB")) + + MIGRATIONS = [ (1, _migration_1), (2, _migration_2), @@ -213,6 +232,7 @@ MIGRATIONS = [ (11, _migration_11), (12, _migration_12), (13, _migration_13), + (14, _migration_14), ] diff --git a/server/app/models.py b/server/app/models.py index dd77673..965fea0 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -19,7 +19,7 @@ from __future__ import annotations import time -from sqlalchemy import JSON, Boolean, Float, ForeignKey, Index, Integer, String +from sqlalchemy import JSON, Boolean, Float, ForeignKey, Index, Integer, LargeBinary, String from sqlalchemy.ext.mutable import MutableList from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship @@ -68,6 +68,18 @@ class User(Base): # 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) + # WebDAV credentials for whiteboard frame mode (see webdav_client.py, + # whiteboard.py) -- generic WebDAV, not Nextcloud-specific, but + # webdav_reuse_caldav_creds is a convenience for the common case + # where it IS the same Nextcloud account as calendar_caldav_*: skip + # re-entering the same username/password, since Nextcloud's CalDAV + # and general-file-WebDAV both sit under the one account. Doesn't + # try to be clever and derive the reuse automatically -- an explicit + # opt-in, same as everywhere else in this project defaults features + # off rather than silently inferring them. + webdav_username: Mapped[str] = mapped_column(String, default="") + webdav_password: Mapped[str] = mapped_column(String, default="") + webdav_reuse_caldav_creds: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[float] = mapped_column(Float, default=time.time) __table_args__ = ( @@ -102,8 +114,9 @@ class Frame(Base): # the migrated legacy frame until its device first reports an id. device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True) name: Mapped[str] = mapped_column(String, default="") - # Renderer dispatch seam for future calendar/canva modes -- only - # "photos" is registered today (see routers/device.py RENDERERS). + # Renderer dispatch seam -- "photos", "calendar", or "whiteboard" + # (see routers/device.py RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS + # and routers/common.py FRAME_MODES). mode: Mapped[str] = mapped_column(String, default="photos") # Whose Immich library this frame pulls from; NULL = unclaimed. owner_user_id: Mapped[int | None] = mapped_column( @@ -238,6 +251,27 @@ class Frame(Base): # by due date -- see caldav_client.fetch_tasks. calendar_tasks_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) + # -- whiteboard mode (see webdav_client.py, whiteboard.py, + # routers/device.py's RENDERERS["whiteboard"]) -- a frame-wide + # setting like calendar mode's own frame_calendars source, not + # personal data, but still owner-gated the same way: only + # whiteboard_user_id may point the frame at their own account (see + # routers/api_frames.py's api_whiteboard_source), since it's their + # credentials being used to fetch it. -- + whiteboard_user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + # The specific .whiteboard file's WebDAV URL -- pasted directly, same + # idiom as calendar_ics_url, not discovered/browsed (unlike CalDAV's + # account-has-several-calendars case, a WebDAV account doesn't need + # a picker step here since the user already knows which one file). + whiteboard_url: Mapped[str] = mapped_column(String, default="") + whiteboard_checked_at: Mapped[float] = mapped_column(Float, default=0.0) + # Cached rendered PNG bytes (see whiteboard.fetch_and_render) -- + # BLOB rather than the JSON columns the rest of this cache-pattern + # family uses, since this is binary image data, not JSON-shaped. + whiteboard_cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) + # -- state -- current_asset_id: Mapped[str] = mapped_column(String, default="") current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0) diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py index f5ab3d2..785e023 100644 --- a/server/app/routers/api_frames.py +++ b/server/app/routers/api_frames.py @@ -46,6 +46,7 @@ from .common import ( get_or_refresh_calendar_events, get_or_refresh_tasks, get_or_refresh_weather, + get_or_refresh_whiteboard, immich_client_for, immich_creds, list_assets, @@ -616,6 +617,64 @@ def api_tasks_source( return {"status": "saved", "calendar_key": body.calendar_key} +class WhiteboardSourceRequest(BaseModel): + url: str | None # None clears the source + + +@router.post("/api/frames/{frame_id}/whiteboard-source") +def api_whiteboard_source( + body: WhiteboardSourceRequest, + request: Request, + frame: Frame = Depends(require_frame_view), + db: Session = Depends(get_db), +): + """Points this frame's whiteboard at one of the calling user's own + WebDAV (or reused-CalDAV, see User.webdav_reuse_caldav_creds) + credentials -- same owner-controls-their-own-data permission split + as api_tasks_source: only the account owner can set the frame to use + it, but anyone linked to the frame can clear it, same as muting a + shared calendar.""" + user = require_user_api(request, db) + with frame_locked(db, frame.id) as cfg: + if body.url is None: + cfg.whiteboard_user_id = None + cfg.whiteboard_url = "" + cfg.whiteboard_cached_image = None + else: + stripped = body.url.strip() + if not valid_http_url(stripped): + raise HTTPException(400, "Whiteboard URL must be a plain http:// or https:// URL") + cfg.whiteboard_user_id = user.id + cfg.whiteboard_url = stripped + cfg.whiteboard_checked_at = 0.0 # pick up the change promptly + return {"status": "saved", "url": body.url} + + +@router.get("/api/frames/{frame_id}/preview/whiteboard") +def api_preview_whiteboard(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)): + """The same throttled fetch/render cache a live device request would + use, run through the same panel composition/quantization pipeline + (see routers/device.py's _render_whiteboard_mode) -- "how it will + look on the frame" (dithered, letterboxed), not just the raw + Excalidraw export, same convention as preview/rendered and + preview/calendar.""" + png_bytes = get_or_refresh_whiteboard(db, frame) + if png_bytes is None: + if not frame.whiteboard_url: + raise HTTPException(400, "No whiteboard configured on this frame yet") + raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials") + import io + + from PIL import Image + + source = Image.open(io.BytesIO(png_bytes)).convert("RGB") + png = render_preview_png( + source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb, + display_mode="letterbox", + ) + return Response(content=png, media_type="image/png") + + class WeatherCityAddRequest(BaseModel): name: str diff --git a/server/app/routers/common.py b/server/app/routers/common.py index 41fb69a..9a0578a 100644 --- a/server/app/routers/common.py +++ b/server/app/routers/common.py @@ -16,7 +16,7 @@ from PIL import Image from sqlalchemy import select from sqlalchemy.orm import Session -from .. import caldav_client, calendar_feed, quiet_hours, weather +from .. import caldav_client, calendar_feed, quiet_hours, weather, whiteboard from ..db import frame_locked from ..image_pipeline import render_frame from ..immich_client import ImmichClient @@ -24,7 +24,7 @@ from ..models import BatteryLog, Frame, FrameCalendar, User logger = logging.getLogger(__name__) -FRAME_MODES = ("photos", "calendar") +FRAME_MODES = ("photos", "calendar", "whiteboard") # Battery-history / estimate tuning (see /frame/battery and battery_estimate_s). BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports @@ -542,3 +542,50 @@ def get_or_refresh_tasks(db: Session, frame: Frame) -> list[dict]: locked.calendar_tasks_cached = tasks locked.calendar_tasks_checked_at = now return tasks + + +def webdav_creds_for(user: User) -> tuple[str, str] | None: + """(username, password) for `user`'s WebDAV access -- their own + dedicated webdav_username/password, or (if they opted in) + calendar_caldav_username/password reused from their CalDAV account + (see models.py's User docstring on webdav_reuse_caldav_creds). None + if neither is actually set up.""" + if user.webdav_reuse_caldav_creds: + if user.calendar_caldav_username: + return user.calendar_caldav_username, user.calendar_caldav_password + return None + if user.webdav_username: + return user.webdav_username, user.webdav_password + return None + + +def get_or_refresh_whiteboard(db: Session, frame: Frame) -> bytes | None: + """Frame-level throttled render cache (calendar_feed.CHECK_INTERVAL_S) + -- None if no whiteboard source is configured, credentials are + missing (e.g. the owning user unlinked their WebDAV/CalDAV account), + or the most recent fetch/render failed and nothing was ever cached + yet. A failure after a previous success keeps showing the last + good render rather than going blank for one bad refresh cycle, same + reasoning as get_or_refresh_weather/get_or_refresh_tasks.""" + if not frame.whiteboard_url or not frame.whiteboard_user_id: + return None + now = time.time() + if (frame.whiteboard_cached_image is not None + and now - frame.whiteboard_checked_at < calendar_feed.CHECK_INTERVAL_S): + return frame.whiteboard_cached_image + + user = db.get(User, frame.whiteboard_user_id) + creds = webdav_creds_for(user) if user else None + if creds is None: + return frame.whiteboard_cached_image + + try: + png = whiteboard.fetch_and_render(frame.whiteboard_url, creds[0], creds[1]) + except whiteboard.WhiteboardRenderError as e: + logger.warning("Could not refresh whiteboard for frame %d: %s", frame.id, e) + return frame.whiteboard_cached_image + + with frame_locked(db, frame.id) as locked: + locked.whiteboard_cached_image = png + locked.whiteboard_checked_at = now + return png diff --git a/server/app/routers/device.py b/server/app/routers/device.py index e27a3af..86bb971 100644 --- a/server/app/routers/device.py +++ b/server/app/routers/device.py @@ -12,12 +12,14 @@ see manage_overlay.py and common.build_manage_content).""" from __future__ import annotations +import io import logging import time import httpx from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import FileResponse, RedirectResponse, Response +from PIL import Image from pydantic import BaseModel from sqlalchemy import delete, func, select from sqlalchemy.orm import Session @@ -26,7 +28,7 @@ from .. import calendar_render, mail, photo_queue, quiet_hours from ..auth import get_server_settings, require_device from ..db import frame_locked, get_db from ..firmware import firmware_path -from ..image_pipeline import render_placeholder +from ..image_pipeline import render_frame, render_placeholder from ..models import BatteryLog, Frame from .common import ( BATTERY_HISTORY_MAX, @@ -37,6 +39,7 @@ from .common import ( get_or_refresh_calendar_events, get_or_refresh_tasks, get_or_refresh_weather, + get_or_refresh_whiteboard, immich_client_for, immich_creds, list_assets, @@ -204,17 +207,64 @@ def _back_calendar_mode(db: Session, frame: Frame, manage: dict | None) -> bytes return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False) +# --- whiteboard mode --- + +def _render_whiteboard_mode(db: Session, frame: Frame, request: Request, manage: dict | None, + is_normal_wake: bool) -> bytes: + """Fetches (throttled, see get_or_refresh_whiteboard) and renders the + frame's configured .whiteboard file. The rendered PNG is treated + exactly like a photo from here on -- run through the same + render_frame composition/quantization pipeline as photos mode, + letterboxed (never cropped: unlike a photo, losing part of a + whiteboard to a crop loses actual content, not just some background) + -- rather than a second parallel image pipeline just for this mode.""" + png_bytes = get_or_refresh_whiteboard(db, frame) + if png_bytes is None: + return render_placeholder( + ["This frame's whiteboard isn't set up yet", + "Add a WebDAV/Nextcloud whiteboard file URL on", + "this frame's Whiteboard tab."], + orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage, + ) + + source = Image.open(io.BytesIO(png_bytes)).convert("RGB") + return render_frame( + source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb, + display_mode="letterbox", manage=manage, + ) + + +def _advance_whiteboard_mode(db: Session, frame: Frame, manage: dict | None) -> bytes: + """NEXT in whiteboard mode: there's no "next" concept for a single + static board, so this instead forces an immediate re-fetch/re-render + bypassing the throttle -- a "check now" button for "someone just + updated the board, show it right away" rather than waiting out + calendar_feed.CHECK_INTERVAL_S.""" + with frame_locked(db, frame.id) as locked: + locked.whiteboard_checked_at = 0.0 + return _render_whiteboard_mode(db, frame, request=None, manage=manage, is_normal_wake=False) + + +def _back_whiteboard_mode(db: Session, frame: Frame, manage: dict | None) -> bytes: + """Same "check now" behavior as _advance_whiteboard_mode -- there's + no separate "back" concept for a single static board either.""" + return _advance_whiteboard_mode(db, frame, manage) + + RENDERERS = { "photos": _render_photos_mode, "calendar": _render_calendar_mode, + "whiteboard": _render_whiteboard_mode, } ADVANCE_RENDERERS = { "photos": _advance_photos_mode, "calendar": _advance_calendar_mode, + "whiteboard": _advance_whiteboard_mode, } BACK_RENDERERS = { "photos": _back_photos_mode, "calendar": _back_calendar_mode, + "whiteboard": _back_whiteboard_mode, } diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py index 476a471..be46090 100644 --- a/server/app/routers/frame_pages.py +++ b/server/app/routers/frame_pages.py @@ -152,6 +152,34 @@ def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(g ) +def _whiteboard_source_info(db: Session, frame: Frame) -> dict | None: + """Whose account this frame's whiteboard currently fetches with, for + showing "using 's account" to everyone linked, not just + whoever set it. None if no source is configured.""" + if not frame.whiteboard_user_id or not frame.whiteboard_url: + return None + user = db.get(User, frame.whiteboard_user_id) + if user is None: + return None + return {"user_id": user.id, "display_name": user.display_name or user.username, "url": frame.whiteboard_url} + + +@router.get("/frames/{frame_id}/whiteboard", response_class=HTMLResponse) +def frame_whiteboard_page(frame_id: int, request: Request, db: Session = Depends(get_db)): + viewer = current_user(request, db) + frame = db.get(Frame, frame_id) + viewer_has_webdav_creds = False + if viewer is not None and frame is not None and can_view_frame(db, viewer, frame): + viewer_has_webdav_creds = bool( + viewer.webdav_username or (viewer.webdav_reuse_caldav_creds and viewer.calendar_caldav_username) + ) + return _frame_page( + request, db, frame_id, "frame_whiteboard.html", "whiteboard", + whiteboard_source=_whiteboard_source_info(db, frame) if frame is not None else None, + viewer_has_webdav_creds=viewer_has_webdav_creds, + ) + + @router.get("/frames/{frame_id}/stats", response_class=HTMLResponse) def frame_stats_page(frame_id: int, request: Request, db: Session = Depends(get_db)): return _frame_page(request, db, frame_id, "frame_stats.html", "stats") \ No newline at end of file diff --git a/server/app/routers/pages.py b/server/app/routers/pages.py index af84c5b..7264287 100644 --- a/server/app/routers/pages.py +++ b/server/app/routers/pages.py @@ -428,6 +428,9 @@ def settings_submit( calendar_caldav_url: str = Form(""), calendar_caldav_username: str = Form(""), calendar_caldav_password: str = Form(""), + webdav_username: str = Form(""), + webdav_password: str = Form(""), + webdav_reuse_caldav_creds: bool = Form(False), current_password: str = Form(""), new_password: str = Form(""), db: Session = Depends(get_db), @@ -473,6 +476,11 @@ def settings_submit( if calendar_caldav_password.strip(): user.calendar_caldav_password = calendar_caldav_password.strip() + user.webdav_reuse_caldav_creds = webdav_reuse_caldav_creds + user.webdav_username = webdav_username.strip() + if webdav_password.strip(): + user.webdav_password = webdav_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." diff --git a/server/app/static/frame_header.js b/server/app/static/frame_header.js index 2cd2c97..c5b00e3 100644 --- a/server/app/static/frame_header.js +++ b/server/app/static/frame_header.js @@ -71,9 +71,12 @@ }); if (!resp.ok) throw new Error(await apiError(resp)); previous = mode; - showStatus(true, mode === 'calendar' ? 'Switched to Calendar mode.' : 'Switched to Photos mode.'); + var modeLabels = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard' }; + showStatus(true, `Switched to ${modeLabels[mode] || mode} mode.`); var calTab = document.querySelector('.tabs a[href$="/calendar"]'); if (calTab) calTab.classList.toggle('tab-disabled', mode !== 'calendar'); + var wbTab = document.querySelector('.tabs a[href$="/whiteboard"]'); + if (wbTab) wbTab.classList.toggle('tab-disabled', mode !== 'whiteboard'); } catch (e) { sel.value = previous; showStatus(false, e.message); diff --git a/server/app/static/frame_whiteboard.js b/server/app/static/frame_whiteboard.js new file mode 100644 index 0000000..0e3d946 --- /dev/null +++ b/server/app/static/frame_whiteboard.js @@ -0,0 +1,79 @@ +// Whiteboard tab: source URL (owner-gated, see api_frames.py's +// api_whiteboard_source), preview, and take control. window.FRAME_API is +// set by the template. + +const whiteboardForm = document.getElementById('whiteboard-source-form'); +if (whiteboardForm) { + whiteboardForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const url = document.getElementById('whiteboard-url-input').value.trim(); + if (!url) return; + try { + const resp = await fetch(`${window.FRAME_API}/whiteboard-source`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }), + }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, 'Saved. Reload to see the updated source.'); + loadWhiteboardPreview(); + } catch (e) { + showStatus(false, e.message); + } + }); +} + +const whiteboardClearBtn = document.getElementById('whiteboard-source-clear'); +if (whiteboardClearBtn) { + whiteboardClearBtn.addEventListener('click', async () => { + try { + const resp = await fetch(`${window.FRAME_API}/whiteboard-source`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: null }), + }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, 'Cleared. Reload to see the change.'); + loadWhiteboardPreview(); + } catch (e) { + showStatus(false, e.message); + } + }); +} + +function loadWhiteboardPreview() { + document.getElementById('whiteboard-preview').src = `${window.FRAME_API}/preview/whiteboard?_=${Date.now()}`; +} +document.getElementById('whiteboard-preview-refresh').addEventListener('click', loadWhiteboardPreview); +loadWhiteboardPreview(); + +async function takeControl() { + try { + const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, 'You have control now.'); + loadControl(); + } catch (e) { + showStatus(false, e.message); + } +} + +async function loadControl() { + const banner = document.getElementById('control-banner'); + try { + const resp = await fetch(`${window.FRAME_API}/queue`); + if (!resp.ok) return; // unconfigured frame: control still works via 409s + const data = await resp.json(); + if (data.control && !data.control.you) { + banner.style.display = 'flex'; + document.getElementById('control-holder').textContent = data.control.controller + ? `${data.control.controller} currently has control of this frame.` + : 'Nobody has control of this frame yet.'; + } else { + banner.style.display = 'none'; + } + } catch (e) { /* banner is best-effort */ } +} + +document.getElementById('take-control').addEventListener('click', takeControl); +loadControl(); diff --git a/server/app/static/settings.js b/server/app/static/settings.js index f245a8f..b21e98c 100644 --- a/server/app/static/settings.js +++ b/server/app/static/settings.js @@ -3,6 +3,19 @@ // the frame's already-saved Immich creds) -- so this only works after // the CalDAV URL/username/password have been saved once. +// Hides the dedicated WebDAV username/password fields while "reuse my +// CalDAV creds" is checked -- they'd be ignored server-side anyway (see +// routers/common.py's webdav_creds_for), no reason to leave them visibly +// editable and implying they still do something. +const reuseCaldavCreds = document.getElementById('webdav_reuse_caldav_creds'); +if (reuseCaldavCreds) { + const updateWebdavFieldVisibility = () => { + document.getElementById('webdav-creds-fields').style.display = reuseCaldavCreds.checked ? 'none' : ''; + }; + reuseCaldavCreds.addEventListener('change', updateWebdavFieldVisibility); + updateWebdavFieldVisibility(); +} + const discoverBtn = document.getElementById('caldav-discover'); if (discoverBtn) { discoverBtn.addEventListener('click', async () => { diff --git a/server/app/templates/_frame_mode_select.html b/server/app/templates/_frame_mode_select.html index f955961..a0a0577 100644 --- a/server/app/templates/_frame_mode_select.html +++ b/server/app/templates/_frame_mode_select.html @@ -1,4 +1,5 @@ diff --git a/server/app/templates/_frame_tabs.html b/server/app/templates/_frame_tabs.html index 33406a2..684339f 100644 --- a/server/app/templates/_frame_tabs.html +++ b/server/app/templates/_frame_tabs.html @@ -3,5 +3,7 @@ Configuration Calendar + Whiteboard Stats diff --git a/server/app/templates/frame_whiteboard.html b/server/app/templates/frame_whiteboard.html new file mode 100644 index 0000000..7f5cc38 --- /dev/null +++ b/server/app/templates/frame_whiteboard.html @@ -0,0 +1,80 @@ +{% extends "app_base.html" %} + +{% block title %}{{ frame.name or "Frame" }} ยท Whiteboard{% endblock %} +{% block page_title %}{% include "_frame_name_edit.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 %} + +{% block content %} + + + {% if frame.mode != 'whiteboard' %} +
This frame is currently in {{ frame.mode|capitalize }} mode -- + settings below take effect once you switch it to Whiteboard mode + using the selector at the top of the page.
+ {% endif %} + +
+
+
+

Whiteboard source

+

Renders a Nextcloud Whiteboard (or any Excalidraw + scene) fetched over WebDAV -- credentials set up in + Settings.

+ + {% if whiteboard_source %} +

+ Currently showing {{ whiteboard_source.url }} + using {{ whiteboard_source.display_name }}'s + WebDAV account. + +

+ {% else %} +

No whiteboard configured yet.

+ {% endif %} + + {% if viewer_has_webdav_creds %} +
+ +

The direct WebDAV URL + to the specific file -- in Nextcloud's Files app, this is + the file's path under + remote.php/dav/files/<your-username>/.

+ +
+ {% else %} +

Set up WebDAV credentials + in Settings first to point this frame at + one of your own files.

+ {% endif %} +
+
+ +
+
+

Preview

+

How this frame's whiteboard currently renders.

+ Whiteboard preview + +
+
+
+ +
+{% endblock %} + +{% block scripts %} + + + + +{% endblock %} diff --git a/server/app/templates/settings.html b/server/app/templates/settings.html index 6ccfd77..27de27b 100644 --- a/server/app/templates/settings.html +++ b/server/app/templates/settings.html @@ -71,6 +71,32 @@ you're linked to from that frame's Calendar tab, so a frame only shows calendars people have actually chosen to share with it.

+

Whiteboard (WebDAV)

+

Credentials for whiteboard frame mode -- fetching a + specific file (e.g. a Nextcloud Whiteboard board) over WebDAV. + Any WebDAV server works, not just Nextcloud.

+
+ + +
+
+ + +
+

Doesn't show up anywhere by + itself -- point a specific frame's Whiteboard tab at a file URL + using this account.

+

Change password