diff --git a/server/app/calendar_render.py b/server/app/calendar_render.py index ea4fe81..e261f8d 100644 --- a/server/app/calendar_render.py +++ b/server/app/calendar_render.py @@ -63,8 +63,8 @@ def _event_colors(event: dict, owners_seen: list[str], palette_rgb: list | None) what tells the "same event, more than one calendar" case apart from an ordinary single-calendar event at render time -- see _draw_color_bar. Each source's own manually pinned color - (FrameCalendar.color_index -- see routers/api_frames.py's - api_calendar_color) resolves against whichever palette this frame + (FrameCalendar.color_index -- see routers/api_widgets.py's + api_widget_calendar_color) resolves against whichever palette this frame actually renders with, so a pinned "Blue" stays this frame's actual blue; a source with no color pinned falls back to the old auto-cycle-by-owner-name behavior. owners_seen is shared across every @@ -644,7 +644,7 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h: weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit", days: int = 7, layout: str = "horizontal", tasks: list[dict] | None = None, start_offset: int = 0) -> Image.Image: - """`days` (2-10, see routers/api_frames.py's clamp) side-by-side + """`days` (2-10, see routers/api_widgets.py's clamp) side-by-side columns (layout="horizontal", the original fixed-at-7 behavior generalized) or stacked bands (layout="vertical", reusing _draw_agenda_day the same way _build_today_tomorrow does, just for @@ -657,7 +657,7 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h: weekday, "start on the most recent Monday") exactly like before -- otherwise "start of the week" doesn't mean much for an arbitrary day count, so it instead starts `start_offset` days from today (0 = - today, see routers/api_frames.py's api_config_save).""" + today, see routers/api_widgets.py's api_widget_config_save).""" img = Image.new("RGB", (target_w, target_h), BG) draw = ImageDraw.Draw(img) tier = _size_tier(target_w, target_h) diff --git a/server/app/migration.py b/server/app/migration.py index f331611..7c3de2b 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -15,7 +15,7 @@ import secrets import shutil import time -from sqlalchemy import select, text +from sqlalchemy import inspect, select, text from . import config, grid from .db import SessionLocal, engine @@ -378,6 +378,7 @@ def run_migrations() -> None: _ensure_frame_one() _ensure_server_settings() _ensure_widgets_backfilled() + _ensure_frame_calendars_rekeyed() def new_device_token() -> str: @@ -604,3 +605,64 @@ def _ensure_widgets_backfilled() -> None: continue _backfill_frame_widgets(db, frame) db.commit() + + +def _ensure_frame_calendars_rekeyed() -> None: + """Re-keys frame_calendars from frame_id to widget_id -- a frame can + hold more than one independent calendar widget (see the widget + system), each with its own included-calendars set, so "included on + this frame" no longer means anything unambiguous (see + models.FrameCalendar). Existing rows attach to their frame's calendar + widget if it has one; rows for a frame with no calendar widget at all + are dropped -- they were already-dormant settings for content + nothing ever actually displayed (the Calendar tab stayed reachable + and savable even while a frame's old `mode` was "photos"), not real + live configuration. + + Deliberately NOT a numbered migration: this needs each frame's + calendar widget to already exist to know what to re-key against, and + those widget rows aren't created by a schema migration at all -- + they come from _ensure_widgets_backfilled() above, which (like this + function) runs unconditionally after every startup rather than being + tracked by schema_version. Running this as a numbered migration + would execute it *before* that backfill during a real upgrade (the + numbered-migration loop runs first, see run_migrations), silently + dropping every row -- caught by test_migrations.py actually exercising + the raw-SQL upgrade path instead of the fresh-install create_all() + shortcut every other test in that file takes. + + Runs unconditionally after every startup, like _ensure_widgets_ + backfilled; a no-op the moment frame_calendars is already + widget_id-shaped (every fresh install, and any existing install + after its first run past this code) -- SQLite can't ALTER a column's + FK target or drop a column that's part of an index/FK constraint, so + when it isn't a no-op this is the standard SQLite "rebuild" pattern: + create the new-shape table, copy matching rows across (joining to + find each row's calendar widget), drop the old table, rename the new + one into place.""" + inspector = inspect(engine) + columns = {c["name"] for c in inspector.get_columns("frame_calendars")} + if "widget_id" in columns: + return + with engine.begin() as conn: + conn.execute(text( + "CREATE TABLE frame_calendars_new (" + "id INTEGER PRIMARY KEY, " + "widget_id INTEGER NOT NULL REFERENCES widgets(id) ON DELETE CASCADE, " + "user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, " + "calendar_key TEXT NOT NULL, " + "calendar_label TEXT NOT NULL DEFAULT '', " + "included INTEGER NOT NULL DEFAULT 1, " + "color_index INTEGER)" + )) + conn.execute(text( + "INSERT INTO frame_calendars_new (widget_id, user_id, calendar_key, calendar_label, included, color_index) " + "SELECT w.id, fc.user_id, fc.calendar_key, fc.calendar_label, fc.included, fc.color_index " + "FROM frame_calendars fc " + "JOIN widgets w ON w.frame_id = fc.frame_id AND w.widget_type = 'calendar'" + )) + conn.execute(text("DROP TABLE frame_calendars")) + conn.execute(text("ALTER TABLE frame_calendars_new RENAME TO frame_calendars")) + conn.execute(text( + "CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (widget_id, user_id, calendar_key)" + )) diff --git a/server/app/models.py b/server/app/models.py index 4c22a5c..25640d5 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -80,9 +80,10 @@ 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. + # Optional starting folder for the whiteboard dialog's file-picker + # (see routers/api_widgets.py's api_widget_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="") @@ -241,8 +242,8 @@ class Frame(Base): # _draw_tasks). CalDAV only (a task list is a VTODO collection, not # something a plain ICS subscription meaningfully has); source is # one specific linked user's own CalDAV calendar, same - # owner-controls-their-own-data permission split as FrameCalendar -- - # see routers/api_frames.py's api_tasks_source. calendar_tasks_user_id + # owner-controls-their-own-data permission split as FrameCalendar. + # calendar_tasks_user_id # SET NULL on the user's deletion clears the source rather than # leaving a dangling reference (checked_at isn't reset by that, but # the next refresh attempt finds no source and just returns []). @@ -261,9 +262,8 @@ class Frame(Base): # routers/device.py's RENDERERS["whiteboard"]) -- a frame-wide # setting like calendar mode's own frame_calendars source, not # personal data, but still owner-gated the same way: only - # whiteboard_user_id may point the frame at their own account (see - # routers/api_frames.py's api_whiteboard_source), since it's their - # credentials being used to fetch it. -- + # whiteboard_user_id may point the frame at their own account, since + # it's their credentials being used to fetch it. -- whiteboard_user_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) @@ -355,12 +355,18 @@ class FrameCalendar(Base): ANY user linked to the frame may flip included back to False, muting a calendar they'd rather not see on a shared display even though they don't own it. Only the owner may flip it back to True. See - routers/api_frames.py's api_calendar_select.""" + routers/api_widgets.py's api_widget_calendar_select. + + Keyed by widget_id, not frame_id -- a frame can hold more than one + independent calendar widget (see Widget), each with its own included- + calendars set; "included on this frame" stopped being unambiguous + the moment that became possible (see migration.py's _migration_17, + which re-keyed this table).""" __tablename__ = "frame_calendars" id: Mapped[int] = mapped_column(primary_key=True) - frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE")) + widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE")) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) calendar_key: Mapped[str] = mapped_column(String) # Snapshot label for display -- so the list still reads sensibly even @@ -372,11 +378,11 @@ class FrameCalendar(Base): # text/background) pinning this calendar's events to a specific # panel color rather than calendar_render.py's old owner-name # auto-cycle. NULL keeps the auto-cycle behavior. Only the calendar's - # owner may set this -- see routers/api_frames.py's api_calendar_color. + # owner may set this -- see routers/api_widgets.py's api_widget_calendar_color. color_index: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) __table_args__ = ( - Index("ix_frame_calendars_unique", "frame_id", "user_id", "calendar_key", unique=True), + Index("ix_frame_calendars_unique", "widget_id", "user_id", "calendar_key", unique=True), ) @@ -445,9 +451,8 @@ class CalendarWidgetConfig(Base): minus calendar_photo_inlay (dropped: arbitrary widget placement subsumes what a fixed 50/50 inlay split did, so it's not a special case anymore, just place a photo widget alongside). "Included - calendars" stays on FrameCalendar (frame_id-keyed for now; re-keyed - to widget_id in a later phase once more than one calendar widget per - frame is actually supported end to end).""" + calendars" is its own table (FrameCalendar), widget_id-keyed so each + calendar widget on a frame has its own independent set.""" __tablename__ = "calendar_widget_configs" diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py index e60c092..163e11b 100644 --- a/server/app/routers/api_frames.py +++ b/server/app/routers/api_frames.py @@ -1,16 +1,17 @@ -"""The web UI's JSON API, namespaced per frame: /api/frames/{id}/... +"""The web UI's JSON API for frame-wide settings: /api/frames/{id}/... +Per-widget settings (album, calendar view/inclusion, whiteboard source, +etc.) live in api_widgets.py instead, under /api/frames/{id}/widgets/ +{widget_id}/... -- split out once a frame could hold more than one +widget of the same type. Auth: session-only (require_frame_view for reads, require_frame_control for mutations -- the "take control" soft lock). The limited manage-QR surface lives separately under /api/m/ (routers/manage.py), and device traffic under /frame/* (routers/device.py). -Config saves are PARTIAL updates: each page's form posts only its own -fields (the old single Settings form split across the Photos and -Configuration tabs), so every field is optional and only provided ones -are touched. Checkboxes are sent explicitly as "true"/"false" strings by -the page JS -- an absent field means "not this form's field", never -"unchecked". +Config saves are PARTIAL updates: only provided fields are touched. +Checkboxes are sent explicitly as "true"/"false" strings by the page JS +-- an absent field means "not this form's field", never "unchecked". """ from __future__ import annotations @@ -20,48 +21,16 @@ import time import httpx from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile -from fastapi.responses import Response -from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session -from .. import calendar_render, gitea_releases, grid, photo_queue, quiet_hours, weather, webdav_client +from .. import gitea_releases, grid, quiet_hours from ..auth import require_frame_control, require_frame_view, require_user_api -from ..db import frame_locked, get_db, widget_locked -from ..image_pipeline import ( - DEFAULT_DISPLAY_MODE, - DISPLAY_MODES, - PALETTE_LABELS, - hex_to_rgb, - render_preview_png, -) +from ..db import frame_locked, get_db +from ..image_pipeline import PALETTE_LABELS, hex_to_rgb from ..firmware import firmware_path, parse_app_version -from ..models import ( - BatteryLog, - CalendarWidgetConfig, - Frame, - FrameCalendar, - PhotoWidgetConfig, - WhiteboardWidgetConfig, - Widget, -) -from .common import ( - OVERDUE_FACTOR, - battery_estimate_s, - calendar_sources_for_frame, - fetch_source_and_faces, - get_or_refresh_calendar_events_for_widget, - get_or_refresh_tasks_for_widget, - get_or_refresh_weather_for_widget, - get_or_refresh_whiteboard_for_widget, - immich_client_for, - immich_creds, - list_assets, - photo_widget_config_or_404, - valid_http_url, - webdav_creds_for, - widget_of_type, -) +from ..models import BatteryLog, Frame, Widget +from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url logger = logging.getLogger(__name__) @@ -69,8 +38,6 @@ router = APIRouter() MIN_REFRESH_INTERVAL_S = 60 MAX_REFRESH_INTERVAL_S = 86400 -MIN_QUEUE_TARGET_LEN = 5 -MAX_QUEUE_TARGET_LEN = 5000 ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped") @@ -116,11 +83,7 @@ def api_albums(frame: Frame = Depends(require_frame_view)): @router.post("/api/frames/{frame_id}/config") def api_config_save( name: str | None = Form(None), - album_id: str | None = Form(None), - order: str | None = Form(None), refresh_interval_s: int | None = Form(None), - display_mode: str | None = Form(None), - queue_target_len: int | None = Form(None), orientation: str | None = Form(None), quiet_hours_enabled: bool | None = Form(None), quiet_hours_start: str | None = Form(None), @@ -134,27 +97,18 @@ def api_config_save( color_boost: float | None = Form(None), contrast_boost: float | None = Form(None), dither_strength: float | None = Form(None), - calendar_view: str | None = Form(None), - calendar_week_start: int | None = Form(None), - calendar_week_days: int | None = Form(None), - calendar_week_layout: str | None = Form(None), - calendar_week_start_offset: int | None = Form(None), - calendar_weather_enabled: bool | None = Form(None), - calendar_weather_units: str | None = Form(None), - calendar_tasks_enabled: bool | None = Form(None), frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db), ): - """Partial update, split across up to three sequential lock spans -- - frame-level settings, the frame's photo widget, the frame's calendar - widget -- rather than one, now that those settings live on separate - rows (see models.Widget's per-type extension tables). Never nested - (see db.widget_locked's own docstring on why that would deadlock). - - `mode` and `calendar_photo_inlay` are no longer accepted here: mode - no longer governs anything (a frame's widgets do), and photo inlay - has no widget-system equivalent (place an independent photo widget - alongside instead -- see CalendarWidgetConfig's docstring). Both are + """Partial update of frame-wide settings only -- per-widget settings + (album, calendar view/inclusion, whiteboard source, etc.) live on + routers/api_widgets.py's /widgets/{widget_id}/... endpoints instead, + since a frame can hold more than one widget of the same type and + "the frame's calendar settings" stopped being unambiguous the moment + that became possible. `mode` and `calendar_photo_inlay` are no + longer accepted here either: mode no longer governs anything (a + frame's widgets do), and photo inlay has no widget-system equivalent + (place an independent photo widget alongside instead). All three are harmless no-ops if an old cached page still POSTs them -- FastAPI silently ignores form fields with no matching parameter. @@ -162,14 +116,7 @@ def api_config_save( _reset_widget_layout_for_new_orientation) -- widget placement is grid-cell-relative to the panel's long/short axis, which swaps on a landscape<->portrait change, so an old placement is usually not just - visually wrong but literally out of bounds on the new grid. - - Until the widget-placement UI (a later phase) lets a frame have more - than one widget of a type, "the photo widget" / "the calendar - widget" below unambiguously means the frame's single auto-migrated - one (see widget_of_type) -- these fields are silent no-ops if the - frame doesn't have one yet, same posture as any other partial update - whose target doesn't exist.""" + visually wrong but literally out of bounds on the new grid.""" with frame_locked(db, frame.id) as cfg: if name is not None: cfg.name = name.strip()[:64] or cfg.name @@ -219,72 +166,6 @@ def api_config_save( cfg.dither_strength = max(0.0, min(1.0, dither_strength)) cfg.stats_config_saves += 1 - photo_widget = widget_of_type(db, frame, "photos") - photo_fields_present = any(v is not None for v in (album_id, order, display_mode, queue_target_len)) - if photo_widget and photo_fields_present: - with widget_locked(db, frame.id, photo_widget.id) as (_, _, pcfg): - if album_id is not None and album_id != pcfg.album_id: - # A newly selected album starts clean -- the old current - # photo and queue don't mean anything in the new album's - # context. - pcfg.current_asset_id = "" - pcfg.current_asset_set_at = 0.0 - pcfg.queue = [] - pcfg.queue_cursor = 0 - pcfg.history = [] - pcfg.excluded_asset_ids = [] - pcfg.album_id = album_id - if order is not None: - pcfg.order = order if order in ("sequential", "shuffle") else "sequential" - if display_mode is not None: - pcfg.display_mode = display_mode if display_mode in DISPLAY_MODES else DEFAULT_DISPLAY_MODE - if queue_target_len is not None: - pcfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len)) - - calendar_widget = widget_of_type(db, frame, "calendar") - calendar_fields_present = any(v is not None for v in ( - calendar_view, calendar_week_start, calendar_week_days, calendar_week_layout, - calendar_week_start_offset, calendar_weather_enabled, calendar_weather_units, calendar_tasks_enabled, - )) - if calendar_widget and calendar_fields_present: - with widget_locked(db, frame.id, calendar_widget.id) as (_, _, ccfg): - if calendar_view is not None: - new_view = calendar_view if calendar_view in calendar_render.CALENDAR_VIEWS else "agenda" - if new_view != ccfg.view: - # A stale offset means something different in a - # different view's units (days vs. weeks vs. months). - ccfg.browse_offset = 0 - ccfg.view = new_view - if calendar_week_start is not None: - ccfg.week_start = max(0, min(6, calendar_week_start)) - if calendar_week_days is not None: - new_days = max(2, min(10, calendar_week_days)) - if new_days != ccfg.week_days: - # A stale offset counts a different-sized page under - # the old day count. - ccfg.browse_offset = 0 - ccfg.week_days = new_days - if calendar_week_layout is not None: - ccfg.week_layout = ( - calendar_week_layout if calendar_week_layout in ("horizontal", "vertical") else "horizontal" - ) - if calendar_week_start_offset is not None: - new_offset = max(-30, min(30, calendar_week_start_offset)) - if new_offset != ccfg.week_start_offset: - ccfg.browse_offset = 0 - ccfg.week_start_offset = new_offset - if calendar_weather_enabled is not None: - ccfg.weather_enabled = calendar_weather_enabled - if calendar_weather_units is not None and calendar_weather_units in ("fahrenheit", "celsius"): - if calendar_weather_units != ccfg.weather_units: - # Cached forecasts are in the old unit -- force a - # refetch rather than showing stale numbers under a - # new unit label. - ccfg.weather_checked_at = 0.0 - ccfg.weather_units = calendar_weather_units - if calendar_tasks_enabled is not None: - ccfg.tasks_enabled = calendar_tasks_enabled - return {"status": "saved"} @@ -317,62 +198,35 @@ def api_stats(frame: Frame = Depends(require_frame_view)): } -@router.get("/api/frames/{frame_id}/queue") -def api_queue( +@router.get("/api/frames/{frame_id}/status") +def api_status( request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db) ): + """Device liveness + control-lock info -- frame-level facts (battery, + last-seen, firmware, who has control), not tied to any particular + widget. Powers static/device_status_bar.js, shown on every per-frame + page regardless of which widgets that frame has. Used to piggyback on + the photo queue endpoint (back when a frame had at most one widget, + always photos-shaped); split out once that stopped being true, so the + status bar isn't blank on a frame with no photo widget.""" user = require_user_api(request, db) - photo_widget, pcfg = photo_widget_config_or_404(db, frame) - - client = immich_client_for(frame) - assets = list_assets(client, pcfg.album_id) - - with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg): - photo_queue.get_current(locked_pcfg, assets, locked_frame, - in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame)) - photo_queue.sync_queue_length(locked_pcfg, assets) - snapshot = { - "current_asset_id": locked_pcfg.current_asset_id, - "queue": list(locked_pcfg.queue), - "last_seen": locked_frame.last_seen, - "overdue_gap": quiet_hours.max_expected_gap_s(locked_frame) * OVERDUE_FACTOR, - "firmware_version": locked_frame.device_firmware_version, - "firmware_available": locked_frame.firmware_available_version, - "battery_percent": locked_frame.battery_percent, - "battery_as_of": locked_frame.battery_as_of, - "battery_estimate_s": battery_estimate_s(locked_frame, db), - "controller_id": locked_frame.controlled_by_user_id, - "controller": ( - (locked_frame.controlled_by.display_name or locked_frame.controlled_by.username) - if locked_frame.controlled_by - else None - ), - } - - def entry(asset_id: str) -> dict: - return {"id": asset_id, "thumbnail_url": f"/api/frames/{frame.id}/thumbnail/{asset_id}"} - now = time.time() + overdue_gap = quiet_hours.max_expected_gap_s(frame) * OVERDUE_FACTOR return { - "current": entry(snapshot["current_asset_id"]) if snapshot["current_asset_id"] else None, - "upcoming": [entry(asset_id) for asset_id in snapshot["queue"]], "control": { - "controller": snapshot["controller"], - "you": snapshot["controller_id"] == user.id, + "controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None, + "you": frame.controlled_by_user_id == user.id, }, "device": { - "last_seen": snapshot["last_seen"] or None, - "overdue": bool( - snapshot["last_seen"] and now - snapshot["last_seen"] > snapshot["overdue_gap"] - ), - "firmware_version": snapshot["firmware_version"] or None, - "firmware_available": snapshot["firmware_available"] or None, + "last_seen": frame.last_seen or None, + "overdue": bool(frame.last_seen and now - frame.last_seen > overdue_gap), + "firmware_version": frame.device_firmware_version or None, + "firmware_available": frame.firmware_available_version or None, "battery": ( - {"percent": snapshot["battery_percent"], "as_of": snapshot["battery_as_of"]} - if snapshot["battery_percent"] >= 0 - else None + {"percent": frame.battery_percent, "as_of": frame.battery_as_of} + if frame.battery_percent >= 0 else None ), - "battery_estimate_s": snapshot["battery_estimate_s"], + "battery_estimate_s": battery_estimate_s(frame, db), }, } @@ -387,461 +241,6 @@ def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = De return {"log": [[ts, percent] for ts, percent in rows]} -class QueueReorderRequest(BaseModel): - queue: list[str] - - -@router.post("/api/frames/{frame_id}/queue/reorder") -def api_queue_reorder( - body: QueueReorderRequest, - frame: Frame = Depends(require_frame_control), - db: Session = Depends(get_db), -): - """Applies the client's requested order, tolerating drift between the - browser's last-fetched snapshot and the server's current queue (e.g. - a top-up/trim landed in between) instead of hard-rejecting: any ID - the client sent that's no longer actually queued is dropped, and any - ID the server has that the client didn't know about is appended - rather than lost.""" - photo_widget = widget_of_type(db, frame, "photos") - if photo_widget is None: - raise HTTPException(404, "No photo widget on this frame") - with widget_locked(db, frame.id, photo_widget.id) as (_, _, cfg): - current_set = set(cfg.queue) - reordered = [asset_id for asset_id in body.queue if asset_id in current_set] - reordered += [asset_id for asset_id in cfg.queue if asset_id not in set(reordered)] - cfg.queue = reordered - return {"status": "saved"} - - -class QueuePromoteRequest(BaseModel): - asset_id: str - - -@router.post("/api/frames/{frame_id}/queue/promote") -def api_queue_promote( - body: QueuePromoteRequest, - frame: Frame = Depends(require_frame_control), - db: Session = Depends(get_db), -): - """Moves a single photo to the front of the queue -- "Show next". - Unlike reorder, doesn't depend on the client knowing the queue's - exact current order, so it can't fail from staleness.""" - photo_widget = widget_of_type(db, frame, "photos") - if photo_widget is None: - raise HTTPException(404, "No photo widget on this frame") - with widget_locked(db, frame.id, photo_widget.id) as (_, _, cfg): - if body.asset_id not in cfg.queue: - raise HTTPException(400, "That photo is no longer in the upcoming queue") - cfg.queue = [body.asset_id] + [asset_id for asset_id in cfg.queue if asset_id != body.asset_id] - return {"status": "saved"} - - -class QueueRemoveRequest(BaseModel): - asset_id: str - - -@router.post("/api/frames/{frame_id}/queue/remove") -def api_queue_remove( - body: QueueRemoveRequest, - frame: Frame = Depends(require_frame_control), - db: Session = Depends(get_db), -): - """Permanently removes a photo from this frame's rotation. Does NOT - touch Immich or the album itself; see photo_queue.remove_from_rotation().""" - photo_widget, pcfg = photo_widget_config_or_404(db, frame) - client = immich_client_for(frame) - assets = list_assets(client, pcfg.album_id) - - with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg): - photo_queue.remove_from_rotation(locked_pcfg, assets, body.asset_id, locked_frame) - return {"status": "removed"} - - -@router.get("/api/frames/{frame_id}/thumbnail/{asset_id}") -def api_thumbnail( - asset_id: str, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db) -): - """Scoped to what this frame is actually showing/queuing -- a user - merely linked to view this frame shouldn't be able to pull thumbnails - for arbitrary asset ids in the owner's Immich library, only the - frame's own curated album. Same rule device.frame_share and - manage.manage_thumbnail already enforce.""" - _, pcfg = photo_widget_config_or_404(db, frame) - if asset_id != pcfg.current_asset_id and asset_id not in pcfg.queue: - raise HTTPException(404, "Not on this frame") - client = immich_client_for(frame) - try: - content, content_type = client.download_asset_thumbnail(asset_id) - except httpx.HTTPError as e: - raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e - return Response(content=content, media_type=content_type) - - -def _current_asset_id(frame: Frame, db: Session) -> tuple[str, PhotoWidgetConfig]: - """Same idempotent get_current() dance /api/frames/{id}/queue uses -- - picks a current photo if none is set yet, otherwise just reads it, - never advances early. Returns the photo widget's own config - alongside the asset id, since callers (api_preview_rendered) also - need its display_mode.""" - photo_widget, pcfg = photo_widget_config_or_404(db, frame) - client = immich_client_for(frame) - assets = list_assets(client, pcfg.album_id) - with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg): - photo_queue.get_current(locked_pcfg, assets, locked_frame, - in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame)) - asset_id = locked_pcfg.current_asset_id - if not asset_id: - raise HTTPException(404, "No current photo") - return asset_id, pcfg - - -@router.get("/api/frames/{frame_id}/preview/original") -def api_preview_original(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)): - """The Immich preview image behind the currently-displayed photo, - unprocessed -- the "now displaying" side of the Configuration tab's - before/after comparison.""" - asset_id, _ = _current_asset_id(frame, db) - client = immich_client_for(frame) - try: - jpeg_bytes = client.download_asset_preview(asset_id) - except httpx.HTTPError as e: - raise HTTPException(502, f"Could not download asset from Immich: {e}") from e - return Response(content=jpeg_bytes, media_type="image/jpeg") - - -@router.get("/api/frames/{frame_id}/preview/rendered") -def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)): - """The same photo run through this frame's actual saved rendering - pipeline (display mode, palette, color/contrast/dithering) and - exported as a PNG -- the "how it will look on the frame" side of the - comparison. Not a live preview of unsaved slider values; reflects - whatever's currently saved. display_mode comes from the photo - widget's own config now (palette/color/contrast/dither stay - frame-level -- one physical panel, one set of those).""" - asset_id, pcfg = _current_asset_id(frame, db) - client = immich_client_for(frame) - source, faces = fetch_source_and_faces(client, pcfg.display_mode, asset_id) - png = render_preview_png( - source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb, - display_mode=pcfg.display_mode, color_boost=frame.color_boost, - contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength, - ) - return Response(content=png, media_type="image/png") - - -class CalendarSelectRequest(BaseModel): - user_id: int - calendar_key: str - calendar_label: str = "" - included: bool - - -@router.post("/api/frames/{frame_id}/calendar-select") -def api_calendar_select( - body: CalendarSelectRequest, - request: Request, - frame: Frame = Depends(require_frame_view), # view access only -- NOT require_frame_control - db: Session = Depends(get_db), -): - """Include/exclude one calendar (calendar_key "ics" or - "caldav:", see FrameCalendar) on this frame. Deliberately not - require_frame_control: adding your own calendar, or muting anyone's - (including your own), is each viewer's own call, not something a - frame's controller manages on someone else's behalf. The one-sided - permission split lives here: turning a calendar ON requires being its - owner (nobody can add someone else's calendar to a shared frame for - them); turning one OFF only requires being linked to the frame at - all, so anyone sharing the display can mute a calendar they'd rather - not see there even if they don't own it.""" - user = require_user_api(request, db) - if body.included and body.user_id != user.id: - raise HTTPException(403, "Only a calendar's owner can add it to a frame") - row = db.execute( - select(FrameCalendar).where( - FrameCalendar.frame_id == frame.id, - FrameCalendar.user_id == body.user_id, - FrameCalendar.calendar_key == body.calendar_key, - ) - ).scalar_one_or_none() - if row is None: - if not body.included: - raise HTTPException(404, "Not currently included on this frame") - row = FrameCalendar(frame_id=frame.id, user_id=body.user_id, calendar_key=body.calendar_key) - db.add(row) - row.included = body.included - if body.calendar_label: - row.calendar_label = body.calendar_label - # Force the frame's calendar widget's merged cache to pick up the - # change promptly rather than waiting out the throttle. - calendar_widget = widget_of_type(db, frame, "calendar") - if calendar_widget is not None: - db.get(CalendarWidgetConfig, calendar_widget.id).checked_at = 0.0 - db.commit() - return {"status": "saved", "included": row.included} - - -CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE_LABELS - - -class CalendarColorRequest(BaseModel): - calendar_key: str - color_index: int | None # None clears the pin, reverting to auto-cycle - - -@router.post("/api/frames/{frame_id}/calendar-color") -def api_calendar_color( - body: CalendarColorRequest, - request: Request, - frame: Frame = Depends(require_frame_view), - db: Session = Depends(get_db), -): - """Pins a specific panel color to one of your own included calendars - (models.FrameCalendar.color_index) -- always owner-only, unlike - calendar-select's included=False, since recoloring someone else's - calendar isn't the same kind of "I'd rather not see this" veto as - muting it. None clears the pin, reverting calendar_render.py to its - old auto-cycle-by-owner-name behavior for this calendar.""" - user = require_user_api(request, db) - if body.color_index is not None and body.color_index not in CALENDAR_COLOR_INDEX_RANGE: - raise HTTPException(400, "color_index must be 2-5 (the panel's non-black/white colors)") - row = db.execute( - select(FrameCalendar).where( - FrameCalendar.frame_id == frame.id, - FrameCalendar.user_id == user.id, - FrameCalendar.calendar_key == body.calendar_key, - ) - ).scalar_one_or_none() - if row is None: - raise HTTPException(404, "Not included on this frame") - row.color_index = body.color_index - calendar_widget = widget_of_type(db, frame, "calendar") - if calendar_widget is not None: - db.get(CalendarWidgetConfig, calendar_widget.id).checked_at = 0.0 - db.commit() - return {"status": "saved", "color_index": row.color_index} - - -@router.get("/api/frames/{frame_id}/preview/calendar") -def api_preview_calendar(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)): - """The same merged, cached event set a live device render would use - -- not a live preview of an unsaved calendar_view choice, same - "reflects what's currently saved" convention as preview/rendered. - No photo_inlay parameter anymore -- that's not a widget-system - concept (see CalendarWidgetConfig's docstring); place an independent - photo widget alongside instead.""" - calendar_widget = widget_of_type(db, frame, "calendar") - if calendar_widget is None: - raise HTTPException(400, "No calendar widget on this frame yet") - if not calendar_sources_for_frame(db, frame): - raise HTTPException(400, "No calendars included on this frame yet") - ccfg = db.get(CalendarWidgetConfig, calendar_widget.id) - events, summary = get_or_refresh_calendar_events_for_widget(db, frame, calendar_widget) - weather_cities = get_or_refresh_weather_for_widget(db, frame, calendar_widget) if ccfg.weather_enabled else None - tasks = ( - get_or_refresh_tasks_for_widget(db, frame, calendar_widget) - if (ccfg.view == "week" and ccfg.tasks_enabled) else None - ) - png = calendar_render.render_calendar_preview_png( - events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation, - palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary, - week_start=ccfg.week_start, - weather_cities=weather_cities, weather_units=ccfg.weather_units, - week_days=ccfg.week_days, week_layout=ccfg.week_layout, tasks=tasks, - week_start_offset=ccfg.week_start_offset, - ) - return Response(content=png, media_type="image/png") - - -class TasksSourceRequest(BaseModel): - calendar_key: str | None # None clears the source - - -@router.post("/api/frames/{frame_id}/tasks-source") -def api_tasks_source( - body: TasksSourceRequest, - request: Request, - frame: Frame = Depends(require_frame_view), - db: Session = Depends(get_db), -): - """Points this frame's week-view task list at one of the calling - user's own CalDAV calendars -- same owner-controls-their-own-data - permission split as calendar-select's included=True, since this is - volunteering personal calendar data, not a frame-wide display - setting a controller should get to pick on someone else's behalf. - None clears the source; clearing (unlike setting) isn't - ownership-gated -- like muting a shared calendar, anyone linked to - the frame can turn off a task list they'd rather not see, but only - its owner can point the frame at one of their calendars to begin - with.""" - user = require_user_api(request, db) - calendar_widget = widget_of_type(db, frame, "calendar") - if calendar_widget is None: - raise HTTPException(404, "No calendar widget on this frame yet") - with widget_locked(db, frame.id, calendar_widget.id) as (_, _, cfg): - if body.calendar_key is None: - cfg.tasks_user_id = None - cfg.tasks_calendar_key = None - cfg.tasks_cached = None - else: - cfg.tasks_user_id = user.id - cfg.tasks_calendar_key = body.calendar_key - cfg.tasks_checked_at = 0.0 # pick up the change promptly - return {"status": "saved", "calendar_key": body.calendar_key} - - -class WhiteboardSourceRequest(BaseModel): - url: str | None # None clears the source - - -@router.post("/api/frames/{frame_id}/whiteboard-source") -def api_whiteboard_source( - body: WhiteboardSourceRequest, - request: Request, - frame: Frame = Depends(require_frame_view), - db: Session = Depends(get_db), -): - """Points this frame's whiteboard at one of the calling user's own - WebDAV (or reused-CalDAV, see User.webdav_reuse_caldav_creds) - credentials -- same owner-controls-their-own-data permission split - as api_tasks_source: only the account owner can set the frame to use - it, but anyone linked to the frame can clear it, same as muting a - shared calendar.""" - user = require_user_api(request, db) - whiteboard_widget = widget_of_type(db, frame, "whiteboard") - if whiteboard_widget is None: - raise HTTPException(404, "No whiteboard widget on this frame yet") - with widget_locked(db, frame.id, whiteboard_widget.id) as (_, _, cfg): - if body.url is None: - cfg.user_id = None - cfg.url = "" - cfg.cached_image = None - else: - stripped = body.url.strip() - if not valid_http_url(stripped): - raise HTTPException(400, "Whiteboard URL must be a plain http:// or https:// URL") - cfg.user_id = user.id - cfg.url = stripped - cfg.checked_at = 0.0 # pick up the change promptly - 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( - 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. force=True (the "Refresh now" button, as opposed - to just reopening this tab) bypasses the fetch throttle.""" - whiteboard_widget = widget_of_type(db, frame, "whiteboard") - if whiteboard_widget is None: - raise HTTPException(400, "No whiteboard widget on this frame yet") - wcfg = db.get(WhiteboardWidgetConfig, whiteboard_widget.id) - png_bytes = get_or_refresh_whiteboard_for_widget(db, frame, whiteboard_widget, force=force) - if png_bytes is None: - if not wcfg.url: - raise HTTPException(400, "No whiteboard configured on this frame yet") - raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials") - import io - - from PIL import Image - - source = Image.open(io.BytesIO(png_bytes)).convert("RGB") - png = render_preview_png( - source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb, - display_mode="letterbox", - ) - return Response(content=png, media_type="image/png") - - -class WeatherCityAddRequest(BaseModel): - name: str - - -@router.post("/api/frames/{frame_id}/weather-cities/add") -def api_weather_city_add( - body: WeatherCityAddRequest, - frame: Frame = Depends(require_frame_control), - db: Session = Depends(get_db), -): - """Geocodes a free-text city name (e.g. "Portland, OR") and adds it - to this frame's weather strip -- a frame-wide display setting (like - calendar_view), not personal data, so this is gated the same way as - api_config_save rather than the calendar-select owner/mute split.""" - calendar_widget = widget_of_type(db, frame, "calendar") - if calendar_widget is None: - raise HTTPException(404, "No calendar widget on this frame yet") - try: - city = weather.geocode_city(body.name) - except weather.WeatherFetchError as e: - raise HTTPException(400, str(e)) from e - with widget_locked(db, frame.id, calendar_widget.id) as (_, _, cfg): - cities = list(cfg.weather_cities or []) - if any(c["label"] == city["label"] for c in cities): - raise HTTPException(400, f"{city['label']} is already on this frame's list") - cities.append(city) - cfg.weather_cities = cities - cfg.weather_checked_at = 0.0 # pick up the new city promptly - return {"status": "saved", "city": city} - - -class WeatherCityRemoveRequest(BaseModel): - label: str - - -@router.post("/api/frames/{frame_id}/weather-cities/remove") -def api_weather_city_remove( - body: WeatherCityRemoveRequest, - frame: Frame = Depends(require_frame_control), - db: Session = Depends(get_db), -): - calendar_widget = widget_of_type(db, frame, "calendar") - if calendar_widget is None: - raise HTTPException(404, "No calendar widget on this frame yet") - with widget_locked(db, frame.id, calendar_widget.id) as (_, _, cfg): - cities = [c for c in (cfg.weather_cities or []) if c["label"] != body.label] - cfg.weather_cities = cities - cached = [c for c in (cfg.weather_cached or []) if c["label"] != body.label] - cfg.weather_cached = cached - return {"status": "saved"} - @router.post("/api/frames/{frame_id}/firmware") def api_firmware_upload( diff --git a/server/app/routers/api_widgets.py b/server/app/routers/api_widgets.py index 0b1733a..1ff548d 100644 --- a/server/app/routers/api_widgets.py +++ b/server/app/routers/api_widgets.py @@ -1,33 +1,113 @@ -"""CRUD + grid placement for a frame's widgets (see models.Widget) -- -backs the Layout tab's placement canvas (static/frame_widget_canvas.js). -Every mutation re-validates bounds/minimum footprint/no-overlap +"""Everything scoped to one specific widget rather than "the frame": +placement CRUD (backing the Layout tab's canvas, static/frame_layout.js) +plus every setting/action that used to assume a frame had at most one +widget of a given type -- photo queue, calendar inclusion/color/tasks, +whiteboard source, and their preview endpoints. Split out of +api_frames.py (which keeps frame-wide settings: orientation, quiet +hours, palette, firmware, stats) once a frame could hold more than one +widget of the same type, at which point "the frame's calendar settings" +stopped meaning anything unambiguous. + +Placement mutations re-validate bounds/minimum footprint/no-overlap server-side regardless of what the client already checked -- the client's own checks are UX, not the source of truth (this project's usual posture, e.g. api_frames.py's own field clamps).""" from __future__ import annotations +import io import time -from fastapi import APIRouter, Depends, HTTPException, Request +import httpx +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import Response +from PIL import Image from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.orm import Session -from .. import grid +from .. import calendar_render, grid, 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 ..models import Frame, WIDGET_CONFIG_MODELS, Widget +from ..db import frame_locked, get_db, widget_locked +from ..image_pipeline import DEFAULT_DISPLAY_MODE, DISPLAY_MODES, render_preview_png +from ..models import ( + CalendarWidgetConfig, + Frame, + FrameCalendar, + PhotoWidgetConfig, + WhiteboardWidgetConfig, + WIDGET_CONFIG_MODELS, + Widget, +) from ..widgets import WIDGET_TYPES +from .common import ( + calendar_sources_for_widget, + fetch_source_and_faces, + get_or_refresh_calendar_events_for_widget, + get_or_refresh_tasks_for_widget, + get_or_refresh_weather_for_widget, + get_or_refresh_whiteboard_for_widget, + immich_client_for, + immich_creds, + list_assets, + valid_http_url, + webdav_creds_for, +) router = APIRouter() +MIN_QUEUE_TARGET_LEN = 5 +MAX_QUEUE_TARGET_LEN = 5000 +CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE_LABELS + def _widget_dict(w: Widget) -> dict: return {"id": w.id, "widget_type": w.widget_type, "x": w.x, "y": w.y, "w": w.w, "h": w.h, "sort_order": w.sort_order} +def require_widget_view( + widget_id: int, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db) +) -> tuple[Frame, Widget]: + """View-only widget dependency -- same 404-not-403 posture as + require_frame_view for a widget id that doesn't belong to this + frame (or doesn't exist at all).""" + widget = db.get(Widget, widget_id) + if widget is None or widget.frame_id != frame.id: + raise HTTPException(404, "No such widget") + return frame, widget + + +def require_widget_control( + widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db) +) -> tuple[Frame, Widget]: + """Same as require_widget_view, but behind the frame's "take control" + soft lock -- for endpoints that mutate the widget's own settings.""" + widget = db.get(Widget, widget_id) + if widget is None or widget.frame_id != frame.id: + raise HTTPException(404, "No such widget") + return frame, widget + + +def _require_widget_type(widget: Widget, expected: str) -> None: + if widget.widget_type != expected: + raise HTTPException(400, f"This widget is a {widget.widget_type} widget, not {expected}") + + +def _photo_config_or_400(db: Session, frame: Frame, widget: Widget) -> PhotoWidgetConfig: + """Same 400 shape routers/common.py's photo_widget_config_or_404 uses + for a frame with no configured photo widget at all, here for a widget + we already know is a photos widget -- Immich creds are frame/owner- + level, album_id is this widget's own.""" + url, key = immich_creds(frame) + if not url or not key: + raise HTTPException(400, "Immich URL/API key not configured yet") + pcfg = db.get(PhotoWidgetConfig, widget.id) + if not pcfg.album_id: + raise HTTPException(400, "No album configured yet") + return pcfg + + @router.get("/api/frames/{frame_id}/widgets") def api_widgets_list(request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)): user = require_user_api(request, db) @@ -140,3 +220,575 @@ def api_widget_delete( db.delete(widget) db.commit() return {"status": "deleted"} + + +# --- Per-widget-type config save (the gear-icon dialog's Save button) -------- + +@router.post("/api/frames/{frame_id}/widgets/{widget_id}/config") +def api_widget_config_save( + frame_widget: tuple[Frame, Widget] = Depends(require_widget_control), + db: Session = Depends(get_db), + # photos + album_id: str | None = Form(None), + order: str | None = Form(None), + display_mode: str | None = Form(None), + queue_target_len: int | None = Form(None), + # calendar + calendar_view: str | None = Form(None), + calendar_week_start: int | None = Form(None), + calendar_week_days: int | None = Form(None), + calendar_week_layout: str | None = Form(None), + calendar_week_start_offset: int | None = Form(None), + calendar_weather_enabled: bool | None = Form(None), + calendar_weather_units: str | None = Form(None), + calendar_tasks_enabled: bool | None = Form(None), +): + """Every field optional -- same partial-update, form-urlencoded + convention as the old frame-level api_config_save, now scoped to one + widget instead of "the frame's widget of this type". Fields that + don't apply to this widget's own widget_type are simply ignored, + same posture as an unrecognized form field always had here.""" + frame, widget = frame_widget + if widget.widget_type == "photos": + with widget_locked(db, frame.id, widget.id) as (_, _, pcfg): + if album_id is not None and album_id != pcfg.album_id: + # A newly selected album starts clean -- the old current + # photo and queue don't mean anything in the new album's + # context. + pcfg.current_asset_id = "" + pcfg.current_asset_set_at = 0.0 + pcfg.queue = [] + pcfg.queue_cursor = 0 + pcfg.history = [] + pcfg.excluded_asset_ids = [] + pcfg.album_id = album_id + if order is not None: + pcfg.order = order if order in ("sequential", "shuffle") else "sequential" + if display_mode is not None: + pcfg.display_mode = display_mode if display_mode in DISPLAY_MODES else DEFAULT_DISPLAY_MODE + if queue_target_len is not None: + pcfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len)) + elif widget.widget_type == "calendar": + with widget_locked(db, frame.id, widget.id) as (_, _, ccfg): + if calendar_view is not None: + new_view = calendar_view if calendar_view in calendar_render.CALENDAR_VIEWS else "agenda" + if new_view != ccfg.view: + # A stale offset means something different in a + # different view's units (days vs. weeks vs. months). + ccfg.browse_offset = 0 + ccfg.view = new_view + if calendar_week_start is not None: + ccfg.week_start = max(0, min(6, calendar_week_start)) + if calendar_week_days is not None: + new_days = max(2, min(10, calendar_week_days)) + if new_days != ccfg.week_days: + # A stale offset counts a different-sized page under + # the old day count. + ccfg.browse_offset = 0 + ccfg.week_days = new_days + if calendar_week_layout is not None: + ccfg.week_layout = ( + calendar_week_layout if calendar_week_layout in ("horizontal", "vertical") else "horizontal" + ) + if calendar_week_start_offset is not None: + new_offset = max(-30, min(30, calendar_week_start_offset)) + if new_offset != ccfg.week_start_offset: + ccfg.browse_offset = 0 + ccfg.week_start_offset = new_offset + if calendar_weather_enabled is not None: + ccfg.weather_enabled = calendar_weather_enabled + if calendar_weather_units is not None and calendar_weather_units in ("fahrenheit", "celsius"): + if calendar_weather_units != ccfg.weather_units: + # Cached forecasts are in the old unit -- force a + # refetch rather than showing stale numbers under a + # new unit label. + ccfg.weather_checked_at = 0.0 + ccfg.weather_units = calendar_weather_units + if calendar_tasks_enabled is not None: + ccfg.tasks_enabled = calendar_tasks_enabled + with frame_locked(db, frame.id) as cfg: + cfg.stats_config_saves += 1 + return {"status": "saved"} + + +# --- Photos: queue/thumbnail/preview ------------------------------------ + +@router.get("/api/frames/{frame_id}/widgets/{widget_id}/queue") +def api_widget_queue( + request: Request, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), + db: Session = Depends(get_db), +): + frame, widget = frame_widget + _require_widget_type(widget, "photos") + user = require_user_api(request, db) + pcfg = _photo_config_or_400(db, frame, widget) + client = immich_client_for(frame) + assets = list_assets(client, pcfg.album_id) + + with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_pcfg): + photo_queue.get_current(locked_pcfg, assets, locked_frame, + in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame)) + photo_queue.sync_queue_length(locked_pcfg, assets) + current_asset_id = locked_pcfg.current_asset_id + queue = list(locked_pcfg.queue) + controller_id = locked_frame.controlled_by_user_id + controller = ( + (locked_frame.controlled_by.display_name or locked_frame.controlled_by.username) + if locked_frame.controlled_by else None + ) + + def entry(asset_id: str) -> dict: + return {"id": asset_id, "thumbnail_url": f"/api/frames/{frame.id}/widgets/{widget.id}/thumbnail/{asset_id}"} + + return { + "current": entry(current_asset_id) if current_asset_id else None, + "upcoming": [entry(asset_id) for asset_id in queue], + "control": {"controller": controller, "you": controller_id == user.id}, + } + + +class QueueReorderRequest(BaseModel): + queue: list[str] + + +@router.post("/api/frames/{frame_id}/widgets/{widget_id}/queue/reorder") +def api_widget_queue_reorder( + body: QueueReorderRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control), + db: Session = Depends(get_db), +): + """Applies the client's requested order, tolerating drift between the + browser's last-fetched snapshot and the server's current queue (e.g. + a top-up/trim landed in between) instead of hard-rejecting: any ID + the client sent that's no longer actually queued is dropped, and any + ID the server has that the client didn't know about is appended + rather than lost.""" + frame, widget = frame_widget + _require_widget_type(widget, "photos") + with widget_locked(db, frame.id, widget.id) as (_, _, cfg): + current_set = set(cfg.queue) + reordered = [asset_id for asset_id in body.queue if asset_id in current_set] + reordered += [asset_id for asset_id in cfg.queue if asset_id not in set(reordered)] + cfg.queue = reordered + return {"status": "saved"} + + +class QueuePromoteRequest(BaseModel): + asset_id: str + + +@router.post("/api/frames/{frame_id}/widgets/{widget_id}/queue/promote") +def api_widget_queue_promote( + body: QueuePromoteRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control), + db: Session = Depends(get_db), +): + """Moves a single photo to the front of the queue -- "Show next".""" + frame, widget = frame_widget + _require_widget_type(widget, "photos") + with widget_locked(db, frame.id, widget.id) as (_, _, cfg): + if body.asset_id not in cfg.queue: + raise HTTPException(400, "That photo is no longer in the upcoming queue") + cfg.queue = [body.asset_id] + [asset_id for asset_id in cfg.queue if asset_id != body.asset_id] + return {"status": "saved"} + + +class QueueRemoveRequest(BaseModel): + asset_id: str + + +@router.post("/api/frames/{frame_id}/widgets/{widget_id}/queue/remove") +def api_widget_queue_remove( + body: QueueRemoveRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control), + db: Session = Depends(get_db), +): + """Permanently removes a photo from this widget's rotation. Does NOT + touch Immich or the album itself; see photo_queue.remove_from_rotation().""" + frame, widget = frame_widget + _require_widget_type(widget, "photos") + pcfg = _photo_config_or_400(db, frame, widget) + client = immich_client_for(frame) + assets = list_assets(client, pcfg.album_id) + with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_pcfg): + photo_queue.remove_from_rotation(locked_pcfg, assets, body.asset_id, locked_frame) + return {"status": "removed"} + + +@router.get("/api/frames/{frame_id}/widgets/{widget_id}/thumbnail/{asset_id}") +def api_widget_thumbnail( + asset_id: str, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), + db: Session = Depends(get_db), +): + """Scoped to what this widget is actually showing/queuing -- a user + merely linked to view this frame shouldn't be able to pull thumbnails + for arbitrary asset ids in the owner's Immich library, only this + widget's own curated album. Same rule device.frame_share and + manage.manage_thumbnail already enforce.""" + frame, widget = frame_widget + _require_widget_type(widget, "photos") + pcfg = db.get(PhotoWidgetConfig, widget.id) + if asset_id != pcfg.current_asset_id and asset_id not in pcfg.queue: + raise HTTPException(404, "Not on this frame") + client = immich_client_for(frame) + try: + content, content_type = client.download_asset_thumbnail(asset_id) + except httpx.HTTPError as e: + raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e + return Response(content=content, media_type=content_type) + + +def _current_asset_id(db: Session, frame: Frame, widget: Widget) -> tuple[str, PhotoWidgetConfig]: + """Same idempotent get_current() dance the queue endpoint uses -- + picks a current photo if none is set yet, otherwise just reads it, + never advances early.""" + pcfg = _photo_config_or_400(db, frame, widget) + client = immich_client_for(frame) + assets = list_assets(client, pcfg.album_id) + with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_pcfg): + photo_queue.get_current(locked_pcfg, assets, locked_frame, + in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame)) + asset_id = locked_pcfg.current_asset_id + if not asset_id: + raise HTTPException(404, "No current photo") + return asset_id, pcfg + + +@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/original") +def api_widget_preview_original( + frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db) +): + """The Immich preview image behind the currently-displayed photo, + unprocessed -- the "now displaying" side of the dialog's before/after + comparison.""" + frame, widget = frame_widget + _require_widget_type(widget, "photos") + asset_id, _ = _current_asset_id(db, frame, widget) + client = immich_client_for(frame) + try: + jpeg_bytes = client.download_asset_preview(asset_id) + except httpx.HTTPError as e: + raise HTTPException(502, f"Could not download asset from Immich: {e}") from e + return Response(content=jpeg_bytes, media_type="image/jpeg") + + +@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/rendered") +def api_widget_preview_rendered( + frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db) +): + """The same photo run through this frame's actual saved rendering + pipeline (display mode, palette, color/contrast/dithering) and + exported as a PNG -- the "how it will look on the frame" side of the + comparison. Not a live preview of unsaved slider values; reflects + whatever's currently saved. display_mode comes from this widget's own + config (palette/color/contrast/dither stay frame-level -- one + physical panel, one set of those).""" + frame, widget = frame_widget + _require_widget_type(widget, "photos") + asset_id, pcfg = _current_asset_id(db, frame, widget) + client = immich_client_for(frame) + source, faces = fetch_source_and_faces(client, pcfg.display_mode, asset_id) + png = render_preview_png( + source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb, + display_mode=pcfg.display_mode, color_boost=frame.color_boost, + contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength, + ) + return Response(content=png, media_type="image/png") + + +# --- Calendar: inclusion/color/tasks/weather/preview -------------------- + +class CalendarSelectRequest(BaseModel): + user_id: int + calendar_key: str + calendar_label: str = "" + included: bool + + +@router.post("/api/frames/{frame_id}/widgets/{widget_id}/calendar-select") +def api_widget_calendar_select( + body: CalendarSelectRequest, request: Request, + frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), # view access only -- NOT control + db: Session = Depends(get_db), +): + """Include/exclude one calendar (calendar_key "ics" or + "caldav:", see FrameCalendar) on this calendar widget. + Deliberately not require_widget_control: adding your own calendar, or + muting anyone's (including your own), is each viewer's own call, not + something a frame's controller manages on someone else's behalf. The + one-sided permission split lives here: turning a calendar ON requires + being its owner (nobody can add someone else's calendar to a shared + frame for them); turning one OFF only requires being linked to the + frame at all, so anyone sharing the display can mute a calendar + they'd rather not see there even if they don't own it.""" + frame, widget = frame_widget + _require_widget_type(widget, "calendar") + user = require_user_api(request, db) + if body.included and body.user_id != user.id: + raise HTTPException(403, "Only a calendar's owner can add it to a frame") + row = db.execute( + select(FrameCalendar).where( + FrameCalendar.widget_id == widget.id, + FrameCalendar.user_id == body.user_id, + FrameCalendar.calendar_key == body.calendar_key, + ) + ).scalar_one_or_none() + if row is None: + if not body.included: + raise HTTPException(404, "Not currently included on this widget") + row = FrameCalendar(widget_id=widget.id, user_id=body.user_id, calendar_key=body.calendar_key) + db.add(row) + row.included = body.included + if body.calendar_label: + row.calendar_label = body.calendar_label + # Force this widget's merged cache to pick up the change promptly + # rather than waiting out the throttle. + db.get(CalendarWidgetConfig, widget.id).checked_at = 0.0 + db.commit() + return {"status": "saved", "included": row.included} + + +class CalendarColorRequest(BaseModel): + calendar_key: str + color_index: int | None # None clears the pin, reverting to auto-cycle + + +@router.post("/api/frames/{frame_id}/widgets/{widget_id}/calendar-color") +def api_widget_calendar_color( + body: CalendarColorRequest, request: Request, + frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), + db: Session = Depends(get_db), +): + """Pins a specific panel color to one of your own included calendars + (models.FrameCalendar.color_index) -- always owner-only, unlike + calendar-select's included=False, since recoloring someone else's + calendar isn't the same kind of "I'd rather not see this" veto as + muting it. None clears the pin, reverting calendar_render.py to its + old auto-cycle-by-owner-name behavior for this calendar.""" + frame, widget = frame_widget + _require_widget_type(widget, "calendar") + user = require_user_api(request, db) + if body.color_index is not None and body.color_index not in CALENDAR_COLOR_INDEX_RANGE: + raise HTTPException(400, "color_index must be 2-5 (the panel's non-black/white colors)") + row = db.execute( + select(FrameCalendar).where( + FrameCalendar.widget_id == widget.id, + FrameCalendar.user_id == user.id, + FrameCalendar.calendar_key == body.calendar_key, + ) + ).scalar_one_or_none() + if row is None: + raise HTTPException(404, "Not included on this widget") + row.color_index = body.color_index + db.get(CalendarWidgetConfig, widget.id).checked_at = 0.0 + db.commit() + return {"status": "saved", "color_index": row.color_index} + + +@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/calendar") +def api_widget_preview_calendar( + frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db) +): + """The same merged, cached event set a live device render would use -- + not a live preview of an unsaved calendar_view choice, same "reflects + what's currently saved" convention as preview/rendered.""" + frame, widget = frame_widget + _require_widget_type(widget, "calendar") + if not calendar_sources_for_widget(db, widget): + raise HTTPException(400, "No calendars included on this widget yet") + ccfg = db.get(CalendarWidgetConfig, widget.id) + events, summary = get_or_refresh_calendar_events_for_widget(db, frame, widget) + weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if ccfg.weather_enabled else None + tasks = ( + get_or_refresh_tasks_for_widget(db, frame, widget) + if (ccfg.view == "week" and ccfg.tasks_enabled) else None + ) + png = calendar_render.render_calendar_preview_png( + events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation, + palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary, + week_start=ccfg.week_start, + weather_cities=weather_cities, weather_units=ccfg.weather_units, + week_days=ccfg.week_days, week_layout=ccfg.week_layout, tasks=tasks, + week_start_offset=ccfg.week_start_offset, + ) + return Response(content=png, media_type="image/png") + + +class TasksSourceRequest(BaseModel): + calendar_key: str | None # None clears the source + + +@router.post("/api/frames/{frame_id}/widgets/{widget_id}/tasks-source") +def api_widget_tasks_source( + body: TasksSourceRequest, request: Request, + frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), + db: Session = Depends(get_db), +): + """Points this widget's week-view task list at one of the calling + user's own CalDAV calendars -- same owner-controls-their-own-data + permission split as calendar-select's included=True, since this is + volunteering personal calendar data, not a display setting a + controller should get to pick on someone else's behalf. None clears + the source; clearing (unlike setting) isn't ownership-gated -- like + muting a shared calendar, anyone linked to the frame can turn off a + task list they'd rather not see, but only its owner can point the + widget at one of their calendars to begin with.""" + frame, widget = frame_widget + _require_widget_type(widget, "calendar") + user = require_user_api(request, db) + with widget_locked(db, frame.id, widget.id) as (_, _, cfg): + if body.calendar_key is None: + cfg.tasks_user_id = None + cfg.tasks_calendar_key = None + cfg.tasks_cached = None + else: + cfg.tasks_user_id = user.id + cfg.tasks_calendar_key = body.calendar_key + cfg.tasks_checked_at = 0.0 # pick up the change promptly + return {"status": "saved", "calendar_key": body.calendar_key} + + +class WeatherCityAddRequest(BaseModel): + name: str + + +@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-cities/add") +def api_widget_weather_city_add( + body: WeatherCityAddRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control), + db: Session = Depends(get_db), +): + """Geocodes a free-text city name (e.g. "Portland, OR") and adds it to + this widget's weather strip -- a widget-wide display setting (like + calendar_view), not personal data, so this is gated the same way as + the config-save endpoint rather than the calendar-select owner/mute + split.""" + frame, widget = frame_widget + _require_widget_type(widget, "calendar") + try: + city = weather.geocode_city(body.name) + except weather.WeatherFetchError as e: + raise HTTPException(400, str(e)) from e + with widget_locked(db, frame.id, widget.id) as (_, _, cfg): + cities = list(cfg.weather_cities or []) + if any(c["label"] == city["label"] for c in cities): + raise HTTPException(400, f"{city['label']} is already on this widget's list") + cities.append(city) + cfg.weather_cities = cities + cfg.weather_checked_at = 0.0 # pick up the new city promptly + return {"status": "saved", "city": city} + + +class WeatherCityRemoveRequest(BaseModel): + label: str + + +@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-cities/remove") +def api_widget_weather_city_remove( + body: WeatherCityRemoveRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control), + db: Session = Depends(get_db), +): + frame, widget = frame_widget + _require_widget_type(widget, "calendar") + with widget_locked(db, frame.id, widget.id) as (_, _, cfg): + cities = [c for c in (cfg.weather_cities or []) if c["label"] != body.label] + cfg.weather_cities = cities + cached = [c for c in (cfg.weather_cached or []) if c["label"] != body.label] + cfg.weather_cached = cached + return {"status": "saved"} + + +# --- Whiteboard: source/preview ------------------------------------------ + +class WhiteboardSourceRequest(BaseModel): + url: str | None # None clears the source + + +@router.post("/api/frames/{frame_id}/widgets/{widget_id}/whiteboard-source") +def api_widget_whiteboard_source( + body: WhiteboardSourceRequest, request: Request, + frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), + db: Session = Depends(get_db), +): + """Points this widget at one of the calling user's own WebDAV (or + reused-CalDAV, see User.webdav_reuse_caldav_creds) credentials -- + same owner-controls-their-own-data permission split as + api_widget_tasks_source: only the account owner can set the widget to + use it, but anyone linked to the frame can clear it, same as muting a + shared calendar.""" + frame, widget = frame_widget + _require_widget_type(widget, "whiteboard") + user = require_user_api(request, db) + with widget_locked(db, frame.id, widget.id) as (_, _, cfg): + if body.url is None: + cfg.user_id = None + cfg.url = "" + cfg.cached_image = None + else: + stripped = body.url.strip() + if not valid_http_url(stripped): + raise HTTPException(400, "Whiteboard URL must be a plain http:// or https:// URL") + cfg.user_id = user.id + cfg.url = stripped + cfg.checked_at = 0.0 # pick up the change promptly + return {"status": "saved", "url": body.url} + + +@router.get("/api/frames/{frame_id}/widgets/{widget_id}/whiteboard-browse") +def api_widget_whiteboard_browse( + request: Request, url: str | None = None, + frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), + db: Session = Depends(get_db), +): + """One level of a WebDAV directory listing, using the calling user's + own credentials (never this widget's saved 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 in + the whiteboard dialog as an alternative to pasting a URL. Nested + under this widget's own path purely so the dialog's JS can keep using + one shared window.FRAME_API base for every call it makes -- the + lookup itself doesn't touch this (or any) widget's own state. `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.""" + frame, widget = frame_widget + _require_widget_type(widget, "whiteboard") + 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}/widgets/{widget_id}/preview/whiteboard") +def api_widget_preview_whiteboard( + force: bool = False, frame_widget: tuple[Frame, Widget] = Depends(require_widget_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 -- + "how it will look on the frame" (dithered, letterboxed), not just the + raw Excalidraw export, same convention as the other preview + endpoints. force=True (the "Refresh now" button, as opposed to just + reopening the dialog) bypasses the fetch throttle.""" + frame, widget = frame_widget + _require_widget_type(widget, "whiteboard") + wcfg = db.get(WhiteboardWidgetConfig, widget.id) + png_bytes = get_or_refresh_whiteboard_for_widget(db, frame, widget, force=force) + if png_bytes is None: + if not wcfg.url: + raise HTTPException(400, "No whiteboard configured on this widget yet") + raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials") + source = Image.open(io.BytesIO(png_bytes)).convert("RGB") + png = render_preview_png( + source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb, + display_mode="letterbox", + ) + return Response(content=png, media_type="image/png") diff --git a/server/app/routers/common.py b/server/app/routers/common.py index e06b4e2..ab42d47 100644 --- a/server/app/routers/common.py +++ b/server/app/routers/common.py @@ -97,12 +97,11 @@ def fetch_source_and_faces( client: ImmichClient, display_mode: str, asset_id: str ) -> tuple[Image.Image, list[dict] | None]: """The shared first half of rendering: download the Immich preview - and (only if display_mode needs it) its detected faces. Used by both - render_asset (device-facing) and the web UI's rendered-preview - endpoint (routers/api_frames.py) so they can't drift apart. Takes - display_mode directly (a photos widget's own setting, see - PhotoWidgetConfig) rather than a whole Frame -- this function only - ever needed that one attribute off it.""" + and (only if display_mode needs it) its detected faces. Used by the + web UI's rendered-preview endpoint (routers/api_widgets.py's + api_widget_preview_rendered). Takes display_mode directly (a photos + widget's own setting, see PhotoWidgetConfig) rather than a whole + Frame -- this function only ever needed that one attribute off it.""" try: jpeg_bytes = client.download_asset_preview(asset_id) except httpx.HTTPError as e: @@ -477,16 +476,16 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict: return content -def calendar_sources_for_frame(db: Session, frame: Frame) -> list[calendar_feed.CalendarSource]: - """Every calendar included on this frame (FrameCalendar.included) -- - the exact set calendar_feed.merge_events needs. A calendar_key of - "ics" resolves against its owner's calendar_ics_url; "caldav:" - resolves against the href itself, authenticated with the owner's - CalDAV account credentials (see caldav_client.py).""" +def calendar_sources_for_widget(db: Session, widget: Widget) -> list[calendar_feed.CalendarSource]: + """Every calendar included on this calendar widget (FrameCalendar. + included) -- the exact set calendar_feed.merge_events needs. A + calendar_key of "ics" resolves against its owner's calendar_ics_url; + "caldav:" resolves against the href itself, authenticated with + the owner's CalDAV account credentials (see caldav_client.py).""" rows = db.execute( select(FrameCalendar, User) .join(User, User.id == FrameCalendar.user_id) - .where(FrameCalendar.frame_id == frame.id, FrameCalendar.included == True) # noqa: E712 + .where(FrameCalendar.widget_id == widget.id, FrameCalendar.included == True) # noqa: E712 ).all() sources = [] for fc, u in rows: @@ -513,19 +512,13 @@ def get_or_refresh_calendar_events_for_widget(db: Session, frame: Frame, widget: whole merged result (every included user's events together), not per-user -- ICS feeds are small and this refetches at most every ~20 minutes regardless of how many are included, so per-user cache - columns would add bookkeeping for a marginal benefit. - calendar_sources_for_frame stays frame_id-scoped (see FrameCalendar's - own docstring) until a later phase re-keys it to widget_id, so every - calendar widget on a frame currently shares the same "included - calendars" set -- not a real limitation yet since nothing supports - more than one calendar widget per frame end to end until that phase - lands.""" + columns would add bookkeeping for a marginal benefit.""" cfg = db.get(CalendarWidgetConfig, widget.id) now = time.time() if cfg.cached_events is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S: return cfg.cached_events, cfg.fetch_summary - sources = calendar_sources_for_frame(db, frame) + sources = calendar_sources_for_widget(db, widget) today = quiet_hours.local_date(frame) events, summary = calendar_feed.merge_events( sources, diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py index 3312c86..9029cb5 100644 --- a/server/app/routers/frame_pages.py +++ b/server/app/routers/frame_pages.py @@ -1,6 +1,12 @@ -"""The per-frame HTML pages: Photos (/frames/{id}), Configuration, -Calendar, and Stats tabs, all inside the sidebar app shell. Data loading -happens client-side against /api/frames/{id}/... (routers/api_frames.py); +"""The per-frame HTML pages: Layout (/frames/{id}, the widget placement +canvas), Configuration, and Stats, all inside the sidebar app shell. +Each widget's own settings (album, calendar view/inclusion, whiteboard +source, etc.) no longer have their own tab/page -- they're a dialog +opened from a gear icon on the widget's box in the Layout canvas (see +static/frame_layout.js), whose content this module also serves (the +/widgets/{widget_id}/dialog route) as a small HTML fragment, not a full +page. Data loading otherwise happens client-side against +/api/frames/{id}/... (routers/api_frames.py, routers/api_widgets.py); these routes just authorize and render the scaffold.""" from __future__ import annotations @@ -20,7 +26,16 @@ from ..image_pipeline import ( PALETTE_LABELS, palette_to_hex, ) -from ..models import CalendarWidgetConfig, Frame, FrameCalendar, PhotoWidgetConfig, User, UserFrame, WhiteboardWidgetConfig +from ..models import ( + CalendarWidgetConfig, + Frame, + FrameCalendar, + PhotoWidgetConfig, + User, + UserFrame, + WhiteboardWidgetConfig, + Widget, +) from ..quiet_hours import ALL_TIMEZONES from .common import shell_context, widget_of_type @@ -36,37 +51,42 @@ def _frame_page(request: Request, db: Session, frame_id: int, template: str, tab if frame is None or not can_view_frame(db, user, frame): raise HTTPException(404, "No such frame") ctx = shell_context(request, db, user, active_frame=frame) - ctx.update({ - "frame": frame, "active_tab": tab, - "has_calendar_widget": widget_of_type(db, frame, "calendar") is not None, - "has_whiteboard_widget": widget_of_type(db, frame, "whiteboard") is not None, - **extra, - }) + ctx.update({"frame": frame, "active_tab": tab, **extra}) return templates.TemplateResponse(template, ctx) @router.get("/frames/{frame_id}", response_class=HTMLResponse) -def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get_db)): - frame = db.get(Frame, frame_id) - photo_cfg = None - if frame is not None: - photo_widget = widget_of_type(db, frame, "photos") - if photo_widget is not None: - photo_cfg = db.get(PhotoWidgetConfig, photo_widget.id) - return _frame_page( - request, db, frame_id, "frame_photos.html", "photos", - display_mode_labels=DISPLAY_MODE_LABELS, - photo_cfg=photo_cfg, - ) - - -@router.get("/frames/{frame_id}/layout", response_class=HTMLResponse) def frame_layout_page(frame_id: int, request: Request, db: Session = Depends(get_db)): return _frame_page(request, db, frame_id, "frame_layout.html", "layout") +@router.get("/frames/{frame_id}/config", response_class=HTMLResponse) +def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)): + frame = db.get(Frame, frame_id) + photo_widget_id = None + if frame is not None: + photo_widget = widget_of_type(db, frame, "photos") + if photo_widget is not None: + photo_widget_id = photo_widget.id + return _frame_page( + request, db, frame_id, "frame_config.html", "config", + timezones=ALL_TIMEZONES, + palette_labels=PALETTE_LABELS, + default_palette_rgb=DEFAULT_PALETTE_RGB, + palette_to_hex=palette_to_hex, + photo_widget_id=photo_widget_id, + ) + + +@router.get("/frames/{frame_id}/stats", response_class=HTMLResponse) +def frame_stats_page(frame_id: int, request: Request, db: Session = Depends(get_db)): + return _frame_page(request, db, frame_id, "frame_stats.html", "stats") + + +# --- Per-widget config dialog content ------------------------------------- + def _user_available_calendars(user: User) -> list[dict]: - """This user's full set of calendars available to add to any frame: + """This user's full set of calendars available to add to any widget: the single ICS subscription (if set) plus every CalDAV calendar last discovered from Settings' "Discover calendars" button. Doesn't hit the network -- reads the cached list a user refreshes themselves.""" @@ -78,20 +98,20 @@ def _user_available_calendars(user: User) -> list[dict]: return calendars -def _calendar_users_for_frame(db: Session, frame_id: int, viewer_id: int | None) -> list[dict]: - """Per-linked-user calendar list for the Calendar tab's "Included +def _calendar_users_for_widget(db: Session, frame_id: int, widget_id: int, viewer_id: int | None) -> list[dict]: + """Per-linked-user calendar list for the calendar dialog's "Included calendars" section. The viewer's own row lists EVERY calendar they have available, each with a full add/remove toggle; every other linked user's row lists ONLY the calendars they've already included - (mute-only for the viewer -- see api_frames.py's api_calendar_select: - only a calendar's owner may turn it on, but anyone linked to the - frame may turn one off).""" + (mute-only for the viewer -- see api_widgets.py's + api_widget_calendar_select: only a calendar's owner may turn it on, + but anyone linked to the frame may turn one off).""" users = db.execute( select(User).join(UserFrame, UserFrame.user_id == User.id) .where(UserFrame.frame_id == frame_id).order_by(User.username) ).scalars().all() included_by_user: dict[int, list[FrameCalendar]] = {} - for fc in db.execute(select(FrameCalendar).where(FrameCalendar.frame_id == frame_id)).scalars().all(): + for fc in db.execute(select(FrameCalendar).where(FrameCalendar.widget_id == widget_id)).scalars().all(): included_by_user.setdefault(fc.user_id, []).append(fc) result = [] @@ -116,12 +136,12 @@ def _calendar_users_for_frame(db: Session, frame_id: int, viewer_id: int | None) return result -def _tasks_source_info(db: Session, calendar_cfg: CalendarWidgetConfig | None) -> dict | None: - """Whose CalDAV calendar this frame's week-view task list currently +def _tasks_source_info(db: Session, calendar_cfg: CalendarWidgetConfig) -> dict | None: + """Whose CalDAV calendar this widget's week-view task list currently pulls from, and its label -- for showing "using 's Chores list" to everyone linked, not just whoever set it. None if no source is configured.""" - if calendar_cfg is None or not calendar_cfg.tasks_user_id or not calendar_cfg.tasks_calendar_key: + if not calendar_cfg.tasks_user_id or not calendar_cfg.tasks_calendar_key: return None user = db.get(User, calendar_cfg.tasks_user_id) if user is None: @@ -134,52 +154,11 @@ def _tasks_source_info(db: Session, calendar_cfg: CalendarWidgetConfig | None) - return {"user_id": user.id, "display_name": user.display_name or user.username, "label": label} -@router.get("/frames/{frame_id}/config", response_class=HTMLResponse) -def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)): - return _frame_page( - request, db, frame_id, "frame_config.html", "config", - timezones=ALL_TIMEZONES, - palette_labels=PALETTE_LABELS, - default_palette_rgb=DEFAULT_PALETTE_RGB, - palette_to_hex=palette_to_hex, - ) - - -WEEK_START_LABELS = {0: "Monday", 1: "Tuesday", 2: "Wednesday", 3: "Thursday", - 4: "Friday", 5: "Saturday", 6: "Sunday"} - - -@router.get("/frames/{frame_id}/calendar", response_class=HTMLResponse) -def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(get_db)): - viewer = current_user(request, db) - frame = db.get(Frame, frame_id) - viewer_task_calendars = [] - calendar_cfg = None - if viewer is not None and frame is not None and can_view_frame(db, viewer, frame): - viewer_task_calendars = [c for c in _user_available_calendars(viewer) if c["key"].startswith("caldav:")] - if frame is not None: - calendar_widget = widget_of_type(db, frame, "calendar") - if calendar_widget is not None: - calendar_cfg = db.get(CalendarWidgetConfig, calendar_widget.id) - return _frame_page( - request, db, frame_id, "frame_calendar.html", "calendar", - calendar_views=CALENDAR_VIEW_LABELS, - calendar_users=_calendar_users_for_frame(db, frame_id, viewer.id if viewer else None), - week_start_labels=WEEK_START_LABELS, - calendar_color_labels=PALETTE_LABELS, - default_palette_rgb=DEFAULT_PALETTE_RGB, - palette_to_hex=palette_to_hex, - viewer_task_calendars=viewer_task_calendars, - calendar_cfg=calendar_cfg, - tasks_source=_tasks_source_info(db, calendar_cfg), - ) - - -def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig | None) -> dict | None: - """Whose account this frame's whiteboard currently fetches with, for - showing "using 's account" to everyone linked, not just - whoever set it. None if no source is configured.""" - if whiteboard_cfg is None or not whiteboard_cfg.user_id or not whiteboard_cfg.url: +def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig) -> dict | None: + """Whose account this widget currently fetches with, for showing + "using 's account" to everyone linked, not just whoever set + it. None if no source is configured.""" + if not whiteboard_cfg.user_id or not whiteboard_cfg.url: return None user = db.get(User, whiteboard_cfg.user_id) if user is None: @@ -187,27 +166,57 @@ def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig return {"user_id": user.id, "display_name": user.display_name or user.username, "url": whiteboard_cfg.url} -@router.get("/frames/{frame_id}/whiteboard", response_class=HTMLResponse) -def frame_whiteboard_page(frame_id: int, request: Request, db: Session = Depends(get_db)): - viewer = current_user(request, db) +WEEK_START_LABELS = {0: "Monday", 1: "Tuesday", 2: "Wednesday", 3: "Thursday", + 4: "Friday", 5: "Saturday", 6: "Sunday"} + + +@router.get("/frames/{frame_id}/widgets/{widget_id}/dialog", response_class=HTMLResponse) +def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session = Depends(get_db)): + """The gear-icon dialog's content, dispatched by widget_type -- a + small HTML fragment (no app_base shell/tabs), fetched and injected + into a by static/frame_layout.js. Not itself a page a user + would navigate to directly.""" + user = current_user(request, db) + if user is None: + raise HTTPException(401, "Not logged in") frame = db.get(Frame, frame_id) - viewer_has_webdav_creds = False - whiteboard_cfg = None - if viewer is not None and frame is not None and can_view_frame(db, viewer, frame): + if frame is None or not can_view_frame(db, user, frame): + raise HTTPException(404, "No such frame") + widget = db.get(Widget, widget_id) + if widget is None or widget.frame_id != frame.id: + raise HTTPException(404, "No such widget") + + if widget.widget_type == "photos": + photo_cfg = db.get(PhotoWidgetConfig, widget.id) + return templates.TemplateResponse("_widget_dialog_photos.html", { + "request": request, "frame": frame, "widget": widget, "photo_cfg": photo_cfg, + "display_mode_labels": DISPLAY_MODE_LABELS, + }) + + if widget.widget_type == "calendar": + calendar_cfg = db.get(CalendarWidgetConfig, widget.id) + viewer_task_calendars = [c for c in _user_available_calendars(user) if c["key"].startswith("caldav:")] + return templates.TemplateResponse("_widget_dialog_calendar.html", { + "request": request, "frame": frame, "widget": widget, "calendar_cfg": calendar_cfg, "user": user, + "calendar_views": CALENDAR_VIEW_LABELS, + "calendar_users": _calendar_users_for_widget(db, frame.id, widget.id, user.id), + "week_start_labels": WEEK_START_LABELS, + "calendar_color_labels": PALETTE_LABELS, + "default_palette_rgb": DEFAULT_PALETTE_RGB, + "palette_to_hex": palette_to_hex, + "viewer_task_calendars": viewer_task_calendars, + "tasks_source": _tasks_source_info(db, calendar_cfg), + }) + + if widget.widget_type == "whiteboard": + whiteboard_cfg = db.get(WhiteboardWidgetConfig, widget.id) viewer_has_webdav_creds = bool( - viewer.webdav_username or (viewer.webdav_reuse_caldav_creds and viewer.calendar_caldav_username) + user.webdav_username or (user.webdav_reuse_caldav_creds and user.calendar_caldav_username) ) - if frame is not None: - whiteboard_widget = widget_of_type(db, frame, "whiteboard") - if whiteboard_widget is not None: - whiteboard_cfg = db.get(WhiteboardWidgetConfig, whiteboard_widget.id) - return _frame_page( - request, db, frame_id, "frame_whiteboard.html", "whiteboard", - whiteboard_source=_whiteboard_source_info(db, whiteboard_cfg), - viewer_has_webdav_creds=viewer_has_webdav_creds, - ) + return templates.TemplateResponse("_widget_dialog_whiteboard.html", { + "request": request, "frame": frame, "widget": widget, "user": user, + "whiteboard_source": _whiteboard_source_info(db, whiteboard_cfg), + "viewer_has_webdav_creds": viewer_has_webdav_creds, + }) - -@router.get("/frames/{frame_id}/stats", response_class=HTMLResponse) -def frame_stats_page(frame_id: int, request: Request, db: Session = Depends(get_db)): - return _frame_page(request, db, frame_id, "frame_stats.html", "stats") \ No newline at end of file + raise HTTPException(400, f"Unknown widget type: {widget.widget_type}") diff --git a/server/app/static/device_status_bar.js b/server/app/static/device_status_bar.js index 2ad828b..d135e03 100644 --- a/server/app/static/device_status_bar.js +++ b/server/app/static/device_status_bar.js @@ -1,7 +1,10 @@ // Device status bar: always-visible strip (below the page title, above // the tabs -- see _device_status_bar.html) showing last-seen/firmware/ // battery, so it's not tucked away on just the Stats tab. Shared by -// every frame page; each sets window.FRAME_API before this loads. +// every frame page; each sets window.FRAME_BASE_API before this loads +// -- a stable frame-level base, unlike window.FRAME_API, which the +// Layout page's widget dialogs repoint to a widget-scoped base while +// one is open. let lastDeviceStatus = null; @@ -51,7 +54,7 @@ function renderDeviceStatusBar(device) { async function loadDeviceStatusBar() { try { - const resp = await fetch(`${window.FRAME_API}/queue`); + const resp = await fetch(`${window.FRAME_BASE_API}/status`); if (!resp.ok) { return; } diff --git a/server/app/static/frame_calendar.js b/server/app/static/frame_calendar.js deleted file mode 100644 index 6343798..0000000 --- a/server/app/static/frame_calendar.js +++ /dev/null @@ -1,308 +0,0 @@ -// Calendar tab: view/week-start settings, per-user opt-in, and the -// rendered preview. Extracted from frame_config.js when the Calendar -// card became its own tab (window.FRAME_API is set by the template; -// checkboxes are always sent explicitly as "true"/"false"). - -// Week-view-only settings (days/layout/start-offset) only matter when -// View is actually "Week"; "Week starts on" also matters for Month, so -// it gets its own, slightly looser condition. The start-offset row is -// further gated on the day count -- it's meaningless at the default 7 -// days, where "Week starts on" governs instead (see -// calendar_render.py's _build_week). -function updateCalendarFieldVisibility() { - const view = document.getElementById('calendar_view').value; - const days = Number(document.getElementById('calendar_week_days').value); - const isWeek = view === 'week'; - document.getElementById('calendar-week-start-row').style.display = - (view === 'week' || view === 'month') ? '' : 'none'; - document.getElementById('calendar-week-days-row').style.display = isWeek ? '' : 'none'; - document.getElementById('calendar-week-layout-row').style.display = isWeek ? '' : 'none'; - document.getElementById('calendar-week-offset-row').style.display = (isWeek && days !== 7) ? '' : 'none'; -} -document.getElementById('calendar_view').addEventListener('change', updateCalendarFieldVisibility); -document.getElementById('calendar_week_days').addEventListener('input', updateCalendarFieldVisibility); -updateCalendarFieldVisibility(); - -document.getElementById('calendar-config-form').addEventListener('submit', async (e) => { - e.preventDefault(); - const body = new URLSearchParams({ - calendar_view: document.getElementById('calendar_view').value, - calendar_week_start: document.getElementById('calendar_week_start').value, - calendar_week_days: document.getElementById('calendar_week_days').value, - calendar_week_layout: document.getElementById('calendar_week_layout').value, - calendar_week_start_offset: document.getElementById('calendar_week_start_offset').value, - }); - try { - const resp = await fetch(`${window.FRAME_API}/config`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body, - }); - if (!resp.ok) throw new Error(await apiError(resp)); - showStatus(true, 'Saved.'); - loadCalendarPreview(); - } catch (e) { - showStatus(false, e.message); - } -}); - -// Each calendar's own include/mute toggle -- auto-saves on change, not -// batched into the form above, since it's a data-sharing choice (see -// api_frames.py's /calendar-select), not a frame-wide setting. Works the -// same element for your own calendars (full add/remove) and other -// people's (mute only) -- the server enforces which direction is allowed -// and this just reverts the checkbox with an error message if rejected. -document.querySelectorAll('.calendar-toggle').forEach((el) => { - el.addEventListener('change', async () => { - try { - const resp = await fetch(`${window.FRAME_API}/calendar-select`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - user_id: Number(el.dataset.userId), - calendar_key: el.dataset.key, - calendar_label: el.dataset.label, - included: el.checked, - }), - }); - if (!resp.ok) throw new Error(await apiError(resp)); - showStatus(true, el.checked ? 'Calendar included on this frame.' : 'Calendar removed from this frame.'); - } catch (e) { - el.checked = !el.checked; - showStatus(false, e.message); - } - }); -}); - -// Per-calendar color pin -- owner-only (the server enforces it; these -// buttons only ever render for the viewer's own calendars anyway, see -// frame_calendar.html). Clicking the currently-selected swatch again has -// no special "toggle off" behavior -- use the explicit Auto button. -document.querySelectorAll('.calendar-color-picker').forEach((picker) => { - const key = picker.dataset.key; - picker.querySelectorAll('.color-swatch').forEach((btn) => { - btn.addEventListener('click', async () => { - const colorIndex = btn.dataset.index === '' ? null : Number(btn.dataset.index); - try { - const resp = await fetch(`${window.FRAME_API}/calendar-color`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ calendar_key: key, color_index: colorIndex }), - }); - if (!resp.ok) throw new Error(await apiError(resp)); - picker.querySelectorAll('.color-swatch').forEach((el) => el.classList.remove('selected')); - btn.classList.add('selected'); - showStatus(true, 'Color saved.'); - loadCalendarPreview(); - } catch (e) { - showStatus(false, e.message); - } - }); - }); -}); - -document.getElementById('weather-config-form').addEventListener('submit', async (e) => { - e.preventDefault(); - const body = new URLSearchParams({ - calendar_weather_enabled: String(document.getElementById('weather_enabled').checked), - calendar_weather_units: document.getElementById('weather_units').value, - }); - try { - const resp = await fetch(`${window.FRAME_API}/config`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body, - }); - if (!resp.ok) throw new Error(await apiError(resp)); - showStatus(true, 'Saved.'); - loadCalendarPreview(); - } catch (e) { - showStatus(false, e.message); - } -}); - -function addWeatherCityRow(label) { - const list = document.getElementById('weather-city-list'); - const empty = document.getElementById('weather-city-empty'); - if (empty) empty.remove(); - const li = document.createElement('li'); - li.className = 'checkbox-row'; - li.style.cssText = 'justify-content: space-between; margin-top: 6px;'; - const span = document.createElement('span'); - span.textContent = label; - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'btn-inline secondary weather-city-remove'; - btn.dataset.label = label; - btn.textContent = 'Remove'; - btn.addEventListener('click', removeWeatherCity); - li.appendChild(span); - li.appendChild(btn); - list.appendChild(li); -} - -async function removeWeatherCity(e) { - const label = e.target.dataset.label; - try { - const resp = await fetch(`${window.FRAME_API}/weather-cities/remove`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ label }), - }); - if (!resp.ok) throw new Error(await apiError(resp)); - e.target.closest('li').remove(); - const list = document.getElementById('weather-city-list'); - if (!list.querySelector('li')) { - list.innerHTML = '
  • No cities added yet.
  • '; - } - showStatus(true, `${label} removed.`); - loadCalendarPreview(); - } catch (e) { - showStatus(false, e.message); - } -} -document.querySelectorAll('.weather-city-remove').forEach((el) => el.addEventListener('click', removeWeatherCity)); - -document.getElementById('weather-city-add').addEventListener('click', async () => { - const input = document.getElementById('weather-city-input'); - const name = input.value.trim(); - if (!name) return; - try { - const resp = await fetch(`${window.FRAME_API}/weather-cities/add`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }), - }); - if (!resp.ok) throw new Error(await apiError(resp)); - const data = await resp.json(); - addWeatherCityRow(data.city.label); - input.value = ''; - showStatus(true, `Added ${data.city.label}.`); - loadCalendarPreview(); - } catch (e) { - showStatus(false, e.message); - } -}); - -document.getElementById('tasks_enabled').addEventListener('change', async (e) => { - const el = e.target; - try { - const resp = await fetch(`${window.FRAME_API}/config`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ calendar_tasks_enabled: String(el.checked) }), - }); - if (!resp.ok) throw new Error(await apiError(resp)); - showStatus(true, 'Saved.'); - loadCalendarPreview(); - } catch (e) { - el.checked = !el.checked; - showStatus(false, e.message); - } -}); - -// Rewrites #tasks-current-source in place instead of telling the user -// to reload -- the API always assigns a successful "set" to the caller -// (see api_tasks_source), so after either action we already know -// exactly what the new state is without asking the server again. -function renderTasksCurrentSource(label) { - const container = document.getElementById('tasks-current-source'); - container.innerHTML = ''; - if (!label) return; // matches the template's no-tasks_source branch: nothing rendered - const p = document.createElement('p'); - p.className = 'sub'; - p.style.marginTop = '10px'; - p.append('Currently using your '); - const labelEl = document.createElement('strong'); - labelEl.textContent = label; - p.append(labelEl, ' list. '); - const clearBtn = document.createElement('button'); - clearBtn.type = 'button'; - clearBtn.className = 'btn-inline secondary'; - clearBtn.id = 'tasks-source-clear'; - clearBtn.textContent = 'Clear'; - clearBtn.addEventListener('click', clearTasksSource); - p.append(clearBtn); - container.append(p); -} - -// Choosing one of your own CalDAV task lists as this frame's source -- -// owner-only (see api_frames.py's api_tasks_source), so these radios -// only ever render for the viewer's own calendars anyway. -document.querySelectorAll('.tasks-source-choice').forEach((el) => { - el.addEventListener('change', async () => { - try { - const resp = await fetch(`${window.FRAME_API}/tasks-source`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ calendar_key: el.dataset.key }), - }); - if (!resp.ok) throw new Error(await apiError(resp)); - showStatus(true, 'Task list saved.'); - const labelEl = el.closest('li').querySelector('label'); - renderTasksCurrentSource(labelEl ? labelEl.textContent.trim() : ''); - loadCalendarPreview(); - } catch (e) { - showStatus(false, e.message); - } - }); -}); - -async function clearTasksSource() { - try { - const resp = await fetch(`${window.FRAME_API}/tasks-source`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ calendar_key: null }), - }); - if (!resp.ok) throw new Error(await apiError(resp)); - showStatus(true, 'Task list cleared.'); - renderTasksCurrentSource(null); - document.querySelectorAll('.tasks-source-choice').forEach((r) => { r.checked = false; }); - loadCalendarPreview(); - } catch (e) { - showStatus(false, e.message); - } -} - -const tasksSourceClear = document.getElementById('tasks-source-clear'); -if (tasksSourceClear) { - tasksSourceClear.addEventListener('click', clearTasksSource); -} - -function loadCalendarPreview() { - document.getElementById('calendar-preview').src = `${window.FRAME_API}/preview/calendar?_=${Date.now()}`; -} -document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview); -loadCalendarPreview(); - -async function takeControl() { - try { - const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' }); - if (!resp.ok) throw new Error(await apiError(resp)); - showStatus(true, 'You have control now.'); - loadControl(); - } catch (e) { - showStatus(false, e.message); - } -} - -async function loadControl() { - const banner = document.getElementById('control-banner'); - try { - const resp = await fetch(`${window.FRAME_API}/queue`); - if (!resp.ok) return; // unconfigured frame: control still works via 409s - const data = await resp.json(); - if (data.control && !data.control.you) { - banner.style.display = 'flex'; - document.getElementById('control-holder').textContent = data.control.controller - ? `${data.control.controller} currently has control of this frame.` - : 'Nobody has control of this frame yet.'; - } else { - banner.style.display = 'none'; - } - } catch (e) { /* banner is best-effort */ } -} - -document.getElementById('take-control').addEventListener('click', takeControl); -loadControl(); diff --git a/server/app/static/frame_config.js b/server/app/static/frame_config.js index 8e9b390..3529e2d 100644 --- a/server/app/static/frame_config.js +++ b/server/app/static/frame_config.js @@ -1,11 +1,12 @@ -// Configuration tab: frame settings + firmware card + take control. -// Frame name/mode live in the page header now (frame_header.js), Calendar -// settings have their own tab (frame_calendar.js), and Order/Display mode -// live on the Photos tab (frame_photos.js) -- photos-specific settings, -// not device-wide configuration. window.FRAME_API is set by the template. -// Checkboxes are always sent explicitly as "true"/"false" -- the server -// treats absent fields as "leave unchanged", so a checkbox must never be -// simply omitted. +// Configuration tab: frame-wide settings (orientation, quiet hours, +// palette/color/contrast/dither, firmware, battery alerts) + take +// control. Frame name lives in the page header now (frame_header.js); +// every per-widget setting (album, calendar view/inclusion, whiteboard +// source) lives in its own widget's gear-icon dialog instead (see +// static/frame_layout.js) -- this tab never touches those. +// window.FRAME_API is set by the template. Checkboxes are always sent +// explicitly as "true"/"false" -- the server treats absent fields as +// "leave unchanged", so a checkbox must never be simply omitted. async function saveConfig() { const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60; @@ -72,7 +73,7 @@ async function takeControl() { async function loadControl() { const banner = document.getElementById('control-banner'); try { - const resp = await fetch(`${window.FRAME_API}/queue`); + const resp = await fetch(`${window.FRAME_API}/status`); if (!resp.ok) return; // unconfigured frame: control still works via 409s const data = await resp.json(); if (data.control && !data.control.you) { @@ -190,14 +191,22 @@ document.getElementById('palette-reset').addEventListener('click', () => { }); // ---- Preview: current photo vs. how it renders with saved settings ---- +// Scoped to this frame's photo widget (window.PHOTO_WIDGET_PREVIEW_API, +// set by the template) rather than window.FRAME_API -- palette/color/ +// contrast/dither are frame-level, but "the current photo" to preview +// them against is necessarily one specific photo widget's. Null (no +// photo widget on this frame) means the template didn't render the +// preview section at all -- nothing to wire up. function loadPreview() { + if (!window.PHOTO_WIDGET_PREVIEW_API) return; const bust = Date.now(); // avoid a stale cached image after settings change - document.getElementById('preview-original').src = `${window.FRAME_API}/preview/original?_=${bust}`; - document.getElementById('preview-rendered').src = `${window.FRAME_API}/preview/rendered?_=${bust}`; + document.getElementById('preview-original').src = `${window.PHOTO_WIDGET_PREVIEW_API}/preview/original?_=${bust}`; + document.getElementById('preview-rendered').src = `${window.PHOTO_WIDGET_PREVIEW_API}/preview/rendered?_=${bust}`; } -document.getElementById('preview-refresh').addEventListener('click', loadPreview); +const previewRefreshBtn = document.getElementById('preview-refresh'); +if (previewRefreshBtn) previewRefreshBtn.addEventListener('click', loadPreview); loadPreview(); // ---- Battery alerts card ---- diff --git a/server/app/static/frame_header.js b/server/app/static/frame_header.js index 8bb29c1..e8dc2de 100644 --- a/server/app/static/frame_header.js +++ b/server/app/static/frame_header.js @@ -1,7 +1,9 @@ -// Page-header controls shared by every per-frame page (Photos/ -// Configuration/Calendar/Whiteboard/Stats): the frame-name pencil-edit, -// living outside the tab structure since it applies regardless of which -// tab is open. Depends on window.FRAME_API (set per-page) and +// Page-header controls shared by every per-frame page (Layout/ +// Configuration/Stats): the frame-name pencil-edit, living outside the +// tab structure since it applies regardless of which tab is open. +// Depends on window.FRAME_BASE_API (a stable frame-level base set by +// every page -- unlike window.FRAME_API, which the Layout page's +// widget dialogs repoint to a widget-scoped base while one is open) and // common.js's showStatus/apiError. (function () { @@ -12,7 +14,7 @@ var textEl = document.getElementById('frame-name-text'); var saveBtn = document.getElementById('frame-name-save'); var cancelBtn = document.getElementById('frame-name-cancel'); - if (!view || !window.FRAME_API) return; + if (!view || !window.FRAME_BASE_API) return; function openEdit() { input.value = textEl.textContent.trim(); @@ -36,7 +38,7 @@ return; } try { - const resp = await fetch(`${window.FRAME_API}/config`, { + const resp = await fetch(`${window.FRAME_BASE_API}/config`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ name }), diff --git a/server/app/static/frame_layout.js b/server/app/static/frame_layout.js index 9c56bb4..a6078a8 100644 --- a/server/app/static/frame_layout.js +++ b/server/app/static/frame_layout.js @@ -191,6 +191,15 @@ function renderCanvas() { label.textContent = WIDGET_LABELS[widget.widget_type] || widget.widget_type; box.appendChild(label); + const settingsBtn = document.createElement('button'); + settingsBtn.type = 'button'; + settingsBtn.className = 'widget-box-settings'; + settingsBtn.textContent = '⚙'; + settingsBtn.title = `${WIDGET_LABELS[widget.widget_type] || widget.widget_type} settings`; + settingsBtn.addEventListener('pointerdown', (e) => e.stopPropagation()); + settingsBtn.addEventListener('click', (e) => { e.stopPropagation(); openWidgetDialog(widget); }); + box.appendChild(settingsBtn); + const removeBtn = document.createElement('button'); removeBtn.type = 'button'; removeBtn.className = 'widget-box-remove'; @@ -246,4 +255,66 @@ window.addEventListener('resize', () => { resizeTimer = setTimeout(layoutCanvas, 100); }); +// --- gear-icon dialog: each widget's own settings, fetched as an HTML +// fragment (routers/frame_pages.py's widget_dialog) and injected into a +// single shared , rather than a separate page per widget type -- +// a frame can now have several widgets of the same type, so "the +// Calendar tab" stopped meaning anything unambiguous. + +// widget_dialog_{photos,calendar,whiteboard}.js each define an +// initDialog()/closeDialog() pair (loaded unconditionally by +// frame_layout.html, since which one runs depends on which widget's gear +// icon was clicked). +const DIALOG_INIT = { photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog }; +const DIALOG_CLOSE = { photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog }; + +let openDialogWidgetType = null; + +async function openWidgetDialog(widget) { + const dialogEl = document.getElementById('widget-dialog'); + const bodyEl = document.getElementById('widget-dialog-body'); + bodyEl.innerHTML = '

    Loading...

    '; + openDialogWidgetType = widget.widget_type; + dialogEl.showModal(); + try { + const resp = await fetch(`/frames/${window.FRAME_ID}/widgets/${widget.id}/dialog`); + if (!resp.ok) throw new Error(await apiError(resp)); + bodyEl.innerHTML = await resp.text(); + // Every dialog script's fetch calls use window.FRAME_API as their + // base -- repointing it at this specific widget (instead of the + // frame-level window.FRAME_BASE_API) is what makes the SAME + // widget_dialog_photos.js/queue.js/etc. code work correctly no + // matter which widget's dialog is currently open. Restored on close. + window.FRAME_API = `${window.FRAME_BASE_API}/widgets/${widget.id}`; + const init = DIALOG_INIT[widget.widget_type]; + if (init) init(); + } catch (e) { + bodyEl.innerHTML = `

    Could not load: ${e.message}

    `; + } +} + +document.getElementById('widget-dialog-close').addEventListener('click', () => { + document.getElementById('widget-dialog').close(); +}); + +// Native doesn't close on backdrop click by default -- a click +// that lands outside the dialog's own box (but is still technically +// "on" the dialog element, since the backdrop is part of it) counts as +// a backdrop click. +document.getElementById('widget-dialog').addEventListener('click', (e) => { + const dialogEl = e.currentTarget; + if (e.target !== dialogEl) return; // click landed on dialog content, not the backdrop + const rect = dialogEl.getBoundingClientRect(); + const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom; + if (!inside) dialogEl.close(); +}); + +document.getElementById('widget-dialog').addEventListener('close', () => { + const close = DIALOG_CLOSE[openDialogWidgetType]; + if (close) close(); + openDialogWidgetType = null; + window.FRAME_API = window.FRAME_BASE_API; + document.getElementById('widget-dialog-body').innerHTML = ''; +}); + loadWidgets(); diff --git a/server/app/static/frame_photos.js b/server/app/static/frame_photos.js deleted file mode 100644 index 351190b..0000000 --- a/server/app/static/frame_photos.js +++ /dev/null @@ -1,128 +0,0 @@ -// Photos tab: now-displaying, album picker, order/display-mode settings, -// and the upcoming grid (rendering/drag logic in queue.js). window. -// FRAME_API is set by the template. - -function renderControlBanner(control) { - const banner = document.getElementById('control-banner'); - if (!banner) return; - if (!control || control.you) { - banner.style.display = 'none'; - return; - } - banner.style.display = 'flex'; - document.getElementById('control-holder').textContent = control.controller - ? `${control.controller} currently has control of this frame.` - : 'Nobody has control of this frame yet.'; -} - -async function takeControl() { - try { - const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' }); - if (!resp.ok) throw new Error(await apiError(resp)); - showStatus(true, 'You have control now.'); - loadQueue(); - } catch (e) { - showStatus(false, e.message); - } -} - -async function loadQueue() { - if (dragState) { - return; // don't yank the grid out from under an in-progress drag - } - const currentEl = document.getElementById('current-thumb'); - try { - const resp = await fetch(`${window.FRAME_API}/queue`); - if (!resp.ok) { - currentEl.innerHTML = - '

    Not available yet -- the owner needs to connect Immich (Settings) and pick an album.

    '; - renderUpcoming([]); - return; - } - const data = await resp.json(); - currentEl.innerHTML = ''; - if (data.current) { - const wrap = document.createElement('div'); - wrap.className = 'thumb-wrap'; - - const img = document.createElement('img'); - img.className = 'thumb'; - img.src = data.current.thumbnail_url; - img.alt = ''; - wrap.appendChild(img); - - const removeBtn = document.createElement('button'); - removeBtn.type = 'button'; - removeBtn.className = 'remove-btn'; - removeBtn.title = 'Remove from rotation'; - removeBtn.textContent = '×'; - removeBtn.addEventListener('click', () => removeAsset(data.current.id)); - wrap.appendChild(removeBtn); - - currentEl.appendChild(wrap); - } else { - currentEl.innerHTML = '

    Nothing displayed yet.

    '; - } - renderControlBanner(data.control); - renderUpcoming(data.upcoming); - } catch (e) { - currentEl.innerHTML = '

    Could not load.

    '; - } -} - -async function savePhotoSettings() { - const body = new URLSearchParams({ - album_id: document.getElementById('album_id').value || '', - queue_target_len: document.getElementById('queue_target_len').value, - order: document.getElementById('order').value, - display_mode: document.getElementById('display_mode').value, - }); - const resp = await fetch(`${window.FRAME_API}/config`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body, - }); - if (!resp.ok) { - throw new Error(await apiError(resp)); - } -} - -document.getElementById('load-albums').addEventListener('click', async () => { - try { - const resp = await fetch(`${window.FRAME_API}/albums`); - if (!resp.ok) { - throw new Error(await apiError(resp)); - } - const albums = await resp.json(); - - const select = document.getElementById('album_id'); - select.innerHTML = ''; - for (const a of albums) { - const opt = document.createElement('option'); - opt.value = a.id; - opt.textContent = `${a.name} (${a.count})`; - select.appendChild(opt); - } - showStatus(true, `Loaded ${albums.length} album(s) -- pick one and click Save.`); - } catch (e) { - showStatus(false, e.message); - } -}); - -document.getElementById('photos-form').addEventListener('submit', async (e) => { - e.preventDefault(); - try { - await savePhotoSettings(); - showStatus(true, 'Saved.'); - loadQueue(); - } catch (e) { - showStatus(false, e.message); - } -}); - -document.getElementById('take-control').addEventListener('click', takeControl); - -loadQueue(); -// Slow poll: picks up real changes (new photo displayed, queue edited -// from elsewhere) without a manual refresh. Skipped mid-drag. -setInterval(loadQueue, 10000); diff --git a/server/app/static/frame_whiteboard.js b/server/app/static/frame_whiteboard.js deleted file mode 100644 index 13de92d..0000000 --- a/server/app/static/frame_whiteboard.js +++ /dev/null @@ -1,183 +0,0 @@ -// Whiteboard tab: source URL (owner-gated, see api_frames.py's -// api_whiteboard_source), preview, and take control. window.FRAME_API is -// set by the template. - -// Rewrites #whiteboard-current-source in place instead of telling the -// user to reload -- the API always assigns a successful "set" to the -// caller (see api_whiteboard_source), so after either action we already -// know exactly what the new state is without asking the server again. -function renderWhiteboardCurrentSource(url) { - const container = document.getElementById('whiteboard-current-source'); - container.innerHTML = ''; - const p = document.createElement('p'); - p.className = 'sub'; - p.style.marginTop = '10px'; - if (url) { - p.append('Currently showing '); - const urlEl = document.createElement('strong'); - urlEl.textContent = url; - p.append(urlEl, ' using your WebDAV account. '); - const clearBtn = document.createElement('button'); - clearBtn.type = 'button'; - clearBtn.className = 'btn-inline secondary'; - clearBtn.id = 'whiteboard-source-clear'; - clearBtn.textContent = 'Clear'; - clearBtn.addEventListener('click', clearWhiteboardSource); - p.append(clearBtn); - } else { - p.textContent = 'No whiteboard configured yet.'; - } - container.append(p); - - const label = document.getElementById('whiteboard-source-form-label'); - if (label) { - label.textContent = url ? 'Change to one of your own files' : 'Use one of your own files'; - } -} - -const whiteboardForm = document.getElementById('whiteboard-source-form'); -if (whiteboardForm) { - whiteboardForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const url = document.getElementById('whiteboard-url-input').value.trim(); - if (!url) return; - try { - const resp = await fetch(`${window.FRAME_API}/whiteboard-source`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url }), - }); - if (!resp.ok) throw new Error(await apiError(resp)); - showStatus(true, 'Saved.'); - renderWhiteboardCurrentSource(url); - loadWhiteboardPreview(); - } catch (e) { - showStatus(false, e.message); - } - }); -} - -async function clearWhiteboardSource() { - try { - const resp = await fetch(`${window.FRAME_API}/whiteboard-source`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: null }), - }); - if (!resp.ok) throw new Error(await apiError(resp)); - showStatus(true, 'Cleared.'); - renderWhiteboardCurrentSource(null); - const urlInput = document.getElementById('whiteboard-url-input'); - if (urlInput) urlInput.value = ''; - loadWhiteboardPreview(); - } catch (e) { - showStatus(false, e.message); - } -} - -const whiteboardClearBtn = document.getElementById('whiteboard-source-clear'); -if (whiteboardClearBtn) { - whiteboardClearBtn.addEventListener('click', clearWhiteboardSource); -} - -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 = '
  • Loading...
  • '; - 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 = '
  • (empty folder)
  • '; - } - 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); - } - }); -} - -async function takeControl() { - try { - const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' }); - if (!resp.ok) throw new Error(await apiError(resp)); - showStatus(true, 'You have control now.'); - loadControl(); - } catch (e) { - showStatus(false, e.message); - } -} - -async function loadControl() { - const banner = document.getElementById('control-banner'); - try { - const resp = await fetch(`${window.FRAME_API}/queue`); - if (!resp.ok) return; // unconfigured frame: control still works via 409s - const data = await resp.json(); - if (data.control && !data.control.you) { - banner.style.display = 'flex'; - document.getElementById('control-holder').textContent = data.control.controller - ? `${data.control.controller} currently has control of this frame.` - : 'Nobody has control of this frame yet.'; - } else { - banner.style.display = 'none'; - } - } catch (e) { /* banner is best-effort */ } -} - -document.getElementById('take-control').addEventListener('click', takeControl); -loadControl(); diff --git a/server/app/static/queue.js b/server/app/static/queue.js index b9983df..6531c46 100644 --- a/server/app/static/queue.js +++ b/server/app/static/queue.js @@ -3,8 +3,9 @@ // machine below (hold-to-arm on touch so page scrolling still works) is // battle-tested; treat changes with suspicion. // -// Expects window.FRAME_API = '/api/frames/' set by the page, and a -// loadQueue() global (frame_photos.js) to refetch authoritative state. +// Expects window.FRAME_API = '/api/frames//widgets/' (set +// by frame_layout.js when the photos dialog opens), and a loadQueue() +// global (widget_dialog_photos.js) to refetch authoritative state. let upcomingItems = []; diff --git a/server/app/static/settings.js b/server/app/static/settings.js index b21e98c..ea273a2 100644 --- a/server/app/static/settings.js +++ b/server/app/static/settings.js @@ -1,7 +1,7 @@ // Settings page: "Discover calendars" against the CalDAV account already -// saved on this form (same idiom as frame_photos.js's Load Albums using -// the frame's already-saved Immich creds) -- so this only works after -// the CalDAV URL/username/password have been saved once. +// saved on this form (same idiom as widget_dialog_photos.js's Load +// Albums using the frame's already-saved Immich creds) -- so this only +// works after the CalDAV URL/username/password have been saved once. // Hides the dedicated WebDAV username/password fields while "reuse my // CalDAV creds" is checked -- they'd be ignored server-side anyway (see diff --git a/server/app/static/theme.css b/server/app/static/theme.css index f40b088..a3ce890 100644 --- a/server/app/static/theme.css +++ b/server/app/static/theme.css @@ -360,10 +360,9 @@ button.secondary:hover { background: var(--surface-alt); } color: var(--text); pointer-events: none; } -.widget-box-remove { +.widget-box-remove, .widget-box-settings { position: absolute; top: 4px; - right: 4px; width: 20px; height: 20px; padding: 0; @@ -373,9 +372,12 @@ button.secondary:hover { background: var(--surface-alt); } border: none; background: var(--overlay); color: #fff; + font-size: 12px; cursor: pointer; } -.widget-box-remove:hover { background: var(--overlay-hover); } +.widget-box-remove { right: 4px; } +.widget-box-settings { right: 28px; } +.widget-box-remove:hover, .widget-box-settings:hover { background: var(--overlay-hover); } .widget-box-resize-handle { position: absolute; bottom: 0; @@ -389,6 +391,32 @@ button.secondary:hover { background: var(--surface-alt); } border-bottom-right-radius: 4px; } +#widget-dialog { + position: fixed; + margin: auto; + width: min(680px, calc(100vw - 32px)); + max-height: min(720px, calc(100vh - 64px)); + padding: 24px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface); + color: var(--text); + box-shadow: var(--shadow-hover); +} +#widget-dialog::backdrop { background: var(--overlay); } +#widget-dialog-close { + position: absolute; + top: 14px; + right: 14px; + width: 30px; + height: 30px; + font-size: 18px; + z-index: 1; +} +.dialog-title { margin: 0 40px 16px 0; font-size: 18px; } +#widget-dialog-body .card { box-shadow: none; } +#widget-dialog-body .card:first-child { margin-top: 0; } + code { background: var(--surface-alt); color: var(--text); diff --git a/server/app/static/widget_dialog_calendar.js b/server/app/static/widget_dialog_calendar.js new file mode 100644 index 0000000..93e3a8d --- /dev/null +++ b/server/app/static/widget_dialog_calendar.js @@ -0,0 +1,290 @@ +// Calendar widget dialog: view/week-start settings, per-user opt-in, +// weather, tasks, and the rendered preview. Not a page-load script -- +// frame_layout.js fetches this widget's dialog HTML fragment, injects +// it into the shared , points window.FRAME_API at this specific +// widget (/api/frames/{id}/widgets/{widget_id}), then calls +// initCalendarDialog(). Checkboxes are always sent explicitly as +// "true"/"false". + +// Week-view-only settings (days/layout/start-offset) only matter when +// View is actually "Week"; "Week starts on" also matters for Month, so +// it gets its own, slightly looser condition. The start-offset row is +// further gated on the day count -- it's meaningless at the default 7 +// days, where "Week starts on" governs instead (see +// calendar_render.py's _build_week). +function updateCalendarFieldVisibility() { + const view = document.getElementById('calendar_view').value; + const days = Number(document.getElementById('calendar_week_days').value); + const isWeek = view === 'week'; + document.getElementById('calendar-week-start-row').style.display = + (view === 'week' || view === 'month') ? '' : 'none'; + document.getElementById('calendar-week-days-row').style.display = isWeek ? '' : 'none'; + document.getElementById('calendar-week-layout-row').style.display = isWeek ? '' : 'none'; + document.getElementById('calendar-week-offset-row').style.display = (isWeek && days !== 7) ? '' : 'none'; +} + +function addWeatherCityRow(label) { + const list = document.getElementById('weather-city-list'); + const empty = document.getElementById('weather-city-empty'); + if (empty) empty.remove(); + const li = document.createElement('li'); + li.className = 'checkbox-row'; + li.style.cssText = 'justify-content: space-between; margin-top: 6px;'; + const span = document.createElement('span'); + span.textContent = label; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn-inline secondary weather-city-remove'; + btn.dataset.label = label; + btn.textContent = 'Remove'; + btn.addEventListener('click', removeWeatherCity); + li.appendChild(span); + li.appendChild(btn); + list.appendChild(li); +} + +async function removeWeatherCity(e) { + const label = e.target.dataset.label; + try { + const resp = await fetch(`${window.FRAME_API}/weather-cities/remove`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ label }), + }); + if (!resp.ok) throw new Error(await apiError(resp)); + e.target.closest('li').remove(); + const list = document.getElementById('weather-city-list'); + if (!list.querySelector('li')) { + list.innerHTML = '
  • No cities added yet.
  • '; + } + showStatus(true, `${label} removed.`); + loadCalendarPreview(); + } catch (e) { + showStatus(false, e.message); + } +} + +// Rewrites #tasks-current-source in place instead of telling the user +// to reload -- the API always assigns a successful "set" to the caller +// (see api_widget_tasks_source), so after either action we already know +// exactly what the new state is without asking the server again. +function renderTasksCurrentSource(label) { + const container = document.getElementById('tasks-current-source'); + container.innerHTML = ''; + if (!label) return; // matches the template's no-tasks_source branch: nothing rendered + const p = document.createElement('p'); + p.className = 'sub'; + p.style.marginTop = '10px'; + p.append('Currently using your '); + const labelEl = document.createElement('strong'); + labelEl.textContent = label; + p.append(labelEl, ' list. '); + const clearBtn = document.createElement('button'); + clearBtn.type = 'button'; + clearBtn.className = 'btn-inline secondary'; + clearBtn.id = 'tasks-source-clear'; + clearBtn.textContent = 'Clear'; + clearBtn.addEventListener('click', clearTasksSource); + p.append(clearBtn); + container.append(p); +} + +async function clearTasksSource() { + try { + const resp = await fetch(`${window.FRAME_API}/tasks-source`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ calendar_key: null }), + }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, 'Task list cleared.'); + renderTasksCurrentSource(null); + document.querySelectorAll('.tasks-source-choice').forEach((r) => { r.checked = false; }); + loadCalendarPreview(); + } catch (e) { + showStatus(false, e.message); + } +} + +function loadCalendarPreview() { + document.getElementById('calendar-preview').src = `${window.FRAME_API}/preview/calendar?_=${Date.now()}`; +} + +function initCalendarDialog() { + document.getElementById('calendar_view').addEventListener('change', updateCalendarFieldVisibility); + document.getElementById('calendar_week_days').addEventListener('input', updateCalendarFieldVisibility); + updateCalendarFieldVisibility(); + + document.getElementById('calendar-config-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const body = new URLSearchParams({ + calendar_view: document.getElementById('calendar_view').value, + calendar_week_start: document.getElementById('calendar_week_start').value, + calendar_week_days: document.getElementById('calendar_week_days').value, + calendar_week_layout: document.getElementById('calendar_week_layout').value, + calendar_week_start_offset: document.getElementById('calendar_week_start_offset').value, + }); + try { + const resp = await fetch(`${window.FRAME_API}/config`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, 'Saved.'); + loadCalendarPreview(); + } catch (e) { + showStatus(false, e.message); + } + }); + + // Each calendar's own include/mute toggle -- auto-saves on change, not + // batched into the form above, since it's a data-sharing choice (see + // api_widget_calendar_select), not a widget-wide setting. Works the + // same element for your own calendars (full add/remove) and other + // people's (mute only) -- the server enforces which direction is + // allowed and this just reverts the checkbox with an error message if + // rejected. + document.querySelectorAll('.calendar-toggle').forEach((el) => { + el.addEventListener('change', async () => { + try { + const resp = await fetch(`${window.FRAME_API}/calendar-select`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: Number(el.dataset.userId), + calendar_key: el.dataset.key, + calendar_label: el.dataset.label, + included: el.checked, + }), + }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, el.checked ? 'Calendar included on this widget.' : 'Calendar removed from this widget.'); + } catch (e) { + el.checked = !el.checked; + showStatus(false, e.message); + } + }); + }); + + // Per-calendar color pin -- owner-only (the server enforces it; these + // buttons only ever render for the viewer's own calendars anyway). + // Clicking the currently-selected swatch again has no special + // "toggle off" behavior -- use the explicit Auto button. + document.querySelectorAll('.calendar-color-picker').forEach((picker) => { + const key = picker.dataset.key; + picker.querySelectorAll('.color-swatch').forEach((btn) => { + btn.addEventListener('click', async () => { + const colorIndex = btn.dataset.index === '' ? null : Number(btn.dataset.index); + try { + const resp = await fetch(`${window.FRAME_API}/calendar-color`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ calendar_key: key, color_index: colorIndex }), + }); + if (!resp.ok) throw new Error(await apiError(resp)); + picker.querySelectorAll('.color-swatch').forEach((el) => el.classList.remove('selected')); + btn.classList.add('selected'); + showStatus(true, 'Color saved.'); + loadCalendarPreview(); + } catch (e) { + showStatus(false, e.message); + } + }); + }); + }); + + document.getElementById('weather-config-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const body = new URLSearchParams({ + calendar_weather_enabled: String(document.getElementById('weather_enabled').checked), + calendar_weather_units: document.getElementById('weather_units').value, + }); + try { + const resp = await fetch(`${window.FRAME_API}/config`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, 'Saved.'); + loadCalendarPreview(); + } catch (e) { + showStatus(false, e.message); + } + }); + + document.querySelectorAll('.weather-city-remove').forEach((el) => el.addEventListener('click', removeWeatherCity)); + + document.getElementById('weather-city-add').addEventListener('click', async () => { + const input = document.getElementById('weather-city-input'); + const name = input.value.trim(); + if (!name) return; + try { + const resp = await fetch(`${window.FRAME_API}/weather-cities/add`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + if (!resp.ok) throw new Error(await apiError(resp)); + const data = await resp.json(); + addWeatherCityRow(data.city.label); + input.value = ''; + showStatus(true, `Added ${data.city.label}.`); + loadCalendarPreview(); + } catch (e) { + showStatus(false, e.message); + } + }); + + document.getElementById('tasks_enabled').addEventListener('change', async (e) => { + const el = e.target; + try { + const resp = await fetch(`${window.FRAME_API}/config`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ calendar_tasks_enabled: String(el.checked) }), + }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, 'Saved.'); + loadCalendarPreview(); + } catch (e) { + el.checked = !el.checked; + showStatus(false, e.message); + } + }); + + // Choosing one of your own CalDAV task lists as this widget's source -- + // owner-only (see api_widget_tasks_source), so these radios only ever + // render for the viewer's own calendars anyway. + document.querySelectorAll('.tasks-source-choice').forEach((el) => { + el.addEventListener('change', async () => { + try { + const resp = await fetch(`${window.FRAME_API}/tasks-source`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ calendar_key: el.dataset.key }), + }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, 'Task list saved.'); + const labelEl = el.closest('li').querySelector('label'); + renderTasksCurrentSource(labelEl ? labelEl.textContent.trim() : ''); + loadCalendarPreview(); + } catch (e) { + showStatus(false, e.message); + } + }); + }); + + const tasksSourceClear = document.getElementById('tasks-source-clear'); + if (tasksSourceClear) { + tasksSourceClear.addEventListener('click', clearTasksSource); + } + + document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview); + loadCalendarPreview(); +} + +function closeCalendarDialog() { + // Nothing to tear down -- no poll interval, unlike the photos dialog. +} diff --git a/server/app/static/widget_dialog_photos.js b/server/app/static/widget_dialog_photos.js new file mode 100644 index 0000000..81f6aae --- /dev/null +++ b/server/app/static/widget_dialog_photos.js @@ -0,0 +1,116 @@ +// Photos widget dialog: now-displaying, album picker, order/display-mode +// settings, and the upcoming grid (rendering/drag logic in queue.js). +// Not a page-load script -- frame_layout.js fetches this widget's dialog +// HTML fragment, injects it into the shared , points +// window.FRAME_API at this specific widget (/api/frames/{id}/widgets/ +// {widget_id}), then calls initPhotosDialog(). closePhotosDialog() stops +// the poll interval when the dialog closes, same "expects window. +// FRAME_API + a global loadQueue()" contract queue.js has always had. + +let photosPollTimer = null; + +async function loadQueue() { + if (dragState) { + return; // don't yank the grid out from under an in-progress drag + } + const currentEl = document.getElementById('current-thumb'); + if (!currentEl) return; // dialog closed mid-flight + try { + const resp = await fetch(`${window.FRAME_API}/queue`); + if (!resp.ok) { + currentEl.innerHTML = + '

    Not available yet -- the owner needs to connect Immich (Settings) and pick an album.

    '; + renderUpcoming([]); + return; + } + const data = await resp.json(); + currentEl.innerHTML = ''; + if (data.current) { + const wrap = document.createElement('div'); + wrap.className = 'thumb-wrap'; + + const img = document.createElement('img'); + img.className = 'thumb'; + img.src = data.current.thumbnail_url; + img.alt = ''; + wrap.appendChild(img); + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'remove-btn'; + removeBtn.title = 'Remove from rotation'; + removeBtn.textContent = '×'; + removeBtn.addEventListener('click', () => removeAsset(data.current.id)); + wrap.appendChild(removeBtn); + + currentEl.appendChild(wrap); + } else { + currentEl.innerHTML = '

    Nothing displayed yet.

    '; + } + renderUpcoming(data.upcoming); + } catch (e) { + currentEl.innerHTML = '

    Could not load.

    '; + } +} + +async function savePhotoSettings() { + const body = new URLSearchParams({ + album_id: document.getElementById('album_id').value || '', + queue_target_len: document.getElementById('queue_target_len').value, + order: document.getElementById('order').value, + display_mode: document.getElementById('display_mode').value, + }); + const resp = await fetch(`${window.FRAME_API}/config`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + if (!resp.ok) { + throw new Error(await apiError(resp)); + } +} + +function initPhotosDialog() { + document.getElementById('load-albums').addEventListener('click', async () => { + try { + const resp = await fetch(`${window.FRAME_API}/albums`); + if (!resp.ok) { + throw new Error(await apiError(resp)); + } + const albums = await resp.json(); + + const select = document.getElementById('album_id'); + select.innerHTML = ''; + for (const a of albums) { + const opt = document.createElement('option'); + opt.value = a.id; + opt.textContent = `${a.name} (${a.count})`; + select.appendChild(opt); + } + showStatus(true, `Loaded ${albums.length} album(s) -- pick one and click Save.`); + } catch (e) { + showStatus(false, e.message); + } + }); + + document.getElementById('photos-form').addEventListener('submit', async (e) => { + e.preventDefault(); + try { + await savePhotoSettings(); + showStatus(true, 'Saved.'); + loadQueue(); + } catch (e) { + showStatus(false, e.message); + } + }); + + loadQueue(); + // Slow poll: picks up real changes (new photo displayed, queue edited + // from elsewhere) without a manual refresh. Skipped mid-drag. + photosPollTimer = setInterval(loadQueue, 10000); +} + +function closePhotosDialog() { + clearInterval(photosPollTimer); + photosPollTimer = null; +} diff --git a/server/app/static/widget_dialog_whiteboard.js b/server/app/static/widget_dialog_whiteboard.js new file mode 100644 index 0000000..14f9cdf --- /dev/null +++ b/server/app/static/widget_dialog_whiteboard.js @@ -0,0 +1,163 @@ +// Whiteboard widget dialog: source URL (owner-gated, see +// api_widgets.py's api_widget_whiteboard_source), preview, and the file +// browser. Not a page-load script -- frame_layout.js fetches this +// widget's dialog HTML fragment, injects it into the shared , +// points window.FRAME_API at this specific widget (/api/frames/{id}/ +// widgets/{widget_id}), then calls initWhiteboardDialog(). + +// Rewrites #whiteboard-current-source in place instead of telling the +// user to reload -- the API always assigns a successful "set" to the +// caller (see api_widget_whiteboard_source), so after either action we +// already know exactly what the new state is without asking the server +// again. +function renderWhiteboardCurrentSource(url) { + const container = document.getElementById('whiteboard-current-source'); + container.innerHTML = ''; + const p = document.createElement('p'); + p.className = 'sub'; + p.style.marginTop = '10px'; + if (url) { + p.append('Currently showing '); + const urlEl = document.createElement('strong'); + urlEl.textContent = url; + p.append(urlEl, ' using your WebDAV account. '); + const clearBtn = document.createElement('button'); + clearBtn.type = 'button'; + clearBtn.className = 'btn-inline secondary'; + clearBtn.id = 'whiteboard-source-clear'; + clearBtn.textContent = 'Clear'; + clearBtn.addEventListener('click', clearWhiteboardSource); + p.append(clearBtn); + } else { + p.textContent = 'No whiteboard configured yet.'; + } + container.append(p); + + const label = document.getElementById('whiteboard-source-form-label'); + if (label) { + label.textContent = url ? 'Change to one of your own files' : 'Use one of your own files'; + } +} + +async function clearWhiteboardSource() { + try { + const resp = await fetch(`${window.FRAME_API}/whiteboard-source`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: null }), + }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, 'Cleared.'); + renderWhiteboardCurrentSource(null); + const urlInput = document.getElementById('whiteboard-url-input'); + if (urlInput) urlInput.value = ''; + loadWhiteboardPreview(false); + } catch (e) { + showStatus(false, e.message); + } +} + +function loadWhiteboardPreview(force) { + const forceParam = force ? '&force=1' : ''; + document.getElementById('whiteboard-preview').src = `${window.FRAME_API}/preview/whiteboard?_=${Date.now()}${forceParam}`; +} + +function initWhiteboardDialog() { + const whiteboardForm = document.getElementById('whiteboard-source-form'); + if (whiteboardForm) { + whiteboardForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const url = document.getElementById('whiteboard-url-input').value.trim(); + if (!url) return; + try { + const resp = await fetch(`${window.FRAME_API}/whiteboard-source`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }), + }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, 'Saved.'); + renderWhiteboardCurrentSource(url); + loadWhiteboardPreview(false); + } catch (e) { + showStatus(false, e.message); + } + }); + } + + const whiteboardClearBtn = document.getElementById('whiteboard-source-clear'); + if (whiteboardClearBtn) { + whiteboardClearBtn.addEventListener('click', clearWhiteboardSource); + } + + // Shows whatever's already cached (cheap, no refetch) on open; the + // button is the one place that means "no really, go check now" -- + // bypasses the fetch throttle server-side (see api_widget_preview_ + // whiteboard's `force` param). + 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 = '
  • Loading...
  • '; + 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 = '
  • (empty folder)
  • '; + } + 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); + } + }); + } +} + +function closeWhiteboardDialog() { + // Nothing to tear down -- no poll interval, unlike the photos dialog. +} diff --git a/server/app/templates/_frame_tabs.html b/server/app/templates/_frame_tabs.html index feaa5ea..5f034dc 100644 --- a/server/app/templates/_frame_tabs.html +++ b/server/app/templates/_frame_tabs.html @@ -1,10 +1,5 @@ diff --git a/server/app/templates/_widget_dialog_calendar.html b/server/app/templates/_widget_dialog_calendar.html new file mode 100644 index 0000000..2dfd5d9 --- /dev/null +++ b/server/app/templates/_widget_dialog_calendar.html @@ -0,0 +1,173 @@ +

    Calendar widget

    + +
    +

    Calendar

    +
    + +
    + +

    Only affects the Week and Month views, and (for Week) only at 7 days.

    +
    +
    + +
    +
    + +
    +
    + +

    0 = starts today, negative = starts in the + past, positive = starts in the future. Only used when Days to show isn't 7.

    +
    + +
    + +

    Included calendars

    +

    Each linked person adds their own calendars (ICS + subscription or CalDAV, set up in Settings) + -- being linked here doesn't include anything automatically. + Anyone linked to this frame can mute a calendar they'd rather + not see here, even one they don't own; only its owner can add + it back.

    +
      + {% for u in calendar_users %} +
    • +

      {{ u.display_name }}{% if u.is_self %} (you){% endif %}

      + {% if u.calendars %} + {% set current_palette = frame.palette_rgb or default_palette_rgb %} + {% set current_hex = palette_to_hex(current_palette) %} + {% for c in u.calendars %} +
      + + + {% if u.is_self %} + + {% for idx in range(2, 6) %} + + {% endfor %} + + + {% endif %} +
      + {% endfor %} + {% elif u.is_self %} +

      No calendars set up yet -- add an ICS link or CalDAV account in Settings.

      + {% else %} +

      No calendars included.

      + {% endif %} +
    • + {% endfor %} +
    + + {% if calendar_cfg.fetch_summary %} +

    Last fetch: {{ calendar_cfg.fetch_summary }}

    + {% endif %} +
    + +
    +

    Weather

    +

    Shown above the event list on Agenda, Agenda (today + & tomorrow), and Week views -- there's no room for it on Month.

    +
    +
    + + +
    + + +
    + +

    Cities

    +

    Every city shows on every day -- add more than one if + people split their time between places.

    +
      + {% for c in calendar_cfg.weather_cities or [] %} +
    • + {{ c.label }} + +
    • + {% else %} +
    • No cities added yet.
    • + {% endfor %} +
    +
    + + +
    +
    + +
    +

    Tasks

    +

    Week view only -- takes the place of one day slot + instead of adding an extra one.

    +
    + + +
    + +
    + {% if tasks_source %} +

    + Currently using {{ tasks_source.display_name }}'s + {{ tasks_source.label }} list. + +

    + {% endif %} +
    + + {% if viewer_task_calendars %} +

    Use one of your own CalDAV task lists:

    +
      + {% for c in viewer_task_calendars %} +
    • + + +
    • + {% endfor %} +
    + {% else %} +

    You don't have any CalDAV + task lists available -- set up a CalDAV account in + Settings first (a plain ICS subscription + doesn't carry tasks).

    + {% endif %} +
    + +
    +

    Preview

    +

    How this widget currently renders.

    + Calendar preview + +
    diff --git a/server/app/templates/_widget_dialog_photos.html b/server/app/templates/_widget_dialog_photos.html new file mode 100644 index 0000000..fc0bb77 --- /dev/null +++ b/server/app/templates/_widget_dialog_photos.html @@ -0,0 +1,55 @@ +

    Photos widget

    + +
    +

    Album

    +
    + + + + +

    How a photo's aspect ratio + is reconciled with the panel's: Crop to fill + trims the excess; Crop to faces does the same + but shifts the crop to keep people on screen; Stretch to + fill fills the panel exactly without cropping (photos + not matching the panel's aspect ratio look stretched); + Shrink to fit shows the whole photo, letterboxed + if needed.

    + + +
    +
    + +
    +

    Now displaying

    +

    Loading...

    +
    + +
    +

    Upcoming

    +

    Drag a photo to reorder (on touch, hold briefly first so a + normal scroll still works), "Show next" to jump it to the front, or + the × to remove it from rotation entirely.

    +
    +
    diff --git a/server/app/templates/_widget_dialog_whiteboard.html b/server/app/templates/_widget_dialog_whiteboard.html new file mode 100644 index 0000000..311c1d3 --- /dev/null +++ b/server/app/templates/_widget_dialog_whiteboard.html @@ -0,0 +1,58 @@ +

    Whiteboard widget

    + +
    +

    Whiteboard source

    +

    Renders a Nextcloud Whiteboard (or any Excalidraw + scene) fetched over WebDAV -- credentials set up in + Settings.

    + +
    + {% if whiteboard_source %} +

    + Currently showing {{ whiteboard_source.url }} + using {{ whiteboard_source.display_name }}'s + WebDAV account. + +

    + {% else %} +

    No whiteboard configured yet.

    + {% endif %} +
    + + {% if viewer_has_webdav_creds %} +
    + +

    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>/. +

    + + + + +
    + {% else %} +

    Set up WebDAV credentials + in Settings first to point this widget at + one of your own files.

    + {% endif %} +
    + +
    +

    Preview

    +

    How this widget currently renders.

    + Whiteboard preview + +
    diff --git a/server/app/templates/frame_calendar.html b/server/app/templates/frame_calendar.html deleted file mode 100644 index 98990b9..0000000 --- a/server/app/templates/frame_calendar.html +++ /dev/null @@ -1,206 +0,0 @@ -{% extends "app_base.html" %} - -{% block title %}{{ frame.name or "Frame" }} · Calendar{% endblock %} -{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %} - -{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %} -{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %} - -{% block content %} - - - {% if not has_calendar_widget %} -
    This frame doesn't have a Calendar widget on - screen yet -- settings below won't show up anywhere until one is added.
    - {% endif %} - -
    -
    -
    -

    Calendar

    -
    - -
    - -

    Only affects the Week and Month views, and (for Week) only at 7 days.

    -
    -
    - -
    -
    - -
    -
    - -

    0 = starts today, negative = starts in the - past, positive = starts in the future. Only used when Days to show isn't 7.

    -
    - -
    - -

    Included calendars

    -

    Each linked person adds their own calendars (ICS - subscription or CalDAV, set up in Settings) - -- being linked here doesn't include anything automatically. - Anyone linked to this frame can mute a calendar they'd rather - not see here, even one they don't own; only its owner can add - it back.

    -
      - {% for u in calendar_users %} -
    • -

      {{ u.display_name }}{% if u.is_self %} (you){% endif %}

      - {% if u.calendars %} - {% set current_palette = frame.palette_rgb or default_palette_rgb %} - {% set current_hex = palette_to_hex(current_palette) %} - {% for c in u.calendars %} -
      - - - {% if u.is_self %} - - {% for idx in range(2, 6) %} - - {% endfor %} - - - {% endif %} -
      - {% endfor %} - {% elif u.is_self %} -

      No calendars set up yet -- add an ICS link or CalDAV account in Settings.

      - {% else %} -

      No calendars included.

      - {% endif %} -
    • - {% endfor %} -
    - - {% if calendar_cfg and calendar_cfg.fetch_summary %} -

    Last fetch: {{ calendar_cfg.fetch_summary }}

    - {% endif %} -
    - -
    -

    Weather

    -

    Shown above the event list on Agenda, Agenda (today - & tomorrow), and Week views -- there's no room for it on Month.

    -
    -
    - - -
    - - -
    - -

    Cities

    -

    Every city shows on every day -- add more than one if - people split their time between places.

    -
      - {% for c in (calendar_cfg.weather_cities if calendar_cfg else []) or [] %} -
    • - {{ c.label }} - -
    • - {% else %} -
    • No cities added yet.
    • - {% endfor %} -
    -
    - - -
    -
    - -
    -

    Tasks

    -

    Week view only -- takes the place of one day slot - instead of adding an extra one.

    -
    - - -
    - -
    - {% if tasks_source %} -

    - Currently using {{ tasks_source.display_name }}'s - {{ tasks_source.label }} list. - -

    - {% endif %} -
    - - {% if viewer_task_calendars %} -

    Use one of your own CalDAV task lists:

    -
      - {% for c in viewer_task_calendars %} -
    • - - -
    • - {% endfor %} -
    - {% else %} -

    You don't have any CalDAV - task lists available -- set up a CalDAV account in - Settings first (a plain ICS subscription - doesn't carry tasks).

    - {% endif %} -
    -
    - -
    -
    -

    Preview

    -

    How this frame's calendar currently renders.

    - Calendar preview - -
    -
    -
    - -
    -{% endblock %} - -{% block scripts %} - - - - -{% endblock %} diff --git a/server/app/templates/frame_config.html b/server/app/templates/frame_config.html index 1faf756..3d6e8ba 100644 --- a/server/app/templates/frame_config.html +++ b/server/app/templates/frame_config.html @@ -158,19 +158,25 @@

    Preview

    -

    The current photo, and exactly how it renders on the - panel with this frame's saved settings above.

    -
    -
    -

    Now displaying

    - Original photo + {% if photo_widget_id %} +

    The current photo (from this frame's photo widget), and + exactly how it renders on the panel with this frame's saved settings + above.

    +
    +
    +

    Now displaying

    + Original photo +
    +
    +

    How it will look on the frame

    + Rendered preview +
    -
    -

    How it will look on the frame

    - Rendered preview -
    -
    - + + {% else %} +

    This frame doesn't have a photo widget to preview + against yet -- add one from the Layout tab.

    + {% endif %}
    @@ -180,8 +186,9 @@ {% block scripts %} diff --git a/server/app/templates/frame_layout.html b/server/app/templates/frame_layout.html index e81b101..6998fd0 100644 --- a/server/app/templates/frame_layout.html +++ b/server/app/templates/frame_layout.html @@ -18,8 +18,8 @@

    Widgets

    Drag a widget to move it, drag its bottom-right corner to resize it -- like arranging widgets on a phone's home screen. - Widgets can't overlap. Each widget's own settings (which album, - which calendars, etc.) live on its type's own tab.

    + Widgets can't overlap. Click a widget's gear icon for its own + settings (which album, which calendars, etc.).

    @@ -35,12 +35,24 @@ + + +

    Loading...

    +
    +
    {% endblock %} {% block scripts %} - + + + + + {% endblock %} diff --git a/server/app/templates/frame_photos.html b/server/app/templates/frame_photos.html deleted file mode 100644 index 62ce625..0000000 --- a/server/app/templates/frame_photos.html +++ /dev/null @@ -1,84 +0,0 @@ -{% extends "app_base.html" %} - -{% block title %}{{ frame.name or "Frame" }} · Photos{% endblock %} -{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %} - -{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %} -{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %} - -{% block content %} - - -
    -
    -
    -

    Album

    -
    - - - - -

    How a photo's aspect ratio - is reconciled with the panel's: Crop to fill - trims the excess; Crop to faces does the same - but shifts the crop to keep people on screen; Stretch to - fill fills the panel exactly without cropping (photos - not matching the panel's aspect ratio look stretched); - Shrink to fit shows the whole photo, letterboxed - if needed.

    - - -
    -
    -
    - -
    -
    -

    Now displaying

    -

    Loading...

    -
    -
    -
    - -
    -

    Upcoming

    -

    Drag a photo to reorder (on touch, hold briefly first so a - normal scroll still works), "Show next" to jump it to the front, or - the × to remove it from rotation entirely.

    -
    -
    - -
    -{% endblock %} - -{% block scripts %} - - - - - -{% endblock %} diff --git a/server/app/templates/frame_stats.html b/server/app/templates/frame_stats.html index fc66e25..35e949f 100644 --- a/server/app/templates/frame_stats.html +++ b/server/app/templates/frame_stats.html @@ -21,7 +21,7 @@ {% endblock %} {% block scripts %} - + diff --git a/server/app/templates/frame_whiteboard.html b/server/app/templates/frame_whiteboard.html deleted file mode 100644 index f627fcc..0000000 --- a/server/app/templates/frame_whiteboard.html +++ /dev/null @@ -1,91 +0,0 @@ -{% extends "app_base.html" %} - -{% block title %}{{ frame.name or "Frame" }} · Whiteboard{% endblock %} -{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %} - -{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %} -{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %} - -{% block content %} - - - {% if not has_whiteboard_widget %} -
    This frame doesn't have a Whiteboard widget on - screen yet -- settings below won't show up anywhere until one is added.
    - {% endif %} - -
    -
    -
    -

    Whiteboard source

    -

    Renders a Nextcloud Whiteboard (or any Excalidraw - scene) fetched over WebDAV -- credentials set up in - Settings.

    - -
    - {% if whiteboard_source %} -

    - Currently showing {{ whiteboard_source.url }} - using {{ whiteboard_source.display_name }}'s - WebDAV account. - -

    - {% else %} -

    No whiteboard configured yet.

    - {% endif %} -
    - - {% if viewer_has_webdav_creds %} -
    - -

    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>/. -

    - - - - -
    - {% else %} -

    Set up WebDAV credentials - in Settings first to point this frame at - one of your own files.

    - {% endif %} -
    -
    - -
    -
    -

    Preview

    -

    How this frame's whiteboard currently renders.

    - Whiteboard preview - -
    -
    -
    - -
    -{% endblock %} - -{% block scripts %} - - - - -{% endblock %} diff --git a/server/tests/test_calendar_preview_endpoint.py b/server/tests/test_calendar_preview_endpoint.py index d5c4eae..e342ed9 100644 --- a/server/tests/test_calendar_preview_endpoint.py +++ b/server/tests/test_calendar_preview_endpoint.py @@ -1,9 +1,10 @@ -"""GET /api/frames/{id}/preview/calendar -- the Calendar tab's live -render preview. No prior coverage existed for this endpoint; added -after a Phase 3 refactor (calendar_render.py's size-tier rewrite, see -the widget-system plan) left a stale photo_inlay=None kwarg here that -would have TypeError'd on the very next request -- nothing in the -existing suite actually called this endpoint to catch it.""" +"""GET /api/frames/{id}/widgets/{widget_id}/preview/calendar -- the +calendar dialog's live render preview. No prior coverage existed for +this endpoint; added after a Phase 3 refactor (calendar_render.py's +size-tier rewrite, see the widget-system plan) left a stale +photo_inlay=None kwarg here that would have TypeError'd on the very next +request -- nothing in the existing suite actually called this endpoint +to catch it.""" from __future__ import annotations @@ -25,36 +26,46 @@ def _configure_calendar_widget(db_session) -> Widget: return widget -def test_preview_calendar_requires_a_calendar_widget(client, db_session): +def _photo_widget_id(db_session) -> int: + return db_session.query(Widget).filter_by(frame_id=1, widget_type="photos").one().id + + +def test_preview_calendar_404s_for_a_widget_id_that_does_not_exist(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) - resp = client.get("/api/frames/1/preview/calendar") + resp = client.get("/api/frames/1/widgets/999999/preview/calendar") + assert resp.status_code == 404 + + +def test_preview_calendar_400s_when_widget_is_not_a_calendar(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + photo_widget_id = _photo_widget_id(db_session) + resp = client.get(f"/api/frames/1/widgets/{photo_widget_id}/preview/calendar") assert resp.status_code == 400 - assert "widget" in resp.json()["detail"].lower() def test_preview_calendar_requires_an_included_calendar(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) - _configure_calendar_widget(db_session) - resp = client.get("/api/frames/1/preview/calendar") + widget = _configure_calendar_widget(db_session) + resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/calendar") assert resp.status_code == 400 assert "calendar" in resp.json()["detail"].lower() def test_preview_calendar_renders_a_png(client, db_session, monkeypatch): client.post("/setup", data={"username": "alice", "password": "hunter22"}) - _configure_calendar_widget(db_session) + widget = _configure_calendar_widget(db_session) alice = db_session.get(Frame, 1).owner alice.calendar_ics_url = "http://example.invalid/alice.ics" - db_session.add(FrameCalendar(frame_id=1, user_id=alice.id, calendar_key="ics", + db_session.add(FrameCalendar(widget_id=widget.id, user_id=alice.id, calendar_key="ics", calendar_label="My calendar", included=True)) db_session.commit() monkeypatch.setattr( - "app.routers.api_frames.get_or_refresh_calendar_events_for_widget", + "app.routers.api_widgets.get_or_refresh_calendar_events_for_widget", lambda db, frame, widget: ([], ""), ) - resp = client.get("/api/frames/1/preview/calendar", headers=csrf_headers(client)) + resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/calendar", headers=csrf_headers(client)) assert resp.status_code == 200, resp.text assert resp.headers["content-type"] == "image/png" assert resp.content[:8] == b"\x89PNG\r\n\x1a\n" diff --git a/server/tests/test_migrations.py b/server/tests/test_migrations.py index a2b4f20..1a141da 100644 --- a/server/tests/test_migrations.py +++ b/server/tests/test_migrations.py @@ -17,12 +17,15 @@ from app.models import ( CalendarWidgetConfig, Frame, FrameButtonAction, + FrameCalendar, PhotoWidgetConfig, ServerSettings, Widget, WhiteboardWidgetConfig, ) +from .conftest import make_user + def test_migrations_list_is_sequential_and_unique(): versions = [v for v, _ in MIGRATIONS] @@ -170,11 +173,29 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db """Exercises _migration_16's actual CREATE TABLE statements (the real "existing production database upgrading past this migration" scenario) rather than the fresh-install create_all() shortcut, which - every other test in this file goes through instead.""" + every other test in this file goes through instead. frame_calendars + also gets rebuilt back to its pre-rekey (frame_id-keyed) shape, + matching what _migration_9/_migration_11 originally produced, so + _ensure_frame_calendars_rekeyed has a real frame_id-shaped table to + migrate.""" with db_module.engine.begin() as conn: for table in ("frame_button_actions", "whiteboard_widget_configs", "calendar_widget_configs", "photo_widget_configs", "widgets"): conn.execute(text(f"DROP TABLE {table}")) + conn.execute(text("DROP TABLE frame_calendars")) + conn.execute(text( + "CREATE TABLE frame_calendars (" + "id INTEGER PRIMARY KEY, " + "frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, " + "user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, " + "calendar_key TEXT NOT NULL, " + "calendar_label TEXT NOT NULL DEFAULT '', " + "included INTEGER NOT NULL DEFAULT 1, " + "color_index INTEGER)" + )) + conn.execute(text( + "CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)" + )) conn.execute(text("UPDATE schema_version SET version = 15")) frame = Frame( @@ -183,8 +204,20 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db queue=["legacy-asset", "next-asset"], created_at=time.time(), ) db_session.add(frame) + db_session.flush() + user = make_user(db_session, "legacy-owner") db_session.commit() - frame_id = frame.id + frame_id, user_id = frame.id, user.id + + # An orphaned frame_calendars row (this frame's mode was never + # "calendar", so it has no calendar widget for _ensure_frame_ + # calendars_rekeyed to attach it to) -- exercises that it's dropped + # cleanly rather than raising. + with db_module.engine.begin() as conn: + conn.execute(text( + "INSERT INTO frame_calendars (frame_id, user_id, calendar_key, calendar_label, included) " + "VALUES (:frame_id, :user_id, 'ics', 'Legacy Cal', 1)" + ), {"frame_id": frame_id, "user_id": user_id}) run_migrations() @@ -198,3 +231,61 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db assert config.album_id == "legacy-album" assert config.current_asset_id == "legacy-asset" assert config.queue == ["legacy-asset", "next-asset"] + + +def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(db_session): + """A frame whose mode was "calendar" (not "photos") gets a calendar + widget from _ensure_widgets_backfilled -- _ensure_frame_calendars_ + rekeyed should then attach the pre-existing frame_id-keyed + FrameCalendar row inserted below to that widget's id, not drop it. + This is the regression case for the ordering bug the two migration + tests above's setup was designed to catch: the rekey must run AFTER + the widget backfill, not as a numbered migration racing ahead of + it (see _ensure_frame_calendars_rekeyed's own docstring).""" + with db_module.engine.begin() as conn: + for table in ("frame_button_actions", "whiteboard_widget_configs", + "calendar_widget_configs", "photo_widget_configs", "widgets"): + conn.execute(text(f"DROP TABLE {table}")) + conn.execute(text("DROP TABLE frame_calendars")) + conn.execute(text( + "CREATE TABLE frame_calendars (" + "id INTEGER PRIMARY KEY, " + "frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, " + "user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, " + "calendar_key TEXT NOT NULL, " + "calendar_label TEXT NOT NULL DEFAULT '', " + "included INTEGER NOT NULL DEFAULT 1, " + "color_index INTEGER)" + )) + conn.execute(text( + "CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)" + )) + conn.execute(text("UPDATE schema_version SET version = 15")) + + frame = Frame( + name="Calendar Frame", device_token="tok-cal", manage_token="mtok-cal", + mode="calendar", created_at=time.time(), + ) + db_session.add(frame) + db_session.flush() + user = make_user(db_session, "cal-owner") + db_session.commit() + frame_id, user_id = frame.id, user.id + + with db_module.engine.begin() as conn: + conn.execute(text( + "INSERT INTO frame_calendars (frame_id, user_id, calendar_key, calendar_label, included, color_index) " + "VALUES (:frame_id, :user_id, 'ics', 'Legacy Cal', 1, 3)" + ), {"frame_id": frame_id, "user_id": user_id}) + + run_migrations() + + widget = db_session.scalars( + select(Widget).where(Widget.frame_id == frame_id, Widget.widget_type == "calendar") + ).one() + row = db_session.scalars(select(FrameCalendar).where(FrameCalendar.widget_id == widget.id)).one() + assert row.user_id == user_id + assert row.calendar_key == "ics" + assert row.calendar_label == "Legacy Cal" + assert row.included is True + assert row.color_index == 3 diff --git a/server/tests/test_permission_boundaries.py b/server/tests/test_permission_boundaries.py index d30d0ea..6b02694 100644 --- a/server/tests/test_permission_boundaries.py +++ b/server/tests/test_permission_boundaries.py @@ -2,7 +2,8 @@ permission pattern, repeated across calendar-select, tasks-source, and whiteboard-source -- exercised at the HTTP layer (not just unit-level) since the whole point is verifying the *endpoint's* authorization check, -not just a helper function's logic.""" +not just a helper function's logic. All three now live under +/api/frames/{id}/widgets/{widget_id}/... (see routers/api_widgets.py).""" from __future__ import annotations @@ -30,12 +31,18 @@ def _setup_two_linked_users(client, db_session) -> Frame: return frame +def _photo_widget_id(db_session, frame: Frame) -> int: + """Frame #1's auto-migrated widget -- used to exercise the "wrong + widget type" 400 case (e.g. posting a whiteboard-source to a photos + widget).""" + return db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id + + def _add_whiteboard_widget(db_session, frame: Frame) -> Widget: """Frame #1's auto-migrated widget is a photos widget (its mode was "photos" before the widget system existed) -- these tests need a - whiteboard widget too, which nothing creates yet until the - widget-placement UI (a later phase) ships, so it's added directly - here the same way the widget unit tests do.""" + whiteboard widget too, added directly here the same way the widget + unit tests do.""" widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5, sort_order=1, created_at=time.time()) db_session.add(widget) @@ -61,7 +68,7 @@ def test_whiteboard_source_owner_can_set_it(client, db_session): frame = _setup_two_linked_users(client, db_session) widget = _add_whiteboard_widget(db_session, frame) # alice is still logged in from /setup - resp = client.post("/api/frames/1/whiteboard-source", json={ + resp = client.post(f"/api/frames/1/widgets/{widget.id}/whiteboard-source", json={ "url": "https://cloud.example.com/dav/files/alice/board.whiteboard", }, headers=csrf_headers(client)) assert resp.status_code == 200, resp.text @@ -72,7 +79,7 @@ def test_whiteboard_source_owner_can_set_it(client, db_session): def test_whiteboard_source_set_always_targets_the_caller(client, db_session): - """bob has no way to point the frame at someone else's account -- + """bob has no way to point the widget at someone else's account -- there's no target-user field in the request at all, so a "set" call from bob always attaches to bob, even if he pastes alice's URL.""" frame = _setup_two_linked_users(client, db_session) @@ -80,7 +87,7 @@ def test_whiteboard_source_set_always_targets_the_caller(client, db_session): client.cookies.clear() login(client, "bob") - resp = client.post("/api/frames/1/whiteboard-source", json={ + resp = client.post(f"/api/frames/1/widgets/{widget.id}/whiteboard-source", json={ "url": "https://cloud.example.com/dav/files/alice/board.whiteboard", }, headers=csrf_headers(client)) assert resp.status_code == 200, resp.text @@ -93,12 +100,14 @@ def test_whiteboard_source_set_always_targets_the_caller(client, db_session): def test_whiteboard_source_anyone_linked_can_clear(client, db_session): frame = _setup_two_linked_users(client, db_session) widget = _add_whiteboard_widget(db_session, frame) - client.post("/api/frames/1/whiteboard-source", json={"url": "https://cloud.example.com/board.whiteboard"}, - headers=csrf_headers(client)) + client.post(f"/api/frames/1/widgets/{widget.id}/whiteboard-source", + json={"url": "https://cloud.example.com/board.whiteboard"}, + headers=csrf_headers(client)) client.cookies.clear() login(client, "bob") - resp = client.post("/api/frames/1/whiteboard-source", json={"url": None}, headers=csrf_headers(client)) + resp = client.post(f"/api/frames/1/widgets/{widget.id}/whiteboard-source", json={"url": None}, + headers=csrf_headers(client)) assert resp.status_code == 200, resp.text cfg = db_session.get(WhiteboardWidgetConfig, widget.id) @@ -108,35 +117,43 @@ def test_whiteboard_source_anyone_linked_can_clear(client, db_session): def test_whiteboard_source_rejects_non_http_url(client, db_session): frame = _setup_two_linked_users(client, db_session) - _add_whiteboard_widget(db_session, frame) - resp = client.post("/api/frames/1/whiteboard-source", json={"url": "javascript:alert(1)"}, - headers=csrf_headers(client)) + widget = _add_whiteboard_widget(db_session, frame) + resp = client.post(f"/api/frames/1/widgets/{widget.id}/whiteboard-source", + json={"url": "javascript:alert(1)"}, headers=csrf_headers(client)) assert resp.status_code == 400 def test_whiteboard_source_unlinked_user_cannot_touch_it(client, db_session): frame = _setup_two_linked_users(client, db_session) - _add_whiteboard_widget(db_session, frame) + widget = _add_whiteboard_widget(db_session, frame) make_user(db_session, "mallory") # exists, but never linked to frame 1 client.cookies.clear() login(client, "mallory") - resp = client.post("/api/frames/1/whiteboard-source", json={"url": "https://x.example.com/b.whiteboard"}, + resp = client.post(f"/api/frames/1/widgets/{widget.id}/whiteboard-source", + json={"url": "https://x.example.com/b.whiteboard"}, headers=csrf_headers(client, "/settings")) assert resp.status_code == 404 -def test_whiteboard_source_404s_when_frame_has_no_whiteboard_widget(client, db_session): - """Distinct from the unlinked-user 404 above -- this is a linked, - fully-permitted owner hitting the endpoint on a frame that simply - doesn't have a whiteboard widget yet (frame #1's auto-migrated - widget is a photos widget).""" +def test_whiteboard_source_404s_for_a_widget_id_that_does_not_exist(client, db_session): _setup_two_linked_users(client, db_session) - resp = client.post("/api/frames/1/whiteboard-source", json={"url": "https://x.example.com/b.whiteboard"}, - headers=csrf_headers(client)) + resp = client.post("/api/frames/1/widgets/999999/whiteboard-source", + json={"url": "https://x.example.com/b.whiteboard"}, headers=csrf_headers(client)) assert resp.status_code == 404 +def test_whiteboard_source_400s_when_widget_is_not_a_whiteboard(client, db_session): + """A linked, fully-permitted owner hitting this endpoint on a widget + that exists but is the wrong type (frame #1's auto-migrated widget + is photos, not whiteboard).""" + frame = _setup_two_linked_users(client, db_session) + photo_widget_id = _photo_widget_id(db_session, frame) + resp = client.post(f"/api/frames/1/widgets/{photo_widget_id}/whiteboard-source", + json={"url": "https://x.example.com/b.whiteboard"}, headers=csrf_headers(client)) + assert resp.status_code == 400 + + # --- tasks-source --- def test_tasks_source_set_always_targets_the_caller(client, db_session): @@ -145,8 +162,8 @@ def test_tasks_source_set_always_targets_the_caller(client, db_session): client.cookies.clear() login(client, "bob") - resp = client.post("/api/frames/1/tasks-source", json={"calendar_key": "caldav:/some/tasks/"}, - headers=csrf_headers(client)) + resp = client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source", + json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client)) assert resp.status_code == 200, resp.text bob_row = db_session.query(User).filter_by(username="bob").one() @@ -158,12 +175,13 @@ def test_tasks_source_set_always_targets_the_caller(client, db_session): def test_tasks_source_anyone_linked_can_clear(client, db_session): frame = _setup_two_linked_users(client, db_session) widget = _add_calendar_widget(db_session, frame) - client.post("/api/frames/1/tasks-source", json={"calendar_key": "caldav:/alice/tasks/"}, - headers=csrf_headers(client)) + client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source", + json={"calendar_key": "caldav:/alice/tasks/"}, headers=csrf_headers(client)) client.cookies.clear() login(client, "bob") - resp = client.post("/api/frames/1/tasks-source", json={"calendar_key": None}, headers=csrf_headers(client)) + resp = client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source", json={"calendar_key": None}, + headers=csrf_headers(client)) assert resp.status_code == 200, resp.text cfg = db_session.get(CalendarWidgetConfig, widget.id) @@ -171,67 +189,89 @@ def test_tasks_source_anyone_linked_can_clear(client, db_session): assert cfg.tasks_calendar_key is None -def test_tasks_source_404s_when_frame_has_no_calendar_widget(client, db_session): +def test_tasks_source_404s_for_a_widget_id_that_does_not_exist(client, db_session): _setup_two_linked_users(client, db_session) - resp = client.post("/api/frames/1/tasks-source", json={"calendar_key": "caldav:/some/tasks/"}, - headers=csrf_headers(client)) + resp = client.post("/api/frames/1/widgets/999999/tasks-source", + json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client)) assert resp.status_code == 404 +def test_tasks_source_400s_when_widget_is_not_a_calendar(client, db_session): + frame = _setup_two_linked_users(client, db_session) + photo_widget_id = _photo_widget_id(db_session, frame) + resp = client.post(f"/api/frames/1/widgets/{photo_widget_id}/tasks-source", + json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client)) + assert resp.status_code == 400 + + # --- calendar-select --- def test_calendar_select_bob_cannot_add_alices_calendar(client, db_session): - _setup_two_linked_users(client, db_session) + frame = _setup_two_linked_users(client, db_session) + widget = _add_calendar_widget(db_session, frame) alice_id = db_session.query(User).filter_by(username="alice").one().id client.cookies.clear() login(client, "bob") - resp = client.post("/api/frames/1/calendar-select", json={ + resp = client.post(f"/api/frames/1/widgets/{widget.id}/calendar-select", json={ "user_id": alice_id, "calendar_key": "ics", "included": True, }, headers=csrf_headers(client)) assert resp.status_code == 403 def test_calendar_select_owner_can_add_their_own(client, db_session): - _setup_two_linked_users(client, db_session) + frame = _setup_two_linked_users(client, db_session) + widget = _add_calendar_widget(db_session, frame) alice_id = db_session.query(User).filter_by(username="alice").one().id - resp = client.post("/api/frames/1/calendar-select", json={ + resp = client.post(f"/api/frames/1/widgets/{widget.id}/calendar-select", json={ "user_id": alice_id, "calendar_key": "ics", "included": True, }, headers=csrf_headers(client)) assert resp.status_code == 200, resp.text - row = db_session.query(FrameCalendar).filter_by(frame_id=1, user_id=alice_id, calendar_key="ics").one() + row = db_session.query(FrameCalendar).filter_by(widget_id=widget.id, user_id=alice_id, calendar_key="ics").one() assert row.included is True def test_calendar_select_bob_can_mute_alices_calendar(client, db_session): """Muting is a display-preference veto anyone linked gets, unlike adding -- the one-sided half of this endpoint's permission split.""" - _setup_two_linked_users(client, db_session) + frame = _setup_two_linked_users(client, db_session) + widget = _add_calendar_widget(db_session, frame) alice_id = db_session.query(User).filter_by(username="alice").one().id - client.post("/api/frames/1/calendar-select", json={ + client.post(f"/api/frames/1/widgets/{widget.id}/calendar-select", json={ "user_id": alice_id, "calendar_key": "ics", "included": True, }, headers=csrf_headers(client)) client.cookies.clear() login(client, "bob") - resp = client.post("/api/frames/1/calendar-select", json={ + resp = client.post(f"/api/frames/1/widgets/{widget.id}/calendar-select", json={ "user_id": alice_id, "calendar_key": "ics", "included": False, }, headers=csrf_headers(client)) assert resp.status_code == 200, resp.text - row = db_session.query(FrameCalendar).filter_by(frame_id=1, user_id=alice_id, calendar_key="ics").one() + row = db_session.query(FrameCalendar).filter_by(widget_id=widget.id, user_id=alice_id, calendar_key="ics").one() assert row.included is False def test_calendar_select_cannot_mute_a_calendar_that_was_never_added(client, db_session): - _setup_two_linked_users(client, db_session) + frame = _setup_two_linked_users(client, db_session) + widget = _add_calendar_widget(db_session, frame) alice_id = db_session.query(User).filter_by(username="alice").one().id client.cookies.clear() login(client, "bob") - resp = client.post("/api/frames/1/calendar-select", json={ + resp = client.post(f"/api/frames/1/widgets/{widget.id}/calendar-select", json={ "user_id": alice_id, "calendar_key": "ics", "included": False, }, headers=csrf_headers(client)) assert resp.status_code == 404 + + +def test_calendar_select_400s_when_widget_is_not_a_calendar(client, db_session): + frame = _setup_two_linked_users(client, db_session) + photo_widget_id = _photo_widget_id(db_session, frame) + alice_id = db_session.query(User).filter_by(username="alice").one().id + resp = client.post(f"/api/frames/1/widgets/{photo_widget_id}/calendar-select", json={ + "user_id": alice_id, "calendar_key": "ics", "included": True, + }, headers=csrf_headers(client)) + assert resp.status_code == 400 diff --git a/server/tests/test_whiteboard_refresh_and_browse.py b/server/tests/test_whiteboard_refresh_and_browse.py index 2b0a789..07549b4 100644 --- a/server/tests/test_whiteboard_refresh_and_browse.py +++ b/server/tests/test_whiteboard_refresh_and_browse.py @@ -1,9 +1,9 @@ """get_or_refresh_whiteboard_for_widget's fetch throttle (and force=True -bypassing it) plus the whiteboard-browse HTTP endpoint. -whiteboard.fetch_and_render is monkeypatched -- it talks to a real -WebDAV server and the Node render sidecar (see render-service/), neither -of which this suite needs a real copy of to verify the *throttle*/ -*permission* logic around it.""" +bypassing it) plus the whiteboard-browse HTTP endpoint (both now under +/api/frames/{id}/widgets/{widget_id}/...). whiteboard.fetch_and_render is +monkeypatched -- it talks to a real WebDAV server and the Node render +sidecar (see render-service/), neither of which this suite needs a real +copy of to verify the *throttle*/*permission* logic around it.""" from __future__ import annotations @@ -12,7 +12,7 @@ import time from app import whiteboard from app.models import Frame, User, Widget, WhiteboardWidgetConfig -from .conftest import csrf_headers, link_user, login, make_user +from .conftest import link_user, login, make_user def _configure_whiteboard(db_session, frame: Frame, user: User) -> Widget: @@ -28,6 +28,19 @@ def _configure_whiteboard(db_session, frame: Frame, user: User) -> Widget: return widget +def _add_bare_whiteboard_widget(db_session, frame: Frame) -> Widget: + """A whiteboard widget with no source configured yet -- for tests + that need the widget to exist (so the URL resolves) but want to + exercise the "not configured" branches themselves.""" + widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5, + sort_order=1, created_at=time.time()) + db_session.add(widget) + db_session.flush() + db_session.add(WhiteboardWidgetConfig(widget_id=widget.id)) + db_session.commit() + return widget + + def test_unforced_call_within_throttle_uses_cache(client, db_session, monkeypatch): from app.routers.common import get_or_refresh_whiteboard_for_widget @@ -101,11 +114,11 @@ def test_preview_endpoint_force_query_param_bypasses_throttle(client, db_session monkeypatch.setattr(whiteboard, "fetch_and_render", lambda url, u, p: calls.append(1) or tiny_png) - resp = client.get("/api/frames/1/preview/whiteboard") + resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/whiteboard") assert resp.status_code == 200 assert calls == [] # fresh cache, no force -- no refetch - resp = client.get("/api/frames/1/preview/whiteboard?force=1") + resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/whiteboard?force=1") assert resp.status_code == 200 assert len(calls) == 1 # force=1 -- refetched despite fresh cache @@ -114,7 +127,9 @@ def test_preview_endpoint_force_query_param_bypasses_throttle(client, db_session def test_browse_requires_webdav_creds(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) - resp = client.get("/api/frames/1/whiteboard-browse") + frame = db_session.get(Frame, 1) + widget = _add_bare_whiteboard_widget(db_session, frame) + resp = client.get(f"/api/frames/1/widgets/{widget.id}/whiteboard-browse") assert resp.status_code == 400 assert "credentials" in resp.json()["detail"].lower() @@ -125,8 +140,10 @@ def test_browse_requires_a_base_url_when_none_supplied(client, db_session): alice.webdav_username = "alice" alice.webdav_password = "secret" db_session.commit() + frame = db_session.get(Frame, 1) + widget = _add_bare_whiteboard_widget(db_session, frame) - resp = client.get("/api/frames/1/whiteboard-browse") + resp = client.get(f"/api/frames/1/widgets/{widget.id}/whiteboard-browse") assert resp.status_code == 400 assert "browse root" in resp.json()["detail"].lower() @@ -141,6 +158,7 @@ def test_browse_uses_the_caller_own_creds_not_the_frames_owner(client, db_sessio bob = make_user(db_session, "bob") frame = db_session.get(Frame, 1) link_user(db_session, bob, frame) + widget = _add_bare_whiteboard_widget(db_session, frame) bob.webdav_username = "bob" bob.webdav_password = "bobsecret" bob.webdav_base_url = "https://cloud.example.com/dav/files/bob/" @@ -157,7 +175,7 @@ def test_browse_uses_the_caller_own_creds_not_the_frames_owner(client, db_sessio client.cookies.clear() login(client, "bob") - resp = client.get("/api/frames/1/whiteboard-browse") + resp = client.get(f"/api/frames/1/widgets/{widget.id}/whiteboard-browse") assert resp.status_code == 200, resp.text assert seen_creds == [("https://cloud.example.com/dav/files/bob/", "bob", "bobsecret")] assert resp.json()["entries"][0]["name"] == "Board.whiteboard" diff --git a/server/tests/test_widget_config_and_queue_endpoints.py b/server/tests/test_widget_config_and_queue_endpoints.py new file mode 100644 index 0000000..baee6b2 --- /dev/null +++ b/server/tests/test_widget_config_and_queue_endpoints.py @@ -0,0 +1,160 @@ +"""api_widgets.py's config-save (form-urlencoded, dispatched by +widget_type) and the photo-queue endpoints it shares the file with -- +the moved-and-consolidated counterparts of the old frame-level +api_config_save/api_queue/etc. No prior HTTP-level coverage existed for +either the old or new shape of these endpoints; added after manual +browser testing caught api_widget_config_save expecting a JSON body +while the (unmodified, copied-over) dialog JS posts form-urlencoded data +-- a real bug an HTTP-level test would have caught immediately.""" + +from __future__ import annotations + +from app.models import CalendarWidgetConfig, Frame, PhotoWidgetConfig, Widget + +from .conftest import csrf_headers + +_ASSETS = [{"id": "asset-1"}, {"id": "asset-2"}, {"id": "asset-3"}] + + +def _photo_widget(db_session) -> Widget: + return db_session.query(Widget).filter_by(frame_id=1, widget_type="photos").one() + + +def _add_calendar_widget(db_session) -> Widget: + import time + + widget = Widget(frame_id=1, widget_type="calendar", x=0, y=0, w=3, h=2, + sort_order=1, created_at=time.time()) + db_session.add(widget) + db_session.flush() + db_session.add(CalendarWidgetConfig(widget_id=widget.id)) + db_session.commit() + return widget + + +def _mock_immich(monkeypatch): + monkeypatch.setattr("app.routers.api_widgets.immich_client_for", lambda frame: object()) + monkeypatch.setattr("app.routers.api_widgets.list_assets", lambda client, album_id: _ASSETS) + + +# --- api_widget_config_save --- + +def test_config_save_updates_a_photos_widget(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget = _photo_widget(db_session) + + resp = client.post( + f"/api/frames/1/widgets/{widget.id}/config", + data={"album_id": "album-42", "order": "shuffle", "display_mode": "stretch_fill", + "queue_target_len": "15"}, + headers=csrf_headers(client), + ) + assert resp.status_code == 200, resp.text + + cfg = db_session.get(PhotoWidgetConfig, widget.id) + assert cfg.album_id == "album-42" + assert cfg.order == "shuffle" + assert cfg.display_mode == "stretch_fill" + assert cfg.queue_target_len == 15 + + +def test_config_save_updates_a_calendar_widget(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget = _add_calendar_widget(db_session) + + resp = client.post( + f"/api/frames/1/widgets/{widget.id}/config", + data={"calendar_view": "week", "calendar_week_start": "1", "calendar_week_days": "5", + "calendar_week_layout": "vertical", "calendar_weather_enabled": "true", + "calendar_weather_units": "celsius", "calendar_tasks_enabled": "true"}, + headers=csrf_headers(client), + ) + assert resp.status_code == 200, resp.text + + cfg = db_session.get(CalendarWidgetConfig, widget.id) + assert cfg.view == "week" + assert cfg.week_start == 1 + assert cfg.week_days == 5 + assert cfg.week_layout == "vertical" + assert cfg.weather_enabled is True + assert cfg.weather_units == "celsius" + assert cfg.tasks_enabled is True + + +def test_config_save_only_partially_updates_provided_fields(client, db_session): + """Fields not present in the POST are left untouched -- the whole + point of the partial-update convention (each dialog's own form only + ever posts its own fields).""" + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget = _photo_widget(db_session) + cfg = db_session.get(PhotoWidgetConfig, widget.id) + cfg.order = "shuffle" + db_session.commit() + + resp = client.post( + f"/api/frames/1/widgets/{widget.id}/config", + data={"queue_target_len": "10"}, + headers=csrf_headers(client), + ) + assert resp.status_code == 200, resp.text + + cfg = db_session.get(PhotoWidgetConfig, widget.id) + assert cfg.queue_target_len == 10 + assert cfg.order == "shuffle" # untouched + + +def test_config_save_calendar_fields_are_a_no_op_on_a_photos_widget(client, db_session): + """Posting calendar-shaped fields at a photos widget's config + endpoint doesn't error -- it just doesn't apply, since the dispatch + is purely by widget.widget_type.""" + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget = _photo_widget(db_session) + + resp = client.post( + f"/api/frames/1/widgets/{widget.id}/config", + data={"calendar_view": "week"}, + headers=csrf_headers(client), + ) + assert resp.status_code == 200, resp.text + + +def test_config_save_404s_for_a_widget_id_that_does_not_exist(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + resp = client.post("/api/frames/1/widgets/999999/config", data={"order": "shuffle"}, + headers=csrf_headers(client)) + assert resp.status_code == 404 + + +# --- photo queue (moved from the old frame-level /api/frames/{id}/queue) --- + +def test_queue_requires_a_configured_album(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget = _photo_widget(db_session) + resp = client.get(f"/api/frames/1/widgets/{widget.id}/queue") + assert resp.status_code == 400 + + +def test_queue_returns_current_and_upcoming(client, db_session, monkeypatch): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + frame = db_session.get(Frame, 1) + frame.immich_url = "http://immich.example.com" + frame.immich_api_key = "key" + widget = _photo_widget(db_session) + cfg = db_session.get(PhotoWidgetConfig, widget.id) + cfg.album_id = "album-1" + db_session.commit() + _mock_immich(monkeypatch) + + resp = client.get(f"/api/frames/1/widgets/{widget.id}/queue") + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["current"]["id"] == "asset-1" + assert [u["id"] for u in data["upcoming"]] == ["asset-2", "asset-3"] + assert data["control"]["you"] is True + + +def test_queue_400s_when_widget_is_not_photos(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget = _add_calendar_widget(db_session) + resp = client.get(f"/api/frames/1/widgets/{widget.id}/queue") + assert resp.status_code == 400