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 @@
How this frame's whiteboard currently renders.
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'