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")) 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 = [ MIGRATIONS = [
(1, _migration_1), (1, _migration_1),
(2, _migration_2), (2, _migration_2),
@@ -233,6 +240,7 @@ MIGRATIONS = [
(12, _migration_12), (12, _migration_12),
(13, _migration_13), (13, _migration_13),
(14, _migration_14), (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_username: Mapped[str] = mapped_column(String, default="")
webdav_password: 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) 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) created_at: Mapped[float] = mapped_column(Float, default=time.time)
__table_args__ = ( __table_args__ = (
+41 -4
View File
@@ -25,7 +25,7 @@ from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session 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 ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db from ..db import frame_locked, get_db
from ..image_pipeline import ( from ..image_pipeline import (
@@ -52,6 +52,7 @@ from .common import (
list_assets, list_assets,
require_configured, require_configured,
valid_http_url, valid_http_url,
webdav_creds_for,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -650,15 +651,51 @@ def api_whiteboard_source(
return {"status": "saved", "url": body.url} 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") @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 """The same throttled fetch/render cache a live device request would
use, run through the same panel composition/quantization pipeline use, run through the same panel composition/quantization pipeline
(see routers/device.py's _render_whiteboard_mode) -- "how it will (see routers/device.py's _render_whiteboard_mode) -- "how it will
look on the frame" (dithered, letterboxed), not just the raw look on the frame" (dithered, letterboxed), not just the raw
Excalidraw export, same convention as preview/rendered and Excalidraw export, same convention as preview/rendered and
preview/calendar.""" preview/calendar. force=True (the "Refresh now" button, as opposed
png_bytes = get_or_refresh_whiteboard(db, frame) 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 png_bytes is None:
if not frame.whiteboard_url: if not frame.whiteboard_url:
raise HTTPException(400, "No whiteboard configured on this frame yet") 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 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) """Frame-level throttled render cache (calendar_feed.CHECK_INTERVAL_S)
-- None if no whiteboard source is configured, credentials are -- None if no whiteboard source is configured, credentials are
missing (e.g. the owning user unlinked their WebDAV/CalDAV account), missing (e.g. the owning user unlinked their WebDAV/CalDAV account),
or the most recent fetch/render failed and nothing was ever cached or the most recent fetch/render failed and nothing was ever cached
yet. A failure after a previous success keeps showing the last yet. A failure after a previous success keeps showing the last
good render rather than going blank for one bad refresh cycle, same 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: if not frame.whiteboard_url or not frame.whiteboard_user_id:
return None return None
now = time.time() 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): and now - frame.whiteboard_checked_at < calendar_feed.CHECK_INTERVAL_S):
return frame.whiteboard_cached_image return frame.whiteboard_cached_image
+9
View File
@@ -431,6 +431,7 @@ def settings_submit(
webdav_username: str = Form(""), webdav_username: str = Form(""),
webdav_password: str = Form(""), webdav_password: str = Form(""),
webdav_reuse_caldav_creds: bool = Form(False), webdav_reuse_caldav_creds: bool = Form(False),
webdav_base_url: str = Form(""),
current_password: str = Form(""), current_password: str = Form(""),
new_password: str = Form(""), new_password: str = Form(""),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -481,6 +482,14 @@ def settings_submit(
if webdav_password.strip(): if webdav_password.strip():
user.webdav_password = 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 new_password:
if not user.password_hash or not verify_password(current_password, user.password_hash): if not user.password_hash or not verify_password(current_password, user.password_hash):
error = "Current password is wrong -- password not changed." error = "Current password is wrong -- password not changed."
+69 -4
View File
@@ -41,11 +41,76 @@ if (whiteboardClearBtn) {
}); });
} }
function loadWhiteboardPreview() { function loadWhiteboardPreview(force) {
document.getElementById('whiteboard-preview').src = `${window.FRAME_API}/preview/whiteboard?_=${Date.now()}`; 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() { async function takeControl() {
try { try {
+13 -2
View File
@@ -48,7 +48,18 @@
<p class="sub" style="margin-top: 4px;">The direct WebDAV URL <p class="sub" style="margin-top: 4px;">The direct WebDAV URL
to the specific file -- in Nextcloud's Files app, this is to the specific file -- in Nextcloud's Files app, this is
the file's path under 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> <button type="submit">Save</button>
</form> </form>
{% else %} {% else %}
@@ -64,7 +75,7 @@
<h2 class="card-title">Preview</h2> <h2 class="card-title">Preview</h2>
<p class="sub">How this frame's whiteboard currently renders.</p> <p class="sub">How this frame's whiteboard currently renders.</p>
<img class="preview-img" id="whiteboard-preview" alt="Whiteboard preview"> <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> </section>
</div> </div>
</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 %}"> placeholder="{% if user.webdav_password %}(unchanged -- enter a new password to replace){% else %}app password or account password{% endif %}">
</label> </label>
</div> </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 <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 itself -- point a specific frame's Whiteboard tab at a file URL
using this account.</p> using this account.</p>
+76
View File
@@ -14,11 +14,21 @@ philosophy as calendar_feed.py/caldav_client.py.
from __future__ import annotations from __future__ import annotations
from urllib.parse import unquote, urljoin, urlsplit, urlunsplit
from xml.etree import ElementTree
import httpx import httpx
HTTP_TIMEOUT_S = 15.0 HTTP_TIMEOUT_S = 15.0
FETCH_MAX_BYTES = 10 * 1024 * 1024 # a whiteboard scene is KB, not MB -- sanity cap, not an expected size 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): class WebDavError(Exception):
"""Fetch failed -- network, auth, a missing file, or an oversized """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) return b"".join(chunks)
except httpx.HTTPError as e: except httpx.HTTPError as e:
raise WebDavError(str(e)) from 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 + "/", "", ""))