Add a whiteboard file picker and force-refresh
Build and push server image / build-and-push (push) Successful in 1m57s
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:
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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__ = (
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/<your-username>/</code>.</p>
|
||||
<code>remote.php/dav/files/<your-username>/</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 + "/", "", ""))
|
||||
|
||||
Reference in New Issue
Block a user