Add a whiteboard file picker and force-refresh
Build and push server image / build-and-push (push) Successful in 1m57s

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.
This commit is contained in:
2026-07-23 18:20:39 -04:00
parent 8556221b08
commit b4ca795003
9 changed files with 237 additions and 13 deletions
+8
View File
@@ -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),
]
+6
View File
@@ -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__ = (
+41 -4
View File
@@ -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")
+6 -3
View File
@@ -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
+9
View File
@@ -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."
+69 -4
View File
@@ -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 = '<li class="sub">Loading...</li>';
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 = '<li class="sub">(empty folder)</li>';
}
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 {
+13 -2
View File
@@ -48,7 +48,18 @@
<p class="sub" style="margin-top: 4px;">The direct WebDAV URL
to the specific file -- in Nextcloud's Files app, this is
the file's path under
<code>remote.php/dav/files/&lt;your-username&gt;/</code>.</p>
<code>remote.php/dav/files/&lt;your-username&gt;/</code>.
<button type="button" class="btn-inline secondary" id="whiteboard-browse-toggle">Browse...</button></p>
<div id="whiteboard-browser" style="display: none; margin-top: 8px; border: 1px solid var(--border-color, #ccc); border-radius: 6px; padding: 8px;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px;">
<button type="button" class="btn-inline secondary" id="whiteboard-browse-up" disabled>Up</button>
<span class="sub" id="whiteboard-browse-current" style="word-break: break-all;"></span>
</div>
<ul id="whiteboard-browse-list" style="list-style: none; margin: 0; padding: 0; max-height: 260px; overflow-y: auto;"></ul>
<p class="sub" id="whiteboard-browse-error" style="display: none; color: var(--error-color, #c00);"></p>
</div>
<button type="submit">Save</button>
</form>
{% else %}
@@ -64,7 +75,7 @@
<h2 class="card-title">Preview</h2>
<p class="sub">How this frame's whiteboard currently renders.</p>
<img class="preview-img" id="whiteboard-preview" alt="Whiteboard preview">
<button type="button" class="secondary" id="whiteboard-preview-refresh">Refresh preview</button>
<button type="button" class="secondary" id="whiteboard-preview-refresh">Refresh now</button>
</section>
</div>
</div>
+9
View File
@@ -93,6 +93,15 @@
placeholder="{% if user.webdav_password %}(unchanged -- enter a new password to replace){% else %}app password or account password{% endif %}">
</label>
</div>
<label>WebDAV browse root (optional)
<input type="text" id="webdav_base_url" name="webdav_base_url" autocomplete="off"
placeholder="https://cloud.example.com/remote.php/dav/files/you/"
value="{{ user.webdav_base_url }}">
</label>
<p class="sub" style="margin-top: 4px;">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.</p>
<p class="sub" style="margin-top: 8px;">Doesn't show up anywhere by
itself -- point a specific frame's Whiteboard tab at a file URL
using this account.</p>
+76
View File
@@ -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 = (
'<?xml version="1.0" encoding="utf-8" ?>'
f'<d:propfind xmlns:d="{_DAV_NS}"><d:prop>'
"<d:displayname/><d:resourcetype/></d:prop></d:propfind>"
)
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 + "/", "", ""))