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 = '
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.
-
+
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 + "/", "", ""))