From b4ca795003964a8c503f9079b372e99a245da762 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Thu, 23 Jul 2026 18:20:39 -0400 Subject: [PATCH] Add a whiteboard file picker and force-refresh File picker: an optional WebDAV browse root in Settings (User.webdav_base_url) plus a plain-PROPFIND directory listing (webdav_client.list_directory) power a "Browse..." panel on a frame's Whiteboard tab, so a file can be clicked into rather than typing its exact WebDAV URL. Manual URL entry still works unchanged either way. Force-refresh: get_or_refresh_whiteboard takes a force flag that skips the fetch throttle entirely; the Whiteboard tab's refresh button now passes it, so clicking it always re-fetches and re-renders instead of possibly just re-showing the same cached image from within the last ~20 minutes. --- server/app/migration.py | 8 +++ server/app/models.py | 6 ++ server/app/routers/api_frames.py | 45 +++++++++++-- server/app/routers/common.py | 9 ++- server/app/routers/pages.py | 9 +++ server/app/static/frame_whiteboard.js | 73 +++++++++++++++++++-- server/app/templates/frame_whiteboard.html | 15 ++++- server/app/templates/settings.html | 9 +++ server/app/webdav_client.py | 76 ++++++++++++++++++++++ 9 files changed, 237 insertions(+), 13 deletions(-) diff --git a/server/app/migration.py b/server/app/migration.py index 827b465..8583b4a 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -218,6 +218,13 @@ def _migration_14(conn) -> None: conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_cached_image BLOB")) +def _migration_15(conn) -> None: + """Optional starting folder for the whiteboard file-picker (see + models.py's User.webdav_base_url docstring) -- purely a browsing + convenience, never used for actual fetch/render.""" + conn.execute(text("ALTER TABLE users ADD COLUMN webdav_base_url TEXT NOT NULL DEFAULT ''")) + + MIGRATIONS = [ (1, _migration_1), (2, _migration_2), @@ -233,6 +240,7 @@ MIGRATIONS = [ (12, _migration_12), (13, _migration_13), (14, _migration_14), + (15, _migration_15), ] diff --git a/server/app/models.py b/server/app/models.py index 965fea0..fb0ebf2 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -80,6 +80,12 @@ class User(Base): 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) + # Optional starting folder for the file-picker on a frame's Whiteboard + # tab (see routers/api_frames.py's whiteboard-browse) -- purely a + # convenience for browsing to a file rather than typing its full URL. + # Never used for fetching/rendering itself, which always uses the + # frame's own saved whiteboard_url regardless of whether this is set. + webdav_base_url: Mapped[str] = mapped_column(String, default="") created_at: Mapped[float] = mapped_column(Float, default=time.time) __table_args__ = ( diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py index 785e023..5d9048c 100644 --- a/server/app/routers/api_frames.py +++ b/server/app/routers/api_frames.py @@ -25,7 +25,7 @@ from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session -from .. import calendar_render, gitea_releases, photo_queue, quiet_hours, weather +from .. import calendar_render, gitea_releases, photo_queue, quiet_hours, weather, webdav_client from ..auth import require_frame_control, require_frame_view, require_user_api from ..db import frame_locked, get_db from ..image_pipeline import ( @@ -52,6 +52,7 @@ from .common import ( list_assets, require_configured, valid_http_url, + webdav_creds_for, ) logger = logging.getLogger(__name__) @@ -650,15 +651,51 @@ def api_whiteboard_source( return {"status": "saved", "url": body.url} +@router.get("/api/frames/{frame_id}/whiteboard-browse") +def api_whiteboard_browse( + request: Request, + url: str | None = None, + frame: Frame = Depends(require_frame_view), + db: Session = Depends(get_db), +): + """One level of a WebDAV directory listing, using the calling user's + own credentials (never the frame's saved whiteboard_user_id -- this + is "help me find a file in MY account", same person as whoever would + go on to Save it, before that's even happened) -- powers the file + picker on the Whiteboard tab as an alternative to pasting a URL. + `url` omitted/None starts from the user's webdav_base_url (see + models.py's User docstring); passing back a previous response's + `entries[].url` (for a folder) descends into it.""" + user = require_user_api(request, db) + creds = webdav_creds_for(user) + if creds is None: + raise HTTPException(400, "Set up WebDAV credentials in Settings first") + target = url or user.webdav_base_url + if not target: + raise HTTPException(400, "Set a WebDAV browse root in Settings first, or paste the file URL directly") + if not valid_http_url(target): + raise HTTPException(400, "Browse URL must be a plain http:// or https:// URL") + try: + entries = webdav_client.list_directory(target, creds[0], creds[1]) + except webdav_client.WebDavError as e: + raise HTTPException(502, f"Could not browse: {e}") + base = user.webdav_base_url or target + parent_url = webdav_client.parent_directory_url(base, target) + return {"current_url": target, "parent_url": parent_url, "entries": entries} + + @router.get("/api/frames/{frame_id}/preview/whiteboard") -def api_preview_whiteboard(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)): +def api_preview_whiteboard( + force: bool = False, 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) + preview/calendar. force=True (the "Refresh now" button, as opposed + to just reopening this tab) bypasses the fetch throttle.""" + png_bytes = get_or_refresh_whiteboard(db, frame, force=force) if png_bytes is None: if not frame.whiteboard_url: raise HTTPException(400, "No whiteboard configured on this frame yet") diff --git a/server/app/routers/common.py b/server/app/routers/common.py index 9a0578a..233da56 100644 --- a/server/app/routers/common.py +++ b/server/app/routers/common.py @@ -559,18 +559,21 @@ def webdav_creds_for(user: User) -> tuple[str, str] | None: return None -def get_or_refresh_whiteboard(db: Session, frame: Frame) -> bytes | None: +def get_or_refresh_whiteboard(db: Session, frame: Frame, force: bool = False) -> 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.""" + reasoning as get_or_refresh_weather/get_or_refresh_tasks. force=True + (the web UI's "Refresh now" button) skips the throttle entirely -- + unlike a device's normal wake, a person clicking a button means do + it right now, not eventually once the cache goes stale.""" if not frame.whiteboard_url or not frame.whiteboard_user_id: return None now = time.time() - if (frame.whiteboard_cached_image is not None + if (not force and frame.whiteboard_cached_image is not None and now - frame.whiteboard_checked_at < calendar_feed.CHECK_INTERVAL_S): return frame.whiteboard_cached_image diff --git a/server/app/routers/pages.py b/server/app/routers/pages.py index 7264287..9c6c687 100644 --- a/server/app/routers/pages.py +++ b/server/app/routers/pages.py @@ -431,6 +431,7 @@ def settings_submit( webdav_username: str = Form(""), webdav_password: str = Form(""), webdav_reuse_caldav_creds: bool = Form(False), + webdav_base_url: str = Form(""), current_password: str = Form(""), new_password: str = Form(""), db: Session = Depends(get_db), @@ -481,6 +482,14 @@ def settings_submit( if webdav_password.strip(): user.webdav_password = webdav_password.strip() + # Not a secret -- round-trips visibly, so blank is an explicit clear, + # same convention as calendar_ics_url above. + stripped_webdav_base = webdav_base_url.strip() + if stripped_webdav_base and not valid_http_url(stripped_webdav_base): + error = "WebDAV browse root must be a plain http:// or https:// URL." + else: + user.webdav_base_url = stripped_webdav_base + 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_whiteboard.js b/server/app/static/frame_whiteboard.js index 0e3d946..a4d4de4 100644 --- a/server/app/static/frame_whiteboard.js +++ b/server/app/static/frame_whiteboard.js @@ -41,11 +41,76 @@ if (whiteboardClearBtn) { }); } -function loadWhiteboardPreview() { - document.getElementById('whiteboard-preview').src = `${window.FRAME_API}/preview/whiteboard?_=${Date.now()}`; +function loadWhiteboardPreview(force) { + const forceParam = force ? '&force=1' : ''; + document.getElementById('whiteboard-preview').src = `${window.FRAME_API}/preview/whiteboard?_=${Date.now()}${forceParam}`; +} +// Loading the tab shows whatever's already cached (cheap, no refetch); +// the button is the one place that means "no really, go check now" -- +// bypasses the fetch throttle server-side (see api_frames.py's `force`). +document.getElementById('whiteboard-preview-refresh').addEventListener('click', () => loadWhiteboardPreview(true)); +loadWhiteboardPreview(false); + +// --- file picker (Browse...) --- + +const browseToggle = document.getElementById('whiteboard-browse-toggle'); +if (browseToggle) { + const browsePanel = document.getElementById('whiteboard-browser'); + const browseList = document.getElementById('whiteboard-browse-list'); + const browseCurrent = document.getElementById('whiteboard-browse-current'); + const browseUp = document.getElementById('whiteboard-browse-up'); + const browseError = document.getElementById('whiteboard-browse-error'); + const urlInput = document.getElementById('whiteboard-url-input'); + let opened = false; + + async function browseTo(url) { + browseError.style.display = 'none'; + browseList.innerHTML = '
  • Loading...
  • '; + try { + const qs = url ? `?url=${encodeURIComponent(url)}` : ''; + const resp = await fetch(`${window.FRAME_API}/whiteboard-browse${qs}`); + if (!resp.ok) throw new Error(await apiError(resp)); + const data = await resp.json(); + browseCurrent.textContent = data.current_url; + browseUp.disabled = !data.parent_url; + browseUp.onclick = data.parent_url ? () => browseTo(data.parent_url) : null; + browseList.innerHTML = ''; + if (data.entries.length === 0) { + browseList.innerHTML = '
  • (empty folder)
  • '; + } + for (const entry of data.entries) { + const li = document.createElement('li'); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn-inline secondary'; + btn.style.margin = '2px 0'; + btn.textContent = (entry.is_dir ? '📁 ' : '📄 ') + entry.name; + if (entry.is_dir) { + btn.addEventListener('click', () => browseTo(entry.url)); + } else { + btn.addEventListener('click', () => { + urlInput.value = entry.url; + browsePanel.style.display = 'none'; + }); + } + li.appendChild(btn); + browseList.appendChild(li); + } + } catch (e) { + browseList.innerHTML = ''; + browseError.textContent = e.message; + browseError.style.display = 'block'; + } + } + + browseToggle.addEventListener('click', () => { + opened = !opened; + browsePanel.style.display = opened ? 'block' : 'none'; + if (opened && !browseCurrent.textContent) { + browseTo(null); + } + }); } -document.getElementById('whiteboard-preview-refresh').addEventListener('click', loadWhiteboardPreview); -loadWhiteboardPreview(); async function takeControl() { try { diff --git a/server/app/templates/frame_whiteboard.html b/server/app/templates/frame_whiteboard.html index 7f5cc38..092d9e3 100644 --- a/server/app/templates/frame_whiteboard.html +++ b/server/app/templates/frame_whiteboard.html @@ -48,7 +48,18 @@

    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>/.

    + remote.php/dav/files/<your-username>/. +

    + + + {% else %} @@ -64,7 +75,7 @@

    Preview

    How this frame's whiteboard currently renders.

    Whiteboard preview - + diff --git a/server/app/templates/settings.html b/server/app/templates/settings.html index 27de27b..4dc03fa 100644 --- a/server/app/templates/settings.html +++ b/server/app/templates/settings.html @@ -93,6 +93,15 @@ placeholder="{% if user.webdav_password %}(unchanged -- enter a new password to replace){% else %}app password or account password{% endif %}"> + +

    A starting folder for the + file picker on a frame's Whiteboard tab, so you can browse to a + file instead of typing its exact URL. Not required -- you can + still paste a URL directly without setting this.

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

    diff --git a/server/app/webdav_client.py b/server/app/webdav_client.py index e8ffd23..7b08664 100644 --- a/server/app/webdav_client.py +++ b/server/app/webdav_client.py @@ -14,11 +14,21 @@ philosophy as calendar_feed.py/caldav_client.py. from __future__ import annotations +from urllib.parse import unquote, urljoin, urlsplit, urlunsplit +from xml.etree import ElementTree + import httpx HTTP_TIMEOUT_S = 15.0 FETCH_MAX_BYTES = 10 * 1024 * 1024 # a whiteboard scene is KB, not MB -- sanity cap, not an expected size +_DAV_NS = "DAV:" +_PROPFIND_BODY = ( + '' + f'' + "" +) + class WebDavError(Exception): """Fetch failed -- network, auth, a missing file, or an oversized @@ -44,3 +54,69 @@ def fetch_file(url: str, username: str, password: str) -> bytes: return b"".join(chunks) except httpx.HTTPError as e: raise WebDavError(str(e)) from e + + +def list_directory(url: str, username: str, password: str) -> list[dict]: + """One level of a WebDAV directory listing -- name/url/is_dir for + each entry, folders first then alphabetical. Powers the whiteboard + file-picker (browse instead of paste the exact file URL); a plain + Depth-1 PROPFIND for displayname + resourcetype is all that needs, + same "just enough protocol, not a full WebDAV client" scope as + fetch_file above.""" + try: + resp = httpx.request( + "PROPFIND", url, auth=(username, password), timeout=HTTP_TIMEOUT_S, + follow_redirects=True, headers={"Depth": "1", "Content-Type": "application/xml"}, + content=_PROPFIND_BODY, + ) + resp.raise_for_status() + except httpx.HTTPError as e: + raise WebDavError(str(e)) from e + + try: + root = ElementTree.fromstring(resp.content) + except ElementTree.ParseError as e: + raise WebDavError(f"Server returned an unparseable directory listing: {e}") from e + + self_path = urlsplit(url).path.rstrip("/") + entries = [] + for response_el in root.findall(f"{{{_DAV_NS}}}response"): + href_el = response_el.find(f"{{{_DAV_NS}}}href") + if href_el is None or not href_el.text: + continue + href = href_el.text + if urlsplit(href).path.rstrip("/") == self_path: + continue # the listed directory's own entry, not a child + + propstat = response_el.find(f"{{{_DAV_NS}}}propstat") + prop = propstat.find(f"{{{_DAV_NS}}}prop") if propstat is not None else None + resourcetype = prop.find(f"{{{_DAV_NS}}}resourcetype") if prop is not None else None + is_dir = resourcetype is not None and resourcetype.find(f"{{{_DAV_NS}}}collection") is not None + + displayname_el = prop.find(f"{{{_DAV_NS}}}displayname") if prop is not None else None + name = (displayname_el.text or "").strip() if displayname_el is not None else "" + if not name: + name = unquote(href.rstrip("/").rsplit("/", 1)[-1]) + if not name: + continue + + entries.append({"name": name, "url": urljoin(url, href), "is_dir": is_dir}) + + entries.sort(key=lambda e: (not e["is_dir"], e["name"].casefold())) + return entries + + +def parent_directory_url(base_url: str, current_url: str) -> str | None: + """One level up from `current_url`, or None if `current_url` is + already at (or above) `base_url` -- the file-picker's "Up" button + doesn't wander outside the folder the user configured as their + browse root in Settings.""" + base_path = urlsplit(base_url).path.rstrip("/") + current = urlsplit(current_url) + current_path = current.path.rstrip("/") + if len(current_path) <= len(base_path): + return None + parent_path = current_path.rsplit("/", 1)[0] or "/" + if len(parent_path) < len(base_path): + parent_path = base_path + return urlunsplit((current.scheme, current.netloc, parent_path + "/", "", ""))